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:
syntaxbullet
2026-04-05 15:19:51 +02:00
parent ebac1ad6cc
commit 56db5bc998
24 changed files with 206 additions and 921 deletions

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