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:
141
api/src/games/RoomManager.test.ts
Normal file
141
api/src/games/RoomManager.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
168
api/src/games/RoomManager.ts
Normal file
168
api/src/games/RoomManager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user