diff --git a/tests/soak/scenarios/shared/multiplayer-game.ts b/tests/soak/scenarios/shared/multiplayer-game.ts new file mode 100644 index 0000000..6c4d51d --- /dev/null +++ b/tests/soak/scenarios/shared/multiplayer-game.ts @@ -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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function runOneMultiplayerGame( + ctx: ScenarioContext, + sessions: Session[], + opts: MultiplayerGameOptions, +): Promise { + 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 { + 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), + }; + } +}