Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
13 kB · 431 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431import { 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<void> { return new Promise(resolve => setTimeout(resolve, ms));}
/** * @returns Promise that resolves after a standard delay (3-5 seconds) */function standardDelay(): Promise<void> { 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<T>(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<T>(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<string, GameState> = new Map(); private socketToPlayer: Map<string, PlayerSocketMapping> = new Map(); private playerToSocket: Map<string, string> = 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<GameState | null> { 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<GameState | null> { 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<GameState | null> { 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();