refactor singletons to their own folder

This commit is contained in:
AlecM33
2023-01-01 22:27:23 -05:00
parent 5e0d4d50b2
commit d760f72ac0
8 changed files with 17 additions and 16 deletions

View File

@@ -0,0 +1,70 @@
const { fork } = require('child_process');
const path = require('path');
const globals = require('../../config/globals');
class ActiveGameRunner {
constructor (logger) {
if (ActiveGameRunner.instance) {
throw new Error('The server tried to instantiate more than one ActiveGameRunner');
}
logger.info('CREATING SINGLETON ACTIVE GAME RUNNER');
this.activeGames = new Map();
this.timerThreads = {};
this.logger = logger;
ActiveGameRunner.instance = this;
}
/* We're only going to fork a child process for games with a timer. They will report back to the parent process whenever
the timer is up.
*/
runGame = (game, namespace) => {
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:
game.timerParams.paused = false;
game.timerParams.timeRemaining = 0;
namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.END_TIMER);
this.logger.trace('PARENT: END TIMER');
break;
case globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER:
game.timerParams.paused = true;
this.logger.trace(msg);
game.timerParams.timeRemaining = msg.timeRemaining;
this.logger.trace('PARENT: PAUSE TIMER');
namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER, game.timerParams.timeRemaining);
break;
case globals.GAME_PROCESS_COMMANDS.RESUME_TIMER:
game.timerParams.paused = false;
this.logger.trace(msg);
game.timerParams.timeRemaining = msg.timeRemaining;
this.logger.trace('PARENT: RESUME TIMER');
namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.RESUME_TIMER, game.timerParams.timeRemaining);
break;
case globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING:
this.logger.trace(msg);
game.timerParams.timeRemaining = msg.timeRemaining;
this.logger.trace('PARENT: GET TIME REMAINING');
namespace.to(msg.socketId).emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused);
break;
}
});
gameProcess.on('exit', (code, signal) => {
this.logger.debug('Game timer thread ' + gameProcess.pid + ' exiting with code ' + code + ' - game ' + game.accessCode);
});
gameProcess.send({
command: globals.GAME_PROCESS_COMMANDS.START_TIMER,
accessCode: game.accessCode,
logLevel: this.logger.logLevel,
hours: game.timerParams.hours,
minutes: game.timerParams.minutes
});
game.startTime = new Date().toJSON();
};
}
module.exports = ActiveGameRunner;

View File

