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:
8
packages/engine/package.json
Normal file
8
packages/engine/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@bg/engine",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"dependencies": { "zod": "4.6.5" }
|
||||
}
|
||||
54
packages/engine/src/grid.ts
Normal file
54
packages/engine/src/grid.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** The four line directions (horizontal, vertical, two diagonals). */
|
||||
export const LINE_DIRS: readonly Point[] = [
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: -1 },
|
||||
];
|
||||
|
||||
export const ORTHO_DIRS: readonly Point[] = [
|
||||
{ x: 1, y: 0 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
|
||||
export const inBounds = (size: number, x: number, y: number): boolean =>
|
||||
x >= 0 && y >= 0 && x < size && y < size;
|
||||
|
||||
export const idx = (size: number, x: number, y: number): number => y * size + x;
|
||||
|
||||
/**
|
||||
* Count consecutive cells equal to `value` starting next to (x, y) going in (dx, dy).
|
||||
* The origin cell itself is not counted.
|
||||
*/
|
||||
export function runLength<T>(
|
||||
cells: readonly T[],
|
||||
size: number,
|
||||
x: number,
|
||||
y: number,
|
||||
dx: number,
|
||||
dy: number,
|
||||
value: T,
|
||||
): number {
|
||||
let n = 0;
|
||||
let cx = x + dx;
|
||||
let cy = y + dy;
|
||||
while (inBounds(size, cx, cy) && cells[idx(size, cx, cy)] === value) {
|
||||
n++;
|
||||
cx += dx;
|
||||
cy += dy;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Board coordinate label used in UI and screen reader text, e.g. (7, 7) on 15 → "H8". */
|
||||
export function coordLabel(size: number, x: number, y: number): string {
|
||||
const letters = 'ABCDEFGHJKLMNOPQRST'; // Go convention skips I
|
||||
return `${letters[x] ?? '?'}${size - y}`;
|
||||
}
|
||||
5
packages/engine/src/index.ts
Normal file
5
packages/engine/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './rng';
|
||||
export * from './types';
|
||||
export * from './grid';
|
||||
export * from './turns';
|
||||
export * from './testing';
|
||||
36
packages/engine/src/rng.test.ts
Normal file
36
packages/engine/src/rng.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, createSeed, seedFromHex, seedToHex } from './rng';
|
||||
|
||||
describe('SeededRng', () => {
|
||||
test('is deterministic for the same seed', () => {
|
||||
const a = SeededRng.fromSeed([1, 2, 3, 4]);
|
||||
const b = SeededRng.fromSeed([1, 2, 3, 4]);
|
||||
for (let i = 0; i < 100; i++) expect(a.nextUint32()).toBe(b.nextUint32());
|
||||
});
|
||||
|
||||
test('state can be saved and resumed', () => {
|
||||
const a = SeededRng.fromSeed([9, 8, 7, 6]);
|
||||
a.int(10);
|
||||
const resumed = new SeededRng(a.state());
|
||||
expect(resumed.int(1000)).toBe(a.int(1000));
|
||||
});
|
||||
|
||||
test('int stays in range and is roughly uniform', () => {
|
||||
const r = SeededRng.fromSeed([5, 5, 5, 5]);
|
||||
const counts = new Array(6).fill(0);
|
||||
for (let i = 0; i < 60000; i++) counts[r.int(6)]++;
|
||||
for (const c of counts) expect(Math.abs(c - 10000)).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('shuffle is a permutation', () => {
|
||||
const r = SeededRng.fromSeed(createSeed());
|
||||
const arr = Array.from({ length: 52 }, (_, i) => i);
|
||||
const s = r.shuffle(arr);
|
||||
expect(s.slice().sort((x, y) => x - y)).toEqual(arr);
|
||||
});
|
||||
|
||||
test('hex round trip', () => {
|
||||
const seed = createSeed();
|
||||
expect(seedFromHex(seedToHex(seed))).toEqual(seed);
|
||||
});
|
||||
});
|
||||
91
packages/engine/src/rng.ts
Normal file
91
packages/engine/src/rng.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Deterministic seeded PRNG (sfc32). The state is four 32-bit unsigned ints and is
|
||||
* JSON-serialisable so it can be persisted next to the game state (docs/09 §3).
|
||||
*/
|
||||
export type RngState = [number, number, number, number];
|
||||
|
||||
export interface Rng {
|
||||
/** Float in [0, 1). */
|
||||
next(): number;
|
||||
/** Unbiased integer in [0, maxExclusive). */
|
||||
int(maxExclusive: number): number;
|
||||
/** Fisher–Yates shuffle, returns a new array. */
|
||||
shuffle<T>(arr: readonly T[]): T[];
|
||||
}
|
||||
|
||||
export function createSeed(): RngState {
|
||||
const buf = new Uint32Array(4);
|
||||
crypto.getRandomValues(buf);
|
||||
// An all-zero state would be degenerate for sfc32's counter; guard anyway.
|
||||
if (buf[0] === 0 && buf[1] === 0 && buf[2] === 0 && buf[3] === 0) buf[3] = 1;
|
||||
return [buf[0]!, buf[1]!, buf[2]!, buf[3]!];
|
||||
}
|
||||
|
||||
export function seedToHex(seed: RngState): string {
|
||||
return seed.map((n) => (n >>> 0).toString(16).padStart(8, '0')).join('');
|
||||
}
|
||||
|
||||
export function seedFromHex(hex: string): RngState {
|
||||
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error('invalid seed hex');
|
||||
return [0, 1, 2, 3].map((i) => parseInt(hex.slice(i * 8, i * 8 + 8), 16) >>> 0) as RngState;
|
||||
}
|
||||
|
||||
/** Mutable PRNG over a copy of `state`; read the advanced state back with `rng.state()`. */
|
||||
export class SeededRng implements Rng {
|
||||
private a: number;
|
||||
private b: number;
|
||||
private c: number;
|
||||
private d: number;
|
||||
|
||||
constructor(state: RngState) {
|
||||
[this.a, this.b, this.c, this.d] = state.map((n) => n >>> 0) as RngState;
|
||||
}
|
||||
|
||||
/** Seeds from a hex string, running a few warm-up rounds as recommended for sfc32. */
|
||||
static fromSeed(seed: RngState): SeededRng {
|
||||
const rng = new SeededRng(seed);
|
||||
for (let i = 0; i < 12; i++) rng.nextUint32();
|
||||
return rng;
|
||||
}
|
||||
|
||||
state(): RngState {
|
||||
return [this.a, this.b, this.c, this.d];
|
||||
}
|
||||
|
||||
nextUint32(): number {
|
||||
const t = (((this.a + this.b) >>> 0) + this.d) >>> 0;
|
||||
this.d = (this.d + 1) >>> 0;
|
||||
this.a = this.b ^ (this.b >>> 9);
|
||||
this.b = (this.c + (this.c << 3)) >>> 0;
|
||||
this.c = ((this.c << 21) | (this.c >>> 11)) >>> 0;
|
||||
this.c = (this.c + t) >>> 0;
|
||||
return t;
|
||||
}
|
||||
|
||||
next(): number {
|
||||
return this.nextUint32() / 4294967296;
|
||||
}
|
||||
|
||||
int(maxExclusive: number): number {
|
||||
if (!Number.isInteger(maxExclusive) || maxExclusive <= 0 || maxExclusive > 4294967296) {
|
||||
throw new Error(`rng.int: invalid bound ${maxExclusive}`);
|
||||
}
|
||||
// Rejection sampling to avoid modulo bias.
|
||||
const limit = 4294967296 - (4294967296 % maxExclusive);
|
||||
for (;;) {
|
||||
const x = this.nextUint32();
|
||||
if (x < limit) return x % maxExclusive;
|
||||
}
|
||||
}
|
||||
|
||||
shuffle<T>(arr: readonly T[]): T[] {
|
||||
const out = arr.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = this.int(i + 1);
|
||||
const tmp = out[i]!;
|
||||
out[i] = out[j]!;
|
||||
out[j] = tmp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
8
packages/engine/src/turns.ts
Normal file
8
packages/engine/src/turns.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Next index clockwise among `count` seats, skipping seats where `skip(i)` is true. */
|
||||
export function nextSeat(current: number, count: number, skip: (i: number) => boolean = () => false): number {
|
||||
for (let step = 1; step <= count; step++) {
|
||||
const i = (current + step) % count;
|
||||
if (!skip(i)) return i;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
71
packages/engine/src/types.ts
Normal file
71
packages/engine/src/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import type { Rng } from './rng';
|
||||
|
||||
export type PlayerId = string;
|
||||
|
||||
/** Animation / log hint. Must contain public information only. */
|
||||
export interface GameEvent {
|
||||
type: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export type ResultReason = 'normal' | 'resign' | 'timeout' | 'abandoned' | 'draw-agreed';
|
||||
|
||||
export interface GameResult {
|
||||
/** Groups of tied players, best first: [[winner], [second, second], ...]. */
|
||||
ranking: PlayerId[][];
|
||||
scores?: Record<PlayerId, number>;
|
||||
/** Korean, user-facing summary line. */
|
||||
summary: string;
|
||||
reason: ResultReason;
|
||||
}
|
||||
|
||||
export type Validation = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
export interface SetupContext<O> {
|
||||
players: PlayerId[];
|
||||
options: O;
|
||||
rng: Rng;
|
||||
now: number;
|
||||
/** 1-based game number within the room (for alternating first player on rematch). */
|
||||
gameNo?: number;
|
||||
}
|
||||
|
||||
export interface ApplyContext {
|
||||
rng: Rng;
|
||||
now: number;
|
||||
}
|
||||
|
||||
/** Pure, deterministic game module (docs/09-game-engine.md §2). */
|
||||
export interface GameDefinition<S, A, V, O> {
|
||||
id: string;
|
||||
nameKo: string;
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
stateVersion: number;
|
||||
defaultOptions: O;
|
||||
optionsSchema: ZodType<O>;
|
||||
actionSchema: ZodType<A>;
|
||||
playersFor?(options: O): { min: number; max: number };
|
||||
setup(ctx: SetupContext<O>): S;
|
||||
validate(state: S, actor: PlayerId, action: A): Validation;
|
||||
apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] };
|
||||
view(state: S, viewer: PlayerId | null): V;
|
||||
activePlayers(state: S): PlayerId[];
|
||||
deadline?(state: S): number | null;
|
||||
/** Players the deadline applies to (default: activePlayers). */
|
||||
timeoutPlayers?(state: S): PlayerId[];
|
||||
onTimeout(state: S, player: PlayerId): A;
|
||||
onLeave?(state: S, player: PlayerId): A | null;
|
||||
result(state: S): GameResult | null;
|
||||
migrate?(old: unknown, fromVersion: number): S;
|
||||
/** Test-only helpers. */
|
||||
legalActions?(state: S, player: PlayerId): A[];
|
||||
secretsOf?(state: S, player: PlayerId): string[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyGameDefinition = GameDefinition<any, any, any, any>;
|
||||
|
||||
export const ok: Validation = { ok: true };
|
||||
export const fail = (reason: string): Validation => ({ ok: false, reason });
|
||||
5
packages/engine/tsconfig.json
Normal file
5
packages/engine/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src", "types": ["bun"] },
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user