/** * 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 { // 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. // We must wait for auth hydration to unhide #lobby-game-controls. await Promise.all( sessions.map(async (s) => { await s.bot.goto('/'); try { await s.page.waitForSelector('#create-room-btn', { state: 'visible', timeout: 15000, }); } catch { // Auth may have been lost — re-login via the page const html = await s.page.content().catch(() => ''); ctx.logger.warn('lobby_not_ready', { session: s.key, hasControls: html.includes('lobby-game-controls'), hasHidden: html.includes('lobby-game-controls" class="hidden"') || html.includes("lobby-game-controls' class='hidden'"), }); throw new Error(`lobby not ready for ${s.key} after 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 { const session = sessions[sessionIdx]; const isHost = sessionIdx === 0; while (true) { if (ctx.signal.aborted) return; if (Date.now() - start > maxDuration) return; const phase = await session.bot.getGamePhase(); if (phase === 'game_over') return; if (phase === 'round_over') { if (isHost) { await sleep(1500); // The scoresheet modal uses #ss-next-btn; the side panel uses #next-round-btn. // Try both — the visible one gets clicked. const ssBtn = session.page.locator('#ss-next-btn'); const sideBtn = session.page.locator('#next-round-btn'); const clicked = await ssBtn.click({ timeout: 3000 }).then(() => 'ss').catch(() => null) || await sideBtn.click({ timeout: 3000 }).then(() => 'side').catch(() => null); ctx.logger.info('round_advance', { room: opts.roomId, session: session.key, clicked }); } else { await sleep(2000); } // Wait for the next round to actually start (or game_over on last round) for (let i = 0; i < 40; i++) { const p = await session.bot.getGamePhase(); if (p === 'game_over' || p === 'playing' || p === 'initial_flip') break; await sleep(500); } ctx.heartbeat(opts.roomId); continue; } 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), players: sessions.map((s, j) => ({ key: s.key, score: null, isActive: j === sessionIdx, })), }); const thinkMs = randomInt(opts.thinkTimeMs[0], opts.thinkTimeMs[1]); await sleep(thinkMs); } else { await sleep(200); } } } await Promise.all(sessions.map((_, i) => sessionLoop(i))); // Let the server finish processing game completion (stats, DB update) // before we navigate away and kill the WebSocket connections. await sleep(2000); const totalTurns = turnCounts.reduce((a, b) => a + b, 0); ctx.dashboard.update(opts.roomId, { phase: 'game_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), }; } }