2 Commits

Author SHA1 Message Date
syntaxbullet
94d259e92a fix(panel): guild settings not pre-filling from database
Some checks failed
Deploy to Production / test (push) Failing after 33s
Guild draft was initialized with defaults before the API response
arrived, then never updated because the !guildDraft guard prevented
overwriting. Gate initialization on !loading so saved values are used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:28:40 +02:00
syntaxbullet
56db5bc998 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>
2026-04-05 15:19:51 +02:00
25 changed files with 208 additions and 924 deletions

31
api/src/CLAUDE.md Normal file
View 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`.

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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");
}
}
}

View File

@@ -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() }),
]);

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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=="],

View File

@@ -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
View 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`.

View File

@@ -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",

View File

@@ -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;

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -1,10 +0,0 @@
import { gameUIRegistry } from "../registry";
import { ChessBoard } from "./ChessBoard";
gameUIRegistry.register({
slug: "chess",
name: "Chess",
icon: "♟",
maxPlayers: 2,
component: ChessBoard,
});

View File

@@ -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]);

View File

@@ -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) => {

View File

@@ -20,7 +20,6 @@ import {
useSettings,
type GameSettings,
type GuildSettings,
type SettingsMeta,
} from "../lib/useSettings";
import {
GuildSection,
@@ -107,14 +106,14 @@ export default function Settings() {
}, [settings, gameDraft]);
useEffect(() => {
if (!guildDraft) {
if (!guildDraft && !loading) {
setGuildDraft(
guildSettings
? structuredClone(guildSettings)
: { ...defaultGuildSettings, guildId: meta?.guildId ?? "" }
);
}
}, [guildSettings, guildDraft, meta?.guildId]);
}, [guildSettings, guildDraft, meta?.guildId, loading]);
const isGuildTab = activeSection === "guild";

40
shared/db/CLAUDE.md Normal file
View 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.

View File

@@ -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);
});
});
});

View File

@@ -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 };
},
};

View File

@@ -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" };

View 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.

View 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.

View 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.