- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구 - packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그 - packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개) - apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
/**
|
|
* 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<O> {
|
|
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<O>(def: AnyGameDefinition, opts: PlayoutOptions<O>): 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];
|
|
}
|