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

View File

@@ -0,0 +1,8 @@
{
"name": "@bg/games",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts", "./*": "./src/*/index.ts" },
"dependencies": { "@bg/engine": "workspace:*", "zod": "4.6.5" }
}

View File

@@ -0,0 +1,6 @@
import type { AnyGameDefinition } from '@bg/engine';
import { omok } from './omok';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
};

View File

@@ -0,0 +1,518 @@
/** 오목 (docs/games/omok.md). */
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import {
Board,
candidateCells,
forbiddenCells,
forbiddenKind,
straightFourCount,
winningLine,
type Cell,
type RuleConfig,
type Stone,
} from './rules';
export type Color = 'black' | 'white';
const STONE: Record<Color, Stone> = { black: 1, white: 2 };
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
export const omokOptionsSchema = z.object({
ruleSet: z.enum(['korean', 'renju', 'free']).default('korean'),
freeWin: z.enum(['fivePlus', 'exactFive']).default('fivePlus'),
boardSize: z.union([z.literal(15), z.literal(19)]).default(15),
firstMoveCenter: z.enum(['auto', 'on', 'off']).default('auto'),
colorAssignment: z.enum(['random', 'hostBlack', 'hostWhite', 'alternate']).default('random'),
timeControl: z
.object({
kind: z.enum(['none', 'perMove', 'fischer']).default('perMove'),
perMoveSec: z.union([z.literal(10), z.literal(20), z.literal(30), z.literal(60)]).default(30),
baseSec: z.number().int().min(60).max(1800).default(300),
incSec: z.number().int().min(0).max(30).default(3),
})
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 }),
timeoutPolicy: z.enum(['autoMove', 'lose']).default('autoMove'),
idleLimitSec: z.union([z.literal(120), z.literal(300), z.literal(600)]).default(300),
undo: z.enum(['off', '1', '3', 'unlimited']).default('3'),
drawOffer: z.boolean().default(true),
showForbidden: z.boolean().default(true),
hints: z.enum(['off', 'warn']).default('warn'),
});
export type OmokOptions = z.infer<typeof omokOptionsSchema>;
export const omokActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('place'), x: z.number().int().min(0).max(18), y: z.number().int().min(0).max(18) }),
z.object({ type: z.literal('requestUndo') }),
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
z.object({ type: z.literal('offerDraw') }),
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
z.object({ type: z.literal('cancelOffer') }),
z.object({ type: z.literal('resign') }),
]);
/** Client-sendable actions plus the system-only timeout action (not in the schema). */
export type OmokAction = z.infer<typeof omokActionSchema> | { type: 'timeout' };
export interface Pt {
x: number;
y: number;
}
export type OmokReason = 'five' | 'boardFull' | 'noLegalMove' | 'resign' | 'timeLoss' | 'drawAgreed';
export interface OmokState {
options: OmokOptions;
size: 15 | 19;
board: Cell[];
players: Record<Color, PlayerId>;
turn: Color;
moves: { color: Color; pt: Pt; at: number; auto: boolean }[];
phase: 'playing' | 'finished';
winLine: Pt[] | null;
clock: { remainingMs: Record<Color, number>; turnStartedAt: number; autoMoveStreak: Record<Color, number> };
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
draw: { pendingBy: Color | null; lastOfferPly: Record<Color, number | null> };
/** Cached forbidden cells for the side to move (derived from board). */
forbidden: number[];
result: { winner: Color | null; reason: OmokReason; moveCount: number; summary: string } | null;
}
export interface OmokView {
size: number;
board: Cell[];
players: Record<Color, PlayerId>;
myColor: Color | null;
turn: Color;
ruleSet: OmokOptions['ruleSet'];
firstMoveCenter: boolean;
lastMove: Pt | null;
moveCount: number;
moves: { color: Color; pt: Pt; auto: boolean }[];
phase: OmokState['phase'];
winLine: Pt[] | null;
timeControl: OmokOptions['timeControl'];
remainingMs: Record<Color, number>;
turnStartedAt: number;
undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null };
draw: { allowed: boolean; pendingBy: Color | null };
forbidden: Pt[];
hints: { urgent: Pt[]; warn: Pt[] } | null;
result: OmokState['result'];
}
const rule = (o: OmokOptions): RuleConfig => ({ ruleSet: o.ruleSet, freeWin: o.freeWin });
const centerOf = (size: number): Pt => ({ x: (size - 1) / 2, y: (size - 1) / 2 });
const toPt = (i: number, size: number): Pt => ({ x: i % size, y: Math.floor(i / size) });
const undoLimit = (o: OmokOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
const firstMoveCenterOn = (o: OmokOptions): boolean =>
o.firstMoveCenter === 'on' || (o.firstMoveCenter === 'auto' && o.ruleSet === 'renju');
const colorOf = (s: OmokState, p: PlayerId): Color | null =>
s.players.black === p ? 'black' : s.players.white === p ? 'white' : null;
const FORBID_MSG = {
'double-three': (c: Color) => `${COLOR_KO[c]}은 3-3 자리에 둘 수 없어요(쌍삼 금지).`,
'double-four': (c: Color) => `${COLOR_KO[c]}은 4-4 자리에 둘 수 없어요.`,
overline: (c: Color) => `${COLOR_KO[c]}은 6목 이상을 만들 수 없어요.`,
} as const;
function boardOf(s: OmokState): Board {
return new Board(s.size, s.board);
}
function refreshForbidden(s: OmokState): void {
s.forbidden = s.phase === 'playing' ? forbiddenCells(boardOf(s), STONE[s.turn], rule(s.options)) : [];
}
function finish(s: OmokState, winner: Color | null, reason: OmokReason): void {
s.phase = 'finished';
s.undo.pending = null;
s.draw.pendingBy = null;
s.forbidden = [];
const n = s.moves.length;
let summary: string;
switch (reason) {
case 'five': {
const line = s.winLine!;
const lab = (p: Pt) => `${'ABCDEFGHIJKLMNOPQRS'[p.x]}${s.size - p.y}`;
summary = `${COLOR_KO[winner!]} 승 (${lab(line[0]!)}~${lab(line[line.length - 1]!)} 오목, ${n}수)`;
break;
}
case 'resign':
summary = `${COLOR_KO[other(winner!)]} 기권으로 ${COLOR_KO[winner!]} 승`;
break;
case 'timeLoss':
summary = `${COLOR_KO[other(winner!)]} 시간패로 ${COLOR_KO[winner!]} 승`;
break;
case 'drawAgreed':
summary = '합의 무승부';
break;
default:
summary = `무승부 (${n}수, 더 둘 곳 없음)`;
}
s.result = { winner, reason, moveCount: n, summary };
}
/** Legal cells for the side to move. */
function legalCells(s: OmokState): number[] {
if (s.phase !== 'playing') return [];
if (s.moves.length === 0 && firstMoveCenterOn(s.options)) {
const c = centerOf(s.size);
return [c.y * s.size + c.x];
}
const forb = new Set(s.forbidden);
const out: number[] = [];
for (let i = 0; i < s.board.length; i++) if (s.board[i] === 0 && !forb.has(i)) out.push(i);
return out;
}
function placeStone(s: OmokState, color: Color, pt: Pt, now: number, auto: boolean, events: GameEvent[]): void {
const b = boardOf(s);
b.set(pt.x, pt.y, STONE[color]);
s.moves.push({ color, pt, at: now, auto });
s.undo.pending = null;
s.draw.pendingBy = null;
events.push({ type: 'stonePlaced', color, x: pt.x, y: pt.y, auto });
const line = winningLine(b, pt.x, pt.y, STONE[color], rule(s.options));
if (line) {
s.winLine = line.map((i) => toPt(i, s.size));
finish(s, color, 'five');
events.push({ type: 'win', color });
return;
}
s.turn = other(color);
s.clock.turnStartedAt = now;
refreshForbidden(s);
if (!s.board.includes(0)) {
finish(s, null, 'boardFull');
events.push({ type: 'draw' });
} else if (legalCells(s).length === 0) {
finish(s, null, 'noLegalMove');
events.push({ type: 'draw' });
}
}
/** docs/games/omok.md §8.4: win > block > near existing stones > anywhere. */
function chooseAutoMove(s: OmokState, rng: Rng): Pt {
const legal = legalCells(s);
const b = boardOf(s);
const me = STONE[s.turn];
const opp = STONE[other(s.turn)];
const r = rule(s.options);
const pick = (cells: number[]) => toPt(cells[rng.int(cells.length)]!, s.size);
if (s.moves.length === 0 && legal.length > 0) {
const c = centerOf(s.size);
if (b.at(c.x, c.y) === 0) return c;
}
const completes = (i: number, color: Stone) => {
const { x, y } = toPt(i, s.size);
b.set(x, y, color);
const w = winningLine(b, x, y, color, r) !== null;
b.set(x, y, 0);
return w;
};
const win = legal.filter((i) => completes(i, me));
if (win.length) return pick(win);
const block = legal.filter((i) => completes(i, opp));
if (block.length) return pick(block);
const near = legal.filter((i) => {
const { x, y } = toPt(i, s.size);
for (let dy = -2; dy <= 2; dy++)
for (let dx = -2; dx <= 2; dx++) if (b.on(x + dx, y + dy) && b.at(x + dx, y + dy) !== 0) return true;
return false;
});
return pick(near.length ? near : legal);
}
function threatHints(s: OmokState): { urgent: Pt[]; warn: Pt[] } {
const b = boardOf(s);
const opp = STONE[other(s.turn)];
const r = rule(s.options);
const urgent: Pt[] = [];
const warn: Pt[] = [];
for (const i of candidateCells(b, opp, 2)) {
const { x, y } = toPt(i, s.size);
b.set(x, y, opp);
if (winningLine(b, x, y, opp, r)) urgent.push({ x, y });
else if (straightFourCount(b, x, y, opp, r) > 0) warn.push({ x, y });
b.set(x, y, 0);
}
return { urgent, warn };
}
function pendingResponder(s: OmokState): Color | null {
if (s.undo.pending) return other(s.undo.pending.by);
if (s.draw.pendingBy) return other(s.draw.pendingBy);
return null;
}
export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions> = {
id: 'omok',
nameKo: '오목',
minPlayers: 2,
maxPlayers: 2,
stateVersion: 1,
defaultOptions: omokOptionsSchema.parse({}),
optionsSchema: omokOptionsSchema,
actionSchema: omokActionSchema as z.ZodType<OmokAction>,
setup({ players, options, rng, now, gameNo }) {
const [host, guest] = players as [PlayerId, PlayerId];
let hostBlack: boolean;
switch (options.colorAssignment) {
case 'hostBlack':
hostBlack = true;
break;
case 'hostWhite':
hostBlack = false;
break;
case 'alternate':
hostBlack = (gameNo ?? 1) % 2 === 1;
break;
default:
hostBlack = rng.int(2) === 0;
}
const size = options.boardSize;
const base = options.timeControl.baseSec * 1000;
const s: OmokState = {
options,
size,
board: new Array<Cell>(size * size).fill(0),
players: hostBlack ? { black: host, white: guest } : { black: guest, white: host },
turn: 'black',
moves: [],
phase: 'playing',
winLine: null,
clock: { remainingMs: { black: base, white: base }, turnStartedAt: now, autoMoveStreak: { black: 0, white: 0 } },
undo: { usedBy: { black: 0, white: 0 }, pending: null },
draw: { pendingBy: null, lastOfferPly: { black: null, white: null } },
forbidden: [],
result: null,
};
return s;
},
validate(s, actor, a) {
const me = colorOf(s, actor);
if (!me) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'place': {
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
if (a.x >= s.size || a.y >= s.size) return fail('판 밖이에요.');
if (s.board[a.y * s.size + a.x] !== 0) return fail('이미 돌이 있는 자리예요.');
if (s.moves.length === 0 && firstMoveCenterOn(s.options)) {
const c = centerOf(s.size);
if (a.x !== c.x || a.y !== c.y) return fail('첫 수는 가운데에 둬야 해요.');
}
const kind = forbiddenKind(boardOf(s), a.x, a.y, STONE[me], rule(s.options));
if (kind) return fail(FORBID_MSG[kind](me));
return ok;
}
case 'requestUndo': {
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
if (s.undo.usedBy[me] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
if (!s.moves.some((m) => m.color === me)) return fail('아직 무를 수가 없어요.');
return ok;
}
case 'respondUndo':
if (!s.undo.pending || s.undo.pending.by === me) return fail('응답할 무르기 요청이 없어요.');
return ok;
case 'offerDraw': {
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
const last = s.draw.lastOfferPly[me];
if (last !== null && s.moves.slice(last).filter((m) => m.color === me).length < 3) {
return fail('무승부 제안은 세 수를 더 둔 뒤에 다시 할 수 있어요.');
}
return ok;
}
case 'respondDraw':
if (!s.draw.pendingBy || s.draw.pendingBy === me) return fail('응답할 무승부 제안이 없어요.');
return ok;
case 'cancelOffer':
if (s.undo.pending?.by !== me && s.draw.pendingBy !== me) return fail('취소할 요청이 없어요.');
return ok;
case 'resign':
return ok;
case 'timeout':
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
return ok;
}
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const me = colorOf(s, actor)!;
const events: GameEvent[] = [];
switch (a.type) {
case 'place': {
if (s.options.timeControl.kind === 'fischer') {
s.clock.remainingMs[me] -= now - s.clock.turnStartedAt;
if (s.clock.remainingMs[me] <= 0) {
s.clock.remainingMs[me] = 0;
finish(s, other(me), 'timeLoss');
events.push({ type: 'timeLoss', color: me });
break;
}
s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000;
}
s.clock.autoMoveStreak[me] = 0;
placeStone(s, me, { x: a.x, y: a.y }, now, false, events);
break;
}
case 'timeout': {
const tc = s.options.timeControl.kind;
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[me] < 2) {
s.clock.autoMoveStreak[me]++;
const pt = chooseAutoMove(s, rng);
events.push({ type: 'timeoutAutoMove', color: me });
placeStone(s, me, pt, now, true, events);
} else {
if (tc === 'fischer') s.clock.remainingMs[me] = 0;
finish(s, other(me), 'timeLoss');
events.push({ type: 'timeLoss', color: me });
}
break;
}
case 'requestUndo': {
const last = s.moves[s.moves.length - 1]!;
s.undo.pending = { by: me, plies: last.color === me ? 1 : 2 };
events.push({ type: 'undoRequested', color: me });
break;
}
case 'respondUndo': {
const { by, plies } = s.undo.pending!;
s.undo.pending = null;
if (a.accept) {
const b = boardOf(s);
for (let i = 0; i < plies; i++) {
const m = s.moves.pop();
if (m) b.set(m.pt.x, m.pt.y, 0);
}
s.undo.usedBy[by]++;
s.turn = by;
s.clock.turnStartedAt = now;
refreshForbidden(s);
events.push({ type: 'undoAccepted', color: by, plies });
} else {
events.push({ type: 'undoDeclined', color: by });
}
break;
}
case 'offerDraw':
s.draw.pendingBy = me;
s.draw.lastOfferPly[me] = s.moves.length;
events.push({ type: 'drawOffered', color: me });
break;
case 'respondDraw':
s.draw.pendingBy = null;
if (a.accept) {
finish(s, null, 'drawAgreed');
events.push({ type: 'drawAccepted' });
} else events.push({ type: 'drawDeclined' });
break;
case 'cancelOffer':
if (s.undo.pending?.by === me) s.undo.pending = null;
if (s.draw.pendingBy === me) s.draw.pendingBy = null;
events.push({ type: 'offerCancelled', color: me });
break;
case 'resign':
finish(s, other(me), 'resign');
events.push({ type: 'resigned', color: me });
break;
}
return { state: s, events };
},
view(s, viewer) {
const myColor = viewer ? colorOf(s, viewer) : null;
const limit = undoLimit(s.options);
const showForbidden = s.options.showForbidden && s.phase === 'playing' && (myColor === null || myColor === s.turn);
const showHints = s.options.hints === 'warn' && s.phase === 'playing' && myColor !== null && myColor === s.turn;
return {
size: s.size,
board: s.board,
players: s.players,
myColor,
turn: s.turn,
ruleSet: s.options.ruleSet,
firstMoveCenter: firstMoveCenterOn(s.options),
lastMove: s.moves.length ? s.moves[s.moves.length - 1]!.pt : null,
moveCount: s.moves.length,
moves: s.moves.map((m) => ({ color: m.color, pt: m.pt, auto: m.auto })),
phase: s.phase,
winLine: s.winLine,
timeControl: s.options.timeControl,
remainingMs: s.clock.remainingMs,
turnStartedAt: s.clock.turnStartedAt,
undo: {
allowed: s.options.undo !== 'off',
remaining: {
black: Number.isFinite(limit) ? limit - s.undo.usedBy.black : null,
white: Number.isFinite(limit) ? limit - s.undo.usedBy.white : null,
},
pending: s.undo.pending,
},
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
forbidden: showForbidden ? s.forbidden.map((i) => toPt(i, s.size)) : [],
hints: showHints ? threatHints(s) : null,
result: s.result,
};
},
activePlayers(s) {
if (s.phase !== 'playing') return [];
const out = [s.players[s.turn]];
const responder = pendingResponder(s);
if (responder && responder !== s.turn) out.push(s.players[responder]);
return out;
},
deadline(s) {
if (s.phase !== 'playing') return null;
const tc = s.options.timeControl;
const start = s.clock.turnStartedAt;
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn];
return start + s.options.idleLimitSec * 1000;
},
timeoutPlayers(s) {
return s.phase === 'playing' ? [s.players[s.turn]] : [];
},
onTimeout() {
return { type: 'timeout' };
},
onLeave() {
return { type: 'resign' };
},
result(s): GameResult | null {
if (!s.result) return null;
const { winner, reason, summary } = s.result;
const ranking = winner ? [[s.players[winner]], [s.players[other(winner)]]] : [[s.players.black, s.players.white]];
const map: Record<OmokReason, GameResult['reason']> = {
five: 'normal',
boardFull: 'normal',
noLegalMove: 'normal',
resign: 'resign',
timeLoss: 'timeout',
drawAgreed: 'draw-agreed',
};
return { ranking, summary, reason: map[reason] };
},
legalActions(s, p) {
const me = colorOf(s, p);
if (!me || s.phase !== 'playing') return [];
const responder = pendingResponder(s);
if (responder === me) {
return s.undo.pending ? [{ type: 'respondUndo', accept: false }] : [{ type: 'respondDraw', accept: false }];
}
if (s.turn !== me) return [];
return legalCells(s).map((i) => ({ type: 'place' as const, ...toPt(i, s.size) }));
},
};

