diff --git a/.eslintrc.json b/.eslintrc.json index 1de264a..20490af 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,6 +21,7 @@ "no-void": ["error", { "allowAsStatement": true }], "no-prototype-builtins": "off", "no-undef": "off", + "no-case-declarations": "off", "no-return-assign": "warn", "prefer-promise-reject-errors": "warn", "no-trailing-spaces": "off", diff --git a/client/src/config/globals.js b/client/src/config/globals.js index 72f5e8b..fba64bc 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -42,6 +42,21 @@ export const globals = { PLAYER_LEFT: 'playerLeft', NEW_SPECTATOR: 'newSpectator' }, + SOCKET_EVENTS: { + IN_GAME_MESSAGE: 'inGameMessage' + }, + EVENT_IDS: { + FETCH_GAME_STATE: 'fetchGameState', + START_GAME: 'startGame', + PAUSE_TIMER: 'pauseTimer', + RESUME_TIMER: 'resumeTimer', + GET_TIME_REMAINING: 'getTimeRemaining', + KILL_PLAYER: 'killPlayer', + REVEAL_PLAYER: 'revealPlayer', + TRANSFER_MODERATOR: 'transferModerator', + CHANGE_NAME: 'changeName', + END_GAME: 'endGame' + }, USER_TYPES: { MODERATOR: 'moderator', PLAYER: 'player', diff --git a/client/src/images/tutorial/moderation-option.png b/client/src/images/tutorial/moderation-option.png index 0bf06d0..2763a47 100644 Binary files a/client/src/images/tutorial/moderation-option.png and b/client/src/images/tutorial/moderation-option.png differ diff --git a/client/src/images/tutorial/transfer-mod.gif b/client/src/images/tutorial/transfer-mod.gif index d6c4609..d43ffe6 100644 Binary files a/client/src/images/tutorial/transfer-mod.gif and b/client/src/images/tutorial/transfer-mod.gif differ diff --git a/client/src/modules/GameCreationStepManager.js b/client/src/modules/GameCreationStepManager.js index f674f45..f40e39d 100644 --- a/client/src/modules/GameCreationStepManager.js +++ b/client/src/modules/GameCreationStepManager.js @@ -305,7 +305,7 @@ function renderModerationTypeStep (game, containerId, stepNumber) { stepContainer.innerHTML = "
I will be the dedicated mod. Don't deal me a card.
" + - "
The first person out will mod. Deal me into the game.
"; + "
I will be the temporary mod. Deal me into the game.
"; const dedicatedOption = stepContainer.querySelector('#moderation-dedicated'); if (game.hasDedicatedModerator) { diff --git a/client/src/modules/GameStateRenderer.js b/client/src/modules/GameStateRenderer.js index 6b4791e..50e776a 100644 --- a/client/src/modules/GameStateRenderer.js +++ b/client/src/modules/GameStateRenderer.js @@ -17,7 +17,7 @@ export class GameStateRenderer { this.startGameHandler = (e) => { // TODO: prevent multiple emissions of this event (recommend converting to XHR) e.preventDefault(); if (confirm('Start the game and deal roles?')) { - socket.emit(globals.COMMANDS.START_GAME, this.stateBucket.currentGameState.accessCode); + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.START_GAME, stateBucket.currentGameState.accessCode); } }; this.restartGameHandler = (e) => { @@ -110,7 +110,6 @@ export class GameStateRenderer { QRCode.toCanvas(document.getElementById('canvas'), link, { scale: 2 }, function (error) { if (error) console.error(error); - console.log('success!'); }); const linkCopyHandler = (e) => { @@ -358,7 +357,7 @@ export class GameStateRenderer { } else if (!player.out && moderatorType) { killPlayerHandlers[player.id] = () => { if (confirm('KILL ' + player.name + '?')) { - socket.emit(globals.COMMANDS.KILL_PLAYER, accessCode, player.id); + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.KILL_PLAYER, accessCode, { personId: player.id }); } }; playerEl.querySelector('.kill-player-button').addEventListener('click', killPlayerHandlers[player.id]); @@ -373,7 +372,7 @@ export class GameStateRenderer { } else if (!player.revealed && moderatorType) { revealRoleHandlers[player.id] = () => { if (confirm('REVEAL ' + player.name + '?')) { - socket.emit(globals.COMMANDS.REVEAL_PLAYER, accessCode, player.id); + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.REVEAL_PLAYER, accessCode, { personId: player.id }); } }; playerEl.querySelector('.reveal-role-button').addEventListener('click', revealRoleHandlers[player.id]); @@ -404,7 +403,7 @@ function renderPotentialMods (gameState, group, transferModHandlers, socket) { if (transferPrompt !== null) { transferPrompt.innerHTML = ''; } - socket.emit(globals.COMMANDS.TRANSFER_MODERATOR, gameState.accessCode, member.id); + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.TRANSFER_MODERATOR, gameState.accessCode, { personId: member.id }); } } }; @@ -535,7 +534,8 @@ function createEndGamePromptComponent (socket, stateBucket) { e.preventDefault(); if (confirm('End the game?')) { socket.emit( - globals.COMMANDS.END_GAME, + globals.SOCKET_EVENTS.IN_GAME_MESSAGE, + globals.EVENT_IDS.END_GAME, stateBucket.currentGameState.accessCode ); } diff --git a/client/src/modules/GameTimerManager.js b/client/src/modules/GameTimerManager.js index 404cc08..4bb2089 100644 --- a/client/src/modules/GameTimerManager.js +++ b/client/src/modules/GameTimerManager.js @@ -4,10 +4,10 @@ export class GameTimerManager { constructor (stateBucket, socket) { this.stateBucket = stateBucket; this.playListener = () => { - socket.emit(globals.COMMANDS.RESUME_TIMER, this.stateBucket.currentGameState.accessCode); + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.RESUME_TIMER, this.stateBucket.currentGameState.accessCode); }; this.pauseListener = () => { - socket.emit(globals.COMMANDS.PAUSE_TIMER, this.stateBucket.currentGameState.accessCode); + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.PAUSE_TIMER, this.stateBucket.currentGameState.accessCode); }; } diff --git a/client/src/modules/Navbar.js b/client/src/modules/Navbar.js index 642b61a..1cd04f4 100644 --- a/client/src/modules/Navbar.js +++ b/client/src/modules/Navbar.js @@ -44,6 +44,7 @@ function getNavbarLinks (page = null, device) { 'Create' + 'How to Use' + 'Contact' + + 'Github' + 'Support the App'; } diff --git a/client/src/scripts/game.js b/client/src/scripts/game.js index d57bc22..024fbc1 100644 --- a/client/src/scripts/game.js +++ b/client/src/scripts/game.js @@ -50,7 +50,7 @@ function syncWithGame (stateBucket, gameTimerManager, gameStateRenderer, timerWo const splitUrl = window.location.href.split('/game/'); const accessCode = splitUrl[1]; if (/^[a-zA-Z0-9]+$/.test(accessCode) && accessCode.length === globals.ACCESS_CODE_LENGTH) { - socket.emit(globals.COMMANDS.FETCH_GAME_STATE, accessCode, cookie, function (gameState) { + 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 { @@ -138,7 +138,7 @@ function processGameState ( break; } if (currentGameState.timerParams) { - socket.emit(globals.COMMANDS.GET_TIME_REMAINING, currentGameState.accessCode); + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.GET_TIME_REMAINING, currentGameState.accessCode); } else { document.querySelector('#game-timer')?.remove(); document.querySelector('#timer-container-moderator')?.remove(); @@ -198,9 +198,10 @@ function setClientSocketHandlers (stateBucket, gameStateRenderer, socket, timerW socket.on(globals.EVENTS.START_GAME, () => { socket.emit( - globals.COMMANDS.FETCH_GAME_STATE, + globals.SOCKET_EVENTS.IN_GAME_MESSAGE, + globals.EVENT_IDS.FETCH_GAME_STATE, stateBucket.currentGameState.accessCode, - stateBucket.currentGameState.client.cookie, + { personId: stateBucket.currentGameState.client.cookie }, function (gameState) { stateBucket.currentGameState = gameState; processGameState( @@ -219,9 +220,10 @@ function setClientSocketHandlers (stateBucket, gameStateRenderer, socket, timerW socket.on(globals.EVENTS.SYNC_GAME_STATE, () => { socket.emit( - globals.COMMANDS.FETCH_GAME_STATE, + globals.SOCKET_EVENTS.IN_GAME_MESSAGE, + globals.EVENT_IDS.FETCH_GAME_STATE, stateBucket.currentGameState.accessCode, - stateBucket.currentGameState.client.cookie, + { personId: stateBucket.currentGameState.client.cookie }, function (gameState) { stateBucket.currentGameState = gameState; processGameState( diff --git a/client/src/styles/GLOBAL.css b/client/src/styles/GLOBAL.css index 17fa761..2eb8a8f 100644 --- a/client/src/styles/GLOBAL.css +++ b/client/src/styles/GLOBAL.css @@ -74,7 +74,7 @@ textarea { } .toast-bottom { - bottom: 90px; + bottom: 140px; } .toast-success { @@ -309,6 +309,10 @@ button { #how-to-use-container h1 { color: #4b6bfa; font-family: signika-negative, sans-serif; + background-color: #1e1b26; + width: fit-content; + padding: 0 5px; + border-radius: 3px; } input { @@ -355,8 +359,8 @@ input { color: #d7d7d7; display: flex; flex-direction: column; - margin: 0 auto; - width: 95%; + margin: 1em auto 0 auto; + width: 90%; max-width: 64em; line-height: 1.5; } @@ -782,7 +786,7 @@ input { @media(min-width: 551px) { .how-to-use-header { - font-size: 40px; + font-size: 35px; } #how-to-use-container h3 { font-size: 25px; diff --git a/index.js b/index.js index a473d39..6eff3be 100644 --- a/index.js +++ b/index.js @@ -1,11 +1,7 @@ 'use strict'; const express = require('express'); -const path = require('path'); const app = express(); -const GameManager = require('./server/modules/GameManager.js'); -const SocketManager = require('./server/modules/SocketManager.js'); -const globals = require('./server/config/globals'); const ServerBootstrapper = require('./server/modules/ServerBootstrapper'); app.use(express.json()); @@ -15,64 +11,17 @@ const args = ServerBootstrapper.processCLIArgs(); const logger = require('./server/modules/Logger')(args.logLevel); logger.info('LOG LEVEL IS: ' + args.logLevel); -const index = ServerBootstrapper.createServerWithCorrectHTTPProtocol(app, args.useHttps, args.port, logger); +const port = parseInt(process.env.PORT) || args.port || 8080; -app.set('port', parseInt(process.env.PORT) || args.port || 8080); +const webServer = ServerBootstrapper.createServerWithCorrectHTTPProtocol(app, args.useHttps, args.port, logger); +const singletons = ServerBootstrapper.singletons(logger); -const inGameSocketServer = ServerBootstrapper.createSocketServer(index, app, args.port, logger); -const gameNamespace = ServerBootstrapper.createGameSocketNamespace(inGameSocketServer, logger); +const socketServer = singletons.socketManager.createSocketServer(webServer, app, port); +singletons.gameManager.setGameSocketNamespace(singletons.socketManager.createGameSocketNamespace(socketServer, logger, singletons.gameManager)); +ServerBootstrapper.establishRouting(app, express); -let gameManager; +app.set('port', port); -/* Instantiate the singleton game manager */ -if (process.env.NODE_ENV.trim() === 'development') { - gameManager = new GameManager(logger, globals.ENVIRONMENT.LOCAL, gameNamespace).getInstance(); -} else { - gameManager = new GameManager(logger, globals.ENVIRONMENT.PRODUCTION, gameNamespace).getInstance(); -} - -/* Instantiate the singleton socket manager */ -const socketManager = new SocketManager(logger, inGameSocketServer).getInstance(); - -gameNamespace.on('connection', function (socket) { - socket.on('disconnecting', (reason) => { - logger.trace('client socket disconnecting because: ' + reason); - }); - gameManager.addGameSocketHandlers(gameNamespace, socket); -}); - -/* api endpoints */ -const games = require('./server/api/GamesAPI'); -const admin = require('./server/api/AdminAPI'); -app.use('/api/games', games); -app.use('/api/admin', admin); - -/* serve all the app's pages */ -app.use('/manifest.json', (req, res) => { - res.sendFile(path.join(__dirname, './manifest.json')); -}); - -app.use('/favicon.ico', (req, res) => { - res.sendFile(path.join(__dirname, './client/favicon_package/favicon.ico')); -}); - -const router = require('./server/routes/router'); -app.use('', router); - -app.use('/dist', express.static(path.join(__dirname, './client/dist'))); - -// set up routing for static content that isn't being bundled. -app.use('/images', express.static(path.join(__dirname, './client/src/images'))); -app.use('/styles', express.static(path.join(__dirname, './client/src/styles'))); -app.use('/webfonts', express.static(path.join(__dirname, './client/src/webfonts'))); -app.use('/robots.txt', (req, res) => { - res.sendFile(path.join(__dirname, './client/robots.txt')); -}); - -app.use(function (req, res) { - res.sendFile(path.join(__dirname, './client/src/views/404.html')); -}); - -index.listen(app.get('port'), function () { +webServer.listen(app.get('port'), function () { logger.info(`Starting server on port ${app.get('port')}`); }); diff --git a/server/config/globals.js b/server/config/globals.js index 75a3e84..77d0dec 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -21,7 +21,10 @@ const globals = { } }, STALE_GAME_HOURS: 24, - CLIENT_COMMANDS: { + SOCKET_EVENTS: { + IN_GAME_MESSAGE: 'inGameMessage' + }, + EVENT_IDS: { FETCH_GAME_STATE: 'fetchGameState', START_GAME: 'startGame', PAUSE_TIMER: 'pauseTimer', diff --git a/server/modules/GameManager.js b/server/modules/GameManager.js index d292f24..b17a6c9 100644 --- a/server/modules/GameManager.js +++ b/server/modules/GameManager.js @@ -6,159 +6,15 @@ const GameStateCurator = require('./GameStateCurator'); const UsernameGenerator = require('./UsernameGenerator'); class GameManager { - constructor (logger, environment, namespace) { + constructor (logger, environment) { this.logger = logger; this.environment = environment; this.activeGameRunner = new ActiveGameRunner(logger).getInstance(); - this.namespace = namespace; + this.namespace = null; } - addGameSocketHandlers = (namespace, socket) => { - socket.on(globals.CLIENT_COMMANDS.FETCH_GAME_STATE, async (accessCode, personId, ackFn) => { - this.logger.trace('request for game state for accessCode: ' + accessCode + ' from socket: ' + socket.id + ' with cookie: ' + personId); - await this.handleRequestForGameState( - this.namespace, - this.logger, - this.activeGameRunner, - accessCode, - personId, - ackFn, - socket - ); - }); - - socket.on(globals.CLIENT_COMMANDS.START_GAME, (accessCode) => { - const game = this.activeGameRunner.activeGames[accessCode]; - if (game && game.isFull) { - game.status = globals.STATUS.IN_PROGRESS; - if (game.hasTimer) { - game.timerParams.paused = true; - this.activeGameRunner.runGame(game, namespace); - } - namespace.in(accessCode).emit(globals.CLIENT_COMMANDS.START_GAME); - } - }); - - socket.on(globals.CLIENT_COMMANDS.PAUSE_TIMER, (accessCode) => { - this.logger.trace(accessCode); - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - const thread = this.activeGameRunner.timerThreads[accessCode]; - if (thread) { - this.logger.debug('Timer thread found for game ' + accessCode); - thread.send({ - command: globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER, - accessCode: game.accessCode, - logLevel: this.logger.logLevel - }); - } - } - }); - - socket.on(globals.CLIENT_COMMANDS.RESUME_TIMER, (accessCode) => { - this.logger.trace(accessCode); - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - const thread = this.activeGameRunner.timerThreads[accessCode]; - if (thread) { - this.logger.debug('Timer thread found for game ' + accessCode); - thread.send({ - command: globals.GAME_PROCESS_COMMANDS.RESUME_TIMER, - accessCode: game.accessCode, - logLevel: this.logger.logLevel - }); - } - } - }); - - socket.on(globals.CLIENT_COMMANDS.GET_TIME_REMAINING, (accessCode) => { - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - const thread = this.activeGameRunner.timerThreads[accessCode]; - if (thread) { - thread.send({ - command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, - accessCode: accessCode, - socketId: socket.id, - logLevel: this.logger.logLevel - }); - } else { - 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); - } - } - } - }); - - socket.on(globals.CLIENT_COMMANDS.KILL_PLAYER, (accessCode, personId) => { - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - const person = game.people.find((person) => person.id === personId); - this.killPlayer(game, person, namespace, this.logger); - } - }); - - socket.on(globals.CLIENT_COMMANDS.REVEAL_PLAYER, (accessCode, personId) => { - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - const person = game.people.find((person) => person.id === personId); - if (person && !person.revealed) { - this.logger.debug('game ' + accessCode + ': revealing player ' + person.name); - person.revealed = true; - namespace.in(accessCode).emit( - globals.CLIENT_COMMANDS.REVEAL_PLAYER, - { - id: person.id, - gameRole: person.gameRole, - alignment: person.alignment - }); - } - } - }); - - socket.on(globals.CLIENT_COMMANDS.TRANSFER_MODERATOR, (accessCode, personId) => { - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - let person = game.people.find((person) => person.id === personId); - if (!person) { - person = game.spectators.find((spectator) => spectator.id === personId); - } - this.transferModeratorPowers(game, person, namespace, this.logger); - } - }); - - socket.on(globals.CLIENT_COMMANDS.CHANGE_NAME, (accessCode, data, ackFn) => { - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - const person = findPersonByField(game, 'id', data.personId); - if (person) { - if (!isNameTaken(game, data.name)) { - ackFn('changed'); - person.name = data.name.trim(); - person.hasEnteredName = true; - namespace.in(accessCode).emit(globals.CLIENT_COMMANDS.CHANGE_NAME, person.id, person.name); - } else { - ackFn('taken'); - } - } - } - }); - - socket.on(globals.CLIENT_COMMANDS.END_GAME, (accessCode) => { - const game = this.activeGameRunner.activeGames[accessCode]; - if (game) { - game.status = globals.STATUS.ENDED; - if (this.activeGameRunner.timerThreads[accessCode]) { - this.logger.trace('KILLING TIMER PROCESS FOR ENDED GAME ' + accessCode); - this.activeGameRunner.timerThreads[accessCode].kill(); - delete this.activeGameRunner.timerThreads[accessCode]; - } - for (const person of game.people) { - person.revealed = true; - } - namespace.in(accessCode).emit(globals.CLIENT_COMMANDS.END_GAME, GameStateCurator.mapPeopleForModerator(game.people)); - } - }); + setGameSocketNamespace = (namespace) => { + this.namespace = namespace; }; createGame = (gameParams) => { @@ -196,6 +52,99 @@ class GameManager { } }; + startGame = (game, namespace) => { + if (game.isFull) { + game.status = globals.STATUS.IN_PROGRESS; + if (game.hasTimer) { + game.timerParams.paused = true; + this.activeGameRunner.runGame(game, namespace); + } + namespace.in(game.accessCode).emit(globals.EVENT_IDS.START_GAME); + } + }; + + pauseTimer = (game, logger) => { + const thread = this.activeGameRunner.timerThreads[game.accessCode]; + if (thread) { + this.logger.debug('Timer thread found for game ' + game.accessCode); + thread.send({ + command: globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER, + accessCode: game.accessCode, + logLevel: this.logger.logLevel + }); + } + }; + + resumeTimer = (game, logger) => { + const thread = this.activeGameRunner.timerThreads[game.accessCode]; + if (thread) { + this.logger.debug('Timer thread found for game ' + game.accessCode); + thread.send({ + command: globals.GAME_PROCESS_COMMANDS.RESUME_TIMER, + accessCode: game.accessCode, + logLevel: this.logger.logLevel + }); + } + }; + + getTimeRemaining = (game, socket) => { + const thread = this.activeGameRunner.timerThreads[game.accessCode]; + if (thread) { + thread.send({ + command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, + accessCode: game.accessCode, + socketId: socket.id, + logLevel: this.logger.logLevel + }); + } else { + 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); + } + } + }; + + revealPlayer = (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; + 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) => { + 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(); + delete this.activeGameRunner.timerThreads[game.accessCode]; + } + for (const person of game.people) { + person.revealed = true; + } + this.namespace.in(game.accessCode).emit(globals.EVENT_IDS.END_GAME, GameStateCurator.mapPeopleForModerator(game.people)); + }; + checkAvailability = (code) => { const game = this.activeGameRunner.activeGames[code.toUpperCase()]; if (game) { @@ -224,7 +173,7 @@ class GameManager { : accessCode; }; - transferModeratorPowers = (game, person, namespace, logger) => { + transferModeratorPowers = (game, person, logger) => { if (person && (person.out || person.userType === globals.USER_TYPES.SPECTATOR)) { logger.debug('game ' + game.accessCode + ': transferring mod powers to ' + person.name); if (game.moderator === person) { @@ -247,7 +196,7 @@ class GameManager { game.moderator = person; } - namespace.in(game.accessCode).emit(globals.EVENTS.SYNC_GAME_STATE); + this.namespace.in(game.accessCode).emit(globals.EVENTS.SYNC_GAME_STATE); } }; @@ -258,10 +207,10 @@ class GameManager { person.userType = globals.USER_TYPES.KILLED_PLAYER; } person.out = true; - namespace.in(game.accessCode).emit(globals.CLIENT_COMMANDS.KILL_PLAYER, person.id); + namespace.in(game.accessCode).emit(globals.EVENT_IDS.KILL_PLAYER, person.id); // temporary moderators will transfer their powers automatically to the first person they kill. if (game.moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { - this.transferModeratorPowers(game, person, namespace, logger); + this.transferModeratorPowers(game, person, logger); } } }; @@ -363,7 +312,7 @@ class GameManager { this.activeGameRunner.runGame(game, namespace); } - namespace.in(game.accessCode).emit(globals.CLIENT_COMMANDS.START_GAME); + namespace.in(game.accessCode).emit(globals.EVENT_IDS.START_GAME); }; handleRequestForGameState = async (namespace, logger, gameRunner, accessCode, personCookie, ackFn, clientSocket) => { @@ -553,10 +502,10 @@ function getGameSize (cards) { } class Singleton { - constructor (logger, environment, namespace) { + constructor (logger, environment) { if (!Singleton.instance) { logger.info('CREATING SINGLETON GAME MANAGER'); - Singleton.instance = new GameManager(logger, environment, namespace); + Singleton.instance = new GameManager(logger, environment); } } diff --git a/server/modules/ServerBootstrapper.js b/server/modules/ServerBootstrapper.js index ce52597..17b65d2 100644 --- a/server/modules/ServerBootstrapper.js +++ b/server/modules/ServerBootstrapper.js @@ -4,9 +4,21 @@ const https = require('https'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); -const { RateLimiterMemory } = require('rate-limiter-flexible'); +const SocketManager = require('./SocketManager.js'); +const GameManager = require('./GameManager.js'); +const { ENVIRONMENT } = require('../config/globals.js'); const ServerBootstrapper = { + + singletons: (logger) => { + return { + socketManager: new SocketManager(logger).getInstance(), + gameManager: process.env.NODE_ENV.trim() === 'development' + ? new GameManager(logger, ENVIRONMENT.LOCAL).getInstance() + : new GameManager(logger, ENVIRONMENT.PRODUCTION).getInstance() + }; + }, + processCLIArgs: () => { try { const args = Array.from(process.argv.map((arg) => arg.trim().toLowerCase())); @@ -80,46 +92,43 @@ const ServerBootstrapper = { return main; }, - createSocketServer: (main, app, port, logger) => { - let io; - if (process.env.NODE_ENV.trim() === 'development') { - io = require('socket.io')(main, { - cors: { origin: 'http://localhost:' + port } - }); - } else { - io = require('socket.io')(main, { - cors: { origin: 'https://playwerewolf.uk.r.appspot.com' } - }); - } + establishRouting: (app, express) => { + /* api endpoints */ + const games = require('../api/GamesAPI'); + const admin = require('../api/AdminAPI'); + app.use('/api/games', games); + app.use('/api/admin', admin); - registerRateLimiter(io, logger); + /* serve all the app's pages */ + app.use('/manifest.json', (req, res) => { + res.sendFile(path.join(__dirname, '../../manifest.json')); + }); - return io; - }, + app.use('/favicon.ico', (req, res) => { + res.sendFile(path.join(__dirname, '../../client/favicon_package/favicon.ico')); + }); - createGameSocketNamespace (server, logger) { - const namespace = server.of('/in-game'); - registerRateLimiter(namespace, logger); - return server.of('/in-game'); + app.use('/apple-touch-icon.png', (req, res) => { + res.sendFile(path.join(__dirname, '../../client/favicon_package/apple-touch-icon.png')); + }); + + const router = require('../routes/router'); + app.use('', router); + + app.use('/dist', express.static(path.join(__dirname, '../../client/dist'))); + + // set up routing for static content that isn't being bundled. + app.use('/images', express.static(path.join(__dirname, '../../client/src/images'))); + app.use('/styles', express.static(path.join(__dirname, '../../client/src/styles'))); + app.use('/webfonts', express.static(path.join(__dirname, '../../client/src/webfonts'))); + app.use('/robots.txt', (req, res) => { + res.sendFile(path.join(__dirname, '../../client/robots.txt')); + }); + + app.use(function (req, res) { + res.sendFile(path.join(__dirname, '../../client/src/views/404.html')); + }); } }; -function registerRateLimiter (server, logger) { - const rateLimiter = new RateLimiterMemory( - { - points: 10, - duration: 1 - }); - - server.use(async (socket, next) => { - try { - await rateLimiter.consume(socket.handshake.address); - logger.trace('consumed point from ' + socket.handshake.address); - next(); - } catch (rejection) { - next(new Error('Your connection has been blocked.')); - } - }); -} - module.exports = ServerBootstrapper; diff --git a/server/modules/SocketManager.js b/server/modules/SocketManager.js index 35bf0e0..0b25bb5 100644 --- a/server/modules/SocketManager.js +++ b/server/modules/SocketManager.js @@ -1,21 +1,127 @@ -const globals = require('../config/globals.js'); +const globals = require('../config/globals'); +const EVENT_IDS = globals.EVENT_IDS; +const { RateLimiterMemory } = require('rate-limiter-flexible'); class SocketManager { - constructor (logger, io) { + constructor (logger) { this.logger = logger; - this.io = io; + this.io = null; } broadcast = (message) => { - this.io.emit(globals.EVENTS.BROADCAST, message); + this.io?.emit(globals.EVENTS.BROADCAST, message); + }; + + createSocketServer = (main, app, port, logger) => { + let io; + if (process.env.NODE_ENV.trim() === 'development') { + io = require('socket.io')(main, { + cors: { origin: 'http://localhost:' + port } + }); + } else { + io = require('socket.io')(main, { + cors: { origin: 'https://play-werewolf.app' } + }); + } + + registerRateLimiter(io, logger); + this.io = io; + + return io; + }; + + createGameSocketNamespace = (server, logger, gameManager) => { + const namespace = server.of('/in-game'); + const registerHandlers = this.registerHandlers; + registerRateLimiter(namespace, logger); + namespace.on('connection', function (socket) { + socket.on('disconnecting', (reason) => { + logger.trace('client socket disconnecting because: ' + reason); + }); + + registerHandlers(namespace, socket, gameManager); + }); + return server.of('/in-game'); + }; + + registerHandlers = (namespace, socket, gameManager) => { + socket.on(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, async (eventId, accessCode, args, ackFn) => { + const game = gameManager.activeGameRunner.activeGames[accessCode]; + if (game) { + switch (eventId) { + case EVENT_IDS.FETCH_GAME_STATE: + await gameManager.handleRequestForGameState( + this.namespace, + this.logger, + gameManager.activeGameRunner, + accessCode, + args.personId, + ackFn, + socket + ); + break; + case EVENT_IDS.START_GAME: + gameManager.startGame(game, namespace); + 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(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(game, person, this.logger); + break; + case EVENT_IDS.CHANGE_NAME: + gameManager.changeName(game, args.data, ackFn); + break; + case EVENT_IDS.END_GAME: + gameManager.endGame(game); + break; + default: + break; + } + } + }); }; } +function registerRateLimiter (server, logger) { + const rateLimiter = new RateLimiterMemory( + { + points: 10, + duration: 1 + }); + + server.use(async (socket, next) => { + try { + await rateLimiter.consume(socket.handshake.address); + logger.trace('consumed point from ' + socket.handshake.address); + next(); + } catch (rejection) { + next(new Error('Your connection has been blocked.')); + } + }); +} + class Singleton { - constructor (logger, io) { + constructor (logger) { if (!Singleton.instance) { logger.info('CREATING SINGLETON SOCKET MANAGER'); - Singleton.instance = new SocketManager(logger, io); + Singleton.instance = new SocketManager(logger); } } diff --git a/spec/unit/server/modules/GameManager_Spec.js b/spec/unit/server/modules/GameManager_Spec.js index 2d172e8..11ae7fc 100644 --- a/spec/unit/server/modules/GameManager_Spec.js +++ b/spec/unit/server/modules/GameManager_Spec.js @@ -17,7 +17,8 @@ describe('GameManager', () => { const inObj = { emit: () => {} }; namespace = { in: () => { return inObj; } }; - gameManager = new GameManager(logger, globals.ENVIRONMENT.PRODUCTION, namespace).getInstance(); + gameManager = new GameManager(logger, globals.ENVIRONMENT.PRODUCTION).getInstance(); + gameManager.setGameSocketNamespace(namespace); }); beforeEach(() => { @@ -36,7 +37,7 @@ describe('GameManager', () => { false, moderator ); - gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + gameManager.transferModeratorPowers(game, personToTransferTo, logger); expect(game.moderator).toEqual(personToTransferTo); expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); @@ -55,7 +56,7 @@ describe('GameManager', () => { moderator ); game.spectators.push(personToTransferTo); - gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + gameManager.transferModeratorPowers(game, personToTransferTo, logger); expect(game.moderator).toEqual(personToTransferTo); expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); @@ -74,7 +75,7 @@ describe('GameManager', () => { false, tempMod ); - gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + gameManager.transferModeratorPowers(game, personToTransferTo, logger); expect(game.moderator).toEqual(personToTransferTo); expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); @@ -93,7 +94,7 @@ describe('GameManager', () => { false, tempMod ); - gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + gameManager.transferModeratorPowers(game, personToTransferTo, logger); expect(game.moderator).toEqual(personToTransferTo); expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); @@ -330,7 +331,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); }); it('should reset all relevant game parameters, including when the game has a timer', async () => { @@ -357,7 +358,7 @@ describe('GameManager', () => { expect(runGameSpy).toHaveBeenCalled(); expect(Object.keys(gameManager.activeGameRunner.timerThreads).length).toEqual(0); expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); }); it('should reset all relevant game parameters and preserve temporary moderator', async () => { @@ -378,7 +379,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); }); it('should reset all relevant game parameters and restore a temporary moderator from a dedicated moderator', async () => { @@ -399,7 +400,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); }); it('should reset all relevant game parameters and create a temporary mod if a dedicated mod transferred to a killed player', async () => { @@ -420,7 +421,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); }); }); });