diff --git a/client/src/config/globals.js b/client/src/config/globals.js index ebca36d..7eb3180 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -49,7 +49,8 @@ export const globals = { SYNC_GAME_STATE: 'syncGameState', START_TIMER: 'startTimer', PLAYER_LEFT: 'playerLeft', - NEW_SPECTATOR: 'newSpectator' + NEW_SPECTATOR: 'newSpectator', + RESTART_GAME: 'restartGame' }, USER_TYPES: { MODERATOR: 'moderator', diff --git a/client/src/images/shuffle.svg b/client/src/images/shuffle.svg new file mode 100644 index 0000000..54a4a2e --- /dev/null +++ b/client/src/images/shuffle.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/modules/front_end_components/HTMLFragments.js b/client/src/modules/front_end_components/HTMLFragments.js index 4a55af7..704f5ce 100644 --- a/client/src/modules/front_end_components/HTMLFragments.js +++ b/client/src/modules/front_end_components/HTMLFragments.js @@ -43,8 +43,8 @@ export const HTMLFragments = {

All players must join to start.

`, - END_GAME_PROMPT: - `
+ GAME_CONTROL_PROMPT: + `
`, PLAYER_GAME_VIEW: @@ -104,7 +104,7 @@ export const HTMLFragments = {
- +
@@ -215,22 +215,16 @@ export const HTMLFragments = { `

🏁 The moderator has ended the game. Roles are revealed.

-
- -
-
- - - -
+ + + +
`, - RESTART_GAME_BUTTON: - '', CREATE_GAME_DECK: `
diff --git a/client/src/modules/game_state/states/Ended.js b/client/src/modules/game_state/states/Ended.js index ea4e9fd..773c184 100644 --- a/client/src/modules/game_state/states/Ended.js +++ b/client/src/modules/game_state/states/Ended.js @@ -1,9 +1,6 @@ import { globals } from '../../../config/globals.js'; import { HTMLFragments } from '../../front_end_components/HTMLFragments.js'; -import { XHRUtility } from '../../utility/XHRUtility.js'; -import { UserUtility } from '../../utility/UserUtility.js'; -import { toast } from '../../front_end_components/Toast.js'; -import { Confirmation } from '../../front_end_components/Confirmation.js'; +import { SharedStateUtil } from './shared/SharedStateUtil.js'; export class Ended { constructor (containerId, stateBucket, socket) { @@ -11,25 +8,6 @@ export class Ended { this.socket = socket; this.container = document.getElementById(containerId); this.container.innerHTML = HTMLFragments.END_OF_GAME_VIEW; - this.restartGameHandler = () => { - XHRUtility.xhr( - '/api/games/' + this.stateBucket.currentGameState.accessCode + '/restart', - 'PATCH', - null, - JSON.stringify({ - playerName: this.stateBucket.currentGameState.client.name, - accessCode: this.stateBucket.currentGameState.accessCode, - sessionCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.LOCAL), - localCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.PRODUCTION) - }) - ) - .then((res) => { - toast('Game restarted!', 'success', true, true, 'medium'); - }) - .catch((res) => { - toast(res.content, 'error', true, true, 'medium'); - }); - }; } renderEndOfGame (gameState) { @@ -37,15 +15,7 @@ export class Ended { gameState.client.userType === globals.USER_TYPES.MODERATOR || gameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR ) { - const restartGameContainer = document.createElement('div'); - restartGameContainer.innerHTML = HTMLFragments.RESTART_GAME_BUTTON; - const button = restartGameContainer.querySelector('#restart-game'); - button.addEventListener('click', () => { - Confirmation('Restart the game, dealing everyone new roles?', () => { - this.restartGameHandler(); - }); - }); - document.getElementById('end-of-game-buttons').prepend(restartGameContainer); + document.getElementById('end-of-game-buttons').prepend(SharedStateUtil.createRestartButton(this.stateBucket)); } this.renderPlayersWithRoleInformation(); } diff --git a/client/src/modules/game_state/states/InProgress.js b/client/src/modules/game_state/states/InProgress.js index 1bd83d5..fcabca2 100644 --- a/client/src/modules/game_state/states/InProgress.js +++ b/client/src/modules/game_state/states/InProgress.js @@ -5,6 +5,7 @@ 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 { constructor (containerId, stateBucket, socket) { @@ -407,9 +408,9 @@ function removeExistingPlayerElements (killPlayerHandlers, revealRoleHandlers) { } function createEndGamePromptComponent (socket, stateBucket) { - if (document.querySelector('#end-game-prompt') === null) { + if (document.querySelector('#game-control-prompt') === null) { const div = document.createElement('div'); - div.innerHTML = HTMLFragments.END_GAME_PROMPT; + div.innerHTML = HTMLFragments.GAME_CONTROL_PROMPT; div.querySelector('#end-game-button').addEventListener('click', (e) => { e.preventDefault(); Confirmation('End the game?', () => { @@ -419,11 +420,12 @@ function createEndGamePromptComponent (socket, stateBucket) { stateBucket.currentGameState.accessCode, null, () => { - document.querySelector('#end-game-prompt')?.remove(); + document.querySelector('#game-control-prompt')?.remove(); } ); }); }); + div.querySelector('#game-control-prompt').prepend(SharedStateUtil.createRestartButton(stateBucket)); document.getElementById('game-content').appendChild(div); } } diff --git a/client/src/modules/game_state/states/shared/SharedStateUtil.js b/client/src/modules/game_state/states/shared/SharedStateUtil.js new file mode 100644 index 0000000..fdafeed --- /dev/null +++ b/client/src/modules/game_state/states/shared/SharedStateUtil.js @@ -0,0 +1,39 @@ +import { XHRUtility } from '../../../utility/XHRUtility.js'; +import { UserUtility } from '../../../utility/UserUtility.js'; +import { globals } from '../../../../config/globals.js'; +import { toast } from '../../../front_end_components/Toast.js'; +import { Confirmation } from '../../../front_end_components/Confirmation.js'; + +// This constant is meant to house logic that is utilized by more than one game state +export const SharedStateUtil = { + restartHandler: (stateBucket) => { + XHRUtility.xhr( + '/api/games/' + stateBucket.currentGameState.accessCode + '/restart', + 'PATCH', + null, + JSON.stringify({ + playerName: stateBucket.currentGameState.client.name, + accessCode: stateBucket.currentGameState.accessCode, + sessionCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.LOCAL), + localCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.PRODUCTION) + }) + ) + .then((res) => {}) + .catch((res) => { + toast(res.content, 'error', true, true, 'medium'); + }); + }, + createRestartButton: (stateBucket) => { + const restartGameButton = document.createElement('button'); + restartGameButton.classList.add('app-button'); + restartGameButton.setAttribute('id', 'restart-game-button'); + restartGameButton.innerText = 'Restart'; + restartGameButton.addEventListener('click', () => { + Confirmation('Restart the game, dealing everyone new roles?', () => { + SharedStateUtil.restartHandler(stateBucket); + }); + }); + + return restartGameButton; + } +}; diff --git a/client/src/modules/page_handlers/gameHandler.js b/client/src/modules/page_handlers/gameHandler.js index b2421ea..f1c982f 100644 --- a/client/src/modules/page_handlers/gameHandler.js +++ b/client/src/modules/page_handlers/gameHandler.js @@ -107,7 +107,7 @@ function processGameState ( break; case globals.STATUS.IN_PROGRESS: if (refreshPrompt) { - document.querySelector('#end-game-prompt')?.remove(); + document.querySelector('#game-control-prompt')?.remove(); } const inProgressGame = new InProgress('game-state-container', stateBucket, socket); inProgressGame.setSocketHandlers(); @@ -173,24 +173,39 @@ function displayClientInfo (name, userType) { // Should be reserved for socket events not specific to any one game state (Lobby, In Progress, etc.) function setClientSocketHandlers (stateBucket, socket) { - socket.on(globals.EVENT_IDS.START_GAME, () => { + const commonAckLogic = (gameState) => { + stateBucket.currentGameState = gameState; + processGameState( + stateBucket.currentGameState, + gameState.client.cookie, + socket, + true, + true + ); + }; + const startGameStateAckFn = (gameState) => { + commonAckLogic(gameState); + toast('Game started!', 'success'); + }; + + const restartGameStateAckFn = (gameState) => { + commonAckLogic(gameState); + toast('Game restarted!', 'success'); + }; + + const fetchGameStateHandler = (ackFn) => { socket.emit( globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.FETCH_GAME_STATE, stateBucket.currentGameState.accessCode, { personId: stateBucket.currentGameState.client.cookie }, - function (gameState) { - stateBucket.currentGameState = gameState; - processGameState( - stateBucket.currentGameState, - gameState.client.cookie, - socket, - true, - true - ); - } + ackFn ); - }); + }; + + socket.on(globals.EVENT_IDS.START_GAME, () => { fetchGameStateHandler(startGameStateAckFn); }); + + socket.on(globals.EVENT_IDS.RESTART_GAME, () => { fetchGameStateHandler(restartGameStateAckFn); }); socket.on(globals.EVENT_IDS.SYNC_GAME_STATE, () => { socket.emit( diff --git a/client/src/styles/game.css b/client/src/styles/game.css index c058c5e..d0894d9 100644 --- a/client/src/styles/game.css +++ b/client/src/styles/game.css @@ -55,8 +55,16 @@ max-width: 17em; } -#restart-game { - background-color: #0078D7; +#restart-game-button, #mod-transfer-button { + background-color: #045EA6; +} + +#restart-game-button:hover, #mod-transfer-button:hover { + background-color: #0078D773; + border: 2px solid #045EA6; +} + +#rrrr { min-width: 10em; margin-bottom: 1em !important; animation: shadow-pulse 1.5s infinite ease-out; @@ -127,7 +135,15 @@ h1 { #end-of-game-header button { margin: 0.5em; min-width: 12em; + font-size: 18px; } + +#end-of-game-header #restart-game-button { + margin-bottom: 1em !important; + animation: shadow-pulse 1.5s infinite ease-out; + padding: 10px; +} + .potential-moderator { display: flex; color: #d7d7d7; @@ -191,7 +207,7 @@ h1 { color: #21ba45; } -#role-info-button img { +#role-info-button img, #mod-transfer-button img { height: 25px; margin-left: 10px; } @@ -440,7 +456,7 @@ label[for='moderator'] { font-size: 30px; } -#start-game-prompt, #end-game-prompt { +#start-game-prompt, #game-control-prompt { padding: 0.5em 0; display: flex; flex-direction: column; @@ -463,6 +479,34 @@ label[for='moderator'] { background-color: #1b1a24; } +#game-control-prompt { + padding: 0.5em 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + position: fixed; + z-index: 3; + font-family: 'signika-negative', sans-serif; + font-weight: 100; + box-shadow: 0 -6px 15px rgba(0, 0, 0, 0.5); + left: 0; + right: 0; + bottom: 0; + border-radius: 3px; + /* width: fit-content; */ + font-size: 20px; + height: 100px; + margin: 0 auto; + max-width: 100%; + background-color: #1b1a24; +} + +#game-control-prompt button { + margin: 0 15px; + min-width: 5em; +} + #start-game-prompt p { color: whitesmoke; font-size: 15px; @@ -475,11 +519,11 @@ label[for='moderator'] { justify-content: space-evenly; } -#end-game-prompt { +#game-control-prompt { box-shadow: 0 -6px 40px black; } -#start-game-button, #end-game-button { +#start-game-button, #end-game-button, #restart-game-button { font-family: 'signika-negative', sans-serif !important; padding: 10px; border-radius: 3px; @@ -774,7 +818,7 @@ canvas { } @media(max-width: 800px) { - #start-game-prompt, #end-game-prompt { + #start-game-prompt, #game-control-prompt { border-radius: 0; width: 100%; bottom: 0; @@ -837,11 +881,11 @@ canvas { font-size: 20px; } - #start-game-prompt, #end-game-prompt { + #start-game-prompt, #game-control-prompt { height: 65px; } - #start-game-button, #end-game-button { + #start-game-button, #end-game-button, #restart-game-button { font-size: 20px; padding: 5px; } diff --git a/server/config/globals.js b/server/config/globals.js index 77d0dec..e8972f8 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -34,7 +34,8 @@ const globals = { REVEAL_PLAYER: 'revealPlayer', TRANSFER_MODERATOR: 'transferModerator', CHANGE_NAME: 'changeName', - END_GAME: 'endGame' + END_GAME: 'endGame', + RESTART_GAME: 'restartGame' }, MESSAGES: { ENTER_NAME: 'Client must enter name.' diff --git a/server/modules/ActiveGameRunner.js b/server/modules/ActiveGameRunner.js index 1749bee..9837842 100644 --- a/server/modules/ActiveGameRunner.js +++ b/server/modules/ActiveGameRunner.js @@ -21,6 +21,7 @@ class ActiveGameRunner { this.logger.debug('running 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', (msg) => { switch (msg.command) { case globals.GAME_PROCESS_COMMANDS.END_TIMER: @@ -52,8 +53,7 @@ class ActiveGameRunner { }); gameProcess.on('exit', () => { - this.logger.debug('Game ' + game.accessCode + ' timer has expired.'); - delete this.timerThreads[game.accessCode]; + this.logger.debug('Game timer thread ' + gameProcess.pid + ' exiting - game ' + game.accessCode); }); gameProcess.send({ command: globals.GAME_PROCESS_COMMANDS.START_TIMER, diff --git a/server/modules/GameManager.js b/server/modules/GameManager.js index c10834b..ef854b4 100644 --- a/server/modules/GameManager.js +++ b/server/modules/GameManager.js @@ -143,7 +143,6 @@ class GameManager { 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; @@ -266,9 +265,13 @@ class GameManager { restartGame = async (game, namespace) => { // kill any outstanding timer threads - if (this.activeGameRunner.timerThreads[game.accessCode]) { - this.logger.info('KILLING STALE TIMER PROCESS FOR ' + game.accessCode); - this.activeGameRunner.timerThreads[game.accessCode].kill(); + const subProcess = this.activeGameRunner.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.logger.debug('Deleting reference to subprocess ' + subProcess.pid); delete this.activeGameRunner.timerThreads[game.accessCode]; } @@ -322,7 +325,7 @@ class GameManager { this.activeGameRunner.runGame(game, namespace); } - namespace.in(game.accessCode).emit(globals.EVENT_IDS.START_GAME); + namespace.in(game.accessCode).emit(globals.EVENT_IDS.RESTART_GAME); }; handleRequestForGameState = async (game, namespace, logger, gameRunner, accessCode, personCookie, ackFn, clientSocket) => { diff --git a/spec/e2e/game_spec.js b/spec/e2e/game_spec.js index eed051d..012bd0f 100644 --- a/spec/e2e/game_spec.js +++ b/spec/e2e/game_spec.js @@ -290,7 +290,7 @@ describe('game page', () => { } ]); expect(document.getElementById('end-of-game-header')).not.toBeNull(); - expect(document.getElementById('restart-game')).not.toBeNull(); + expect(document.getElementById('restart-game-button')).not.toBeNull(); }); afterAll(() => { diff --git a/spec/unit/server/modules/GameManager_Spec.js b/spec/unit/server/modules/GameManager_Spec.js index b142c12..3484360 100644 --- a/spec/unit/server/modules/GameManager_Spec.js +++ b/spec/unit/server/modules/GameManager_Spec.js @@ -360,7 +360,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters, including when the game has a timer', async () => { @@ -387,7 +387,7 @@ describe('GameManager', () => { expect(runGameSpy).toHaveBeenCalled(); expect(Object.keys(gameManager.activeGameRunner.timerThreads).length).toEqual(0); expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters and preserve temporary moderator', async () => { @@ -408,7 +408,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters and restore a temporary moderator from a dedicated moderator', async () => { @@ -429,7 +429,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters and create a temporary mod if a dedicated mod transferred to a killed player', async () => { @@ -450,7 +450,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); });