Merge pull request #107 from AlecM33/restart-game

Restart game functionality
This commit is contained in:
Alec
2022-05-10 15:14:15 -04:00
committed by GitHub
16 changed files with 463 additions and 119 deletions

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -80,7 +80,7 @@ export const HTMLFragments = {
<label id='players-alive-label'></label>
<div id='game-player-list'></div>
</div>`,
MODERATOR_GAME_VIEW:
TRANSFER_MOD_MODAL:
`<div id='transfer-mod-modal-background' class='modal-background'></div>
<div tabindex='-1' id='transfer-mod-modal' class='modal'>
<h3>Transfer Mod Powers &#128081;</h3>
@@ -88,14 +88,17 @@ export const HTMLFragments = {
<div class='modal-button-container'>
<button id='close-mod-transfer-modal-button' class='app-button cancel'>Cancel</button>
</div>
</div>
<div id='game-header'>
<div class='timer-container-moderator'>
</div>`,
MODERATOR_GAME_VIEW:
`<div id='game-header'>
<div id='timer-container-moderator'>
<div>
<label for='game-timer'>Time Remaining</label>
<div id='game-timer'></div>
</div>
<div id='play-pause'> </div>
<div id='play-pause'>
<div id="play-pause-placeholder"></div>
</div>
</div>
<button id='mod-transfer-button' class='moderator-player-button make-mod-button'>Transfer Mod Powers \uD83D\uDD00</button>
<div>
@@ -116,19 +119,8 @@ export const HTMLFragments = {
</div>
</div>`,
TEMP_MOD_GAME_VIEW:
`<div id='transfer-mod-modal-background' class='modal-background'></div>
<div id='transfer-mod-modal' class='modal'>
<form id='transfer-mod-form'>
<div id='transfer-mod-form-content'>
<h3>Transfer Mod Powers &#128081;</h3>
</div>
<div class='modal-button-container'>
<button id='close-modal-button' class='cancel app-button'>Cancel</button>
</div>
</form>
</div>
<div id='game-header'>
<div class='timer-container-moderator'>
`<div id='game-header'>
<div id='timer-container-moderator'>
<div>
<label for='game-timer'>Time Remaining</label>
<div id='game-timer'></div>
@@ -216,21 +208,27 @@ export const HTMLFragments = {
</div>
</div>`,
END_OF_GAME_VIEW:
`<h2>The moderator has ended the game. Roles are revealed.</h2>
<div id='end-of-game-header'>
<div>
<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'/></button>
</div>
<div>
<a href='/'>
<button class='app-button'>Go Home \uD83C\uDFE0</button>
</a>
`<div id='end-of-game-header'>
<h2>&#x1F3C1; The moderator has ended the game. Roles are revealed.</h2>
<div id="end-of-game-buttons">
<div>
<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'/></button>
</div>
<div>
<a href='/'>
<button class='app-button'>Go Home \uD83C\uDFE0</button>
</a>
</div>
</div>
</div>
<div id='game-people-container'>
<label id='players-alive-label'></label>
<div id='game-player-list'></div>
</div>`,
RESTART_GAME_BUTTON:
`<div>
<button id='restart-game' class='app-button'>Run it back 🔄</button>
</div>`,
CREATE_GAME_DECK:
`<div id='deck-container'>
<div>

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -19,7 +19,8 @@
<link rel="preload" href="/webfonts/SignikaNegative-Light.woff2" as="font" type="font/woff2" crossorigin>
</head>
<body>
<div id="prompt"></div>
<div id="role-info-prompt"></div>
<div id="transfer-mod-prompt"></div>
<div class="spinner-background"></div>
<div class="spinner-container">
<div class="lds-spinner">

View File

@@ -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);
});

View File

@@ -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',

View File

@@ -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;

View File

@@ -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 n1 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 n1 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 ++) {

View File

@@ -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);
});
});
});