import mitt from "mitt"; import { gameRegistry } from "@shared/games/registry"; import type { Room, RoomSummary } from "./types"; const ROOM_CONFIG = { WAITING_CLEANUP_MS: 60_000, FINISHED_CLEANUP_MS: 60_000, } as const; type ActionResult = | { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null } | { ok: false; error: string }; type CreateResult = { ok: true; roomId: string } | { ok: false; error: string }; type JoinResult = | { ok: true; joinedAs: "player" | "spectator"; started: boolean } | { ok: false; error: string }; type RoomEvents = { "room:created": { roomId: string; gameSlug: string; hostId: string }; "player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" }; "game:started": { roomId: string; spectatorView: unknown; playerViews: Map }; "game:updated": { roomId: string; spectatorView: unknown; playerViews: Map }; "game:ended": { roomId: string; winner: string | null; reason: string }; "player:left": { roomId: string; playerId: string }; "room:list:changed": void; }; export class RoomManager { private rooms = new Map(); private cleanupTimers = new Map(); readonly emitter = mitt(); createRoom(gameSlug: string, hostId: string): CreateResult { const plugin = gameRegistry.get(gameSlug); if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` }; const id = crypto.randomUUID(); const room: Room = { id, gameSlug, host: hostId, players: [hostId], spectators: new Set(), state: null, status: "waiting", createdAt: Date.now(), }; this.rooms.set(id, room); this.scheduleCleanup(id, ROOM_CONFIG.WAITING_CLEANUP_MS); this.emitter.emit("room:created", { roomId: id, gameSlug, hostId }); this.emitter.emit("room:list:changed"); return { ok: true, roomId: id }; } joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult { const room = this.rooms.get(roomId); if (!room) return { ok: false, error: "Room not found" }; if (preferAs === "spectator" || room.status !== "waiting") { room.spectators.add(playerId); return { ok: true, joinedAs: "spectator", started: room.status === "playing" }; } if (room.players.includes(playerId)) { return { ok: true, joinedAs: "player", started: false }; } const plugin = gameRegistry.get(room.gameSlug)!; const isAdmin = role === "admin"; if (room.players.length >= plugin.maxPlayers && !isAdmin) { // Downgrade to spectator — room is full but still waiting, so game hasn't started room.spectators.add(playerId); return { ok: true, joinedAs: "spectator", started: false }; } room.players.push(playerId); if (room.players.length >= plugin.maxPlayers) { room.state = plugin.createInitialState(room.players); room.status = "playing"; this.clearCleanup(roomId); const spectatorView = plugin.getSpectatorView(room.state); const playerViews = new Map(); for (const pid of room.players) { playerViews.set(pid, plugin.getPlayerView(room.state, pid)); } this.emitter.emit("game:started", { roomId, spectatorView, playerViews }); this.emitter.emit("room:list:changed"); return { ok: true, joinedAs: "player", started: true }; } this.emitter.emit("room:list:changed"); return { ok: true, joinedAs: "player", started: false }; } handleAction(roomId: string, playerId: string, action: unknown): ActionResult { const room = this.rooms.get(roomId); if (!room) return { ok: false, error: "Room not found" }; if (room.status !== "playing") return { ok: false, error: "Game is not in progress" }; if (!room.players.includes(playerId)) return { ok: false, error: "You are not a player in this game" }; const plugin = gameRegistry.get(room.gameSlug)!; const result = plugin.handleAction(room.state, action, playerId); if (!result.ok) return result; room.state = result.state; const gameOver = plugin.isGameOver?.(room.state) ?? null; if (gameOver) { room.status = "finished"; this.scheduleCleanup(roomId, ROOM_CONFIG.FINISHED_CLEANUP_MS); } const spectatorView = plugin.getSpectatorView(room.state); const playerViews = new Map(); for (const pid of room.players) { playerViews.set(pid, plugin.getPlayerView(room.state, pid)); } this.emitter.emit("game:updated", { roomId, spectatorView, playerViews }); if (gameOver) { this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason }); this.emitter.emit("room:list:changed"); } return { ok: true, state: room.state, gameOver }; } leaveRoom(roomId: string, playerId: string): void { const room = this.rooms.get(roomId); if (!room) return; room.spectators.delete(playerId); const playerIdx = room.players.indexOf(playerId); if (playerIdx !== -1) { room.players.splice(playerIdx, 1); if (room.status === "playing") { const plugin = gameRegistry.get(room.gameSlug)!; if (plugin.onPlayerDisconnect) { room.state = plugin.onPlayerDisconnect(room.state, playerId); const gameOver = plugin.isGameOver?.(room.state) ?? null; if (gameOver) { room.status = "finished"; this.scheduleCleanup(roomId, ROOM_CONFIG.FINISHED_CLEANUP_MS); this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason }); } } } if (room.players.length === 0 && room.status === "waiting") { this.deleteRoom(roomId); this.emitter.emit("room:list:changed"); return; } } this.emitter.emit("player:left", { roomId, playerId }); this.emitter.emit("room:list:changed"); } fillRoom(roomId: string, adminId: string): { ok: true } | { ok: false; error: string } { const room = this.rooms.get(roomId); if (!room) return { ok: false, error: "Room not found" }; if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" }; if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" }; const plugin = gameRegistry.get(room.gameSlug)!; while (room.players.length < plugin.maxPlayers) { room.players.push(adminId); } room.state = plugin.createInitialState(room.players); room.status = "playing"; this.clearCleanup(roomId); const spectatorView = plugin.getSpectatorView(room.state); const playerViews = new Map(); for (const pid of new Set(room.players)) { playerViews.set(pid, plugin.getPlayerView(room.state, pid)); } this.emitter.emit("game:started", { roomId, spectatorView, playerViews }); this.emitter.emit("room:list:changed"); return { ok: true }; } getRoom(roomId: string): Room | undefined { return this.rooms.get(roomId); } listRooms(gameSlug?: string): RoomSummary[] { const summaries: RoomSummary[] = []; for (const room of this.rooms.values()) { if (gameSlug && room.gameSlug !== gameSlug) continue; const plugin = gameRegistry.get(room.gameSlug); summaries.push({ id: room.id, gameSlug: room.gameSlug, gameName: plugin?.name ?? room.gameSlug, host: room.host, playerCount: room.players.length, maxPlayers: plugin?.maxPlayers ?? 0, spectatorCount: room.spectators.size, status: room.status, }); } return summaries; } getPlayerView(roomId: string, playerId: string): unknown { const room = this.rooms.get(roomId); if (!room || !room.state) return null; const plugin = gameRegistry.get(room.gameSlug)!; return plugin.getPlayerView(room.state, playerId); } getSpectatorView(roomId: string): unknown { const room = this.rooms.get(roomId); if (!room || !room.state) return null; const plugin = gameRegistry.get(room.gameSlug)!; return plugin.getSpectatorView(room.state); } private scheduleCleanup(roomId: string, ms: number): void { this.clearCleanup(roomId); const timer = setTimeout(() => this.deleteRoom(roomId), ms); this.cleanupTimers.set(roomId, timer); } private clearCleanup(roomId: string): void { const existing = this.cleanupTimers.get(roomId); if (existing) { clearTimeout(existing); this.cleanupTimers.delete(roomId); } } private deleteRoom(roomId: string): void { this.clearCleanup(roomId); this.rooms.delete(roomId); } }