From 33a1848096c67c1f296f6804c9ae3da9059ae695 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 2 Apr 2026 13:26:26 +0200 Subject: [PATCH] feat(games): implement RoomManager with room lifecycle and tests Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/games/RoomManager.test.ts | 141 +++++++++++++++++++++++++ api/src/games/RoomManager.ts | 168 ++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 api/src/games/RoomManager.test.ts create mode 100644 api/src/games/RoomManager.ts diff --git a/api/src/games/RoomManager.test.ts b/api/src/games/RoomManager.test.ts new file mode 100644 index 0000000..e762124 --- /dev/null +++ b/api/src/games/RoomManager.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { RoomManager } from "./RoomManager"; +import { gameRegistry } from "@shared/games/registry"; +import { chessPlugin } from "@shared/games/chess/plugin"; + +// Register chess plugin for tests +if (!gameRegistry.get("chess")) { + gameRegistry.register(chessPlugin); +} + +describe("RoomManager", () => { + let manager: RoomManager; + + beforeEach(() => { + manager = new RoomManager(); + }); + + describe("createRoom", () => { + it("should create a room and return its id", () => { + const result = manager.createRoom("chess", "player1"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.roomId).toBeDefined(); + expect(typeof result.roomId).toBe("string"); + } + }); + + it("should reject unknown game type", () => { + const result = manager.createRoom("unknown-game", "player1"); + expect(result.ok).toBe(false); + }); + + it("should add creator as first player", () => { + const result = manager.createRoom("chess", "player1"); + if (result.ok) { + const room = manager.getRoom(result.roomId); + expect(room?.players).toContain("player1"); + expect(room?.host).toBe("player1"); + expect(room?.status).toBe("waiting"); + } + }); + }); + + describe("joinRoom", () => { + it("should add a player to a waiting room", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + const join = manager.joinRoom(create.roomId, "player2", "player"); + expect(join.ok).toBe(true); + }); + + it("should auto-start when room reaches maxPlayers", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + manager.joinRoom(create.roomId, "player2", "player"); + const room = manager.getRoom(create.roomId); + expect(room?.status).toBe("playing"); + expect(room?.state).toBeDefined(); + }); + + it("should allow joining as spectator when game is playing", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + manager.joinRoom(create.roomId, "player2", "player"); + const spec = manager.joinRoom(create.roomId, "spectator1", "spectator"); + expect(spec.ok).toBe(true); + }); + + it("should reject joining full room as player", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + manager.joinRoom(create.roomId, "player2", "player"); + const result = manager.joinRoom(create.roomId, "player3", "player"); + expect(result.ok).toBe(false); + }); + + it("should reject joining nonexistent room", () => { + const result = manager.joinRoom("fake-id", "player1", "player"); + expect(result.ok).toBe(false); + }); + }); + + describe("handleAction", () => { + it("should apply a valid game action", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + manager.joinRoom(create.roomId, "player2", "player"); + const result = manager.handleAction(create.roomId, "player1", { type: "move", from: [6, 4], to: [4, 4] }); + expect(result.ok).toBe(true); + }); + + it("should reject action from spectator", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + manager.joinRoom(create.roomId, "player2", "player"); + manager.joinRoom(create.roomId, "spectator1", "spectator"); + const result = manager.handleAction(create.roomId, "spectator1", { type: "move", from: [6, 4], to: [4, 4] }); + expect(result.ok).toBe(false); + }); + }); + + describe("leaveRoom", () => { + it("should remove a player from the room", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + manager.leaveRoom(create.roomId, "player1"); + // Room is deleted when last player leaves a waiting room + const room = manager.getRoom(create.roomId); + expect(room).toBeUndefined(); + }); + + it("should remove a spectator from the room", () => { + const create = manager.createRoom("chess", "player1"); + if (!create.ok) throw new Error("Failed to create room"); + manager.joinRoom(create.roomId, "player2", "player"); + manager.joinRoom(create.roomId, "spec1", "spectator"); + manager.leaveRoom(create.roomId, "spec1"); + const room = manager.getRoom(create.roomId); + expect(room?.spectators.has("spec1")).toBe(false); + }); + }); + + describe("listRooms", () => { + it("should return summaries of all rooms", () => { + manager.createRoom("chess", "player1"); + manager.createRoom("chess", "player2"); + const rooms = manager.listRooms(); + expect(rooms.length).toBe(2); + expect(rooms[0].gameSlug).toBe("chess"); + expect(rooms[0].status).toBe("waiting"); + }); + + it("should filter by game type", () => { + manager.createRoom("chess", "player1"); + const rooms = manager.listRooms("chess"); + expect(rooms.length).toBe(1); + const empty = manager.listRooms("blackjack"); + expect(empty.length).toBe(0); + }); + }); +}); diff --git a/api/src/games/RoomManager.ts b/api/src/games/RoomManager.ts new file mode 100644 index 0000000..9d18884 --- /dev/null +++ b/api/src/games/RoomManager.ts @@ -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(); + private cleanupTimers = new Map(); + + 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); + } +}