feat(soak): stress scenario with chaos injection

Rapid short games with a parallel chaos loop that has a 5% per-turn
chance of firing one of:
  - rapid_clicks: 5 quick clicks at the player's own cards
  - tab_blur:    window blur/focus event pair
  - brief_offline: 300ms network outage via context.setOffline

Chaos counts roll up into ScenarioResult.customMetrics.chaos_fired.

Important detail: chaos loop has a 3-second initial delay so room
creation, joiners, and game start can complete without interference.
Chaos during lobby setup (especially brief_offline) was causing
#create-room-btn to go unstable.

Verified: stress smoke with --games-per-room=3, 4 accounts + 1 CPU,
first game completed with 37 turns and chaos events fired across all
three event types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 21:49:30 -04:00
parent c307027dd0
commit 921a6ad984
3 changed files with 238 additions and 0 deletions

View File

@@ -8,9 +8,11 @@
import type { Scenario } from '../core/types';
import populate from './populate';
import stress from './stress';
const registry: Record<string, Scenario> = {
populate,
stress,
};
export function getScenario(name: string): Scenario | undefined {

View File

@@ -0,0 +1,65 @@
/**
* Chaos injector — occasionally fires unexpected UI events while a
* game is playing, to hunt race conditions and recovery bugs.
*
* Called from the stress scenario's background chaos loop. Each call
* has `probability` of firing; when it fires it picks one random
* event type and runs it against one session.
*/
import type { Session, Logger } from '../../core/types';
export type ChaosEvent = 'rapid_clicks' | 'tab_blur' | 'brief_offline';
const ALL_EVENTS: ChaosEvent[] = ['rapid_clicks', 'tab_blur', 'brief_offline'];
function pickEvent(): ChaosEvent {
return ALL_EVENTS[Math.floor(Math.random() * ALL_EVENTS.length)];
}
export async function maybeInjectChaos(
session: Session,
probability: number,
logger: Logger,
roomId: string,
): Promise<ChaosEvent | null> {
if (Math.random() >= probability) return null;
const event = pickEvent();
logger.info('chaos_injected', { room: roomId, session: session.key, event });
try {
switch (event) {
case 'rapid_clicks': {
// Fire 5 rapid clicks at the player's own cards
for (let i = 0; i < 5; i++) {
await session.page
.locator(`#player-cards .card:nth-child(${(i % 6) + 1})`)
.click({ timeout: 300 })
.catch(() => {});
}
break;
}
case 'tab_blur': {
// Briefly dispatch blur then focus — simulates user tabbing away
await session.page.evaluate(() => {
window.dispatchEvent(new Event('blur'));
setTimeout(() => window.dispatchEvent(new Event('focus')), 200);
});
break;
}
case 'brief_offline': {
// 300ms network outage — should trigger client reconnect logic
await session.context.setOffline(true);
await new Promise((r) => setTimeout(r, 300));
await session.context.setOffline(false);
break;
}
}
} catch (err) {
logger.warn('chaos_error', {
event,
error: err instanceof Error ? err.message : String(err),
});
}
return event;
}

View File

@@ -0,0 +1,171 @@
/**
* Stress scenario — rapid short games with chaos injection.
*
* Partitions sessions into N rooms (default 4), runs gamesPerRoom
* short 1-hole games per room in parallel. While each game plays,
* a background loop injects chaos events (rapid clicks, tab blur,
* brief offline) with 5% per-turn probability to hunt race
* conditions and recovery bugs.
*/
import type {
Scenario,
ScenarioContext,
ScenarioResult,
ScenarioError,
Session,
} from '../core/types';
import { runOneMultiplayerGame } from './shared/multiplayer-game';
import { maybeInjectChaos } from './shared/chaos';
interface StressConfig {
gamesPerRoom: number;
holes: number;
decks: number;
rooms: number;
cpusPerRoom: number;
thinkTimeMs: [number, number];
interGamePauseMs: number;
chaosChance: number;
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
async function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function runStressRoom(
ctx: ScenarioContext,
cfg: StressConfig,
roomIdx: number,
sessions: Session[],
): Promise<{ completed: number; errors: ScenarioError[]; chaosFired: number }> {
const roomId = `room-${roomIdx}`;
let completed = 0;
let chaosFired = 0;
const errors: ScenarioError[] = [];
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
if (ctx.signal.aborted) break;
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
// Background chaos loop — runs concurrently with the game turn loop.
// Delay the first tick by 3 seconds so room creation + joiners + game
// start have time to complete without chaos interference (rapid clicks
// or brief_offline during lobby setup can prevent #create-room-btn
// from becoming stable).
let chaosActive = true;
const chaosLoop = (async () => {
await sleep(3000);
while (chaosActive && !ctx.signal.aborted) {
await sleep(500);
for (const session of sessions) {
const e = await maybeInjectChaos(
session,
cfg.chaosChance,
ctx.logger,
roomId,
);
if (e) chaosFired++;
}
}
})();
const result = await runOneMultiplayerGame(ctx, sessions, {
roomId,
holes: cfg.holes,
decks: cfg.decks,
cpusPerRoom: cfg.cpusPerRoom,
thinkTimeMs: cfg.thinkTimeMs,
});
chaosActive = false;
await chaosLoop;
if (result.completed) {
completed++;
ctx.logger.info('game_complete', {
room: roomId,
game: gameNum + 1,
turns: result.turns,
});
} else {
errors.push({
room: roomId,
reason: 'game_failed',
detail: result.error,
timestamp: Date.now(),
});
ctx.logger.error('game_failed', { room: roomId, error: result.error });
}
await sleep(cfg.interGamePauseMs);
}
return { completed, errors, chaosFired };
}
const stress: Scenario = {
name: 'stress',
description: 'Rapid short games for stability & race condition hunting',
needs: { accounts: 16, rooms: 4, cpusPerRoom: 2 },
defaultConfig: {
gamesPerRoom: 50,
holes: 1,
decks: 1,
rooms: 4,
cpusPerRoom: 2,
thinkTimeMs: [50, 150],
interGamePauseMs: 200,
chaosChance: 0.05,
},
async run(ctx: ScenarioContext): Promise<ScenarioResult> {
const start = Date.now();
const cfg = ctx.config as unknown as StressConfig;
const perRoom = Math.floor(ctx.sessions.length / cfg.rooms);
if (perRoom * cfg.rooms !== ctx.sessions.length) {
throw new Error(
`stress: ${ctx.sessions.length} sessions does not divide evenly into ${cfg.rooms} rooms`,
);
}
const roomSessions = chunk(ctx.sessions, perRoom);
const results = await Promise.allSettled(
roomSessions.map((s, idx) => runStressRoom(ctx, cfg, idx, s)),
);
let gamesCompleted = 0;
let chaosFired = 0;
const errors: ScenarioError[] = [];
results.forEach((r, idx) => {
if (r.status === 'fulfilled') {
gamesCompleted += r.value.completed;
chaosFired += r.value.chaosFired;
errors.push(...r.value.errors);
} else {
errors.push({
room: `room-${idx}`,
reason: 'room_threw',
detail: r.reason instanceof Error ? r.reason.message : String(r.reason),
timestamp: Date.now(),
});
}
});
return {
gamesCompleted,
errors,
durationMs: Date.now() - start,
customMetrics: { chaos_fired: chaosFired },
};
},
};
export default stress;