diff --git a/index.js b/index.js index 28617f6..b95f598 100644 --- a/index.js +++ b/index.js @@ -1,27 +1,54 @@ 'use strict'; -const express = require('express'); -const app = express(); -const ServerBootstrapper = require('./server/modules/ServerBootstrapper'); +const main = async () => { + return new Promise(async (resolve, reject) => { + try { + const express = require('express'); + const app = express(); + const ServerBootstrapper = require('./server/modules/ServerBootstrapper'); + const globals = require('./server/config/globals'); -app.use(express.json({ limit: '10kb' })); + app.use(express.json({limit: '10kb'})); -const args = ServerBootstrapper.processCLIArgs(); + const args = ServerBootstrapper.processCLIArgs(); -const logger = require('./server/modules/Logger')(args.logLevel); -logger.info('LOG LEVEL IS: ' + args.logLevel); + const logger = require('./server/modules/Logger')(args.logLevel); + logger.info('LOG LEVEL IS: ' + args.logLevel); -const port = parseInt(process.env.PORT) || args.port || 8080; + const port = parseInt(process.env.PORT) || args.port || 8080; -const webServer = ServerBootstrapper.createServerWithCorrectHTTPProtocol(app, args.useHttps, args.port, logger); -const singletons = ServerBootstrapper.singletons(logger); + const webServer = ServerBootstrapper.createServerWithCorrectHTTPProtocol(app, args.useHttps, args.port, logger); + const singletons = ServerBootstrapper.singletons(logger, (() => { + let id = ''; + for (let i = 0; i < globals.INSTANCE_ID_LENGTH; i ++) { + id += globals.INSTANCE_ID_CHAR_POOL[Math.floor(Math.random() * globals.INSTANCE_ID_CHAR_POOL.length)]; + } + return id; + })()); -const socketServer = singletons.socketManager.createSocketServer(webServer, app, port); -singletons.gameManager.setGameSocketNamespace(singletons.socketManager.createGameSocketNamespace(socketServer, logger, singletons.gameManager)); -ServerBootstrapper.establishRouting(app, express); + await singletons.activeGameRunner.client.connect(); + console.log('Root Redis client connected'); + await singletons.activeGameRunner.refreshActiveGames(); + await singletons.activeGameRunner.createGameSyncSubscriber(singletons.gameManager, singletons.socketManager); + await singletons.socketManager.createRedisPublisher(); + await singletons.gameManager.createRedisPublisher(); -app.set('port', port); + const socketServer = singletons.socketManager.createSocketServer(webServer, app, port); + singletons.gameManager.setGameSocketNamespace(singletons.socketManager.createGameSocketNamespace(socketServer, logger, singletons.gameManager)); + ServerBootstrapper.establishRouting(app, express); -webServer.listen(app.get('port'), function () { - logger.info(`Starting server on port ${app.get('port')}`); -}); + app.set('port', port); + + await webServer.listen(app.get('port'), function () { + logger.info(`Starting server on port ${app.get('port')}`); + resolve(); + }); + } catch(e) { + reject(e); + } + }) +} + +main() + .then(() => console.log('Server startup complete.')) + .catch((e) => console.error('SERVER FAILED TO START: ' + e)); diff --git a/package-lock.json b/package-lock.json index 0375409..210525b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "express-rate-limit": "^6.0.1", "open": "^7.0.3", "rate-limiter-flexible": "^2.3.6", + "redis": "^4.5.1", "regenerator-runtime": "^0.13.9", "socket.io": "^4.5.3", "socket.io-client": "^4.5.4" @@ -1869,6 +1870,59 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@redis/bloom": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.1.0.tgz", + "integrity": "sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz", + "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==", + "dependencies": { + "cluster-key-slot": "1.1.1", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz", + "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.4.tgz", + "integrity": "sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.0.tgz", + "integrity": "sha512-NyFZEVnxIJEybpy+YskjgOJRNsfTYqaPbK/Buv6W2kmFNaRk85JiqjJZA5QkRmWvGbyQYwoO5QfDi2wHskKrQQ==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz", + "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -2603,6 +2657,14 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -4094,6 +4156,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5782,6 +5852,19 @@ "node": ">= 0.10" } }, + "node_modules/redis": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.5.1.tgz", + "integrity": "sha512-oxXSoIqMJCQVBTfxP6BNTCtDMyh9G6Vi5wjdPdV/sRKkufyZslDqCScSGcOr6XGR/reAWZefz7E4leM31RgdBA==", + "dependencies": { + "@redis/bloom": "1.1.0", + "@redis/client": "1.4.2", + "@redis/graph": "1.1.0", + "@redis/json": "1.0.4", + "@redis/search": "1.1.0", + "@redis/time-series": "1.0.4" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -7197,8 +7280,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yargs": { "version": "16.2.0", @@ -8501,6 +8583,46 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@redis/bloom": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.1.0.tgz", + "integrity": "sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ==", + "requires": {} + }, + "@redis/client": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz", + "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==", + "requires": { + "cluster-key-slot": "1.1.1", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + } + }, + "@redis/graph": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz", + "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==", + "requires": {} + }, + "@redis/json": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.4.tgz", + "integrity": "sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw==", + "requires": {} + }, + "@redis/search": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.0.tgz", + "integrity": "sha512-NyFZEVnxIJEybpy+YskjgOJRNsfTYqaPbK/Buv6W2kmFNaRk85JiqjJZA5QkRmWvGbyQYwoO5QfDi2wHskKrQQ==", + "requires": {} + }, + "@redis/time-series": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz", + "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==", + "requires": {} + }, "@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -9080,6 +9202,11 @@ "shallow-clone": "^3.0.0" } }, + "cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==" + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -10193,6 +10320,11 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, + "generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -11421,6 +11553,19 @@ "resolve": "^1.9.0" } }, + "redis": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.5.1.tgz", + "integrity": "sha512-oxXSoIqMJCQVBTfxP6BNTCtDMyh9G6Vi5wjdPdV/sRKkufyZslDqCScSGcOr6XGR/reAWZefz7E4leM31RgdBA==", + "requires": { + "@redis/bloom": "1.1.0", + "@redis/client": "1.4.2", + "@redis/graph": "1.1.0", + "@redis/json": "1.0.4", + "@redis/search": "1.1.0", + "@redis/time-series": "1.0.4" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -12438,8 +12583,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yargs": { "version": "16.2.0", diff --git a/package.json b/package.json index b9e5bde..9bd3e28 100644 --- a/package.json +++ b/package.json @@ -33,29 +33,30 @@ "express-rate-limit": "^6.0.1", "open": "^7.0.3", "rate-limiter-flexible": "^2.3.6", + "redis": "^4.5.1", "regenerator-runtime": "^0.13.9", "socket.io": "^4.5.3", "socket.io-client": "^4.5.4" }, "devDependencies": { - "acorn": "^8.6.0", - "eslint-config-standard": "^16.0.3", - "eslint": "^7.12.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^5.0.0", - "jasmine-browser-runner": "^1.0.0", - "jasmine-core": "^4.0.1", - "karma": "^6.3.16", - "karma-chrome-launcher": "^3.1.0", - "jasmine": "^3.5.0", - "karma-jasmine": "^4.0.1", - "babel-eslint": "^10.1.0", "@babel/core": "^7.18.13", "@babel/eslint-parser": "^7.16.5", "@babel/plugin-transform-object-assign": "^7.16.5", "@babel/preset-env": "^7.16.5", + "acorn": "^8.6.0", + "babel-eslint": "^10.1.0", "core-js": "^3.25.0", + "eslint": "^7.12.1", + "eslint-config-standard": "^16.0.3", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^5.0.0", + "jasmine": "^3.5.0", + "jasmine-browser-runner": "^1.0.0", + "jasmine-core": "^4.0.1", + "karma": "^6.3.16", + "karma-chrome-launcher": "^3.1.0", + "karma-jasmine": "^4.0.1", "webpack": "^5.65.0", "webpack-cli": "^4.9.1", "webpack-remove-debug": "^0.1.0" diff --git a/server/config/globals.js b/server/config/globals.js index aaa7e8a..cfbeb9c 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -1,5 +1,6 @@ const globals = { ACCESS_CODE_CHAR_POOL: 'BCDFGHJKLMNPQRSTVWXYZ23456789', + INSTANCE_ID_CHAR_POOL: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', ACCESS_CODE_LENGTH: 4, ACCESS_CODE_GENERATION_ATTEMPTS: 50, CLOCK_TICK_INTERVAL_MILLIS: 100, @@ -9,6 +10,9 @@ const globals = { GOOD: 'good', EVIL: 'evil' }, + REDIS_CHANNELS: { + ACTIVE_GAME_STREAM: 'active_game_stream' + }, CORS: process.env.NODE_ENV?.trim() === 'development' ? { origin: '*', @@ -31,6 +35,7 @@ const globals = { IN_GAME_MESSAGE: 'inGameMessage' }, EVENT_IDS: { + NEW_GAME: 'newGame', FETCH_GAME_STATE: 'fetchGameState', START_GAME: 'startGame', PAUSE_TIMER: 'pauseTimer', @@ -41,7 +46,9 @@ const globals = { TRANSFER_MODERATOR: 'transferModerator', CHANGE_NAME: 'changeName', END_GAME: 'endGame', - RESTART_GAME: 'restartGame' + RESTART_GAME: 'restartGame', + PLAYER_JOINED: 'playerJoined', + SPECTATOR_JOINED: 'spectatorJoined' }, MESSAGES: { ENTER_NAME: 'Client must enter name.' @@ -52,6 +59,7 @@ const globals = { ENDED: 'ended' }, USER_SIGNATURE_LENGTH: 25, + INSTANCE_ID_LENGTH: 75, USER_TYPES: { MODERATOR: 'moderator', PLAYER: 'player', diff --git a/server/modules/ServerBootstrapper.js b/server/modules/ServerBootstrapper.js index 1b84068..01f90ac 100644 --- a/server/modules/ServerBootstrapper.js +++ b/server/modules/ServerBootstrapper.js @@ -12,13 +12,13 @@ const rateLimit = require('express-rate-limit').default; const ServerBootstrapper = { - singletons: (logger) => { + singletons: (logger, instanceId) => { return { - activeGameRunner: new ActiveGameRunner(logger), - socketManager: new SocketManager(logger, ActiveGameRunner.instance), + activeGameRunner: new ActiveGameRunner(logger, instanceId), + socketManager: new SocketManager(logger, ActiveGameRunner.instance, instanceId), gameManager: process.env.NODE_ENV.trim() === 'development' - ? new GameManager(logger, ENVIRONMENT.LOCAL, ActiveGameRunner.instance) - : new GameManager(logger, ENVIRONMENT.PRODUCTION, ActiveGameRunner.instance) + ? new GameManager(logger, ENVIRONMENT.LOCAL, ActiveGameRunner.instance, instanceId) + : new GameManager(logger, ENVIRONMENT.PRODUCTION, ActiveGameRunner.instance, instanceId) }; }, diff --git a/server/modules/singletons/ActiveGameRunner.js b/server/modules/singletons/ActiveGameRunner.js index e521005..b4e0305 100644 --- a/server/modules/singletons/ActiveGameRunner.js +++ b/server/modules/singletons/ActiveGameRunner.js @@ -1,19 +1,53 @@ const { fork } = require('child_process'); const path = require('path'); const globals = require('../../config/globals'); +const redis = require('redis'); class ActiveGameRunner { - constructor (logger) { + 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.activeGames = new Map(); this.timerThreads = {}; this.logger = logger; + this.client = redis.createClient(); + this.publisher = null; + this.subscriber = null; + this.instanceId = instanceId; ActiveGameRunner.instance = this; } + refreshActiveGames = async () => { + await this.client.hGetAll('activeGames').then(async (r) => { + this.activeGames = new Map(Object.entries(r).map(([k, v]) => { + return [k, JSON.parse(v)]; + })); + }); + } + + 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); + let messageComponents = message.split(';'); + if (messageComponents[messageComponents.length - 1] === this.instanceId) { + this.logger.trace('Disregarding self-authored message'); + return; + } + const game = this.activeGames.get(messageComponents[0]); + let args; + if (messageComponents[2]) { + args = JSON.parse(messageComponents[2]); + } + if (game || messageComponents[1] === globals.EVENT_IDS.NEW_GAME) { + await socketManager.handleEventById(messageComponents[1], game, null, gameManager, game?.accessCode || messageComponents[0], args ? args : null, null) + } + }); + 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. */ diff --git a/server/modules/singletons/GameManager.js b/server/modules/singletons/GameManager.js index 05c8282..de1ae7d 100644 --- a/server/modules/singletons/GameManager.js +++ b/server/modules/singletons/GameManager.js @@ -4,9 +4,11 @@ const Person = require('../../model/Person'); const GameStateCurator = require('../GameStateCurator'); const UsernameGenerator = require('../UsernameGenerator'); const GameCreationRequest = require('../../model/GameCreationRequest'); +const redis = require('redis'); + class GameManager { - constructor (logger, environment, activeGameRunner) { + constructor (logger, environment, activeGameRunner, instanceId) { if (GameManager.instance) { throw new Error('The server tried to instantiate more than one GameManager'); } @@ -15,16 +17,29 @@ class GameManager { this.environment = environment; this.activeGameRunner = activeGameRunner; this.namespace = null; + this.publisher = null; + this.instanceId = instanceId; GameManager.instance = this; } + createRedisPublisher = async () => { + this.publisher = redis.createClient(); + await this.publisher.connect(); + this.logger.info('GAME MANAGER - CREATED GAME SYNC PUBLISHER'); + } + setGameSocketNamespace = (namespace) => { this.namespace = namespace; }; - createGame = (gameParams) => { + refreshGame = async (game) => { + this.logger.debug('PUSHING REFRESH OF ' + game.accessCode); + await this.activeGameRunner.client.hSet('activeGames', game.accessCode, JSON.stringify(game)); + } + + createGame = async (gameParams) => { this.logger.debug('Received request to create new game.'); - return GameCreationRequest.validate(gameParams).then(() => { + return GameCreationRequest.validate(gameParams).then(async () => { const req = new GameCreationRequest( gameParams.deck, gameParams.hasTimer, @@ -32,8 +47,8 @@ class GameManager { gameParams.moderatorName, gameParams.hasDedicatedModerator ); - this.pruneStaleGames(); - const newAccessCode = this.generateAccessCode(globals.ACCESS_CODE_CHAR_POOL); + await this.pruneStaleGames(); + const newAccessCode = await this.generateAccessCode(globals.ACCESS_CODE_CHAR_POOL); if (newAccessCode === null) { return Promise.reject(globals.ERROR_MESSAGE.NO_UNIQUE_ACCESS_CODE); } @@ -56,26 +71,34 @@ class GameManager { ); this.activeGameRunner.activeGames.set(newAccessCode, newGame); + await this.activeGameRunner.client.hSet('activeGames', newAccessCode, JSON.stringify(newGame)); + + this.publisher?.publish( + globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, + newGame.accessCode + ';' + globals.EVENT_IDS.NEW_GAME + ';' + JSON.stringify({ newGame: newGame }) + ';' + this.instanceId + ); return Promise.resolve({ accessCode: newAccessCode, cookie: moderator.cookie, environment: this.environment }); }).catch((message) => { + console.log(message); this.logger.debug('Received invalid request to create new game.'); return Promise.reject(message); }); }; - startGame = (game, namespace) => { + startGame = async (game, namespace) => { if (game.isFull) { game.status = globals.STATUS.IN_PROGRESS; if (game.hasTimer) { game.timerParams.paused = true; this.activeGameRunner.runGame(game, namespace); } + await this.refreshGame(game); namespace.in(game.accessCode).emit(globals.EVENT_IDS.START_GAME); } }; - pauseTimer = (game, logger) => { + pauseTimer = async (game, logger) => { const thread = this.activeGameRunner.timerThreads[game.accessCode]; if (thread && !thread.killed) { this.logger.debug('Timer thread found for game ' + game.accessCode); @@ -87,7 +110,8 @@ class GameManager { } }; - resumeTimer = (game, logger) => { + resumeTimer = async (game, logger) => { + await this.activeGameRunner.refreshActiveGames(); const thread = this.activeGameRunner.timerThreads[game.accessCode]; if (thread && !thread.killed) { this.logger.debug('Timer thread found for game ' + game.accessCode); @@ -99,52 +123,43 @@ class GameManager { } }; - getTimeRemaining = (game, socket) => { - const thread = this.activeGameRunner.timerThreads[game.accessCode]; - if (thread && (!thread.killed && thread.exitCode === null)) { - thread.send({ - command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, - accessCode: game.accessCode, - socketId: socket.id, - logLevel: this.logger.logLevel - }); - } else if (thread) { - if (game.timerParams && game.timerParams.timeRemaining === 0) { - this.namespace.to(socket.id).emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); + getTimeRemaining = async (game, socketId) => { + if (socketId) { + await this.activeGameRunner.refreshActiveGames(); + const thread = this.activeGameRunner.timerThreads[game.accessCode]; + if (thread && (!thread.killed && thread.exitCode === null)) { + thread.send({ + command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, + accessCode: game.accessCode, + socketId: socketId, + logLevel: this.logger.logLevel + }); + } else if (thread) { + if (game.timerParams && game.timerParams.timeRemaining === 0) { + this.namespace.to(socketId).emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); + } } } }; - revealPlayer = (game, personId) => { + revealPlayer = async (game, personId) => { const person = game.people.find((person) => person.id === personId); if (person && !person.revealed) { this.logger.debug('game ' + game.accessCode + ': revealing player ' + person.name); person.revealed = true; + await this.refreshGame(game); this.namespace.in(game.accessCode).emit( globals.EVENT_IDS.REVEAL_PLAYER, { id: person.id, gameRole: person.gameRole, alignment: person.alignment - }); + } + ); } }; - changeName = (game, data, ackFn) => { - const person = findPersonByField(game, 'id', data.personId); - if (person) { - if (!isNameTaken(game, data.name)) { - ackFn('changed'); - person.name = data.name.trim(); - person.hasEnteredName = true; - this.namespace.in(game.accessCode).emit(globals.EVENT_IDS.CHANGE_NAME, person.id, person.name); - } else { - ackFn('taken'); - } - } - }; - - endGame = (game) => { + endGame = async (game) => { game.status = globals.STATUS.ENDED; if (this.activeGameRunner.timerThreads[game.accessCode]) { this.logger.trace('KILLING TIMER PROCESS FOR ENDED GAME ' + game.accessCode); @@ -153,10 +168,11 @@ class GameManager { for (const person of game.people) { person.revealed = true; } + await this.refreshGame(game); this.namespace.in(game.accessCode).emit(globals.EVENT_IDS.END_GAME, GameStateCurator.mapPeopleForModerator(game.people)); }; - checkAvailability = (code) => { + checkAvailability = async (code) => { const game = this.activeGameRunner.activeGames.get(code.toUpperCase().trim()); if (game) { return Promise.resolve({ accessCode: code, playerCount: getGameSize(game.deck), timerParams: game.timerParams }); @@ -165,7 +181,7 @@ class GameManager { } }; - generateAccessCode = (charPool) => { + generateAccessCode = async (charPool) => { const charCount = charPool.length; let codeDigits, accessCode; let attempts = 0; @@ -184,7 +200,7 @@ class GameManager { : accessCode; }; - transferModeratorPowers = (socket, game, person, namespace, logger) => { + transferModeratorPowers = async (socketId, game, person, namespace, logger) => { if (person && (person.out || person.userType === globals.USER_TYPES.SPECTATOR)) { let spectatorsUpdated = false; if (game.spectators.includes(person)) { @@ -195,7 +211,9 @@ class GameManager { if (game.moderator === person) { person.userType = globals.USER_TYPES.MODERATOR; this.namespace.to(person.socketId).emit(globals.EVENTS.SYNC_GAME_STATE); - socket.to(game.accessCode).emit(globals.EVENT_IDS.KILL_PLAYER, person.id); + if (socketId) { + this.namespace.sockets.get(socketId).to(game.accessCode).emit(globals.EVENT_IDS.KILL_PLAYER, person.id); + } } else { const oldModerator = game.moderator; if (game.moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { @@ -218,10 +236,11 @@ class GameManager { this.namespace.to(person.socketId).emit(globals.EVENTS.SYNC_GAME_STATE); this.namespace.to(oldModerator.socketId).emit(globals.EVENTS.SYNC_GAME_STATE); } + await this.refreshGame(game); } }; - killPlayer = (socket, game, person, namespace, logger) => { + killPlayer = async (socketId, game, person, namespace, logger) => { if (person && !person.out) { logger.debug('game ' + game.accessCode + ': killing player ' + person.name); if (person.userType !== globals.USER_TYPES.TEMPORARY_MODERATOR) { @@ -230,14 +249,15 @@ class GameManager { person.out = true; // temporary moderators will transfer their powers automatically to the first person they kill. if (game.moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { - this.transferModeratorPowers(socket, game, person, namespace, logger); + await this.transferModeratorPowers(socketId, game, person, namespace, logger); } else { + await this.refreshGame(game); namespace.in(game.accessCode).emit(globals.EVENT_IDS.KILL_PLAYER, person.id); } } }; - joinGame = (game, name, cookie, joinAsSpectator) => { + joinGame = async (game, name, cookie, joinAsSpectator) => { const matchingPerson = findPersonByField(game, 'cookie', cookie); if (matchingPerson) { return Promise.resolve(matchingPerson.cookie); @@ -248,7 +268,7 @@ class GameManager { if (joinAsSpectator && game.spectators.length === globals.MAX_SPECTATORS) { return Promise.reject({ status: 400, reason: 'There are too many people already spectating.' }); } else if (joinAsSpectator) { - return addSpectator(game, name, this.logger, this.namespace); + return await addSpectator(game, name, this.logger, this.namespace, this.publisher, this.instanceId, this.refreshGame); } const unassignedPerson = game.moderator.assigned === false ? game.moderator @@ -257,7 +277,12 @@ class GameManager { this.logger.trace('request from client to join game. Assigning: ' + unassignedPerson.name); unassignedPerson.assigned = true; unassignedPerson.name = name; - game.isFull = isGameFull(game); + game.isFull = this.isGameFull(game); + await this.refreshGame(game); + await this.publisher?.publish( + globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, + game.accessCode + ';' + globals.EVENT_IDS.PLAYER_JOINED + ';' + JSON.stringify(unassignedPerson) + ';' + this.instanceId + ); this.namespace.in(game.accessCode).emit( globals.EVENTS.PLAYER_JOINED, GameStateCurator.mapPerson(unassignedPerson), @@ -268,7 +293,7 @@ class GameManager { if (game.spectators.length === globals.MAX_SPECTATORS) { return Promise.reject({ status: 400, reason: 'This game has reached the maximum number of players and spectators.' }); } - return addSpectator(game, name, this.logger, this.namespace); + return await addSpectator(game, name, this.logger, this.namespace, this.publisher, this.instanceId, this.refreshGame); } }; @@ -322,50 +347,32 @@ class GameManager { this.activeGameRunner.runGame(game, namespace); } + await this.refreshGame(game); namespace.in(game.accessCode).emit(globals.EVENT_IDS.RESTART_GAME); }; - handleRequestForGameState = async (game, namespace, logger, gameRunner, accessCode, personCookie, ackFn, clientSocket) => { + handleRequestForGameState = async (game, namespace, logger, gameRunner, accessCode, personCookie, ackFn, socketId) => { const matchingPerson = findPersonByField(game, 'cookie', personCookie); if (matchingPerson) { - if (matchingPerson.socketId === clientSocket.id) { + if (matchingPerson.socketId === socketId) { logger.debug('matching person found with an established connection to the room: ' + matchingPerson.name); - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson)); + if (ackFn) { + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson)); + } } else { logger.debug('matching person found with a new connection to the room: ' + matchingPerson.name); - clientSocket.join(accessCode); - matchingPerson.socketId = clientSocket.id; - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson)); - } - } else { - rejectClientRequestForGameState(ackFn); - } - }; - - removeClientFromLobbyIfApplicable (socket) { - socket.rooms.forEach((room) => { - if (this.activeGameRunner.activeGames.get(room)) { - this.logger.trace('disconnected socket is in a game'); - const game = this.activeGameRunner.activeGames.get(room); - if (game.status === globals.STATUS.LOBBY) { - const matchingPlayer = findPlayerBySocketId(game.people, socket.id); - if (matchingPlayer) { - this.logger.trace('un-assigning disconnected player: ' + matchingPlayer.name); - matchingPlayer.assigned = false; - matchingPlayer.socketId = null; - matchingPlayer.cookie = createRandomId(); - matchingPlayer.hasEnteredName = false; - socket.to(game.accessCode).emit( - globals.EVENTS.PLAYER_LEFT, - GameStateCurator.mapPerson(matchingPlayer) - ); - game.isFull = isGameFull(game); - matchingPlayer.name = UsernameGenerator.generate(); - } + this.namespace.sockets.get(socketId).join(accessCode); + matchingPerson.socketId = socketId; + if (ackFn) { + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson)); } } - }); - } + } else { + if (ackFn) { + rejectClientRequestForGameState(ackFn); + } + } + }; /* -- To shuffle an array a of n elements (indices 0..n-1): @@ -384,8 +391,9 @@ class GameManager { return array; }; - pruneStaleGames = () => { - this.activeGameRunner.activeGames.forEach((value, key) => { + pruneStaleGames = async () => { + await this.activeGameRunner.refreshActiveGames(); + this.activeGameRunner.activeGames.forEach((key, value) => { if (value.createTime) { const createDate = new Date(value.createTime); if (createDate.setHours(createDate.getHours() + globals.STALE_GAME_HOURS) < Date.now()) { @@ -400,6 +408,10 @@ class GameManager { } }); }; + + isGameFull = (game) => { + return game.moderator.assigned === true && !game.people.find((person) => person.assigned === false); + } } function getRandomInt (max) { @@ -471,10 +483,6 @@ function findPlayerBySocketId (people, socketId) { return people.find((person) => person.socketId === socketId && person.userType === globals.USER_TYPES.PLAYER); } -function isGameFull (game) { - return game.moderator.assigned === true && !game.people.find((person) => person.assigned === false); -} - function findPersonByField (game, fieldName, value) { let person; if (value === game.moderator[fieldName]) { @@ -505,7 +513,7 @@ function getGameSize (cards) { return quantity; } -function addSpectator (game, name, logger, namespace) { +async function addSpectator (game, name, logger, namespace, publisher, instanceId, refreshGame) { const spectator = new Person( createRandomId(), createRandomId(), @@ -514,10 +522,15 @@ function addSpectator (game, name, logger, namespace) { ); logger.trace('new spectator: ' + spectator.name); game.spectators.push(spectator); + await refreshGame(game); namespace.in(game.accessCode).emit( globals.EVENTS.UPDATE_SPECTATORS, game.spectators.map((spectator) => { return GameStateCurator.mapPerson(spectator); }) ); + publisher.publish( + globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, + game.accessCode + ';' + globals.EVENT_IDS.SPECTATOR_JOINED + ';' + JSON.stringify(spectator) + ';' + instanceId +); return Promise.resolve(spectator.cookie); } diff --git a/server/modules/singletons/SocketManager.js b/server/modules/singletons/SocketManager.js index 505f758..5fc5f97 100644 --- a/server/modules/singletons/SocketManager.js +++ b/server/modules/singletons/SocketManager.js @@ -1,16 +1,20 @@ const globals = require('../../config/globals'); const EVENT_IDS = globals.EVENT_IDS; const { RateLimiterMemory } = require('rate-limiter-flexible'); +const redis = require('redis'); +const GameStateCurator = require("../GameStateCurator"); class SocketManager { - constructor (logger, activeGameRunner) { + constructor (logger, activeGameRunner, instanceId) { if (SocketManager.instance) { throw new Error('The server attempted to instantiate more than one SocketManager.'); } logger.info('CREATING SINGLETON SOCKET MANAGER'); this.logger = logger; this.io = null; + this.publisher = null; this.activeGameRunner = activeGameRunner; + this.instanceId = instanceId; SocketManager.instance = this; } @@ -18,6 +22,12 @@ class SocketManager { this.io?.emit(globals.EVENTS.BROADCAST, message); }; + createRedisPublisher = async () => { + this.publisher = redis.createClient(); + await this.publisher.connect(); + this.logger.info('SOCKET MANAGER - CREATED GAME SYNC PUBLISHER'); + } + createSocketServer = (main, app, port, logger) => { let io; if (process.env.NODE_ENV.trim() === 'development') { @@ -54,60 +64,99 @@ class SocketManager { socket.on(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, async (eventId, accessCode, args = null, ackFn = null) => { const game = gameManager.activeGameRunner.activeGames.get(accessCode); if (game) { - switch (eventId) { - case EVENT_IDS.FETCH_GAME_STATE: - await gameManager.handleRequestForGameState( - game, - this.namespace, - this.logger, - gameManager.activeGameRunner, - accessCode, - args.personId, - ackFn, - socket - ); - break; - case EVENT_IDS.START_GAME: - gameManager.startGame(game, namespace); - ackFn(); - break; - case EVENT_IDS.PAUSE_TIMER: - gameManager.pauseTimer(game, this.logger); - break; - case EVENT_IDS.RESUME_TIMER: - gameManager.resumeTimer(game, this.logger); - break; - case EVENT_IDS.GET_TIME_REMAINING: - gameManager.getTimeRemaining(game, socket); - break; - case EVENT_IDS.KILL_PLAYER: - gameManager.killPlayer(socket, game, game.people.find((person) => person.id === args.personId), namespace, this.logger); - break; - case EVENT_IDS.REVEAL_PLAYER: - gameManager.revealPlayer(game, args.personId); - break; - case EVENT_IDS.TRANSFER_MODERATOR: - let person = game.people.find((person) => person.id === args.personId); - if (!person) { - person = game.spectators.find((spectator) => spectator.id === args.personId); - } - gameManager.transferModeratorPowers(socket, game, person, namespace, this.logger); - break; - case EVENT_IDS.CHANGE_NAME: - gameManager.changeName(game, args.data, ackFn); - break; - case EVENT_IDS.END_GAME: - gameManager.endGame(game); - ackFn(); - break; - default: - break; + await this.handleEventById(eventId, game, socket.id, gameManager, accessCode, args, ackFn); + /* This server should publish events initiated by a connected socket to Redis for consumption by other instances. */ + if (Object.values(EVENT_IDS).includes(eventId)) { + await gameManager.refreshGame(game); + this.publisher?.publish( + globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, + accessCode + ';' + eventId + ';' + JSON.stringify(args) + ';' + this.instanceId + ); } } else { ackFn(null); } }); }; + + handleEventById = async (eventId, game, socketId, gameManager, accessCode, args, ackFn) => { + this.logger.debug('ARGS TO HANDLER: ' + JSON.stringify(args)); + switch (eventId) { + case EVENT_IDS.NEW_GAME: + this.activeGameRunner.activeGames.set(accessCode, args.newGame); + break; + case EVENT_IDS.PLAYER_JOINED: + let toBeAssignedIndex = game.people.findIndex((person) => person.id === args.id && person.assigned === false); + if (toBeAssignedIndex >= 0) { + game.people[toBeAssignedIndex] = args; + game.isFull = gameManager.isGameFull(game); + gameManager.namespace.in(game.accessCode).emit( + globals.EVENTS.PLAYER_JOINED, + GameStateCurator.mapPerson(args), + game.isFull + ); + } + break; + case EVENT_IDS.SPECTATOR_JOINED: + if (!game.spectators.find((spectator) => spectator.id === args.id)) { + game.spectators.push(args); + } + gameManager.namespace.in(game.accessCode).emit( + globals.EVENTS.UPDATE_SPECTATORS, + game.spectators.map((spectator) => { return GameStateCurator.mapPerson(spectator); }) + ); + break; + case EVENT_IDS.FETCH_GAME_STATE: + if (!socketId) break; + await gameManager.handleRequestForGameState( + game, + this.namespace, + this.logger, + gameManager.activeGameRunner, + accessCode, + args.personId, + ackFn, + socketId + ); + break; + case EVENT_IDS.START_GAME: + await gameManager.startGame(game, gameManager.namespace); + if (ackFn) { + ackFn(); + } + break; + case EVENT_IDS.PAUSE_TIMER: + await gameManager.pauseTimer(game, this.logger); + break; + case EVENT_IDS.RESUME_TIMER: + await gameManager.resumeTimer(game, this.logger); + break; + case EVENT_IDS.GET_TIME_REMAINING: + await gameManager.getTimeRemaining(game, socketId); + break; + case EVENT_IDS.KILL_PLAYER: + await gameManager.killPlayer(socketId, game, game.people.find((person) => person.id === args.personId), gameManager.namespace, this.logger); + break; + case EVENT_IDS.REVEAL_PLAYER: + await gameManager.revealPlayer(game, args.personId); + break; + case EVENT_IDS.TRANSFER_MODERATOR: + let person = game.people.find((person) => person.id === args.personId); + if (!person) { + person = game.spectators.find((spectator) => spectator.id === args.personId); + } + await gameManager.transferModeratorPowers(socketId, game, person, gameManager.namespace, this.logger); + break; + case EVENT_IDS.END_GAME: + await gameManager.endGame(game); + if (ackFn) { + ackFn(); + } + break; + default: + break; + } + } } function registerRateLimiter (server, logger) {