View File

@@ -0,0 +1,265 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { omok, type OmokOptions, type OmokState, type OmokAction } from './index';
const COLS = 'ABCDEFGHIJKLMNOPQRS';
const at = (label: string, size = 15) => ({ x: COLS.indexOf(label[0]!), y: size - Number(label.slice(1)) });
function make(opts: Partial<OmokOptions> = {}, stones: { black?: string[]; white?: string[] } = {}, turn: 'black' | 'white' = 'black'): OmokState {
const options = omok.optionsSchema.parse({ colorAssignment: 'hostBlack', ...opts });
const s = omok.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
for (const [color, v] of [['black', 1], ['white', 2]] as const) {
for (const l of stones[color] ?? []) {
const p = at(l, s.size);
s.board[p.y * s.size + p.x] = v;
s.moves.push({ color, pt: p, at: 0, auto: false });
}
}
s.turn = turn;
return s;
}
const player = (s: OmokState, c: 'black' | 'white') => s.players[c];
function place(s: OmokState, label: string, color = s.turn) {
const p = at(label, s.size);
const a: OmokAction = { type: 'place', ...p };
return omok.validate(s, player(s, color), a);
}
function play(s: OmokState, label: string, now = 1000) {
const p = at(label, s.size);
const a: OmokAction = { type: 'place', ...p };
const v = omok.validate(s, player(s, s.turn), a);
if (!v.ok) throw new Error(v.reason);
return omok.apply(s, player(s, s.turn), a, { rng: SeededRng.fromSeed([4, 3, 2, 1]), now }).state;
}
describe('win detection', () => {
test.each([
['horizontal', ['D8', 'E8', 'F8', 'G8'], 'H8'],
['vertical', ['H4', 'H5', 'H6', 'H7'], 'H8'],
['diagonal ↘', ['D12', 'E11', 'F10', 'G9'], 'H8'],
['diagonal ↗', ['D4', 'E5', 'F6', 'G7'], 'H8'],
['edge', ['A1', 'A2', 'A3', 'A4'], 'A5'],
])('%s five wins', (_n, black, last) => {
const s = play(make({}, { black }), last);
expect(s.phase).toBe('finished');
expect(s.result?.winner).toBe('black');
expect(s.winLine).toHaveLength(5);
expect(omok.result(s)?.ranking).toEqual([['A'], ['B']]);
});
test('korean: six in a row is legal but not a win (both colours)', () => {
const s = play(make({ ruleSet: 'korean' }, { black: ['D8', 'E8', 'F8', 'G8', 'I8'] }), 'H8');
expect(s.phase).toBe('playing');
const w = play(make({ ruleSet: 'korean' }, { white: ['D8', 'E8', 'F8', 'G8', 'I8'] }, 'white'), 'H8');
expect(w.phase).toBe('playing');
});
test('renju: black overline forbidden, white overline wins', () => {
const b = make({ ruleSet: 'renju' }, { black: ['D8', 'E8', 'F8', 'G8', 'I8'] });
expect(place(b, 'H8')).toEqual({ ok: false, reason: '흑은 6목 이상을 만들 수 없어요.' });
const w = play(make({ ruleSet: 'renju' }, { white: ['D8', 'E8', 'F8', 'G8', 'I8'] }, 'white'), 'H8');
expect(w.result?.winner).toBe('white');
});
test('free: fivePlus counts six, exactFive does not', () => {
const stones = { black: ['D8', 'E8', 'F8', 'G8', 'I8'] };
expect(play(make({ ruleSet: 'free', freeWin: 'fivePlus' }, stones), 'H8').result?.winner).toBe('black');
expect(play(make({ ruleSet: 'free', freeWin: 'exactFive' }, stones), 'H8').phase).toBe('playing');
});
});
describe('forbidden moves (docs §5.3)', () => {
const A = { black: ['H10', 'H9', 'F8', 'G8'] };
test('A: real 3-3 is forbidden in korean and renju, legal in free', () => {
expect(place(make({ ruleSet: 'korean' }, A), 'H8').ok).toBe(false);
expect(place(make({ ruleSet: 'renju' }, A), 'H8').ok).toBe(false);
expect(place(make({ ruleSet: 'free' }, A), 'H8').ok).toBe(true);
});
test('korean forbids 3-3 for white too', () => {
expect(place(make({ ruleSet: 'korean' }, { white: A.black }, 'white'), 'H8').ok).toBe(false);
expect(place(make({ ruleSet: 'renju' }, { white: A.black }, 'white'), 'H8').ok).toBe(true);
});
test('B: one-line 4-4 forbidden for renju black only', () => {
const B = { black: ['D8', 'F8', 'H8', 'J8'] };
expect(place(make({ ruleSet: 'renju' }, B), 'G8')).toEqual({ ok: false, reason: '흑은 4-4 자리에 둘 수 없어요.' });
expect(place(make({ ruleSet: 'korean' }, B), 'G8').ok).toBe(true);
});
test('C: blocked three is not a three', () => {
const C = { black: ['H10', 'H9', 'F8', 'G8'], white: ['E8'] };
expect(place(make({ ruleSet: 'renju' }, C), 'H8').ok).toBe(true);
});
test('D: three that can only become an overline is fake', () => {
const D = { black: ['H10', 'H9', 'C8', 'F8', 'G8', 'K8'] };
expect(place(make({ ruleSet: 'renju' }, D), 'H8').ok).toBe(true);
expect(place(make({ ruleSet: 'korean' }, D), 'H8').ok).toBe(true);
});
test('E: three whose extension point is itself forbidden is fake (recursion)', () => {
const E = { black: ['E10', 'E9', 'F8', 'G8', 'F7', 'H7', 'G6', 'H6'], white: ['J8'] };
const s = make({ ruleSet: 'renju' }, E);
expect(place(s, 'H8').ok).toBe(true);
// Sanity: E8 is forbidden once H8 is on the board.
const after = make({ ruleSet: 'renju' }, { ...E, black: [...E.black, 'H8'] });
expect(place(after, 'E8').ok).toBe(false);
});
test('F: five takes priority over 3-3 / 4-4', () => {
const F = { black: ['D8', 'E8', 'F8', 'G8', 'H9', 'H10', 'I9', 'J10'] };
const s = play(make({ ruleSet: 'renju' }, F), 'H8');
expect(s.result?.winner).toBe('black');
});
test('forbidden points are shown to the mover and spectators, not the waiting player', () => {
const s2 = play(make({ ruleSet: 'renju' }, { black: ['H10', 'H9', 'F8'], white: ['A1'] }), 'G8');
// Now white to move; black's forbidden cells are not in white's turn list.
expect(omok.view(s2, 'B').forbidden).toEqual([]);
const s3 = play(s2, 'A2');
const blackView = omok.view(s3, 'A');
expect(blackView.forbidden).toContainEqual(at('H8'));
expect(omok.view(s3, null).forbidden).toContainEqual(at('H8'));
expect(omok.view(s3, 'B').forbidden).toEqual([]);
});
});
describe('flow', () => {
test('first move must be centre when firstMoveCenter is on', () => {
const s = make({ ruleSet: 'renju' });
expect(place(s, 'A1')).toEqual({ ok: false, reason: '첫 수는 가운데에 둬야 해요.' });
const s2 = play(s, 'H8');
expect(place(s2, 'A1').ok).toBe(true);
});
test('not your turn / occupied', () => {
const s = play(make(), 'H8');
expect(omok.validate(s, 'A', { type: 'place', ...at('A1') }).ok).toBe(false);
expect(omok.validate(s, 'B', { type: 'place', ...at('H8') })).toEqual({ ok: false, reason: '이미 돌이 있는 자리예요.' });
expect(omok.validate(s, 'Z', { type: 'resign' }).ok).toBe(false);
});
test('board full → draw', () => {
const s = make({ ruleSet: 'free' });
let blacks = 0;
let whites = 0;
for (let y = 0; y < 15; y++)
for (let x = 0; x < 15; x++) {
if (x === 14 && y === 14) continue;
const v = ((x >> 1) + y) % 2 === 0 ? 1 : 2;
s.board[y * 15 + x] = v;
v === 1 ? blacks++ : whites++;
}
s.turn = ((14 >> 1) + 14) % 2 === 0 ? 'black' : 'white';
s.moves = new Array(224).fill({ color: 'black', pt: { x: 0, y: 0 }, at: 0, auto: false });
const end = play(s, 'O1');
expect(end.phase).toBe('finished');
expect(end.result?.reason).toBe('boardFull');
expect(omok.result(end)?.ranking).toEqual([['A', 'B']]);
});
test('undo: 1 ply after own move, 2 plies after reply, decline keeps board, limit enforced', () => {
const rng = SeededRng.fromSeed([1, 1, 1, 1]);
let s = play(make({ undo: '1' }), 'H8');
s = omok.apply(s, 'A', { type: 'requestUndo' }, { rng, now: 1 }).state;
expect(s.undo.pending).toEqual({ by: 'black', plies: 1 });
expect(omok.activePlayers(s)).toEqual(['B']);
s = omok.apply(s, 'B', { type: 'respondUndo', accept: true }, { rng, now: 2 }).state;
expect(s.moves).toHaveLength(0);
expect(s.turn).toBe('black');
s = play(play(s, 'H8'), 'H9');
expect(omok.validate(s, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '무르기 횟수를 다 썼어요.' });
expect(omok.validate(s, 'B', { type: 'requestUndo' }).ok).toBe(true);
s = omok.apply(s, 'B', { type: 'requestUndo' }, { rng, now: 3 }).state;
expect(s.undo.pending?.plies).toBe(1);
s = omok.apply(s, 'A', { type: 'respondUndo', accept: false }, { rng, now: 4 }).state;
expect(s.moves).toHaveLength(2);
// Pending request vanishes when the responder plays.
s = omok.apply(s, 'B', { type: 'requestUndo' }, { rng, now: 5 }).state;
s = play(s, 'A1');
expect(s.undo.pending).toBeNull();
});
test('draw offer: re-offer blocked for 3 own moves, accept ends drawn', () => {
const rng = SeededRng.fromSeed([1, 1, 1, 1]);
let s = play(make(), 'H8');
s = omok.apply(s, 'A', { type: 'offerDraw' }, { rng, now: 1 }).state;
s = omok.apply(s, 'B', { type: 'respondDraw', accept: false }, { rng, now: 2 }).state;
expect(omok.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false);
for (const l of ['A1', 'A15', 'C1', 'C15', 'E1', 'E15']) s = play(s, l);
expect(omok.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true);
s = omok.apply(s, 'A', { type: 'offerDraw' }, { rng, now: 3 }).state;
s = omok.apply(s, 'B', { type: 'respondDraw', accept: true }, { rng, now: 4 }).state;
expect(omok.result(s)?.reason).toBe('draw-agreed');
});
test('resign', () => {
const s = omok.apply(make(), 'B', { type: 'resign' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 0 }).state;
expect(omok.result(s)?.ranking).toEqual([['A'], ['B']]);
});
});
describe('time', () => {
test('perMove autoMove: deterministic legal auto moves, third timeout loses', () => {
const run = () => {
let s = make({ ruleSet: 'renju' });
const out: unknown[] = [];
for (let i = 0; i < 3; i++) {
const p = omok.timeoutPlayers!(s)[0]!;
const a = omok.onTimeout(s, p);
expect(omok.validate(s, p, a).ok).toBe(true);
s = omok.apply(s, p, a, { rng: SeededRng.fromSeed([7, 7, 7, 7]), now: 1000 * i }).state;
out.push(s.moves[s.moves.length - 1]?.pt);
}
return { s, out };
};
const r1 = run();
expect(r1.out).toEqual(run().out);
expect(r1.s.moves[0]!.pt).toEqual(at('H8'));
// Black: auto, auto, then lose on the third timeout.
let s = r1.s;
expect(s.phase).toBe('playing');
const rng = SeededRng.fromSeed([1, 1, 1, 1]);
s = omok.apply(s, s.players[s.turn], { type: 'timeout' }, { rng, now: 9 }).state;
s = omok.apply(s, s.players[s.turn], { type: 'timeout' }, { rng, now: 10 }).state;
s = omok.apply(s, s.players[s.turn], { type: 'timeout' }, { rng, now: 11 }).state;
expect(s.result?.reason).toBe('timeLoss');
});
test('perMove lose policy', () => {
const s = omok.apply(make({ timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 0 }).state;
expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
});
test('fischer: increment added, late move loses', () => {
let s = make({ timeControl: { kind: 'fischer', perMoveSec: 30, baseSec: 60, incSec: 5 } });
expect(omok.deadline!(s)).toBe(60_000);
s = play(s, 'H8', 10_000);
expect(s.clock.remainingMs.black).toBe(55_000);
s = play(s, 'A1', 80_000); // white used 70s > 60s
expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
});
});
describe('views and playouts', () => {
test('hints are only for the player to move', () => {
const s = make({}, { black: ['F8', 'G8', 'H8'], white: ['A1', 'A3'] }, 'white');
expect(omok.view(s, 'B').hints?.warn.length).toBeGreaterThan(0);
expect(omok.view(s, 'A').hints).toBeNull();
expect(omok.view(s, null).hints).toBeNull();
});
test('random playouts finish, are deterministic and fast', () => {
for (const ruleSet of ['korean', 'renju', 'free'] as const) {
for (let i = 0; i < 15; i++) {
const options = omok.optionsSchema.parse({ ruleSet });
const a = randomPlayout(omok, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 });
const b = randomPlayout(omok, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 });
expect(a.finished).toBe(true);
expect(JSON.stringify(a.finalState)).toBe(JSON.stringify(b.finalState));
}
}
});
});