@@ -0,0 +1,524 @@
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, activeGameRunner) {
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.activeGameRunner = activeGameRunner;
this.namespace = null;
GameManager.instance = this;
}
setGameSocketNamespace = (namespace) => {
this.namespace = namespace;
};
createGame = (gameParams) => {
this.logger.debug('Received request to create new game.');
return GameCreationRequest.validate(gameParams).then(() => {
const req = new GameCreationRequest(
gameParams.deck,
gameParams.hasTimer,
gameParams.timerParams,
gameParams.moderatorName,
gameParams.hasDedicatedModerator
);
this.pruneStaleGames();
const newAccessCode = 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 = false;
}
const newGame = new Game(
newAccessCode,
globals.STATUS.LOBBY,
initializePeopleForGame(req.deck, moderator, this.shuffle),
req.deck,
req.hasTimer,
moderator,
req.hasDedicatedModerator,
moderator.id,
new Date().toJSON(),
req.timerParams
);
this.activeGameRunner.activeGames.set(newAccessCode, newGame);
return Promise.resolve({ accessCode: newAccessCode, cookie: moderator.cookie, environment: this.environment });
}).catch((message) => {
this.logger.debug('Received invalid request to create new game.');
return Promise.reject(message);
});
};
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 && !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 = (game, logger) => {
const thread = this.activeGameRunner.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 = (game, socket) => {
const thread = this.activeGameRunner.timerThreads[game.accessCode];
if (thread && (!thread.killed && thread.exitCode === null)) {
thread.send({
command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
accessCode: game.accessCode,
socketId: socket.id,
logLevel: this.logger.logLevel
});
} else if (thread) {
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();
}
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.get(code.toUpperCase().trim());
if (game) {
return Promise.resolve({ accessCode: code, playerCount: getGameSize(game.deck), timerParams: game.timerParams });
} else {
return Promise.resolve(404);
}
};
generateAccessCode = (charPool) => {
const charCount = charPool.length;
let codeDigits, accessCode;
let attempts = 0;
while (!accessCode || (this.activeGameRunner.activeGames.get(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 this.activeGameRunner.activeGames.get(accessCode)
? null
: accessCode;
};
transferModeratorPowers = (socket, game, person, namespace, logger) => {
if (person && (person.out || person.userType === globals.USER_TYPES.SPECTATOR)) {
let spectatorsUpdated = false;
if (game.spectators.includes(person)) {
game.spectators.splice(game.spectators.indexOf(person), 1);
spectatorsUpdated = true;
}
logger.debug('game ' + game.accessCode + ': transferring mod powers to ' + person.name);
if (game.moderator === person) {
person.userType = globals.USER_TYPES.MODERATOR;
this.namespace.to(person.socketId).emit(globals.EVENTS.SYNC_GAME_STATE);
socket.to(game.accessCode).emit(globals.EVENT_IDS.KILL_PLAYER, person.id);
} else {
const oldModerator = game.moderator;
if (game.moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) {
game.moderator.userType = globals.USER_TYPES.PLAYER;
} else if (game.moderator.gameRole) { // the current moderator was at one point a dealt-in player.
game.moderator.userType = globals.USER_TYPES.KILLED_PLAYER; // restore their state from before being made mod.
} else if (game.moderator.userType === globals.USER_TYPES.MODERATOR) {
game.moderator.userType = globals.USER_TYPES.SPECTATOR;
game.spectators.push(game.moderator);
spectatorsUpdated = true;
}
person.userType = globals.USER_TYPES.MODERATOR;
game.moderator = person;
if (spectatorsUpdated === true) {
namespace.in(game.accessCode).emit(
globals.EVENTS.UPDATE_SPECTATORS,
game.spectators.map((spectator) => GameStateCurator.mapPerson(spectator))
);
}
this.namespace.to(person.socketId).emit(globals.EVENTS.SYNC_GAME_STATE);
this.namespace.to(oldModerator.socketId).emit(globals.EVENTS.SYNC_GAME_STATE);
}
}
};
killPlayer = (socket, game, person, namespace, logger) => {
if (person && !person.out) {
logger.debug('game ' + game.accessCode + ': killing player ' + person.name);
if (person.userType !== globals.USER_TYPES.TEMPORARY_MODERATOR) {
person.userType = globals.USER_TYPES.KILLED_PLAYER;
}
person.out = true;
// temporary moderators will transfer their powers automatically to the first person they kill.
if (game.moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) {
this.transferModeratorPowers(socket, game, person, namespace, logger);
} else {
namespace.in(game.accessCode).emit(globals.EVENT_IDS.KILL_PLAYER, person.id);
}
}
};
joinGame = (game, name, cookie, joinAsSpectator) => {
const matchingPerson = 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.spectators.length === globals.MAX_SPECTATORS) {
return Promise.reject({ status: 400, reason: 'There are too many people already spectating.' });
} else if (joinAsSpectator) {
return addSpectator(game, name, this.logger, this.namespace);
}
const unassignedPerson = game.moderator.assigned === false
? game.moderator
: game.people.find((person) => person.assigned === false);
if (unassignedPerson) {
this.logger.trace('request from client to join game. Assigning: ' + unassignedPerson.name);
unassignedPerson.assigned = true;
unassignedPerson.name = name;
game.isFull = isGameFull(game);
this.namespace.in(game.accessCode).emit(
globals.EVENTS.PLAYER_JOINED,
GameStateCurator.mapPerson(unassignedPerson),
game.isFull
);
return Promise.resolve(unassignedPerson.cookie);
} else {
if (game.spectators.length === globals.MAX_SPECTATORS) {
return Promise.reject({ status: 400, reason: 'This game has reached the maximum number of players and spectators.' });
}
return addSpectator(game, name, this.logger, this.namespace);
}
};
restartGame = async (game, namespace) => {
// kill any outstanding timer threads
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];
}
// 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 there is currently a dedicated mod, and that person was once a player (i.e. they have a game role), make
them a temporary mod for the restarted game.
*/
if (game.moderator.gameRole && game.moderator.userType === globals.USER_TYPES.MODERATOR) {
game.moderator.userType = globals.USER_TYPES.TEMPORARY_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.EVENT_IDS.RESTART_GAME);
};
handleRequestForGameState = async (game, namespace, logger, gameRunner, accessCode, personCookie, ackFn, clientSocket) => {
const matchingPerson = findPersonByField(game, 'cookie', personCookie);
if (matchingPerson) {
if (matchingPerson.socketId === clientSocket.id) {
logger.debug('matching person found with an established connection to the room: ' + matchingPerson.name);
ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson));
} else {
logger.debug('matching person found with a new connection to the room: ' + matchingPerson.name);
clientSocket.join(accessCode);
matchingPerson.socketId = clientSocket.id;
ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson));
}
} else {
rejectClientRequestForGameState(ackFn);
}
};
removeClientFromLobbyIfApplicable (socket) {
socket.rooms.forEach((room) => {
if (this.activeGameRunner.activeGames.get(room)) {
this.logger.trace('disconnected socket is in a game');
const game = this.activeGameRunner.activeGames.get(room);
if (game.status === globals.STATUS.LOBBY) {
const matchingPlayer = findPlayerBySocketId(game.people, socket.id);
if (matchingPlayer) {
this.logger.trace('un-assigning disconnected player: ' + matchingPlayer.name);
matchingPlayer.assigned = false;
matchingPlayer.socketId = null;
matchingPlayer.cookie = createRandomId();
matchingPlayer.hasEnteredName = false;
socket.to(game.accessCode).emit(
globals.EVENTS.PLAYER_LEFT,
GameStateCurator.mapPerson(matchingPlayer)
);
game.isFull = isGameFull(game);
matchingPlayer.name = UsernameGenerator.generate();
}
}
}
});
}
/*
-- 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;
};
pruneStaleGames = () => {
this.activeGameRunner.activeGames.forEach((value, key) => {
if (value.createTime) {
const createDate = new Date(value.createTime);
if (createDate.setHours(createDate.getHours() + globals.STALE_GAME_HOURS) < Date.now()) {
this.logger.info('PRUNING STALE GAME ' + key);
this.activeGameRunner.activeGames.delete(key);
if (this.activeGameRunner.timerThreads[key]) {
this.logger.info('KILLING STALE TIMER PROCESS FOR ' + key);
this.activeGameRunner.timerThreads[key].kill();
delete this.activeGameRunner.timerThreads[key];
}
}
}
});
};
}
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 (uniqueCards, moderator, shuffle) {
const people = [];
const cards = [];
let numberOfRoles = 0;
for (const card of uniqueCards) {
for (let i = 0; i < card.quantity; i ++) {
cards.push(card);
numberOfRoles ++;
}
}
shuffle(cards); // this shuffles in-place.
let j = 0;
if (moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { // temporary moderators should be dealt in.
moderator.gameRole = cards[j].role;
moderator.customRole = cards[j].custom;
moderator.gameRoleDescription = cards[j].description;
moderator.alignment = cards[j].team;
people.push(moderator);
j ++;
}
while (j < numberOfRoles) {
const person = new Person(
createRandomId(),
createRandomId(),
UsernameGenerator.generate(),
globals.USER_TYPES.PLAYER,
cards[j].role,
cards[j].description,
cards[j].team
);
person.customRole = cards[j].custom;
person.hasEnteredName = false;
people.push(person);
j ++;
}
return people;
}
function createRandomId () {
let id = '';
for (let i = 0; i < globals.USER_SIGNATURE_LENGTH; i ++) {
id += globals.ACCESS_CODE_CHAR_POOL[Math.floor(Math.random() * globals.ACCESS_CODE_CHAR_POOL.length)];
}
return id;
}
function rejectClientRequestForGameState (acknowledgementFunction) {
return acknowledgementFunction(null);
}
function findPlayerBySocketId (people, socketId) {
return people.find((person) => person.socketId === socketId && person.userType === globals.USER_TYPES.PLAYER);
}
function isGameFull (game) {
return game.moderator.assigned === true && !game.people.find((person) => person.assigned === false);
}
function findPersonByField (game, fieldName, value) {
let person;
if (value === game.moderator[fieldName]) {
person = game.moderator;
}
if (!person) {
person = game.people.find((person) => person[fieldName] === value);
}
if (!person) {
person = game.spectators.find((spectator) => spectator[fieldName] === value);
}
return person;
}
function isNameTaken (game, name) {
const processedName = name.toLowerCase().trim();
return (game.people.find((person) => person.name.toLowerCase().trim() === processedName))
|| (game.moderator.name.toLowerCase().trim() === processedName)
|| (game.spectators.find((spectator) => spectator.name.toLowerCase().trim() === processedName));
}
function getGameSize (cards) {
let quantity = 0;
for (const card of cards) {
quantity += card.quantity;
}
return quantity;
}
function addSpectator (game, name, logger, namespace) {
const spectator = new Person(
createRandomId(),
createRandomId(),
name,
globals.USER_TYPES.SPECTATOR
);
logger.trace('new spectator: ' + spectator.name);
game.spectators.push(spectator);
namespace.in(game.accessCode).emit(
globals.EVENTS.UPDATE_SPECTATORS,
game.spectators.map((spectator) => { return GameStateCurator.mapPerson(spectator); })
);
return Promise.resolve(spectator.cookie);
}
module.exports = GameManager;

View File

@@ -0,0 +1,131 @@
const globals = require('../../config/globals');
const EVENT_IDS = globals.EVENT_IDS;
const { RateLimiterMemory } = require('rate-limiter-flexible');
class SocketManager {
constructor (logger, activeGameRunner) {
if (SocketManager.instance) {
throw new Error('The server attempted to instantiate more than one SocketManager.');
}
logger.info('CREATING SINGLETON SOCKET MANAGER');
this.logger = logger;
this.io = null;
this.activeGameRunner = activeGameRunner;
SocketManager.instance = this;
}
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 = null, ackFn = null) => {
const game = gameManager.activeGameRunner.activeGames.get(accessCode);
if (game) {
switch (eventId) {
case EVENT_IDS.FETCH_GAME_STATE:
await gameManager.handleRequestForGameState(
game,
this.namespace,
this.logger,
gameManager.activeGameRunner,
accessCode,
args.personId,
ackFn,
socket
);
break;
case EVENT_IDS.START_GAME:
gameManager.startGame(game, namespace);
ackFn();
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(socket, 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(socket, game, person, namespace, this.logger);
break;
case EVENT_IDS.CHANGE_NAME:
gameManager.changeName(game, args.data, ackFn);
break;
case EVENT_IDS.END_GAME:
gameManager.endGame(game);
ackFn();
break;
default:
break;
}
} else {
ackFn(null);
}
});
};
}
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 = SocketManager;