Files
Werewolf/server/modules/Events.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

502 lines
21 KiB
JavaScript

const GameStateCurator = require('./GameStateCurator');
const GameCreationRequest = require('../model/GameCreationRequest');
const { EVENT_IDS, STATUS, USER_TYPES, GAME_PROCESS_COMMANDS, REDIS_CHANNELS, PRIMITIVES } = require('../config/globals');
async function handleTimerCommand (timerEventSubtype, game, socketId, vars) {
switch (timerEventSubtype) {
case GAME_PROCESS_COMMANDS.PAUSE_TIMER:
const pauseTimeRemaining = await vars.gameManager.pauseTimer(game);
if (pauseTimeRemaining !== null) {
await vars.eventManager.handleEventById(
EVENT_IDS.PAUSE_TIMER,
null,
game,
null,
game.accessCode,
{ timeRemaining: pauseTimeRemaining },
null,
false
);
await vars.gameManager.refreshGame(game);
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.PAUSE_TIMER,
vars.instanceId,
JSON.stringify({ timeRemaining: pauseTimeRemaining })
)
);
}
break;
case GAME_PROCESS_COMMANDS.RESUME_TIMER:
const resumeTimeRemaining = await vars.gameManager.resumeTimer(game);
if (resumeTimeRemaining !== null) {
await vars.eventManager.handleEventById(
EVENT_IDS.RESUME_TIMER,
null,
game,
null,
game.accessCode,
{ timeRemaining: resumeTimeRemaining },
null,
false
);
await vars.gameManager.refreshGame(game);
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.RESUME_TIMER,
vars.instanceId,
JSON.stringify({ timeRemaining: resumeTimeRemaining })
)
);
}
break;
case GAME_PROCESS_COMMANDS.GET_TIME_REMAINING:
if (game.timerParams && game.timerParams.ended) {
const socket = vars.gameManager.namespace.sockets.get(socketId);
if (socket) {
vars.gameManager.namespace.to(socket.id).emit(
GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
0,
false
);
}
} else {
const timer = vars.gameManager.timers[game.accessCode];
if (timer) {
const socket = vars.gameManager.namespace.sockets.get(socketId);
if (socket) {
vars.gameManager.namespace.to(socket.id).emit(
GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
timer.currentTimeInMillis,
game.timerParams ? game.timerParams.paused : false
);
}
} else {
await vars.eventManager.publisher?.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.SOURCE_TIMER_EVENT,
vars.instanceId,
JSON.stringify({ socketId: socketId, timerEventSubtype: timerEventSubtype })
)
);
}
}
break;
}
}
const Events = [
{
id: EVENT_IDS.PLAYER_JOINED,
stateChange: async (game, socketArgs, vars) => {
game.people.push(socketArgs);
game.isStartable = vars.gameManager.isGameStartable(game);
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.PLAYER_JOINED,
GameStateCurator.mapPerson(socketArgs),
game.isStartable
);
}
},
{
id: EVENT_IDS.KICK_PERSON,
stateChange: async (game, socketArgs, vars) => {
const toBeClearedIndex = game.people.findIndex(
(person) => person.id === socketArgs.personId && person.assigned === true
);
if (toBeClearedIndex >= 0) {
game.people.splice(toBeClearedIndex, 1);
game.isStartable = vars.gameManager.isGameStartable(game);
}
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.KICK_PERSON,
socketArgs.personId,
game.isStartable
);
}
},
{
id: EVENT_IDS.LEAVE_ROOM,
stateChange: async (game, socketArgs, vars) => {
const toBeClearedIndex = game.people.findIndex(
(person) => person.id === socketArgs.personId && person.assigned === true
);
if (toBeClearedIndex >= 0) {
game.people.splice(toBeClearedIndex, 1);
game.isStartable = vars.gameManager.isGameStartable(game);
}
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.LEAVE_ROOM,
socketArgs.personId,
game.isStartable
);
}
},
{
id: EVENT_IDS.CHANGE_NAME,
stateChange: async (game, socketArgs, vars) => {
const toChangeIndex = game.people.findIndex(
(person) => person.id === socketArgs.personId
);
if (toChangeIndex >= 0) {
if (vars.gameManager.isNameTaken(game, socketArgs.newName)) {
vars.hasNameChanged = false;
if (game.people[toChangeIndex].name.toLowerCase().trim() === socketArgs.newName.toLowerCase().trim()) {
return;
}
vars.ackFn({ errorFlag: 1, message: 'This name is taken.' });
} else if (socketArgs.newName.length > PRIMITIVES.MAX_PERSON_NAME_LENGTH) {
vars.ackFn({ errorFlag: 1, message: 'Your new name is too long - the max is ' + PRIMITIVES.MAX_PERSON_NAME_LENGTH + ' characters.' });
vars.hasNameChanged = false;
} else if (socketArgs.newName.length === 0) {
vars.ackFn({ errorFlag: 1, message: 'Your new name cannot be empty.' });
vars.hasNameChanged = false;
} else {
game.people[toChangeIndex].name = socketArgs.newName;
vars.ackFn({ errorFlag: 0, message: 'Name updated!' });
vars.hasNameChanged = true;
}
}
},
communicate: async (game, socketArgs, vars) => {
if (vars.hasNameChanged) {
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.CHANGE_NAME,
socketArgs.personId,
socketArgs.newName
);
}
}
},
{
id: EVENT_IDS.UPDATE_GAME_ROLES,
stateChange: async (game, socketArgs, vars) => {
if (GameCreationRequest.deckIsValid(socketArgs.deck)) {
game.deck = socketArgs.deck;
game.gameSize = socketArgs.deck.reduce(
(accumulator, currentValue) => accumulator + currentValue.quantity,
0
);
game.isStartable = vars.gameManager.isGameStartable(game);
}
},
communicate: async (game, socketArgs, vars) => {
if (vars.ackFn) {
vars.ackFn();
}
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.UPDATE_GAME_ROLES,
game.deck,
game.gameSize,
game.isStartable
);
}
},
{
id: EVENT_IDS.ADD_SPECTATOR,
stateChange: async (game, socketArgs, vars) => {
game.people.push(socketArgs);
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.ADD_SPECTATOR,
GameStateCurator.mapPerson(socketArgs)
);
}
},
{
id: EVENT_IDS.FETCH_GAME_STATE,
stateChange: async (game, socketArgs, vars) => {
const matchingPerson = vars.gameManager.findPersonByField(game, 'cookie', socketArgs.personId);
if (matchingPerson && matchingPerson.socketId !== vars.requestingSocketId) {
matchingPerson.socketId = vars.requestingSocketId;
vars.gameManager.namespace.sockets.get(vars.requestingSocketId)?.join(game.accessCode);
}
},
communicate: async (game, socketArgs, vars) => {
if (!vars.ackFn) return;
const matchingPerson = vars.gameManager.findPersonByField(game, 'cookie', socketArgs.personId);
if (matchingPerson && vars.gameManager.namespace.sockets.get(matchingPerson.socketId)) {
vars.ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson));
} else {
vars.ackFn(null);
}
}
},
{
id: EVENT_IDS.SYNC_GAME_STATE,
stateChange: async (game, socketArgs, vars) => {},
communicate: async (game, socketArgs, vars) => {
const matchingPerson = vars.gameManager.findPersonByField(game, 'id', socketArgs.personId);
if (matchingPerson && vars.gameManager.namespace.sockets.get(matchingPerson.socketId)) {
vars.gameManager.namespace.to(matchingPerson.socketId).emit(EVENT_IDS.SYNC_GAME_STATE);
}
}
},
{
id: EVENT_IDS.START_GAME,
stateChange: async (game, socketArgs, vars) => {
if (game.isStartable) {
game.status = STATUS.IN_PROGRESS;
vars.gameManager.deal(game);
if (game.hasTimer) {
game.timerParams.paused = true;
await vars.gameManager.runTimer(game);
}
}
},
communicate: async (game, socketArgs, vars) => {
if (vars.ackFn) {
vars.ackFn();
}
vars.gameManager.namespace.in(game.accessCode).emit(EVENT_IDS.START_GAME);
}
},
{
id: EVENT_IDS.KILL_PLAYER,
stateChange: async (game, socketArgs, vars) => {
const person = game.people.find((person) => person.id === socketArgs.personId);
if (person && !person.out) {
person.userType = person.userType === USER_TYPES.BOT
? USER_TYPES.KILLED_BOT
: USER_TYPES.KILLED_PLAYER;
person.out = true;
person.killed = true;
}
},
communicate: async (game, socketArgs, vars) => {
const person = game.people.find((person) => person.id === socketArgs.personId);
if (person) {
vars.gameManager.namespace.in(game.accessCode).emit(EVENT_IDS.KILL_PLAYER, person);
}
}
},
{
id: EVENT_IDS.REVEAL_PLAYER,
stateChange: async (game, socketArgs, vars) => {
const person = game.people.find((person) => person.id === socketArgs.personId);
if (person && !person.revealed) {
person.revealed = true;
}
},
communicate: async (game, socketArgs, vars) => {
const person = game.people.find((person) => person.id === socketArgs.personId);
if (person) {
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.REVEAL_PLAYER,
{
id: person.id,
gameRole: person.gameRole,
alignment: person.alignment
}
);
}
}
},
{
id: EVENT_IDS.END_GAME,
stateChange: async (game, socketArgs, vars) => {
game.status = STATUS.ENDED;
if (game.hasTimer && vars.gameManager.timers[game.accessCode]) {
vars.logger.trace('STOPPING TIMER FOR ENDED GAME ' + game.accessCode);
vars.gameManager.timers[game.accessCode].stopTimer();
delete vars.gameManager.timers[game.accessCode];
}
for (const person of game.people) {
person.revealed = true;
}
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode)
.emit(EVENT_IDS.END_GAME, GameStateCurator.mapPeopleForModerator(game.people));
if (vars.ackFn) {
vars.ackFn();
}
}
},
{
id: EVENT_IDS.TRANSFER_MODERATOR,
stateChange: async (game, socketArgs, vars) => {
const currentModerator = vars.gameManager.findPersonByField(game, 'id', game.currentModeratorId);
const toTransferTo = vars.gameManager.findPersonByField(game, 'id', socketArgs.personId);
if (currentModerator) {
if (currentModerator.gameRole) {
currentModerator.userType = USER_TYPES.KILLED_PLAYER;
} else {
currentModerator.userType = USER_TYPES.SPECTATOR;
}
game.previousModeratorId = currentModerator.id;
}
if (toTransferTo) {
toTransferTo.userType = USER_TYPES.MODERATOR;
game.currentModeratorId = toTransferTo.id;
}
},
communicate: async (game, socketArgs, vars) => {
if (vars.ackFn) {
vars.ackFn();
}
vars.gameManager.namespace.to(game.accessCode).emit(EVENT_IDS.SYNC_GAME_STATE);
}
},
{
id: EVENT_IDS.ASSIGN_DEDICATED_MOD,
stateChange: async (game, socketArgs, vars) => {
const currentModerator = vars.gameManager.findPersonByField(game, 'id', game.currentModeratorId);
const toTransferTo = vars.gameManager.findPersonByField(game, 'id', socketArgs.personId);
if (currentModerator && toTransferTo) {
if (currentModerator.id !== toTransferTo.id) {
currentModerator.userType = USER_TYPES.PLAYER;
}
toTransferTo.userType = USER_TYPES.MODERATOR;
toTransferTo.out = true;
toTransferTo.killed = true;
game.previousModeratorId = currentModerator.id;
game.currentModeratorId = toTransferTo.id;
}
},
communicate: async (game, socketArgs, vars) => {
const moderator = vars.gameManager.findPersonByField(game, 'id', game.currentModeratorId);
const moderatorSocket = vars.gameManager.namespace.sockets.get(moderator?.socketId);
if (moderator && moderatorSocket) {
vars.gameManager.namespace.to(moderator.socketId).emit(EVENT_IDS.SYNC_GAME_STATE);
moderatorSocket.to(game.accessCode).emit(EVENT_IDS.KILL_PLAYER, moderator);
} else {
vars.gameManager.namespace.in(game.accessCode).emit(EVENT_IDS.KILL_PLAYER, moderator);
}
const previousModerator = vars.gameManager.findPersonByField(game, 'id', game.previousModeratorId);
if (previousModerator && previousModerator.id !== moderator.id && vars.gameManager.namespace.sockets.get(previousModerator.socketId)) {
vars.gameManager.namespace.to(previousModerator.socketId).emit(EVENT_IDS.SYNC_GAME_STATE);
}
}
},
{
id: EVENT_IDS.RESTART_GAME,
stateChange: async (game, socketArgs, vars) => {
if (vars.instanceId !== vars.senderInstanceId
&& vars.gameManager.timers[game.accessCode]
) {
vars.gameManager.timers[game.accessCode].stopTimer();
delete vars.gameManager.timers[game.accessCode];
}
},
communicate: async (game, socketArgs, vars) => {
if (vars.ackFn) {
vars.ackFn();
}
vars.gameManager.namespace.in(game.accessCode).emit(EVENT_IDS.RESTART_GAME);
}
},
{
id: EVENT_IDS.TIMER_EVENT,
stateChange: async (game, socketArgs, vars) => {},
communicate: async (game, socketArgs, vars) => {
await handleTimerCommand(vars.timerEventSubtype, game, vars.requestingSocketId, vars);
}
},
{
id: EVENT_IDS.SOURCE_TIMER_EVENT,
stateChange: async (game, socketArgs, vars) => {},
communicate: async (game, socketArgs, vars) => {
if (socketArgs.timerEventSubtype === GAME_PROCESS_COMMANDS.GET_TIME_REMAINING) {
const timer = vars.gameManager.timers[game.accessCode];
if (timer) {
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
vars.instanceId,
JSON.stringify({
socketId: socketArgs.socketId,
timeRemaining: timer.currentTimeInMillis,
paused: game.timerParams ? game.timerParams.paused : false
})
)
);
}
} else {
const timer = vars.gameManager.timers[game.accessCode];
if (timer) {
await handleTimerCommand(socketArgs.timerEventSubtype, game, socketArgs.socketId, vars);
}
}
}
},
{
id: EVENT_IDS.UPDATE_GAME_TIMER,
stateChange: async (game, socketArgs, vars) => {
if (GameCreationRequest.timerParamsAreValid(socketArgs.hasTimer, socketArgs.timerParams)) {
game.hasTimer = socketArgs.hasTimer;
game.timerParams = socketArgs.timerParams;
}
},
communicate: async (game, socketArgs, vars) => {
if (vars.ackFn) {
vars.ackFn();
}
vars.gameManager.namespace.in(game.accessCode).emit(
EVENT_IDS.UPDATE_GAME_TIMER,
game.hasTimer,
game.timerParams
);
}
},
{
id: EVENT_IDS.END_TIMER,
stateChange: async (game, socketArgs, vars) => {
game.timerParams.paused = false;
game.timerParams.timeRemaining = 0;
game.timerParams.ended = true;
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode).emit(GAME_PROCESS_COMMANDS.END_TIMER);
}
},
{
id: EVENT_IDS.PAUSE_TIMER,
stateChange: async (game, socketArgs, vars) => {
game.timerParams.paused = true;
game.timerParams.timeRemaining = socketArgs.timeRemaining;
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode).emit(GAME_PROCESS_COMMANDS.PAUSE_TIMER, socketArgs.timeRemaining);
}
},
{
id: EVENT_IDS.RESUME_TIMER,
stateChange: async (game, socketArgs, vars) => {
game.timerParams.paused = false;
game.timerParams.timeRemaining = socketArgs.timeRemaining;
},
communicate: async (game, socketArgs, vars) => {
vars.gameManager.namespace.in(game.accessCode).emit(GAME_PROCESS_COMMANDS.RESUME_TIMER, socketArgs.timeRemaining);
}
},
{
id: EVENT_IDS.GET_TIME_REMAINING,
stateChange: async (game, socketArgs, vars) => {},
communicate: async (game, socketArgs, vars) => {
const socket = vars.gameManager.namespace.sockets.get(socketArgs.socketId);
if (socket) {
vars.gameManager.namespace.to(socket.id).emit(GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, socketArgs.timeRemaining, game.timerParams.paused);
}
}
}
];
module.exports = Events;