/** * Populate scenario — long multi-round games to populate scoreboards. * * Partitions sessions into N rooms (default 4) and runs gamesPerRoom * games per room in parallel via Promise.allSettled so a failure in * one room never unwinds the others. */ import type { Scenario, ScenarioContext, ScenarioResult, ScenarioError, Session, } from '../core/types'; import { runOneMultiplayerGame } from './shared/multiplayer-game'; const CPU_PERSONALITIES = ['Sofia', 'Marcus', 'Kenji', 'Priya']; interface PopulateConfig { gamesPerRoom: number; holes: number; decks: number; rooms: number; cpusPerRoom: number; thinkTimeMs: [number, number]; interGamePauseMs: 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((resolve) => setTimeout(resolve, ms)); } async function runRoom( ctx: ScenarioContext, cfg: PopulateConfig, roomIdx: number, sessions: Session[], ): Promise<{ completed: number; errors: ScenarioError[] }> { const roomId = `room-${roomIdx}`; const cpuPersonality = CPU_PERSONALITIES[roomIdx % CPU_PERSONALITIES.length]; let completed = 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 }); ctx.logger.info('game_start', { room: roomId, game: gameNum + 1 }); const result = await runOneMultiplayerGame(ctx, sessions, { roomId, holes: cfg.holes, decks: cfg.decks, cpusPerRoom: cfg.cpusPerRoom, cpuPersonality, thinkTimeMs: cfg.thinkTimeMs, }); if (result.completed) { completed++; ctx.logger.info('game_complete', { room: roomId, game: gameNum + 1, turns: result.turns, durationMs: result.durationMs, }); } else { errors.push({ room: roomId, reason: 'game_failed', detail: result.error, timestamp: Date.now(), }); ctx.logger.error('game_failed', { room: roomId, game: gameNum + 1, error: result.error }); } if (gameNum < cfg.gamesPerRoom - 1) { await sleep(cfg.interGamePauseMs); } } return { completed, errors }; } const populate: Scenario = { name: 'populate', description: 'Long multi-round games to populate scoreboards', needs: { accounts: 16, rooms: 4, cpusPerRoom: 1 }, defaultConfig: { gamesPerRoom: 10, holes: 9, decks: 2, rooms: 4, cpusPerRoom: 1, thinkTimeMs: [800, 2200], interGamePauseMs: 3000, }, async run(ctx: ScenarioContext): Promise { const start = Date.now(); const cfg = ctx.config as unknown as PopulateConfig; const perRoom = Math.floor(ctx.sessions.length / cfg.rooms); if (perRoom * cfg.rooms !== ctx.sessions.length) { throw new Error( `populate: ${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((sessions, idx) => runRoom(ctx, cfg, idx, sessions)), ); let gamesCompleted = 0; const errors: ScenarioError[] = []; results.forEach((r, idx) => { if (r.status === 'fulfilled') { gamesCompleted += r.value.completed; 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, }; }, }; export default populate;