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 }) 21 return { harness, agents } 25 for (const a of agents) await harness.applyAction({ sessId: a.sessId, playerAction: 'setPlayerAlias', playerAlias: a.playerAlias }) 29 for (const a of agents) { 30 const action = a.chooseAction(harness.getState()) 31 if (action) await harness.applyAction(action) 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) 40 return await fn(harness, agents) 42 await harness.cleanup?.() 46const mkPastDeadlineMs = (taskName: string) => { 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 }) 57 // For live tests, use fewer rounds since we can't reliably detect game finish 58 const maxRounds = isLive ? 20 : 100 61 // Check invariants after completion (skip for live harness - state structure differs) 62 if (harness.getState()?.gameActionHistA) { 66 // For live tests, success = ran without crashing and had steady progress 68 await harness.cleanup?.() 69 if (result.rounds >= maxRounds || result.isGameFin) { 70 return pass(`Live test completed ${result.rounds} rounds successfully`) 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)`) 76 if (!result.isGameFin) { 77 return { ...fail(`Game did not finish after ${result.rounds} rounds`), details: result } 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}`) 89 // Cleanup if harness supports it (live harness needs browser cleanup) 90 await harness.cleanup?.() 92 return { ...pass(`${result.rounds} rounds`), details: { stackLengths } } 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) { 102 const remainingAgents = agents.filter(a => a.sessId !== agents[2].sessId) 104 if (harness.getState()?.gameActionHistA) { 108 return result.isGameFin 109 ? pass(`${result.rounds} rounds`) 110 : fail(`Game did not finish after player left (${result.rounds} rounds)`) 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, 129 ? fail('Wrong stack submission should have been rejected!') 134 largePlayers: async ({ createHarness = createTestHarness } = {}) => { 135 const harness = await createHarness({ seed: 'large8', playerCount: 8 }) 139 if (harness.getState()?.gameActionHistA) { 143 await harness.cleanup?.() 145 return result.isGameFin ? pass(`8p, ${result.rounds} rounds`) : fail(`8-player game stuck at ${result.rounds} rounds`) 148 tooFewPlayersBlocked: async ({ createHarness = createTestHarness } = {}) => { 149 return withScenario({ seed: 'toofew', playerCount: 3, createHarness }, async (harness, agents) => { 151 return result.isGameFin 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 167 if (!rejoinedPlayer) return fail('Rejoined player not found or still kicked') 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?)') 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, 210 ? fail('Empty description should have been rejected!') 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`) 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`) 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) 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)`) 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) 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)`) 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 }) 306 if (!result.isGameFin) { 307 await harness.cleanup?.() 308 return fail(`Game did not finish (${result.rounds} rounds)`) 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`) 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 335 if (!result.isGameFin) { 336 await harnessBase.cleanup?.() 337 return fail(`Game did not finish (${result.rounds} rounds)`) 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)') 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 358 if (stacksEndingOnDesc.length > 0) { 359 return fail(`${stacksEndingOnDesc.length} stacks still end on description (AI draw should have filled them)`) 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}`) 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})`) 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}`) 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 390 if (replayedAiDrawings.length !== aiDrawings.length) { 391 return fail(`Replay lost AI drawings: had ${aiDrawings.length}, replayed ${replayedAiDrawings.length}`) 394 return pass(`${aiDrawings.length} drawings with ipfsCid, replay verified`) 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)) 405 for (const [sessId, ip] of _.toPairs(playerIps)) { 406 const result = await harness.applyAction({ sessId, playerAction: 'setPlayerAlias', playerAlias: `player-${sessId}` }) 408 await harness.cleanup?.() 409 return fail(`Failed to join player ${sessId}: ${result.error?.message}`) 413 // Host (sess-a) kicks player sess-b WITH blockIp 414 const kickResult = await harness.applyAction({ 416 playerAction: 'kickPlayer', 417 playerAlias: 'player-sess-b', 420 if (!kickResult.ok) { 421 await harness.cleanup?.() 422 return fail(`Kick failed: ${kickResult.error?.message}`) 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)}`) 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' 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' 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' 452 if (rejoinBlockedResult2.ok) { 453 await harness.cleanup?.() 454 return fail('Blocked IP was able to rejoin (should have been blocked)') 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}`) 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' 472 if (!newIpResult.ok) { 473 await harness.cleanup?.() 474 return fail(`New IP should have been able to join: ${newIpResult.error?.message}`) 477 await harness.cleanup?.() 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({ 494 playerAction: 'kickPlayer', 495 playerAlias: 'victim', 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') 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' 513 if (rejoinBlockedResult.ok) { 514 await harness.cleanup?.() 515 return fail('Should have been blocked before reset') 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' 529 if (!rejoinAfterReset.ok) { 530 await harness.cleanup?.() 531 return fail(`Should have been able to rejoin after reset: ${rejoinAfterReset.error?.message}`) 534 await harness.cleanup?.() 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({ 551 playerAction: 'kickPlayer', 552 playerAlias: 'kicked-player', 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') 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' 571 if (!rejoinResult.ok) { 572 await harness.cleanup?.() 573 return fail(`Should have been able to rejoin without IP block: ${rejoinResult.error?.message}`) 576 await harness.cleanup?.() 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 591 if (!result.isGameFin) { 592 await harness.cleanup?.() 593 return fail(`Game did not finish (${result.rounds} rounds)`) 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) 606 if (aiDrawingPositions.length !== 1) { 607 await harness.cleanup?.() 608 return fail(`Stack ${stackName}: expected 1 AI drawing, got ${aiDrawingPositions.length}`) 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`) 618 await harness.cleanup?.() 619 return pass(`4 stacks each with AI drawing at position 3`) 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 633 if (!result.isGameFin) { 634 await harness.cleanup?.() 635 return fail(`Game did not finish (${result.rounds} rounds)`) 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) 647 if (aiDrawingPositions.length !== 1) { 648 await harness.cleanup?.() 649 return fail(`Stack ${stackName}: expected 1 AI drawing, got ${aiDrawingPositions.length}`) 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)`) 660 await harness.cleanup?.() 661 return pass(`5 stacks each with AI drawing at end (position 5)`) 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') 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}`) 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))}`) 687 if (!afterState.justFinishedGameId) { 688 await harness.cleanup?.() 689 return fail('justFinishedGameId not set after reset') 691 if (afterState.justFinishedGameId !== oldGameId) { 692 await harness.cleanup?.() 693 return fail(`justFinishedGameId mismatch: ${afterState.justFinishedGameId} !== ${oldGameId}`) 695 if (afterState.id === oldGameId) { 696 await harness.cleanup?.() 697 return fail('game id not rotated after reset') 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}`) 706 await harness.cleanup?.() 707 return pass(`reset from ${beforePlayers.length} players, justFinishedGameId preserved`) 710 freshGameNoLeakedPlayers: async ({ createHarness = createTestHarness } = {}) => { 711 const harnessA = await createHarness({ seed: 'leakA', playerCount: 4 }) 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}`) 721 if (Object.keys(stateB.playerStates).length !== 0) { 722 await harnessA.cleanup?.() 723 return fail(`mkBaseGameState().playerStates leaked: ${Object.keys(stateB.playerStates).join(',')}`) 727 if (Object.keys(freshGame.playerStates).length !== 0) { 728 await harnessA.cleanup?.() 729 return fail(`getDefaultGameState.playerStates leaked: ${Object.keys(freshGame.playerStates).join(',')}`) 732 freshGame.playerStates['sess-fake'] = {publicState: {playerAlias: 'polluter'}} 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(',')}`) 740 gameActionHistA: [{sessId: 's1', playerAction: 'setPlayerAlias', playerAlias: 'replayPlayer', actionAtMs: 1000}], 741 cpGameStateH: {id: 'test-replay-id'}, 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(',')}`) 749 await harnessA.cleanup?.() 754export const runAllScenarios = async () => { 758 for (const [name, scenarioFn] of _.toPairs(scenarios)) { 760 const result = await scenarioFn() 761 results[name] = result 762 if (!result.passed) allPassed = false 764 results[name] = { ...fail(`Error: ${err.message}`), error: err } 769 return { allPassed, results }