M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- 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>
This commit is contained in:
80
packages/engine/src/testing.ts
Normal file
80
packages/engine/src/testing.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
Reference in New Issue
Block a user