Batched remaining harness tasks (27-30, 33):
Task 27 — Artifact capture on failure: screenshots, HTML snapshots,
game state JSON, and console error tails are captured into
tests/soak/artifacts/<run-id>/ when a scenario throws. Successful
runs get a summary.json. Old runs (>7d) are pruned on startup.
Task 28 — Graceful shutdown: first SIGINT/SIGTERM flips the abort
signal (scenarios finish current turn then unwind). 10s after, a
hard-kill fires if cleanup hangs. Double Ctrl-C = immediate exit.
Exit codes: 0 success, 1 errors, 2 interrupted.
Task 29 — Periodic health probes: every 30s GET /health against the
target server. Three consecutive failures abort the run with
health_fatal, preventing staging outages from being misattributed
to harness bugs. Corrected endpoint from /api/health to /health
per server/routers/health.py.
Task 30 — Smoke test script: tests/soak/scripts/smoke.sh, a 60s
end-to-end canary that health-probes the target, seeds if needed,
and runs one minimal populate game.
Task 33 — Version bump to v3.3.4: both index.html footers (was
v3.1.6), new footer added to admin.html (had none), pyproject.toml.
Also fixes discovered during stress testing:
- SessionPool sets baseURL on all contexts so relative goto('/')
resolves correctly between games (was "invalid URL" error)
- RoomCoordinator key is now unique per game-start (Date.now
suffix) so Deferred promises don't carry stale room codes from
previous games
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
TypeScript
134 lines
4.4 KiB
TypeScript
/**
|
|
* runOneMultiplayerGame — the shared "play one game in one room" loop.
|
|
*
|
|
* Host creates the room, announces the code via RoomCoordinator,
|
|
* joiners wait for the code and join concurrently, host adds CPUs and
|
|
* starts the game, then every session loops on isMyTurn/playTurn until
|
|
* the game ends (or the abort signal fires, or maxDurationMs elapses).
|
|
*
|
|
* Used by both the populate and stress scenarios so the turn loop
|
|
* lives in exactly one place.
|
|
*/
|
|
|
|
import type { Session, ScenarioContext } from '../../core/types';
|
|
|
|
export interface MultiplayerGameOptions {
|
|
roomId: string;
|
|
holes: number;
|
|
decks: number;
|
|
cpusPerRoom: number;
|
|
cpuPersonality?: string;
|
|
/** Per-turn think time in [min, max] ms. */
|
|
thinkTimeMs: [number, number];
|
|
/** Max wall-clock time before giving up on the game (ms). */
|
|
maxDurationMs?: number;
|
|
}
|
|
|
|
export interface MultiplayerGameResult {
|
|
completed: boolean;
|
|
turns: number;
|
|
durationMs: number;
|
|
error?: string;
|
|
}
|
|
|
|
function randomInt(min: number, max: number): number {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
}
|
|
|
|
async function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export async function runOneMultiplayerGame(
|
|
ctx: ScenarioContext,
|
|
sessions: Session[],
|
|
opts: MultiplayerGameOptions,
|
|
): Promise<MultiplayerGameResult> {
|
|
const start = Date.now();
|
|
const [host, ...joiners] = sessions;
|
|
const maxDuration = opts.maxDurationMs ?? 5 * 60_000;
|
|
|
|
try {
|
|
// Reset every session back to the lobby before starting.
|
|
// After the first game ends each session is parked on the
|
|
// game_over screen, which hides the lobby's Create Room button.
|
|
// goto('/') bounces them back; localStorage-cached auth persists.
|
|
await Promise.all(sessions.map((s) => s.bot.goto('/')));
|
|
|
|
// Use a unique coordinator key per game-start so Deferreds don't
|
|
// carry stale room codes from previous games. The coordinator's
|
|
// Promises only resolve once — reusing `opts.roomId` across games
|
|
// would make joiners receive the first game's code on every game.
|
|
const coordKey = `${opts.roomId}-${Date.now()}`;
|
|
|
|
// Host creates game and announces the code
|
|
const code = await host.bot.createGame(host.account.username);
|
|
ctx.coordinator.announce(coordKey, code);
|
|
ctx.heartbeat(opts.roomId);
|
|
ctx.dashboard.update(opts.roomId, { phase: 'lobby' });
|
|
ctx.logger.info('room_created', { room: opts.roomId, code });
|
|
|
|
// Joiners join concurrently
|
|
await Promise.all(
|
|
joiners.map(async (joiner) => {
|
|
const awaited = await ctx.coordinator.await(coordKey);
|
|
await joiner.bot.joinGame(awaited, joiner.account.username);
|
|
}),
|
|
);
|
|
ctx.heartbeat(opts.roomId);
|
|
|
|
// Host adds CPUs (if any) and starts
|
|
for (let i = 0; i < opts.cpusPerRoom; i++) {
|
|
await host.bot.addCPU(opts.cpuPersonality);
|
|
}
|
|
await host.bot.startGame({ holes: opts.holes, decks: opts.decks });
|
|
ctx.heartbeat(opts.roomId);
|
|
ctx.dashboard.update(opts.roomId, { phase: 'playing', totalHoles: opts.holes });
|
|
|
|
// Concurrent turn loops — one per session
|
|
const turnCounts = new Array(sessions.length).fill(0);
|
|
|
|
async function sessionLoop(sessionIdx: number): Promise<void> {
|
|
const session = sessions[sessionIdx];
|
|
while (true) {
|
|
if (ctx.signal.aborted) return;
|
|
if (Date.now() - start > maxDuration) return;
|
|
|
|
const phase = await session.bot.getGamePhase();
|
|
if (phase === 'game_over' || phase === 'round_over') return;
|
|
|
|
if (await session.bot.isMyTurn()) {
|
|
await session.bot.playTurn();
|
|
turnCounts[sessionIdx]++;
|
|
ctx.heartbeat(opts.roomId);
|
|
ctx.dashboard.update(opts.roomId, {
|
|
currentPlayer: session.account.username,
|
|
moves: turnCounts.reduce((a, b) => a + b, 0),
|
|
});
|
|
const thinkMs = randomInt(opts.thinkTimeMs[0], opts.thinkTimeMs[1]);
|
|
await sleep(thinkMs);
|
|
} else {
|
|
await sleep(200);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(sessions.map((_, i) => sessionLoop(i)));
|
|
|
|
const totalTurns = turnCounts.reduce((a, b) => a + b, 0);
|
|
ctx.dashboard.update(opts.roomId, { phase: 'round_over' });
|
|
return {
|
|
completed: true,
|
|
turns: totalTurns,
|
|
durationMs: Date.now() - start,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
completed: false,
|
|
turns: 0,
|
|
durationMs: Date.now() - start,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|