M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구 - packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그 - packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개) - apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
187
apps/server/src/rooms/manager.ts
Normal file
187
apps/server/src/rooms/manager.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
|
||||
import { ulid } from 'ulid';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
|
||||
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
|
||||
import type { RoomStore } from './store';
|
||||
|
||||
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;
|
||||
|
||||
export interface ManagerDeps {
|
||||
store: RoomStore;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
users: { publicUser(id: string): PublicUser | null };
|
||||
now?: () => number;
|
||||
log?: RoomDeps['log'];
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
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,
|
||||
onSeatChange: (room, userId, seated) => {
|
||||
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];
|
||||
if (!def) return '없는 게임이에요.';
|
||||
if (this.byCode.size >= MAX_ROOMS) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
|
||||
const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c));
|
||||
if (mine.length >= MAX_ROOMS_PER_USER) return `방은 한 번에 ${MAX_ROOMS_PER_USER}개까지 만들 수 있어요.`;
|
||||
const config: RoomConfig = {
|
||||
gameId,
|
||||
options: def.defaultOptions,
|
||||
maxPlayers: def.playersFor ? def.playersFor(def.defaultOptions).max : def.maxPlayers,
|
||||
visibility,
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
publicRooms(gameId: string): { code: string; host: string; seated: number; max: number; options: unknown }[] {
|
||||
return this.all()
|
||||
.filter((r) => r.config.gameId === gameId && r.config.visibility === 'public' && r.status === 'lobby')
|
||||
.map((r) => ({
|
||||
code: r.code,
|
||||
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();
|
||||
for (const room of this.all()) {
|
||||
if (room.conns.size > 0) continue;
|
||||
const idle = now - room.lastActiveAt;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user