🌳
pt0/gamesapp/testsF/testHarnessAI.mts
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 */
20export const createTestHarness = ({ seed = 'test', gameRoom = 'testroom', fakeIp, playerCount }: CreateHarnessOpts = {}): TestHarness => {
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> = {}
25 let actionIdx = 0
26 let gameState: AnyGameState = {
28 id: `test-${seed}`,
29 isEphemeralGame: true,
30 gameActionHistA: [],
31 playerStates: {},
32 hiddenState: { paperStacks: {} },
33 settings: {
34 maxHumPlayers: 50,
35 aiDescriptEnabled: false,
36 aiDrawEnabled: false,
37 aiDrawingAfterFirstHumanDescript: false,
38 aiDrawerUsername: null,
39 blockedIps: []
40 }
41 }
42 const gamesById = (global as any).gamesStore ||= {}
43 gamesById[gameRoom] = gameState
45 const applyAction = async (actionH: GameAction) => {
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
57 try {
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) {
62 validateGameActionInput({ actionJSON: fullAction, gameRoom })
63 }
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 })
71 }
73 await applyGameAction({ existingGame: gameState, gameRoom, gameActionH: fullAction })
74 if (gamesById[gameRoom] !== gameState) {
75 gameState = gamesById[gameRoom]
76 }
77 if (gameState.gameActionHistA && (gameActionFncs as any)[playerAction]?.pushToHistory !== false) {
78 gameState.gameActionHistA.push(fullAction)
79 }
81 if (gameState.needsAiDrawings) {
82 const aiActionHistA = await addAiDrawingsToStacks({existingGame: gameState})
83 if (aiActionHistA.length > 0) {
84 gameState.gameActionHistA.push(...aiActionHistA)
85 update1PlayerStates({existingGame: gameState})
86 }
87 delete gameState.needsAiDrawings
88 }
90 return { ok: true, gameState }
91 } catch (err) {
92 return { ok: false, error: err }
93 }
94 }
96 /** Set a specific IP address for a player (for IP blocking tests) */
97 const setPlayerIp = (sessId: string, ip: string) => {
98 playerIps[sessId] = ip
99 }
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
106 }
107 }
109 /** Simulate the server timeout checker for a specific player */
110 const triggerTimeoutCheck = async (sessId: string) => {
111 const nowMs = luxNow().toMillis()
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 }
118 const deadlineMs = getDeadlineMs({ myTaskName, gameActiveAt })
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) }
132 }
134 return { kicked: false, remainingSec: Math.round((deadlineMs - nowMs) / 1000) }
135 }
137 return {
138 applyAction,
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 || {},
144 setGameActiveAt,
145 triggerTimeoutCheck,
146 setPlayerIp,
147 resetBlockedIps: () => { gameState.settings.blockedIps = [] },
148 }
151/**
152 * Replay a game from action history (from DB or fixtures)
153 */
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 })
159 const results: ApplyActionResult[] = []
161 for (const action of gameActionHistA) {
162 const result = await harness.applyAction(action)
163 results.push(result)
164 if (!result.ok) break
165 }
167 return {
168 harness,
169 results,
170 allPassed: results.every(r => r.ok),
171 failedAt: results.findIndex(r => !r.ok),
172 }