View File

@@ -0,0 +1,222 @@
/**
* Omok rule core: win detection and forbidden-move detection (docs/games/omok.md §5).
* Board is a flat array indexed y*size+x; 0 empty, 1 black, 2 white.
*/
export type Cell = 0 | 1 | 2;
export type Stone = 1 | 2;
export type RuleSet = 'korean' | 'renju' | 'free';
export interface RuleConfig {
ruleSet: RuleSet;
freeWin: 'fivePlus' | 'exactFive';
}
const DIRS: readonly [number, number][] = [
[1, 0],
[0, 1],
[1, 1],
[1, -1],
];
export class Board {
constructor(
public readonly size: number,
public readonly cells: Cell[],
) {}
static empty(size: number): Board {
return new Board(size, new Array<Cell>(size * size).fill(0));
}
on(x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < this.size && y < this.size;
}
at(x: number, y: number): Cell {
return this.cells[y * this.size + x]!;
}
set(x: number, y: number, c: Cell): void {
this.cells[y * this.size + x] = c;
}
}
export function isWinLength(len: number, color: Stone, rule: RuleConfig): boolean {
switch (rule.ruleSet) {
case 'korean':
return len === 5;
case 'renju':
return color === 1 ? len === 5 : len >= 5;
case 'free':
return rule.freeWin === 'fivePlus' ? len >= 5 : len === 5;
}
}
interface Forbids {
has33: boolean;
has44: boolean;
hasOverline: boolean;
}
export function forbidsFor(color: Stone, rule: RuleConfig): Forbids {
if (rule.ruleSet === 'korean') return { has33: true, has44: false, hasOverline: false };
if (rule.ruleSet === 'renju' && color === 1) return { has33: true, has44: true, hasOverline: true };
return { has33: false, has44: false, hasOverline: false };
}
/** Cells (as indices) of the contiguous same-colour run through (x,y) along (dx,dy), including (x,y). */
function segment(b: Board, x: number, y: number, dx: number, dy: number, color: Stone): number[] {
const out: number[] = [y * b.size + x];
for (const s of [1, -1]) {
let cx = x + dx * s;
let cy = y + dy * s;
while (b.on(cx, cy) && b.at(cx, cy) === color) {
out.push(cy * b.size + cx);
cx += dx * s;
cy += dy * s;
}
}
return out;
}
export function runLength(b: Board, x: number, y: number, dx: number, dy: number, color: Stone): number {
return segment(b, x, y, dx, dy, color).length;
}
/** If the stone at (x,y) completes a winning line, returns its cells sorted along the line. */
export function winningLine(b: Board, x: number, y: number, color: Stone, rule: RuleConfig): number[] | null {
for (const [dx, dy] of DIRS) {
const seg = segment(b, x, y, dx, dy, color);
if (isWinLength(seg.length, color, rule)) return seg.sort((a, c) => a - c);
}
return null;
}
/**
* For the stone already at p, along (dx,dy): map from "4-stone set" key → number of empty
* cells q that would complete a winning five containing p.
*/
function fourStoneSets(b: Board, px: number, py: number, dx: number, dy: number, color: Stone, rule: RuleConfig): Map<string, { n: number; stones: number[] }> {
const sets = new Map<string, { n: number; stones: number[] }>();
const p = py * b.size + px;
for (let k = -4; k <= 4; k++) {
if (k === 0) continue;
const qx = px + k * dx;
const qy = py + k * dy;
if (!b.on(qx, qy) || b.at(qx, qy) !== 0) continue;
b.set(qx, qy, color);
const seg = segment(b, qx, qy, dx, dy, color);
b.set(qx, qy, 0);
if (isWinLength(seg.length, color, rule) && seg.includes(p)) {
const q = qy * b.size + qx;
const stones = seg.filter((s) => s !== q).sort((a, c) => a - c);
const key = stones.join('|');
const cur = sets.get(key);
if (cur) cur.n++;
else sets.set(key, { n: 1, stones });
}
}
return sets;
}
function isThree(b: Board, px: number, py: number, dx: number, dy: number, color: Stone, rule: RuleConfig, forb: Forbids): boolean {
for (let k = -4; k <= 4; k++) {
if (k === 0) continue;
const qx = px + k * dx;
const qy = py + k * dy;
if (!b.on(qx, qy) || b.at(qx, qy) !== 0) continue;
const q = qy * b.size + qx;
b.set(qx, qy, color);
const makesFive = runLength(b, qx, qy, dx, dy, color) >= 5;
let straight = false;
if (!makesFive) {
for (const { n, stones } of fourStoneSets(b, px, py, dx, dy, color, rule).values()) {
if (n >= 2 && stones.includes(q)) {
straight = true;
break;
}
}
}
b.set(qx, qy, 0);
if (straight && !isForbiddenWith(b, qx, qy, color, rule, forb)) return true;
}
return false;
}
export type ForbiddenKind = 'double-three' | 'double-four' | 'overline';
/** Whether placing `color` at empty (x,y) is forbidden. Board is restored before returning. */
export function forbiddenKind(b: Board, x: number, y: number, color: Stone, rule: RuleConfig): ForbiddenKind | null {
const forb = forbidsFor(color, rule);
if (!forb.has33 && !forb.has44 && !forb.hasOverline) return null;
return check(b, x, y, color, rule, forb);
}
function isForbiddenWith(b: Board, x: number, y: number, color: Stone, rule: RuleConfig, forb: Forbids): boolean {
return check(b, x, y, color, rule, forb) !== null;
}
function check(b: Board, x: number, y: number, color: Stone, rule: RuleConfig, forb: Forbids): ForbiddenKind | null {
b.set(x, y, color);
try {
for (const [dx, dy] of DIRS) if (isWinLength(runLength(b, x, y, dx, dy, color), color, rule)) return null;
if (forb.hasOverline) {
for (const [dx, dy] of DIRS) if (runLength(b, x, y, dx, dy, color) >= 6) return 'overline';
}
if (forb.has44) {
let fours = 0;
for (const [dx, dy] of DIRS) fours += fourStoneSets(b, x, y, dx, dy, color, rule).size;
if (fours >= 2) return 'double-four';
}
if (forb.has33) {
let threes = 0;
for (const [dx, dy] of DIRS) {
if (isThree(b, x, y, dx, dy, color, rule, forb)) threes++;
if (threes >= 2) return 'double-three';
}
}
return null;
} finally {
b.set(x, y, 0);
}
}
/** Empty cells that have at least `minStones` stones of `color` within 4 steps along some line. */
export function candidateCells(b: Board, color: Stone, minStones: number): number[] {
const out: number[] = [];
for (let y = 0; y < b.size; y++) {
for (let x = 0; x < b.size; x++) {
if (b.at(x, y) !== 0) continue;
let best = 0;
for (const [dx, dy] of DIRS) {
let n = 0;
for (let k = -4; k <= 4; k++) {
if (k === 0) continue;
const cx = x + k * dx;
const cy = y + k * dy;
if (b.on(cx, cy) && b.at(cx, cy) === color) n++;
}
best = Math.max(best, n);
}
if (best >= minStones) out.push(y * b.size + x);
}
}
return out;
}
/** All forbidden cells for `color` (empty when the rule set has no restriction for that colour). */
export function forbiddenCells(b: Board, color: Stone, rule: RuleConfig): number[] {
const forb = forbidsFor(color, rule);
if (!forb.has33 && !forb.has44 && !forb.hasOverline) return [];
// A forbidden move needs at least two own stones nearby on some line.
return candidateCells(b, color, 2).filter((i) => forbiddenKind(b, i % b.size, Math.floor(i / b.size), color, rule) !== null);
}
/** Count straight (open) fours created through (x,y) for `color` already placed there. */
export function straightFourCount(b: Board, x: number, y: number, color: Stone, rule: RuleConfig): number {
let n = 0;
for (const [dx, dy] of DIRS) {
for (const v of fourStoneSets(b, x, y, dx, dy, color, rule).values()) if (v.n >= 2) n++;
}
return n;
}

