refactor(games): rework room lifecycle events and remove chess plugin
Consolidate room leave/delete event handling into RoomManager emitter, remove redundant PLAYER_LEFT publishes from GameServer, and delete the chess game plugin (board, types, tests) in favor of the new plugin architecture. Add per-module CLAUDE.md files for leveling, guild-settings, feature-flags, db, api, and panel to improve agent navigability. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
31
api/src/CLAUDE.md
Normal file
31
api/src/CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# API Layer
|
||||
|
||||
## Server
|
||||
- Bun's native `serve()` API — no Express/Fastify. Custom `handleRequest()` dispatcher with pathname prefix matching.
|
||||
- Route modules export `{ name: string, handler: RouteHandler }`. Handlers return `null` for non-matching paths.
|
||||
|
||||
## Authentication
|
||||
- Discord OAuth2 with session cookies (`aurora_session`, HttpOnly, 7-day TTL).
|
||||
- In-memory session store — sessions lost on restart.
|
||||
- Role-based: `admin` vs `player` (admins set via `ADMIN_USER_IDS` env var).
|
||||
- Non-enrolled users (not in DB) get 403 even with valid Discord auth.
|
||||
- Call `getSession(req)` for all protected routes.
|
||||
|
||||
## Response Conventions
|
||||
- Success: `jsonResponse(data, status)` — uses custom BigInt-safe JSON replacer.
|
||||
- Error: `errorResponse(message, status, details?)` → `{ error, details? }`
|
||||
- Validation: `validationErrorResponse(zodError)` → `{ error: "Invalid payload", issues: [...] }`
|
||||
- Zod schemas centralized in `schemas.ts`.
|
||||
|
||||
## WebSocket
|
||||
- Upgrade via `/ws` endpoint (requires auth).
|
||||
- Pub/sub via Bun's `.publish()` / `.subscribe()` on channels: `dashboard`, `lobby`, `room:${roomId}`.
|
||||
- Dashboard stats broadcast every 5 seconds. Game events are room-scoped.
|
||||
- Hard limit: 200 concurrent WS connections (429 rejection), 16KB max payload, 60s idle timeout.
|
||||
- Fire-and-forget broadcasts — no ack mechanism.
|
||||
|
||||
## Gotchas
|
||||
- All DB IDs are BigInt — JSON responses must use the custom `jsonReplacer`.
|
||||
- No rate limiting on HTTP routes.
|
||||
- Some routes accept multipart form data (e.g., item icon upload) — manual parsing, not abstracted.
|
||||
- Asset directories resolve relative to `import.meta.dir`.
|
||||
@@ -75,6 +75,17 @@ export class GameServer {
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:deleted", ({ roomId }) => {
|
||||
const channel = `room:${roomId}`;
|
||||
for (const [, ws] of this.connections) {
|
||||
if (ws.data.rooms.has(roomId)) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||
ws.unsubscribe(channel);
|
||||
ws.data.rooms.delete(roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:list:changed", () => {
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
@@ -186,11 +197,6 @@ export class GameServer {
|
||||
this.roomManager.leaveRoom(msg.roomId, discordId);
|
||||
ws.unsubscribe(`room:${msg.roomId}`);
|
||||
ws.data.rooms.delete(msg.roomId);
|
||||
this.publish(`room:${msg.roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId: msg.roomId,
|
||||
playerId: discordId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -231,14 +237,8 @@ export class GameServer {
|
||||
|
||||
for (const roomId of ws.data.rooms) {
|
||||
this.roomManager.leaveRoom(roomId, ws.data.session.discordId);
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId,
|
||||
playerId: ws.data.session.discordId,
|
||||
});
|
||||
}
|
||||
this.connections.delete(ws.data.session.discordId);
|
||||
this.publishRoomListUpdate();
|
||||
}
|
||||
|
||||
private publish(channel: string, message: unknown): void {
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
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";
|
||||
import type { GamePlugin } from "@shared/games/types";
|
||||
|
||||
// Register chess plugin for tests
|
||||
if (!gameRegistry.get("chess")) {
|
||||
gameRegistry.register(chessPlugin);
|
||||
// Minimal stub plugin for testing the room system
|
||||
const stubPlugin: GamePlugin<{ turn: number }, { type: string }> = {
|
||||
slug: "stub",
|
||||
name: "Stub Game",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
createInitialState: (players) => ({ turn: 0 }),
|
||||
handleAction: (state, action, playerId) => ({ ok: true, state: { ...state, turn: state.turn + 1 } }),
|
||||
getPlayerView: (state) => state,
|
||||
getSpectatorView: (state) => state,
|
||||
};
|
||||
|
||||
if (!gameRegistry.get("stub")) {
|
||||
gameRegistry.register(stubPlugin);
|
||||
}
|
||||
|
||||
describe("RoomManager", () => {
|
||||
@@ -17,7 +28,7 @@ describe("RoomManager", () => {
|
||||
|
||||
describe("createRoom", () => {
|
||||
it("should create a room and return its id", () => {
|
||||
const result = manager.createRoom("chess", "player1");
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.roomId).toBeDefined();
|
||||
@@ -31,7 +42,7 @@ describe("RoomManager", () => {
|
||||
});
|
||||
|
||||
it("should add creator as first player", () => {
|
||||
const result = manager.createRoom("chess", "player1");
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
if (result.ok) {
|
||||
const room = manager.getRoom(result.roomId);
|
||||
expect(room?.players).toContain("player1");
|
||||
@@ -43,7 +54,7 @@ describe("RoomManager", () => {
|
||||
|
||||
describe("joinRoom", () => {
|
||||
it("should add a player to a waiting room", () => {
|
||||
const create = manager.createRoom("chess", "player1");
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
const join = manager.joinRoom(create.roomId, "player2", "player");
|
||||
expect(join.ok).toBe(true);
|
||||
@@ -53,7 +64,7 @@ describe("RoomManager", () => {
|
||||
});
|
||||
|
||||
it("should auto-start when room reaches maxPlayers", () => {
|
||||
const create = manager.createRoom("chess", "player1");
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
@@ -62,7 +73,7 @@ describe("RoomManager", () => {
|
||||
});
|
||||
|
||||
it("should allow joining as spectator when game is playing", () => {
|
||||
const create = manager.createRoom("chess", "player1");
|
||||
const create = manager.createRoom("stub", "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");
|
||||
@@ -70,7 +81,7 @@ describe("RoomManager", () => {
|
||||
});
|
||||
|
||||
it("should downgrade to spectator when joining full room as player", () => {
|
||||
const create = manager.createRoom("chess", "player1");
|
||||
const create = manager.createRoom("stub", "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");
|
||||
@@ -88,35 +99,34 @@ describe("RoomManager", () => {
|
||||
|
||||
describe("handleAction", () => {
|
||||
it("should apply a valid game action", () => {
|
||||
const create = manager.createRoom("chess", "player1");
|
||||
const create = manager.createRoom("stub", "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: "e2", to: "e4" });
|
||||
const result = manager.handleAction(create.roomId, "player1", { type: "action" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject action from spectator", () => {
|
||||
const create = manager.createRoom("chess", "player1");
|
||||
const create = manager.createRoom("stub", "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: "e2", to: "e4" });
|
||||
const result = manager.handleAction(create.roomId, "spectator1", { type: "action" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaveRoom", () => {
|
||||
it("should remove a player from the room", () => {
|
||||
const create = manager.createRoom("chess", "player1");
|
||||
const create = manager.createRoom("stub", "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");
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
manager.joinRoom(create.roomId, "spec1", "spectator");
|
||||
@@ -128,17 +138,17 @@ describe("RoomManager", () => {
|
||||
|
||||
describe("listRooms", () => {
|
||||
it("should return summaries of all rooms", () => {
|
||||
manager.createRoom("chess", "player1");
|
||||
manager.createRoom("chess", "player2");
|
||||
manager.createRoom("stub", "player1");
|
||||
manager.createRoom("stub", "player2");
|
||||
const rooms = manager.listRooms();
|
||||
expect(rooms.length).toBe(2);
|
||||
expect(rooms[0].gameSlug).toBe("chess");
|
||||
expect(rooms[0].gameSlug).toBe("stub");
|
||||
expect(rooms[0].status).toBe("waiting");
|
||||
});
|
||||
|
||||
it("should filter by game type", () => {
|
||||
manager.createRoom("chess", "player1");
|
||||
const rooms = manager.listRooms("chess");
|
||||
manager.createRoom("stub", "player1");
|
||||
const rooms = manager.listRooms("stub");
|
||||
expect(rooms.length).toBe(1);
|
||||
const empty = manager.listRooms("blackjack");
|
||||
expect(empty.length).toBe(0);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 =
|
||||
@@ -23,6 +24,7 @@ type RoomEvents = {
|
||||
"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;
|
||||
};
|
||||
|
||||
@@ -85,7 +87,7 @@ export class RoomManager {
|
||||
if (room.players.length >= plugin.maxPlayers) {
|
||||
room.state = plugin.createInitialState(room.players);
|
||||
room.status = "playing";
|
||||
this.clearCleanup(roomId);
|
||||
this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS);
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
@@ -158,7 +160,6 @@ export class RoomManager {
|
||||
|
||||
if (room.players.length === 0 && room.status === "waiting") {
|
||||
this.deleteRoom(roomId);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +168,12 @@ export class RoomManager {
|
||||
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" };
|
||||
@@ -180,7 +187,7 @@ export class RoomManager {
|
||||
|
||||
room.state = plugin.createInitialState(room.players);
|
||||
room.status = "playing";
|
||||
this.clearCleanup(roomId);
|
||||
this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS);
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
@@ -245,6 +252,9 @@ export class RoomManager {
|
||||
|
||||
private deleteRoom(roomId: string): void {
|
||||
this.clearCleanup(roomId);
|
||||
this.rooms.delete(roomId);
|
||||
if (this.rooms.delete(roomId)) {
|
||||
this.emitter.emit("room:deleted", { roomId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ export const GameWsClientSchema = z.discriminatedUnion("type", [
|
||||
role: z.enum(["player", "admin"]).optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
|
||||
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.record(z.unknown()) }),
|
||||
// Use looseObject for GAME_ACTION to avoid Zod bug with record()
|
||||
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.looseObject({}, { message: "Invalid action" }) }),
|
||||
z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -183,8 +183,8 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
// Fall back to full WsMessageSchema for dashboard messages that aren't PING
|
||||
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||
const parsed = WsMessageSchema.safeParse(rawData);
|
||||
if (!parsed.success) {
|
||||
logger.error("web", "Invalid message format", parsed.error.issues);
|
||||
if (!parsed?.success) {
|
||||
logger.error("web", "Invalid message format", parsed?.error.issues);
|
||||
}
|
||||
// Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user