Some checks failed
Deploy to Production / test (push) Failing after 32s
Add chess as the first game plugin using the existing multiplayer framework. Server-side game logic uses chess.js with server-authoritative clock management. Client uses react-chessboard v5 with cburnett piece set, drag-and-drop + click-to-move, configurable time controls (bullet/blitz/rapid/classical/none), draw offers, resignation, and timeout detection. Extends the game framework with room creation options to support per-game configuration. Includes 57 tests covering all code paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
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,
|
|
PLAYING_MAX_MS: 30 * 60_000, // 30 minutes — safety net for stuck games
|
|
} 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<string, unknown> };
|
|
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
|
"game:ended": { roomId: string; winner: string | null; reason: string };
|
|
"player:left": { roomId: string; playerId: string };
|
|
"room:deleted": { roomId: string };
|
|
"room:list:changed": void;
|
|
};
|
|
|
|
export class RoomManager {
|
|
private rooms = new Map<string, Room>();
|
|
private cleanupTimers = new Map<string, Timer>();
|
|
readonly emitter = mitt<RoomEvents>();
|
|
|
|
createRoom(gameSlug: string, hostId: string, options?: Record<string, unknown>): 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(),
|
|
options,
|
|
};
|
|
|
|
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" };
|
|
|
|
// Reconnecting player: must be checked before the in-progress spectator guard.
|
|
if (preferAs !== "spectator" && room.players.includes(playerId)) {
|
|
room.spectators.delete(playerId);
|
|
return { ok: true, joinedAs: "player", started: room.status === "playing" };
|
|
}
|
|
|
|
if (preferAs === "spectator" || room.status !== "waiting") {
|
|
room.spectators.add(playerId);
|
|
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
|
}
|
|
|
|
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.options);
|
|
room.status = "playing";
|
|
this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS);
|
|
|
|
const spectatorView = plugin.getSpectatorView(room.state);
|
|
const playerViews = new Map<string, unknown>();
|
|
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<string, unknown>();
|
|
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);
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.emitter.emit("player:left", { roomId, playerId });
|
|
this.emitter.emit("room:list:changed");
|
|
}
|
|
|
|
/**
|
|
* Fills empty seats with the admin's own ID for solo testing.
|
|
* This means `createInitialState` will receive duplicate player IDs
|
|
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
|
|
* solo-test mode produces non-unique player arrays.
|
|
*/
|
|
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.options);
|
|
room.status = "playing";
|
|
this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS);
|
|
|
|
const spectatorView = plugin.getSpectatorView(room.state);
|
|
const playerViews = new Map<string, unknown>();
|
|
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);
|
|
if (this.rooms.delete(roomId)) {
|
|
this.emitter.emit("room:deleted", { roomId });
|
|
this.emitter.emit("room:list:changed");
|
|
}
|
|
}
|
|
}
|