1import * as _ from 'lodash-es' 16// eslint-disable-next-line @typescript-eslint/no-explicit-any 17type AnyGameState = any 19/** Lightweight test harness for game engine - no DB, instant execution */ 21 // Each harness gets a unique fake IP based on seed to avoid rate limit collisions 22 // Use full seed as IP to guarantee uniqueness (IPv6-like format works fine as rate limit key) 23 const defaultIp = fakeIp || `10.${seed.slice(0, 8)}.test` 24 const playerIps: Record<string, string> = {} 26 let gameState: AnyGameState = { 29 isEphemeralGame: true, 32 hiddenState: { paperStacks: {} }, 35 aiDescriptEnabled: false, 37 aiDrawingAfterFirstHumanDescript: false, 38 aiDrawerUsername: null, 42 const gamesById = (global as any).gamesStore ||= {} 43 gamesById[gameRoom] = gameState 46 const { sessId, playerAction, actionAtMs: providedAtMs, ...rest } = actionH 47 // Use provided timestamp from real game history, or generate synthetic one 48 const actionAtMs = providedAtMs ?? (actionIdx++ * 1000) 49 const fullAction = { sessId, playerAction, actionAtMs, ...rest } 51 // Use per-player IP if set, otherwise default 52 const ipAddress = playerIps[sessId] || defaultIp 54 gameRoomCtx.enterWith({ gameRoom }) // ctx:clear 55 gameActionCtx.enterWith({ ...fullAction, existingGame: gameState, ipAddress }) // ctx:clear 58 // Validate input sizes + rate limiting - redundant with mutations.mts but ensures 59 // db replay tests catch if our limits are too strict for real historical games 60 // Skip validation for AI-generated actions (server-generated, can exceed client limits) 61 if (!fullAction.isAiGenerated) { 65 // Call pregenFnc like doGameAction does (for AI drawing after description, etc) 66 // Skip if pregenFncRet already present (e.g., replaying from DB) 67 const gameActionFnc = (gameActionFncs as any)[playerAction] 68 if (gameActionFnc?.pregenFnc && !(fullAction as any).pregenFncRet) { 69 const pregenFncRet = await gameActionFnc.pregenFnc() 70 if (pregenFncRet) Object.assign(fullAction, { pregenFncRet }) 73 await applyGameAction({ existingGame: gameState, gameRoom, gameActionH: fullAction }) 74 if (gamesById[gameRoom] !== gameState) { 75 gameState = gamesById[gameRoom] 77 if (gameState.gameActionHistA && (gameActionFncs as any)[playerAction]?.pushToHistory !== false) { 78 gameState.gameActionHistA.push(fullAction) 81 if (gameState.needsAiDrawings) { 83 if (aiActionHistA.length > 0) { 84 gameState.gameActionHistA.push(...aiActionHistA) 87 delete gameState.needsAiDrawings 90 return { ok: true, gameState } 92 return { ok: false, error: err } 96 /** Set a specific IP address for a player (for IP blocking tests) */ 97 const setPlayerIp = (sessId: string, ip: string) => { 98 playerIps[sessId] = ip 101 /** Set a player's gameActiveAt to simulate time having passed */ 102 const setGameActiveAt = (sessId: string, gameActiveAtMs: number) => { 103 const playerState = gameState.playerStates[sessId] 104 if (playerState?.publicState) { 105 playerState.publicState.gameActiveAt = gameActiveAtMs 109 /** Simulate the server timeout checker for a specific player */ 110 const triggerTimeoutCheck = async (sessId: string) => { 112 const playerState = gameState.playerStates[sessId] 113 if (!playerState || playerState.hasBeenKicked) return { kicked: false } 115 const { gameActiveAt, myTaskName } = playerState.publicState || {} 116 if (!gameActiveAt || myTaskName === 'WaitingTask') return { kicked: false } 119 if (!deadlineMs) return { kicked: false } 121 if (nowMs > deadlineMs) { 122 const gameActionH = { playerAction: 'kickAwol', sessId, actionAtMs: nowMs } 124 gameRoomCtx.enterWith({ gameRoom }) // ctx:clear 125 gameActionCtx.enterWith({ existingGame: gameState, sessId, actionAtMs: nowMs }) // ctx:clear 126 await kickAwol({ existingGame: gameState, sessId }) 128 // Record in history for replay consistency 129 gameState.gameActionHistA.push(gameActionH) 131 return { kicked: true, overdueSec: Math.round((nowMs - deadlineMs) / 1000) } 134 return { kicked: false, remainingSec: Math.round((deadlineMs - nowMs) / 1000) } 139 getState: () => gameState, 140 getActivePlayers: () => _.map(_.filter(_.toPairs(gameState.playerStates) as [string, AnyGameState][], 141 ([, ps]) => !ps.hasBeenKicked), ([sessId, ps]) => ({ sessId, ...ps as object })), 142 getPlayer: (sessId: string) => gameState.playerStates[sessId], 143 getPaperStacks: () => gameState.hiddenState?.paperStacks || {}, 147 resetBlockedIps: () => { gameState.settings.blockedIps = [] }, 152 * Replay a game from action history (from DB or fixtures) 154export const replayGame = async ({ gameActionHistA, seed = 'replay' }: { gameActionHistA: GameAction[], seed?: string }) => { 155 // Mark as replay mode to suppress escape hatch notifications 156 isReplayGameCtx.enterWith({isReplay: true}) // ctx:clear 158 const harness = createTestHarness({ seed }) 161 for (const action of gameActionHistA) { 162 const result = await harness.applyAction(action) 164 if (!result.ok) break 170 allPassed: results.every(r => r.ok), 171 failedAt: results.findIndex(r => !r.ok),