/** * Shared test helpers (docs/09-game-engine.md ยง7): random playouts with invariant checks, * determinism, JSON round-trip and hidden-info leak detection. */ import { SeededRng, type RngState } from './rng'; import type { AnyGameDefinition, PlayerId } from './types'; export interface PlayoutOptions { players: PlayerId[]; options?: O; seed: RngState; maxSteps?: number; /** Extra invariant per step; throw to fail. */ invariant?: (state: unknown) => void; maxApplyMs?: number; } export interface PlayoutReport { steps: number; finished: boolean; trace: { actor: PlayerId; action: unknown }[]; finalState: unknown; } export function randomPlayout(def: AnyGameDefinition, opts: PlayoutOptions): PlayoutReport { if (!def.legalActions) throw new Error(`${def.id}: legalActions is required for playouts`); const rng = SeededRng.fromSeed(opts.seed); const chooser = SeededRng.fromSeed([opts.seed[3], opts.seed[2], opts.seed[1], opts.seed[0] ^ 0x9e3779b9]); let now = 1_700_000_000_000; let state = def.setup({ players: opts.players, options: opts.options ?? def.defaultOptions, rng, now }); const trace: PlayoutReport['trace'] = []; const maxSteps = opts.maxSteps ?? 5000; for (let step = 0; step < maxSteps; step++) { checkRoundTrip(state); for (const p of opts.players) checkNoLeak(def, state, p, opts.players); opts.invariant?.(state); if (def.result(state)) return { steps: step, finished: true, trace, finalState: state }; const actives = def.activePlayers(state); if (actives.length === 0) throw new Error(`${def.id}: no active players but game not finished`); const actor = actives[chooser.int(actives.length)]!; const legal = def.legalActions(state, actor); const action = legal.length > 0 ? legal[chooser.int(legal.length)] : def.onTimeout(state, actor); const v = def.validate(state, actor, action); if (!v.ok) throw new Error(`${def.id}: generated action rejected: ${v.reason} ${JSON.stringify(action)}`); now += 1000; const t0 = performance.now(); state = def.apply(state, actor, action, { rng, now }).state; const ms = performance.now() - t0; if (ms > (opts.maxApplyMs ?? 5)) throw new Error(`${def.id}: apply took ${ms.toFixed(2)}ms`); trace.push({ actor, action }); } return { steps: maxSteps, finished: false, trace, finalState: state }; } export function checkRoundTrip(state: unknown): void { const json = JSON.stringify(state); const back = JSON.parse(json); if (JSON.stringify(back) !== json) throw new Error('state is not JSON round-trippable'); } /** The viewer's view must not contain secrets belonging to any other player. */ export function checkNoLeak(def: AnyGameDefinition, state: unknown, viewer: PlayerId, players: PlayerId[]): void { if (!def.secretsOf) return; const view = JSON.stringify(def.view(state, viewer)); const spectatorView = JSON.stringify(def.view(state, null)); for (const other of players) { for (const secret of def.secretsOf(state, other)) { if (other !== viewer && view.includes(JSON.stringify(secret))) { throw new Error(`${def.id}: view for ${viewer} leaks secret ${secret} of ${other}`); } if (spectatorView.includes(JSON.stringify(secret))) { throw new Error(`${def.id}: spectator view leaks secret ${secret} of ${other}`); } } } } export function seedN(n: number): RngState { return [n >>> 0, (n * 2654435761) >>> 0, (n ^ 0xdeadbeef) >>> 0, (n + 1) >>> 0]; }