diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..83664f9 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/package.json b/package.json index 4421143..16b8f19 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,7 @@ "start:dev:windows": "SET NODE_ENV=development && nodemon server/main.js", "start": "NODE_ENV=production node server/main.js -- loglevel=debug port=8080", "start:windows": "SET NODE_ENV=production && node server/main.js -- loglevel=warn port=8080", - "test": "jasmine && node browsertest.js openBrowser socket", - "test:unit": "jasmine", - "test:e2e": "node browsertest.js" + "test": "jasmine" }, "engines": { "node": ">=14.0.0" diff --git a/server/main.js b/server/main.js index 8d3006b..22ada28 100644 --- a/server/main.js +++ b/server/main.js @@ -2,7 +2,7 @@ const express = require('express'); const path = require('path'); const app = express(); const bodyParser = require('body-parser'); -const GameManager = require('./modules/GameManager.js'); +const GameManager = require('./modules/GameManager.js'); const globals = require('./config/globals'); const ServerBootstrapper = require('./modules/ServerBootstrapper'); diff --git a/server/modules/GameManager.js b/server/modules/GameManager.js index c313dc3..a89b887 100644 --- a/server/modules/GameManager.js +++ b/server/modules/GameManager.js @@ -18,7 +18,7 @@ class GameManager { this.namespace = namespace; socket.on(globals.CLIENT_COMMANDS.FETCH_GAME_STATE, (accessCode, personId, ackFn) => { this.logger.trace('request for game state for accessCode ' + accessCode + ', person ' + personId); - handleRequestForGameState( + this.handleRequestForGameState( this.namespace, this.logger, this.activeGameRunner, @@ -100,18 +100,7 @@ class GameManager { let game = this.activeGameRunner.activeGames[accessCode]; if (game) { let person = game.people.find((person) => person.id === personId) - if (person && !person.out) { - this.logger.debug('game ' + accessCode + ': killing player ' + person.name); - if (person.userType !== globals.USER_TYPES.TEMPORARY_MODERATOR) { - person.userType = globals.USER_TYPES.KILLED_PLAYER; - } - person.out = true; - namespace.in(accessCode).emit(globals.CLIENT_COMMANDS.KILL_PLAYER, person.id); - // temporary moderators will transfer their powers automatically to the first person they kill. - if (game.moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { - transferModeratorPowers(game, person, namespace, this.logger); - } - } + this.killPlayer(game, person, namespace, this.logger); } }); @@ -140,7 +129,7 @@ class GameManager { if (!person) { person = game.spectators.find((spectator) => spectator.id === personId) } - transferModeratorPowers(game, person, namespace, this.logger); + this.transferModeratorPowers(game, person, namespace, this.logger); } }); @@ -226,6 +215,131 @@ class GameManager { return codeDigits.join(''); } + transferModeratorPowers = (game, person, namespace, logger) => { + if (person && (person.out || person.userType === globals.USER_TYPES.SPECTATOR)) { + logger.debug('game ' + game.accessCode + ': transferring mod powers to ' + person.name); + if (game.moderator === person) { + logger.debug('temp mod killed themselves'); + person.userType = globals.USER_TYPES.MODERATOR; + } else { + 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; + if (!game.spectators.includes(game.moderator)) { + game.spectators.push(game.moderator); + } + if (game.spectators.includes(person)) { + game.spectators.splice(game.spectators.indexOf(person), 1); + } + } + person.userType = globals.USER_TYPES.MODERATOR; + game.moderator = person; + } + + namespace.in(game.accessCode).emit(globals.EVENTS.SYNC_GAME_STATE); + } + } + + killPlayer = (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; + namespace.in(game.accessCode).emit(globals.CLIENT_COMMANDS.KILL_PLAYER, person.id); + // temporary moderators will transfer their powers automatically to the first person they kill. + if (game.moderator.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { + this.transferModeratorPowers(game, person, namespace, logger); + } + } + } + + /* Since clients are anonymous, we have to rely to some extent on a cookie to identify them. Socket ids + are unique to a client, but they are re-generated if a client disconnects and then reconnects. + Thus, to have the most resilient identification i.e. to let them refresh, navigate away and come back, + get disconnected and reconnect, etc. we should have a combination of the socket id and the cookie. + My philosophy is to make it exceptionally difficult for clients to _accidentally_ break their experience. + */ + handleRequestForGameState = (namespace, logger, gameRunner, accessCode, personCookie, ackFn, socket) => { + const game = gameRunner.activeGames[accessCode]; + if (game) { + let matchingPerson = game.people.find((person) => person.cookie === personCookie); + if (!matchingPerson) { + matchingPerson = game.spectators.find((spectator) => spectator.cookie === personCookie); + } + if (game.moderator.cookie === personCookie) { + matchingPerson = game.moderator; + } + if (matchingPerson) { + if (matchingPerson.socketId === socket.id) { + logger.trace("matching person found with an established connection to the room: " + matchingPerson.name); + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson, gameRunner, socket, logger)); + } else { + if (!this.roomContainsSocketOfMatchingPerson(namespace, matchingPerson, logger, accessCode)) { + logger.trace("matching person found with a new connection to the room: " + matchingPerson.name); + socket.join(accessCode); + matchingPerson.socketId = socket.id; + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson, gameRunner, socket, logger)); + } else { + logger.trace('this person is already associated with a socket connection'); + this.handleRequestFromNonMatchingPerson(game, socket, gameRunner, ackFn, logger); + } + } + } else { + this.handleRequestFromNonMatchingPerson(game, socket, gameRunner, ackFn, logger); + } + } else { + rejectClientRequestForGameState(ackFn); + logger.trace('the game ' + accessCode + ' was not found'); + } + } + + handleRequestFromNonMatchingPerson = (game, socket, gameRunner, ackFn, logger) => { + let personWithMatchingSocketId = findPersonWithMatchingSocketId(game.people, socket.id); + if (personWithMatchingSocketId) { + logger.trace("matching person found whose cookie got cleared after establishing a connection to the room: " + personWithMatchingSocketId.name); + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, personWithMatchingSocketId, gameRunner, socket, logger)); + } else { + let unassignedPerson = game.moderator.assigned === false + ? game.moderator + : game.people.find((person) => person.assigned === false); + if (unassignedPerson) { + logger.trace("completely new person with a first connection to the room: " + unassignedPerson.name); + unassignedPerson.assigned = true; + unassignedPerson.socketId = socket.id; + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, unassignedPerson, gameRunner, socket, logger)); + let isFull = isGameFull(game); + game.isFull = isFull; + socket.to(game.accessCode).emit( + globals.EVENTS.PLAYER_JOINED, + {name: unassignedPerson.name, userType: unassignedPerson.userType}, + isFull + ); + } else { // if the game is full, make them a spectator. + let spectator = new Person( + createRandomId(), + createRandomId(), + UsernameGenerator.generate(), + globals.USER_TYPES.SPECTATOR + ); + logger.trace("new spectator: " + spectator.name); + game.spectators.push(spectator); + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, spectator, gameRunner, socket, logger)); + } + socket.join(game.accessCode); + } + } + + // starting with socket.io 4.x, the rooms object is a Map, and its values a Set. + roomContainsSocketOfMatchingPerson = (namespace, matchingPerson, logger, accessCode) => { + return namespace.adapter + && namespace.adapter.rooms.get(accessCode) + && namespace.adapter.rooms.get(accessCode).has(matchingPerson.socketId); + } } @@ -237,8 +351,7 @@ function initializeModerator(name, hasDedicatedModerator) { const userType = hasDedicatedModerator ? globals.USER_TYPES.MODERATOR : globals.USER_TYPES.TEMPORARY_MODERATOR; - let moderator = new Person(createRandomId(), createRandomId(), name, userType); - return moderator; + return new Person(createRandomId(), createRandomId(), name, userType);; } function initializePeopleForGame(uniqueCards, moderator) { @@ -299,133 +412,6 @@ function createRandomId () { return id; } -class Singleton { - constructor (logger, environment) { - if (!Singleton.instance) { - logger.log('CREATING SINGLETON GAME MANAGER'); - Singleton.instance = new GameManager(logger, environment); - } - } - - getInstance () { - return Singleton.instance; - } -} - -function transferModeratorPowers(game, person, namespace, logger) { - if (person && (person.out || person.userType === globals.USER_TYPES.SPECTATOR)) { - logger.debug('game ' + game.accessCode + ': transferring mod powers to ' + person.name); - if (game.moderator === person) { - logger.debug('temp mod killed themselves'); - person.userType = globals.USER_TYPES.MODERATOR; - } else { - 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; - if (!game.spectators.includes(game.moderator)) { - game.spectators.push(game.moderator); - } - if (game.spectators.includes(person)) { - game.spectators.splice(game.spectators.indexOf(person), 1); - } - } - person.userType = globals.USER_TYPES.MODERATOR; - game.moderator = person; - } - - namespace.in(game.accessCode).emit(globals.EVENTS.SYNC_GAME_STATE); - } -} - -/* Since clients are anonymous, we have to rely to some extent on a cookie to identify them. Socket ids - are unique to a client, but they are re-generated if a client disconnects and then reconnects. - Thus, to have the most resilient identification i.e. to let them refresh, navigate away and come back, - get disconnected and reconnect, etc. we should have a combination of the socket id and the cookie. - My philosophy is to make it exceptionally difficult for clients to _accidentally_ break their experience. - */ -function handleRequestForGameState(namespace, logger, gameRunner, accessCode, personCookie, ackFn, socket) { - const game = gameRunner.activeGames[accessCode]; - if (game) { - let matchingPerson = game.people.find((person) => person.cookie === personCookie); - if (!matchingPerson) { - matchingPerson = game.spectators.find((spectator) => spectator.cookie === personCookie); - } - if (game.moderator.cookie === personCookie) { - matchingPerson = game.moderator; - } - if (matchingPerson) { - if (matchingPerson.socketId === socket.id) { - logger.trace("matching person found with an established connection to the room: " + matchingPerson.name); - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson, gameRunner, socket, logger)); - } else { - if (!roomContainsSocketOfMatchingPerson(namespace, matchingPerson, logger, accessCode)) { - logger.trace("matching person found with a new connection to the room: " + matchingPerson.name); - socket.join(accessCode); - matchingPerson.socketId = socket.id; - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson, gameRunner, socket, logger)); - } else { - logger.trace('this person is already associated with a socket connection'); - let alreadyConnectedSocket = namespace.connected[matchingPerson.socketId]; - if (alreadyConnectedSocket && alreadyConnectedSocket.leave) { - alreadyConnectedSocket.leave(accessCode); - logger.trace('kicked existing connection out of room ' + accessCode); - socket.join(accessCode); - matchingPerson.socketId = socket.id; - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson, gameRunner, socket, logger)); - } - } - } - } else { - let personWithMatchingSocketId = findPersonWithMatchingSocketId(game.people, socket.id); - if (personWithMatchingSocketId) { - logger.trace("matching person found whose cookie got cleared after establishing a connection to the room: " + personWithMatchingSocketId.name); - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, personWithMatchingSocketId, gameRunner, socket, logger)); - } else { - let unassignedPerson = game.moderator.assigned === false - ? game.moderator - : game.people.find((person) => person.assigned === false); - if (unassignedPerson) { - logger.trace("completely new person with a first connection to the room: " + unassignedPerson.name); - unassignedPerson.assigned = true; - unassignedPerson.socketId = socket.id; - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, unassignedPerson, gameRunner, socket, logger)); - let isFull = isGameFull(game); - game.isFull = isFull; - socket.to(accessCode).emit( - globals.EVENTS.PLAYER_JOINED, - {name: unassignedPerson.name, userType: unassignedPerson.userType}, - isFull - ); - } else { // if the game is full, make them a spectator. - let spectator = new Person( - createRandomId(), - createRandomId(), - UsernameGenerator.generate(), - globals.USER_TYPES.SPECTATOR - ); - logger.trace("new spectator: " + spectator.name); - game.spectators.push(spectator); - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, spectator, gameRunner, socket, logger)); - } - socket.join(accessCode); - } - } - } else { - rejectClientRequestForGameState(ackFn); - logger.trace('the game ' + accessCode + ' was not found'); - } -} - -// in socket.io 2.x , the rooms property is an object. in 3.x and 4.x, it is a javascript Set. -function roomContainsSocketOfMatchingPerson(namespace, matchingPerson, logger, accessCode) { - return namespace.adapter - && namespace.adapter.rooms[accessCode] - && namespace.adapter.rooms[accessCode].sockets[matchingPerson.socketId]; -} - function rejectClientRequestForGameState(acknowledgementFunction) { return acknowledgementFunction(null); } @@ -476,4 +462,18 @@ function pruneStaleGames(activeGames, timerThreads, logger) { } } +class Singleton { + constructor (logger, environment) { + if (!Singleton.instance) { + logger.log('CREATING SINGLETON GAME MANAGER'); + Singleton.instance = new GameManager(logger, environment); + } + } + + getInstance () { + return Singleton.instance; + } +} + + module.exports = Singleton; diff --git a/spec/unit/server/modules/GameManager_Spec.js b/spec/unit/server/modules/GameManager_Spec.js new file mode 100644 index 0000000..d0a8b4a --- /dev/null +++ b/spec/unit/server/modules/GameManager_Spec.js @@ -0,0 +1,257 @@ +// TODO: clean up these deep relative paths? jsconfig.json is not working... +const Game = require("../../../../server/model/Game"); +const globals = require("../../../../server/config/globals"); +const USER_TYPES = globals.USER_TYPES; +const Person = require("../../../../server/model/Person"); +const GameManager = require('../../../../server/modules/GameManager.js'); +const GameStateCurator = require("../../../../server/modules/GameStateCurator"); +const logger = require('../../../../server/modules/Logger.js')(false); + +describe('GameManager', function () { + let gameManager, namespace; + + beforeAll(function () { + spyOn(logger, 'debug'); + spyOn(logger, 'error'); + gameManager = new GameManager(logger, globals.ENVIRONMENT.PRODUCTION).getInstance(); + let inObj = { emit: () => {} } + namespace = { in: () => { return inObj }}; + }); + + beforeEach(function () { + }); + + describe('#transferModerator', function () { + it('Should transfer successfully from a dedicated moderator to a killed player', () => { + let personToTransferTo = new Person("1", "123", "Joe", USER_TYPES.KILLED_PLAYER); + personToTransferTo.out = true; + let moderator = new Person("3", "789", "Jack", USER_TYPES.MODERATOR) + let game = new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ personToTransferTo, new Person("2", "456", "Jane", USER_TYPES.PLAYER)], + [], + false, + moderator + ); + gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + + + expect(game.moderator).toEqual(personToTransferTo); + expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); + expect(moderator.userType).toEqual(USER_TYPES.SPECTATOR); + }); + + it('Should transfer successfully from a dedicated moderator to a spectator', () => { + let personToTransferTo = new Person("1", "123", "Joe", USER_TYPES.SPECTATOR); + let moderator = new Person("3", "789", "Jack", USER_TYPES.MODERATOR) + let game = new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ new Person("2", "456", "Jane", USER_TYPES.PLAYER)], + [], + false, + moderator + ); + game.spectators.push(personToTransferTo) + gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + + + expect(game.moderator).toEqual(personToTransferTo); + expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); + expect(moderator.userType).toEqual(USER_TYPES.SPECTATOR); + }); + + it('Should transfer successfully from a temporary moderator to a killed player', () => { + let personToTransferTo = new Person("1", "123", "Joe", USER_TYPES.KILLED_PLAYER); + personToTransferTo.out = true; + let tempMod = new Person("3", "789", "Jack", USER_TYPES.TEMPORARY_MODERATOR) + let game = new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ personToTransferTo, tempMod, new Person("2", "456", "Jane", USER_TYPES.PLAYER)], + [], + false, + tempMod + ); + gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + + + expect(game.moderator).toEqual(personToTransferTo); + expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); + expect(tempMod.userType).toEqual(USER_TYPES.PLAYER); + }); + + it('Should make the temporary moderator a dedicated moderator when they take themselves out of the game', () => { + let tempMod = new Person("3", "789", "Jack", USER_TYPES.TEMPORARY_MODERATOR); + let personToTransferTo = tempMod; + tempMod.out = true; + let game = new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ personToTransferTo, tempMod, new Person("2", "456", "Jane", USER_TYPES.PLAYER)], + [], + false, + tempMod + ); + gameManager.transferModeratorPowers(game, personToTransferTo, namespace, logger); + + + expect(game.moderator).toEqual(personToTransferTo); + expect(personToTransferTo.userType).toEqual(USER_TYPES.MODERATOR); + expect(tempMod.userType).toEqual(USER_TYPES.MODERATOR); + }); + }); + + describe('#killPlayer', function () { + it('Should mark a player as out and broadcast it, and should not transfer moderators if the moderator is a dedicated mod.', () => { + spyOn(namespace.in(), 'emit'); + spyOn(gameManager, 'transferModeratorPowers'); + let player = new Person("1", "123", "Joe", USER_TYPES.PLAYER); + let game = new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ player ], + [], + false, + new Person("2", "456", "Jane", USER_TYPES.MODERATOR) + ); + gameManager.killPlayer(game, player, namespace, logger); + + + expect(player.out).toEqual(true); + expect(player.userType).toEqual(USER_TYPES.KILLED_PLAYER); + expect(namespace.in().emit).toHaveBeenCalled(); + expect(gameManager.transferModeratorPowers).not.toHaveBeenCalled(); + }); + + it('Should mark a temporary moderator as out but preserve their user type, and call the transfer mod function', () => { + spyOn(namespace.in(), 'emit'); + spyOn(gameManager, 'transferModeratorPowers'); + let tempMod = new Person("1", "123", "Joe", USER_TYPES.TEMPORARY_MODERATOR); + let game = new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ tempMod ], + [], + false, + tempMod + ); + gameManager.killPlayer(game, tempMod, namespace, logger); + + + expect(tempMod.out).toEqual(true); + expect(tempMod.userType).toEqual(USER_TYPES.TEMPORARY_MODERATOR); + expect(namespace.in().emit).toHaveBeenCalled(); + expect(gameManager.transferModeratorPowers).toHaveBeenCalled(); + }); + }); + + describe('#handleRequestForGameState', function () { + it('should send the game state to a matching person with an active connection to the room', () => { + let player = new Person("1", "123", "Joe", USER_TYPES.PLAYER); + let socket = { id: "socket1"}; + spyOn(GameStateCurator, 'getGameStateFromPerspectiveOfPerson'); + player.socketId = "socket1"; + let gameRunner = { + activeGames: { + "abc": new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ player ], + [], + false, + new Person("2", "456", "Jane", USER_TYPES.MODERATOR) + ) + } + } + spyOn(namespace.in(), 'emit'); + gameManager.handleRequestForGameState( + namespace, + logger, + gameRunner, + "abc", + "123", + (arg) => {}, + socket + ); + + expect(GameStateCurator.getGameStateFromPerspectiveOfPerson) + .toHaveBeenCalledWith(gameRunner.activeGames["abc"], player, gameRunner, socket, logger); + }); + + it('should send the game state to a matching person who reset their connection', () => { + let player = new Person("1", "123", "Joe", USER_TYPES.PLAYER); + let socket = { id: "socket_222222", join: () => {}}; + spyOn(socket, 'join'); + spyOn(GameStateCurator, 'getGameStateFromPerspectiveOfPerson'); + spyOn(gameManager, 'roomContainsSocketOfMatchingPerson').and.callFake(() => { return false }); + player.socketId = "socket_111111"; + let gameRunner = { + activeGames: { + "abc": new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ player ], + [], + false, + new Person("2", "456", "Jane", USER_TYPES.MODERATOR) + ) + } + } + spyOn(namespace.in(), 'emit'); + gameManager.handleRequestForGameState( + namespace, + logger, + gameRunner, + "abc", + "123", + (arg) => {}, + socket + ); + + expect(GameStateCurator.getGameStateFromPerspectiveOfPerson) + .toHaveBeenCalledWith(gameRunner.activeGames["abc"], player, gameRunner, socket, logger); + expect(player.socketId).toEqual(socket.id); + expect(socket.join).toHaveBeenCalled(); + }); + + it('should seek to re-assign a socket connection should two connections match the same person', () => { + let player = new Person("1", "123", "Joe", USER_TYPES.PLAYER); + let socket = { id: "socket_222222", join: () => {}}; + let ackFn = () => {}; + spyOn(socket, 'join'); + spyOn(GameStateCurator, 'getGameStateFromPerspectiveOfPerson'); + spyOn(gameManager, 'handleRequestFromNonMatchingPerson'); + spyOn(gameManager, 'roomContainsSocketOfMatchingPerson').and.callFake(() => { return true }); + player.socketId = "socket_111111"; + let gameRunner = { + activeGames: { + "abc": new Game( + "abc", + globals.STATUS.IN_PROGRESS, + [ player ], + [], + false, + new Person("2", "456", "Jane", USER_TYPES.MODERATOR) + ) + } + } + spyOn(namespace.in(), 'emit'); + gameManager.handleRequestForGameState( + namespace, + logger, + gameRunner, + "abc", + "123", + ackFn, + socket + ); + + expect(GameStateCurator.getGameStateFromPerspectiveOfPerson).not.toHaveBeenCalled(); + expect(gameManager.handleRequestFromNonMatchingPerson).toHaveBeenCalledWith(gameRunner.activeGames["abc"], socket, gameRunner, ackFn, logger) + expect(player.socketId).not.toEqual(socket.id); + expect(socket.join).not.toHaveBeenCalled(); + }); + }); +});