diff --git a/client/src/config/globals.js b/client/src/config/globals.js index d22357f..72d5bb6 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -1,6 +1,6 @@ export const globals = { USER_SIGNATURE_LENGTH: 25, - CLOCK_TICK_INTERVAL_MILLIS: 10, + CLOCK_TICK_INTERVAL_MILLIS: 100, MAX_CUSTOM_ROLE_NAME_LENGTH: 30, MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH: 500, TOAST_DURATION_DEFAULT: 6, diff --git a/client/src/modules/GameCreationStepManager.js b/client/src/modules/GameCreationStepManager.js index 1d12537..1178ae0 100644 --- a/client/src/modules/GameCreationStepManager.js +++ b/client/src/modules/GameCreationStepManager.js @@ -107,13 +107,23 @@ export class GameCreationStepManager { 5: { title: 'Review and submit:', backHandler: this.defaultBackHandler, - forwardHandler: (deck, hasTimer, hasDedicatedModerator, moderatorName, timerParams) => { + forwardHandler: () => { + const button = document.getElementById('create-game'); + button.removeEventListener('click', this.steps['5'].forwardHandler); + button.classList.add('submitted'); + button.innerText = 'Creating'; XHRUtility.xhr( '/api/games/create', 'POST', null, JSON.stringify( - new Game(deck, hasTimer, hasDedicatedModerator, moderatorName, timerParams) + new Game( + this.currentGame.deck.filter((card) => card.quantity > 0), + this.currentGame.hasTimer, + this.currentGame.hasDedicatedModerator, + this.currentGame.moderatorName, + this.currentGame.timerParams + ) ) ) .then((res) => { @@ -128,9 +138,10 @@ export class GameCreationStepManager { } }).catch((e) => { const button = document.getElementById('create-game'); - button.innerText = 'Create Game'; + button.innerText = 'Create'; button.classList.remove('submitted'); - button.addEventListener('click', this.steps['4'].forwardHandler); + button.addEventListener('click', this.steps['5'].forwardHandler); + toast(e.content, 'error', true, true, 'medium'); if (e.status === 429) { toast('You\'ve sent this request too many times.', 'error', true, true, 'medium'); } @@ -449,18 +460,7 @@ function showButtons (back, forward, forwardHandler, backHandler, builtGame = nu createButton.innerText = 'Create'; createButton.setAttribute('id', 'create-game'); createButton.classList.add('app-button'); - createButton.addEventListener('click', () => { - createButton.removeEventListener('click', forwardHandler); - createButton.classList.add('submitted'); - createButton.innerText = 'Creating...'; - forwardHandler( - builtGame.deck.filter((card) => card.quantity > 0), - builtGame.hasTimer, - builtGame.hasDedicatedModerator, - builtGame.moderatorName, - builtGame.timerParams - ); - }); + createButton.addEventListener('click', forwardHandler); document.getElementById('tracker-container').appendChild(createButton); } } diff --git a/client/src/modules/GameStateRenderer.js b/client/src/modules/GameStateRenderer.js index fdc6c7a..1ae1bdd 100644 --- a/client/src/modules/GameStateRenderer.js +++ b/client/src/modules/GameStateRenderer.js @@ -2,6 +2,8 @@ import { globals } from '../config/globals.js'; import { toast } from './Toast.js'; import { HTMLFragments } from './HTMLFragments.js'; import { ModalManager } from './ModalManager.js'; +import { XHRUtility } from './XHRUtility.js'; +import { UserUtility } from './UserUtility.js'; export class GameStateRenderer { constructor (stateBucket, socket) { @@ -10,12 +12,40 @@ export class GameStateRenderer { this.killPlayerHandlers = {}; this.revealRoleHandlers = {}; this.transferModHandlers = {}; - this.startGameHandler = (e) => { + 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); } }; + this.restartGameHandler = (e) => { + e.preventDefault(); + const button = document.getElementById('restart-game'); + button.removeEventListener('click', this.restartGameHandler); + button.classList.add('submitted'); + button.innerText = 'Restarting...'; + 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) => { + const button = document.getElementById('restart-game'); + button.innerText = 'Run it back šŸ”„'; + button.classList.remove('submitted'); + button.addEventListener('click', this.restartGameHandler); + toast(res.content, 'error', true, true, 'medium'); + }); + }; } renderLobbyPlayers () { @@ -256,7 +286,17 @@ export class GameStateRenderer { } } - renderEndOfGame () { + renderEndOfGame (gameState) { + if ( + 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', this.restartGameHandler); + document.getElementById('end-of-game-buttons').appendChild(restartGameContainer); + } this.renderPlayersWithNoRoleInformationUnlessRevealed(); } } @@ -273,6 +313,10 @@ function renderPotentialMods (gameState, group, transferModHandlers, socket) { transferModHandlers[member.id] = (e) => { if (e.type === 'click' || e.code === 'Enter') { if (confirm('Transfer moderator powers to ' + member.name + '?')) { + const transferPrompt = document.getElementById('transfer-mod-prompt'); + if (transferPrompt !== null) { + transferPrompt.innerHTML = ''; + } socket.emit(globals.COMMANDS.TRANSFER_MODERATOR, gameState.accessCode, member.id); } } diff --git a/client/src/modules/GameTimerManager.js b/client/src/modules/GameTimerManager.js index 6313512..404cc08 100644 --- a/client/src/modules/GameTimerManager.js +++ b/client/src/modules/GameTimerManager.js @@ -135,6 +135,7 @@ export class GameTimerManager { const playBtn = document.createElement('img'); playBtn.setAttribute('src', '../images/play-button.svg'); playBtn.addEventListener('click', this.playListener); + document.querySelector('#play-pause-placeholder')?.remove(); document.getElementById('play-pause').appendChild(playBtn); } @@ -148,6 +149,7 @@ export class GameTimerManager { const pauseBtn = document.createElement('img'); pauseBtn.setAttribute('src', '../images/pause-button.svg'); pauseBtn.addEventListener('click', this.pauseListener); + document.querySelector('#play-pause-placeholder')?.remove(); document.getElementById('play-pause').appendChild(pauseBtn); } } diff --git a/client/src/modules/HTMLFragments.js b/client/src/modules/HTMLFragments.js index a5f8bc0..0bb0b8e 100644 --- a/client/src/modules/HTMLFragments.js +++ b/client/src/modules/HTMLFragments.js @@ -80,7 +80,7 @@ export const HTMLFragments = {
`, - MODERATOR_GAME_VIEW: + TRANSFER_MOD_MODAL: ` -
-
+
`, + MODERATOR_GAME_VIEW: + `
+
-
+
+
+
@@ -116,19 +119,8 @@ export const HTMLFragments = {
`, TEMP_MOD_GAME_VIEW: - ` - -
-
+ `
+
@@ -216,21 +208,27 @@ export const HTMLFragments = {
`, END_OF_GAME_VIEW: - `

The moderator has ended the game. Roles are revealed.

-
-
- -
-
- - - + `
+

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

+
+
+ +
+
`, + RESTART_GAME_BUTTON: + `
+ +
`, CREATE_GAME_DECK: `
diff --git a/client/src/scripts/game.js b/client/src/scripts/game.js index 53066ea..22b7f7a 100644 --- a/client/src/scripts/game.js +++ b/client/src/scripts/game.js @@ -59,7 +59,7 @@ function syncWithGame (stateBucket, gameTimerManager, gameStateRenderer, timerWo 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, gameStateRenderer, gameTimerManager, timerWorker); + processGameState(stateBucket.currentGameState, cookie, socket, gameStateRenderer, gameTimerManager, timerWorker, true, true); } }); } else { @@ -67,7 +67,28 @@ function syncWithGame (stateBucket, gameTimerManager, gameStateRenderer, timerWo } } -function processGameState (currentGameState, userId, socket, gameStateRenderer, gameTimerManager, timerWorker, refreshPrompt = true) { +function processGameState ( + currentGameState, + userId, + socket, + gameStateRenderer, + gameTimerManager, + timerWorker, + refreshPrompt = true, + animateContainer = false +) { + const containerAnimation = document.getElementById('game-state-container').animate( + [ + { opacity: '0', transform: 'translateY(10px)' }, + { opacity: '1', transform: 'translateY(0px)' } + ], { + duration: 500, + easing: 'ease-in-out', + fill: 'both' + }); + if (animateContainer) { + containerAnimation.play(); + } displayClientInfo(currentGameState.client.name, currentGameState.client.userType); if (refreshPrompt) { removeStartGameFunctionalityIfPresent(gameStateRenderer); @@ -102,10 +123,12 @@ function processGameState (currentGameState, userId, socket, gameStateRenderer, gameStateRenderer.renderPlayerView(true); break; case globals.USER_TYPES.MODERATOR: + document.getElementById('transfer-mod-prompt').innerHTML = HTMLFragments.TRANSFER_MOD_MODAL; document.getElementById('game-state-container').innerHTML = HTMLFragments.MODERATOR_GAME_VIEW; gameStateRenderer.renderModeratorView(); break; case globals.USER_TYPES.TEMPORARY_MODERATOR: + document.getElementById('transfer-mod-prompt').innerHTML = HTMLFragments.TRANSFER_MOD_MODAL; document.getElementById('game-state-container').innerHTML = HTMLFragments.TEMP_MOD_GAME_VIEW; gameStateRenderer.renderTempModView(); break; @@ -120,14 +143,14 @@ function processGameState (currentGameState, userId, socket, gameStateRenderer, socket.emit(globals.COMMANDS.GET_TIME_REMAINING, currentGameState.accessCode); } else { document.querySelector('#game-timer')?.remove(); + document.querySelector('#timer-container-moderator')?.remove(); document.querySelector('label[for="game-timer"]')?.remove(); } break; case globals.STATUS.ENDED: { const container = document.getElementById('game-state-container'); container.innerHTML = HTMLFragments.END_OF_GAME_VIEW; - container.classList.add('vertical-flex'); - gameStateRenderer.renderEndOfGame(); + gameStateRenderer.renderEndOfGame(currentGameState); break; } default: @@ -190,7 +213,9 @@ function setClientSocketHandlers (stateBucket, gameStateRenderer, socket, timerW socket, gameStateRenderer, gameTimerManager, - timerWorker + timerWorker, + true, + true ); } ); @@ -209,7 +234,9 @@ function setClientSocketHandlers (stateBucket, gameStateRenderer, socket, timerW socket, gameStateRenderer, gameTimerManager, - timerWorker + timerWorker, + true, + true ); } ); @@ -280,6 +307,7 @@ function setClientSocketHandlers (stateBucket, gameStateRenderer, socket, timerW gameStateRenderer, gameTimerManager, timerWorker, + false, false ); }); @@ -293,7 +321,9 @@ function setClientSocketHandlers (stateBucket, gameStateRenderer, socket, timerW socket, gameStateRenderer, gameTimerManager, - timerWorker + timerWorker, + true, + true ); }); } @@ -357,7 +387,7 @@ function activateRoleInfoButton (deck) { }); document.getElementById('role-info-button').addEventListener('click', (e) => { e.preventDefault(); - document.getElementById('prompt').innerHTML = HTMLFragments.ROLE_INFO_MODAL; + document.getElementById('role-info-prompt').innerHTML = HTMLFragments.ROLE_INFO_MODAL; const modalContent = document.getElementById('game-role-info-container'); for (const card of deck) { const roleDiv = document.createElement('div'); diff --git a/client/src/scripts/home.js b/client/src/scripts/home.js index a78f0a2..17a9a94 100644 --- a/client/src/scripts/home.js +++ b/client/src/scripts/home.js @@ -4,45 +4,50 @@ import { injectNavbar } from '../modules/Navbar.js'; const home = () => { injectNavbar(); - document.getElementById('join-form').onsubmit = (e) => { - e.preventDefault(); - const userCode = document.getElementById('room-code').value; - if (roomCodeIsValid(userCode)) { - attemptToJoinGame(userCode); - } else { - toast('Invalid code. Codes are 4 numbers or letters.', 'error', true, true); - } - }; + document.getElementById('join-form').addEventListener('submit', attemptToJoinGame); }; function roomCodeIsValid (code) { return typeof code === 'string' && /^[A-Z0-9]{4}$/.test(code.toUpperCase().trim()); } -function attemptToJoinGame (code) { - XHRUtility.xhr( - '/api/games/' + code.toUpperCase().trim() + '/availability', - 'GET', - null, - null - ) - .then((res) => { - if (res.status === 200) { - const json = JSON.parse(res.content); - window.location = window.location.protocol + '//' + window.location.host + - '/join/' + encodeURIComponent(json.accessCode) + - '?playerCount=' + encodeURIComponent(json.playerCount) + - '&timer=' + encodeURIComponent(getTimeString(json.timerParams)); - } - }).catch((res) => { - if (res.status === 404) { - toast('Game not found', 'error', true); - } else if (res.status === 400) { - toast(res.content, 'error', true); - } else { - toast('An unknown error occurred. Please try again later.', 'error', true); - } - }); +function attemptToJoinGame (event) { + event.preventDefault(); + const userCode = document.getElementById('room-code').value; + if (roomCodeIsValid(userCode)) { + const form = document.getElementById('join-form'); + form.removeEventListener('submit', attemptToJoinGame); + form.classList.add('submitted'); + document.getElementById('join-button')?.setAttribute('value', 'Joining...'); + XHRUtility.xhr( + '/api/games/' + userCode.toUpperCase().trim() + '/availability', + 'GET', + null, + null + ) + .then((res) => { + if (res.status === 200) { + const json = JSON.parse(res.content); + window.location = window.location.protocol + '//' + window.location.host + + '/join/' + encodeURIComponent(json.accessCode) + + '?playerCount=' + encodeURIComponent(json.playerCount) + + '&timer=' + encodeURIComponent(getTimeString(json.timerParams)); + } + }).catch((res) => { + form.addEventListener('submit', attemptToJoinGame); + form.classList.remove('submitted'); + document.getElementById('join-button')?.setAttribute('value', 'Join'); + if (res.status === 404) { + toast('Game not found', 'error', true); + } else if (res.status === 400) { + toast(res.content, 'error', true); + } else { + toast('An unknown error occurred. Please try again later.', 'error', true); + } + }); + } else { + toast('Invalid code. Codes are 4 numbers or letters.', 'error', true, true); + } } function getTimeString (timerParams) { diff --git a/client/src/styles/GLOBAL.css b/client/src/styles/GLOBAL.css index 2ff1451..f1cf65e 100644 --- a/client/src/styles/GLOBAL.css +++ b/client/src/styles/GLOBAL.css @@ -607,7 +607,7 @@ input { left: 0; width: 100%; height: calc(100% + 100px); - background-color: rgba(0, 0, 0, 0.75); + background-color: rgba(0, 0, 0, 0.5); z-index: 50; } diff --git a/client/src/styles/create.css b/client/src/styles/create.css index 4bd0ac6..846324e 100644 --- a/client/src/styles/create.css +++ b/client/src/styles/create.css @@ -107,7 +107,7 @@ #deck-status-container { width: 20em; max-width: 95%; - height: 13em; + height: 10em; overflow-y: auto; position: relative; } @@ -356,7 +356,7 @@ input[type="number"] { #deck-select { margin: 0.5em 1em 1.5em 0; overflow-y: auto; - height: 15em; + height: 12em; } .deck-select-role { diff --git a/client/src/styles/game.css b/client/src/styles/game.css index a0422d6..5644446 100644 --- a/client/src/styles/game.css +++ b/client/src/styles/game.css @@ -47,14 +47,28 @@ display: flex; width: 95%; margin: 1em auto 100px auto; + animation: fade-in-slide-up 2s; } #game-state-container h2 { - margin: 1em 0; - text-align: center; + margin: 0.5em 0; max-width: 17em; } +#restart-game { + background-color: #0078D7; + min-width: 10em; +} + +#play-pause-placeholder { + width: 60px; + height: 60px; +} + +#restart-game:hover { + border-color: whitesmoke; +} + #lobby-header { margin-bottom: 1em; max-width: 95%; @@ -84,6 +98,7 @@ h1 { #end-of-game-header { display: flex; + flex-direction: column; flex-wrap: wrap; margin: 0 !important; align-items: center; @@ -91,6 +106,7 @@ h1 { #end-of-game-header button { margin: 0.5em; + min-width: 10em; } .potential-moderator { display: flex; @@ -494,12 +510,19 @@ label[for='moderator'] { align-items: flex-start; } -.timer-container-moderator { +#game-header button { + min-width: 10em; +} + +#timer-container-moderator { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; margin-bottom: 1em; + padding: 0.5em; + border-radius: 3px; + background-color: #333243; } .game-player { @@ -517,7 +540,7 @@ label[for='moderator'] { .game-player-name { position: relative; - width: 10em; + min-width: 6em; overflow: hidden; white-space: nowrap; font-weight: bold; diff --git a/client/src/views/game.html b/client/src/views/game.html index 8db4db3..c0b68e0 100644 --- a/client/src/views/game.html +++ b/client/src/views/game.html @@ -19,7 +19,8 @@ -
+
+
diff --git a/server/api/GamesAPI.js b/server/api/GamesAPI.js index f5aca63..ae4364b 100644 --- a/server/api/GamesAPI.js +++ b/server/api/GamesAPI.js @@ -89,6 +89,29 @@ router.patch('/:code/players', function (req, res) { } }); +router.patch('/:code/restart', function (req, res) { + if ( + req.body === null + || !validateAccessCode(req.body.accessCode) + || !validateName(req.body.playerName) + || !validateCookie(req.body.localCookie) + || !validateCookie(req.body.sessionCookie) + ) { + res.status(400).send(); + } else { + const game = gameManager.activeGameRunner.activeGames[req.body.accessCode]; + if (game) { + gameManager.restartGame(game, gameManager.namespace).then((data) => { + res.status(200).send(); + }).catch((code) => { + res.status(code).send(); + }); + } else { + res.status(404).send(); + } + } +}); + router.get('/environment', function (req, res) { res.status(200).send(gameManager.environment); }); diff --git a/server/config/globals.js b/server/config/globals.js index 0f5a617..17c7c81 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -2,7 +2,7 @@ const globals = { ACCESS_CODE_CHAR_POOL: 'BCDFGHJKLMNPQRSTVWXYZ23456789', ACCESS_CODE_LENGTH: 4, ACCESS_CODE_GENERATION_ATTEMPTS: 50, - CLOCK_TICK_INTERVAL_MILLIS: 10, + CLOCK_TICK_INTERVAL_MILLIS: 100, STALE_GAME_HOURS: 12, CLIENT_COMMANDS: { FETCH_GAME_STATE: 'fetchGameState', diff --git a/server/model/Game.js b/server/model/Game.js index 7a35f89..3756190 100644 --- a/server/model/Game.js +++ b/server/model/Game.js @@ -1,11 +1,23 @@ class Game { - constructor (accessCode, status, people, deck, hasTimer, moderator, timerParams = null) { + constructor ( + accessCode, + status, + people, + deck, + hasTimer, + moderator, + hasDedicatedModerator, + originalModeratorId, + timerParams = null + ) { this.accessCode = accessCode; this.status = status; this.moderator = moderator; this.people = people; this.deck = deck; this.hasTimer = hasTimer; + this.hasDedicatedModerator = hasDedicatedModerator; + this.originalModeratorId = originalModeratorId; this.timerParams = timerParams; this.isFull = false; this.timeRemaining = null; diff --git a/server/modules/GameManager.js b/server/modules/GameManager.js index 589113d..9fb7079 100644 --- a/server/modules/GameManager.js +++ b/server/modules/GameManager.js @@ -183,10 +183,12 @@ class GameManager { this.activeGameRunner.activeGames[newAccessCode] = new Game( newAccessCode, globals.STATUS.LOBBY, - initializePeopleForGame(gameParams.deck, moderator), + initializePeopleForGame(gameParams.deck, moderator, this.shuffle), gameParams.deck, gameParams.hasTimer, moderator, + gameParams.hasDedicatedModerator, + moderator.id, gameParams.timerParams ); this.activeGameRunner.activeGames[newAccessCode].createTime = new Date().toJSON(); @@ -308,6 +310,67 @@ 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(); + delete this.activeGameRunner.timerThreads[game.accessCode]; + } + + // re-shuffle the deck + const cards = []; + for (const card of game.deck) { + for (let i = 0; i < card.quantity; i ++) { + cards.push(card); + } + } + + this.shuffle(cards); + + // make sure no players are marked as out or revealed, and give them new cards. + for (let i = 0; i < game.people.length; i ++) { + if (game.people[i].out) { + game.people[i].out = false; + } + if (game.people[i].userType === globals.USER_TYPES.KILLED_PLAYER) { + game.people[i].userType = globals.USER_TYPES.PLAYER; + } + game.people[i].revealed = false; + game.people[i].gameRole = cards[i].role; + game.people[i].gameRoleDescription = cards[i].description; + game.people[i].alignment = cards[i].team; + } + + /* If the game was originally set up with a TEMP mod and the game has gone far enough to establish + a DEDICATED mod, make the current mod a TEMP mod for the restart. */ + if (!game.hasDedicatedModerator && game.moderator.userType === globals.USER_TYPES.MODERATOR) { + game.moderator.userType = globals.USER_TYPES.TEMPORARY_MODERATOR; + } + + /* If the game was originally set up with a DEDICATED moderator and the current mod is DIFFERENT from that mod + (i.e. they transferred their powers at some point), check if the current mod was once a player (i.e. they have + a game role). If they were once a player, make them a temp mod for the restart. Otherwise, they were a + spectator, and we want to leave them as a dedicated moderator. + */ + if (game.hasDedicatedModerator && game.moderator.id !== game.originalModeratorId) { + if (game.moderator.gameRole) { + game.moderator.userType = globals.USER_TYPES.TEMPORARY_MODERATOR; + } else { + game.moderator.userType = globals.USER_TYPES.MODERATOR; + } + } + + // start the new game + game.status = globals.STATUS.IN_PROGRESS; + if (game.hasTimer) { + game.timerParams.paused = true; + this.activeGameRunner.runGame(game, namespace); + } + + namespace.in(game.accessCode).emit(globals.CLIENT_COMMANDS.START_GAME); + }; + handleRequestForGameState = async (namespace, logger, gameRunner, accessCode, personCookie, ackFn, clientSocket) => { const game = gameRunner.activeGames[accessCode]; if (game) { @@ -355,6 +418,23 @@ class GameManager { } }); } + + /* + -- To shuffle an array a of n elements (indices 0..n-1): + for i from nāˆ’1 downto 1 do + j ← random integer such that 0 ≤ j ≤ i + exchange a[j] and a[i] + */ + shuffle = (array) => { + for (let i = array.length - 1; i > 0; i --) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = array[j]; + array[j] = array[i]; + array[i] = temp; + } + + return array; + }; } function getRandomInt (max) { @@ -365,12 +445,12 @@ function initializeModerator (name, hasDedicatedModerator) { const userType = hasDedicatedModerator ? globals.USER_TYPES.MODERATOR : globals.USER_TYPES.TEMPORARY_MODERATOR; - return new Person(createRandomId(), createRandomId(), name, userType); ; + return new Person(createRandomId(), createRandomId(), name, userType); } -function initializePeopleForGame (uniqueCards, moderator) { +function initializePeopleForGame (uniqueCards, moderator, shuffle) { const people = []; - let cards = []; // this will contain copies of each card equal to the quantity. + const cards = []; let numberOfRoles = 0; for (const card of uniqueCards) { for (let i = 0; i < card.quantity; i ++) { @@ -379,7 +459,7 @@ function initializePeopleForGame (uniqueCards, moderator) { } } - cards = shuffle(cards); // The deck should probably be shuffled, ey?. + shuffle(cards); // this shuffles in-place. let j = 0; if (moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { // temporary moderators should be dealt in. @@ -410,23 +490,6 @@ function initializePeopleForGame (uniqueCards, moderator) { return people; } -/* --- To shuffle an array a of n elements (indices 0..n-1): -for i from nāˆ’1 downto 1 do - j ← random integer such that 0 ≤ j ≤ i - exchange a[j] and a[i] - */ -function shuffle(array) { - for (let i = array.length - 1; i > 0; i --) { - const j = Math.floor(Math.random() * (i + 1)); - const temp = array[j]; - array[j] = array[i]; - array[i] = temp; - } - - return array; -} - function createRandomId () { let id = ''; for (let i = 0; i < globals.USER_SIGNATURE_LENGTH; i ++) { diff --git a/spec/unit/server/modules/GameManager_Spec.js b/spec/unit/server/modules/GameManager_Spec.js index aaac3ee..2d172e8 100644 --- a/spec/unit/server/modules/GameManager_Spec.js +++ b/spec/unit/server/modules/GameManager_Spec.js @@ -2,6 +2,7 @@ const Game = require('../../../../server/model/Game'); const globals = require('../../../../server/config/globals'); const USER_TYPES = globals.USER_TYPES; +const STATUS = globals.STATUS; const Person = require('../../../../server/model/Person'); const GameManager = require('../../../../server/modules/GameManager.js'); const GameStateCurator = require('../../../../server/modules/GameStateCurator'); @@ -280,4 +281,146 @@ describe('GameManager', () => { expect(accessCode).toEqual('BBBB'); }); }); + + describe('#restartGame', () => { + let person1, + person2, + person3, + shuffleSpy, + game, + moderator; + + beforeEach(() => { + person1 = new Person('1', '123', 'Placeholder1', USER_TYPES.KILLED_PLAYER); + person2 = new Person('2', '456', 'Placeholder2', USER_TYPES.PLAYER); + person3 = new Person('3', '789', 'Placeholder3', USER_TYPES.PLAYER); + moderator = new Person('4', '000', 'Jack', USER_TYPES.MODERATOR); + person1.out = true; + person2.revealed = true; + moderator.assigned = true; + shuffleSpy = spyOn(gameManager, 'shuffle').and.stub(); + game = new Game( + 'test', + STATUS.ENDED, + [person1, person2, person3], + [ + { role: 'Villager', description: 'test', team: 'good', quantity: 1 }, + { role: 'Seer', description: 'test', team: 'good', quantity: 1 }, + { role: 'Werewolf', description: 'test', team: 'evil', quantity: 1 } + ], + false, + moderator, + true, + '4', + null + ); + }); + + it('should reset all relevant game parameters', async () => { + const emitSpy = spyOn(namespace.in(), 'emit'); + + await gameManager.restartGame(game, namespace); + + expect(game.status).toEqual(STATUS.IN_PROGRESS); + expect(game.moderator.id).toEqual('4'); + expect(game.moderator.userType).toEqual(USER_TYPES.MODERATOR); + expect(person1.out).toEqual(false); + expect(person2.revealed).toEqual(false); + for (const person of game.people) { + expect(person.gameRole).toBeDefined(); + } + expect(shuffleSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + }); + + it('should reset all relevant game parameters, including when the game has a timer', async () => { + game.timerParams = { hours: 2, minutes: 2, paused: false }; + game.hasTimer = true; + gameManager.activeGameRunner.timerThreads = { test: { kill: () => {} } }; + + const threadKillSpy = spyOn(gameManager.activeGameRunner.timerThreads.test, 'kill'); + const runGameSpy = spyOn(gameManager.activeGameRunner, 'runGame').and.stub(); + const emitSpy = spyOn(namespace.in(), 'emit'); + + await gameManager.restartGame(game, namespace); + + expect(game.status).toEqual(STATUS.IN_PROGRESS); + expect(game.timerParams.paused).toBeTrue(); + expect(game.moderator.id).toEqual('4'); + expect(game.moderator.userType).toEqual(USER_TYPES.MODERATOR); + expect(person1.out).toEqual(false); + expect(person2.revealed).toEqual(false); + for (const person of game.people) { + expect(person.gameRole).toBeDefined(); + } + expect(threadKillSpy).toHaveBeenCalled(); + expect(runGameSpy).toHaveBeenCalled(); + expect(Object.keys(gameManager.activeGameRunner.timerThreads).length).toEqual(0); + expect(shuffleSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + }); + + it('should reset all relevant game parameters and preserve temporary moderator', async () => { + const emitSpy = spyOn(namespace.in(), 'emit'); + game.moderator = game.people[0]; + game.moderator.userType = USER_TYPES.TEMPORARY_MODERATOR; + game.hasDedicatedModerator = false; + + await gameManager.restartGame(game, namespace); + + expect(game.status).toEqual(STATUS.IN_PROGRESS); + expect(game.moderator.id).toEqual('1'); + expect(game.moderator.userType).toEqual(USER_TYPES.TEMPORARY_MODERATOR); + expect(game.moderator.gameRole).toBeDefined(); + expect(person1.out).toEqual(false); + expect(person2.revealed).toEqual(false); + for (const person of game.people) { + expect(person.gameRole).toBeDefined(); + } + expect(shuffleSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + }); + + it('should reset all relevant game parameters and restore a temporary moderator from a dedicated moderator', async () => { + const emitSpy = spyOn(namespace.in(), 'emit'); + game.moderator = game.people[0]; + game.moderator.userType = USER_TYPES.MODERATOR; + game.hasDedicatedModerator = false; + + await gameManager.restartGame(game, namespace); + + expect(game.status).toEqual(STATUS.IN_PROGRESS); + expect(game.moderator.id).toEqual('1'); + expect(game.moderator.userType).toEqual(USER_TYPES.TEMPORARY_MODERATOR); + expect(game.moderator.gameRole).toBeDefined(); + expect(person1.out).toEqual(false); + expect(person2.revealed).toEqual(false); + for (const person of game.people) { + expect(person.gameRole).toBeDefined(); + } + expect(shuffleSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + }); + + it('should reset all relevant game parameters and create a temporary mod if a dedicated mod transferred to a killed player', async () => { + const emitSpy = spyOn(namespace.in(), 'emit'); + game.moderator = game.people[0]; + game.moderator.userType = USER_TYPES.MODERATOR; + game.hasDedicatedModerator = true; + + await gameManager.restartGame(game, namespace); + + expect(game.status).toEqual(STATUS.IN_PROGRESS); + expect(game.moderator.id).toEqual('1'); + expect(game.moderator.userType).toEqual(USER_TYPES.TEMPORARY_MODERATOR); + expect(game.moderator.gameRole).toBeDefined(); + expect(person1.out).toEqual(false); + expect(person2.revealed).toEqual(false); + for (const person of game.people) { + expect(person.gameRole).toBeDefined(); + } + expect(shuffleSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(globals.CLIENT_COMMANDS.START_GAME); + }); + }); });