/** * 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'; import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings'; 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; /** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */ settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings }; } 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(); /** Players who left during a game; their seat is freed when the game ends. */ leftDuringGame = 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 } { return this.rangeFor(this.config.gameId, this.config.options); } /** Game's own range (per options) intersected with the admin-configured range. */ private rangeFor(gameId: string, options: unknown): { min: number; max: number } { const d = this.deps.games[gameId]!; const base = d.playersFor ? d.playersFor(options) : { min: d.minPlayers, max: d.maxPlayers }; const eff = this.deps.settings?.game(gameId); if (!eff) return base; const min = Math.max(base.min, eff.minPlayers); const max = Math.min(base.max, eff.maxPlayers); return min <= max ? { min, max } : base; } private locked(gameId: string): { defaults: unknown; paths: string[] } { const eff = this.deps.settings?.game(gameId); return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] }; } 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; } // 30s to reconnect, plus up to 30s jitter so thousands of restored rooms don't all time out // in the same instant (thundering herd after a restart, docs/04 §3). room.game.deadlineFloor = now + 30_000 + Math.floor(Math.random() * 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, lockedOptions: this.locked(this.config.gameId).paths, 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.leftDuringGame.delete(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)) { // Stay in the seat as "away" until the game ends so the game state stays consistent. this.leftDuringGame.add(userId); this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 }); const action = this.def.onLeave?.(this.game!.state, userId); if (action) this.runAction(userId, action, undefined, true); } 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]; const eff = this.deps.settings?.game(patch.gameId); if (!def || (eff && !eff.enabled)) return '없는 게임이에요.'; next.gameId = patch.gameId; next.options = eff?.defaultOptions ?? def.defaultOptions; next.maxPlayers = eff?.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; } // Admin-locked options always keep the admin value (docs/14-admin.md §4). const lk = this.locked(next.gameId); if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths)); const range = this.rangeFor(next.gameId, next.options); if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers; // A room that had every seat its game allows keeps doing so when an option changes the range (e.g. 윷놀이 팀전 8명). else if (next.gameId === this.config.gameId && this.config.maxPlayers === this.playerRange().max) next.maxPlayers = range.max; 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, this.deps.settings?.getSite().bannedWords ?? []) : 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 lk = this.locked(def.id); const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options); if (!parsed.success) return '설정 값이 올바르지 않아요.'; const countErr = def.playerCountError?.(parsed.data, players.length); if (countErr) return countErr; const eff = this.deps.settings?.game(def.id); if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.'; this.config = { ...this.config, options: parsed.data }; 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.leftDuringGame.has(s.userId)) { this.seats[i] = null; this.presence.delete(s.userId); this.deps.onSeatChange?.(this, s.userId, false); } } this.leftDuringGame.clear(); 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 notBefore = def.timeoutNotBefore?.(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); if (notBefore !== null) d = Math.max(d, notBefore); 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 notBefore = def.timeoutNotBefore?.(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 (notBefore !== null && d !== null) d = Math.max(d, notBefore); 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); } /** Admin closes the room: void any running game, tell everyone, disconnect. */ closeByAdmin(): void { if (this.game && !this.game.finished) { this.finishGame({ ranking: [this.game.players], summary: '관리자가 방을 닫아 무효 처리됐어요.', reason: 'abandoned' }, true); } this.notice('closed', '관리자가 방을 닫았어요.'); this.clearTimer(); for (const c of this.conns.values()) { c.send({ t: 'bye', reason: 'closed' }); c.close(4002, 'closed'); } this.conns.clear(); } shutdown(): void { this.clearTimer(); for (const c of this.conns.values()) { c.send({ t: 'bye', reason: 'restart' }); c.close(1012, 'restart'); } } }