feat(games): implement RoomManager with room lifecycle and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 13:26:26 +02:00
parent 55df982a0b
commit 33a1848096
2 changed files with 309 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
import { gameRegistry } from "@shared/games/registry";
import type { Room, RoomSummary } from "./types";
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; started: boolean } | { ok: false; error: string };
export class RoomManager {
private rooms = new Map<string, Room>();
private cleanupTimers = new Map<string, Timer>();
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, 60_000);
return { ok: true, roomId: id };
}
joinRoom(roomId: string, playerId: string, as: "player" | "spectator"): JoinResult {
const room = this.rooms.get(roomId);
if (!room) return { ok: false, error: "Room not found" };
if (as === "spectator") {
room.spectators.add(playerId);
return { ok: true, started: false };
}
if (room.status !== "waiting") return { ok: false, error: "Game already started" };
const plugin = gameRegistry.get(room.gameSlug)!;
if (room.players.length >= plugin.maxPlayers) return { ok: false, error: "Room is full" };
if (room.players.includes(playerId)) return { ok: false, error: "Already in room" };
room.players.push(playerId);
if (room.players.length >= plugin.maxPlayers) {
room.state = plugin.createInitialState(room.players);
room.status = "playing";
this.clearCleanup(roomId);
return { ok: true, started: true };
}
return { ok: true, 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, 60_000);
}
return { ok: true, state: room.state, gameOver };
}
leaveRoom(roomId: string, playerId: string): void {
const room = this.rooms.get(roomId);
if (!room) return;
if (room.spectators.has(playerId)) {
room.spectators.delete(playerId);
return;
}
const playerIdx = room.players.indexOf(playerId);
if (playerIdx === -1) return;
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, 60_000);
}
}
}
if (room.players.length === 0 && room.status === "waiting") {
this.deleteRoom(roomId);
}
}
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);
}
}