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:
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
test-results/
|
||||
playwright-report/
|
||||
18
apps/server/package.json
Normal file
18
apps/server/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@bg/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"start": "bun src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bg/engine": "workspace:*",
|
||||
"@bg/games": "workspace:*",
|
||||
"@bg/shared": "workspace:*",
|
||||
"hono": "4.13.12",
|
||||
"ulid": "3.0.2",
|
||||
"zod": "4.6.5"
|
||||
}
|
||||
}
|
||||
59
apps/server/src/auth/discord.ts
Normal file
59
apps/server/src/auth/discord.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** Discord OAuth2 (authorization code + state), docs/05-accounts-auth.md §3. */
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
const API = 'https://discord.com/api/v10';
|
||||
|
||||
export function authorizeUrl(clientId: string, redirectUri: string, state: string): string {
|
||||
const p = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'identify',
|
||||
state,
|
||||
prompt: 'none',
|
||||
});
|
||||
return `https://discord.com/oauth2/authorize?${p}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
cfg: { clientId: string; clientSecret: string },
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscordUser> {
|
||||
const tokenRes = await fetchImpl(`${API}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error(`discord token exchange failed: ${tokenRes.status}`);
|
||||
const token = (await tokenRes.json()) as { access_token: string; token_type: string };
|
||||
const meRes = await fetchImpl(`${API}/users/@me`, {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!meRes.ok) throw new Error(`discord /users/@me failed: ${meRes.status}`);
|
||||
return (await meRes.json()) as DiscordUser;
|
||||
}
|
||||
|
||||
export function avatarUrl(u: DiscordUser): string | null {
|
||||
return u.avatar ? `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.png?size=128` : null;
|
||||
}
|
||||
|
||||
/** Only same-site relative paths are allowed as post-login redirect targets. */
|
||||
export function safeNext(next: string | null | undefined): string {
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//') || next.includes('\\')) return '/';
|
||||
return next;
|
||||
}
|
||||
86
apps/server/src/auth/sessions.ts
Normal file
86
apps/server/src/auth/sessions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
const DAY = 24 * 3600_000;
|
||||
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
|
||||
export const SESSION_COOKIE = 'sid';
|
||||
|
||||
export function randomToken(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return Buffer.from(buf).toString('base64url');
|
||||
}
|
||||
|
||||
export function sha256Hex(input: string): string {
|
||||
return new Bun.CryptoHasher('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export class Sessions {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
create(userId: string, kind: 'guest' | 'member', userAgent: string | null, now = Date.now()): { token: string; expiresAt: number } {
|
||||
const token = randomToken();
|
||||
const expiresAt = now + SESSION_TTL[kind];
|
||||
this.db
|
||||
.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at, last_used_at, user_agent) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(sha256Hex(token), userId, now, expiresAt, now, userAgent?.slice(0, 200) ?? null);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
/** Resolves a token to a user id; slides expiry at most once per day. */
|
||||
resolve(token: string, now = Date.now()): string | null {
|
||||
const hash = sha256Hex(token);
|
||||
const row = this.db
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND u.deleted_at IS NULL`,
|
||||
)
|
||||
.get(hash);
|
||||
if (!row) return null;
|
||||
if (row.expires_at <= now) {
|
||||
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash);
|
||||
return null;
|
||||
}
|
||||
if (now - row.last_used_at > DAY) {
|
||||
this.db
|
||||
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
|
||||
.run(now, now + SESSION_TTL[row.kind], hash);
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
revoke(token: string): void {
|
||||
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(sha256Hex(token));
|
||||
}
|
||||
|
||||
revokeAll(userId: string): void {
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
purgeExpired(now = Date.now()): number {
|
||||
return this.db.query('DELETE FROM sessions WHERE expires_at <= ?').run(now).changes;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal HMAC-signed value for short-lived cookies (OAuth state + PKCE verifier). */
|
||||
export async function signValue(secret: string, value: string): Promise<string> {
|
||||
const mac = await hmac(secret, value);
|
||||
return `${Buffer.from(value).toString('base64url')}.${mac}`;
|
||||
}
|
||||
|
||||
export async function unsignValue(secret: string, signed: string): Promise<string | null> {
|
||||
const dot = signed.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
const value = Buffer.from(signed.slice(0, dot), 'base64url').toString();
|
||||
const expected = await hmac(secret, value);
|
||||
const given = signed.slice(dot + 1);
|
||||
if (expected.length !== given.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
|
||||
return diff === 0 ? value : null;
|
||||
}
|
||||
|
||||
async function hmac(secret: string, value: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value));
|
||||
return Buffer.from(sig).toString('base64url');
|
||||
}
|
||||
144
apps/server/src/auth/users.ts
Normal file
144
apps/server/src/auth/users.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ulid } from 'ulid';
|
||||
import type { PublicUser } from '@bg/shared';
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
kind: 'guest' | 'member';
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
use_avatar: number;
|
||||
settings_json: string;
|
||||
nick_changed_at: number | null;
|
||||
nick_change_count: number;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
deleted_at: number | null;
|
||||
}
|
||||
|
||||
export const NICK_CHANGES_PER_DAY = 10;
|
||||
|
||||
export class Users {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
get(id: string): UserRow | null {
|
||||
return this.db.query<UserRow, [string]>('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
|
||||
}
|
||||
|
||||
createGuest(nickname: string, now = Date.now()): UserRow {
|
||||
const id = ulid(now);
|
||||
this.db
|
||||
.query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, 'guest', nickname, now, now);
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
findByOauth(provider: string, providerUserId: string): UserRow | null {
|
||||
return this.db
|
||||
.query<UserRow, [string, string]>(
|
||||
`SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id
|
||||
WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`,
|
||||
)
|
||||
.get(provider, providerUserId);
|
||||
}
|
||||
|
||||
createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow {
|
||||
const id = ulid(now);
|
||||
this.db
|
||||
.query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, 'member', nickname, avatarUrl, now, now);
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void {
|
||||
this.db
|
||||
.query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(provider, providerUserId, userId, username, now);
|
||||
}
|
||||
|
||||
promoteToMember(userId: string, avatarUrl: string | null): void {
|
||||
this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId);
|
||||
}
|
||||
|
||||
setAvatar(userId: string, avatarUrl: string | null): void {
|
||||
this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId);
|
||||
}
|
||||
|
||||
/** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */
|
||||
mergeGuestInto(guestId: string, memberId: string): void {
|
||||
this.db.transaction(() => {
|
||||
const rows = this.db
|
||||
.query<{ game_id: string; played: number; wins: number; draws: number }, [string]>(
|
||||
'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?',
|
||||
)
|
||||
.all(guestId);
|
||||
for (const r of rows) {
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played,
|
||||
wins = wins + excluded.wins, draws = draws + excluded.draws`,
|
||||
)
|
||||
.run(memberId, r.game_id, r.played, r.wins, r.draws);
|
||||
}
|
||||
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId);
|
||||
this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId);
|
||||
this.db.query('DELETE FROM users WHERE id = ?').run(guestId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Returns an error message or null. Enforces the daily change limit. */
|
||||
changeNickname(userId: string, nickname: string, now = Date.now()): string | null {
|
||||
const u = this.get(userId);
|
||||
if (!u) return '사용자를 찾을 수 없어요.';
|
||||
if (u.nickname === nickname) return null;
|
||||
const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000;
|
||||
const count = sameDay ? u.nick_change_count : 0;
|
||||
if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.';
|
||||
this.db
|
||||
.query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?')
|
||||
.run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record<string, unknown> }): void {
|
||||
if (patch.useAvatar !== undefined) {
|
||||
this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId);
|
||||
}
|
||||
if (patch.settings) {
|
||||
this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId);
|
||||
}
|
||||
}
|
||||
|
||||
touch(userId: string, now = Date.now()): void {
|
||||
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
|
||||
}
|
||||
|
||||
softDelete(userId: string, now = Date.now()): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?")
|
||||
.run(now, userId);
|
||||
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] {
|
||||
return this.db
|
||||
.query<{ gameId: string; played: number; wins: number; draws: number }, [string]>(
|
||||
'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC',
|
||||
)
|
||||
.all(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export function toPublicUser(u: UserRow): PublicUser {
|
||||
return {
|
||||
id: u.id,
|
||||
nickname: u.nickname,
|
||||
avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null,
|
||||
kind: u.kind,
|
||||
};
|
||||
}
|
||||
30
apps/server/src/config.ts
Normal file
30
apps/server/src/config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Config {
|
||||
port: number;
|
||||
publicOrigin: string;
|
||||
/** Extra origins accepted for WS/CSRF checks (e.g. Vite dev server). */
|
||||
allowedOrigins: string[];
|
||||
dbPath: string;
|
||||
discord: { clientId: string; clientSecret: string } | null;
|
||||
sessionSecret: string;
|
||||
secureCookies: boolean;
|
||||
}
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
const extra = (env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const clientId = env.DISCORD_CLIENT_ID;
|
||||
const clientSecret = env.DISCORD_CLIENT_SECRET;
|
||||
const sessionSecret = env.SESSION_SECRET ?? '';
|
||||
if (publicOrigin.startsWith('https://') && sessionSecret.length < 32) {
|
||||
throw new Error('SESSION_SECRET must be at least 32 characters in production');
|
||||
}
|
||||
return {
|
||||
port: Number(env.PORT ?? 3000),
|
||||
publicOrigin,
|
||||
allowedOrigins: [publicOrigin, ...extra],
|
||||
dbPath: env.DB_PATH ?? './data/app.db',
|
||||
discord: clientId && clientSecret ? { clientId, clientSecret } : null,
|
||||
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
|
||||
secureCookies: publicOrigin.startsWith('https://'),
|
||||
};
|
||||
}
|
||||
32
apps/server/src/db/index.ts
Normal file
32
apps/server/src/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const MIGRATIONS_DIR = join(import.meta.dir, 'migrations');
|
||||
|
||||
export function openDb(path: string): Database {
|
||||
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
||||
const db = new Database(path, { create: true, strict: true });
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec('PRAGMA synchronous = NORMAL;');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
db.exec('PRAGMA busy_timeout = 5000;');
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function migrate(db: Database): void {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)');
|
||||
const applied = new Set(
|
||||
db.query<{ name: string }, []>('SELECT name FROM schema_migrations').all().map((r) => r.name),
|
||||
);
|
||||
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
|
||||
db.transaction(() => {
|
||||
db.exec(sql);
|
||||
db.query('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now());
|
||||
})();
|
||||
}
|
||||
}
|
||||
100
apps/server/src/db/migrations/001_init.sql
Normal file
100
apps/server/src/db/migrations/001_init.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('guest','member')),
|
||||
nickname TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
use_avatar INTEGER NOT NULL DEFAULT 1,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||
nick_changed_at INTEGER,
|
||||
nick_change_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_accounts (
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
username TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (provider, provider_user_id)
|
||||
);
|
||||
CREATE INDEX oauth_accounts_user ON oauth_accounts(user_id);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL,
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE INDEX sessions_user ON sessions(user_id);
|
||||
|
||||
CREATE TABLE rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
host_id TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('lobby','playing','finished','paused','broken','closed')),
|
||||
visibility TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
closed_at INTEGER
|
||||
);
|
||||
CREATE UNIQUE INDEX rooms_open_code ON rooms(code) WHERE status <> 'closed';
|
||||
CREATE INDEX rooms_status ON rooms(status);
|
||||
|
||||
CREATE TABLE room_state (
|
||||
room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE room_log (
|
||||
room_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
game_no INTEGER NOT NULL,
|
||||
actor_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
PRIMARY KEY (room_id, seq)
|
||||
);
|
||||
|
||||
CREATE TABLE games (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL,
|
||||
game_no INTEGER NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
options_json TEXT NOT NULL,
|
||||
seed_hash TEXT NOT NULL,
|
||||
seed TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
end_reason TEXT,
|
||||
result_json TEXT
|
||||
);
|
||||
CREATE INDEX games_room ON games(room_id);
|
||||
|
||||
CREATE TABLE game_players (
|
||||
game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
seat INTEGER NOT NULL,
|
||||
rank INTEGER,
|
||||
score REAL,
|
||||
PRIMARY KEY (game_pk, user_id)
|
||||
);
|
||||
CREATE INDEX game_players_user ON game_players(user_id);
|
||||
|
||||
CREATE TABLE user_stats (
|
||||
user_id TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
played INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
draws INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, game_id)
|
||||
);
|
||||
268
apps/server/src/http/app.ts
Normal file
268
apps/server/src/http/app.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/** HTTP API (docs/05 §6, docs/06). */
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
||||
import { z } from 'zod';
|
||||
import { CATALOG, catalogById, nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
|
||||
import type { Config } from '../config';
|
||||
import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord';
|
||||
import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
|
||||
import { toPublicUser, type Users } from '../auth/users';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
import { WindowLimiter } from '../ws/rate-limit';
|
||||
|
||||
export interface HttpDeps {
|
||||
config: Config;
|
||||
users: Users;
|
||||
sessions: Sessions;
|
||||
rooms: RoomManager;
|
||||
/** Injected for tests. */
|
||||
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
|
||||
ipOf: (req: Request) => string;
|
||||
health: () => { ok: boolean; [k: string]: unknown };
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null } };
|
||||
|
||||
const OAUTH_COOKIE = 'oauth';
|
||||
|
||||
export function createHttpApp(d: HttpDeps) {
|
||||
const app = new Hono<Env>();
|
||||
const createLimiter = new WindowLimiter(10, 60_000);
|
||||
const lookupLimiter = new WindowLimiter(30, 60_000);
|
||||
const guestLimiter = new WindowLimiter(20, 60_000);
|
||||
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
|
||||
|
||||
const cookieOpts = (maxAgeMs: number) => ({
|
||||
httpOnly: true,
|
||||
secure: d.config.secureCookies,
|
||||
sameSite: 'Lax' as const,
|
||||
path: '/',
|
||||
maxAge: Math.floor(maxAgeMs / 1000),
|
||||
});
|
||||
|
||||
// Session resolution.
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
c.set('userId', token ? d.sessions.resolve(token) : null);
|
||||
await next();
|
||||
});
|
||||
|
||||
// CSRF: state-changing requests must come from our origin (docs/04 §7).
|
||||
app.use('/api/*', async (c, next) => {
|
||||
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
|
||||
const origin = c.req.header('origin');
|
||||
if (!origin || !d.config.allowedOrigins.includes(origin)) return c.json({ error: '잘못된 요청이에요.' }, 403);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
const requireUser = (c: Context<Env>) => c.get('userId');
|
||||
|
||||
const startSession = (c: Context<Env>, userId: string, kind: 'guest' | 'member') => {
|
||||
const { token } = d.sessions.create(userId, kind, c.req.header('user-agent') ?? null);
|
||||
setCookie(c, SESSION_COOKIE, token, cookieOpts(SESSION_TTL[kind]));
|
||||
};
|
||||
|
||||
app.get('/healthz', (c) => {
|
||||
const h = d.health();
|
||||
return c.json(h, h.ok ? 200 : 503);
|
||||
});
|
||||
|
||||
app.get('/api/config', (c) => c.json({ discord: d.config.discord !== null, catalog: CATALOG }));
|
||||
|
||||
app.post('/api/auth/guest', async (c) => {
|
||||
if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400);
|
||||
const nick = normalizeNickname(body.data.nickname);
|
||||
const e = nicknameError(nick);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
const current = requireUser(c);
|
||||
if (current) {
|
||||
// Already signed in: treat as a nickname change instead of creating another account.
|
||||
const err = d.users.changeNickname(current, nick);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
return c.json({ me: toPublicUser(d.users.get(current)!) });
|
||||
}
|
||||
const u = d.users.createGuest(nick);
|
||||
startSession(c, u.id, 'guest');
|
||||
return c.json({ me: toPublicUser(u) });
|
||||
});
|
||||
|
||||
app.get('/api/auth/discord/start', async (c) => {
|
||||
if (!d.config.discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
|
||||
const state = randomToken(24);
|
||||
const next = safeNext(c.req.query('next'));
|
||||
setCookie(c, OAUTH_COOKIE, await signValue(d.config.sessionSecret, JSON.stringify({ state, next, at: Date.now() })), {
|
||||
...cookieOpts(10 * 60_000),
|
||||
path: '/api/auth/discord',
|
||||
});
|
||||
return c.redirect(authorizeUrl(d.config.discord.clientId, redirectUri, state));
|
||||
});
|
||||
|
||||
app.get('/api/auth/discord/callback', async (c) => {
|
||||
const discord = d.config.discord;
|
||||
if (!discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
|
||||
const raw = getCookie(c, OAUTH_COOKIE);
|
||||
deleteCookie(c, OAUTH_COOKIE, { path: '/api/auth/discord' });
|
||||
const payload = raw ? await unsignValue(d.config.sessionSecret, raw) : null;
|
||||
const parsed = payload ? (JSON.parse(payload) as { state: string; next: string; at: number }) : null;
|
||||
const code = c.req.query('code');
|
||||
if (!parsed || !code || parsed.state !== c.req.query('state') || Date.now() - parsed.at > 10 * 60_000) {
|
||||
return c.redirect('/?login=failed');
|
||||
}
|
||||
let du: DiscordUser;
|
||||
try {
|
||||
du = d.discordExchange ? await d.discordExchange(code, redirectUri) : await exchangeCode(discord, code, redirectUri);
|
||||
} catch {
|
||||
return c.redirect('/?login=failed');
|
||||
}
|
||||
const avatar = avatarUrl(du);
|
||||
const existing = d.users.findByOauth('discord', du.id);
|
||||
const current = requireUser(c) ? d.users.get(requireUser(c)!) : null;
|
||||
let userId: string;
|
||||
let merged = false;
|
||||
if (existing) {
|
||||
userId = existing.id;
|
||||
d.users.setAvatar(userId, avatar);
|
||||
if (current && current.kind === 'guest' && current.id !== existing.id) {
|
||||
d.users.mergeGuestInto(current.id, existing.id);
|
||||
merged = true;
|
||||
}
|
||||
} else if (current && current.kind === 'guest') {
|
||||
d.users.linkOauth(current.id, 'discord', du.id, du.username);
|
||||
d.users.promoteToMember(current.id, avatar);
|
||||
userId = current.id;
|
||||
merged = true;
|
||||
} else {
|
||||
const nick = normalizeNickname(du.global_name ?? du.username).slice(0, 12);
|
||||
const u = d.users.createMember(nicknameError(nick) ? '디스코드 사용자' : nick, avatar);
|
||||
d.users.linkOauth(u.id, 'discord', du.id, du.username);
|
||||
userId = u.id;
|
||||
}
|
||||
const old = getCookie(c, SESSION_COOKIE);
|
||||
if (old) d.sessions.revoke(old);
|
||||
startSession(c, userId, 'member');
|
||||
const next = safeNext(parsed.next);
|
||||
return c.redirect(merged ? `${next}${next.includes('?') ? '&' : '?'}linked=1` : next);
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (c) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
if (token) d.sessions.revoke(token);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout-all', (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
d.sessions.revokeAll(u);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/me', (c) => {
|
||||
const u = requireUser(c);
|
||||
const row = u ? d.users.get(u) : null;
|
||||
if (!row) return c.json({ me: null });
|
||||
return c.json({
|
||||
me: toPublicUser(row),
|
||||
settings: JSON.parse(row.settings_json),
|
||||
useAvatar: !!row.use_avatar,
|
||||
hasDiscordAvatar: !!row.avatar_url,
|
||||
activeRoom: d.rooms.activeRoomFor(row.id),
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/api/me', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
const body = z
|
||||
.object({
|
||||
nickname: z.string().max(100).optional(),
|
||||
useAvatar: z.boolean().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
fontScale: z.enum(['normal', 'large', 'xlarge']).optional(),
|
||||
theme: z.enum(['auto', 'light', 'dark']).optional(),
|
||||
sound: z.boolean().optional(),
|
||||
vibrate: z.boolean().optional(),
|
||||
colorBlind: z.boolean().optional(),
|
||||
tapConfirm: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400);
|
||||
if (body.data.nickname !== undefined) {
|
||||
const nick = normalizeNickname(body.data.nickname);
|
||||
const e = nicknameError(nick) ?? d.users.changeNickname(u, nick);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
}
|
||||
d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings });
|
||||
return c.json({ me: toPublicUser(d.users.get(u)!) });
|
||||
});
|
||||
|
||||
app.delete('/api/me', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
const body = z.object({ confirm: z.literal('탈퇴') }).safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json({ error: '확인 문구를 정확히 입력해 주세요.' }, 400);
|
||||
d.users.softDelete(u);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/me/stats', (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
return c.json({ stats: d.users.stats(u) });
|
||||
});
|
||||
|
||||
app.post('/api/rooms', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const body = z
|
||||
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() })
|
||||
.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success || !catalogById(body.data.gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
const r = d.rooms.create(u, body.data.gameId, body.data.visibility);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
return c.json({ code: r.code });
|
||||
});
|
||||
|
||||
app.post('/api/quick/:gameId', (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
const gameId = c.req.param('gameId');
|
||||
if (!catalogById(gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const r = d.rooms.quickPlay(u, gameId);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
return c.json({ code: r.code });
|
||||
});
|
||||
|
||||
/** Room preview for the join screen (no auth needed). */
|
||||
app.get('/api/rooms/:code', (c) => {
|
||||
if (!lookupLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const code = normalizeRoomCode(c.req.param('code'));
|
||||
const r = code ? d.rooms.get(code) : undefined;
|
||||
if (!r || r.status === 'broken') return c.json({ error: '방을 찾을 수 없어요. 코드를 다시 확인해 주세요.' }, 404);
|
||||
const host = d.users.get(r.hostId);
|
||||
return c.json({
|
||||
code: r.code,
|
||||
codeLabel: formatRoomCode(r.code),
|
||||
gameId: r.config.gameId,
|
||||
host: host?.nickname ?? '',
|
||||
seated: r.seatedIds().length,
|
||||
maxPlayers: r.config.maxPlayers,
|
||||
status: r.status,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) }));
|
||||
|
||||
return app;
|
||||
}
|
||||
27
apps/server/src/index.ts
Normal file
27
apps/server/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { loadConfig } from './config';
|
||||
import { jsonLog, startServer } from './server';
|
||||
|
||||
const config = loadConfig();
|
||||
const app = startServer({ config });
|
||||
jsonLog('info', 'server started', { port: app.server.port, origin: config.publicOrigin, discord: config.discord !== null });
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
jsonLog('info', 'shutting down', { signal });
|
||||
const force = setTimeout(() => process.exit(0), 10_000);
|
||||
await app.stop();
|
||||
clearTimeout(force);
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('uncaughtException', (err) => {
|
||||
jsonLog('error', 'uncaughtException', { err: String(err), stack: err.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (err) => {
|
||||
jsonLog('error', 'unhandledRejection', { err: String(err) });
|
||||
process.exit(1);
|
||||
});
|
||||
187
apps/server/src/rooms/manager.ts
Normal file
187
apps/server/src/rooms/manager.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
|
||||
import { ulid } from 'ulid';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
|
||||
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
|
||||
import type { RoomStore } from './store';
|
||||
|
||||
export const MAX_ROOMS = 2000;
|
||||
export const MAX_ROOMS_PER_USER = 3;
|
||||
const EMPTY_LOBBY_TTL = 10 * 60_000;
|
||||
const FINISHED_TTL = 30 * 60_000;
|
||||
|
||||
export interface ManagerDeps {
|
||||
store: RoomStore;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
users: { publicUser(id: string): PublicUser | null };
|
||||
now?: () => number;
|
||||
log?: RoomDeps['log'];
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
private byCode = new Map<string, Room>();
|
||||
/** userId → room code where the user holds a seat. */
|
||||
private seatIndex = new Map<string, Set<string>>();
|
||||
private createdBy = new Map<string, Set<string>>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(private deps: ManagerDeps) {
|
||||
this.now = deps.now ?? Date.now;
|
||||
}
|
||||
|
||||
private roomDeps(): RoomDeps {
|
||||
return {
|
||||
store: this.deps.store,
|
||||
games: this.deps.games,
|
||||
users: this.deps.users,
|
||||
now: this.now,
|
||||
log: this.deps.log,
|
||||
onSeatChange: (room, userId, seated) => {
|
||||
const set = this.seatIndex.get(userId) ?? new Set<string>();
|
||||
if (seated) set.add(room.code);
|
||||
else set.delete(room.code);
|
||||
if (set.size) this.seatIndex.set(userId, set);
|
||||
else this.seatIndex.delete(userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get(code: string): Room | undefined {
|
||||
return this.byCode.get(code);
|
||||
}
|
||||
|
||||
all(): Room[] {
|
||||
return [...this.byCode.values()];
|
||||
}
|
||||
|
||||
private newCode(): string {
|
||||
const rand = this.deps.random ?? Math.random;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const code = String(Math.floor(rand() * 1_000_000)).padStart(6, '0');
|
||||
if (!isAllowedRoomCode(code) || this.byCode.has(code)) continue;
|
||||
if (this.deps.store.codeInUse(code, this.now())) continue;
|
||||
return code;
|
||||
}
|
||||
throw new Error('could not allocate room code');
|
||||
}
|
||||
|
||||
/** Returns the room or a Korean error message. */
|
||||
create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string {
|
||||
const def = this.deps.games[gameId];
|
||||
if (!def) return '없는 게임이에요.';
|
||||
if (this.byCode.size >= MAX_ROOMS) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
|
||||
const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c));
|
||||
if (mine.length >= MAX_ROOMS_PER_USER) return `방은 한 번에 ${MAX_ROOMS_PER_USER}개까지 만들 수 있어요.`;
|
||||
const config: RoomConfig = {
|
||||
gameId,
|
||||
options: def.defaultOptions,
|
||||
maxPlayers: def.playersFor ? def.playersFor(def.defaultOptions).max : def.maxPlayers,
|
||||
visibility,
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const code = this.newCode();
|
||||
const id = ulid(this.now());
|
||||
const room = new Room(id, code, config, hostId, this.roomDeps());
|
||||
this.deps.store.insertRoom({
|
||||
id,
|
||||
code,
|
||||
hostId,
|
||||
gameId,
|
||||
status: 'lobby',
|
||||
visibility,
|
||||
config,
|
||||
stateJson: JSON.stringify(room.snapshot()),
|
||||
now: this.now(),
|
||||
});
|
||||
this.byCode.set(code, room);
|
||||
this.createdBy.set(hostId, new Set([...mine, code]));
|
||||
return room;
|
||||
}
|
||||
|
||||
/** Rooms where the user holds a seat in an unfinished game. */
|
||||
activeRoomFor(userId: string): string | null {
|
||||
for (const code of this.seatIndex.get(userId) ?? []) {
|
||||
const r = this.byCode.get(code);
|
||||
if (r && (r.inGame(userId) || r.status === 'lobby' || r.status === 'finished')) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** When a user takes a seat in `code`, free lobby seats they hold elsewhere (one room at a time). */
|
||||
releaseOtherLobbySeats(userId: string, code: string): void {
|
||||
for (const other of [...(this.seatIndex.get(userId) ?? [])]) {
|
||||
if (other === code) continue;
|
||||
const r = this.byCode.get(other);
|
||||
if (r && !r.inGame(userId)) r.leave(userId);
|
||||
}
|
||||
}
|
||||
|
||||
publicRooms(gameId: string): { code: string; host: string; seated: number; max: number; options: unknown }[] {
|
||||
return this.all()
|
||||
.filter((r) => r.config.gameId === gameId && r.config.visibility === 'public' && r.status === 'lobby')
|
||||
.map((r) => ({
|
||||
code: r.code,
|
||||
host: this.deps.users.publicUser(r.hostId)?.nickname ?? '',
|
||||
seated: r.seatedIds().length,
|
||||
max: r.config.maxPlayers,
|
||||
options: r.config.options,
|
||||
}))
|
||||
.filter((r) => r.seated < r.max)
|
||||
.sort((a, b) => a.max - a.seated - (b.max - b.seated));
|
||||
}
|
||||
|
||||
/** Quick play: fullest open public lobby, else a new public room. */
|
||||
quickPlay(userId: string, gameId: string): Room | string {
|
||||
const best = this.publicRooms(gameId)[0];
|
||||
if (best) return this.byCode.get(best.code)!;
|
||||
return this.create(userId, gameId, 'public');
|
||||
}
|
||||
|
||||
restoreAll(): { restored: number; broken: number } {
|
||||
let restored = 0;
|
||||
let broken = 0;
|
||||
for (const { row, seq, stateJson } of this.deps.store.openRooms()) {
|
||||
try {
|
||||
const snap = JSON.parse(stateJson) as RoomSnapshot;
|
||||
const room = Room.restore(row.id, row.code, seq, snap, this.roomDeps());
|
||||
this.byCode.set(row.code, room);
|
||||
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, true);
|
||||
restored++;
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'room restore failed', { code: row.code, err: String(err) });
|
||||
this.deps.store.markBroken(row.id);
|
||||
broken++;
|
||||
}
|
||||
}
|
||||
return { restored, broken };
|
||||
}
|
||||
|
||||
/** Periodic cleanup (call every minute). */
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
for (const room of this.all()) {
|
||||
if (room.conns.size > 0) continue;
|
||||
const idle = now - room.lastActiveAt;
|
||||
const closable =
|
||||
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
|
||||
(room.status === 'finished' && idle > FINISHED_TTL) ||
|
||||
((room.status === 'paused' || room.status === 'broken') && idle > FINISHED_TTL);
|
||||
if (closable) this.close(room);
|
||||
}
|
||||
}
|
||||
|
||||
close(room: Room): void {
|
||||
room.shutdown();
|
||||
this.deps.store.close(room.id, this.now());
|
||||
this.byCode.delete(room.code);
|
||||
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, false);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
for (const r of this.all()) r.shutdown();
|
||||
}
|
||||
}
|
||||
805
apps/server/src/rooms/room.ts
Normal file
805
apps/server/src/rooms/room.ts
Normal file
@@ -0,0 +1,805 @@
|
||||
/**
|
||||
* One room: seats, spectators, chat, game runner, timers (docs/06, docs/03, docs/09 §4).
|
||||
* All mutations run on the single JS thread, so a room never sees concurrent updates.
|
||||
*/
|
||||
import { ulid } from 'ulid';
|
||||
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
|
||||
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { sha256Hex } from '../auth/sessions';
|
||||
import type { RoomStore } from './store';
|
||||
|
||||
export interface Conn {
|
||||
userId: string;
|
||||
send(msg: ServerMessage): void;
|
||||
close(code: number, reason: string): void;
|
||||
}
|
||||
|
||||
export interface RoomConfig {
|
||||
gameId: string;
|
||||
options: unknown;
|
||||
maxPlayers: number;
|
||||
visibility: 'private' | 'public';
|
||||
allowSpectators: boolean;
|
||||
chatEnabled: boolean;
|
||||
chatFilter: boolean;
|
||||
/** Seconds a disconnected player keeps their turn timer before auto actions kick in (docs/03 §6.2). */
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
export interface Seat {
|
||||
userId: string;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
interface Presence {
|
||||
connected: boolean;
|
||||
since: number;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
interface GameSlot {
|
||||
gameId: string;
|
||||
stateVersion: number;
|
||||
state: unknown;
|
||||
rng: RngState;
|
||||
gamePk: string;
|
||||
seedHex: string;
|
||||
seedHash: string;
|
||||
startedAt: number;
|
||||
players: string[];
|
||||
finished: boolean;
|
||||
/** After a restore, deadlines are not enforced before this time (docs/04 §3.3). */
|
||||
deadlineFloor: number | null;
|
||||
}
|
||||
|
||||
/** Shape persisted to room_state.state_json. */
|
||||
export interface RoomSnapshot {
|
||||
v: 1;
|
||||
config: RoomConfig;
|
||||
hostId: string;
|
||||
status: Room['status'];
|
||||
seats: (Seat | null)[];
|
||||
gameNo: number;
|
||||
sessionStats: Record<string, { played: number; wins: number }>;
|
||||
rematchVotes: string[];
|
||||
lastSeed: string | null;
|
||||
lastResult: GameResult | null;
|
||||
game: GameSlot | null;
|
||||
joinedAt: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface RoomDeps {
|
||||
store: RoomStore;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
users: { publicUser(id: string): PublicUser | null };
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
onEmpty?: (room: Room) => void;
|
||||
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
||||
}
|
||||
|
||||
const CHAT_HISTORY = 30;
|
||||
const ERROR_LIMIT = 3;
|
||||
|
||||
export class Room {
|
||||
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
|
||||
seq = 0;
|
||||
hostId: string;
|
||||
seats: (Seat | null)[];
|
||||
spectators = new Set<string>();
|
||||
presence = new Map<string, Presence>();
|
||||
conns = new Map<string, Conn>();
|
||||
kicked = new Set<string>();
|
||||
gameNo = 0;
|
||||
sessionStats: Record<string, { played: number; wins: number }> = {};
|
||||
rematchVotes = new Set<string>();
|
||||
lastSeed: string | null = null;
|
||||
lastResult: GameResult | null = null;
|
||||
game: GameSlot | null = null;
|
||||
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
||||
lastActiveAt: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private recentCs = new Map<string, number[]>();
|
||||
private lastChatAt = new Map<string, number>();
|
||||
private consecutiveErrors = 0;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly code: string,
|
||||
public config: RoomConfig,
|
||||
hostId: string,
|
||||
private deps: RoomDeps,
|
||||
) {
|
||||
this.hostId = hostId;
|
||||
this.seats = new Array(config.maxPlayers).fill(null);
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.lastActiveAt = this.now();
|
||||
}
|
||||
|
||||
get def(): AnyGameDefinition {
|
||||
const d = this.deps.games[this.config.gameId];
|
||||
if (!d) throw new Error(`unknown game ${this.config.gameId}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
playerRange(): { min: number; max: number } {
|
||||
const d = this.def;
|
||||
return d.playersFor ? d.playersFor(this.config.options) : { min: d.minPlayers, max: d.maxPlayers };
|
||||
}
|
||||
|
||||
seatOf(userId: string): number {
|
||||
return this.seats.findIndex((s) => s?.userId === userId);
|
||||
}
|
||||
|
||||
isParticipant(userId: string): boolean {
|
||||
return this.seatOf(userId) >= 0 || this.spectators.has(userId);
|
||||
}
|
||||
|
||||
seatedIds(): string[] {
|
||||
return this.seats.filter((s): s is Seat => s !== null).map((s) => s.userId);
|
||||
}
|
||||
|
||||
inGame(userId: string): boolean {
|
||||
return !!this.game && !this.game.finished && this.game.players.includes(userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
snapshot(): RoomSnapshot {
|
||||
const joinedAt: Record<string, number> = {};
|
||||
for (const [u, p] of this.presence) if (this.seatOf(u) >= 0) joinedAt[u] = p.joinedAt;
|
||||
return {
|
||||
v: 1,
|
||||
config: this.config,
|
||||
hostId: this.hostId,
|
||||
status: this.status,
|
||||
seats: this.seats,
|
||||
gameNo: this.gameNo,
|
||||
sessionStats: this.sessionStats,
|
||||
rematchVotes: [...this.rematchVotes],
|
||||
lastSeed: this.lastSeed,
|
||||
lastResult: this.lastResult,
|
||||
game: this.game,
|
||||
joinedAt,
|
||||
};
|
||||
}
|
||||
|
||||
static restore(id: string, code: string, seq: number, snap: RoomSnapshot, deps: RoomDeps): Room {
|
||||
const room = new Room(id, code, snap.config, snap.hostId, deps);
|
||||
room.seq = seq;
|
||||
room.status = snap.status;
|
||||
room.seats = snap.seats;
|
||||
room.gameNo = snap.gameNo;
|
||||
room.sessionStats = snap.sessionStats;
|
||||
room.rematchVotes = new Set(snap.rematchVotes);
|
||||
room.lastSeed = snap.lastSeed;
|
||||
room.lastResult = snap.lastResult;
|
||||
room.game = snap.game;
|
||||
const now = room.now();
|
||||
for (const s of room.seats) {
|
||||
if (s) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
|
||||
}
|
||||
if (room.game && !room.game.finished) {
|
||||
const def = room.def;
|
||||
if (def.stateVersion !== room.game.stateVersion) {
|
||||
if (!def.migrate) throw new Error(`state version mismatch for ${def.id}`);
|
||||
room.game.state = def.migrate(room.game.state, room.game.stateVersion);
|
||||
room.game.stateVersion = def.stateVersion;
|
||||
}
|
||||
room.game.deadlineFloor = now + 30_000;
|
||||
room.scheduleTimer();
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
|
||||
const nextSeq = this.seq + 1;
|
||||
this.deps.store.save(
|
||||
this.id,
|
||||
nextSeq,
|
||||
JSON.stringify(this.snapshot()),
|
||||
{ hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config },
|
||||
log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null,
|
||||
);
|
||||
this.seq = nextSeq;
|
||||
this.lastActiveAt = this.now();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
roomView(): RoomView {
|
||||
const pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const seats: (SeatView | null)[] = this.seats.map((s) => {
|
||||
if (!s) return null;
|
||||
const p = this.presence.get(s.userId);
|
||||
const connected = !!p?.connected;
|
||||
const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000;
|
||||
return { user: pub(s.userId), ready: s.ready, connected, away };
|
||||
});
|
||||
const range = this.playerRange();
|
||||
return {
|
||||
code: this.code,
|
||||
hostId: this.hostId,
|
||||
gameId: this.config.gameId,
|
||||
options: this.config.options,
|
||||
maxPlayers: this.config.maxPlayers,
|
||||
minPlayers: range.min,
|
||||
visibility: this.config.visibility,
|
||||
turnSeconds: null,
|
||||
allowSpectators: this.config.allowSpectators,
|
||||
chatEnabled: this.config.chatEnabled,
|
||||
chatFilter: this.config.chatFilter,
|
||||
seats,
|
||||
spectators: [...this.spectators].map(pub),
|
||||
status: this.status,
|
||||
gameNo: this.gameNo,
|
||||
rematchVotes: [...this.rematchVotes],
|
||||
sessionStats: this.sessionStats,
|
||||
seedHash: this.game?.seedHash ?? null,
|
||||
lastSeed: this.lastSeed,
|
||||
};
|
||||
}
|
||||
|
||||
private stateMessage(viewer: string, cs?: number): ServerMessage | null {
|
||||
if (!this.game) return null;
|
||||
const def = this.def;
|
||||
const asPlayer = this.game.players.includes(viewer) ? viewer : null;
|
||||
let view: unknown;
|
||||
try {
|
||||
view = def.view(this.game.state, asPlayer);
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) });
|
||||
return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' };
|
||||
}
|
||||
return {
|
||||
t: 'state',
|
||||
seq: this.seq,
|
||||
view,
|
||||
events: [],
|
||||
active: this.game.finished ? [] : def.activePlayers(this.game.state),
|
||||
deadline: this.game.finished ? null : this.effectiveDeadline(),
|
||||
...(cs !== undefined ? { cs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
broadcastRoom(): void {
|
||||
const msg: ServerMessage = { t: 'room', seq: this.seq, room: this.roomView() };
|
||||
for (const c of this.conns.values()) c.send(msg);
|
||||
}
|
||||
|
||||
private broadcastState(events: unknown[], actor?: string, cs?: number): void {
|
||||
if (!this.game) return;
|
||||
let spectatorMsg: ServerMessage | null = null;
|
||||
for (const c of this.conns.values()) {
|
||||
let msg: ServerMessage | null;
|
||||
if (this.game.players.includes(c.userId)) {
|
||||
msg = this.stateMessage(c.userId, c.userId === actor ? cs : undefined);
|
||||
} else {
|
||||
spectatorMsg ??= this.stateMessage('');
|
||||
msg = spectatorMsg;
|
||||
}
|
||||
if (msg && msg.t === 'state') c.send({ ...msg, events });
|
||||
else if (msg) c.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
notice(code: string, message: string): void {
|
||||
for (const c of this.conns.values()) c.send({ t: 'notice', code, message });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- membership
|
||||
|
||||
/** Returns an error message, or null on success. */
|
||||
join(conn: Conn, as: 'player' | 'spectator'): string | null {
|
||||
const u = conn.userId;
|
||||
if (this.kicked.has(u)) return '방장이 내보낸 방에는 다시 들어갈 수 없어요.';
|
||||
if (this.status === 'broken') return '서버 업데이트로 이 게임을 이어갈 수 없어요. 새로 시작해 주세요.';
|
||||
const old = this.conns.get(u);
|
||||
if (old && old !== conn) {
|
||||
old.send({ t: 'bye', reason: 'replaced' });
|
||||
old.close(4000, 'replaced');
|
||||
}
|
||||
const now = this.now();
|
||||
const wasSeated = this.seatOf(u) >= 0;
|
||||
if (!wasSeated && !this.spectators.has(u)) {
|
||||
const seatable = as === 'player' && (this.status === 'lobby' || this.status === 'finished') && this.seats.some((s) => s === null);
|
||||
if (seatable) {
|
||||
const idx = this.seats.findIndex((s) => s === null);
|
||||
this.seats[idx] = { userId: u, ready: false };
|
||||
this.deps.onSeatChange?.(this, u, true);
|
||||
} else {
|
||||
if (!this.config.allowSpectators && !this.inGame(u)) return '이 방은 가득 찼어요.';
|
||||
this.spectators.add(u);
|
||||
}
|
||||
}
|
||||
const prev = this.presence.get(u);
|
||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt ?? now });
|
||||
this.conns.set(u, conn);
|
||||
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
||||
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
||||
}
|
||||
this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } });
|
||||
this.broadcastRoom();
|
||||
const st = this.stateMessage(u);
|
||||
if (st) conn.send(st);
|
||||
if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult });
|
||||
for (const m of this.chatLog) conn.send(m);
|
||||
this.scheduleTimer();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Connection dropped (not an intentional leave). */
|
||||
disconnect(conn: Conn): void {
|
||||
const u = conn.userId;
|
||||
if (this.conns.get(u) !== conn) return;
|
||||
this.conns.delete(u);
|
||||
const p = this.presence.get(u);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
p.since = this.now();
|
||||
}
|
||||
if (this.spectators.has(u)) {
|
||||
this.spectators.delete(u);
|
||||
this.presence.delete(u);
|
||||
} else if (this.inGame(u)) {
|
||||
this.notice('disconnected', `${this.nick(u)}님의 연결이 끊겼어요.`);
|
||||
}
|
||||
this.broadcastRoom();
|
||||
this.scheduleTimer();
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
}
|
||||
|
||||
leave(userId: string): void {
|
||||
const conn = this.conns.get(userId);
|
||||
this.conns.delete(userId);
|
||||
this.spectators.delete(userId);
|
||||
this.presence.delete(userId);
|
||||
const seat = this.seatOf(userId);
|
||||
if (seat >= 0) {
|
||||
if (this.inGame(userId)) {
|
||||
const action = this.def.onLeave?.(this.game!.state, userId);
|
||||
if (action) this.runAction(userId, action, undefined, true);
|
||||
// Stay in the seat as "away" until the game ends so the game state stays consistent.
|
||||
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
||||
} else {
|
||||
this.seats[seat] = null;
|
||||
this.rematchVotes.delete(userId);
|
||||
this.deps.onSeatChange?.(this, userId, false);
|
||||
}
|
||||
}
|
||||
if (this.hostId === userId) this.pickNewHost();
|
||||
this.persist({ actorId: userId, kind: 'leave', data: null });
|
||||
this.broadcastRoom();
|
||||
conn?.send({ t: 'bye', reason: 'closed' });
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
}
|
||||
|
||||
private pickNewHost(): void {
|
||||
const candidates = [...this.presence.entries()]
|
||||
.filter(([u, p]) => this.seatOf(u) >= 0 && p.connected)
|
||||
.sort((a, b) => a[1].joinedAt - b[1].joinedAt);
|
||||
const next = candidates[0]?.[0] ?? this.seatedIds()[0] ?? [...this.spectators][0];
|
||||
if (next) this.hostId = next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lobby commands
|
||||
|
||||
private requireLobby(): string | null {
|
||||
return this.status === 'lobby' || this.status === 'finished' ? null : '게임 중에는 바꿀 수 없어요.';
|
||||
}
|
||||
|
||||
seat(userId: string, seat?: number): string | null {
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const target = seat ?? this.seats.findIndex((s) => s === null);
|
||||
if (target < 0 || target >= this.seats.length) return '빈자리가 없어요.';
|
||||
if (this.seats[target] && this.seats[target]!.userId !== userId) return '이미 다른 사람이 앉은 자리예요.';
|
||||
const cur = this.seatOf(userId);
|
||||
if (cur === target) return null;
|
||||
const ready = cur >= 0 ? this.seats[cur]!.ready : false;
|
||||
if (cur >= 0) this.seats[cur] = null;
|
||||
this.seats[target] = { userId, ready };
|
||||
this.spectators.delete(userId);
|
||||
if (cur < 0) this.deps.onSeatChange?.(this, userId, true);
|
||||
this.persist({ actorId: userId, kind: 'seat', data: { seat: target } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
unseat(userId: string): string | null {
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const cur = this.seatOf(userId);
|
||||
if (cur < 0) return null;
|
||||
if (!this.config.allowSpectators) return '이 방은 관전을 허용하지 않아요.';
|
||||
this.seats[cur] = null;
|
||||
this.spectators.add(userId);
|
||||
this.rematchVotes.delete(userId);
|
||||
this.deps.onSeatChange?.(this, userId, false);
|
||||
this.persist({ actorId: userId, kind: 'unseat', data: null });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
ready(userId: string, ready: boolean): string | null {
|
||||
const cur = this.seatOf(userId);
|
||||
if (cur < 0) return '자리에 앉아야 준비할 수 있어요.';
|
||||
if (this.requireLobby()) return this.requireLobby();
|
||||
this.seats[cur]!.ready = ready;
|
||||
this.persist(null);
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
configure(
|
||||
userId: string,
|
||||
patch: Partial<Omit<RoomConfig, 'graceSec'>> & { gameId?: string },
|
||||
): string | null {
|
||||
if (userId !== this.hostId) return '방장만 설정을 바꿀 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const next: RoomConfig = { ...this.config };
|
||||
if (patch.gameId !== undefined && patch.gameId !== this.config.gameId) {
|
||||
const def = this.deps.games[patch.gameId];
|
||||
if (!def) return '없는 게임이에요.';
|
||||
next.gameId = patch.gameId;
|
||||
next.options = def.defaultOptions;
|
||||
next.maxPlayers = def.maxPlayers;
|
||||
}
|
||||
const def = this.deps.games[next.gameId]!;
|
||||
if (patch.options !== undefined) {
|
||||
const parsed = def.optionsSchema.safeParse(patch.options);
|
||||
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
||||
next.options = parsed.data;
|
||||
}
|
||||
const range = def.playersFor ? def.playersFor(next.options) : { min: def.minPlayers, max: def.maxPlayers };
|
||||
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
|
||||
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
|
||||
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
|
||||
for (const k of ['visibility', 'allowSpectators', 'chatEnabled', 'chatFilter'] as const) {
|
||||
if (patch[k] !== undefined) (next as unknown as Record<string, unknown>)[k] = patch[k];
|
||||
}
|
||||
// Resize seats, compacting occupied seats to the front if needed.
|
||||
if (next.maxPlayers !== this.seats.length) {
|
||||
const occupied = this.seats.filter((s): s is Seat => s !== null);
|
||||
const seats: (Seat | null)[] = new Array(next.maxPlayers).fill(null);
|
||||
let i = 0;
|
||||
for (let k = 0; k < this.seats.length && k < next.maxPlayers; k++) if (this.seats[k]) seats[k] = this.seats[k]!;
|
||||
const placed = new Set(seats.filter(Boolean).map((s) => s!.userId));
|
||||
for (const s of occupied) {
|
||||
if (placed.has(s.userId)) continue;
|
||||
while (seats[i]) i++;
|
||||
seats[i] = s;
|
||||
}
|
||||
this.seats = seats;
|
||||
}
|
||||
this.config = next;
|
||||
for (const s of this.seats) if (s) s.ready = false;
|
||||
this.persist({ actorId: userId, kind: 'config', data: patch });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
kick(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
|
||||
if (target === userId) return '자기 자신은 내보낼 수 없어요.';
|
||||
if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
|
||||
const conn = this.conns.get(target);
|
||||
this.kicked.add(target);
|
||||
this.leave(target);
|
||||
conn?.send({ t: 'bye', reason: 'kicked' });
|
||||
conn?.close(4001, 'kicked');
|
||||
return null;
|
||||
}
|
||||
|
||||
transferHost(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
|
||||
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
|
||||
this.hostId = target;
|
||||
this.persist({ actorId: userId, kind: 'host', data: { target } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
chat(userId: string, text: string): string | null {
|
||||
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
|
||||
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
|
||||
this.lastChatAt.set(userId, now);
|
||||
const clean = text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
||||
if (!clean) return null;
|
||||
const msg: Extract<ServerMessage, { t: 'chat' }> = {
|
||||
t: 'chat',
|
||||
from: this.deps.users.publicUser(userId),
|
||||
text: this.config.chatFilter ? maskProfanity(clean) : clean,
|
||||
at: now,
|
||||
};
|
||||
this.chatLog.push(msg);
|
||||
if (this.chatLog.length > CHAT_HISTORY) this.chatLog.shift();
|
||||
for (const c of this.conns.values()) c.send(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
emote(userId: string, id: string): void {
|
||||
if (!this.isParticipant(userId)) return;
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
|
||||
this.lastChatAt.set(`e:${userId}`, now);
|
||||
for (const c of this.conns.values()) c.send({ t: 'emote', from: userId, id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- game lifecycle
|
||||
|
||||
start(userId: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 시작할 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const players = this.orderedPlayers();
|
||||
const range = this.playerRange();
|
||||
if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`;
|
||||
if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`;
|
||||
return this.startGame(players);
|
||||
}
|
||||
|
||||
rematch(userId: string): string | null {
|
||||
if (this.status !== 'finished') return '게임이 끝난 뒤에 누를 수 있어요.';
|
||||
if (this.seatOf(userId) < 0) return '자리에 앉아야 해요.';
|
||||
this.rematchVotes.add(userId);
|
||||
const seated = this.seatedIds();
|
||||
const range = this.playerRange();
|
||||
if (seated.length >= range.min && seated.every((u) => this.rematchVotes.has(u))) {
|
||||
return this.startGame(this.orderedPlayers());
|
||||
}
|
||||
this.persist(null);
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Seated players in seat order, rotated so the host (if seated) comes first. */
|
||||
private orderedPlayers(): string[] {
|
||||
const ids = this.seatedIds();
|
||||
const h = ids.indexOf(this.hostId);
|
||||
return h > 0 ? [...ids.slice(h), ...ids.slice(0, h)] : ids;
|
||||
}
|
||||
|
||||
private startGame(players: string[]): string | null {
|
||||
const def = this.def;
|
||||
const parsed = def.optionsSchema.safeParse(this.config.options);
|
||||
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
||||
const seed = createSeed();
|
||||
const seedHex = seedToHex(seed);
|
||||
const rng = SeededRng.fromSeed(seed);
|
||||
const now = this.now();
|
||||
const gameNo = this.gameNo + 1;
|
||||
let state: unknown;
|
||||
try {
|
||||
state = def.setup({ players, options: parsed.data, rng, now, gameNo });
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'setup failed', { room: this.code, err: String(err) });
|
||||
return '게임을 시작하지 못했어요.';
|
||||
}
|
||||
const gamePk = ulid(now);
|
||||
const seedHash = sha256Hex(seedHex);
|
||||
this.deps.store.insertGame({ id: gamePk, roomId: this.id, gameNo, gameId: def.id, options: parsed.data, seedHash, startedAt: now });
|
||||
this.gameNo = gameNo;
|
||||
this.game = {
|
||||
gameId: def.id,
|
||||
stateVersion: def.stateVersion,
|
||||
state,
|
||||
rng: rng.state(),
|
||||
gamePk,
|
||||
seedHex,
|
||||
seedHash,
|
||||
startedAt: now,
|
||||
players,
|
||||
finished: false,
|
||||
deadlineFloor: null,
|
||||
};
|
||||
this.status = 'playing';
|
||||
this.rematchVotes.clear();
|
||||
this.lastResult = null;
|
||||
this.consecutiveErrors = 0;
|
||||
for (const s of this.seats) if (s) s.ready = false;
|
||||
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } });
|
||||
this.broadcastRoom();
|
||||
this.broadcastState([{ type: 'gameStarted' }]);
|
||||
this.scheduleTimer();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Client action entry. `cs` dedupes retransmissions (docs/03 §5). */
|
||||
act(userId: string, cs: number, action: unknown): void {
|
||||
const conn = this.conns.get(userId);
|
||||
const reject = (reason: string) => conn?.send({ t: 'reject', cs, reason });
|
||||
const recent = this.recentCs.get(userId) ?? [];
|
||||
if (recent.includes(cs)) return;
|
||||
recent.push(cs);
|
||||
if (recent.length > 50) recent.shift();
|
||||
this.recentCs.set(userId, recent);
|
||||
if (!this.game || this.game.finished || this.status !== 'playing') return reject('진행 중인 게임이 없어요.');
|
||||
if (!this.game.players.includes(userId)) return reject('이 게임의 플레이어가 아니에요.');
|
||||
const parsed = this.def.actionSchema.safeParse(action);
|
||||
if (!parsed.success) return reject('잘못된 요청이에요.');
|
||||
const err = this.runAction(userId, parsed.data, cs, false);
|
||||
if (err) reject(err);
|
||||
}
|
||||
|
||||
/** Validates and applies one action; returns an error message on rejection. */
|
||||
private runAction(userId: string, action: unknown, cs: number | undefined, system: boolean): string | null {
|
||||
const g = this.game;
|
||||
if (!g || g.finished) return '진행 중인 게임이 없어요.';
|
||||
const def = this.def;
|
||||
let next: { state: unknown; events: unknown[] };
|
||||
const rng = new SeededRng(g.rng);
|
||||
try {
|
||||
const v = def.validate(g.state, userId, action);
|
||||
if (!v.ok) return v.reason;
|
||||
next = def.apply(g.state, userId, action, { rng, now: this.now() });
|
||||
} catch (err) {
|
||||
this.consecutiveErrors++;
|
||||
this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack });
|
||||
if (this.consecutiveErrors >= ERROR_LIMIT) {
|
||||
this.status = 'paused';
|
||||
this.clearTimer();
|
||||
this.persist({ actorId: null, kind: 'paused', data: null });
|
||||
this.broadcastRoom();
|
||||
this.notice('paused', '오류가 반복되어 게임을 멈췄어요. 방장은 게임을 끝낼 수 있어요.');
|
||||
}
|
||||
return '알 수 없는 오류가 발생했어요.';
|
||||
}
|
||||
this.consecutiveErrors = 0;
|
||||
g.state = next.state;
|
||||
g.rng = rng.state();
|
||||
g.deadlineFloor = null;
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
const result = def.result(g.state);
|
||||
if (result) this.finishGame(result);
|
||||
else this.scheduleTimer();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Host ends a paused/broken game without counting it. */
|
||||
voidGame(userId: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 할 수 있어요.';
|
||||
if (!this.game || this.game.finished) return '진행 중인 게임이 없어요.';
|
||||
this.finishGame({ ranking: [this.game.players], summary: '게임이 무효 처리됐어요.', reason: 'abandoned' }, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
private finishGame(result: GameResult, voided = false): void {
|
||||
const g = this.game!;
|
||||
g.finished = true;
|
||||
this.clearTimer();
|
||||
const rankOf = new Map<string, number>();
|
||||
let rank = 1;
|
||||
for (const group of result.ranking) {
|
||||
for (const u of group) rankOf.set(u, rank);
|
||||
rank += group.length;
|
||||
}
|
||||
const players = g.players.map((u) => ({
|
||||
userId: u,
|
||||
seat: this.seatOf(u),
|
||||
rank: rankOf.get(u) ?? null,
|
||||
score: result.scores?.[u] ?? null,
|
||||
}));
|
||||
this.deps.store.finishGame({
|
||||
id: g.gamePk,
|
||||
gameId: g.gameId,
|
||||
seed: g.seedHex,
|
||||
endedAt: this.now(),
|
||||
endReason: voided ? 'void' : result.reason,
|
||||
result,
|
||||
players,
|
||||
});
|
||||
const allFirst = players.every((p) => p.rank === 1);
|
||||
if (!voided) {
|
||||
for (const p of players) {
|
||||
const st = (this.sessionStats[p.userId] ??= { played: 0, wins: 0 });
|
||||
st.played++;
|
||||
if (p.rank === 1 && !allFirst) st.wins++;
|
||||
}
|
||||
}
|
||||
this.lastSeed = g.seedHex;
|
||||
this.lastResult = result;
|
||||
this.status = 'finished';
|
||||
// Players who left during the game lose their seat now.
|
||||
for (let i = 0; i < this.seats.length; i++) {
|
||||
const s = this.seats[i];
|
||||
if (s && !this.presence.get(s.userId)?.connected && this.presence.get(s.userId)?.joinedAt === 0) {
|
||||
this.seats[i] = null;
|
||||
this.presence.delete(s.userId);
|
||||
this.deps.onSeatChange?.(this, s.userId, false);
|
||||
}
|
||||
}
|
||||
this.persist({ actorId: null, kind: 'finish', data: { result, voided } });
|
||||
this.broadcastState([{ type: 'gameOver' }]);
|
||||
for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result });
|
||||
this.broadcastRoom();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timers
|
||||
|
||||
/** Earliest time any timed player must be auto-acted for. */
|
||||
effectiveDeadline(): number | null {
|
||||
const g = this.game;
|
||||
if (!g || g.finished || this.status !== 'playing') return null;
|
||||
const def = this.def;
|
||||
const base = def.deadline?.(g.state) ?? null;
|
||||
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
|
||||
let best: number | null = null;
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + this.config.graceSec * 1000;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (d === null) continue;
|
||||
if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor);
|
||||
best = best === null ? d : Math.min(best, d);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
scheduleTimer(): void {
|
||||
this.clearTimer();
|
||||
const at = this.effectiveDeadline();
|
||||
if (at === null) return;
|
||||
const delay = Math.max(0, at - this.now());
|
||||
this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1));
|
||||
}
|
||||
|
||||
clearTimer(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
/** Fires due timeouts. Public for tests. */
|
||||
onTimer(): void {
|
||||
this.timer = null;
|
||||
const g = this.game;
|
||||
if (!g || g.finished || this.status !== 'playing') return;
|
||||
const def = this.def;
|
||||
const now = this.now();
|
||||
const base = def.deadline?.(g.state) ?? null;
|
||||
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + this.config.graceSec * 1000;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
|
||||
if (d === null || d > now) continue;
|
||||
const action = def.onTimeout(g.state, u);
|
||||
const err = this.runAction(u, action, undefined, true);
|
||||
if (err) this.deps.log?.('warn', 'timeout action rejected', { room: this.code, user: u, err });
|
||||
break; // state changed; re-evaluate via scheduleTimer
|
||||
}
|
||||
if (!this.game?.finished) this.scheduleTimer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- misc
|
||||
|
||||
private nick(u: string): string {
|
||||
return this.deps.users.publicUser(u)?.nickname ?? '알 수 없음';
|
||||
}
|
||||
|
||||
sendSync(conn: Conn): void {
|
||||
conn.send({ t: 'room', seq: this.seq, room: this.roomView() });
|
||||
const st = this.stateMessage(conn.userId);
|
||||
if (st) conn.send(st);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
c.send({ t: 'bye', reason: 'restart' });
|
||||
c.close(1012, 'restart');
|
||||
}
|
||||
}
|
||||
}
|
||||
140
apps/server/src/rooms/store.ts
Normal file
140
apps/server/src/rooms/store.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/** Room persistence (docs/04 §2, docs/08 §3). All writes are synchronous SQLite transactions. */
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
export interface RoomRow {
|
||||
id: string;
|
||||
code: string;
|
||||
host_id: string;
|
||||
game_id: string;
|
||||
status: string;
|
||||
visibility: string;
|
||||
config_json: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
seq: number;
|
||||
gameNo: number;
|
||||
actorId: string | null;
|
||||
kind: string;
|
||||
data: unknown;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export class RoomStore {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
codeInUse(code: string, now = Date.now()): boolean {
|
||||
// Open rooms, plus rooms closed within the last 24h (docs/06 §3).
|
||||
const row = this.db
|
||||
.query<{ n: number }, [string, number]>(
|
||||
"SELECT COUNT(*) AS n FROM rooms WHERE code = ? AND (status <> 'closed' OR closed_at > ?)",
|
||||
)
|
||||
.get(code, now - 24 * 3600_000);
|
||||
return (row?.n ?? 0) > 0;
|
||||
}
|
||||
|
||||
insertRoom(r: { id: string; code: string; hostId: string; gameId: string; status: string; visibility: string; config: unknown; stateJson: string; now: number }): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.query('INSERT INTO rooms (id, code, host_id, game_id, status, visibility, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(r.id, r.code, r.hostId, r.gameId, r.status, r.visibility, JSON.stringify(r.config), r.now, r.now);
|
||||
this.db.query('INSERT INTO room_state (room_id, seq, state_json, updated_at) VALUES (?, 0, ?, ?)').run(r.id, r.stateJson, r.now);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Writes the log entry (optional), latest room snapshot and room metadata in one transaction. */
|
||||
save(
|
||||
roomId: string,
|
||||
seq: number,
|
||||
stateJson: string,
|
||||
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
|
||||
log: LogEntry | null,
|
||||
now = Date.now(),
|
||||
): void {
|
||||
this.db.transaction(() => {
|
||||
if (log) {
|
||||
this.db
|
||||
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(roomId, log.seq, log.gameNo, log.actorId, log.kind, JSON.stringify(log.data ?? null), log.at);
|
||||
}
|
||||
this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
|
||||
this.db
|
||||
.query('UPDATE rooms SET host_id = ?, game_id = ?, status = ?, visibility = ?, config_json = ?, updated_at = ? WHERE id = ?')
|
||||
.run(meta.hostId, meta.gameId, meta.status, meta.visibility, JSON.stringify(meta.config), now, roomId);
|
||||
})();
|
||||
}
|
||||
|
||||
close(roomId: string, now = Date.now()): void {
|
||||
this.db.transaction(() => {
|
||||
this.db.query("UPDATE rooms SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?").run(now, now, roomId);
|
||||
this.db.query('DELETE FROM room_state WHERE room_id = ?').run(roomId);
|
||||
})();
|
||||
}
|
||||
|
||||
markBroken(roomId: string, now = Date.now()): void {
|
||||
this.db.query("UPDATE rooms SET status = 'broken', updated_at = ? WHERE id = ?").run(now, roomId);
|
||||
}
|
||||
|
||||
openRooms(): { row: RoomRow; seq: number; stateJson: string }[] {
|
||||
return this.db
|
||||
.query<RoomRow & { seq: number; state_json: string }, []>(
|
||||
"SELECT r.*, s.seq, s.state_json FROM rooms r JOIN room_state s ON s.room_id = r.id WHERE r.status IN ('lobby','playing','finished','paused')",
|
||||
)
|
||||
.all()
|
||||
.map((r) => ({ row: r, seq: r.seq, stateJson: r.state_json }));
|
||||
}
|
||||
|
||||
insertGame(g: { id: string; roomId: string; gameNo: number; gameId: string; options: unknown; seedHash: string; startedAt: number }): void {
|
||||
this.db
|
||||
.query('INSERT INTO games (id, room_id, game_no, game_id, options_json, seed_hash, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(g.id, g.roomId, g.gameNo, g.gameId, JSON.stringify(g.options), g.seedHash, g.startedAt);
|
||||
}
|
||||
|
||||
finishGame(g: {
|
||||
id: string;
|
||||
gameId: string;
|
||||
seed: string;
|
||||
endedAt: number;
|
||||
endReason: string;
|
||||
result: unknown;
|
||||
players: { userId: string; seat: number; rank: number | null; score: number | null }[];
|
||||
}): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?')
|
||||
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(g.result), g.id);
|
||||
const counted = g.endReason !== 'void';
|
||||
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
|
||||
const firstCount = ranks.filter((r) => r === 1).length;
|
||||
for (const p of g.players) {
|
||||
this.db
|
||||
.query('INSERT OR REPLACE INTO game_players (game_pk, user_id, seat, rank, score) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(g.id, p.userId, p.seat, p.rank, p.score);
|
||||
if (!counted) continue;
|
||||
const draw = p.rank === 1 && firstCount === g.players.length && g.players.length > 1;
|
||||
const win = p.rank === 1 && !draw;
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + 1, wins = wins + excluded.wins, draws = draws + excluded.draws`,
|
||||
)
|
||||
.run(p.userId, g.gameId, win ? 1 : 0, draw ? 1 : 0);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
log(roomId: string): LogEntry[] {
|
||||
return this.db
|
||||
.query<{ seq: number; game_no: number; actor_id: string | null; kind: string; data_json: string; at: number }, [string]>(
|
||||
'SELECT seq, game_no, actor_id, kind, data_json, at FROM room_log WHERE room_id = ? ORDER BY seq',
|
||||
)
|
||||
.all(roomId)
|
||||
.map((r) => ({ seq: r.seq, gameNo: r.game_no, actorId: r.actor_id, kind: r.kind, data: JSON.parse(r.data_json), at: r.at }));
|
||||
}
|
||||
|
||||
purgeOldLogs(now = Date.now()): void {
|
||||
this.db.query('DELETE FROM room_log WHERE at < ?').run(now - 90 * 24 * 3600_000);
|
||||
}
|
||||
}
|
||||
128
apps/server/src/server.ts
Normal file
128
apps/server/src/server.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/** Wires DB, auth, rooms, HTTP and WebSocket into one Bun server (docs/02 §2). */
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import type { Server } from 'bun';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { Config } from './config';
|
||||
import { openDb } from './db';
|
||||
import { SESSION_COOKIE, Sessions } from './auth/sessions';
|
||||
import { Users, toPublicUser } from './auth/users';
|
||||
import { createHttpApp, type HttpDeps } from './http/app';
|
||||
import { RoomManager } from './rooms/manager';
|
||||
import { RoomStore } from './rooms/store';
|
||||
import { Gateway, type WsData } from './ws/gateway';
|
||||
|
||||
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
|
||||
export const jsonLog: LogFn = (level, msg, extra) => {
|
||||
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
|
||||
if (level === 'error') console.error(line);
|
||||
else console.log(line);
|
||||
};
|
||||
|
||||
export interface StartOptions {
|
||||
config: Config;
|
||||
log?: LogFn;
|
||||
staticDir?: string | null;
|
||||
discordExchange?: HttpDeps['discordExchange'];
|
||||
}
|
||||
|
||||
export function startServer(opts: StartOptions) {
|
||||
const { config } = opts;
|
||||
const log = opts.log ?? jsonLog;
|
||||
const db = openDb(config.dbPath);
|
||||
const users = new Users(db);
|
||||
const sessions = new Sessions(db);
|
||||
const store = new RoomStore(db);
|
||||
const publicUser = (id: string) => {
|
||||
const u = users.get(id);
|
||||
return u ? toPublicUser(u) : null;
|
||||
};
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log });
|
||||
const restored = rooms.restoreAll();
|
||||
log('info', 'rooms restored', restored);
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log });
|
||||
let ready = true;
|
||||
|
||||
const ipOf = (req: Request) =>
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || server?.requestIP(req)?.address || 'unknown';
|
||||
|
||||
const app = createHttpApp({
|
||||
config,
|
||||
users,
|
||||
sessions,
|
||||
rooms,
|
||||
ipOf,
|
||||
discordExchange: opts.discordExchange,
|
||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||
});
|
||||
|
||||
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
|
||||
const serveStatic = staticDir && existsSync(staticDir);
|
||||
|
||||
const server: Server<WsData> = Bun.serve<WsData>({
|
||||
port: config.port,
|
||||
async fetch(req, srv) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === '/ws') {
|
||||
const origin = req.headers.get('origin');
|
||||
if (!origin || !config.allowedOrigins.includes(origin)) return new Response('forbidden origin', { status: 403 });
|
||||
const cookie = req.headers.get('cookie') ?? '';
|
||||
const token = cookie
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.startsWith(`${SESSION_COOKIE}=`))
|
||||
?.slice(SESSION_COOKIE.length + 1);
|
||||
const userId = token ? sessions.resolve(decodeURIComponent(token)) : null;
|
||||
if (!userId) return new Response('unauthorized', { status: 401 });
|
||||
return gateway.upgrade(req, srv, userId);
|
||||
}
|
||||
if (url.pathname.startsWith('/api/') || url.pathname === '/healthz') return app.fetch(req);
|
||||
if (url.pathname.startsWith('/internal/')) return new Response('not found', { status: 404 });
|
||||
if (serveStatic) return serveFile(staticDir!, url.pathname);
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
websocket: {
|
||||
...gateway.handlers,
|
||||
idleTimeout: 40,
|
||||
sendPings: true,
|
||||
perMessageDeflate: true,
|
||||
maxPayloadLength: 64 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
const sweeper = setInterval(() => {
|
||||
try {
|
||||
rooms.sweep();
|
||||
sessions.purgeExpired();
|
||||
store.purgeOldLogs();
|
||||
} catch (err) {
|
||||
log('error', 'sweep failed', { err: String(err) });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const stop = async () => {
|
||||
ready = false;
|
||||
clearInterval(sweeper);
|
||||
gateway.shutdown();
|
||||
rooms.shutdown();
|
||||
await server.stop(true);
|
||||
db.close();
|
||||
};
|
||||
|
||||
return { server, stop, rooms, users, sessions, db, gateway };
|
||||
}
|
||||
|
||||
async function serveFile(root: string, pathname: string): Promise<Response> {
|
||||
const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
|
||||
const candidate = join(root, safe);
|
||||
if (candidate.startsWith(root) && !candidate.endsWith('/')) {
|
||||
const f = Bun.file(candidate);
|
||||
if (await f.exists()) {
|
||||
const immutable = safe.startsWith('/assets/');
|
||||
return new Response(f, { headers: { 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' } });
|
||||
}
|
||||
}
|
||||
// SPA fallback.
|
||||
return new Response(Bun.file(join(root, 'index.html')), { headers: { 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
}
|
||||
205
apps/server/src/ws/gateway.ts
Normal file
205
apps/server/src/ws/gateway.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/** WebSocket gateway: auth on upgrade, validation, rate limits, dispatch (docs/03). */
|
||||
import type { Server, ServerWebSocket } from 'bun';
|
||||
import { ClientMessage, MAX_CLIENT_MESSAGE_BYTES, PROTOCOL, type PublicUser, type ServerMessage } from '@bg/shared';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
import type { Conn, Room } from '../rooms/room';
|
||||
import { TokenBucket } from './rate-limit';
|
||||
|
||||
export interface WsData {
|
||||
userId: string;
|
||||
connId: number;
|
||||
bucket: TokenBucket;
|
||||
strikes: number;
|
||||
room: string | null;
|
||||
conn: Conn | null;
|
||||
}
|
||||
|
||||
export interface GatewayDeps {
|
||||
rooms: RoomManager;
|
||||
me(userId: string): PublicUser | null;
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const MAX_CONNS_PER_USER = 5;
|
||||
const MAX_BUFFERED = 1024 * 1024;
|
||||
|
||||
export class Gateway {
|
||||
private nextId = 1;
|
||||
private byUser = new Map<string, Set<ServerWebSocket<WsData>>>();
|
||||
private closing = false;
|
||||
|
||||
constructor(private deps: GatewayDeps) {}
|
||||
|
||||
get connectionCount(): number {
|
||||
let n = 0;
|
||||
for (const s of this.byUser.values()) n += s.size;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Called from fetch() after the session was resolved. Returns a Response on refusal. */
|
||||
upgrade(req: Request, server: Server<WsData>, userId: string): Response | undefined {
|
||||
if (this.closing) return new Response('restarting', { status: 503 });
|
||||
if ((this.byUser.get(userId)?.size ?? 0) >= MAX_CONNS_PER_USER) return new Response('too many connections', { status: 429 });
|
||||
const data: WsData = { userId, connId: this.nextId++, bucket: new TokenBucket(20, 40), strikes: 0, room: null, conn: null };
|
||||
if (server.upgrade(req, { data })) return undefined;
|
||||
return new Response('upgrade failed', { status: 400 });
|
||||
}
|
||||
|
||||
private send(ws: ServerWebSocket<WsData>, msg: ServerMessage): void {
|
||||
if (ws.getBufferedAmount() > MAX_BUFFERED) {
|
||||
ws.close(1013, 'slow consumer');
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
private connFor(ws: ServerWebSocket<WsData>): Conn {
|
||||
ws.data.conn ??= {
|
||||
userId: ws.data.userId,
|
||||
send: (m) => this.send(ws, m),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
};
|
||||
return ws.data.conn;
|
||||
}
|
||||
|
||||
readonly handlers = {
|
||||
open: (ws: ServerWebSocket<WsData>) => {
|
||||
const set = this.byUser.get(ws.data.userId) ?? new Set();
|
||||
set.add(ws);
|
||||
this.byUser.set(ws.data.userId, set);
|
||||
const me = this.deps.me(ws.data.userId);
|
||||
if (!me) {
|
||||
ws.close(4401, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
this.send(ws, {
|
||||
t: 'welcome',
|
||||
me,
|
||||
serverTime: (this.deps.now ?? Date.now)(),
|
||||
protocol: PROTOCOL,
|
||||
activeRoom: this.deps.rooms.activeRoomFor(ws.data.userId),
|
||||
});
|
||||
},
|
||||
|
||||
message: (ws: ServerWebSocket<WsData>, raw: string | Buffer) => {
|
||||
const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.byteLength;
|
||||
if (size > MAX_CLIENT_MESSAGE_BYTES) {
|
||||
ws.close(1009, 'message too big');
|
||||
return;
|
||||
}
|
||||
if (!ws.data.bucket.take()) {
|
||||
if (++ws.data.strikes > 50) ws.close(4429, 'rate limited');
|
||||
return;
|
||||
}
|
||||
let parsed: ClientMessage;
|
||||
try {
|
||||
const json = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
||||
const r = ClientMessage.safeParse(json);
|
||||
if (!r.success) {
|
||||
this.send(ws, { t: 'error', code: 'bad-message', message: '잘못된 요청이에요.' });
|
||||
return;
|
||||
}
|
||||
parsed = r.data;
|
||||
} catch {
|
||||
this.send(ws, { t: 'error', code: 'bad-json', message: '잘못된 요청이에요.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.dispatch(ws, parsed);
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'dispatch failed', { user: ws.data.userId, t: parsed.t, err: String(err), stack: (err as Error).stack });
|
||||
this.send(ws, { t: 'error', code: 'internal', message: '알 수 없는 오류가 발생했어요.' });
|
||||
}
|
||||
},
|
||||
|
||||
close: (ws: ServerWebSocket<WsData>) => {
|
||||
this.byUser.get(ws.data.userId)?.delete(ws);
|
||||
if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId);
|
||||
const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (room && ws.data.conn) room.disconnect(ws.data.conn);
|
||||
},
|
||||
};
|
||||
|
||||
private dispatch(ws: ServerWebSocket<WsData>, m: ClientMessage): void {
|
||||
const u = ws.data.userId;
|
||||
const err = (message: string, code = 'rejected') => this.send(ws, { t: 'error', code, message });
|
||||
if (m.t === 'ping') {
|
||||
this.send(ws, { t: 'pong', ts: m.ts, serverTime: (this.deps.now ?? Date.now)() });
|
||||
return;
|
||||
}
|
||||
if (m.t === 'join') {
|
||||
const room = this.deps.rooms.get(m.code);
|
||||
if (!room) return err('방을 찾을 수 없어요. 코드를 다시 확인해 주세요.', 'room-not-found');
|
||||
// Leaving a previous room on this socket.
|
||||
if (ws.data.room && ws.data.room !== m.code) {
|
||||
const prev = this.deps.rooms.get(ws.data.room);
|
||||
if (prev && ws.data.conn) prev.disconnect(ws.data.conn);
|
||||
}
|
||||
if (m.as === 'player') this.deps.rooms.releaseOtherLobbySeats(u, m.code);
|
||||
const e = room.join(this.connFor(ws), m.as);
|
||||
if (e) return err(e, 'join-failed');
|
||||
ws.data.room = m.code;
|
||||
return;
|
||||
}
|
||||
const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room');
|
||||
let e: string | null = null;
|
||||
switch (m.t) {
|
||||
case 'leave':
|
||||
room.leave(u);
|
||||
ws.data.room = null;
|
||||
return;
|
||||
case 'seat':
|
||||
e = room.seat(u, m.seat);
|
||||
break;
|
||||
case 'unseat':
|
||||
e = room.unseat(u);
|
||||
break;
|
||||
case 'ready':
|
||||
e = room.ready(u, m.ready);
|
||||
break;
|
||||
case 'config': {
|
||||
const { t: _t, ...patch } = m;
|
||||
e = room.configure(u, patch);
|
||||
break;
|
||||
}
|
||||
case 'start':
|
||||
e = room.start(u);
|
||||
break;
|
||||
case 'kick':
|
||||
e = room.kick(u, m.userId);
|
||||
break;
|
||||
case 'host':
|
||||
e = room.transferHost(u, m.userId);
|
||||
break;
|
||||
case 'act':
|
||||
room.act(u, m.cs, m.a);
|
||||
return;
|
||||
case 'chat':
|
||||
e = room.chat(u, m.text);
|
||||
break;
|
||||
case 'emote':
|
||||
room.emote(u, m.id);
|
||||
return;
|
||||
case 'rematch':
|
||||
e = room.rematch(u);
|
||||
break;
|
||||
case 'sync':
|
||||
room.sendSync(this.connFor(ws));
|
||||
return;
|
||||
}
|
||||
if (e) err(e);
|
||||
}
|
||||
|
||||
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
|
||||
shutdown(): void {
|
||||
this.closing = true;
|
||||
for (const set of this.byUser.values()) {
|
||||
for (const ws of set) {
|
||||
this.send(ws, { t: 'bye', reason: 'restart' });
|
||||
ws.close(1012, 'restart');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/server/src/ws/rate-limit.ts
Normal file
45
apps/server/src/ws/rate-limit.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** Token bucket (docs/03 §8). */
|
||||
export class TokenBucket {
|
||||
private tokens: number;
|
||||
private last: number;
|
||||
constructor(
|
||||
private ratePerSec: number,
|
||||
private burst: number,
|
||||
now = Date.now(),
|
||||
) {
|
||||
this.tokens = burst;
|
||||
this.last = now;
|
||||
}
|
||||
|
||||
take(now = Date.now()): boolean {
|
||||
this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.ratePerSec);
|
||||
this.last = now;
|
||||
if (this.tokens < 1) return false;
|
||||
this.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed-window counter per key (HTTP endpoints). */
|
||||
export class WindowLimiter {
|
||||
private hits = new Map<string, { start: number; n: number }>();
|
||||
constructor(
|
||||
private limit: number,
|
||||
private windowMs: number,
|
||||
) {}
|
||||
|
||||
allow(key: string, now = Date.now()): boolean {
|
||||
const h = this.hits.get(key);
|
||||
if (!h || now - h.start >= this.windowMs) {
|
||||
this.hits.set(key, { start: now, n: 1 });
|
||||
if (this.hits.size > 50_000) this.prune(now);
|
||||
return true;
|
||||
}
|
||||
h.n++;
|
||||
return h.n <= this.limit;
|
||||
}
|
||||
|
||||
private prune(now: number): void {
|
||||
for (const [k, v] of this.hits) if (now - v.start >= this.windowMs) this.hits.delete(k);
|
||||
}
|
||||
}
|
||||
13
apps/server/tsconfig.json
Normal file
13
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@bg/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bg/shared": "workspace:*",
|
||||
"qrcode-generator": "2.0.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-router": "8.4.0",
|
||||
"zustand": "5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"vite": "8.3.2"
|
||||
}
|
||||
}
|
||||
196
bun.lock
Normal file
196
bun.lock
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "boardgame-site",
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.2",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@bg/server",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@bg/engine": "workspace:*",
|
||||
"@bg/games": "workspace:*",
|
||||
"@bg/shared": "workspace:*",
|
||||
"hono": "4.13.12",
|
||||
"ulid": "3.0.2",
|
||||
"zod": "4.6.5",
|
||||
},
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@bg/web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@bg/shared": "workspace:*",
|
||||
"qrcode-generator": "2.0.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-router": "8.4.0",
|
||||
"zustand": "5.0.15",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"vite": "8.3.2",
|
||||
},
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@bg/engine",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"zod": "4.6.5",
|
||||
},
|
||||
},
|
||||
"packages/games": {
|
||||
"name": "@bg/games",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@bg/engine": "workspace:*",
|
||||
"zod": "4.6.5",
|
||||
},
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@bg/shared",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"zod": "4.6.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@bg/engine": ["@bg/engine@workspace:packages/engine"],
|
||||
|
||||
"@bg/games": ["@bg/games@workspace:packages/games"],
|
||||
|
||||
"@bg/server": ["@bg/server@workspace:apps/server"],
|
||||
|
||||
"@bg/shared": ["@bg/shared@workspace:packages/shared"],
|
||||
|
||||
"@bg/web": ["@bg/web@workspace:apps/web"],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="],
|
||||
|
||||
"@remix-run/route-pattern": ["@remix-run/route-pattern@0.22.1", "", {}, "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||
|
||||
"@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"hono": ["hono@4.13.12", "", {}, "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="],
|
||||
|
||||
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||
|
||||
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
||||
|
||||
"react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="],
|
||||
|
||||
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="],
|
||||
|
||||
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="],
|
||||
|
||||
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
|
||||
"zustand": ["zustand@5.0.15", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="],
|
||||
}
|
||||
}
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
root = "."
|
||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "boardgame-site",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev",
|
||||
"build": "bun run --filter '@bg/web' build",
|
||||
"typecheck": "tsc -p packages/engine && tsc -p packages/shared && tsc -p packages/games && tsc -p apps/server && tsc -p apps/web",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.2",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
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"]
|
||||
}
|
||||
8
packages/games/package.json
Normal file
8
packages/games/package.json
Normal 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" }
|
||||
}
|
||||
6
packages/games/src/index.ts
Normal file
6
packages/games/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { omok } from './omok';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
};
|
||||
518
packages/games/src/omok/index.ts
Normal file
518
packages/games/src/omok/index.ts
Normal 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) }));
|
||||
},
|
||||
};
|
||||
265
packages/games/src/omok/omok.test.ts
Normal file
265
packages/games/src/omok/omok.test.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
222
packages/games/src/omok/rules.ts
Normal file
222
packages/games/src/omok/rules.ts
Normal 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;
|
||||
}
|
||||
13
packages/games/tsconfig.json
Normal file
13
packages/games/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
8
packages/shared/package.json
Normal file
8
packages/shared/package.json
Normal 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" }
|
||||
}
|
||||
17
packages/shared/src/catalog.ts
Normal file
17
packages/shared/src/catalog.ts
Normal 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);
|
||||
4
packages/shared/src/index.ts
Normal file
4
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './protocol';
|
||||
export * from './nickname';
|
||||
export * from './room-code';
|
||||
export * from './catalog';
|
||||
48
packages/shared/src/nickname.ts
Normal file
48
packages/shared/src/nickname.ts
Normal 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}`;
|
||||
}
|
||||
117
packages/shared/src/protocol.ts
Normal file
117
packages/shared/src/protocol.ts
Normal 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' };
|
||||
20
packages/shared/src/room-code.ts
Normal file
20
packages/shared/src/room-code.ts
Normal 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)}`;
|
||||
}
|
||||
5
packages/shared/tsconfig.json
Normal file
5
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src", "lib": ["ES2023", "DOM"] },
|
||||
"include": ["src"]
|
||||
}
|
||||
20
tsconfig.base.json
Normal file
20
tsconfig.base.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user