diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4ba6ec8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +insert_final_newline = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07b87b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +*.tsbuildinfo +data/ +.env +*.db +*.db-wal +*.db-shm +test-results/ +playwright-report/ diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..6804837 --- /dev/null +++ b/apps/server/package.json @@ -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" + } +} diff --git a/apps/server/src/auth/discord.ts b/apps/server/src/auth/discord.ts new file mode 100644 index 0000000..accf7f7 --- /dev/null +++ b/apps/server/src/auth/discord.ts @@ -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 { + 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; +} diff --git a/apps/server/src/auth/sessions.ts b/apps/server/src/auth/sessions.ts new file mode 100644 index 0000000..865f0cc --- /dev/null +++ b/apps/server/src/auth/sessions.ts @@ -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 { + const mac = await hmac(secret, value); + return `${Buffer.from(value).toString('base64url')}.${mac}`; +} + +export async function unsignValue(secret: string, signed: string): Promise { + 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 { + 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'); +} diff --git a/apps/server/src/auth/users.ts b/apps/server/src/auth/users.ts new file mode 100644 index 0000000..900b061 --- /dev/null +++ b/apps/server/src/auth/users.ts @@ -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('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( + `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 }): 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, + }; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..eb5d388 --- /dev/null +++ b/apps/server/src/config.ts @@ -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 = 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://'), + }; +} diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts new file mode 100644 index 0000000..acefb61 --- /dev/null +++ b/apps/server/src/db/index.ts @@ -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()); + })(); + } +} diff --git a/apps/server/src/db/migrations/001_init.sql b/apps/server/src/db/migrations/001_init.sql new file mode 100644 index 0000000..5a884d5 --- /dev/null +++ b/apps/server/src/db/migrations/001_init.sql @@ -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) +); diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts new file mode 100644 index 0000000..6dd7f27 --- /dev/null +++ b/apps/server/src/http/app.ts @@ -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; + 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(); + 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) => c.get('userId'); + + const startSession = (c: Context, 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; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..9d2e203 --- /dev/null +++ b/apps/server/src/index.ts @@ -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); +}); diff --git a/apps/server/src/rooms/manager.ts b/apps/server/src/rooms/manager.ts new file mode 100644 index 0000000..205b9a5 --- /dev/null +++ b/apps/server/src/rooms/manager.ts @@ -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; + users: { publicUser(id: string): PublicUser | null }; + now?: () => number; + log?: RoomDeps['log']; + random?: () => number; +} + +export class RoomManager { + private byCode = new Map(); + /** userId → room code where the user holds a seat. */ + private seatIndex = new Map>(); + private createdBy = new Map>(); + 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(); + 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(); + } +} diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts new file mode 100644 index 0000000..79bdbab --- /dev/null +++ b/apps/server/src/rooms/room.ts @@ -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; + rematchVotes: string[]; + lastSeed: string | null; + lastResult: GameResult | null; + game: GameSlot | null; + joinedAt: Record; +} + +export interface RoomDeps { + store: RoomStore; + games: Record; + users: { publicUser(id: string): PublicUser | null }; + now?: () => number; + log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => 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(); + presence = new Map(); + conns = new Map(); + kicked = new Set(); + gameNo = 0; + sessionStats: Record = {}; + rematchVotes = new Set(); + lastSeed: string | null = null; + lastResult: GameResult | null = null; + game: GameSlot | null = null; + chatLog: Extract[] = []; + lastActiveAt: number; + private timer: ReturnType | null = null; + private recentCs = new Map(); + private lastChatAt = new Map(); + 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 = {}; + 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> & { 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)[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 = { + 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(); + 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'); + } + } +} diff --git a/apps/server/src/rooms/store.ts b/apps/server/src/rooms/store.ts new file mode 100644 index 0000000..ff7838c --- /dev/null +++ b/apps/server/src/rooms/store.ts @@ -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( + "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); + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts new file mode 100644 index 0000000..e3c3f14 --- /dev/null +++ b/apps/server/src/server.ts @@ -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) => 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 = Bun.serve({ + 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 { + 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' } }); +} diff --git a/apps/server/src/ws/gateway.ts b/apps/server/src/ws/gateway.ts new file mode 100644 index 0000000..52097e4 --- /dev/null +++ b/apps/server/src/ws/gateway.ts @@ -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) => void; +} + +export const MAX_CONNS_PER_USER = 5; +const MAX_BUFFERED = 1024 * 1024; + +export class Gateway { + private nextId = 1; + private byUser = new Map>>(); + 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, 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, msg: ServerMessage): void { + if (ws.getBufferedAmount() > MAX_BUFFERED) { + ws.close(1013, 'slow consumer'); + return; + } + ws.send(JSON.stringify(msg)); + } + + private connFor(ws: ServerWebSocket): 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) => { + 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, 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) => { + 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, 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'); + } + } + } +} diff --git a/apps/server/src/ws/rate-limit.ts b/apps/server/src/ws/rate-limit.ts new file mode 100644 index 0000000..d09541c --- /dev/null +++ b/apps/server/src/ws/rate-limit.ts @@ -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(); + 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); + } +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..8522f2e --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "bun" + ] + }, + "include": [ + "src" + ] +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..567176a --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..6105987 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..98c2e52 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "." diff --git a/package.json b/package.json new file mode 100644 index 0000000..197d8b2 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 0000000..ab28c28 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,8 @@ +{ + "name": "@bg/engine", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "dependencies": { "zod": "4.6.5" } +} diff --git a/packages/engine/src/grid.ts b/packages/engine/src/grid.ts new file mode 100644 index 0000000..1582fbd --- /dev/null +++ b/packages/engine/src/grid.ts @@ -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( + 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}`; +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 0000000..7924724 --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,5 @@ +export * from './rng'; +export * from './types'; +export * from './grid'; +export * from './turns'; +export * from './testing'; diff --git a/packages/engine/src/rng.test.ts b/packages/engine/src/rng.test.ts new file mode 100644 index 0000000..6c41bf1 --- /dev/null +++ b/packages/engine/src/rng.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/rng.ts b/packages/engine/src/rng.ts new file mode 100644 index 0000000..299a9fe --- /dev/null +++ b/packages/engine/src/rng.ts @@ -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(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(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; + } +} diff --git a/packages/engine/src/testing.ts b/packages/engine/src/testing.ts new file mode 100644 index 0000000..50493b7 --- /dev/null +++ b/packages/engine/src/testing.ts @@ -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 { + 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(def: AnyGameDefinition, opts: PlayoutOptions): 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]; +} diff --git a/packages/engine/src/turns.ts b/packages/engine/src/turns.ts new file mode 100644 index 0000000..f286320 --- /dev/null +++ b/packages/engine/src/turns.ts @@ -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; +} diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts new file mode 100644 index 0000000..904230a --- /dev/null +++ b/packages/engine/src/types.ts @@ -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; + /** Korean, user-facing summary line. */ + summary: string; + reason: ResultReason; +} + +export type Validation = { ok: true } | { ok: false; reason: string }; + +export interface SetupContext { + 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 { + id: string; + nameKo: string; + minPlayers: number; + maxPlayers: number; + stateVersion: number; + defaultOptions: O; + optionsSchema: ZodType; + actionSchema: ZodType; + playersFor?(options: O): { min: number; max: number }; + setup(ctx: SetupContext): 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; + +export const ok: Validation = { ok: true }; +export const fail = (reason: string): Validation => ({ ok: false, reason }); diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 0000000..abe8385 --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src", "types": ["bun"] }, + "include": ["src"] +} diff --git a/packages/games/package.json b/packages/games/package.json new file mode 100644 index 0000000..54fd90e --- /dev/null +++ b/packages/games/package.json @@ -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" } +} diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts new file mode 100644 index 0000000..0488b38 --- /dev/null +++ b/packages/games/src/index.ts @@ -0,0 +1,6 @@ +import type { AnyGameDefinition } from '@bg/engine'; +import { omok } from './omok'; + +export const GAMES: Record = { + [omok.id]: omok, +}; diff --git a/packages/games/src/omok/index.ts b/packages/games/src/omok/index.ts new file mode 100644 index 0000000..1aa52ec --- /dev/null +++ b/packages/games/src/omok/index.ts @@ -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 = { black: 1, white: 2 }; +const other = (c: Color): Color => (c === 'black' ? 'white' : 'black'); +const COLOR_KO: Record = { 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; + +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 | { 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; + turn: Color; + moves: { color: Color; pt: Pt; at: number; auto: boolean }[]; + phase: 'playing' | 'finished'; + winLine: Pt[] | null; + clock: { remainingMs: Record; turnStartedAt: number; autoMoveStreak: Record }; + undo: { usedBy: Record; pending: { by: Color; plies: 1 | 2 } | null }; + draw: { pendingBy: Color | null; lastOfferPly: Record }; + /** 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; + 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; + turnStartedAt: number; + undo: { allowed: boolean; remaining: Record; 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 = { + id: 'omok', + nameKo: '오목', + minPlayers: 2, + maxPlayers: 2, + stateVersion: 1, + defaultOptions: omokOptionsSchema.parse({}), + optionsSchema: omokOptionsSchema, + actionSchema: omokActionSchema as z.ZodType, + + 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(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 = { + 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) })); + }, +}; diff --git a/packages/games/src/omok/omok.test.ts b/packages/games/src/omok/omok.test.ts new file mode 100644 index 0000000..2d85d45 --- /dev/null +++ b/packages/games/src/omok/omok.test.ts @@ -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 = {}, 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)); + } + } + }); +}); diff --git a/packages/games/src/omok/rules.ts b/packages/games/src/omok/rules.ts new file mode 100644 index 0000000..74fe4a4 --- /dev/null +++ b/packages/games/src/omok/rules.ts @@ -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(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 { + const sets = new Map(); + 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; +} diff --git a/packages/games/tsconfig.json b/packages/games/tsconfig.json new file mode 100644 index 0000000..8522f2e --- /dev/null +++ b/packages/games/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "bun" + ] + }, + "include": [ + "src" + ] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..c196f37 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,8 @@ +{ + "name": "@bg/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "dependencies": { "zod": "4.6.5" } +} diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts new file mode 100644 index 0000000..a9cb842 --- /dev/null +++ b/packages/shared/src/catalog.ts @@ -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); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..0d11d6d --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from './protocol'; +export * from './nickname'; +export * from './room-code'; +export * from './catalog'; diff --git a/packages/shared/src/nickname.ts b/packages/shared/src/nickname.ts new file mode 100644 index 0000000..cfb8b39 --- /dev/null +++ b/packages/shared/src/nickname.ts @@ -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}`; +} diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts new file mode 100644 index 0000000..11005d0 --- /dev/null +++ b/packages/shared/src/protocol.ts @@ -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; + +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; + seedHash: string | null; + /** Seed revealed after the game for fairness check. */ + lastSeed: string | null; +} + +export interface GameResultView { + ranking: string[][]; + scores?: Record; + 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' }; diff --git a/packages/shared/src/room-code.ts b/packages/shared/src/room-code.ts new file mode 100644 index 0000000..4bad58b --- /dev/null +++ b/packages/shared/src/room-code.ts @@ -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)}`; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..053fb8b --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src", "lib": ["ES2023", "DOM"] }, + "include": ["src"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..bfe892a --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +}