Files
Werewolf/server/modules/singletons/GameManager.js
Copilot 7b6da37ef5 Refactor timer system from child processes to main thread (#216)
* Initial plan

* Refactor timer system to run on main thread instead of child processes

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Remove obsolete GameProcess.js and simplify TimerManager to stub

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Address code review feedback: add comments and reduce duplication

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Remove comments, delete TimerManager, and simplify SOURCE_TIMER_EVENT

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Fix cross-instance timer communication and expired timer handling

- GET_TIME_REMAINING now publishes response through Redis for cross-instance communication
- SOURCE_TIMER_EVENT handles GET_TIME_REMAINING even when timer is not running locally
- Returns 0 time remaining when timer has expired or timerParams is null
- Updated test to expect Redis publish instead of direct method call

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Simplify GET_TIME_REMAINING flow and add ended flag

- Add ended: true flag to timerParams when timer completes
- Simplify TIMER_EVENT to check ended flag first, then local timer, then publish SOURCE_TIMER_EVENT
- SOURCE_TIMER_EVENT only publishes GET_TIME_REMAINING if it has the timer
- Remove GET_TIME_REMAINING from handleTimerCommand helper
- Direct socket emission for local timers instead of Redis publish
- Updated test to verify socket emission

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Refactor GET_TIME_REMAINING into handleTimerCommand switch case

- Moved GET_TIME_REMAINING logic from TIMER_EVENT handler into handleTimerCommand function
- Simplified TIMER_EVENT to just call handleTimerCommand for all timer operations
- Maintains same flow: check ended flag, check local timer, publish SOURCE_TIMER_EVENT if needed
- All tests passing

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Remove unused getTimeRemaining function from GameManager

- Function is no longer called after refactoring GET_TIME_REMAINING into handleTimerCommand
- All timer time remaining logic now handled in Events.js handleTimerCommand switch case
- Tests still passing

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Reset timerParams.ended flag when restarting game

- Added reset of ended flag to false in restartGame function
- Ensures restarted games don't have timer marked as already ended
- Placed right after timer cleanup for logical grouping

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* remuse unused constant

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>
Co-authored-by: AlecM33 <leohfx@gmail.com>
2026-01-24 11:58:51 -05:00

445 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const {
STATUS,
PRIMITIVES,
ERROR_MESSAGES,
USER_TYPES,
EVENT_IDS,
REDIS_CHANNELS
} = require('../../config/globals');
const Game = require('../../model/Game');
const Person = require('../../model/Person');
const GameStateCurator = require('../GameStateCurator');
const UsernameGenerator = require('../UsernameGenerator');
const GameCreationRequest = require('../../model/GameCreationRequest');
const ServerTimer = require('../ServerTimer');
class GameManager {
constructor (logger, environment, instanceId) {
if (GameManager.instance) {
throw new Error('The server tried to instantiate more than one GameManager');
}
logger.info('CREATING SINGLETON GAME MANAGER');
this.logger = logger;
this.environment = environment;
this.eventManager = null;
this.namespace = null;
this.instanceId = instanceId;
this.timers = {};
GameManager.instance = this;
}
getActiveGame = async (accessCode) => {
const r = await this.eventManager.publisher.get(accessCode);
if (r === null && this.timers[accessCode]) {
this.timers[accessCode].stopTimer();
delete this.timers[accessCode];
}
let activeGame;
if (r !== null) {
try {
activeGame = JSON.parse(r);
} catch (e) {
this.logger.error('ERROR PARSING ACTIVE GAME: ' + e);
}
}
return r === null ? r : activeGame;
}
setGameSocketNamespace = (namespace) => {
this.namespace = namespace;
};
refreshGame = async (game) => {
this.logger.debug('PUSHING REFRESH OF ' + game.accessCode);
await this.eventManager.publisher.set(game.accessCode, JSON.stringify(game), {
KEEPTTL: true,
XX: true // only set the key if it already exists
});
}
createGame = async (gameParams) => {
this.logger.debug('Received request to create new game.');
return GameCreationRequest.validate(gameParams).then(async () => {
const req = new GameCreationRequest(
gameParams.deck,
gameParams.hasTimer,
gameParams.timerParams,
gameParams.moderatorName,
gameParams.hasDedicatedModerator,
gameParams.isTestGame
);
const newAccessCode = await this.generateAccessCode(PRIMITIVES.ACCESS_CODE_CHAR_POOL);
if (newAccessCode === null) {
return Promise.reject(ERROR_MESSAGES.NO_UNIQUE_ACCESS_CODE);
}
const moderator = initializeModerator(req.moderatorName, req.hasDedicatedModerator);
moderator.assigned = true;
if (req.timerParams !== null) {
req.timerParams.paused = true;
req.timerParams.timeRemaining = convertFromHoursToMilliseconds(req.timerParams.hours) +
convertFromMinutesToMilliseconds(req.timerParams.minutes);
}
const newGame = new Game(
newAccessCode,
STATUS.LOBBY,
null,
req.deck,
req.hasTimer,
moderator.id,
req.hasDedicatedModerator,
moderator.id,
new Date().toJSON(),
req.timerParams
);
newGame.people = initializePeopleForGame(req.deck, moderator, this.shuffle, req.isTestGame, newGame.gameSize);
newGame.isStartable = newGame.people.filter(person => person.userType === USER_TYPES.PLAYER
|| person.userType === USER_TYPES.TEMPORARY_MODERATOR
|| person.userType === USER_TYPES.BOT).length === newGame.gameSize;
await this.eventManager.publisher.set(newAccessCode, JSON.stringify(newGame), {
EX: PRIMITIVES.STALE_GAME_SECONDS
});
return Promise.resolve({ accessCode: newAccessCode, cookie: moderator.cookie, environment: this.environment });
}).catch((message) => {
console.error(message);
this.logger.error('Received invalid request to create new game.');
return Promise.reject(message);
});
};
runTimer = async (game) => {
this.logger.debug('running timer for game ' + game.accessCode);
const timer = new ServerTimer(
game.timerParams.hours,
game.timerParams.minutes,
PRIMITIVES.CLOCK_TICK_INTERVAL_MILLIS,
this.logger
);
this.timers[game.accessCode] = timer;
timer.runTimer(true).then(async () => {
this.logger.debug('Timer finished for ' + game.accessCode);
game = await this.getActiveGame(game.accessCode);
if (game) {
await this.eventManager.handleEventById(
EVENT_IDS.END_TIMER,
null,
game,
null,
game.accessCode,
{},
null,
false
);
await this.refreshGame(game);
await this.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
this.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.END_TIMER,
this.instanceId,
'{}'
)
);
}
delete this.timers[game.accessCode];
});
game.startTime = new Date().toJSON();
};
pauseTimer = async (game) => {
const timer = this.timers[game.accessCode];
if (timer) {
this.logger.debug('Timer found for game ' + game.accessCode);
timer.stopTimer();
return timer.currentTimeInMillis;
}
return null;
};
resumeTimer = async (game) => {
const timer = this.timers[game.accessCode];
if (timer) {
this.logger.debug('Timer found for game ' + game.accessCode);
timer.resumeTimer();
return timer.currentTimeInMillis;
}
return null;
};
checkAvailability = async (code) => {
const game = await this.getActiveGame(code.toUpperCase().trim());
if (game) {
return Promise.resolve({ accessCode: code, playerCount: game.gameSize, timerParams: game.timerParams });
} else {
return Promise.resolve(404);
}
};
generateAccessCode = async (charPool) => {
const charCount = charPool.length;
let codeDigits, accessCode;
let attempts = 0;
while (!accessCode || ((await this.eventManager.publisher.keys('*')).includes(accessCode)
&& attempts < PRIMITIVES.ACCESS_CODE_GENERATION_ATTEMPTS)) {
codeDigits = [];
let iterations = PRIMITIVES.ACCESS_CODE_LENGTH;
while (iterations > 0) {
iterations --;
codeDigits.push(charPool[getRandomInt(charCount)]);
}
accessCode = codeDigits.join('');
attempts ++;
}
return (await this.eventManager.publisher.keys('*')).includes(accessCode)
? null
: accessCode;
};
joinGame = async (game, name, cookie, joinAsSpectator) => {
const matchingPerson = this.findPersonByField(game, 'cookie', cookie);
if (matchingPerson) {
return Promise.resolve(matchingPerson.cookie);
}
if (this.isNameTaken(game, name)) {
return Promise.reject({ status: 400, reason: 'This name is taken.' });
}
if (joinAsSpectator
&& game.people.filter(person => person.userType === USER_TYPES.SPECTATOR).length === PRIMITIVES.MAX_SPECTATORS
) {
return Promise.reject({ status: 400, reason: 'There are too many people already spectating.' });
} else if (joinAsSpectator || this.isGameStartable(game) || game.status === STATUS.IN_PROGRESS) {
return await addSpectator(game, name, this.logger, this.namespace, this.eventManager, this.instanceId, this.refreshGame);
}
let moderator, newPlayer;
const isModeratorJoining = this.findPersonByField(game, 'id', game.currentModeratorId).assigned === false;
if (isModeratorJoining) {
moderator = this.findPersonByField(game, 'id', game.currentModeratorId);
this.logger.trace('Moderator joining. Assigning: ' + name);
moderator.assigned = true;
moderator.name = name;
} else {
newPlayer = new Person(
createRandomId(),
createRandomId(),
name,
USER_TYPES.PLAYER,
null,
null,
null,
game.isTestGame
);
newPlayer.assigned = true;
game.people.push(newPlayer);
}
game.isStartable = this.isGameStartable(game);
await this.refreshGame(game);
this.namespace.in(game.accessCode).emit(
EVENT_IDS.PLAYER_JOINED,
GameStateCurator.mapPerson(moderator || newPlayer),
game.isStartable
);
await this.eventManager.publisher?.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
this.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.PLAYER_JOINED,
this.instanceId,
JSON.stringify(moderator || newPlayer)
)
);
return Promise.resolve(moderator?.cookie || newPlayer?.cookie);
}
prepareDeck (deck) {
const cards = [];
for (const card of deck) {
for (let i = 0; i < card.quantity; i ++) {
cards.push(card);
}
}
this.shuffle(cards);
return cards;
}
restartGame = async (game, namespace) => {
const timer = this.timers[game.accessCode];
if (timer) {
this.logger.info('Stopping timer for: ' + game.accessCode);
timer.stopTimer();
delete this.timers[game.accessCode];
}
if (game.timerParams) {
game.timerParams.ended = false;
}
for (let i = 0; i < game.people.length; i ++) {
if (game.people[i].userType === USER_TYPES.KILLED_PLAYER) {
game.people[i].userType = USER_TYPES.PLAYER;
game.people[i].out = false;
}
if (game.people[i].userType === USER_TYPES.KILLED_BOT) {
game.people[i].userType = USER_TYPES.BOT;
game.people[i].out = false;
}
if (game.people[i].gameRole && game.people[i].id === game.currentModeratorId && game.people[i].userType === USER_TYPES.MODERATOR) {
game.people[i].userType = USER_TYPES.TEMPORARY_MODERATOR;
game.people[i].out = false;
}
game.people[i].revealed = false;
game.people[i].killed = false;
game.people[i].gameRole = null;
game.people[i].gameRoleDescription = null;
game.people[i].alignment = null;
game.people[i].customRole = null;
}
game.status = STATUS.LOBBY;
await this.refreshGame(game);
await this.eventManager.publisher?.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
this.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.RESTART_GAME,
this.instanceId,
'{}'
)
);
namespace.in(game.accessCode).emit(EVENT_IDS.RESTART_GAME);
};
/*
-- To shuffle an array a of n elements (indices 0..n-1):
for i from n1 downto 1 do
j ← random integer such that 0 ≤ j ≤ i
exchange a[j] and a[i]
*/
shuffle = (array) => {
for (let i = array.length - 1; i > 0; i --) {
const j = Math.floor(Math.random() * (i + 1));
const temp = array[j];
array[j] = array[i];
array[i] = temp;
}
return array;
};
deal = (game) => {
const cards = this.prepareDeck(game.deck);
let i = 0;
for (const person of game.people.filter(person => person.userType === USER_TYPES.PLAYER
|| person.userType === USER_TYPES.TEMPORARY_MODERATOR
|| person.userType === USER_TYPES.BOT)
) {
person.gameRole = cards[i].role;
person.customRole = cards[i].custom;
person.gameRoleDescription = cards[i].description;
person.alignment = cards[i].team;
i ++;
}
}
isGameStartable = (game) => {
return game.people.filter(person => person.userType === USER_TYPES.PLAYER
|| person.userType === USER_TYPES.TEMPORARY_MODERATOR
|| person.userType === USER_TYPES.BOT).length === game.gameSize;
}
findPersonByField = (game, fieldName, value) => {
return game.people.find(person => person[fieldName] === value);
}
isNameTaken (game, name) {
const processedName = name.toLowerCase().trim();
return game.people.find((person) => person.name.toLowerCase().trim() === processedName);
}
}
function getRandomInt (max) {
return Math.floor(Math.random() * Math.floor(max));
}
function initializeModerator (name, hasDedicatedModerator) {
const userType = hasDedicatedModerator
? USER_TYPES.MODERATOR
: USER_TYPES.TEMPORARY_MODERATOR;
return new Person(createRandomId(), createRandomId(), name, userType);
}
function initializePeopleForGame (uniqueRoles, moderator, shuffle, isTestGame, gameSize) {
const people = [];
if (isTestGame) {
let j = 0;
const number = moderator.userType === USER_TYPES.TEMPORARY_MODERATOR
? gameSize - 1
: gameSize;
while (j < number) {
const person = new Person(
createRandomId(),
createRandomId(),
UsernameGenerator.generate(),
USER_TYPES.BOT,
null,
null,
null,
isTestGame
);
people.push(person);
j ++;
}
}
people.push(moderator);
return people;
}
function createRandomId () {
let id = '';
for (let i = 0; i < PRIMITIVES.INSTANCE_ID_LENGTH; i ++) {
id += PRIMITIVES.INSTANCE_ID_CHAR_POOL[Math.floor(Math.random() * PRIMITIVES.INSTANCE_ID_CHAR_POOL.length)];
}
return id;
}
async function addSpectator (game, name, logger, namespace, eventManager, instanceId, refreshGame) {
const spectator = new Person(
createRandomId(),
createRandomId(),
name,
USER_TYPES.SPECTATOR
);
spectator.assigned = true;
logger.trace('new spectator: ' + spectator.name);
game.people.push(spectator);
await refreshGame(game);
namespace.in(game.accessCode).emit(
EVENT_IDS.ADD_SPECTATOR,
GameStateCurator.mapPerson(spectator)
);
await eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.ADD_SPECTATOR,
instanceId,
JSON.stringify(GameStateCurator.mapPerson(spectator))
)
);
return Promise.resolve(spectator.cookie);
}
function convertFromMinutesToMilliseconds (minutes) {
return minutes * 60 * 1000;
}
function convertFromHoursToMilliseconds (hours) {
return hours * 60 * 60 * 1000;
}
module.exports = GameManager;