From 2c20b6c7b523e274a9837c99d436ca4f16e8afa2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:23:56 -0400 Subject: [PATCH] feat(soak): populate scenario + scenario registry Partitions sessions into N rooms, runs gamesPerRoom games per room in parallel via Promise.allSettled so a failure in one room never unwinds the others. Errors roll up into ScenarioResult.errors. Verified via tsx: listScenarios() returns [populate], getScenario() resolves by name and returns undefined for unknown names. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/scenarios/index.ts | 22 +++++ tests/soak/scenarios/populate.ts | 147 +++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/soak/scenarios/index.ts create mode 100644 tests/soak/scenarios/populate.ts diff --git a/tests/soak/scenarios/index.ts b/tests/soak/scenarios/index.ts new file mode 100644 index 0000000..303fdf5 --- /dev/null +++ b/tests/soak/scenarios/index.ts @@ -0,0 +1,22 @@ +/** + * Scenario registry — name → Scenario mapping. + * + * Runner looks up scenarios by name. Add a new scenario by importing + * it here and adding an entry to `registry`. No filesystem scanning, + * no magic. + */ + +import type { Scenario } from '../core/types'; +import populate from './populate'; + +const registry: Record = { + populate, +}; + +export function getScenario(name: string): Scenario | undefined { + return registry[name]; +} + +export function listScenarios(): Scenario[] { + return Object.values(registry); +} diff --git a/tests/soak/scenarios/populate.ts b/tests/soak/scenarios/populate.ts new file mode 100644 index 0000000..bb6dcc4 --- /dev/null +++ b/tests/soak/scenarios/populate.ts @@ -0,0 +1,147 @@ +/** + * 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;