/** * Stress scenario — rapid short games with chaos injection. * * Partitions sessions into N rooms (default 4), runs gamesPerRoom * short 1-hole games per room in parallel. While each game plays, * a background loop injects chaos events (rapid clicks, tab blur, * brief offline) with 5% per-turn probability to hunt race * conditions and recovery bugs. */ import type { Scenario, ScenarioContext, ScenarioResult, ScenarioError, Session, } from '../core/types'; import { runOneMultiplayerGame } from './shared/multiplayer-game'; import { maybeInjectChaos } from './shared/chaos'; interface StressConfig { gamesPerRoom: number; holes: number; decks: number; rooms: number; cpusPerRoom: number; thinkTimeMs: [number, number]; interGamePauseMs: number; chaosChance: number; } function chunk(arr: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } async function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } async function runStressRoom( ctx: ScenarioContext, cfg: StressConfig, roomIdx: number, sessions: Session[], ): Promise<{ completed: number; errors: ScenarioError[]; chaosFired: number }> { const roomId = `room-${roomIdx}`; let completed = 0; let chaosFired = 0; const errors: ScenarioError[] = []; for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) { if (ctx.signal.aborted) break; ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom }); // Background chaos loop — runs concurrently with the game turn loop. // Delay the first tick by 3 seconds so room creation + joiners + game // start have time to complete without chaos interference (rapid clicks // or brief_offline during lobby setup can prevent #create-room-btn // from becoming stable). let chaosActive = true; const chaosLoop = (async () => { await sleep(3000); while (chaosActive && !ctx.signal.aborted) { await sleep(500); for (const session of sessions) { const e = await maybeInjectChaos( session, cfg.chaosChance, ctx.logger, roomId, ); if (e) chaosFired++; } } })(); const result = await runOneMultiplayerGame(ctx, sessions, { roomId, holes: cfg.holes, decks: cfg.decks, cpusPerRoom: cfg.cpusPerRoom, thinkTimeMs: cfg.thinkTimeMs, }); chaosActive = false; await chaosLoop; if (result.completed) { completed++; ctx.logger.info('game_complete', { room: roomId, game: gameNum + 1, turns: result.turns, }); } else { errors.push({ room: roomId, reason: 'game_failed', detail: result.error, timestamp: Date.now(), }); ctx.logger.error('game_failed', { room: roomId, error: result.error }); } await sleep(cfg.interGamePauseMs); } return { completed, errors, chaosFired }; } const stress: Scenario = { name: 'stress', description: 'Rapid short games for stability & race condition hunting', needs: { accounts: 16, rooms: 4, cpusPerRoom: 2 }, defaultConfig: { gamesPerRoom: 50, holes: 1, decks: 1, rooms: 4, cpusPerRoom: 2, thinkTimeMs: [50, 150], interGamePauseMs: 200, chaosChance: 0.05, }, async run(ctx: ScenarioContext): Promise { const start = Date.now(); const cfg = ctx.config as unknown as StressConfig; const perRoom = Math.floor(ctx.sessions.length / cfg.rooms); if (perRoom * cfg.rooms !== ctx.sessions.length) { throw new Error( `stress: ${ctx.sessions.length} sessions does not divide evenly into ${cfg.rooms} rooms`, ); } const roomSessions = chunk(ctx.sessions, perRoom); const results = await Promise.allSettled( roomSessions.map((s, idx) => runStressRoom(ctx, cfg, idx, s)), ); let gamesCompleted = 0; let chaosFired = 0; const errors: ScenarioError[] = []; results.forEach((r, idx) => { if (r.status === 'fulfilled') { gamesCompleted += r.value.completed; chaosFired += r.value.chaosFired; errors.push(...r.value.errors); } else { errors.push({ room: `room-${idx}`, reason: 'room_threw', detail: r.reason instanceof Error ? r.reason.message : String(r.reason), timestamp: Date.now(), }); } }); return { gamesCompleted, errors, durationMs: Date.now() - start, customMetrics: { chaos_fired: chaosFired }, }; }, }; export default stress;