From 56db5bc998e9bae1518acd06fa41e67504715846 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 5 Apr 2026 15:19:51 +0200 Subject: [PATCH] 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) --- api/src/CLAUDE.md | 31 ++ api/src/games/GameServer.ts | 22 +- api/src/games/RoomManager.test.ts | 54 ++- api/src/games/RoomManager.ts | 18 +- api/src/games/types.ts | 3 +- api/src/server.ts | 4 +- bot/index.ts | 6 - bun.lock | 15 - package.json | 1 - panel/CLAUDE.md | 31 ++ panel/package.json | 2 - panel/src/games/GameLobby.tsx | 2 +- panel/src/games/GameRoom.tsx | 1 - panel/src/games/chess/ChessBoard.tsx | 589 ------------------------ panel/src/games/chess/index.ts | 10 - panel/src/lib/useGameRoom.ts | 12 +- panel/src/lib/useWebSocket.ts | 4 +- shared/db/CLAUDE.md | 40 ++ shared/games/chess/plugin.test.ts | 129 ------ shared/games/chess/plugin.ts | 114 ----- shared/games/chess/types.ts | 11 - shared/modules/feature-flags/CLAUDE.md | 9 + shared/modules/guild-settings/CLAUDE.md | 10 + shared/modules/leveling/CLAUDE.md | 9 + 24 files changed, 206 insertions(+), 921 deletions(-) create mode 100644 api/src/CLAUDE.md create mode 100644 panel/CLAUDE.md delete mode 100644 panel/src/games/chess/ChessBoard.tsx delete mode 100644 panel/src/games/chess/index.ts create mode 100644 shared/db/CLAUDE.md delete mode 100644 shared/games/chess/plugin.test.ts delete mode 100644 shared/games/chess/plugin.ts delete mode 100644 shared/games/chess/types.ts create mode 100644 shared/modules/feature-flags/CLAUDE.md create mode 100644 shared/modules/guild-settings/CLAUDE.md create mode 100644 shared/modules/leveling/CLAUDE.md diff --git a/api/src/CLAUDE.md b/api/src/CLAUDE.md new file mode 100644 index 0000000..e1ce373 --- /dev/null +++ b/api/src/CLAUDE.md @@ -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`. diff --git a/api/src/games/GameServer.ts b/api/src/games/GameServer.ts index 67c73db..1ca68fb 100644 --- a/api/src/games/GameServer.ts +++ b/api/src/games/GameServer.ts @@ -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 { diff --git a/api/src/games/RoomManager.test.ts b/api/src/games/RoomManager.test.ts index 38f7e25..cb187ec 100644 --- a/api/src/games/RoomManager.test.ts +++ b/api/src/games/RoomManager.test.ts @@ -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); diff --git a/api/src/games/RoomManager.ts b/api/src/games/RoomManager.ts index cf6b62c..da39586 100644 --- a/api/src/games/RoomManager.ts +++ b/api/src/games/RoomManager.ts @@ -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 }; "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(); @@ -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(); @@ -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"); + } } } diff --git a/api/src/games/types.ts b/api/src/games/types.ts index 23d7f82..5e58774 100644 --- a/api/src/games/types.ts +++ b/api/src/games/types.ts @@ -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() }), ]); diff --git a/api/src/server.ts b/api/src/server.ts index fa86565..084d42f 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -183,8 +183,8 @@ export async function createWebServer(config: WebServerConfig = {}): Promise=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], - - "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], - - "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], - - "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -348,8 +337,6 @@ "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], - "chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -486,8 +473,6 @@ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "react-chessboard": ["react-chessboard@5.10.0", "", { "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-Y3PgaCVhnDG3IaQfu86OzTSEIEAUtuU5XwmHWnx3tcFOX7lSoAq81ZFX3MBj6y5a6FzDMTczMVmkkrV2CzTrIw=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], diff --git a/package.json b/package.json index 2deb75b..3591170 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.89", - "chess.js": "^1.4.0", "discord.js": "^14.25.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", diff --git a/panel/CLAUDE.md b/panel/CLAUDE.md new file mode 100644 index 0000000..4892431 --- /dev/null +++ b/panel/CLAUDE.md @@ -0,0 +1,31 @@ +# Panel (Admin Dashboard) + +## Stack +- React 19 + React Router v7 + Tailwind CSS v4 (Vite plugin) + Lucide icons. +- No component library (no Radix, no shadcn). All styling is inline Tailwind. +- No external state management. All state via custom hooks (`useAuth`, `useUsers`, `useItems`, `useDashboard`, `useGameRoom`). + +## API Client +- Thin `fetch` wrapper in `src/lib/api.ts` with typed generics: `get`, `post`, `put`, `del`. +- Credentials: `same-origin` only — CORS requests will fail. +- 401 responses redirect to Discord auth. No retry logic. +- 204 / empty responses return `undefined`. + +## WebSocket +- Singleton pattern in `useWebSocket()`. Global handler Set — multiple hooks share one connection. +- Auto-reconnects with exponential backoff (max 30s). +- `send()` and `subscribe()` API for components. + +## Patterns +- **Draft editing:** Pages use `selectedUser → userDraft` flow. Changes aren't auto-saved. `saveDraft()` commits, `discardDraft()` reverts, `isDirty()` detects changes. +- **Debounced search:** 300ms debounce on search inputs. +- **No cache invalidation:** After mutations, manually call `refetch()` to update lists. + +## Routing +- Role-based: player routes (`/dashboard`, `/games`, `/leaderboards`) and admin routes (`/admin/*`). +- Sidebar auto-hides admin routes for non-admins. + +## Theme +- Dark theme with "Celestial Gold" primary (`#e9c349`). +- Semantic colors: destructive, success, warning, info. +- 4-tier surface hierarchy. Utility: `cn()` from `clsx + tailwind-merge`. diff --git a/panel/package.json b/panel/package.json index 5ff10ac..c2859d2 100644 --- a/panel/package.json +++ b/panel/package.json @@ -10,12 +10,10 @@ }, "dependencies": { "@imgly/background-removal": "^1.7.0", - "chess.js": "^1.4.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.564.0", "react": "^19.1.0", - "react-chessboard": "^5.10.0", "react-dom": "^19.1.0", "react-router-dom": "^7.13.2", "tailwind-merge": "^3.4.0", diff --git a/panel/src/games/GameLobby.tsx b/panel/src/games/GameLobby.tsx index 8cd6941..8a316b4 100644 --- a/panel/src/games/GameLobby.tsx +++ b/panel/src/games/GameLobby.tsx @@ -2,8 +2,8 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useWebSocket } from "../lib/useWebSocket"; import { gameUIRegistry } from "./registry"; -import "./chess"; +// Mirrors RoomSummary in api/src/games/types.ts — keep in sync interface RoomSummary { id: string; gameSlug: string; diff --git a/panel/src/games/GameRoom.tsx b/panel/src/games/GameRoom.tsx index a91f8d1..a6565a3 100644 --- a/panel/src/games/GameRoom.tsx +++ b/panel/src/games/GameRoom.tsx @@ -3,7 +3,6 @@ import { useParams, useNavigate, useLocation } from "react-router-dom"; import { useGameRoom } from "../lib/useGameRoom"; import { gameUIRegistry } from "./registry"; import { Loader2 } from "lucide-react"; -import "./chess"; function CopyInviteLink({ url }: { url: string }) { const [copied, setCopied] = useState(false); diff --git a/panel/src/games/chess/ChessBoard.tsx b/panel/src/games/chess/ChessBoard.tsx deleted file mode 100644 index 2ab8c00..0000000 --- a/panel/src/games/chess/ChessBoard.tsx +++ /dev/null @@ -1,589 +0,0 @@ -import { useState, useMemo, useRef, useEffect } from "react"; -import { Chessboard } from "react-chessboard"; -import { Chess } from "chess.js"; -import type { GameUIProps } from "../registry"; - -interface ChessState { - fen: string; - players: { white: string; black: string }; - moveHistory: string[]; - status: string; - winner: string | null; -} - -// Chess.com-inspired board colors -const DARK_SQUARE = "#769656"; -const LIGHT_SQUARE = "#eeeed2"; -const LAST_MOVE_DARK = "#638a49"; -const LAST_MOVE_LIGHT = "#cdd96e"; -const SELECTED_SHADOW = "inset 0 0 0 4px rgba(20,85,30,0.8)"; -const CHECK_BG = "rgba(220, 38, 38, 0.7)"; - -function Avatar({ username, color }: { username: string; color: "white" | "black" }) { - return ( -
- {username?.[0]?.toUpperCase() ?? "?"} -
- ); -} - -function CapturedPieces({ fen, color }: { fen: string; color: "white" | "black" }) { - const captured = useMemo(() => { - const game = new Chess(fen); - const board = game.board(); - const counts: Record = { p: 0, n: 0, b: 0, r: 0, q: 0 }; - for (const row of board) { - for (const sq of row) { - if (sq) counts[sq.type] = (counts[sq.type] ?? 0) + 1; - } - } - const start: Record = { p: 8, n: 2, b: 2, r: 2, q: 1 }; - const pieceSymbols: Record = { - p: color === "white" ? "♟" : "♙", - n: color === "white" ? "♞" : "♘", - b: color === "white" ? "♝" : "♗", - r: color === "white" ? "♜" : "♖", - q: color === "white" ? "♛" : "♕", - }; - const pieceColor = color === "white" ? "b" : "w"; - const result: string[] = []; - for (const [type, symbol] of Object.entries(pieceSymbols)) { - const remaining = board.flat().filter(s => s?.type === type && s.color === pieceColor).length; - const missing = start[type]! - remaining; - for (let i = 0; i < missing; i++) result.push(symbol); - } - return result; - }, [fen, color]); - - if (captured.length === 0) return null; - return ( - {captured.join("")} - ); -} - -function PlayerPanel({ - username, - color, - isActive, - fen, - isTop, -}: { - username: string; - color: "white" | "black"; - isActive: boolean; - fen: string; - isTop: boolean; -}) { - return ( -
- -
- - {username} - -
- - {color} - - -
-
- {isActive && ( -
- - - Your turn - -
- )} -
- ); -} - -function MoveHistory({ moveHistory }: { moveHistory: string[] }) { - const ref = useRef(null); - useEffect(() => { - if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; - }, [moveHistory.length]); - - const pairs: [string, string | undefined][] = []; - for (let i = 0; i < moveHistory.length; i += 2) { - pairs.push([moveHistory[i]!, moveHistory[i + 1]]); - } - - return ( -
-
- Moves -
-
- {pairs.length === 0 ? ( -
- No moves yet -
- ) : ( - - - {pairs.map(([white, black], i) => ( - - - - - - ))} - -
- {i + 1}. - - {white} - - {black ?? "—"} -
- )} -
-
- ); -} - -function GameOverOverlay({ - status, - winner, - myPlayerId, - players, -}: { - status: string; - winner: string | null; - myPlayerId: string; - players: { discordId: string; username: string }[]; -}) { - const winnerName = winner ? (players.find(p => p.discordId === winner)?.username ?? winner) : null; - const isWinner = winner === myPlayerId; - const isDraw = !winner; - - let title = ""; - let subtitle = ""; - let titleColor = "#f0d9b5"; - - if (isDraw) { - title = "Draw"; - subtitle = status === "stalemate" ? "Stalemate" : "Game drawn"; - titleColor = "#8e8984"; - } else if (isWinner) { - title = "You Win!"; - subtitle = status === "forfeit" ? "Opponent forfeited" : "By checkmate"; - titleColor = "#81b64c"; - } else { - title = "You Lose"; - subtitle = - status === "forfeit" - ? "You forfeited" - : status === "checkmate" - ? `${winnerName} wins by checkmate` - : `${winnerName} wins`; - titleColor = "#c84b4b"; - } - - return ( -
-
-
- {title} -
-
- {subtitle} -
-
-
- ); -} - -export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) { - const chess = state as ChessState; - const [promotionFrom, setPromotionFrom] = useState(null); - const [promotionTo, setPromotionTo] = useState(null); - const [selectedSquare, setSelectedSquare] = useState(null); - const [confirmForfeit, setConfirmForfeit] = useState(false); - const containerRef = useRef(null); - const [boardWidth, setBoardWidth] = useState(480); - - // Optimistic local FEN — updated immediately on drag/click, then confirmed by server. - // We track a local move count so we never let a stale server message roll back the board. - const [localFen, setLocalFen] = useState(() => chess?.fen ?? "start"); - const localFenRef = useRef(localFen); - const localMoveCountRef = useRef(chess?.moveHistory?.length ?? 0); - const lastMoveRef = useRef<{ from: string; to: string } | null>(null); - - // Sync local FEN from server — only if server has caught up to our optimistic position. - // This prevents any late/duplicate GAME_STARTED messages from rolling back the board. - useEffect(() => { - if (!chess?.fen) return; - const serverMoves = chess.moveHistory?.length ?? 0; - if (serverMoves >= localMoveCountRef.current) { - localMoveCountRef.current = serverMoves; - localFenRef.current = chess.fen; - setLocalFen(chess.fen); - } - }, [chess?.fen, chess?.moveHistory?.length]); - - // Responsive board sizing - useEffect(() => { - const container = containerRef.current; - if (!container) return; - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect.width ?? 480; - setBoardWidth(Math.max(280, Math.min(520, w))); - }); - observer.observe(container); - return () => observer.disconnect(); - }, []); - - const game = useMemo(() => { - if (!localFen || localFen === "start") return new Chess(); - try { return new Chess(localFen); } catch { return new Chess(); } - }, [localFen]); - - if (!chess?.players) { - return ( -
- Waiting for game to start… -
- ); - } - - const isWhite = chess.players.white === myPlayerId; - const isBlack = chess.players.black === myPlayerId; - const isBothSides = isWhite && isBlack; - const myColor = isWhite ? "white" : isBlack ? "black" : null; - const turn = game.turn() === "w" ? "white" : "black"; - const isMyTurn = (isBothSides || myColor === turn) && !isSpectator; - const boardOrientation = myColor ?? "white"; - const isGameOver = chess.status !== "playing"; - - const topColor = boardOrientation === "white" ? "black" : "white"; - const bottomColor = boardOrientation; - - const topPlayerId = chess.players[topColor]; - const bottomPlayerId = chess.players[bottomColor]; - const topPlayer = players.find(p => p.discordId === topPlayerId); - const bottomPlayer = players.find(p => p.discordId === bottomPlayerId); - - const isTopActive = !isGameOver && turn === topColor && !isSpectator; - const isBottomActive = !isGameOver && turn === bottomColor && !isSpectator; - - function dispatchMove(from: string, to: string, promotion?: string) { - try { - const optimistic = new Chess(localFenRef.current); - const moved = optimistic.move({ from, to, promotion: promotion ?? "q" }); - if (moved) { - localMoveCountRef.current += 1; - localFenRef.current = optimistic.fen(); - setLocalFen(optimistic.fen()); - } - } catch { /* invalid — server will reject */ } - lastMoveRef.current = { from, to }; - setSelectedSquare(null); - onAction({ type: "move", from, to, ...(promotion ? { promotion } : {}) }); - } - - function onDrop({ sourceSquare, targetSquare }: { piece: any; sourceSquare: string; targetSquare: string | null }): boolean { - if (!targetSquare) return false; - if (isSpectator || !isMyTurn || isGameOver) return false; - const testGame = new Chess(localFenRef.current); - const piece = testGame.get(sourceSquare as any); - if (piece?.type === "p") { - const rank = targetSquare[1]; - if ((piece.color === "w" && rank === "8") || (piece.color === "b" && rank === "1")) { - setPromotionFrom(sourceSquare); - setPromotionTo(targetSquare); - lastMoveRef.current = { from: sourceSquare, to: targetSquare }; - return true; - } - } - let move; - try { move = testGame.move({ from: sourceSquare, to: targetSquare }); } catch { return false; } - if (!move) return false; - dispatchMove(sourceSquare, targetSquare); - return true; - } - - function handlePromotion(piece: string) { - if (promotionFrom && promotionTo) { - dispatchMove(promotionFrom, promotionTo, piece); - } - setPromotionFrom(null); - setPromotionTo(null); - } - - function onSquareClick({ square }: { piece: any; square: string }) { - if (isSpectator || isGameOver || !isMyTurn) return; - if (promotionFrom !== null) return; - const testGame = new Chess(localFenRef.current); - - if (selectedSquare !== null) { - if (square === selectedSquare) { setSelectedSquare(null); return; } - const legalMoves = testGame.moves({ square: selectedSquare as any, verbose: true }); - const validDest = legalMoves.find(m => m.to === square); - if (validDest) { - const piece = testGame.get(selectedSquare as any); - if (piece?.type === "p") { - const rank = square[1]; - if ((piece.color === "w" && rank === "8") || (piece.color === "b" && rank === "1")) { - setPromotionFrom(selectedSquare); - setPromotionTo(square); - lastMoveRef.current = { from: selectedSquare, to: square }; - setSelectedSquare(null); - return; - } - } - dispatchMove(selectedSquare, square); - return; - } - const clicked = testGame.get(square as any); - if (clicked) { - const color = clicked.color === "w" ? "white" : "black"; - if (isBothSides ? color === turn : color === myColor) { - setSelectedSquare(square); - return; - } - } - setSelectedSquare(null); - return; - } - - const clicked = testGame.get(square as any); - if (!clicked) return; - const color = clicked.color === "w" ? "white" : "black"; - if (isBothSides ? color === turn : color === myColor) setSelectedSquare(square); - } - - function canDragPiece({ piece }: { isSparePiece: boolean; piece: { pieceType: string }; square: string | null }): boolean { - if (isSpectator || !isMyTurn || isGameOver) return false; - const pieceColor = piece.pieceType === piece.pieceType.toUpperCase() ? "white" : "black"; - return isBothSides ? pieceColor === turn : pieceColor === myColor; - } - - // Square styles - const customSquareStyles: Record = {}; - - const lastMove = lastMoveRef.current; - if (lastMove) { - const isDarkSquare = (sq: string) => { - const f = sq.charCodeAt(0) - 97; - const r = parseInt(sq[1]!) - 1; - return (f + r) % 2 === 0; - }; - customSquareStyles[lastMove.from] = { - backgroundColor: isDarkSquare(lastMove.from) ? LAST_MOVE_DARK : LAST_MOVE_LIGHT, - }; - customSquareStyles[lastMove.to] = { - backgroundColor: isDarkSquare(lastMove.to) ? LAST_MOVE_DARK : LAST_MOVE_LIGHT, - }; - } - - if (selectedSquare !== null) { - customSquareStyles[selectedSquare] = { - ...customSquareStyles[selectedSquare], - boxShadow: SELECTED_SHADOW, - }; - const legalMoves = game.moves({ square: selectedSquare as any, verbose: true }); - for (const m of legalMoves) { - const hasPiece = game.get(m.to as any) !== null; - customSquareStyles[m.to] = { - ...customSquareStyles[m.to], - background: hasPiece - ? `radial-gradient(circle, rgba(20,85,30,0.4) 75%, transparent 77%)` - : `radial-gradient(circle, rgba(20,85,30,0.45) 28%, transparent 30%)`, - }; - } - } - - if (game.inCheck()) { - const board = game.board(); - const kingColor = game.turn(); - for (let r = 0; r < 8; r++) { - for (let c = 0; c < 8; c++) { - const sq = board[r][c]; - if (sq?.type === "k" && sq.color === kingColor) { - const key = `${String.fromCharCode(97 + c)}${8 - r}`; - customSquareStyles[key] = { - ...customSquareStyles[key], - background: `radial-gradient(ellipse at center, ${CHECK_BG} 0%, rgba(220,38,38,0.4) 50%, transparent 75%)`, - }; - } - } - } - } - - return ( -
- {/* Board column */} -
- {/* Opponent panel */} - - - {/* Board wrapper */} -
- - {promotionFrom !== null && ( -
-
- {["q", "r", "b", "n"].map((p) => ( - - ))} -
-
- )} - {isGameOver && ( - - )} -
- - {/* My panel */} - -
- - {/* Sidebar */} -
- - - {!isSpectator && chess.status === "playing" && ( -
- {confirmForfeit ? ( -
- - -
- ) : ( - - )} -
- )} -
-
- ); -} diff --git a/panel/src/games/chess/index.ts b/panel/src/games/chess/index.ts deleted file mode 100644 index 92fdb16..0000000 --- a/panel/src/games/chess/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { gameUIRegistry } from "../registry"; -import { ChessBoard } from "./ChessBoard"; - -gameUIRegistry.register({ - slug: "chess", - name: "Chess", - icon: "♟", - maxPlayers: 2, - component: ChessBoard, -}); diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index 182230d..1941ebe 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -22,9 +22,11 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe const { send, subscribe, connected } = useWebSocket(); const navigate = useNavigate(); const navigateRef = useRef(navigate); + const errorTimerRef = useRef>(); useEffect(() => { navigateRef.current = navigate; }, [navigate]); + useEffect(() => () => clearTimeout(errorTimerRef.current), []); const [state, setState] = useState({ gameState: null, @@ -121,6 +123,10 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe setTimeout(() => navigateRef.current("/games"), 2000); } else { setState(prev => ({ ...prev, error: msg.message })); + clearTimeout(errorTimerRef.current); + errorTimerRef.current = setTimeout(() => { + setState(prev => ({ ...prev, error: null })); + }, 5000); } break; } @@ -133,7 +139,11 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe }, [roomId, connected, userId, send, subscribe]); const sendAction = useCallback((action: unknown) => { - send({ type: "GAME_ACTION", roomId, action }); + const sent = send({ type: "GAME_ACTION", roomId, action }); + if (!sent) { + setState(prev => ({ ...prev, error: "Not connected — action not sent." })); + return; + } setState(prev => ({ ...prev, error: null })); }, [roomId, send]); diff --git a/panel/src/lib/useWebSocket.ts b/panel/src/lib/useWebSocket.ts index 17b9126..968da49 100644 --- a/panel/src/lib/useWebSocket.ts +++ b/panel/src/lib/useWebSocket.ts @@ -69,10 +69,12 @@ export function useWebSocket() { }; }, []); - const send = useCallback((data: unknown) => { + const send = useCallback((data: unknown): boolean => { if (globalWs?.readyState === WebSocket.OPEN) { globalWs.send(JSON.stringify(data)); + return true; } + return false; }, []); const subscribe = useCallback((handler: MessageHandler) => { diff --git a/shared/db/CLAUDE.md b/shared/db/CLAUDE.md new file mode 100644 index 0000000..b62533c --- /dev/null +++ b/shared/db/CLAUDE.md @@ -0,0 +1,40 @@ +# Database Layer + +## Column Types +- **Bigint** (`mode: 'bigint'`): All Discord snowflake IDs (`userId`, `guildId`, `roleId`, `channelId`), currency amounts (`balance`, `xp`, transaction `amount`, item `price`, inventory `quantity`). Use `0n` BigInt literals, never `Number()`. +- **Integer**: Small counts and internal IDs — `level`, `daily_streak`, `progress`, `reward_amount` in lootdrops, auto-increment `serial('id')` for items. + +## Composite Primary Keys +- `inventory(userId, itemId)` — one stack per user per item +- `userQuests(userId, questId)` — one assignment per user per quest +- `userTimers(userId, type, key)` — one timer per user per type/key combo + +## Constraints +- `inventory.quantity > 0` check constraint — never store zero-quantity rows +- `classes.name` and `items.name` are unique +- Cascade deletes on user FK; set null on `relatedUserId` (preserves transaction history) + +## JSON Columns (JSONB) +- `quests.requirements`: `{ target: number }` +- `quests.rewards`: `{ xp?: number, balance?: number }` +- `gameSettings` fields: Typed via `.$type()` — `LevelingConfig`, `EconomyConfig`, etc. +- `guildSettings.featureOverrides`: `Record` (sparse) +- `guildSettings.colorRoleIds`: `string[]` +- `users.settings`, `userTimers.metadata`, `items.usageData`: Untyped JSONB, default `{}` + +## Enums +Defined as TypeScript enums in `shared/lib/constants.ts`, **not** as database enums. Schema columns use `varchar` with length constraints. Key enum types: +- `TimerType`: COOLDOWN, EFFECT, ACCESS, EXAM_SYSTEM, TRIVIA_COOLDOWN +- `TransactionType`: TRANSFER_IN, DAILY_REWARD, etc. (8 values) +- `ModerationCaseType`: warn, timeout, kick, ban, note, prune +- `ItemType`: MATERIAL, CONSUMABLE, EQUIPMENT, QUEST + +## Notable Indexes +- `user_timers_lookup_idx`: Composite on (userId, type, key) — fast timer checks +- `user_timers_expires_at_idx`: Expiry-based cleanup queries +- `users_balance_idx` / `users_level_xp_idx`: Leaderboard queries + +## Client Setup +- `DrizzleClient` is a singleton in `shared/db/DrizzleClient.ts` (postgres-js driver, no prefetch). +- `withTransaction` utility in `bot/lib/db.ts` wraps Drizzle transactions and tracks count for graceful shutdown. Accepts optional existing `tx` for nested calls. +- No soft deletes anywhere. `moderationCases` uses `active: boolean` + `resolvedAt`/`resolvedBy` for lifecycle, but rows are never deleted. diff --git a/shared/games/chess/plugin.test.ts b/shared/games/chess/plugin.test.ts deleted file mode 100644 index 18db40d..0000000 --- a/shared/games/chess/plugin.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { chessPlugin } from "./plugin"; -import { gameRegistry } from "../registry"; - -const PLAYER_WHITE = "player1"; -const PLAYER_BLACK = "player2"; - -describe("chessPlugin", () => { - describe("metadata", () => { - it("should have correct slug and player counts", () => { - expect(chessPlugin.slug).toBe("chess"); - expect(chessPlugin.minPlayers).toBe(2); - expect(chessPlugin.maxPlayers).toBe(2); - }); - }); - - describe("createInitialState", () => { - it("should create initial FEN position", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - expect(state.fen).toBe("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); - expect(state.players.white).toBe(PLAYER_WHITE); - expect(state.players.black).toBe(PLAYER_BLACK); - expect(state.moveHistory).toEqual([]); - expect(state.status).toBe("playing"); - }); - }); - - describe("handleAction — move", () => { - it("should allow a legal pawn move", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e4" }, PLAYER_WHITE); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.state.fen).not.toBe(state.fen); - expect(result.state.moveHistory).toEqual(["e4"]); - } - }); - - it("should reject move when it is not the player's turn", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const result = chessPlugin.handleAction(state, { type: "move", from: "e7", to: "e5" }, PLAYER_BLACK); - expect(result.ok).toBe(false); - }); - - it("should reject an illegal move", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e5" }, PLAYER_WHITE); - expect(result.ok).toBe(false); - }); - - it("should reject a non-player's move", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e4" }, "random_player"); - expect(result.ok).toBe(false); - }); - - it("should detect checkmate", () => { - // Scholar's mate: 1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6 4.Qxf7# - let state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const moves = [ - { player: PLAYER_WHITE, from: "e2", to: "e4" }, - { player: PLAYER_BLACK, from: "e7", to: "e5" }, - { player: PLAYER_WHITE, from: "f1", to: "c4" }, - { player: PLAYER_BLACK, from: "b8", to: "c6" }, - { player: PLAYER_WHITE, from: "d1", to: "h5" }, - { player: PLAYER_BLACK, from: "g8", to: "f6" }, - { player: PLAYER_WHITE, from: "h5", to: "f7" }, - ]; - for (const m of moves) { - const result = chessPlugin.handleAction(state, { type: "move", from: m.from, to: m.to }, m.player); - expect(result.ok).toBe(true); - if (result.ok) state = result.state; - } - expect(state.status).toBe("checkmate"); - expect(state.winner).toBe(PLAYER_WHITE); - }); - }); - - describe("handleAction — forfeit", () => { - it("should end the game with the other player as winner", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const result = chessPlugin.handleAction(state, { type: "forfeit" }, PLAYER_WHITE); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.state.status).toBe("forfeit"); - expect(result.state.winner).toBe(PLAYER_BLACK); - } - }); - }); - - describe("getPlayerView", () => { - it("should return full state", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const view = chessPlugin.getPlayerView(state, PLAYER_WHITE); - expect(view).toEqual(state); - }); - }); - - describe("isGameOver", () => { - it("should return null for ongoing game", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - expect(chessPlugin.isGameOver!(state)).toBeNull(); - }); - - it("should return winner for forfeit", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - state.status = "forfeit"; - state.winner = PLAYER_BLACK; - expect(chessPlugin.isGameOver!(state)).toEqual({ winner: PLAYER_BLACK, reason: "forfeit" }); - }); - }); - - describe("onPlayerDisconnect", () => { - it("should forfeit the disconnected player", () => { - const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); - const result = chessPlugin.onPlayerDisconnect!(state, PLAYER_WHITE); - expect(result.status).toBe("forfeit"); - expect(result.winner).toBe(PLAYER_BLACK); - }); - }); - - describe("chess registration", () => { - it("should register and retrieve from gameRegistry", () => { - gameRegistry.register(chessPlugin); - expect(gameRegistry.get("chess")).toBe(chessPlugin); - expect(gameRegistry.list()).toContain(chessPlugin); - }); - }); -}); diff --git a/shared/games/chess/plugin.ts b/shared/games/chess/plugin.ts deleted file mode 100644 index 12ca990..0000000 --- a/shared/games/chess/plugin.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Chess } from "chess.js"; -import type { GamePlugin, GameResult, GameOverResult } from "../types"; -import type { ChessState, ChessAction } from "./types"; - -function getPlayerColor(state: ChessState, playerId: string): "white" | "black" | null { - if (state.players.white === playerId) return "white"; - if (state.players.black === playerId) return "black"; - return null; -} - -function deriveStatus(game: Chess): ChessState["status"] { - if (game.isCheckmate()) return "checkmate"; - if (game.isStalemate()) return "stalemate"; - if (game.isDraw()) return "draw"; - return "playing"; -} - -export const chessPlugin: GamePlugin = { - slug: "chess", - name: "Chess", - minPlayers: 2, - maxPlayers: 2, - - createInitialState(players: string[]): ChessState { - const game = new Chess(); - return { - fen: game.fen(), - players: { white: players[0]!, black: players[1]! }, - moveHistory: [], - status: "playing", - winner: null, - }; - }, - - handleAction(state: ChessState, action: ChessAction, playerId: string): GameResult { - if (state.status !== "playing") { - return { ok: false, error: "Game is already over" }; - } - - const solo = state.players.white === state.players.black; - - if (action.type === "forfeit") { - if (solo) return { ok: true, state: { ...state, status: "forfeit", winner: null } }; - const color = getPlayerColor(state, playerId); - if (!color) return { ok: false, error: "You are not a player in this game" }; - const winner = color === "white" ? state.players.black : state.players.white; - return { ok: true, state: { ...state, status: "forfeit", winner } }; - } - - if (action.type === "move") { - const game = new Chess(state.fen); - const turn = game.turn() === "w" ? "white" : "black"; - - if (!solo) { - const playerColor = getPlayerColor(state, playerId); - if (!playerColor) return { ok: false, error: "You are not a player in this game" }; - if (playerColor !== turn) return { ok: false, error: "It is not your turn" }; - } - - let move; - try { - move = game.move({ - from: action.from, - to: action.to, - promotion: action.promotion ?? "q", - }); - } catch { - return { ok: false, error: "Invalid move" }; - } - - if (!move) return { ok: false, error: "Invalid move" }; - - const status = deriveStatus(game); - const winner = status === "checkmate" - ? (turn === "white" ? state.players.white : state.players.black) - : null; - - return { - ok: true, - state: { - ...state, - fen: game.fen(), - moveHistory: [...state.moveHistory, move.san], - status, - winner, - }, - }; - } - - return { ok: false, error: "Unknown action type" }; - }, - - getPlayerView(state: ChessState, playerId: string): ChessState { - const playerColor = getPlayerColor(state, playerId); - if (!playerColor) return state; - return state; - }, - getSpectatorView(state: ChessState): ChessState { return state; }, - - isGameOver(state: ChessState): GameOverResult | null { - if (state.status === "playing") return null; - return { winner: state.winner, reason: state.status }; - }, - - onPlayerDisconnect(state: ChessState, playerId: string): ChessState { - if (state.players.white === state.players.black) { - return { ...state, status: "forfeit", winner: null }; - } - const color = getPlayerColor(state, playerId); - if (!color) return state; - const winner = color === "white" ? state.players.black : state.players.white; - return { ...state, status: "forfeit", winner }; - }, -}; diff --git a/shared/games/chess/types.ts b/shared/games/chess/types.ts deleted file mode 100644 index 2342625..0000000 --- a/shared/games/chess/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ChessState { - fen: string; - players: { white: string; black: string }; - moveHistory: string[]; - status: "playing" | "checkmate" | "stalemate" | "draw" | "forfeit"; - winner: string | null; -} - -export type ChessAction = - | { type: "move"; from: string; to: string; promotion?: string } - | { type: "forfeit" }; diff --git a/shared/modules/feature-flags/CLAUDE.md b/shared/modules/feature-flags/CLAUDE.md new file mode 100644 index 0000000..66dbb52 --- /dev/null +++ b/shared/modules/feature-flags/CLAUDE.md @@ -0,0 +1,9 @@ +# Feature Flags Module + +- **No caching.** Every `isFlagEnabled()` and `hasAccess()` call hits the database directly. +- `isFlagEnabled(flagName)` checks global on/off state. `hasAccess(flagName, context)` checks both global state AND per-entity access records (guild, user, or role). +- Access logic: flag must be globally enabled AND user must have an explicit access grant. Grants can target guildId, userId, or roleId independently. +- Commands declare `beta: true` and optionally `featureFlag: string` in the Command interface. `CommandHandler` intercepts beta commands and calls `hasAccess()` before execution. +- If a command has no explicit `featureFlag`, the command name (`interaction.commandName`) is used as the flag name fallback. +- Flag names are case-sensitive. Convention is snake_case or camelCase — no enforcement. +- Admin management via `/featureflags` command: CRUD on flags and access grants/revokes. diff --git a/shared/modules/guild-settings/CLAUDE.md b/shared/modules/guild-settings/CLAUDE.md new file mode 100644 index 0000000..867247a --- /dev/null +++ b/shared/modules/guild-settings/CLAUDE.md @@ -0,0 +1,10 @@ +# Guild Settings Module + +- `updateSetting()` uses a hardcoded `keyMap` to map friendly key names to DB columns. Use exact key names (e.g., `"studentRole"` not `"studentRoleId"`). Unknown keys throw `UserError`. +- Type coercion per column: Discord IDs → BigInt automatically; `colorRoleIds` must be array; `featureOverrides` must be object; `moderationDmOnWarn` must be boolean; `moderationAutoTimeoutThreshold` must be number. Null values set columns to NULL. +- **Caching:** `getGuildConfig()` (in `shared/lib/config.ts`) caches transformed settings for 60 seconds. Every mutation (`upsertSettings`, `updateSetting`, `addColorRole`, `removeColorRole`) calls `invalidateGuildConfigCache(guildId)` immediately. +- If settings don't exist for a guild, the cache returns safe defaults — no errors thrown. +- `featureOverrides` is a sparse `Record` — no keys are predefined. Consumers must check key existence. +- **No Discord validation:** The service does not verify that role/channel IDs actually exist in Discord. Invalid IDs are stored silently. +- `addColorRole()` / `removeColorRole()` fetch the full settings, mutate the array in JS, then upsert — this is not atomic and can race under concurrent requests. +- `terminalMessageId` and `terminalChannelId` are separate DB columns but grouped as `terminal: { channelId, messageId }` in the cached config. Setting one without the other can create orphaned data. diff --git a/shared/modules/leveling/CLAUDE.md b/shared/modules/leveling/CLAUDE.md new file mode 100644 index 0000000..697f108 --- /dev/null +++ b/shared/modules/leveling/CLAUDE.md @@ -0,0 +1,9 @@ +# Leveling Module + +- **Level is derived, not stored.** Total XP is the source of truth. `getLevelFromXp()` recalculates level from cumulative XP on every `addXp()` call. Levels are monotonic — they never decrease. +- XP curve is a power law: `xpForLevel(n) = floor(base * n^exponent)` where defaults are `base: 100`, `exponent: 1.5`. Config comes from `gameSettingsService` (30s cache TTL). +- Chat XP (`processChatXp()`) awards random XP between `minXp` (5) and `maxXp` (15) per message, gated by a 60-second per-user cooldown (`TimerType.COOLDOWN`, key `TimerKey.CHAT_XP`). The cooldown is upserted atomically. +- Quest/reward XP uses `addXp()` directly — it bypasses the chat cooldown. +- XP boost multipliers come from active `TimerType.EFFECT` timers with key `'xp_boost'` (metadata field: `multiplier`). +- All XP values are `bigint` in the DB but converted to `Number` for arithmetic. Watch for overflow at extremely high XP values. +- `addXp()` and `processChatXp()` run inside transactions. They emit `XP_GAINED` (fire-and-forget) which the quest system listens to — the weight equals the XP amount.