Files
joke-app/apps/server/src/rooms/manager.ts
EJClaw f5671cda70 플레이어가 모두 사이트를 닫으면 게임 중·끝난 방을 1분 뒤 닫기(관전자는 셈하지 않음)
- 게이트웨이가 사용자별 '마지막 소켓이 닫힌 시각'을 기록(offlineSince). 소켓이 하나라도 열려 있으면 사이트에 있는 것
- sweep: 대기실이 아닌 방에서 자리에 앉은 사람(컴퓨터·게임 중 나간 사람 제외)이 모두 사이트를 떠난 지 1분이면
  게임을 무효로 끝내고 방을 닫는다. 관전자가 접속해 있어도 닫고 내보낸다. 대기실은 초대 입장 때문에 기존 규칙 유지
- 홈 등 다른 화면에서 새로 연 창도 자리가 있으면(/api/me activeRoom) 소켓을 연결해 '사이트 열림'으로 남게
- Room.closeByAdmin을 closeWith(summary, notice)로 일반화
- e2e(bots): 오목은 컴퓨터가 3번만 받게(이후 단계 전에 게임이 끝나지 않게), 홈 단계를 원카드 앞으로(턴 시간 초과 방지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:47:22 +09:00

260 lines
10 KiB
TypeScript

/** 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<string, AnyGameDefinition>;
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<string, Room>();
/** userId → room code where the user holds a seat. */
private seatIndex = new Map<string, Set<string>>();
private createdBy = new Map<string, Set<string>>();
private readonly now: () => number;
constructor(private deps: ManagerDeps) {
this.now = deps.now ?? Date.now;
}
private roomDeps(): RoomDeps {
return {
store: this.deps.store,
games: this.deps.games,
users: this.deps.users,
now: this.now,
log: this.deps.log,
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<string>();
if (seated) set.add(room.code);
else set.delete(room.code);
if (set.size) this.seatIndex.set(userId, set);
else this.seatIndex.delete(userId);
},
};
}
get(code: string): Room | undefined {
return this.byCode.get(code);
}
all(): Room[] {
return [...this.byCode.values()];
}
private newCode(): string {
const rand = this.deps.random ?? Math.random;
for (let i = 0; i < 200; i++) {
const code = String(Math.floor(rand() * 1_000_000)).padStart(6, '0');
if (!isAllowedRoomCode(code) || this.byCode.has(code)) continue;
if (this.deps.store.codeInUse(code, this.now())) continue;
return code;
}
throw new Error('could not allocate room code');
}
/** Returns the room or a Korean error message. */
create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string {
const def = this.deps.games[gameId];
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();
}
}