diff --git a/client/src/config/globals.js b/client/src/config/globals.js index 80fb263..afc8c12 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -1,7 +1,7 @@ export const globals = { CHAR_POOL: 'abcdefghijklmnopqrstuvwxyz0123456789', USER_SIGNATURE_LENGTH: 75, - CLOCK_TICK_INTERVAL_MILLIS: 100, + CLOCK_TICK_INTERVAL_MILLIS: 50, MAX_CUSTOM_ROLE_NAME_LENGTH: 50, MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH: 1000, TOAST_DURATION_DEFAULT: 6, @@ -54,7 +54,19 @@ export const globals = { UPDATE_SPECTATORS: 'updateSpectators', RESTART_GAME: 'restartGame', ASSIGN_DEDICATED_MOD: 'assignDedicatedMod' - + }, + LOBBY_EVENTS: function () { + return [ + this.EVENT_IDS.PLAYER_JOINED, + this.EVENT_IDS.ADD_SPECTATOR + ]; + }, + IN_PROGRESS_EVENTS: function () { + return [ + this.EVENT_IDS.KILL_PLAYER, + this.EVENT_IDS.REVEAL_PLAYER, + this.EVENT_IDS.ADD_SPECTATOR + ]; }, USER_TYPES: { MODERATOR: 'moderator', diff --git a/client/src/modules/game_state/states/InProgress.js b/client/src/modules/game_state/states/InProgress.js index acb0f1c..581a32d 100644 --- a/client/src/modules/game_state/states/InProgress.js +++ b/client/src/modules/game_state/states/InProgress.js @@ -4,7 +4,6 @@ import { HTMLFragments } from '../../front_end_components/HTMLFragments.js'; import { Confirmation } from '../../front_end_components/Confirmation.js'; import { ModalManager } from '../../front_end_components/ModalManager.js'; import { GameTimerManager } from '../../timer/GameTimerManager.js'; -import { stateBucket } from '../StateBucket.js'; import { SharedStateUtil } from './shared/SharedStateUtil.js'; export class InProgress { @@ -194,6 +193,7 @@ export class InProgress { }); this.socket.on(globals.EVENT_IDS.REVEAL_PLAYER, (revealData) => { + console.log('here'); const revealedPerson = this.stateBucket.currentGameState.people.find((person) => person.id === revealData.id); if (revealedPerson) { revealedPerson.revealed = true; @@ -217,27 +217,10 @@ export class InProgress { } }); - if (this.socket.hasListeners(globals.EVENT_IDS.ADD_SPECTATOR)) { - this.socket.removeAllListeners(globals.EVENT_IDS.ADD_SPECTATOR); - } - this.socket.on(globals.EVENT_IDS.ADD_SPECTATOR, (spectator) => { - stateBucket.currentGameState.people.push(spectator); + this.stateBucket.currentGameState.people.push(spectator); SharedStateUtil.setNumberOfSpectators( - stateBucket.currentGameState.people.filter(p => p.userType === globals.USER_TYPES.SPECTATOR).length, - document.getElementById('spectator-count') - ); - if (this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.MODERATOR - || this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { - this.displayAvailableModerators(); - } - }); - - this.socket.on(globals.EVENT_IDS.UPDATE_SPECTATORS, (spectators) => { - stateBucket.currentGameState.people = stateBucket.currentGameState.people.filter(p => p.userType !== globals.USER_TYPES.SPECTATOR); - stateBucket.currentGameState.people = stateBucket.currentGameState.people.concat(spectators); - SharedStateUtil.setNumberOfSpectators( - stateBucket.currentGameState.people.filter(p => p.userType === globals.USER_TYPES.SPECTATOR).length, + this.stateBucket.currentGameState.people.filter(p => p.userType === globals.USER_TYPES.SPECTATOR).length, document.getElementById('spectator-count') ); if (this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.MODERATOR @@ -247,9 +230,11 @@ export class InProgress { }); if (this.stateBucket.currentGameState.timerParams) { - const timerWorker = new Worker(new URL('../../timer/Timer.js', import.meta.url)); - const gameTimerManager = new GameTimerManager(stateBucket, this.socket); - gameTimerManager.attachTimerSocketListeners(this.socket, timerWorker); + if (!this.stateBucket.timerWorker) { + this.stateBucket.timerWorker = new Worker(new URL('../../timer/Timer.js', import.meta.url)); + } + const gameTimerManager = new GameTimerManager(this.stateBucket, this.socket); + gameTimerManager.attachTimerSocketListeners(this.socket, this.stateBucket.timerWorker); } } @@ -540,8 +525,8 @@ function renderPotentialMods (gameState, group, transferModHandlers, socket) { socket.emit( globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.FETCH_GAME_STATE, - stateBucket.currentGameState.accessCode, - { personId: stateBucket.currentGameState.client.cookie }, + gameState.accessCode, + { personId: gameState.client.cookie }, (gameState) => { SharedStateUtil.gameStateAckFn(gameState, socket); } diff --git a/client/src/modules/game_state/states/shared/SharedStateUtil.js b/client/src/modules/game_state/states/shared/SharedStateUtil.js index 8fa742f..3fbdd90 100644 --- a/client/src/modules/game_state/states/shared/SharedStateUtil.js +++ b/client/src/modules/game_state/states/shared/SharedStateUtil.js @@ -118,21 +118,20 @@ export const SharedStateUtil = { const accessCode = splitUrl[1]; if (/^[a-zA-Z0-9]+$/.test(accessCode) && accessCode.length === globals.ACCESS_CODE_LENGTH) { socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.FETCH_GAME_STATE, accessCode, { personId: cookie }, function (gameState) { - // if (gameState === null) { - // window.location = '/not-found?reason=' + encodeURIComponent('game-not-found'); - // } else { - stateBucket.currentGameState = gameState; - document.querySelector('.spinner-container')?.remove(); - document.querySelector('.spinner-background')?.remove(); - document.getElementById('game-content').innerHTML = HTMLFragments.INITIAL_GAME_DOM; - toast('You are connected.', 'success', true, true, 'short'); - processGameState(stateBucket.currentGameState, cookie, socket, true, true); - // } + if (gameState === null) { + window.location = '/not-found?reason=' + encodeURIComponent('game-not-found'); + } else { + stateBucket.currentGameState = gameState; + document.querySelector('.spinner-container')?.remove(); + document.querySelector('.spinner-background')?.remove(); + document.getElementById('game-content').innerHTML = HTMLFragments.INITIAL_GAME_DOM; + toast('You are connected.', 'success', true, true, 'short'); + processGameState(stateBucket.currentGameState, cookie, socket, true, true); + } }); + } else { + window.location = '/not-found?reason=' + encodeURIComponent('invalid-access-code'); } - // else { - // window.location = '/not-found?reason=' + encodeURIComponent('invalid-access-code'); - // } }, buildSpectatorList (people) { @@ -198,6 +197,7 @@ function processGameState ( } lobby.populateHeader(); lobby.populatePlayers(); + globals.LOBBY_EVENTS().forEach(e => socket.removeAllListeners(e)); lobby.setSocketHandlers(); if (( currentGameState.client.userType === globals.USER_TYPES.MODERATOR @@ -213,6 +213,7 @@ function processGameState ( document.querySelector('#game-control-prompt')?.remove(); } const inProgressGame = new InProgress('game-state-container', stateBucket, socket); + globals.IN_PROGRESS_EVENTS().forEach(e => socket.removeAllListeners(e)); inProgressGame.setSocketHandlers(); inProgressGame.setUserView(currentGameState.client.userType); break; diff --git a/index.js b/index.js index dcfb3f1..e1f7114 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,14 @@ 'use strict'; -const main = async () => { +(async () => { return new Promise(async (resolve, reject) => { try { const express = require('express'); const app = express(); const ServerBootstrapper = require('./server/modules/ServerBootstrapper'); - const ActiveGameRunner = require('./server/modules/singletons/ActiveGameRunner'); + const timerManager = require('./server/modules/singletons/TimerManager'); const GameManager = require('./server/modules/singletons/GameManager'); - const SocketManager = require('./server/modules/singletons/SocketManager'); + const eventManager = require('./server/modules/singletons/EventManager'); const globals = require('./server/config/globals'); app.use(express.json({limit: '10kb'})); @@ -28,18 +28,24 @@ const main = async () => { } return id; })()); - singletons.gameManager.activeGameRunner = ActiveGameRunner.instance; - singletons.gameManager.socketManager = SocketManager.instance; - singletons.socketManager.activeGameRunner = ActiveGameRunner.instance; - singletons.socketManager.gameManager = GameManager.instance; + singletons.gameManager.timerManager = timerManager.instance; + singletons.gameManager.eventManager = eventManager.instance; + singletons.eventManager.timerManager = timerManager.instance; + singletons.eventManager.gameManager = GameManager.instance; - await singletons.activeGameRunner.client.connect(); - console.log('Root Redis client connected'); - await singletons.activeGameRunner.createGameSyncSubscriber(singletons.gameManager, singletons.socketManager); - await singletons.socketManager.createRedisPublisher(); + try { + await singletons.eventManager.client.connect(); + logger.info('Root Redis client connected'); - const socketServer = singletons.socketManager.createSocketServer(webServer, app, port); - singletons.gameManager.setGameSocketNamespace(singletons.socketManager.createGameSocketNamespace(socketServer, logger, singletons.gameManager)); + } catch(e) { + reject(new Error('UNABLE TO CONNECT TO REDIS because: '+ e)); + } + + await singletons.eventManager.createGameSyncSubscriber(singletons.gameManager, singletons.eventManager); + await singletons.eventManager.createRedisPublisher(); + + const socketServer = singletons.eventManager.createSocketServer(webServer, app, port); + singletons.gameManager.setGameSocketNamespace(singletons.eventManager.createGameSocketNamespace(socketServer, logger, singletons.gameManager)); ServerBootstrapper.establishRouting(app, express); app.set('port', port); @@ -52,8 +58,5 @@ const main = async () => { reject(e); } }) -} - -main() - .then(() => console.log('Server startup complete.')) +})().then(() => console.log('Server startup complete.')) .catch((e) => console.error('SERVER FAILED TO START: ' + e)); diff --git a/server/api/AdminAPI.js b/server/api/AdminAPI.js index aaf0aaa..9cca6ef 100644 --- a/server/api/AdminAPI.js +++ b/server/api/AdminAPI.js @@ -2,8 +2,8 @@ const express = require('express'); const router = express.Router(); const debugMode = Array.from(process.argv.map((arg) => arg.trim().toLowerCase())).includes('debug'); const logger = require('../modules/Logger')(debugMode); -const socketManager = (require('../modules/singletons/SocketManager.js')).instance; -const activeGameRunner = (require('../modules/singletons/ActiveGameRunner.js')).instance; +const eventManager = (require('../modules/singletons/EventManager.js')).instance; +const timerManager = (require('../modules/singletons/TimerManager.js')).instance; const globals = require('../config/globals.js'); const cors = require('cors'); @@ -16,13 +16,13 @@ router.post('/sockets/broadcast', (req, res, next) => { // TODO: implement client-side display of this message. router.post('/sockets/broadcast', function (req, res) { logger.info('admin user broadcasting message: ' + req.body?.message); - socketManager.broadcast(req.body?.message); + eventManager.broadcast(req.body?.message); res.status(201).send('Broadcasted message to all connected sockets: ' + req.body?.message); }); router.get('/games/state', async (req, res) => { const gamesArray = []; - await activeGameRunner.client.keys('*').then(async (r) => { + await timerManager.client.keys('*').then(async (r) => { Object.values(r).forEach((v) => gamesArray.push(JSON.parse(v))); }); res.status(200).send(gamesArray); diff --git a/server/api/GamesAPI.js b/server/api/GamesAPI.js index c645c6b..9ce912d 100644 --- a/server/api/GamesAPI.js +++ b/server/api/GamesAPI.js @@ -77,7 +77,7 @@ router.patch('/:code/players', async function (req, res) { ) { res.status(400).send(); } else { - const game = await gameManager.activeGameRunner.getActiveGame(req.body.accessCode); + const game = await gameManager.getActiveGame(req.body.accessCode); if (game) { const inUseCookie = gameManager.environment === globals.ENVIRONMENT.PRODUCTION ? req.body.localCookie : req.body.sessionCookie; gameManager.joinGame(game, req.body.playerName, inUseCookie, req.body.joinAsSpectator).then((data) => { @@ -102,7 +102,7 @@ router.patch('/:code/restart', async function (req, res) { ) { res.status(400).send(); } else { - const game = await gameManager.activeGameRunner.getActiveGame(req.body.accessCode); + const game = await gameManager.getActiveGame(req.body.accessCode); if (game) { gameManager.restartGame(game, gameManager.namespace).then((data) => { res.status(200).send(); diff --git a/server/config/globals.js b/server/config/globals.js index 6618cdc..0595b1e 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -42,8 +42,7 @@ const globals = { RESUME_TIMER: 'resumeTimer', END_TIMER: 'endTimer', GET_TIME_REMAINING: 'getTimeRemaining', - SOURCE_TIME_REMAINING: 'sourceTimeRemaining', - SHARE_TIME_REMAINING: 'shareTimeRemaining', + SOURCE_TIMER_EVENT: 'sourceTimerEvent', KILL_PLAYER: 'killPlayer', REVEAL_PLAYER: 'revealPlayer', TRANSFER_MODERATOR: 'transferModerator', @@ -55,7 +54,8 @@ const globals = { ADD_SPECTATOR: 'addSpectator', SYNC_GAME_STATE: 'syncGameState', UPDATE_SOCKET: 'updateSocket', - ASSIGN_DEDICATED_MOD: 'assignDedicatedMod' + ASSIGN_DEDICATED_MOD: 'assignDedicatedMod', + TIMER_EVENT: 'timerEvent' }, SYNCABLE_EVENTS: function () { return [ @@ -81,7 +81,8 @@ const globals = { return [ this.EVENT_IDS.RESUME_TIMER, this.EVENT_IDS.PAUSE_TIMER, - this.EVENT_IDS.END_TIMER + this.EVENT_IDS.END_TIMER, + this.EVENT_IDS.GET_TIME_REMAINING ]; }, MESSAGES: { diff --git a/server/modules/Events.js b/server/modules/Events.js index b0f7ea6..30a0d7e 100644 --- a/server/modules/Events.js +++ b/server/modules/Events.js @@ -53,15 +53,6 @@ const Events = [ } } }, - // { - // id: EVENT_IDS.UPDATE_SOCKET, - // stateChange: (game, socketArgs, vars) => { - // const matchingPerson = vars.gameManager.findPersonByField(game, 'id', socketArgs.personId); - // if (matchingPerson) { - // matchingPerson.socketId = socketArgs.socketId; - // } - // } - // } { id: EVENT_IDS.SYNC_GAME_STATE, stateChange: async (game, socketArgs, vars) => {}, @@ -79,7 +70,7 @@ const Events = [ game.status = globals.STATUS.IN_PROGRESS; if (game.hasTimer) { game.timerParams.paused = true; - await vars.activeGameRunner.runGame(game, vars.gameManager.namespace, vars.socketManager, vars.gameManager); + await vars.timerManager.runTimer(game, vars.gameManager.namespace, vars.eventManager, vars.gameManager); } } }, @@ -133,10 +124,10 @@ const Events = [ id: EVENT_IDS.END_GAME, stateChange: async (game, socketArgs, vars) => { game.status = globals.STATUS.ENDED; - // if (this.activeGameRunner.timerThreads[game.accessCode]) { - // this.logger.trace('KILLING TIMER PROCESS FOR ENDED GAME ' + game.accessCode); - // this.activeGameRunner.timerThreads[game.accessCode].kill(); - // } + if (vars.timerManager.timerThreads[game.accessCode]) { + vars.logger.trace('KILLING TIMER PROCESS FOR ENDED GAME ' + game.accessCode); + vars.timerManager.timerThreads[game.accessCode].kill(); + } for (const person of game.people) { person.revealed = true; } @@ -208,7 +199,15 @@ const Events = [ }, { id: EVENT_IDS.RESTART_GAME, - stateChange: async (game, socketArgs, vars) => {}, + stateChange: async (game, socketArgs, vars) => { + if (vars.instanceId !== vars.senderInstanceId + && vars.timerManager.timerThreads[game.accessCode] + && !vars.timerManager.timerThreads[game.accessCode].killed + ) { + vars.timerManager.timerThreads[game.accessCode].kill(); + delete vars.timerManager.timerThreads[game.accessCode]; + } + }, communicate: async (game, socketArgs, vars) => { if (vars.ackFn) { vars.ackFn(); @@ -217,51 +216,51 @@ const Events = [ } }, { - id: EVENT_IDS.GET_TIME_REMAINING, + id: EVENT_IDS.TIMER_EVENT, stateChange: async (game, socketArgs, vars) => {}, communicate: async (game, socketArgs, vars) => { - const thread = vars.activeGameRunner.timerThreads[game.accessCode]; + const thread = vars.timerManager.timerThreads[game.accessCode]; if (thread && (!thread.killed && thread.exitCode === null)) { thread.send({ - command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, + command: vars.timerEventSubtype, accessCode: game.accessCode, socketId: vars.socketId, logLevel: vars.logger.logLevel }); } else if (thread) { - console.log(game.timerParams); - if (game.timerParams && game.timerParams.timeRemaining === 0) { - vars.gameManager.namespace.to(vars.socketId) - .emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); - await vars.socketManager.publisher.publish( - globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, - game.accessCode + ';' + globals.EVENT_IDS.SHARE_TIME_REMAINING + ';' + - JSON.stringify({ - socketId: vars.socketId, - timeRemaining: game.timerParams.timeRemaining, - paused: game.timerParams.paused - }) + - ';' + vars.instanceId - ); + if (vars.timerEventSubtype === EVENT_IDS.GET_TIME_REMAINING && game.timerParams && game.timerParams.timeRemaining === 0) { + // vars.gameManager.namespace.to(vars.socketId) + // .emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); + // await vars.eventManager.publisher.publish( + // globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, + // game.accessCode + ';' + globals.EVENT_IDS.SHARE_TIME_REMAINING + ';' + + // JSON.stringify({ + // socketId: vars.socketId, + // timeRemaining: game.timerParams.timeRemaining, + // paused: game.timerParams.paused + // }) + + // ';' + vars.instanceId + // ); } } else { // we need to consult another container for the timer data - await vars.socketManager.publisher?.publish( + await vars.eventManager.publisher?.publish( globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, - game.accessCode + ';' + globals.EVENT_IDS.SOURCE_TIME_REMAINING + ';' + JSON.stringify({ socketId: vars.socketId }) + ';' + vars.instanceId + game.accessCode + ';' + globals.EVENT_IDS.SOURCE_TIMER_EVENT + ';' + + JSON.stringify({ socketId: vars.socketId, timerEventSubtype: vars.timerEventSubtype }) + ';' + vars.instanceId ); } } }, { - /* unlike the GET_TIME_REMAINING event, this event is a request from another instance for timer data. In response + /* This event is a request from another instance to consult its timer data. In response * to this event, this instance will check if it is home to a particular timer thread. */ - id: EVENT_IDS.SOURCE_TIME_REMAINING, + id: EVENT_IDS.SOURCE_TIMER_EVENT, stateChange: async (game, socketArgs, vars) => {}, communicate: async (game, socketArgs, vars) => { - const thread = vars.activeGameRunner.timerThreads[game.accessCode]; + const thread = vars.timerManager.timerThreads[game.accessCode]; if (thread && (!thread.killed && thread.exitCode === null)) { thread.send({ - command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, + command: socketArgs.timerEventSubtype, accessCode: game.accessCode, socketId: socketArgs.socketId, logLevel: vars.logger.logLevel @@ -271,7 +270,7 @@ const Events = [ vars.gameManager.namespace.to(vars.socketId) .emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); } - await vars.socketManager.publisher.publish( + await vars.eventManager.publisher.publish( globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, game.accessCode + ';' + globals.EVENT_IDS.SHARE_TIME_REMAINING + ';' + JSON.stringify({ @@ -284,22 +283,12 @@ const Events = [ } } }, - { - /* This is an event fired when an instance receives timer data from another instance. In this case, we should check if the socket id - * given in the message is connected to this namespace. If it is, emit the time remaining to them. */ - id: EVENT_IDS.SHARE_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(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, socketArgs.timeRemaining, socketArgs.paused); - } - } - }, { id: EVENT_IDS.END_TIMER, stateChange: async (game, socketArgs, vars) => { + if (vars.timerManager.timerThreads[game.accessCode]) { + delete vars.timerManager.timerThreads[game.accessCode]; + } game.timerParams.paused = false; game.timerParams.timeRemaining = 0; }, @@ -314,7 +303,7 @@ const Events = [ game.timerParams.timeRemaining = socketArgs.timeRemaining; }, communicate: async (game, socketArgs, vars) => { - vars.gameManager.namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER, game.timerParams.timeRemaining); + vars.gameManager.namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER, socketArgs.timeRemaining); } }, { @@ -324,7 +313,19 @@ const Events = [ game.timerParams.timeRemaining = socketArgs.timeRemaining; }, communicate: async (game, socketArgs, vars) => { - vars.gameManager.namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.RESUME_TIMER, game.timerParams.timeRemaining); + vars.gameManager.namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.RESUME_TIMER, socketArgs.timeRemaining); + } + }, + { + id: EVENT_IDS.GET_TIME_REMAINING, + stateChange: async (game, socketArgs, vars) => { + game.timerParams.timeRemaining = socketArgs.timeRemaining; + }, + communicate: async (game, socketArgs, vars) => { + const socket = vars.gameManager.namespace.sockets.get(socketArgs.socketId); + if (socket) { + vars.gameManager.namespace.to(socket.id).emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, socketArgs.timeRemaining, game.timerParams.paused); + } } } ]; diff --git a/server/modules/GameProcess.js b/server/modules/GameProcess.js index c779c65..92ab35a 100644 --- a/server/modules/GameProcess.js +++ b/server/modules/GameProcess.js @@ -3,7 +3,7 @@ const ServerTimer = require('./ServerTimer.js'); let timer; -// This is a subprocess spawned by logic in the ActiveGameRunner module. +// This is a subprocess spawned by logic in the TimerManager module. process.on('message', (msg) => { const logger = require('./Logger')(msg.logLevel); switch (msg.command) { diff --git a/server/modules/ServerBootstrapper.js b/server/modules/ServerBootstrapper.js index 082f76a..0093f7a 100644 --- a/server/modules/ServerBootstrapper.js +++ b/server/modules/ServerBootstrapper.js @@ -4,9 +4,9 @@ const https = require('https'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); -const SocketManager = require('./singletons/SocketManager.js'); +const EventManager = require('./singletons/EventManager.js'); const GameManager = require('./singletons/GameManager.js'); -const ActiveGameRunner = require('./singletons/ActiveGameRunner.js'); +const TimerManager = require('./singletons/TimerManager.js'); const { ENVIRONMENT } = require('../config/globals.js'); const rateLimit = require('express-rate-limit').default; @@ -14,8 +14,8 @@ const ServerBootstrapper = { singletons: (logger, instanceId) => { return { - activeGameRunner: new ActiveGameRunner(logger, instanceId), - socketManager: new SocketManager(logger, instanceId), + timerManager: new TimerManager(logger, instanceId), + eventManager: new EventManager(logger, instanceId), gameManager: process.env.NODE_ENV.trim() === 'development' ? new GameManager(logger, ENVIRONMENT.LOCAL, instanceId) : new GameManager(logger, ENVIRONMENT.PRODUCTION, instanceId) diff --git a/server/modules/singletons/ActiveGameRunner.js b/server/modules/singletons/ActiveGameRunner.js deleted file mode 100644 index 77b2351..0000000 --- a/server/modules/singletons/ActiveGameRunner.js +++ /dev/null @@ -1,116 +0,0 @@ -const { fork } = require('child_process'); -const path = require('path'); -const globals = require('../../config/globals'); -const redis = require('redis'); - -class ActiveGameRunner { - constructor (logger, instanceId) { - if (ActiveGameRunner.instance) { - throw new Error('The server tried to instantiate more than one ActiveGameRunner'); - } - logger.info('CREATING SINGLETON ACTIVE GAME RUNNER'); - this.timerThreads = {}; - this.logger = logger; - this.client = redis.createClient(); - this.subscriber = null; - this.instanceId = instanceId; - ActiveGameRunner.instance = this; - } - - getActiveGame = async (accessCode) => { - const r = await this.client.get(accessCode); - return r === null ? r : JSON.parse(r); - } - - createGameSyncSubscriber = async (gameManager, socketManager) => { - this.subscriber = this.client.duplicate(); - await this.subscriber.connect(); - await this.subscriber.subscribe(globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, async (message) => { - this.logger.info('MESSAGE: ' + message); - const messageComponents = message.split(';'); - if (messageComponents[messageComponents.length - 1] === this.instanceId) { - this.logger.trace('Disregarding self-authored message'); - return; - } - const game = await this.getActiveGame(messageComponents[0]); - let args; - if (messageComponents[2]) { - args = JSON.parse(messageComponents[2]); - } - if (game) { - await socketManager.handleEventById( - messageComponents[1], - game, - null, - game?.accessCode || messageComponents[0], - args || null, - null, - true - ); - } - }); - this.logger.info('ACTIVE GAME RUNNER - CREATED GAME SYNC SUBSCRIBER'); - } - - /* We're only going to fork a child process for games with a timer. They will report back to the parent process whenever - the timer is up. - */ - runGame = async (game, namespace, socketManager, gameManager) => { - this.logger.debug('running game ' + game.accessCode); - const gameProcess = fork(path.join(__dirname, '../GameProcess.js')); - this.timerThreads[game.accessCode] = gameProcess; - console.log(this.timerThreads); - this.logger.debug('game ' + game.accessCode + ' now associated with subProcess ' + gameProcess.pid); - gameProcess.on('message', async (msg) => { - game = await this.getActiveGame(game.accessCode); - switch (msg.command) { - case globals.GAME_PROCESS_COMMANDS.END_TIMER: - await socketManager.handleEventById(globals.EVENT_IDS.END_TIMER, game, msg.socketId, game.accessCode, msg, null, false); - this.logger.trace('PARENT: END TIMER'); - break; - case globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER: - await socketManager.handleEventById(globals.EVENT_IDS.PAUSE_TIMER, game, msg.socketId, game.accessCode, msg, null, false); - break; - case globals.GAME_PROCESS_COMMANDS.RESUME_TIMER: - await socketManager.handleEventById(globals.EVENT_IDS.RESUME_TIMER, game, msg.socketId, game.accessCode, msg, null, false); - break; - case globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING: - this.logger.trace(msg); - game.timerParams.timeRemaining = msg.timeRemaining; - this.logger.trace('PARENT: GET TIME REMAINING'); - msg.paused = game.timerParams.paused; - await socketManager.publisher.publish( - globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, - game.accessCode + ';' + globals.EVENT_IDS.SHARE_TIME_REMAINING + ';' + JSON.stringify(msg) + ';' + this.instanceId - ); - const socket = namespace.sockets.get(msg.socketId); - if (socket) { - namespace.to(socket.id).emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); - } - break; - } - - if (globals.SYNCABLE_EVENTS().includes(msg.command)) { - await gameManager.refreshGame(game); - await socketManager.publisher.publish( - globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, - game.accessCode + ';' + msg.command + ';' + JSON.stringify(msg) + ';' + this.instanceId - ); - } - }); - - gameProcess.on('exit', (code, signal) => { - this.logger.debug('Game timer thread ' + gameProcess.pid + ' exiting with code ' + code + ' - game ' + game.accessCode); - }); - gameProcess.send({ - command: globals.GAME_PROCESS_COMMANDS.START_TIMER, - accessCode: game.accessCode, - logLevel: this.logger.logLevel, - hours: game.timerParams.hours, - minutes: game.timerParams.minutes - }); - game.startTime = new Date().toJSON(); - }; -} - -module.exports = ActiveGameRunner; diff --git a/server/modules/singletons/SocketManager.js b/server/modules/singletons/EventManager.js similarity index 58% rename from server/modules/singletons/SocketManager.js rename to server/modules/singletons/EventManager.js index 4ec3ea9..c2369ca 100644 --- a/server/modules/singletons/SocketManager.js +++ b/server/modules/singletons/EventManager.js @@ -1,22 +1,23 @@ const globals = require('../../config/globals'); -const EVENT_IDS = globals.EVENT_IDS; const { RateLimiterMemory } = require('rate-limiter-flexible'); const redis = require('redis'); const Events = require('../Events'); -class SocketManager { +class EventManager { constructor (logger, instanceId) { - if (SocketManager.instance) { - throw new Error('The server attempted to instantiate more than one SocketManager.'); + if (EventManager.instance) { + throw new Error('The server attempted to instantiate more than one EventManager.'); } - logger.info('CREATING SINGLETON SOCKET MANAGER'); + logger.info('CREATING SINGLETON EVENT MANAGER'); this.logger = logger; + this.client = redis.createClient(); this.io = null; this.publisher = null; - this.activeGameRunner = null; + this.subscriber = null; + this.timerManager = null; this.gameManager = null; this.instanceId = instanceId; - SocketManager.instance = this; + EventManager.instance = this; } broadcast = (message) => { @@ -26,7 +27,38 @@ class SocketManager { createRedisPublisher = async () => { this.publisher = redis.createClient(); await this.publisher.connect(); - this.logger.info('SOCKET MANAGER - CREATED GAME SYNC PUBLISHER'); + this.logger.info('EVENT MANAGER - CREATED PUBLISHER'); + } + + createGameSyncSubscriber = async (gameManager, eventManager) => { + this.subscriber = this.client.duplicate(); + await this.subscriber.connect(); + await this.subscriber.subscribe(globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, async (message) => { + this.logger.info('MESSAGE: ' + message); + const messageComponents = message.split(';'); + if (messageComponents[messageComponents.length - 1] === this.instanceId) { + this.logger.trace('Disregarding self-authored message'); + return; + } + const game = await gameManager.getActiveGame(messageComponents[0]); + let args; + if (messageComponents[2]) { + args = JSON.parse(messageComponents[2]); + } + if (game) { + await eventManager.handleEventById( + messageComponents[1], + messageComponents[messageComponents.length - 1], + game, + null, + game?.accessCode || messageComponents[0], + args || null, + null, + true + ); + } + }); + this.logger.info('EVENT MANAGER - CREATED SUBSCRIBER'); } createSocketServer = (main, app, port, logger) => { @@ -49,24 +81,24 @@ class SocketManager { createGameSocketNamespace = (server, logger, gameManager) => { const namespace = server.of('/in-game'); - const registerHandlers = this.registerHandlers; + const registerSocketHandler = this.registerSocketHandler; registerRateLimiter(namespace, logger); namespace.on('connection', function (socket) { socket.on('disconnecting', (reason) => { logger.trace('client socket disconnecting because: ' + reason); }); - registerHandlers(namespace, socket, gameManager); + registerSocketHandler(namespace, socket, gameManager); }); return server.of('/in-game'); }; - registerHandlers = (namespace, socket) => { + registerSocketHandler = (namespace, socket, gameManager) => { socket.on(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, async (eventId, accessCode, args = null, ackFn = null) => { - const game = await this.activeGameRunner.getActiveGame(accessCode); + const game = await gameManager.getActiveGame(accessCode); if (game) { if (globals.TIMER_EVENTS().includes(eventId)) { - await this.handleAndSyncTimerEvent(eventId, game, socket, args, ackFn, false); + await this.handleEventById(globals.EVENT_IDS.TIMER_EVENT, null, game, socket.id, game.accessCode, args, ackFn, true, eventId); } else { await this.handleAndSyncSocketEvent(eventId, game, socket, args, ackFn, false); } @@ -77,7 +109,7 @@ class SocketManager { }; handleAndSyncSocketEvent = async (eventId, game, socket, socketArgs, ackFn) => { - await this.handleEventById(eventId, game, socket?.id, game.accessCode, socketArgs, ackFn, false); + await this.handleEventById(eventId, null, game, socket?.id, game.accessCode, socketArgs, ackFn, false); /* This server should publish events initiated by a connected socket to Redis for consumption by other instances. */ if (globals.SYNCABLE_EVENTS().includes(eventId)) { await this.gameManager.refreshGame(game); @@ -88,33 +120,22 @@ class SocketManager { } } - handleAndSyncTimerEvent = async (eventId, game, socketId, accessCode, socketArgs, ackFn, syncOnly) => { - switch (eventId) { - case EVENT_IDS.PAUSE_TIMER: - await this.gameManager.pauseTimer(game, this.logger); - break; - case EVENT_IDS.RESUME_TIMER: - await this.gameManager.resumeTimer(game, this.logger); - break; - default: - break; - } - } - - handleEventById = async (eventId, game, socketId, accessCode, socketArgs, ackFn, syncOnly) => { + handleEventById = async (eventId, senderInstanceId, game, socketId, accessCode, socketArgs, ackFn, syncOnly, timerEventSubtype = null) => { this.logger.trace('ARGS TO HANDLER: ' + JSON.stringify(socketArgs)); const event = Events.find((event) => event.id === eventId); const additionalVars = { gameManager: this.gameManager, - activeGameRunner: this.activeGameRunner, - socketManager: this, + timerManager: this.timerManager, + eventManager: this, socketId: socketId, ackFn: ackFn, logger: this.logger, - instanceId: this.instanceId + instanceId: this.instanceId, + senderInstanceId: senderInstanceId, + timerEventSubtype: timerEventSubtype }; if (event) { - if (!syncOnly) { + if (!syncOnly || eventId === globals.EVENT_IDS.RESTART_GAME) { await event.stateChange(game, socketArgs, additionalVars); } await event.communicate(game, socketArgs, additionalVars); @@ -140,4 +161,4 @@ function registerRateLimiter (server, logger) { }); } -module.exports = SocketManager; +module.exports = EventManager; diff --git a/server/modules/singletons/GameManager.js b/server/modules/singletons/GameManager.js index 3157c63..fcecb4c 100644 --- a/server/modules/singletons/GameManager.js +++ b/server/modules/singletons/GameManager.js @@ -13,20 +13,25 @@ class GameManager { logger.info('CREATING SINGLETON GAME MANAGER'); this.logger = logger; this.environment = environment; - this.activeGameRunner = null; - this.socketManager = null; + this.timerManager = null; + this.eventManager = null; this.namespace = null; this.instanceId = instanceId; GameManager.instance = this; } + getActiveGame = async (accessCode) => { + const r = await this.eventManager.client.get(accessCode); + return r === null ? r : JSON.parse(r); + } + setGameSocketNamespace = (namespace) => { this.namespace = namespace; }; refreshGame = async (game) => { this.logger.debug('PUSHING REFRESH OF ' + game.accessCode); - await this.activeGameRunner.client.set(game.accessCode, JSON.stringify(game)); + await this.eventManager.client.set(game.accessCode, JSON.stringify(game)); } createGame = async (gameParams) => { @@ -61,7 +66,7 @@ class GameManager { new Date().toJSON(), req.timerParams ); - await this.activeGameRunner.client.set(newAccessCode, JSON.stringify(newGame), { + await this.eventManager.client.set(newAccessCode, JSON.stringify(newGame), { EX: globals.STALE_GAME_SECONDS }); return Promise.resolve({ accessCode: newAccessCode, cookie: moderator.cookie, environment: this.environment }); @@ -73,7 +78,7 @@ class GameManager { }; pauseTimer = async (game, logger) => { - const thread = this.activeGameRunner.timerThreads[game.accessCode]; + const thread = this.timerManager.timerThreads[game.accessCode]; if (thread && !thread.killed) { this.logger.debug('Timer thread found for game ' + game.accessCode); thread.send({ @@ -85,7 +90,7 @@ class GameManager { }; resumeTimer = async (game, logger) => { - const thread = this.activeGameRunner.timerThreads[game.accessCode]; + const thread = this.timerManager.timerThreads[game.accessCode]; if (thread && !thread.killed) { this.logger.debug('Timer thread found for game ' + game.accessCode); thread.send({ @@ -98,7 +103,7 @@ class GameManager { getTimeRemaining = async (game, socketId) => { if (socketId) { - const thread = this.activeGameRunner.timerThreads[game.accessCode]; + const thread = this.timerManager.timerThreads[game.accessCode]; if (thread && (!thread.killed && thread.exitCode === null)) { thread.send({ command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, @@ -115,7 +120,7 @@ class GameManager { }; checkAvailability = async (code) => { - const game = await this.activeGameRunner.getActiveGame(code.toUpperCase().trim()); + const game = await this.getActiveGame(code.toUpperCase().trim()); if (game) { return Promise.resolve({ accessCode: code, playerCount: getGameSize(game.deck), timerParams: game.timerParams }); } else { @@ -127,7 +132,7 @@ class GameManager { const charCount = charPool.length; let codeDigits, accessCode; let attempts = 0; - while (!accessCode || ((await this.activeGameRunner.client.keys('*')).includes(accessCode) + while (!accessCode || ((await this.eventManager.client.keys('*')).includes(accessCode) && attempts < globals.ACCESS_CODE_GENERATION_ATTEMPTS)) { codeDigits = []; let iterations = globals.ACCESS_CODE_LENGTH; @@ -138,7 +143,7 @@ class GameManager { accessCode = codeDigits.join(''); attempts ++; } - return (await this.activeGameRunner.client.keys('*')).includes(accessCode) + return (await this.eventManager.client.keys('*')).includes(accessCode) ? null : accessCode; }; @@ -156,7 +161,7 @@ class GameManager { ) { return Promise.reject({ status: 400, reason: 'There are too many people already spectating.' }); } else if (joinAsSpectator) { - return await addSpectator(game, name, this.logger, this.namespace, this.socketManager.publisher, this.instanceId, this.refreshGame); + return await addSpectator(game, name, this.logger, this.namespace, this.eventManager.publisher, this.instanceId, this.refreshGame); } const unassignedPerson = this.findPersonByField(game, 'id', game.currentModeratorId).assigned === false ? this.findPersonByField(game, 'id', game.currentModeratorId) @@ -172,7 +177,7 @@ class GameManager { GameStateCurator.mapPerson(unassignedPerson), game.isFull ); - await this.activeGameRunner.publisher?.publish( + await this.eventManager.publisher?.publish( globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, game.accessCode + ';' + globals.EVENT_IDS.PLAYER_JOINED + ';' + JSON.stringify(unassignedPerson) + ';' + this.instanceId ); @@ -181,20 +186,20 @@ class GameManager { if (game.people.filter(person => person.userType === globals.USER_TYPES.SPECTATOR).length === globals.MAX_SPECTATORS) { return Promise.reject({ status: 400, reason: 'This game has reached the maximum number of players and spectators.' }); } - return await addSpectator(game, name, this.logger, this.namespace, this.socketManager.publisher, this.instanceId, this.refreshGame); + return await addSpectator(game, name, this.logger, this.namespace, this.eventManager.publisher, this.instanceId, this.refreshGame); } }; restartGame = async (game, namespace) => { // kill any outstanding timer threads - const subProcess = this.activeGameRunner.timerThreads[game.accessCode]; + const subProcess = this.timerManager.timerThreads[game.accessCode]; if (subProcess) { if (!subProcess.killed) { this.logger.info('Killing timer process ' + subProcess.pid + ' for: ' + game.accessCode); - this.activeGameRunner.timerThreads[game.accessCode].kill(); + this.timerManager.timerThreads[game.accessCode].kill(); } this.logger.debug('Deleting reference to subprocess ' + subProcess.pid); - delete this.activeGameRunner.timerThreads[game.accessCode]; + delete this.timerManager.timerThreads[game.accessCode]; } // re-shuffle the deck @@ -230,11 +235,11 @@ class GameManager { game.status = globals.STATUS.IN_PROGRESS; if (game.hasTimer) { game.timerParams.paused = true; - await this.activeGameRunner.runGame(game, namespace, this.socketManager, this); + await this.timerManager.runTimer(game, namespace, this.eventManager, this); } await this.refreshGame(game); - await this.socketManager.publisher?.publish( + await this.eventManager.publisher?.publish( globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, game.accessCode + ';' + globals.EVENT_IDS.RESTART_GAME + ';' + JSON.stringify({}) + ';' + this.instanceId ); diff --git a/server/modules/singletons/TimerManager.js b/server/modules/singletons/TimerManager.js new file mode 100644 index 0000000..0f7867b --- /dev/null +++ b/server/modules/singletons/TimerManager.js @@ -0,0 +1,47 @@ +const { fork } = require('child_process'); +const path = require('path'); +const globals = require('../../config/globals'); + +class TimerManager { + constructor (logger, instanceId) { + if (TimerManager.instance) { + throw new Error('The server tried to instantiate more than one TimerManager'); + } + logger.info('CREATING SINGLETON TIMER MANAGER'); + this.timerThreads = {}; + this.logger = logger; + this.subscriber = null; + this.instanceId = instanceId; + TimerManager.instance = this; + } + + runTimer = async (game, namespace, eventManager, gameManager) => { + this.logger.debug('running timer for game ' + game.accessCode); + const gameProcess = fork(path.join(__dirname, '../GameProcess.js')); + this.timerThreads[game.accessCode] = gameProcess; + this.logger.debug('game ' + game.accessCode + ' now associated with subProcess ' + gameProcess.pid); + gameProcess.on('message', async (msg) => { + game = await gameManager.getActiveGame(game.accessCode); + await eventManager.handleEventById(msg.command, null, game, msg.socketId, game.accessCode, msg, null, false); + await gameManager.refreshGame(game); + await eventManager.publisher.publish( + globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, + game.accessCode + ';' + msg.command + ';' + JSON.stringify(msg) + ';' + this.instanceId + ); + }); + + gameProcess.on('exit', (code, signal) => { + this.logger.debug('Game timer thread ' + gameProcess.pid + ' exiting with code ' + code + ' - game ' + game.accessCode); + }); + gameProcess.send({ + command: globals.GAME_PROCESS_COMMANDS.START_TIMER, + accessCode: game.accessCode, + logLevel: this.logger.logLevel, + hours: game.timerParams.hours, + minutes: game.timerParams.minutes + }); + game.startTime = new Date().toJSON(); + } +} + +module.exports = TimerManager; diff --git a/spec/unit/server/modules/GameManager_Spec.js b/spec/unit/server/modules/GameManager_Spec.js index 33208ef..205a011 100644 --- a/spec/unit/server/modules/GameManager_Spec.js +++ b/spec/unit/server/modules/GameManager_Spec.js @@ -6,7 +6,7 @@ const STATUS = globals.STATUS; const Person = require('../../../../server/model/Person'); const GameManager = require('../../../../server/modules/singletons/GameManager.js'); const GameStateCurator = require('../../../../server/modules/GameStateCurator.js'); -const ActiveGameRunner = require('../../../../server/modules/singletons/ActiveGameRunner.js'); +const ActiveGameRunner = require('../../../../server/modules/singletons/TimerManager.js'); const logger = require('../../../../server/modules/Logger.js')(false); describe('GameManager', () => {