# Conflicts: # apps/server/src/rooms/room.ts # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/engine/src/types.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
907 lines
35 KiB
TypeScript
907 lines
35 KiB
TypeScript
/**
|
|
* 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 { LogEntry, 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<string, { played: number; wins: number }>;
|
|
rematchVotes: string[];
|
|
lastSeed: string | null;
|
|
lastResult: GameResult | null;
|
|
game: GameSlot | null;
|
|
joinedAt: Record<string, number>;
|
|
}
|
|
|
|
export interface RoomDeps {
|
|
store: RoomStore;
|
|
games: Record<string, AnyGameDefinition>;
|
|
users: { publicUser(id: string): PublicUser | null };
|
|
now?: () => number;
|
|
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
|
onEmpty?: (room: Room) => void;
|
|
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
|
/** 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;
|
|
/** Streaming actions (drawing) are logged in batches: at most this long between snapshot writes. */
|
|
const STREAM_FLUSH_MS = 1000;
|
|
|
|
export class Room {
|
|
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
|
|
seq = 0;
|
|
hostId: string;
|
|
seats: (Seat | null)[];
|
|
spectators = new Set<string>();
|
|
presence = new Map<string, Presence>();
|
|
conns = new Map<string, Conn>();
|
|
kicked = new Set<string>();
|
|
/** Players who left during a game; their seat is freed when the game ends. */
|
|
leftDuringGame = new Set<string>();
|
|
gameNo = 0;
|
|
sessionStats: Record<string, { played: number; wins: number }> = {};
|
|
rematchVotes = new Set<string>();
|
|
lastSeed: string | null = null;
|
|
lastResult: GameResult | null = null;
|
|
game: GameSlot | null = null;
|
|
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
|
lastActiveAt: number;
|
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
|
private pendingLogs: LogEntry[] = [];
|
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private recentCs = new Map<string, number[]>();
|
|
private lastChatAt = new Map<string, number>();
|
|
private consecutiveErrors = 0;
|
|
private readonly now: () => number;
|
|
|
|
constructor(
|
|
readonly id: string,
|
|
readonly code: string,
|
|
public config: RoomConfig,
|
|
hostId: string,
|
|
private deps: RoomDeps,
|
|
) {
|
|
this.hostId = hostId;
|
|
this.seats = new Array(config.maxPlayers).fill(null);
|
|
this.now = deps.now ?? Date.now;
|
|
this.lastActiveAt = this.now();
|
|
}
|
|
|
|
get def(): AnyGameDefinition {
|
|
const d = this.deps.games[this.config.gameId];
|
|
if (!d) throw new Error(`unknown game ${this.config.gameId}`);
|
|
return d;
|
|
}
|
|
|
|
playerRange(): { min: number; max: number } {
|
|
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<string, number> = {};
|
|
for (const [u, p] of this.presence) if (this.seatOf(u) >= 0) joinedAt[u] = p.joinedAt;
|
|
return {
|
|
v: 1,
|
|
config: this.config,
|
|
hostId: this.hostId,
|
|
status: this.status,
|
|
seats: this.seats,
|
|
gameNo: this.gameNo,
|
|
sessionStats: this.sessionStats,
|
|
rematchVotes: [...this.rematchVotes],
|
|
lastSeed: this.lastSeed,
|
|
lastResult: this.lastResult,
|
|
game: this.game,
|
|
joinedAt,
|
|
};
|
|
}
|
|
|
|
static restore(id: string, code: string, seq: number, snap: RoomSnapshot, deps: RoomDeps): Room {
|
|
const room = new Room(id, code, snap.config, snap.hostId, deps);
|
|
room.seq = seq;
|
|
room.status = snap.status;
|
|
room.seats = snap.seats;
|
|
room.gameNo = snap.gameNo;
|
|
room.sessionStats = snap.sessionStats;
|
|
room.rematchVotes = new Set(snap.rematchVotes);
|
|
room.lastSeed = snap.lastSeed;
|
|
room.lastResult = snap.lastResult;
|
|
room.game = snap.game;
|
|
const now = room.now();
|
|
for (const s of room.seats) {
|
|
if (s) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
|
|
}
|
|
if (room.game && !room.game.finished) {
|
|
const def = room.def;
|
|
if (def.stateVersion !== room.game.stateVersion) {
|
|
if (!def.migrate) throw new Error(`state version mismatch for ${def.id}`);
|
|
room.game.state = def.migrate(room.game.state, room.game.stateVersion);
|
|
room.game.stateVersion = def.stateVersion;
|
|
}
|
|
// 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;
|
|
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
|
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
|
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 },
|
|
logs,
|
|
);
|
|
this.pendingLogs = [];
|
|
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
this.flushTimer = null;
|
|
this.seq = nextSeq;
|
|
this.lastActiveAt = this.now();
|
|
}
|
|
|
|
/**
|
|
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
|
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
|
*/
|
|
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
|
|
this.seq += 1;
|
|
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
|
|
this.lastActiveAt = this.now();
|
|
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
|
}
|
|
|
|
/** Writes pending streamed log entries and the snapshot. Public for tests and shutdown. */
|
|
flushStream(): void {
|
|
this.flushTimer = null;
|
|
if (this.pendingLogs.length) this.persist(null);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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,
|
|
};
|
|
}
|
|
|
|
/** `sync`: full view for (re)join / resync (`syncView`, e.g. the whole drawing). */
|
|
private stateMessage(viewer: string, cs?: number, sync = false): 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 = sync && def.syncView ? def.syncView(this.game.state, asPlayer) : 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, undefined, true);
|
|
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<Omit<RoomConfig, 'graceSec'>> & { gameId?: string },
|
|
): string | null {
|
|
if (userId !== this.hostId) return '방장만 설정을 바꿀 수 있어요.';
|
|
const err = this.requireLobby();
|
|
if (err) return err;
|
|
const next: RoomConfig = { ...this.config };
|
|
if (patch.gameId !== undefined && patch.gameId !== this.config.gameId) {
|
|
const def = this.deps.games[patch.gameId];
|
|
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<string, unknown>)[k] = patch[k];
|
|
}
|
|
// Resize seats, compacting occupied seats to the front if needed.
|
|
if (next.maxPlayers !== this.seats.length) {
|
|
const occupied = this.seats.filter((s): s is Seat => s !== null);
|
|
const seats: (Seat | null)[] = new Array(next.maxPlayers).fill(null);
|
|
let i = 0;
|
|
for (let k = 0; k < this.seats.length && k < next.maxPlayers; k++) if (this.seats[k]) seats[k] = this.seats[k]!;
|
|
const placed = new Set(seats.filter(Boolean).map((s) => s!.userId));
|
|
for (const s of occupied) {
|
|
if (placed.has(s.userId)) continue;
|
|
while (seats[i]) i++;
|
|
seats[i] = s;
|
|
}
|
|
this.seats = seats;
|
|
}
|
|
this.config = next;
|
|
for (const s of this.seats) if (s) s.ready = false;
|
|
this.persist({ actorId: userId, kind: 'config', data: patch });
|
|
this.broadcastRoom();
|
|
return null;
|
|
}
|
|
|
|
kick(userId: string, target: string): string | null {
|
|
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
|
|
if (target === userId) return '자기 자신은 내보낼 수 없어요.';
|
|
if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
|
|
const conn = this.conns.get(target);
|
|
this.kicked.add(target);
|
|
this.leave(target);
|
|
conn?.send({ t: 'bye', reason: 'kicked' });
|
|
conn?.close(4001, 'kicked');
|
|
return null;
|
|
}
|
|
|
|
transferHost(userId: string, target: string): string | null {
|
|
if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
|
|
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
|
|
this.hostId = target;
|
|
this.persist({ actorId: userId, kind: 'host', data: { target } });
|
|
this.broadcastRoom();
|
|
return null;
|
|
}
|
|
|
|
chat(userId: string, text: string): string | null {
|
|
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
|
|
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
|
|
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
|
|
const now = this.now();
|
|
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
|
|
this.lastChatAt.set(userId, now);
|
|
const clean = text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
|
if (!clean) return null;
|
|
const msg: Extract<ServerMessage, { t: 'chat' }> = {
|
|
t: 'chat',
|
|
from: this.deps.users.publicUser(userId),
|
|
text: this.config.chatFilter ? maskProfanity(clean, 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;
|
|
}
|
|
|
|
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
|
|
private gameChatOnly(): boolean {
|
|
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
|
|
}
|
|
|
|
emote(userId: string, id: string): void {
|
|
if (!this.isParticipant(userId) || this.gameChatOnly()) 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);
|
|
const now = this.now();
|
|
try {
|
|
const v = def.validate(g.state, userId, action, { now });
|
|
if (!v.ok) return v.reason;
|
|
next = def.apply(g.state, userId, action, { rng, 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;
|
|
if (!system && def.isStreamAction?.(action)) {
|
|
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
|
this.persistStream({ actorId: userId, kind: 'act', data: action });
|
|
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
|
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
|
} else {
|
|
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
|
this.broadcastState(next.events, userId, cs);
|
|
}
|
|
const result = def.result(g.state);
|
|
if (result) this.finishGame(result);
|
|
else this.scheduleTimer();
|
|
return null;
|
|
}
|
|
|
|
/** Host ends a paused/broken game without counting it. */
|
|
voidGame(userId: string): string | null {
|
|
if (userId !== this.hostId) return '방장만 할 수 있어요.';
|
|
if (!this.game || this.game.finished) return '진행 중인 게임이 없어요.';
|
|
this.finishGame({ ranking: [this.game.players], summary: '게임이 무효 처리됐어요.', reason: 'abandoned' }, true);
|
|
return null;
|
|
}
|
|
|
|
private finishGame(result: GameResult, voided = false): void {
|
|
const g = this.game!;
|
|
g.finished = true;
|
|
this.clearTimer();
|
|
const rankOf = new Map<string, number>();
|
|
let rank = 1;
|
|
for (const group of result.ranking) {
|
|
for (const u of group) rankOf.set(u, rank);
|
|
rank += group.length;
|
|
}
|
|
const players = g.players.map((u) => ({
|
|
userId: u,
|
|
seat: this.seatOf(u),
|
|
rank: rankOf.get(u) ?? null,
|
|
score: result.scores?.[u] ?? null,
|
|
}));
|
|
this.deps.store.finishGame({
|
|
id: g.gamePk,
|
|
gameId: g.gameId,
|
|
seed: g.seedHex,
|
|
endedAt: this.now(),
|
|
endReason: voided ? 'void' : result.reason,
|
|
result,
|
|
players,
|
|
});
|
|
const allFirst = players.every((p) => p.rank === 1);
|
|
if (!voided) {
|
|
for (const p of players) {
|
|
const st = (this.sessionStats[p.userId] ??= { played: 0, wins: 0 });
|
|
st.played++;
|
|
if (p.rank === 1 && !allFirst) st.wins++;
|
|
}
|
|
}
|
|
this.lastSeed = g.seedHex;
|
|
this.lastResult = result;
|
|
this.status = 'finished';
|
|
// Players who left during the game lose their seat now.
|
|
for (let i = 0; i < this.seats.length; i++) {
|
|
const s = this.seats[i];
|
|
if (s && this.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);
|
|
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
|
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 + graceMs;
|
|
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);
|
|
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
|
for (const u of timed) {
|
|
let d = base;
|
|
const p = this.presence.get(u);
|
|
if (p && !p.connected) {
|
|
const grace = p.since + graceMs;
|
|
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, undefined, true);
|
|
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.flushStream();
|
|
this.clearTimer();
|
|
for (const c of this.conns.values()) {
|
|
c.send({ t: 'bye', reason: 'restart' });
|
|
c.close(1012, 'restart');
|
|
}
|
|
}
|
|
}
|