- 게이트웨이가 사용자별 '마지막 소켓이 닫힌 시각'을 기록(offlineSince). 소켓이 하나라도 열려 있으면 사이트에 있는 것 - sweep: 대기실이 아닌 방에서 자리에 앉은 사람(컴퓨터·게임 중 나간 사람 제외)이 모두 사이트를 떠난 지 1분이면 게임을 무효로 끝내고 방을 닫는다. 관전자가 접속해 있어도 닫고 내보낸다. 대기실은 초대 입장 때문에 기존 규칙 유지 - 홈 등 다른 화면에서 새로 연 창도 자리가 있으면(/api/me activeRoom) 소켓을 연결해 '사이트 열림'으로 남게 - Room.closeByAdmin을 closeWith(summary, notice)로 일반화 - e2e(bots): 오목은 컴퓨터가 3번만 받게(이후 단계 전에 게임이 끝나지 않게), 홈 단계를 원카드 앞으로(턴 시간 초과 방지) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|