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:
EJClaw
2026-10-04 04:18:29 +09:00
parent f4d63e1544
commit 6ee8da18f1
45 changed files with 4175 additions and 0 deletions

View 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();
}
}