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>
This commit is contained in:
22
tests/soak/scenarios/index.ts
Normal file
22
tests/soak/scenarios/index.ts
Normal file
@@ -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<string, Scenario> = {
|
||||
populate,
|
||||
};
|
||||
|
||||
export function getScenario(name: string): Scenario | undefined {
|
||||
return registry[name];
|
||||
}
|
||||
|
||||
export function listScenarios(): Scenario[] {
|
||||
return Object.values(registry);
|
||||
}
|
||||
147
tests/soak/scenarios/populate.ts
Normal file
147
tests/soak/scenarios/populate.ts
Normal file
@@ -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<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;
|
||||
Reference in New Issue
Block a user