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:
EJClaw
2026-10-04 04:18:29 +09:00
parent f4d63e1544
commit 6ee8da18f1
45 changed files with 4175 additions and 0 deletions

View 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];
}