Watchdog class with 4 Vitest tests (27 total now), wired into ctx.heartbeat in the runner. One watchdog per room with a 60s timeout; firing logs an error, marks the room's dashboard tile as errored, and triggers the abort signal so the scenario unwinds. Watchdogs are explicitly stopped in the runner's finally block so pending timers don't keep the node process alive on exit. Also fixes a multi-game bug discovered during stress scenario verification: after a game ends sessions stay parked on the game_over screen, which hides the lobby and makes a subsequent #create-room-btn click time out. runOneMultiplayerGame now navigates every session to / before each game — localStorage auth persists so nothing re-logs in. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
128 lines
4.1 KiB
TypeScript
128 lines
4.1 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('/')));
|
|
|
|
// Host creates game and announces the code
|
|
const code = await host.bot.createGame(host.account.username);
|
|
ctx.coordinator.announce(opts.roomId, 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(opts.roomId);
|
|
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),
|
|
};
|
|
}
|
|
}
|