const globals = require('../../config/globals'); const Game = require('../../model/Game'); const Person = require('../../model/Person'); const GameStateCurator = require('../GameStateCurator'); const UsernameGenerator = require('../UsernameGenerator'); const GameCreationRequest = require('../../model/GameCreationRequest'); class GameManager { constructor (logger, environment, instanceId) { if (GameManager.instance) { throw new Error('The server tried to instantiate more than one GameManager'); } logger.info('CREATING SINGLETON GAME MANAGER'); this.logger = logger; this.environment = environment; this.timerManager = null; this.eventManager = null; this.namespace = null; this.instanceId = instanceId; GameManager.instance = this; } getActiveGame = async (accessCode) => { const r = await this.eventManager.publisher.get(accessCode); if (r === null && this.timerManager.timerThreads[accessCode]) { if (!this.timerManager.timerThreads[accessCode].killed) { this.timerManager.timerThreads[accessCode].kill(); } delete this.timerManager.timerThreads[accessCode]; } let activeGame; if (r !== null) { try { activeGame = JSON.parse(r); } catch (e) { this.logger.error('ERROR PARSING ACTIVE GAME: ' + e); } } return r === null ? r : activeGame; } setGameSocketNamespace = (namespace) => { this.namespace = namespace; }; refreshGame = async (game) => { this.logger.debug('PUSHING REFRESH OF ' + game.accessCode); await this.eventManager.publisher.set(game.accessCode, JSON.stringify(game), { KEEPTTL: true, XX: true // only set the key if it already exists }); } createGame = async (gameParams) => { this.logger.debug('Received request to create new game.'); return GameCreationRequest.validate(gameParams).then(async () => { const req = new GameCreationRequest( gameParams.deck, gameParams.hasTimer, gameParams.timerParams, gameParams.moderatorName, gameParams.hasDedicatedModerator, gameParams.isTestGame ); const newAccessCode = await this.generateAccessCode(globals.ACCESS_CODE_CHAR_POOL); if (newAccessCode === null) { return Promise.reject(globals.ERROR_MESSAGE.NO_UNIQUE_ACCESS_CODE); } const moderator = initializeModerator(req.moderatorName, req.hasDedicatedModerator); moderator.assigned = true; if (req.timerParams !== null) { req.timerParams.paused = true; req.timerParams.timeRemaining = convertFromHoursToMilliseconds(req.timerParams.hours) + convertFromMinutesToMilliseconds(req.timerParams.minutes); } const newGame = new Game( newAccessCode, globals.STATUS.LOBBY, initializePeopleForGame(req.deck, moderator, this.shuffle, req.isTestGame), req.deck, req.hasTimer, moderator.id, req.hasDedicatedModerator, moderator.id, new Date().toJSON(), req.timerParams, req.isTestGame ); await this.eventManager.publisher.set(newAccessCode, JSON.stringify(newGame), { EX: globals.STALE_GAME_SECONDS }); return Promise.resolve({ accessCode: newAccessCode, cookie: moderator.cookie, environment: this.environment }); }).catch((message) => { console.error(message); this.logger.error('Received invalid request to create new game.'); return Promise.reject(message); }); }; pauseTimer = async (game, logger) => { const thread = this.timerManager.timerThreads[game.accessCode]; if (thread && !thread.killed) { 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 = async (game, logger) => { const thread = this.timerManager.timerThreads[game.accessCode]; if (thread && !thread.killed) { 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 = async (game, socketId) => { if (socketId) { const thread = this.timerManager.timerThreads[game.accessCode]; if (thread && (!thread.killed && thread.exitCode === null)) { thread.send({ command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, accessCode: game.accessCode, socketId: socketId, logLevel: this.logger.logLevel }); } else if (thread) { if (game.timerParams && game.timerParams.timeRemaining === 0) { this.namespace.to(socketId).emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); } } } }; checkAvailability = async (code) => { const game = await this.getActiveGame(code.toUpperCase().trim()); if (game) { return Promise.resolve({ accessCode: code, playerCount: getGameSize(game.deck), timerParams: game.timerParams }); } else { return Promise.resolve(404); } }; generateAccessCode = async (charPool) => { const charCount = charPool.length; let codeDigits, accessCode; let attempts = 0; while (!accessCode || ((await this.eventManager.publisher.keys('*')).includes(accessCode) && attempts < globals.ACCESS_CODE_GENERATION_ATTEMPTS)) { codeDigits = []; let iterations = globals.ACCESS_CODE_LENGTH; while (iterations > 0) { iterations --; codeDigits.push(charPool[getRandomInt(charCount)]); } accessCode = codeDigits.join(''); attempts ++; } return (await this.eventManager.publisher.keys('*')).includes(accessCode) ? null : accessCode; }; joinGame = async (game, name, cookie, joinAsSpectator) => { const matchingPerson = this.findPersonByField(game, 'cookie', cookie); if (matchingPerson) { return Promise.resolve(matchingPerson.cookie); } if (isNameTaken(game, name)) { return Promise.reject({ status: 400, reason: 'This name is taken.' }); } if (joinAsSpectator && game.people.filter(person => person.userType === globals.USER_TYPES.SPECTATOR).length === globals.MAX_SPECTATORS ) { return Promise.reject({ status: 400, reason: 'There are too many people already spectating.' }); } else if (joinAsSpectator) { return await addSpectator(game, name, this.logger, this.namespace, this.eventManager, this.instanceId, this.refreshGame); } const unassignedPerson = this.findPersonByField(game, 'id', game.currentModeratorId).assigned === false ? this.findPersonByField(game, 'id', game.currentModeratorId) : game.people.find((person) => person.assigned === false && person.userType === globals.USER_TYPES.PLAYER); if (unassignedPerson) { this.logger.trace('request from client to join game. Assigning: ' + unassignedPerson.name); unassignedPerson.assigned = true; unassignedPerson.name = name; game.isFull = this.isGameFull(game); await this.refreshGame(game); this.namespace.in(game.accessCode).emit( globals.EVENTS.PLAYER_JOINED, GameStateCurator.mapPerson(unassignedPerson), game.isFull ); await this.eventManager.publisher?.publish( globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, this.eventManager.createMessageToPublish( game.accessCode, globals.EVENT_IDS.PLAYER_JOINED, this.instanceId, JSON.stringify(unassignedPerson) ) ); return Promise.resolve(unassignedPerson.cookie); } else { if (game.people.filter(person => person.userType === globals.USER_TYPES.SPECTATOR).length === globals.MAX_SPECTATORS) { return Promise.reject({ status: 400, reason: 'This game has reached the maximum number of players and spectators.' }); } return await addSpectator(game, name, this.logger, this.namespace, this.eventManager, this.instanceId, this.refreshGame); } }; restartGame = async (game, namespace) => { // kill any outstanding timer threads const subProcess = this.timerManager.timerThreads[game.accessCode]; if (subProcess) { if (!subProcess.killed) { this.logger.info('Killing timer process ' + subProcess.pid + ' for: ' + game.accessCode); this.timerManager.timerThreads[game.accessCode].kill(); } this.logger.debug('Deleting reference to subprocess ' + subProcess.pid); delete this.timerManager.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].userType === globals.USER_TYPES.KILLED_PLAYER) { game.people[i].userType = globals.USER_TYPES.PLAYER; game.people[i].out = false; } if (game.people[i].userType === globals.USER_TYPES.KILLED_BOT) { game.people[i].userType = globals.USER_TYPES.BOT; game.people[i].out = false; } game.people[i].revealed = false; game.people[i].killed = false; if (game.people[i].gameRole) { game.people[i].gameRole = cards[i].role; game.people[i].gameRoleDescription = cards[i].description; game.people[i].alignment = cards[i].team; if (game.people[i].id === game.currentModeratorId && game.people[i].userType === globals.USER_TYPES.MODERATOR) { game.people[i].userType = globals.USER_TYPES.TEMPORARY_MODERATOR; game.people[i].out = false; } } } // start the new game game.status = globals.STATUS.IN_PROGRESS; if (game.hasTimer) { game.timerParams.paused = true; game.timerParams.timeRemaining = convertFromHoursToMilliseconds(game.timerParams.hours) + convertFromMinutesToMilliseconds(game.timerParams.minutes); await this.timerManager.runTimer(game, namespace, this.eventManager, this); } await this.refreshGame(game); await this.eventManager.publisher?.publish( globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, this.eventManager.createMessageToPublish( game.accessCode, globals.EVENT_IDS.RESTART_GAME, this.instanceId, '{}' ) ); namespace.in(game.accessCode).emit(globals.EVENT_IDS.RESTART_GAME); }; /* -- 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; }; isGameFull = (game) => { return !game.people.find((person) => person.userType === globals.USER_TYPES.PLAYER && person.assigned === false); } findPersonByField = (game, fieldName, value) => { return game.people.find(person => person[fieldName] === value); } } function getRandomInt (max) { return Math.floor(Math.random() * Math.floor(max)); } function initializeModerator (name, hasDedicatedModerator) { const userType = hasDedicatedModerator ? globals.USER_TYPES.MODERATOR : globals.USER_TYPES.TEMPORARY_MODERATOR; return new Person(createRandomId(), createRandomId(), name, userType); } function initializePeopleForGame (uniqueRoles, moderator, shuffle, isTestGame) { const people = []; const cards = []; for (const role of uniqueRoles) { for (let i = 0; i < role.quantity; i ++) { cards.push(role); } } shuffle(cards); // this shuffles in-place. let j = 0; const number = moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR ? cards.length - 1 : cards.length; while (j < number) { const person = new Person( createRandomId(), createRandomId(), UsernameGenerator.generate(), isTestGame ? globals.USER_TYPES.BOT : globals.USER_TYPES.PLAYER, cards[j].role, cards[j].description, cards[j].team, isTestGame ); person.customRole = cards[j].custom; person.hasEnteredName = false; people.push(person); j ++; } if (moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { moderator.gameRole = cards[cards.length - 1].role; moderator.customRole = cards[cards.length - 1].custom; moderator.gameRoleDescription = cards[cards.length - 1].description; moderator.alignment = cards[cards.length - 1].team; } people.push(moderator); return people; } function createRandomId () { let id = ''; for (let i = 0; i < globals.INSTANCE_ID_LENGTH; i ++) { id += globals.INSTANCE_ID_CHAR_POOL[Math.floor(Math.random() * globals.INSTANCE_ID_CHAR_POOL.length)]; } return id; } function isNameTaken (game, name) { const processedName = name.toLowerCase().trim(); return game.people.find((person) => person.name.toLowerCase().trim() === processedName); } function getGameSize (cards) { let quantity = 0; for (const card of cards) { quantity += card.quantity; } return quantity; } async function addSpectator (game, name, logger, namespace, eventManager, instanceId, refreshGame) { const spectator = new Person( createRandomId(), createRandomId(), name, globals.USER_TYPES.SPECTATOR ); logger.trace('new spectator: ' + spectator.name); game.people.push(spectator); await refreshGame(game); namespace.in(game.accessCode).emit( globals.EVENT_IDS.ADD_SPECTATOR, GameStateCurator.mapPerson(spectator) ); await eventManager.publisher.publish( globals.REDIS_CHANNELS.ACTIVE_GAME_STREAM, eventManager.createMessageToPublish( game.accessCode, globals.EVENT_IDS.ADD_SPECTATOR, instanceId, JSON.stringify(GameStateCurator.mapPerson(spectator)) ) ); return Promise.resolve(spectator.cookie); } function convertFromMinutesToMilliseconds (minutes) { return minutes * 60 * 1000; } function convertFromHoursToMilliseconds (hours) { return hours * 60 * 60 * 1000; } module.exports = GameManager;