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) {
|
||||
|
||||
@@ -3,14 +3,8 @@ import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import { chessPlugin } from "@shared/games/chess/plugin";
|
||||
|
||||
import { startWebServerFromRoot } from "../api/src/server";
|
||||
|
||||
// Register game plugins
|
||||
gameRegistry.register(chessPlugin);
|
||||
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
|
||||
15
bun.lock
15
bun.lock
@@ -6,7 +6,6 @@
|
||||
"name": "app",
|
||||
"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",
|
||||
@@ -27,12 +26,10 @@
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
@@ -102,14 +99,6 @@
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
31
panel/CLAUDE.md
Normal file
31
panel/CLAUDE.md
Normal file
@@ -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`.
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="w-8 h-8 rounded-sm flex items-center justify-center text-sm font-bold shrink-0"
|
||||
style={{
|
||||
backgroundColor: color === "white" ? "#f0d9b5" : "#2b1d0e",
|
||||
color: color === "white" ? "#2b1d0e" : "#f0d9b5",
|
||||
border: "2px solid rgba(255,255,255,0.15)",
|
||||
}}
|
||||
>
|
||||
{username?.[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapturedPieces({ fen, color }: { fen: string; color: "white" | "black" }) {
|
||||
const captured = useMemo(() => {
|
||||
const game = new Chess(fen);
|
||||
const board = game.board();
|
||||
const counts: Record<string, number> = { 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<string, number> = { p: 8, n: 2, b: 2, r: 2, q: 1 };
|
||||
const pieceSymbols: Record<string, string> = {
|
||||
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 (
|
||||
<span className="text-sm opacity-70 leading-none tracking-tight">{captured.join("")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerPanel({
|
||||
username,
|
||||
color,
|
||||
isActive,
|
||||
fen,
|
||||
isTop,
|
||||
}: {
|
||||
username: string;
|
||||
color: "white" | "black";
|
||||
isActive: boolean;
|
||||
fen: string;
|
||||
isTop: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-sm select-none"
|
||||
style={{
|
||||
backgroundColor: "#262421",
|
||||
borderTop: isTop ? undefined : "2px solid #1a1917",
|
||||
borderBottom: isTop ? "2px solid #1a1917" : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar username={username} color={color} />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-semibold leading-none truncate" style={{ color: "#f0d9b5" }}>
|
||||
{username}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] opacity-50 leading-none capitalize" style={{ color: "#8e8984" }}>
|
||||
{color}
|
||||
</span>
|
||||
<CapturedPieces fen={fen} color={color === "white" ? "black" : "white"} />
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: "#81b64c" }}
|
||||
/>
|
||||
<span className="text-[11px] font-medium" style={{ color: "#81b64c" }}>
|
||||
Your turn
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveHistory({ moveHistory }: { moveHistory: string[] }) {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex flex-col h-full" style={{ backgroundColor: "#262421" }}>
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider border-b"
|
||||
style={{ color: "#8e8984", borderColor: "#1a1917" }}
|
||||
>
|
||||
Moves
|
||||
</div>
|
||||
<div ref={ref} className="flex-1 overflow-y-auto" style={{ maxHeight: "360px" }}>
|
||||
{pairs.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs" style={{ color: "#6e6966" }}>
|
||||
No moves yet
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs font-mono">
|
||||
<tbody>
|
||||
{pairs.map(([white, black], i) => (
|
||||
<tr
|
||||
key={i}
|
||||
style={{
|
||||
backgroundColor: i % 2 === 0 ? "transparent" : "rgba(255,255,255,0.03)",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
className="pl-3 pr-2 py-1 select-none w-8 text-right"
|
||||
style={{ color: "#6e6966" }}
|
||||
>
|
||||
{i + 1}.
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-1 w-16 font-medium"
|
||||
style={{ color: "#f0d9b5" }}
|
||||
>
|
||||
{white}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-1 w-16"
|
||||
style={{ color: black ? "#f0d9b5" : "transparent" }}
|
||||
>
|
||||
{black ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center z-10 rounded-sm"
|
||||
style={{ backgroundColor: "rgba(20, 18, 15, 0.82)", backdropFilter: "blur(2px)" }}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 px-8 py-6 rounded-lg"
|
||||
style={{ backgroundColor: "#1a1917", border: "1px solid #3a3733" }}
|
||||
>
|
||||
<div className="text-3xl font-bold" style={{ color: titleColor }}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: "#8e8984" }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
|
||||
const chess = state as ChessState;
|
||||
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
|
||||
const [promotionTo, setPromotionTo] = useState<string | null>(null);
|
||||
const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
|
||||
const [confirmForfeit, setConfirmForfeit] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(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<string>(() => chess?.fen ?? "start");
|
||||
const localFenRef = useRef<string>(localFen);
|
||||
const localMoveCountRef = useRef<number>(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 (
|
||||
<div className="flex items-center justify-center p-8 text-sm" style={{ color: "#8e8984" }}>
|
||||
Waiting for game to start…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, React.CSSProperties> = {};
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col lg:flex-row gap-0 items-start justify-center">
|
||||
{/* Board column */}
|
||||
<div
|
||||
className="flex flex-col rounded-sm overflow-hidden shadow-2xl"
|
||||
style={{ backgroundColor: "#262421" }}
|
||||
>
|
||||
{/* Opponent panel */}
|
||||
<PlayerPanel
|
||||
username={topPlayer?.username ?? (isSpectator ? "Player" : "Opponent")}
|
||||
color={topColor}
|
||||
isActive={isTopActive}
|
||||
fen={localFen}
|
||||
isTop
|
||||
/>
|
||||
|
||||
{/* Board wrapper */}
|
||||
<div ref={containerRef} className="relative" style={{ lineHeight: 0, width: boardWidth, maxWidth: "100%" }}>
|
||||
<Chessboard
|
||||
options={{
|
||||
position: localFen,
|
||||
onPieceDrop: onDrop,
|
||||
onSquareClick: onSquareClick,
|
||||
boardOrientation: boardOrientation,
|
||||
canDragPiece: canDragPiece,
|
||||
animationDurationInMs: 150,
|
||||
squareStyles: customSquareStyles,
|
||||
darkSquareStyle: { backgroundColor: DARK_SQUARE },
|
||||
lightSquareStyle: { backgroundColor: LIGHT_SQUARE },
|
||||
boardStyle: { borderRadius: "0" },
|
||||
dropSquareStyle: { boxShadow: "inset 0 0 1px 6px rgba(20,85,30,0.7)" },
|
||||
}}
|
||||
/>
|
||||
{promotionFrom !== null && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center z-20"
|
||||
style={{ backgroundColor: "rgba(20, 18, 15, 0.7)" }}
|
||||
>
|
||||
<div className="flex gap-1 p-2 rounded-lg" style={{ backgroundColor: "#1a1917", border: "1px solid #3a3733" }}>
|
||||
{["q", "r", "b", "n"].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handlePromotion(p)}
|
||||
className="w-12 h-12 flex items-center justify-center text-2xl rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: "#f0d9b5" }}
|
||||
>
|
||||
{{ q: turn === "white" ? "♕" : "♛", r: turn === "white" ? "♖" : "♜", b: turn === "white" ? "♗" : "♝", n: turn === "white" ? "♘" : "♞" }[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isGameOver && (
|
||||
<GameOverOverlay
|
||||
status={chess.status}
|
||||
winner={chess.winner}
|
||||
myPlayerId={myPlayerId}
|
||||
players={players}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My panel */}
|
||||
<PlayerPanel
|
||||
username={bottomPlayer?.username ?? (isSpectator ? "Spectator" : "You")}
|
||||
color={bottomColor}
|
||||
isActive={isBottomActive}
|
||||
fen={localFen}
|
||||
isTop={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className="flex flex-col lg:w-52 w-full rounded-sm overflow-hidden shadow-2xl lg:ml-2 mt-2 lg:mt-0"
|
||||
style={{
|
||||
backgroundColor: "#262421",
|
||||
border: "1px solid #1a1917",
|
||||
minHeight: "200px",
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
>
|
||||
<MoveHistory moveHistory={chess.moveHistory ?? []} />
|
||||
|
||||
{!isSpectator && chess.status === "playing" && (
|
||||
<div className="p-3 border-t" style={{ borderColor: "#1a1917" }}>
|
||||
{confirmForfeit ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction({ type: "forfeit" });
|
||||
setConfirmForfeit(false);
|
||||
}}
|
||||
className="flex-1 rounded px-3 py-2 text-xs font-semibold transition-colors"
|
||||
style={{
|
||||
backgroundColor: "rgba(180,40,40,0.2)",
|
||||
color: "#e06060",
|
||||
border: "1px solid rgba(180,40,40,0.4)",
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmForfeit(false)}
|
||||
className="rounded px-3 py-2 text-xs font-semibold transition-colors"
|
||||
style={{
|
||||
backgroundColor: "rgba(255,255,255,0.05)",
|
||||
color: "#8e8984",
|
||||
border: "1px solid #3a3733",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmForfeit(true)}
|
||||
className="w-full rounded px-3 py-2 text-xs font-semibold transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: "rgba(255,255,255,0.05)",
|
||||
color: "#8e8984",
|
||||
border: "1px solid #3a3733",
|
||||
}}
|
||||
>
|
||||
Resign
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { gameUIRegistry } from "../registry";
|
||||
import { ChessBoard } from "./ChessBoard";
|
||||
|
||||
gameUIRegistry.register({
|
||||
slug: "chess",
|
||||
name: "Chess",
|
||||
icon: "♟",
|
||||
maxPlayers: 2,
|
||||
component: ChessBoard,
|
||||
});
|
||||
@@ -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<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
navigateRef.current = navigate;
|
||||
}, [navigate]);
|
||||
useEffect(() => () => clearTimeout(errorTimerRef.current), []);
|
||||
|
||||
const [state, setState] = useState<GameRoomState>({
|
||||
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]);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
40
shared/db/CLAUDE.md
Normal file
40
shared/db/CLAUDE.md
Normal file
@@ -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<T>()` — `LevelingConfig`, `EconomyConfig`, etc.
|
||||
- `guildSettings.featureOverrides`: `Record<string, boolean>` (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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ChessState, ChessAction> = {
|
||||
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<ChessState> {
|
||||
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 };
|
||||
},
|
||||
};
|
||||
@@ -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" };
|
||||
9
shared/modules/feature-flags/CLAUDE.md
Normal file
9
shared/modules/feature-flags/CLAUDE.md
Normal file
@@ -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.
|
||||
10
shared/modules/guild-settings/CLAUDE.md
Normal file
10
shared/modules/guild-settings/CLAUDE.md
Normal file
@@ -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<string, boolean>` — 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.
|
||||
9
shared/modules/leveling/CLAUDE.md
Normal file
9
shared/modules/leveling/CLAUDE.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user