/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */ import { ulid } from 'ulid'; import type { AnyGameDefinition } from '@bg/engine'; import { botsAllowed, isAllowedRoomCode, isBotId, type PublicUser } from '@bg/shared'; import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room'; import type { RoomStore } from './store'; import type { SettingsStore } from '../admin/settings'; 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; /** Rooms with computer players nobody is looking at: closed this long after the last person went away. */ const PRACTICE_IDLE_TTL = 60_000; const PRACTICE_GAME_TTL = 30 * 60_000; /** Every player closed the site (spectators aside): the room closes this long after the last one went offline. */ const PLAYERS_GONE_TTL = 60_000; export interface ManagerDeps { store: RoomStore; games: Record; users: { publicUser(id: string): PublicUser | null }; now?: () => number; log?: RoomDeps['log']; random?: () => number; settings?: SettingsStore; botDelay?: RoomDeps['botDelay']; /** When the user's last socket to the site closed; null while the site is open somewhere (the gateway knows). */ offlineSince?: (userId: string) => number | null; } 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, settings: this.deps.settings, botDelay: this.deps.botDelay, onEmpty: (room, why) => { // Everyone left a room with computer players on purpose: nobody can come back to it, close it now. if (why === 'leave' && this.byCode.get(room.code) === room && room.hasBots() && !room.hasPeopleSeated()) this.close(room); }, onSeatChange: (room, userId, seated) => { if (isBotId(userId)) return; 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]; const eff = this.deps.settings?.game(gameId); const site = this.deps.settings?.getSite(); if (!def || (eff && !eff.enabled)) return '없는 게임이에요.'; if (site?.maintenance) return site.announcement || '지금은 점검 중이라 새 방을 만들 수 없어요.'; const maxRooms = site?.maxRooms ?? MAX_ROOMS; const perUser = site?.maxRoomsPerUser ?? MAX_ROOMS_PER_USER; if (this.byCode.size >= maxRooms) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.'; const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c)); if (mine.length >= perUser) return `방은 한 번에 ${perUser}개까지 만들 수 있어요.`; const options = eff?.defaultOptions ?? def.defaultOptions; const base = def.playersFor ? def.playersFor(options).max : def.maxPlayers; const config: RoomConfig = { gameId, options, maxPlayers: Math.min(base, eff?.maxPlayers ?? base), visibility, allowSpectators: true, chatEnabled: site?.chatEnabledDefault ?? true, chatFilter: site?.chatFilterDefault ?? true, graceSec: site?.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; } /** * "컴퓨터랑 연습": a private room with just enough computer players to start right away (the host takes the * remaining seat on joining). */ createPractice(hostId: string, gameId: string): Room | string { if (!botsAllowed(gameId)) return '이 게임은 컴퓨터와 할 수 없어요.'; const room = this.create(hostId, gameId, 'private'); if (typeof room === 'string') return room; const def = this.deps.games[gameId]!; const { min, max } = room.playerRange(); let bots = Math.max(1, min - 1); while (bots + 1 < max && def.playerCountError?.(room.config.options, bots + 1)) bots++; // Seat 0 stays free for the host. for (let i = 1; i <= Math.min(bots, room.config.maxPlayers - 1); i++) room.addBot(hostId, i); 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); } } /** Frees every seat the user holds (account removed). */ leaveAll(userId: string): void { for (const code of [...(this.seatIndex.get(userId) ?? [])]) this.byCode.get(code)?.leave(userId); } publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] { return this.all() // Rooms with computer players are practice rooms: never listed, never filled by quick play. .filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby' && !r.hasBots()) .map((r) => ({ code: r.code, gameId: r.config.gameId, 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(); const offlineSince = this.deps.offlineSince ?? (() => 0); for (const room of this.all()) { // A game (running or over) whose players all closed the site: nobody is coming back to it. Lobbies wait for // people arriving by invite link, so they keep the timeouts below. const gone = room.status === 'lobby' ? null : room.playersOfflineSince(offlineSince); if (gone !== null && now - gone >= PLAYERS_GONE_TTL) { room.closeWith('플레이어가 모두 나가서 무효 처리됐어요.', '플레이어가 모두 나가서 방을 닫았어요.'); this.close(room); continue; } if (room.conns.size > 0) continue; const idle = now - room.lastActiveAt; if (room.hasBots()) { // Practice rooms are private and only for the people who made them: once nobody is looking, close soon. const empty = now - (room.emptySince ?? now); const ttl = room.status === 'playing' || room.status === 'paused' ? PRACTICE_GAME_TTL : PRACTICE_IDLE_TTL; if (empty >= ttl) this.close(room); continue; } 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); } } closeByAdmin(code: string): boolean { const room = this.byCode.get(code); if (!room) return false; room.closeByAdmin(); this.close(room); return true; } 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(); } }