Files
golfgame/tests/soak/scenarios/populate.ts
adlee-was-taken 2c20b6c7b5 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) <noreply@anthropic.com>
2026-04-11 17:23:56 -04:00

148 lines
3.9 KiB
TypeScript

/**
* 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<T>(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<void> {
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<ScenarioResult> {
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;