🌳
pt0/gamesapp/testsF/scenariosAI.mts
1// @ts-nocheck - TODO: tighten types incrementally (26 "possibly undefined" errors)
2import * as _ from 'lodash-es'
16type SetupGameOpts = { seed: string, playerCount?: number, createHarness?: CreateHarnessFn }
18const setupGame = async ({ seed, playerCount = 4, createHarness = createTestHarness }: SetupGameOpts) => {
19 const harness = await createHarness({ seed, playerCount })
20 const agents = createAgentTeam({ count: playerCount, seed })
21 return { harness, agents }
24const joinAllPlayers = async (harness: TestHarness, agents: TestAgent[]) => {
25 for (const a of agents) await harness.applyAction({ sessId: a.sessId, playerAction: 'setPlayerAlias', playerAlias: a.playerAlias })
28const doInitialPrompts = async (harness: TestHarness, agents: TestAgent[]) => {
29 for (const a of agents) {
30 const action = a.chooseAction(harness.getState())
31 if (action) await harness.applyAction(action)
32 }
35const withScenario = async <T>({ seed, playerCount = 4, createHarness = createTestHarness, doPrompts = true }: SetupGameOpts & {doPrompts?: boolean}, fn: (harness: TestHarness, agents: TestAgent[]) => Promise<T>): Promise<T> => {
36 const { harness, agents } = await setupGame({ seed, playerCount, createHarness })
37 await joinAllPlayers(harness, agents)
38 if (doPrompts) await doInitialPrompts(harness, agents)
39 try {
40 return await fn(harness, agents)
41 } finally {
42 await harness.cleanup?.()
43 }
46const mkPastDeadlineMs = (taskName: string) => {
47 const nowMs = luxNow().toMillis()
48 const taskLimitMin = taskTimeLimitsMin[taskName]
49 return nowMs - (taskLimitMin * 60 * 1000) - (gracePeriodSec * 1000) - 5000
52export const scenarios = {
53 happyPath4Players: async ({ createHarness = createTestHarness, onRound, isLive = false }: ScenarioOpts = {}): Promise<TestResult> => {
54 const harness = await createHarness({ seed: 'happy4', playerCount: 4 })
55 const agents = createAgentTeam({ count: 4, seed: 'happy4' })
57 // For live tests, use fewer rounds since we can't reliably detect game finish
58 const maxRounds = isLive ? 20 : 100
59 const result = await runAgentsUntilDone({ harness, agents, maxRounds, onRound })
61 // Check invariants after completion (skip for live harness - state structure differs)
62 if (harness.getState()?.gameActionHistA) {
63 assertInvariants(harness.getState(), 'happyPath4Players-end')
64 }
66 // For live tests, success = ran without crashing and had steady progress
67 if (isLive) {
68 await harness.cleanup?.()
69 if (result.rounds >= maxRounds || result.isGameFin) {
70 return pass(`Live test completed ${result.rounds} rounds successfully`)
71 }
72 // If we stopped early due to no actions, that's also ok for POC
73 return pass(`Live test ran ${result.rounds} rounds (stopped: no actions)`)
74 }
76 if (!result.isGameFin) {
77 return { ...fail(`Game did not finish after ${result.rounds} rounds`), details: result }
78 }
80 // Verify all stacks have multiple responses
81 const stacks = harness.getPaperStacks()
82 const stackLengths = _.map(stacks, s => s.responses?.length || 0)
83 const minResponses = _.min(stackLengths)
85 if (minResponses < 2) {
86 return fail(`Some stacks have too few responses: ${stackLengths}`)
87 }
89 // Cleanup if harness supports it (live harness needs browser cleanup)
90 await harness.cleanup?.()
92 return { ...pass(`${result.rounds} rounds`), details: { stackLengths } }
93 },
95 playerLeaveMidGame: async ({ createHarness = createTestHarness } = {}) => {
96 return withScenario({ seed: 'leave1', playerCount: 5, createHarness }, async (harness, agents) => {
97 await harness.applyAction({ sessId: agents[2].sessId, playerAction: 'leaveGame' })
98 if (harness.getState()?.gameActionHistA) {
99 assertInvariants(harness.getState(), 'after-leave')
100 }
102 const remainingAgents = agents.filter(a => a.sessId !== agents[2].sessId)
103 const result = await runAgentsUntilDone({ harness, agents: remainingAgents, maxRounds: 100 })
104 if (harness.getState()?.gameActionHistA) {
105 assertInvariants(harness.getState(), 'playerLeaveMidGame-end')
106 }
108 return result.isGameFin
109 ? pass(`${result.rounds} rounds`)
110 : fail(`Game did not finish after player left (${result.rounds} rounds)`)
111 })
112 },
114 wrongStackRejected: async ({ createHarness = createTestHarness } = {}) => {
115 return withScenario({ seed: 'wrongstack', createHarness }, async (harness, agents) => {
116 const players = harness.getActivePlayers()
117 const playerWithTask = players.find(p => p.assignedStackName && p.publicState.myTaskName !== 'WaitingTask')
118 if (!playerWithTask) return fail('No player with task found for test')
120 const wrongStack = players.find(p => p.assignedStackName !== playerWithTask.assignedStackName)?.assignedStackName
121 if (!wrongStack) return fail('Could not find wrong stack for test')
123 const result = await harness.applyAction({
124 sessId: playerWithTask.sessId, playerAction: 'submitDrawing',
125 finishedTrails: [{ points: [{ x: 1, y: 1 }] }], assignedStackName: wrongStack,
126 })
128 return result.ok
129 ? fail('Wrong stack submission should have been rejected!')
130 : pass('')
131 })
132 },
134 largePlayers: async ({ createHarness = createTestHarness } = {}) => {
135 const harness = await createHarness({ seed: 'large8', playerCount: 8 })
136 const agents = createAgentTeam({ count: 8, seed: 'large8' })
138 const result = await runAgentsUntilDone({ harness, agents, maxRounds: 200 })
139 if (harness.getState()?.gameActionHistA) {
140 assertInvariants(harness.getState(), 'largePlayers-end')
141 }
143 await harness.cleanup?.()
145 return result.isGameFin ? pass(`8p, ${result.rounds} rounds`) : fail(`8-player game stuck at ${result.rounds} rounds`)
146 },
148 tooFewPlayersBlocked: async ({ createHarness = createTestHarness } = {}) => {
149 return withScenario({ seed: 'toofew', playerCount: 3, createHarness }, async (harness, agents) => {
150 const result = await runAgentsUntilDone({ harness, agents, maxRounds: 20 })
151 return result.isGameFin
152 ? fail(`Game finished with only 3 players! minNoPlayers=${minNoPlayers}`)
153 : pass('')
154 })
155 },
157 rejoinAfterLeave: async ({ createHarness = createTestHarness } = {}) => {
158 return withScenario({ seed: 'rejoin', playerCount: 5, createHarness, doPrompts: false }, async (harness, agents) => {
159 await harness.applyAction({ sessId: agents[2].sessId, playerAction: 'leaveGame' })
161 const rejoinResult = await harness.applyAction({ sessId: 'newSessId', playerAction: 'setPlayerAlias', playerAlias: agents[2].playerAlias })
162 if (!rejoinResult.ok) return fail('Rejoin action failed')
164 const rejoinedPlayer = Object.values(harness.getState()?.playerStates || {}).find(
165 ps => ps.publicState?.playerAlias === agents[2].playerAlias && !ps.hasBeenKicked
166 )
167 if (!rejoinedPlayer) return fail('Rejoined player not found or still kicked')
168 return pass('')
169 })
170 },
172 rejoinAfterAwolKick: async ({ createHarness = createTestHarness } = {}) => {
173 return withScenario({ seed: 'rejoinawol', playerCount: 5, createHarness }, async (harness, agents) => {
174 const players = harness.getActivePlayers()
175 const targetPlayer = players.find(p => p.publicState?.myTaskName && p.publicState.myTaskName !== 'WaitingTask')
176 if (!targetPlayer) return fail('No player with active task found')
178 const targetSessId = targetPlayer.sessId
179 const targetAlias = targetPlayer.publicState.playerAlias
180 const pastDeadlineMs = mkPastDeadlineMs(targetPlayer.publicState.myTaskName)
181 harness.setGameActiveAt(targetSessId, pastDeadlineMs)
183 const kickResult = await harness.triggerTimeoutCheck(targetSessId)
184 if (!kickResult.kicked) return fail('Player was not kicked by timeout')
186 const newSessId = 'rejoin-sessid'
187 const rejoinResult = await harness.applyAction({ sessId: newSessId, playerAction: 'setPlayerAlias', playerAlias: targetAlias })
188 if (!rejoinResult.ok) return fail(`Rejoin action failed: ${rejoinResult.error?.message}`)
190 const rejoinedPlayer = harness.getPlayer(newSessId)
191 if (rejoinedPlayer?.hasBeenKicked) return fail('Rejoined player is still marked as kicked')
193 const rejoinedGameActiveAt = rejoinedPlayer?.publicState?.gameActiveAt
194 if (rejoinedGameActiveAt && rejoinedGameActiveAt === pastDeadlineMs) return fail('gameActiveAt was not reset on rejoin (still has old expired value)')
196 harness.setGameActiveAt(newSessId, pastDeadlineMs)
197 const reKickResult = await harness.triggerTimeoutCheck(newSessId)
198 if (!reKickResult.kicked) return fail('Could not re-kick player after manually setting expired time (timeout check broken?)')
199 return pass(targetAlias)
200 })
201 },
203 emptySubmissionRejected: async ({ createHarness = createTestHarness } = {}) => {
204 return withScenario({ seed: 'empty', createHarness, doPrompts: false }, async (harness, agents) => {
205 const result = await harness.applyAction({
206 sessId: agents[0].sessId, playerAction: 'submitDescription',
207 descriptionText: '', assignedStackName: agents[0].playerAlias,
208 })
209 return result.ok
210 ? fail('Empty description should have been rejected!')
211 : pass('')
212 })
213 },
215 kickAfterGraceInitialPrompt: async ({ createHarness = createTestHarness } = {}) => {
216 return withScenario({ seed: 'graceinit', playerCount: 5, createHarness, doPrompts: false }, async (harness, agents) => {
217 const targetSessId = agents[0].sessId
218 const targetAlias = agents[0].playerAlias
220 const playerBefore = harness.getPlayer(targetSessId)
221 if (playerBefore.publicState?.myTaskName !== 'InitialPrompt') return fail(`Expected InitialPrompt task, got ${playerBefore.publicState?.myTaskName}`)
222 if (!playerBefore.publicState?.gameActiveAt) return fail('gameActiveAt not set for InitialPrompt')
224 harness.setGameActiveAt(targetSessId, mkPastDeadlineMs('InitialPrompt'))
225 const timeoutResult = await harness.triggerTimeoutCheck(targetSessId)
226 if (!timeoutResult.kicked) return fail(`Player should have been kicked on InitialPrompt timeout`)
227 return pass(targetAlias)
228 })
229 },
231 kickAfterGrace: async ({ createHarness = createTestHarness } = {}) => {
232 return withScenario({ seed: 'grace1', playerCount: 5, createHarness }, async (harness, agents) => {
233 const players = harness.getActivePlayers()
234 const targetPlayer = players.find(p => p.publicState?.myTaskName === 'DrawDescription')
235 if (!targetPlayer) return fail('No player with DrawDescription task found for test')
237 const targetSessId = targetPlayer.sessId
238 const targetAlias = targetPlayer.publicState.playerAlias
240 harness.setGameActiveAt(targetSessId, mkPastDeadlineMs('DrawDescription'))
241 const timeoutResult = await harness.triggerTimeoutCheck(targetSessId)
243 if (!timeoutResult.kicked) return fail(`Player should have been kicked but wasn't (remainingSec=${timeoutResult.remainingSec})`)
245 const kickedPlayer = harness.getPlayer(targetSessId)
246 if (!kickedPlayer?.hasBeenKicked) return fail('Player was not marked as kicked')
248 if (harness.getState()?.gameActionHistA) assertInvariants(harness.getState(), 'after-grace-kick')
249 return pass(`${targetAlias} ${timeoutResult.overdueSec}s overdue`)
250 })
251 },
253 gameCompletesWith3PlayersAnd4Stacks: async ({ createHarness = createTestHarness } = {}) => {
254 return withScenario({ seed: 'stacks4players3', playerCount: 5, createHarness }, async (harness, agents) => {
255 await harness.applyAction({ sessId: agents[0].sessId, playerAction: 'leaveGame' })
256 await harness.applyAction({ sessId: agents[1].sessId, playerAction: 'leaveGame' })
258 const remainingPlayers = harness.getActivePlayers()
259 if (remainingPlayers.length !== 3) return fail(`Expected 3 remaining players, got ${remainingPlayers.length}`)
261 const nonBlankStacks = Object.values(harness.getPaperStacks()).filter(s => s.responses?.length > 0)
262 if (nonBlankStacks.length < 4) return fail(`Expected 4+ non-blank stacks, got ${nonBlankStacks.length}`)
264 const remainingAgents = agents.slice(2)
265 const result = await runAgentsUntilDone({ harness, agents: remainingAgents, maxRounds: 100 })
267 if (harness.getState()?.gameActionHistA) assertInvariants(harness.getState(), 'after-3players-4stacks')
269 return result.isGameFin
270 ? pass(`3p, ${nonBlankStacks.length} stacks, ${result.rounds} rounds`)
271 : fail(`Game did not finish with 3 players (${result.rounds} rounds)`)
272 })
273 },
275 gameCompletesAfterAwolKick: async ({ createHarness = createTestHarness } = {}) => {
276 return withScenario({ seed: 'awol1', playerCount: 5, createHarness }, async (harness, agents) => {
277 const players = harness.getActivePlayers()
278 const targetPlayer = players.find(p => p.publicState?.myTaskName && p.publicState.myTaskName !== 'WaitingTask')
279 if (!targetPlayer) return fail('No player with active task found')
281 const targetSessId = targetPlayer.sessId
282 harness.setGameActiveAt(targetSessId, mkPastDeadlineMs(targetPlayer.publicState.myTaskName))
283 await harness.triggerTimeoutCheck(targetSessId)
285 const remainingAgents = agents.filter(a => a.sessId !== targetSessId)
286 const result = await runAgentsUntilDone({ harness, agents: remainingAgents, maxRounds: 100 })
288 if (harness.getState()?.gameActionHistA) assertInvariants(harness.getState(), 'after-awol-complete')
290 return result.isGameFin
291 ? pass(`${result.rounds} rounds`)
292 : fail(`Game did not finish after AWOL kick (${result.rounds} rounds)`)
293 })
294 },
296 aiToggleDisablesEndOfGameDrawings: async ({ createHarness = createTestHarness } = {}) => {
297 // Ensure mock mode so we don't call real OpenRouter
298 if (!aiDrawMockCtx.getStore()) aiDrawMockCtx.enterWith({ useMock: true }) // ctx:clear
300 // With aiDrawEnabled: false (default), AI drawings should NOT be added
301 // Use 5 players: odd count means some stacks end on description (desc→draw→desc→draw→desc)
302 const harness = await createHarness({ seed: 'aitoggle-off', playerCount: 5 })
303 const agents = createAgentTeam({ count: 5, seed: 'aitoggle-off' })
305 const result = await runAgentsUntilDone({ harness, agents, maxRounds: 100 })
306 if (!result.isGameFin) {
307 await harness.cleanup?.()
308 return fail(`Game did not finish (${result.rounds} rounds)`)
309 }
311 // Check that no AI-generated drawings exist
312 const stacks = harness.getPaperStacks()
313 const aiDrawings = _.filter(_.flatMap(_.values(stacks), s => s.responses || []), r => r.isAiGenerated)
315 await harness.cleanup?.()
317 if (aiDrawings.length > 0) {
318 return fail(`Found ${aiDrawings.length} AI drawings with aiDrawEnabled=false`)
319 }
320 return pass('')
321 },
323 aiToggleEnablesEndOfGameDrawings: async ({ createHarness = createTestHarness } = {}) => {
324 // Ensure mock mode so we don't call real OpenRouter
325 if (!aiDrawMockCtx.getStore()) aiDrawMockCtx.enterWith({ useMock: true }) // ctx:clear
327 // With aiDrawEnabled: true, AI drawings SHOULD be added for stacks ending on description
328 // Use 5 players: odd count means some stacks end on description
329 const harnessBase = await createHarness({ seed: 'aitoggle-on', playerCount: 5 })
330 // Override settings to enable AI draw
331 harnessBase.getState().settings.aiDrawEnabled = true
332 const agents = createAgentTeam({ count: 5, seed: 'aitoggle-on' })
334 const result = await runAgentsUntilDone({ harness: harnessBase, agents, maxRounds: 100 })
335 if (!result.isGameFin) {
336 await harnessBase.cleanup?.()
337 return fail(`Game did not finish (${result.rounds} rounds)`)
338 }
340 // Check stacks - some should have AI-generated drawings (for stacks that ended on description)
341 const stacks = harnessBase.getPaperStacks()
342 const aiDrawings = _.filter(_.flatMap(_.values(stacks), s => s.responses || []), r => r.isAiGenerated)
344 await harnessBase.cleanup?.()
346 // With 5 players, stacks alternate desc/draw/desc/draw/desc - all end on description
347 // AI should add a drawing to each, so we expect 5 AI drawings
348 if (aiDrawings.length === 0) {
349 return fail('No AI drawings were added (expected at least 1 with 5 players)')
350 }
352 // Verify no stacks still end on description (AI should have filled them all)
353 const stacksEndingOnDesc = _.filter(_.values(stacks), s => {
354 const lastResp = _.last(s.responses)
355 return lastResp?.lastDescription && !lastResp?.lastFinishedTrails && !lastResp?.isAiGenerated
356 })
358 if (stacksEndingOnDesc.length > 0) {
359 return fail(`${stacksEndingOnDesc.length} stacks still end on description (AI draw should have filled them)`)
360 }
362 // Verify AI drawings use ipfsCid (not imageDataUrl)
363 const aiDrawingsWithCid = aiDrawings.filter(r => r.lastIpfsCid)
364 if (aiDrawingsWithCid.length !== aiDrawings.length) {
365 return fail(`Expected all AI drawings to have lastIpfsCid, got ${aiDrawingsWithCid.length}/${aiDrawings.length}`)
366 }
368 // Verify AI drawings survive replay from action history
369 const gameActionHistA = harnessBase.getState().gameActionHistA
370 const aiActionsInHistory = gameActionHistA.filter(a => a.isAiGenerated)
371 if (aiActionsInHistory.length === 0) {
372 return fail(`No AI actions in gameActionHistA (expected ${aiDrawings.length})`)
373 }
375 // Verify action history entries use ipfsCid
376 const actionsWithCid = aiActionsInHistory.filter(a => a.ipfsCid)
377 if (actionsWithCid.length !== aiActionsInHistory.length) {
378 return fail(`Expected all AI actions to have ipfsCid, got ${actionsWithCid.length}/${aiActionsInHistory.length}`)
379 }
381 const { harness: replayedHarness, allPassed } = await replayGame({ gameActionHistA, seed: 'aitoggle-replay' })
382 if (!allPassed) return fail('Replay from action history failed')
384 const replayedStacks = replayedHarness.getPaperStacks()
385 const replayedAiDrawings = _.filter(
386 _.flatMap(_.values(replayedStacks), s => s.responses || []),
387 r => r.isAiGenerated && r.lastIpfsCid
388 )
390 if (replayedAiDrawings.length !== aiDrawings.length) {
391 return fail(`Replay lost AI drawings: had ${aiDrawings.length}, replayed ${replayedAiDrawings.length}`)
392 }
394 return pass(`${aiDrawings.length} drawings with ipfsCid, replay verified`)
395 },
397 kickWithBlockIpPreventsRejoin: async ({ createHarness = createTestHarness } = {}) => {
398 const harness = await createHarness({ seed: 'blockip1' })
399 const playerIps = { 'sess-a': '192.168.1.100', 'sess-b': '192.168.1.101', 'sess-c': '192.168.1.102', 'sess-d': '192.168.1.103' }
401 // Set unique IPs for each player
402 _.each(playerIps, (ip, sessId) => harness.setPlayerIp(sessId, ip))
404 // Join all players
405 for (const [sessId, ip] of _.toPairs(playerIps)) {
406 const result = await harness.applyAction({ sessId, playerAction: 'setPlayerAlias', playerAlias: `player-${sessId}` })
407 if (!result.ok) {
408 await harness.cleanup?.()
409 return fail(`Failed to join player ${sessId}: ${result.error?.message}`)
410 }
411 }
413 // Host (sess-a) kicks player sess-b WITH blockIp
414 const kickResult = await harness.applyAction({
415 sessId: 'sess-a',
416 playerAction: 'kickPlayer',
417 playerAlias: 'player-sess-b',
418 blockIp: true
419 })
420 if (!kickResult.ok) {
421 await harness.cleanup?.()
422 return fail(`Kick failed: ${kickResult.error?.message}`)
423 }
425 // Verify IP was blocked
426 const blockedIps = harness.getState().settings?.blockedIps || []
427 if (!blockedIps.includes('192.168.1.101')) {
428 await harness.cleanup?.()
429 return fail(`IP 192.168.1.101 was not added to blockedIps: ${JSON.stringify(blockedIps)}`)
430 }
432 // Try to rejoin with same IP (should fail)
433 const rejoinResult = await harness.applyAction({
434 sessId: 'sess-b-new',
435 playerAction: 'setPlayerAlias',
436 playerAlias: 'sneaky-player'
437 })
438 // New sessId but same IP should still be blocked
439 harness.setPlayerIp('sess-b-new', '192.168.1.101')
440 const rejoinBlockedResult = await harness.applyAction({
441 sessId: 'sess-b-new2',
442 playerAction: 'setPlayerAlias',
443 playerAlias: 'sneaky-player2'
444 })
445 harness.setPlayerIp('sess-b-new2', '192.168.1.101')
446 const rejoinBlockedResult2 = await harness.applyAction({
447 sessId: 'sess-b-new2',
448 playerAction: 'setPlayerAlias',
449 playerAlias: 'sneaky-again'
450 })
452 if (rejoinBlockedResult2.ok) {
453 await harness.cleanup?.()
454 return fail('Blocked IP was able to rejoin (should have been blocked)')
455 }
457 // Verify error message
458 const errMsg = rejoinBlockedResult2.error?.message || ''
459 if (!errMsg.includes('blocked')) {
460 await harness.cleanup?.()
461 return fail(`Wrong error message: ${errMsg}`)
462 }
464 // Try to join with different IP (should succeed)
465 harness.setPlayerIp('sess-new-ip', testLanIpAddr)
466 const newIpResult = await harness.applyAction({
467 sessId: 'sess-new-ip',
468 playerAction: 'setPlayerAlias',
469 playerAlias: 'new-player'
470 })
472 if (!newIpResult.ok) {
473 await harness.cleanup?.()
474 return fail(`New IP should have been able to join: ${newIpResult.error?.message}`)
475 }
477 await harness.cleanup?.()
478 return pass('')
479 },
481 resetBlockedIpsAllowsRejoin: async ({ createHarness = createTestHarness } = {}) => {
482 const harness = await createHarness({ seed: 'resetips1' })
484 harness.setPlayerIp('sess-host', '10.0.0.1')
485 harness.setPlayerIp('sess-victim', '10.0.0.50')
487 // Host and victim join
488 await harness.applyAction({ sessId: 'sess-host', playerAction: 'setPlayerAlias', playerAlias: 'host' })
489 await harness.applyAction({ sessId: 'sess-victim', playerAction: 'setPlayerAlias', playerAlias: 'victim' })
491 // Kick victim with IP block
492 await harness.applyAction({
493 sessId: 'sess-host',
494 playerAction: 'kickPlayer',
495 playerAlias: 'victim',
496 blockIp: true
497 })
499 // Verify blocked
500 const blockedBefore = harness.getState().settings?.blockedIps || []
501 if (!blockedBefore.includes('10.0.0.50')) {
502 await harness.cleanup?.()
503 return fail('IP was not blocked')
504 }
506 // Try to rejoin - should fail
507 harness.setPlayerIp('sess-victim-new', '10.0.0.50')
508 const rejoinBlockedResult = await harness.applyAction({
509 sessId: 'sess-victim-new',
510 playerAction: 'setPlayerAlias',
511 playerAlias: 'victim-returns'
512 })
513 if (rejoinBlockedResult.ok) {
514 await harness.cleanup?.()
515 return fail('Should have been blocked before reset')
516 }
518 // Reset blocked IPs
519 harness.resetBlockedIps()
521 // Try to rejoin again - should succeed now (set IP before action)
522 harness.setPlayerIp('sess-victim-new2', '10.0.0.50')
523 const rejoinAfterReset = await harness.applyAction({
524 sessId: 'sess-victim-new2',
525 playerAction: 'setPlayerAlias',
526 playerAlias: 'victim-forgiven'
527 })
529 if (!rejoinAfterReset.ok) {
530 await harness.cleanup?.()
531 return fail(`Should have been able to rejoin after reset: ${rejoinAfterReset.error?.message}`)
532 }
534 await harness.cleanup?.()
535 return pass('')
536 },
538 kickWithoutBlockIpAllowsRejoin: async ({ createHarness = createTestHarness } = {}) => {
539 const harness = await createHarness({ seed: 'noblock1' })
541 harness.setPlayerIp('sess-host', '10.1.1.1')
542 harness.setPlayerIp('sess-kicked', '10.1.1.50')
544 // Host and player join
545 await harness.applyAction({ sessId: 'sess-host', playerAction: 'setPlayerAlias', playerAlias: 'host' })
546 await harness.applyAction({ sessId: 'sess-kicked', playerAction: 'setPlayerAlias', playerAlias: 'kicked-player' })
548 // Kick WITHOUT blockIp
549 await harness.applyAction({
550 sessId: 'sess-host',
551 playerAction: 'kickPlayer',
552 playerAlias: 'kicked-player',
553 blockIp: false
554 })
556 // Verify IP was NOT blocked
557 const blockedIps = harness.getState().settings?.blockedIps || []
558 if (blockedIps.includes('10.1.1.50')) {
559 await harness.cleanup?.()
560 return fail('IP should not have been blocked when blockIp=false')
561 }
563 // Should be able to rejoin with same IP
564 harness.setPlayerIp('sess-kicked-new', '10.1.1.50')
565 const rejoinResult = await harness.applyAction({
566 sessId: 'sess-kicked-new',
567 playerAction: 'setPlayerAlias',
568 playerAlias: 'back-again'
569 })
571 if (!rejoinResult.ok) {
572 await harness.cleanup?.()
573 return fail(`Should have been able to rejoin without IP block: ${rejoinResult.error?.message}`)
574 }
576 await harness.cleanup?.()
577 return pass('')
578 },
580 aiDrawingAfterFirstHumanDescriptEvenPlayers: async ({ createHarness = createTestHarness } = {}) => {
581 // With aiDrawingAfterFirstHumanDescript + even player count, AI should draw after first DescribeDrawing
582 // 4 players (even): game ends on drawing, so aiDrawingAfterFirstHumanDescript triggers
583 if (!aiDrawMockCtx.getStore()) aiDrawMockCtx.enterWith({ useMock: true }) // ctx:clear
585 const harness = await createHarness({ seed: 'ai-after-desc-even', playerCount: 4 })
586 harness.getState().settings.aiDrawEnabled = true
587 harness.getState().settings.aiDrawingAfterFirstHumanDescript = true
588 const agents = createAgentTeam({ count: 4, seed: 'ai-after-desc-even' })
590 const result = await runAgentsUntilDone({ harness, agents, maxRounds: 100 })
591 if (!result.isGameFin) {
592 await harness.cleanup?.()
593 return fail(`Game did not finish (${result.rounds} rounds)`)
594 }
596 const stacks = harness.getPaperStacks()
598 // Check each stack: AI drawing should be at position 3 (after: InitialPrompt[0], Drawing[1], DescribeDrawing[2])
599 for (const [stackName, stack] of _.toPairs(stacks)) {
600 const responses = stack.responses || []
601 // Find AI drawings and their positions
602 const aiDrawingPositions = responses
603 .map((r, i) => r.isAiGenerated ? i : -1)
604 .filter(i => i >= 0)
606 if (aiDrawingPositions.length !== 1) {
607 await harness.cleanup?.()
608 return fail(`Stack ${stackName}: expected 1 AI drawing, got ${aiDrawingPositions.length}`)
609 }
611 // AI drawing should be at position 3 (after first DescribeDrawing)
612 if (aiDrawingPositions[0] !== 3) {
613 await harness.cleanup?.()
614 return fail(`Stack ${stackName}: AI drawing at position ${aiDrawingPositions[0]}, expected 3`)
615 }
616 }
618 await harness.cleanup?.()
619 return pass(`4 stacks each with AI drawing at position 3`)
620 },
622 aiDrawingAfterFirstHumanDescriptOddPlayers: async ({ createHarness = createTestHarness } = {}) => {
623 // With aiDrawingAfterFirstHumanDescript + odd player count, AI should NOT draw mid-game
624 // 5 players (odd): game ends on description, so end-of-game AI draws instead
625 if (!aiDrawMockCtx.getStore()) aiDrawMockCtx.enterWith({ useMock: true }) // ctx:clear
627 const harness = await createHarness({ seed: 'ai-after-desc-odd', playerCount: 5 })
628 harness.getState().settings.aiDrawEnabled = true
629 harness.getState().settings.aiDrawingAfterFirstHumanDescript = true
630 const agents = createAgentTeam({ count: 5, seed: 'ai-after-desc-odd' })
632 const result = await runAgentsUntilDone({ harness, agents, maxRounds: 100 })
633 if (!result.isGameFin) {
634 await harness.cleanup?.()
635 return fail(`Game did not finish (${result.rounds} rounds)`)
636 }
638 const stacks = harness.getPaperStacks()
640 // Check each stack: AI drawing should be at last position only (end-of-game)
641 for (const [stackName, stack] of _.toPairs(stacks)) {
642 const responses = stack.responses || []
643 const aiDrawingPositions = responses
644 .map((r, i) => r.isAiGenerated ? i : -1)
645 .filter(i => i >= 0)
647 if (aiDrawingPositions.length !== 1) {
648 await harness.cleanup?.()
649 return fail(`Stack ${stackName}: expected 1 AI drawing, got ${aiDrawingPositions.length}`)
650 }
652 // AI drawing should be at last position (5 responses + 1 AI = position 5)
653 const expectedPos = 5
654 if (aiDrawingPositions[0] !== expectedPos) {
655 await harness.cleanup?.()
656 return fail(`Stack ${stackName}: AI drawing at position ${aiDrawingPositions[0]}, expected ${expectedPos} (end-of-game)`)
657 }
658 }
660 await harness.cleanup?.()
661 return pass(`5 stacks each with AI drawing at end (position 5)`)
662 },
664 resetGameClearsPlayers: async ({ createHarness = createTestHarness } = {}) => {
665 const { harness, agents } = await setupGame({ seed: 'reset1', playerCount: 4, createHarness })
666 await joinAllPlayers(harness, agents)
667 await doInitialPrompts(harness, agents)
669 const beforePlayers = harness.getActivePlayers()
670 if (beforePlayers.length < 2) {
671 await harness.cleanup?.()
672 return fail('Not enough players before reset')
673 }
674 const oldGameId = harness.getState().id
676 const resetResult = await harness.applyAction({ sessId: agents[0].sessId, playerAction: 'resetGame' })
677 if (!resetResult.ok) {
678 await harness.cleanup?.()
679 return fail(`resetGame action failed: ${resetResult.error?.message}`)
680 }
682 const afterState = harness.getState()
683 if (afterState.playerStates && Object.keys(afterState.playerStates).length > 0) {
684 await harness.cleanup?.()
685 return fail(`playerStates not cleared after reset: ${JSON.stringify(Object.keys(afterState.playerStates))}`)
686 }
687 if (!afterState.justFinishedGameId) {
688 await harness.cleanup?.()
689 return fail('justFinishedGameId not set after reset')
690 }
691 if (afterState.justFinishedGameId !== oldGameId) {
692 await harness.cleanup?.()
693 return fail(`justFinishedGameId mismatch: ${afterState.justFinishedGameId} !== ${oldGameId}`)
694 }
695 if (afterState.id === oldGameId) {
696 await harness.cleanup?.()
697 return fail('game id not rotated after reset')
698 }
700 const newPlayers = harness.getActivePlayers()
701 if (newPlayers.length !== 0) {
702 await harness.cleanup?.()
703 return fail(`expected 0 active players after reset, got ${newPlayers.length}`)
704 }
706 await harness.cleanup?.()
707 return pass(`reset from ${beforePlayers.length} players, justFinishedGameId preserved`)
708 },
710 freshGameNoLeakedPlayers: async ({ createHarness = createTestHarness } = {}) => {
711 const harnessA = await createHarness({ seed: 'leakA', playerCount: 4 })
712 const agentsA = createAgentTeam({ count: 4, seed: 'leakA' })
713 await joinAllPlayers(harnessA, agentsA)
714 const playersA = harnessA.getActivePlayers()
715 if (playersA.length !== 4) {
716 await harnessA.cleanup?.()
717 return fail(`harnessA should have 4 players, got ${playersA.length}`)
718 }
720 const stateB = mkBaseGameState()
721 if (Object.keys(stateB.playerStates).length !== 0) {
722 await harnessA.cleanup?.()
723 return fail(`mkBaseGameState().playerStates leaked: ${Object.keys(stateB.playerStates).join(',')}`)
724 }
726 const freshGame = getDefaultGameState({gameRoom: 'leak-test-room'})
727 if (Object.keys(freshGame.playerStates).length !== 0) {
728 await harnessA.cleanup?.()
729 return fail(`getDefaultGameState.playerStates leaked: ${Object.keys(freshGame.playerStates).join(',')}`)
730 }
732 freshGame.playerStates['sess-fake'] = {publicState: {playerAlias: 'polluter'}}
733 const freshGame2 = getDefaultGameState({gameRoom: 'leak-test-room2'})
734 if (Object.keys(freshGame2.playerStates).length !== 0) {
735 await harnessA.cleanup?.()
736 return fail(`getDefaultGameState.playerStates leaked after direct mutation: ${Object.keys(freshGame2.playerStates).join(',')}`)
737 }
739 const replayed = await gameStateFromActionA({
740 gameActionHistA: [{sessId: 's1', playerAction: 'setPlayerAlias', playerAlias: 'replayPlayer', actionAtMs: 1000}],
741 cpGameStateH: {id: 'test-replay-id'},
742 })
743 const replayedPs = Object.keys(replayed.gqGameState.playerStates)
744 if (replayedPs.length !== 1 || replayedPs[0] !== 's1') {
745 await harnessA.cleanup?.()
746 return fail(`gameStateFromActionA leaked players: ${replayedPs.join(',')}`)
747 }
749 await harnessA.cleanup?.()
750 return pass('')
751 },
754export const runAllScenarios = async () => {
755 const results = {}
756 let allPassed = true
758 for (const [name, scenarioFn] of _.toPairs(scenarios)) {
759 try {
760 const result = await scenarioFn()
761 results[name] = result
762 if (!result.passed) allPassed = false
763 } catch (err) {
764 results[name] = { ...fail(`Error: ${err.message}`), error: err }
765 allPassed = false
766 }
767 }
769 return { allPassed, results }