fix(soak): multi-hole round transitions, token refresh, dashboard wiring

- Session loop now handles round_over by clicking #ss-next-btn (the
  scoresheet modal button) instead of exiting early. Waits for next
  round or game_over before continuing.
- SessionPool detects expired tokens on acquire and re-logins
  automatically, writing fresh credentials to .env.stresstest.
- Added 2s post-game delay before goto('/') so the server can process
  game completion before WebSocket disconnect.
- Wired dashboard metrics (games_completed, moves_total, errors),
  activity log entries, and player tiles for both populate and stress
  scenarios.
- Bumped screencast resolution to 960x540 and set headless viewport
  to 960x800 for better click-to-watch framing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-17 20:37:24 -04:00
parent ccc2f3b559
commit 70498b1c33
6 changed files with 121 additions and 7 deletions

View File

@@ -50,9 +50,15 @@ async function runRoom(
let completed = 0;
const errors: ScenarioError[] = [];
// Send player list for dashboard tiles
ctx.dashboard.update(roomId, {
players: sessions.map((s) => ({ key: s.key, score: null, isActive: false })),
});
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
if (ctx.signal.aborted) break;
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
ctx.dashboard.log('info', `${roomId}: starting game ${gameNum + 1}/${cfg.gamesPerRoom}`);
ctx.logger.info('game_start', { room: roomId, game: gameNum + 1 });
const result = await runOneMultiplayerGame(ctx, sessions, {
@@ -66,6 +72,9 @@ async function runRoom(
if (result.completed) {
completed++;
ctx.dashboard.incrementMetric('games_completed');
ctx.dashboard.incrementMetric('moves_total', result.turns);
ctx.dashboard.log('info', `${roomId}: game ${gameNum + 1} complete — ${result.turns} turns, ${(result.durationMs / 1000).toFixed(1)}s`);
ctx.logger.info('game_complete', {
room: roomId,
game: gameNum + 1,
@@ -73,6 +82,8 @@ async function runRoom(
durationMs: result.durationMs,
});
} else {
ctx.dashboard.incrementMetric('errors');
ctx.dashboard.log('error', `${roomId}: game ${gameNum + 1} failed — ${result.error}`);
errors.push({
room: roomId,
reason: 'game_failed',

View File

@@ -53,7 +53,28 @@ export async function runOneMultiplayerGame(
// 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('/')));
// 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
@@ -90,12 +111,36 @@ export async function runOneMultiplayerGame(
async function sessionLoop(sessionIdx: number): Promise<void> {
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' || phase === 'round_over') return;
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();
@@ -104,6 +149,11 @@ export async function runOneMultiplayerGame(
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);
@@ -115,8 +165,12 @@ export async function runOneMultiplayerGame(
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: 'round_over' });
ctx.dashboard.update(opts.roomId, { phase: 'game_over' });
return {
completed: true,
turns: totalTurns,

View File

@@ -50,10 +50,15 @@ async function runStressRoom(
let chaosFired = 0;
const errors: ScenarioError[] = [];
ctx.dashboard.update(roomId, {
players: sessions.map((s) => ({ key: s.key, score: null, isActive: false })),
});
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
if (ctx.signal.aborted) break;
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
ctx.dashboard.log('info', `${roomId}: starting game ${gameNum + 1}/${cfg.gamesPerRoom}`);
// Background chaos loop — runs concurrently with the game turn loop.
// Delay the first tick by 3 seconds so room creation + joiners + game
@@ -90,12 +95,17 @@ async function runStressRoom(
if (result.completed) {
completed++;
ctx.dashboard.incrementMetric('games_completed');
ctx.dashboard.incrementMetric('moves_total', result.turns);
ctx.dashboard.log('info', `${roomId}: game ${gameNum + 1} complete — ${result.turns} turns`);
ctx.logger.info('game_complete', {
room: roomId,
game: gameNum + 1,
turns: result.turns,
});
} else {
ctx.dashboard.incrementMetric('errors');
ctx.dashboard.log('error', `${roomId}: game ${gameNum + 1} failed — ${result.error}`);
errors.push({
room: roomId,
reason: 'game_failed',