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,8 @@
{
"name": "@bg/engine",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"dependencies": { "zod": "4.6.5" }
}

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

View File

@@ -0,0 +1,5 @@
export * from './rng';
export * from './types';
export * from './grid';
export * from './turns';
export * from './testing';

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src", "types": ["bun"] },
"include": ["src"]
}