feat(soak): shared runOneMultiplayerGame helper
Encapsulates the host-creates/joiners-join/loop-until-done flow so populate and stress scenarios don't duplicate it. Honors abort signal and a max-duration timeout, heartbeats on every turn. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
121
tests/soak/scenarios/shared/multiplayer-game.ts
Normal file
121
tests/soak/scenarios/shared/multiplayer-game.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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 {
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user