View File

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

View File

@@ -0,0 +1,8 @@
{
"name": "@bg/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"dependencies": { "zod": "4.6.5" }
}

View File

@@ -0,0 +1,17 @@
/** Game catalog metadata shared by server and web (docs/games/README.md). */
export interface CatalogEntry {
id: string;
nameKo: string;
minPlayers: number;
maxPlayers: number;
minutes: string;
level: '쉬움' | '보통' | '어려움';
blurb: string;
available: boolean;
}
export const CATALOG: CatalogEntry[] = [
{ id: 'omok', nameKo: '오목', minPlayers: 2, maxPlayers: 2, minutes: '5~15', level: '쉬움', blurb: '먼저 다섯 개를 한 줄로!', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);

View File

@@ -0,0 +1,4 @@
export * from './protocol';
export * from './nickname';
export * from './room-code';
export * from './catalog';

View File

@@ -0,0 +1,48 @@
/** Nickname rules, docs/05-accounts-auth.md §2. Shared by server validation and client form. */
export const NICK_MIN = 2;
export const NICK_MAX = 12;
const ALLOWED = /^[0-9A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ _.\-]+$/;
// Minimal built-in list; the server may extend it. Matching ignores spaces and punctuation.
const BANNED = ['시발', '씨발', '병신', '좆', '개새끼', '느금', '니애미', 'fuck', 'shit', 'bitch'];
export function normalizeNickname(raw: string): string {
return raw.normalize('NFC').trim().replace(/\s+/g, ' ');
}
export function containsProfanity(text: string): boolean {
const squashed = text.normalize('NFC').toLowerCase().replace(/[\s_.\-*0-9]/g, '');
return BANNED.some((w) => squashed.includes(w));
}
export function maskProfanity(text: string): string {
let out = text;
for (const w of BANNED) {
out = out.replace(new RegExp(w.split('').map(escapeRe).join('[\\s_.\\-*]*'), 'gi'), (m) => '*'.repeat(m.length));
}
return out;
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Returns a Korean error message, or null when valid. Input must already be normalized. */
export function nicknameError(nick: string): string | null {
const len = [...nick].length;
if (len < NICK_MIN) return `닉네임은 ${NICK_MIN}글자 이상이어야 해요.`;
if (len > NICK_MAX) return `닉네임은 ${NICK_MAX}글자까지 쓸 수 있어요.`;
if (!ALLOWED.test(nick)) return '한글, 영어, 숫자와 _ - . 만 쓸 수 있어요.';
if (containsProfanity(nick)) return '다른 닉네임을 써 주세요.';
return null;
}
const ADJ = ['용감한', '느긋한', '반짝이는', '씩씩한', '졸린', '신나는', '똑똑한', '배고픈', '귀여운', '날쌘', '든든한', '행복한'];
const NOUN = ['호랑이', '고양이', '거북이', '다람쥐', '부엉이', '펭귄', '판다', '토끼', '여우', '돌고래', '곰돌이', '참새'];
export function suggestNickname(rand: () => number = Math.random): string {
const a = ADJ[Math.floor(rand() * ADJ.length)]!;
const n = NOUN[Math.floor(rand() * NOUN.length)]!;
return `${a} ${n}`;
}

View File

@@ -0,0 +1,117 @@
/** WebSocket protocol, docs/03-realtime-protocol.md. */
import { z } from 'zod';
export const PROTOCOL = 1;
export const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024;
export const CHAT_MAX = 200;
const code = z.string().regex(/^\d{6}$/);
const userId = z.string().min(1).max(64);
export const ClientMessage = z.discriminatedUnion('t', [
z.object({ t: z.literal('join'), code, as: z.enum(['player', 'spectator']).default('player') }),
z.object({ t: z.literal('leave') }),
z.object({ t: z.literal('seat'), seat: z.number().int().min(0).max(31).optional() }),
z.object({ t: z.literal('unseat') }),
z.object({ t: z.literal('ready'), ready: z.boolean() }),
z.object({
t: z.literal('config'),
gameId: z.string().max(40).optional(),
options: z.unknown().optional(),
maxPlayers: z.number().int().min(1).max(32).optional(),
visibility: z.enum(['private', 'public']).optional(),
turnSeconds: z.number().int().min(5).max(600).nullable().optional(),
allowSpectators: z.boolean().optional(),
chatEnabled: z.boolean().optional(),
chatFilter: z.boolean().optional(),
}),
z.object({ t: z.literal('start') }),
z.object({ t: z.literal('kick'), userId }),
z.object({ t: z.literal('host'), userId }),
z.object({ t: z.literal('act'), cs: z.number().int().min(1), a: z.unknown() }),
z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }),
z.object({ t: z.literal('emote'), id: z.string().max(20) }),
z.object({ t: z.literal('rematch') }),
z.object({ t: z.literal('sync') }),
z.object({ t: z.literal('ping'), ts: z.number() }),
]);
export type ClientMessage = z.infer<typeof ClientMessage>;
export const EMOTES = ['thumbs', 'laugh', 'wow', 'sad', 'clap', 'nice', 'hurry', 'gg'] as const;
export const EMOTE_LABEL: Record<(typeof EMOTES)[number], string> = {
thumbs: '👍',
laugh: '😂',
wow: '😮',
sad: '😢',
clap: '👏',
nice: '잘했어요!',
hurry: '조금만 서둘러 주세요',
gg: '좋은 게임이었어요',
};
export interface PublicUser {
id: string;
nickname: string;
avatar: string | null;
kind: 'guest' | 'member';
}
export interface SeatView {
user: PublicUser;
ready: boolean;
connected: boolean;
away: boolean;
}
export type RoomStatus = 'lobby' | 'playing' | 'finished' | 'paused' | 'broken';
export interface RoomView {
code: string;
hostId: string;
gameId: string;
options: unknown;
maxPlayers: number;
minPlayers: number;
visibility: 'private' | 'public';
turnSeconds: number | null;
allowSpectators: boolean;
chatEnabled: boolean;
chatFilter: boolean;
seats: (SeatView | null)[];
spectators: PublicUser[];
status: RoomStatus;
gameNo: number;
rematchVotes: string[];
sessionStats: Record<string, { played: number; wins: number }>;
seedHash: string | null;
/** Seed revealed after the game for fairness check. */
lastSeed: string | null;
}
export interface GameResultView {
ranking: string[][];
scores?: Record<string, number>;
summary: string;
reason: string;
}
export type ServerMessage =
| { t: 'welcome'; me: PublicUser; serverTime: number; protocol: number; activeRoom: string | null }
| { t: 'room'; seq: number; room: RoomView }
| {
t: 'state';
seq: number;
view: unknown;
events: unknown[];
active: string[];
deadline: number | null;
cs?: number;
}
| { t: 'reject'; cs: number; reason: string }
| { t: 'chat'; from: PublicUser | null; text: string; at: number; channel?: string }
| { t: 'emote'; from: string; id: string }
| { t: 'result'; seq: number; result: GameResultView }
| { t: 'notice'; code: string; message: string }
| { t: 'error'; code: string; message: string }
| { t: 'pong'; ts: number; serverTime: number }
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };

View File

@@ -0,0 +1,20 @@
/** 6-digit numeric room codes, docs/06-lobby-rooms.md §3. */
export function normalizeRoomCode(input: string): string | null {
const m = input.match(/\/r\/(\d{6})\b/);
if (m) return m[1]!;
const digits = input.replace(/[\s\-]/g, '');
return /^\d{6}$/.test(digits) ? digits : null;
}
export function isAllowedRoomCode(code: string): boolean {
if (!/^\d{6}$/.test(code)) return false;
if (/^(\d)\1{5}$/.test(code)) return false; // 000000, 111111, ...
const asc = '0123456789';
const desc = '9876543210';
if (asc.includes(code) || desc.includes(code)) return false; // 123456, 654321
return true;
}
export function formatRoomCode(code: string): string {
return `${code.slice(0, 3)} ${code.slice(3)}`;
}

View File

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