import { GameState, Player, RoleId, StateLobby, StateNight } from '../types'; interface PlayerSocketMapping { gameId: string; playerId: string; socketId: string; } /** * Returns a promise which acts as a delay for the specified milliseconds. * @param ms Milliseconds to delay */ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * @returns Promise that resolves after a standard delay (3-5 seconds) */ function standardDelay(): Promise { const STANDARD_DELAY_MIN = 3000; const STANDARD_DELAY_MAX = 5000; const delayMs = Math.floor(Math.random() * (STANDARD_DELAY_MAX - STANDARD_DELAY_MIN + 1)) + STANDARD_DELAY_MIN; return delay(delayMs); } /** * Randomly returns true with probability p. * @param p Probability of true */ function flip(p: number): boolean { return Math.random() < p; } function shuffle(array: T[]): T[] { const a = [...array]; // Copy to avoid mutating for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } /* * Selects n random elements from the array, and returns a tuple of * [selectedElements, remainingElements]. * Preserves the original order of elements. */ function chooseN(array: T[], n: number): [T[], T[]] { array = [...array]; // Copy to avoid mutating if (n < 0 || n > array.length) { throw new Error("n must be between 0 and array length"); } // Generate a list of all indices const indices = array.map((_, i) => i); // Shuffle the indices const shuffledIndices = shuffle(indices); // Select the first n indices const selectedIndices = new Set(shuffledIndices.slice(0, n)); // Partition the array based on selected indices const selected: T[] = []; const remaining: T[] = []; array.forEach((item, index) => { if (selectedIndices.has(index)) { selected.push(item); } else { remaining.push(item); } }); return [selected, remaining]; } class GameStateManager { private games: Map = new Map(); private socketToPlayer: Map = new Map(); private playerToSocket: Map = new Map(); createGame(name: string): GameState { const id = this.generateGameId(); const game: GameState = { id, name, gameLog: [], state: { state: 'lobby', ruleset: { roleOrder: [], special: { maybeAllTanners: { enabled: false, probability: 0.05 }, }, }, players: [], }, createdAt: new Date(), }; this.games.set(id, game); return game; } maybeGetGame(id: string): GameState | undefined { return this.games.get(id); } getAllGames(): GameState[] { return Array.from(this.games.values()); } maybeDeleteGame(id: string): boolean { return this.games.delete(id); } maybeAddPlayer(gameId: string, playerName: string): Player | null { const game = this.games.get(gameId); if (!game) return null; const playerId = this.generatePlayerId(); const player: Player = { id: playerId, name: playerName, connected: true }; game.state.players.push(player); return player; } maybeRemovePlayer(gameId: string, playerId: string): boolean { const game = this.games.get(gameId); if (!game) return false; if (game.state.state !== 'lobby') return false; const playerIndex = game.state.players.findIndex(p => p.id === playerId); if (playerIndex === -1) return false; game.state.players.splice(playerIndex, 1); if (game.state.players.length === 0) { this.games.delete(gameId); } return true; } maybeUpdatePlayerConnection(gameId: string, playerId: string, connected: boolean): boolean { const game = this.games.get(gameId); if (!game) return false; const player = game.state.players.find(p => p.id === playerId); if (!player) return false; player.connected = connected; return true; } maybeRejoinPlayer(gameId: string, playerId: string): { player: Player; game: GameState } | null { console.debug(`Attempting to rejoin player ${playerId} to game ${gameId}`); const game = this.games.get(gameId); if (!game) { console.debug(`Rejoin failed: game ${gameId} not found`); return null; } const player = game.state.players.find(p => p.id === playerId); if (!player) { console.debug(`Rejoin failed: player ${playerId} not found in game ${gameId}`); return null; } // Only allow rejoining if player is currently disconnected if (player.connected) return null; player.connected = true; return { player, game }; } private generateGameId(): string { return Math.random().toString(10).substring(2, 8); } private generatePlayerId(): string { return Math.random().toString(36).substring(2, 15); } // Socket registry methods maybeRegisterPlayerSocket(gameId: string, playerId: string, socketId: string): boolean { const game = this.games.get(gameId); if (!game) return false; const player = game.state.players.find(p => p.id === playerId); if (!player) return false; // Remove any existing socket for this player const existingSocketId = this.playerToSocket.get(playerId); if (existingSocketId) { this.socketToPlayer.delete(existingSocketId); } // Register the new socket const mapping: PlayerSocketMapping = { gameId, playerId, socketId }; this.socketToPlayer.set(socketId, mapping); this.playerToSocket.set(playerId, socketId); // Update player connection status player.connected = true; return true; } maybeUnregisterSocket(socketId: string): PlayerSocketMapping | null { const mapping = this.socketToPlayer.get(socketId); if (!mapping) return null; // Remove from both mappings this.socketToPlayer.delete(socketId); this.playerToSocket.delete(mapping.playerId); // Update player connection status this.maybeUpdatePlayerConnection(mapping.gameId, mapping.playerId, false); return mapping; } maybeGetSocketMapping(socketId: string): PlayerSocketMapping | null { return this.socketToPlayer.get(socketId) || null; } maybeGetPlayerSocket(playerId: string): string | null { return this.playerToSocket.get(playerId) || null; } isPlayerConnected(playerId: string): boolean { return this.playerToSocket.has(playerId); } maybeUpdateRuleset(gameId: string, newRuleset: StateLobby["ruleset"]): GameState | null { const game = this.games.get(gameId); if (!game) return null; // Only allow updating ruleset if game is in lobby state if (game.state.state !== 'lobby') return null; // Update the ruleset game.state.ruleset = newRuleset; return game; } async maybeStartGame(gameId: string): Promise { const game = this.games.get(gameId); if (!game) return null; // Only allow starting the game if invariants hold if (game.state.state !== 'lobby') return null; else if (game.state.players.length + 3 !== game.state.ruleset.roleOrder.length) return null; // There must be either 0 or 2 "50/50 duo cop" roles const count5050DuoCop = game.state.ruleset.roleOrder.filter(roleId => roleId === '50/50 duo cop').length; if (count5050DuoCop !== 0 && count5050DuoCop !== 2) return null; // Transform cops into sane/insane variants if present if (count5050DuoCop === 2) { const firstIsSane = flip(0.5); game.state.ruleset.roleOrder = game.state.ruleset.roleOrder.map(roleId => { if (roleId === '50/50 duo cop') { return firstIsSane ? '50/50 duo cop(sane)' : '50/50 duo cop(insane)'; } return roleId; }); } // Transition to in-game state if (game.state.ruleset.special.maybeAllTanners.enabled) { if (flip(game.state.ruleset.special.maybeAllTanners.probability)) { // Set all players to Tanner const playerIdsByWakeupOrder = shuffle(game.state.players.map(p => [p.id])); game.state = { state: 'roleAssignment', playerIdsByWakeupOrder, playerData: Object.fromEntries( game.state.players.map(player => [player.id, { originalRoleId: 'tanner', currentRoleId: 'tanner', playerLog: [], }]) ), centerCards: ['tanner', 'tanner', 'tanner'], ruleset: { ...game.state.ruleset }, playerConfirmations: Object.fromEntries( game.state.players.map(player => [player.id, false]) ), players: [...game.state.players], }; return game; } } /** * Selects 3 center cards such that the role distribution is "happy". * A "happy" role distribution is one where: * - There are either 0 or 2 "50/50 duo cop" roles among players */ const divideRolesHappily = (roleOrder: RoleId[]) => { while (true) { const [unshuffledCenterCards, remainingRoles] = chooseN(game.state.ruleset.roleOrder, 3); const count5050DuoCopInPlayers = remainingRoles.filter(roleId => roleId === '50/50 duo cop(sane)' || roleId === '50/50 duo cop(insane)').length; if (count5050DuoCopInPlayers === 0 || count5050DuoCopInPlayers === 2) { return [unshuffledCenterCards, remainingRoles]; } } } const [unshuffledCenterCards, remainingRoles] = divideRolesHappily(game.state.ruleset.roleOrder); const centerCards = shuffle(unshuffledCenterCards); const playerIdsByWakeupOrder: Player["id"][][] = []; const playerIds = shuffle(game.state.players.map(p => p.id)); const playerData: StateNight["playerData"] = Object.fromEntries( playerIds.map((id, index) => { if (index > 0 && remainingRoles[index - 1] === 'werewolf' && remainingRoles[index] === 'werewolf') { // Group werewolves for simultaneous wake-up playerIdsByWakeupOrder[playerIdsByWakeupOrder.length - 1].push(id); } else { playerIdsByWakeupOrder.push([id]); } return [id, { originalRoleId: remainingRoles[index], currentRoleId: remainingRoles[index], playerLog: [], }]; }) ); game.state = { state: 'roleAssignment', playerIdsByWakeupOrder, playerData, centerCards, ruleset: { ...game.state.ruleset }, playerConfirmations: Object.fromEntries( game.state.players.map(player => [player.id, false]) ), players: [...game.state.players], } return game; } async maybeConfirmPlayerRoleAssignment(gameId: string, playerId: string): Promise { await standardDelay(); const game = this.games.get(gameId); if (!game) return null; // Only allow confirming role assignment if game is in roleAssignment state if (game.state.state !== 'roleAssignment') return null; // Update the player's confirmation status game.state.playerConfirmations[playerId] = true; // Check if all players have confirmed const allConfirmed = Object.values(game.state.playerConfirmations).every(confirmed => confirmed); if (allConfirmed) { // Transition to night state game.state = { state: 'night', playerIdsByWakeupOrder: game.state.playerIdsByWakeupOrder, playerData: game.state.playerData, centerCards: game.state.centerCards, ruleset: game.state.ruleset, turn: 0, endedTurn: game.state.playerIdsByWakeupOrder.map(playerIds => { return Object.fromEntries( playerIds.map(id => [id, false]) ); }), players: [...game.state.players], }; } return game; } async maybeEndPlayerTurn(gameId: string, playerId: string): Promise { const game = this.games.get(gameId); if (!game) return null; // Only allow ending turn if game is in night state if (game.state.state !== 'night') return null; if (!(playerId in game.state.endedTurn[game.state.turn])) { return null; } game.state.endedTurn[game.state.turn][playerId] = true; // Check if all players for the current turn have ended their turn const allEnded = Object.values(game.state.endedTurn[game.state.turn]).every(ended => ended); if (allEnded) { // Move to next turn game.state.turn += 1; // If all turns are completed, transition to day state if (game.state.turn >= game.state.playerIdsByWakeupOrder.length) { game.state = { state: 'day', playerData: game.state.playerData, centerCards: game.state.centerCards, ruleset: game.state.ruleset, players: [...game.state.players], }; } } return game; } } export const gameStateManager = new GameStateManager();