# Web Games Platform Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend the Aurora web panel with a player dashboard and multiplayer game system using a plugin architecture that makes adding new games trivial. **Architecture:** Two-tier auth (admin/player) via Discord OAuth, React Router for URL-based navigation, WebSocket pub/sub channels for real-time game state, and a GamePlugin interface where each game is a set of pure functions (server) + React component (client). RoomManager coordinates rooms generically without knowing game-specific logic. **Tech Stack:** React 19, React Router 7, Bun WebSocket, Zod validation, existing Drizzle ORM for user lookups **Spec:** `docs/superpowers/specs/2026-04-02-web-games-platform-design.md` --- ## File Map ### New Files ``` shared/games/types.ts — GamePlugin, GameResult, GameOverResult interfaces shared/games/registry.ts — gameRegistry singleton (register, get, list) shared/games/chess/types.ts — ChessState, ChessAction, piece types shared/games/chess/plugin.ts — Chess GamePlugin implementation shared/games/chess/plugin.test.ts — Tests for chess pure functions api/src/games/types.ts — Room, WsClientMessage, WsServerMessage types api/src/games/RoomManager.ts — Room CRUD, action routing, cleanup timers api/src/games/RoomManager.test.ts — RoomManager unit tests api/src/games/ws-handler.ts — WebSocket message router for game messages panel/src/lib/useWebSocket.ts — Shared WS connection + message routing panel/src/lib/useGameRoom.ts — Per-room hook (join, leave, sendAction, state) panel/src/games/registry.ts — Client-side GameUIPlugin registry panel/src/games/GameLobby.tsx — Room list + create dialog panel/src/games/GameRoom.tsx — Generic room wrapper + renders plugin component panel/src/games/chess/index.ts — Registers ChessUI plugin panel/src/games/chess/ChessBoard.tsx — Chess React component panel/src/pages/PlayerDashboard.tsx — Player stats, inventory, activity panel/src/pages/NotEnrolled.tsx — "Use the bot first" page ``` ### Modified Files ``` api/src/routes/auth.routes.ts — Remove admin gate, add enrollment check, add role to session api/src/routes/index.ts — Add role-based route protection (admin vs player) api/src/server.ts — Integrate game WS handler, raise connection limit shared/modules/dashboard/dashboard.types.ts — Add game WS message types to schema panel/package.json — Add react-router-dom dependency panel/src/App.tsx — Replace state routing with React Router panel/src/components/Layout.tsx — Accept role prop, render different nav per role panel/src/lib/useAuth.ts — Add role + enrolled fields panel/vite.config.ts — No changes needed (proxy already handles /ws) ``` --- ## Task 1: Game Plugin Interface & Registry **Files:** - Create: `shared/games/types.ts` - Create: `shared/games/registry.ts` - [ ] **Step 1: Create the GamePlugin interface** ```typescript // shared/games/types.ts export interface GamePlugin { slug: string; name: string; minPlayers: number; maxPlayers: number; createInitialState(players: string[]): TState; handleAction(state: TState, action: TAction, playerId: string): GameResult; getPlayerView(state: TState, playerId: string): unknown; getSpectatorView(state: TState): unknown; isGameOver?(state: TState): GameOverResult | null; onPlayerDisconnect?(state: TState, playerId: string): TState; } export type GameResult = | { ok: true; state: TState } | { ok: false; error: string }; export type GameOverResult = { winner: string | null; reason: string; }; ``` - [ ] **Step 2: Create the game registry** ```typescript // shared/games/registry.ts import type { GamePlugin } from "./types"; const games = new Map(); export const gameRegistry = { register(plugin: GamePlugin) { if (games.has(plugin.slug)) { throw new Error(`Game "${plugin.slug}" is already registered`); } games.set(plugin.slug, plugin); }, get(slug: string): GamePlugin | undefined { return games.get(slug); }, list(): GamePlugin[] { return Array.from(games.values()); }, }; ``` - [ ] **Step 3: Commit** ```bash git add shared/games/types.ts shared/games/registry.ts git commit -m "feat(games): add GamePlugin interface and registry" ``` --- ## Task 2: Chess Plugin — Server-Side **Files:** - Create: `shared/games/chess/types.ts` - Create: `shared/games/chess/plugin.ts` - Create: `shared/games/chess/plugin.test.ts` - [ ] **Step 1: Define chess types** ```typescript // shared/games/chess/types.ts export type PieceColor = "white" | "black"; export type PieceType = "pawn" | "rook" | "knight" | "bishop" | "queen" | "king"; export interface Piece { type: PieceType; color: PieceColor; } export interface ChessState { board: (Piece | null)[][]; // 8x8 grid, board[row][col] currentTurn: PieceColor; players: { white: string; black: string }; // discordIds moveHistory: string[]; // algebraic notation status: "playing" | "checkmate" | "stalemate" | "forfeit"; winner: string | null; } export type ChessAction = | { type: "move"; from: [number, number]; to: [number, number] } | { type: "forfeit" }; ``` - [ ] **Step 2: Write failing tests for chess plugin** ```typescript // shared/games/chess/plugin.test.ts import { describe, it, expect } from "bun:test"; import { chessPlugin } from "./plugin"; import type { ChessState, ChessAction } from "./types"; describe("chessPlugin", () => { const PLAYER_WHITE = "player1"; const PLAYER_BLACK = "player2"; 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 a board with pieces in starting positions", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); expect(state.board.length).toBe(8); expect(state.board[0].length).toBe(8); expect(state.currentTurn).toBe("white"); expect(state.players.white).toBe(PLAYER_WHITE); expect(state.players.black).toBe(PLAYER_BLACK); expect(state.moveHistory).toEqual([]); expect(state.status).toBe("playing"); }); it("should place white pawns on row 6 and black pawns on row 1", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); for (let col = 0; col < 8; col++) { expect(state.board[6][col]).toEqual({ type: "pawn", color: "white" }); expect(state.board[1][col]).toEqual({ type: "pawn", color: "black" }); } }); it("should place rooks in corners", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); expect(state.board[0][0]).toEqual({ type: "rook", color: "black" }); expect(state.board[0][7]).toEqual({ type: "rook", color: "black" }); expect(state.board[7][0]).toEqual({ type: "rook", color: "white" }); expect(state.board[7][7]).toEqual({ type: "rook", color: "white" }); }); }); describe("handleAction — move", () => { it("should allow white pawn to move forward on white's turn", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); const result = chessPlugin.handleAction(state, { type: "move", from: [6, 4], to: [4, 4] }, PLAYER_WHITE); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.board[4][4]).toEqual({ type: "pawn", color: "white" }); expect(result.state.board[6][4]).toBeNull(); expect(result.state.currentTurn).toBe("black"); } }); it("should reject move when it is not the player's turn", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); // Black tries to move on white's turn — should be rejected const result = chessPlugin.handleAction(state, { type: "move", from: [1, 4], to: [3, 4] }, PLAYER_BLACK); expect(result.ok).toBe(false); }); it("should reject moving opponent's piece", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); // White tries to move a black pawn const result = chessPlugin.handleAction(state, { type: "move", from: [1, 4], to: [3, 4] }, PLAYER_WHITE); expect(result.ok).toBe(false); }); it("should reject move from empty square", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); const result = chessPlugin.handleAction(state, { type: "move", from: [4, 4], to: [3, 4] }, PLAYER_WHITE); expect(result.ok).toBe(false); }); it("should reject out-of-bounds coordinates", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); const result = chessPlugin.handleAction(state, { type: "move", from: [8, 0], to: [7, 0] }, PLAYER_WHITE); expect(result.ok).toBe(false); }); }); 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 (chess has no hidden info)", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); const view = chessPlugin.getPlayerView(state, PLAYER_WHITE); expect(view).toEqual(state); }); }); describe("getSpectatorView", () => { it("should return full state", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); const view = chessPlugin.getSpectatorView(state); expect(view).toEqual(state); }); }); describe("isGameOver", () => { it("should return null for ongoing game", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); const result = chessPlugin.isGameOver!(state); expect(result).toBeNull(); }); it("should return winner for forfeit", () => { const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]); state.status = "forfeit"; state.winner = PLAYER_BLACK; const result = chessPlugin.isGameOver!(state); expect(result).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); }); }); }); ``` - [ ] **Step 3: Run tests to verify they fail** Run: `bun test shared/games/chess/plugin.test.ts` Expected: FAIL — `chessPlugin` module not found - [ ] **Step 4: Implement chess plugin** ```typescript // shared/games/chess/plugin.ts import type { GamePlugin, GameResult, GameOverResult } from "../types"; import type { ChessState, ChessAction, Piece, PieceColor, PieceType } from "./types"; const BACK_ROW: PieceType[] = ["rook", "knight", "bishop", "queen", "king", "bishop", "knight", "rook"]; function createStartingBoard(): (Piece | null)[][] { const board: (Piece | null)[][] = Array.from({ length: 8 }, () => Array(8).fill(null)); for (let col = 0; col < 8; col++) { board[0][col] = { type: BACK_ROW[col], color: "black" }; board[1][col] = { type: "pawn", color: "black" }; board[6][col] = { type: "pawn", color: "white" }; board[7][col] = { type: BACK_ROW[col], color: "white" }; } return board; } function inBounds(row: number, col: number): boolean { return row >= 0 && row < 8 && col >= 0 && col < 8; } function getPlayerColor(state: ChessState, playerId: string): PieceColor | null { if (state.players.white === playerId) return "white"; if (state.players.black === playerId) return "black"; return null; } function isValidMove(board: (Piece | null)[][], from: [number, number], to: [number, number], piece: Piece): boolean { const [fromRow, fromCol] = from; const [toRow, toCol] = to; const target = board[toRow][toCol]; // Can't capture own piece if (target && target.color === piece.color) return false; const rowDiff = toRow - fromRow; const colDiff = toCol - fromCol; const absRow = Math.abs(rowDiff); const absCol = Math.abs(colDiff); switch (piece.type) { case "pawn": { const direction = piece.color === "white" ? -1 : 1; const startRow = piece.color === "white" ? 6 : 1; // Forward one if (colDiff === 0 && rowDiff === direction && !target) return true; // Forward two from start if (colDiff === 0 && rowDiff === 2 * direction && fromRow === startRow && !target && !board[fromRow + direction][fromCol]) return true; // Capture diagonal if (absCol === 1 && rowDiff === direction && target) return true; return false; } case "rook": if (fromRow !== toRow && fromCol !== toCol) return false; return isPathClear(board, from, to); case "knight": return (absRow === 2 && absCol === 1) || (absRow === 1 && absCol === 2); case "bishop": if (absRow !== absCol) return false; return isPathClear(board, from, to); case "queen": if (fromRow !== toRow && fromCol !== toCol && absRow !== absCol) return false; return isPathClear(board, from, to); case "king": return absRow <= 1 && absCol <= 1; default: return false; } } function isPathClear(board: (Piece | null)[][], from: [number, number], to: [number, number]): boolean { const [fromRow, fromCol] = from; const [toRow, toCol] = to; const rowStep = Math.sign(toRow - fromRow); const colStep = Math.sign(toCol - fromCol); let row = fromRow + rowStep; let col = fromCol + colStep; while (row !== toRow || col !== toCol) { if (board[row][col]) return false; row += rowStep; col += colStep; } return true; } function toAlgebraic(from: [number, number], to: [number, number], piece: Piece, captured: boolean): string { const files = "abcdefgh"; const prefix = piece.type === "pawn" ? "" : piece.type[0].toUpperCase(); const cap = captured ? "x" : ""; const fromStr = piece.type === "pawn" && captured ? files[from[1]] : ""; return `${prefix}${fromStr}${cap}${files[to[1]]}${8 - to[0]}`; } export const chessPlugin: GamePlugin = { slug: "chess", name: "Chess", minPlayers: 2, maxPlayers: 2, createInitialState(players: string[]): ChessState { return { board: createStartingBoard(), currentTurn: "white", players: { white: players[0], black: players[1] }, moveHistory: [], status: "playing", winner: null, }; }, handleAction(state: ChessState, action: ChessAction, playerId: string): GameResult { if (state.status !== "playing") { return { ok: false, error: "Game is already over" }; } if (action.type === "forfeit") { 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 { from, to } = action; if (!inBounds(from[0], from[1]) || !inBounds(to[0], to[1])) { return { ok: false, error: "Coordinates out of bounds" }; } const piece = state.board[from[0]][from[1]]; if (!piece) { return { ok: false, error: "No piece at source square" }; } const playerColor = getPlayerColor(state, playerId); if (!playerColor) { return { ok: false, error: "You are not a player in this game" }; } if (playerColor !== state.currentTurn) { return { ok: false, error: "It is not your turn" }; } if (piece.color !== playerColor) { return { ok: false, error: "That is not your piece" }; } if (!isValidMove(state.board, from, to, piece)) { return { ok: false, error: "Invalid move" }; } // Apply move (immutable) const newBoard = state.board.map(row => [...row]); const captured = newBoard[to[0]][to[1]]; newBoard[to[0]][to[1]] = piece; newBoard[from[0]][from[1]] = null; const notation = toAlgebraic(from, to, piece, captured !== null); const nextTurn: PieceColor = state.currentTurn === "white" ? "black" : "white"; return { ok: true, state: { ...state, board: newBoard, currentTurn: nextTurn, moveHistory: [...state.moveHistory, notation], }, }; } return { ok: false, error: "Unknown action type" }; }, getPlayerView(state: ChessState, _playerId: string): ChessState { 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 { const color = getPlayerColor(state, playerId); if (!color) return state; const winner = color === "white" ? state.players.black : state.players.white; return { ...state, status: "forfeit", winner }; }, }; ``` - [ ] **Step 5: Run tests to verify they pass** Run: `bun test shared/games/chess/plugin.test.ts` Expected: All tests PASS - [ ] **Step 6: Register chess in the registry (smoke test)** Add a quick manual check — open a Bun REPL or add a temporary test: ```typescript // Add to the bottom of shared/games/chess/plugin.test.ts import { gameRegistry } from "../registry"; describe("chess registration", () => { it("should register and retrieve from gameRegistry", () => { gameRegistry.register(chessPlugin); expect(gameRegistry.get("chess")).toBe(chessPlugin); expect(gameRegistry.list()).toContain(chessPlugin); }); }); ``` Run: `bun test shared/games/chess/plugin.test.ts` Expected: All tests PASS - [ ] **Step 7: Commit** ```bash git add shared/games/chess/ git commit -m "feat(games): implement chess plugin with tests" ``` --- ## Task 3: Game Room Types & WebSocket Message Schema **Files:** - Create: `api/src/games/types.ts` - Modify: `shared/modules/dashboard/dashboard.types.ts` - [ ] **Step 1: Create game room and WS message types** ```typescript // api/src/games/types.ts import { z } from "zod"; // --- Room types --- export interface Room { id: string; gameSlug: string; host: string; players: string[]; spectators: Set; state: unknown; status: "waiting" | "playing" | "finished"; createdAt: number; } export interface RoomSummary { id: string; gameSlug: string; gameName: string; host: string; playerCount: number; maxPlayers: number; spectatorCount: number; status: "waiting" | "playing" | "finished"; } export interface PlayerInfo { discordId: string; username: string; } // --- Client → Server messages --- export const GameWsClientSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }), z.object({ type: z.literal("JOIN_ROOM"), roomId: z.string(), as: z.enum(["player", "spectator"]) }), 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()) }), ]); export type GameWsClientMessage = z.infer; // --- Server → Client messages --- export type GameWsServerMessage = | { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] } | { type: "GAME_STATE"; roomId: string; state: unknown } | { type: "GAME_UPDATE"; roomId: string; state: unknown } | { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; as: "player" | "spectator" } | { type: "PLAYER_LEFT"; roomId: string; playerId: string } | { type: "GAME_STARTED"; roomId: string; state: unknown } | { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string } | { type: "ROOM_CREATED"; roomId: string; gameSlug: string } | { type: "ERROR"; message: string }; ``` - [ ] **Step 2: Extend WsMessageSchema to include game messages** In `shared/modules/dashboard/dashboard.types.ts`, update the `WsMessageSchema` at lines 108-113 to include game messages alongside existing ones: ```typescript // shared/modules/dashboard/dashboard.types.ts // Replace lines 108-113 with: export const WsMessageSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("PING") }), z.object({ type: z.literal("PONG") }), z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }), z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }), // Game messages (client → server) z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }), z.object({ type: z.literal("JOIN_ROOM"), roomId: z.string(), as: z.enum(["player", "spectator"]) }), 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()) }), ]); ``` - [ ] **Step 3: Commit** ```bash git add api/src/games/types.ts shared/modules/dashboard/dashboard.types.ts git commit -m "feat(games): add room types and game WS message schemas" ``` --- ## Task 4: RoomManager **Files:** - Create: `api/src/games/RoomManager.ts` - Create: `api/src/games/RoomManager.test.ts` - [ ] **Step 1: Write failing tests for RoomManager** ```typescript // api/src/games/RoomManager.test.ts 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 { RoomSummary } from "./types"; // Register chess plugin for tests if (!gameRegistry.get("chess")) { gameRegistry.register(chessPlugin); } describe("RoomManager", () => { let manager: RoomManager; beforeEach(() => { manager = new RoomManager(); }); describe("createRoom", () => { it("should create a room and return its id", () => { const result = manager.createRoom("chess", "player1"); expect(result.ok).toBe(true); if (result.ok) { expect(result.roomId).toBeDefined(); expect(typeof result.roomId).toBe("string"); } }); it("should reject unknown game type", () => { const result = manager.createRoom("unknown-game", "player1"); expect(result.ok).toBe(false); }); it("should add creator as first player", () => { const result = manager.createRoom("chess", "player1"); if (result.ok) { const room = manager.getRoom(result.roomId); expect(room?.players).toContain("player1"); expect(room?.host).toBe("player1"); expect(room?.status).toBe("waiting"); } }); }); describe("joinRoom", () => { it("should add a player to a waiting room", () => { const create = manager.createRoom("chess", "player1"); if (!create.ok) throw new Error("Failed to create room"); const join = manager.joinRoom(create.roomId, "player2", "player"); expect(join.ok).toBe(true); }); it("should auto-start when room reaches maxPlayers", () => { const create = manager.createRoom("chess", "player1"); if (!create.ok) throw new Error("Failed to create room"); manager.joinRoom(create.roomId, "player2", "player"); const room = manager.getRoom(create.roomId); expect(room?.status).toBe("playing"); expect(room?.state).toBeDefined(); }); it("should allow joining as spectator when game is playing", () => { const create = manager.createRoom("chess", "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"); expect(spec.ok).toBe(true); }); it("should reject joining full room as player", () => { const create = manager.createRoom("chess", "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"); expect(result.ok).toBe(false); }); it("should reject joining nonexistent room", () => { const result = manager.joinRoom("fake-id", "player1", "player"); expect(result.ok).toBe(false); }); }); describe("handleAction", () => { it("should apply a valid game action", () => { const create = manager.createRoom("chess", "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: [6, 4], to: [4, 4] }); expect(result.ok).toBe(true); }); it("should reject action from spectator", () => { const create = manager.createRoom("chess", "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: [6, 4], to: [4, 4] }); expect(result.ok).toBe(false); }); }); describe("leaveRoom", () => { it("should remove a player from the room", () => { const create = manager.createRoom("chess", "player1"); if (!create.ok) throw new Error("Failed to create room"); manager.leaveRoom(create.roomId, "player1"); const room = manager.getRoom(create.roomId); expect(room?.players).not.toContain("player1"); }); it("should remove a spectator from the room", () => { const create = manager.createRoom("chess", "player1"); if (!create.ok) throw new Error("Failed to create room"); manager.joinRoom(create.roomId, "player2", "player"); manager.joinRoom(create.roomId, "spec1", "spectator"); manager.leaveRoom(create.roomId, "spec1"); const room = manager.getRoom(create.roomId); expect(room?.spectators.has("spec1")).toBe(false); }); }); describe("listRooms", () => { it("should return summaries of all rooms", () => { manager.createRoom("chess", "player1"); manager.createRoom("chess", "player2"); const rooms = manager.listRooms(); expect(rooms.length).toBe(2); expect(rooms[0].gameSlug).toBe("chess"); expect(rooms[0].status).toBe("waiting"); }); it("should filter by game type", () => { manager.createRoom("chess", "player1"); const rooms = manager.listRooms("chess"); expect(rooms.length).toBe(1); const empty = manager.listRooms("blackjack"); expect(empty.length).toBe(0); }); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `bun test api/src/games/RoomManager.test.ts` Expected: FAIL — `RoomManager` module not found - [ ] **Step 3: Implement RoomManager** ```typescript // api/src/games/RoomManager.ts import { gameRegistry } from "@shared/games/registry"; import type { Room, RoomSummary } from "./types"; type ActionResult = | { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null } | { ok: false; error: string }; type CreateResult = { ok: true; roomId: string } | { ok: false; error: string }; type JoinResult = { ok: true; started: boolean } | { ok: false; error: string }; export class RoomManager { private rooms = new Map(); private cleanupTimers = new Map(); createRoom(gameSlug: string, hostId: string): CreateResult { const plugin = gameRegistry.get(gameSlug); if (!plugin) { return { ok: false, error: `Unknown game type: ${gameSlug}` }; } const id = crypto.randomUUID(); const room: Room = { id, gameSlug, host: hostId, players: [hostId], spectators: new Set(), state: null, status: "waiting", createdAt: Date.now(), }; this.rooms.set(id, room); this.scheduleCleanup(id, 60_000); return { ok: true, roomId: id }; } joinRoom(roomId: string, playerId: string, as: "player" | "spectator"): JoinResult { const room = this.rooms.get(roomId); if (!room) return { ok: false, error: "Room not found" }; if (as === "spectator") { room.spectators.add(playerId); return { ok: true, started: false }; } // Joining as player if (room.status !== "waiting") { return { ok: false, error: "Game already started" }; } const plugin = gameRegistry.get(room.gameSlug)!; if (room.players.length >= plugin.maxPlayers) { return { ok: false, error: "Room is full" }; } if (room.players.includes(playerId)) { return { ok: false, error: "Already in room" }; } room.players.push(playerId); // Auto-start if full if (room.players.length >= plugin.maxPlayers) { room.state = plugin.createInitialState(room.players); room.status = "playing"; this.clearCleanup(roomId); return { ok: true, started: true }; } return { ok: true, started: false }; } handleAction(roomId: string, playerId: string, action: unknown): ActionResult { const room = this.rooms.get(roomId); if (!room) return { ok: false, error: "Room not found" }; if (room.status !== "playing") return { ok: false, error: "Game is not in progress" }; if (!room.players.includes(playerId)) { return { ok: false, error: "You are not a player in this game" }; } const plugin = gameRegistry.get(room.gameSlug)!; const result = plugin.handleAction(room.state, action, playerId); if (!result.ok) return result; room.state = result.state; const gameOver = plugin.isGameOver?.(room.state) ?? null; if (gameOver) { room.status = "finished"; this.scheduleCleanup(roomId, 60_000); } return { ok: true, state: room.state, gameOver }; } leaveRoom(roomId: string, playerId: string): void { const room = this.rooms.get(roomId); if (!room) return; if (room.spectators.has(playerId)) { room.spectators.delete(playerId); return; } const playerIdx = room.players.indexOf(playerId); if (playerIdx === -1) return; room.players.splice(playerIdx, 1); // If game is playing and a player leaves, handle disconnect if (room.status === "playing") { const plugin = gameRegistry.get(room.gameSlug)!; if (plugin.onPlayerDisconnect) { room.state = plugin.onPlayerDisconnect(room.state, playerId); const gameOver = plugin.isGameOver?.(room.state) ?? null; if (gameOver) { room.status = "finished"; this.scheduleCleanup(roomId, 60_000); } } } // Clean up empty waiting rooms immediately if (room.players.length === 0 && room.status === "waiting") { this.deleteRoom(roomId); } } getRoom(roomId: string): Room | undefined { return this.rooms.get(roomId); } listRooms(gameSlug?: string): RoomSummary[] { const summaries: RoomSummary[] = []; for (const room of this.rooms.values()) { if (gameSlug && room.gameSlug !== gameSlug) continue; const plugin = gameRegistry.get(room.gameSlug); summaries.push({ id: room.id, gameSlug: room.gameSlug, gameName: plugin?.name ?? room.gameSlug, host: room.host, playerCount: room.players.length, maxPlayers: plugin?.maxPlayers ?? 0, spectatorCount: room.spectators.size, status: room.status, }); } return summaries; } getPlayerView(roomId: string, playerId: string): unknown { const room = this.rooms.get(roomId); if (!room || !room.state) return null; const plugin = gameRegistry.get(room.gameSlug)!; return plugin.getPlayerView(room.state, playerId); } getSpectatorView(roomId: string): unknown { const room = this.rooms.get(roomId); if (!room || !room.state) return null; const plugin = gameRegistry.get(room.gameSlug)!; return plugin.getSpectatorView(room.state); } private scheduleCleanup(roomId: string, ms: number): void { this.clearCleanup(roomId); const timer = setTimeout(() => this.deleteRoom(roomId), ms); this.cleanupTimers.set(roomId, timer); } private clearCleanup(roomId: string): void { const existing = this.cleanupTimers.get(roomId); if (existing) { clearTimeout(existing); this.cleanupTimers.delete(roomId); } } private deleteRoom(roomId: string): void { this.clearCleanup(roomId); this.rooms.delete(roomId); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `bun test api/src/games/RoomManager.test.ts` Expected: All tests PASS - [ ] **Step 5: Commit** ```bash git add api/src/games/ git commit -m "feat(games): implement RoomManager with room lifecycle and tests" ``` --- ## Task 5: WebSocket Game Handler & Server Integration **Files:** - Create: `api/src/games/ws-handler.ts` - Modify: `api/src/server.ts` - [ ] **Step 1: Create the WebSocket game message handler** This module receives parsed game messages and routes them through the RoomManager, sending responses back via the server's publish/send mechanisms. ```typescript // api/src/games/ws-handler.ts import { RoomManager } from "./RoomManager"; import type { GameWsClientMessage, PlayerInfo } from "./types"; import { logger } from "@shared/lib/logger"; export const roomManager = new RoomManager(); interface WsContext { playerId: string; username: string; send: (data: string) => void; subscribe: (channel: string) => void; unsubscribe: (channel: string) => void; publish: (channel: string, data: string) => void; } export function handleGameMessage(msg: GameWsClientMessage, ctx: WsContext): void { switch (msg.type) { case "CREATE_ROOM": { const result = roomManager.createRoom(msg.gameType, ctx.playerId); if (!result.ok) { ctx.send(JSON.stringify({ type: "ERROR", message: result.error })); return; } ctx.subscribe(`room:${result.roomId}`); ctx.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType })); ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); logger.debug("games", `Room created: ${result.roomId} (${msg.gameType}) by ${ctx.playerId}`); break; } case "JOIN_ROOM": { const result = roomManager.joinRoom(msg.roomId, ctx.playerId, msg.as); if (!result.ok) { ctx.send(JSON.stringify({ type: "ERROR", message: result.error })); return; } ctx.subscribe(`room:${msg.roomId}`); const playerInfo: PlayerInfo = { discordId: ctx.playerId, username: ctx.username }; ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "PLAYER_JOINED", roomId: msg.roomId, player: playerInfo, as: msg.as })); if (result.started) { // Game auto-started — send each participant their view const room = roomManager.getRoom(msg.roomId)!; // Broadcast game started with spectator view on the channel const spectatorView = roomManager.getSpectatorView(msg.roomId); ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_STARTED", roomId: msg.roomId, state: spectatorView })); } else { // Send current game state if joining mid-game as spectator const room = roomManager.getRoom(msg.roomId); if (room && room.status === "playing") { const view = msg.as === "spectator" ? roomManager.getSpectatorView(msg.roomId) : roomManager.getPlayerView(msg.roomId, ctx.playerId); ctx.send(JSON.stringify({ type: "GAME_STATE", roomId: msg.roomId, state: view })); } } ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); break; } case "LEAVE_ROOM": { roomManager.leaveRoom(msg.roomId, ctx.playerId); ctx.unsubscribe(`room:${msg.roomId}`); ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "PLAYER_LEFT", roomId: msg.roomId, playerId: ctx.playerId })); ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); break; } case "GAME_ACTION": { const result = roomManager.handleAction(msg.roomId, ctx.playerId, msg.action); if (!result.ok) { ctx.send(JSON.stringify({ type: "ERROR", message: result.error })); return; } // Broadcast updated spectator view to room const spectatorView = roomManager.getSpectatorView(msg.roomId); ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_UPDATE", roomId: msg.roomId, state: spectatorView })); if (result.gameOver) { ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_ENDED", roomId: msg.roomId, winner: result.gameOver.winner, reason: result.gameOver.reason, })); ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); } break; } } } ``` - [ ] **Step 2: Modify server.ts to integrate game WS handler** In `api/src/server.ts`, make the following changes: 1. Add import at the top (after line 13): ```typescript import { handleGameMessage, roomManager } from "./games/ws-handler"; import { getSession } from "./routes/auth.routes"; ``` 2. Raise MAX_CONNECTIONS from 10 to 200 (line 94): ```typescript const MAX_CONNECTIONS = 200; ``` 3. Add session validation on WS upgrade. Replace lines 108-117: ```typescript // WebSocket upgrade handling if (url.pathname === "/ws") { const currentConnections = server.pendingWebSockets; if (currentConnections >= MAX_CONNECTIONS) { logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`); return new Response("Connection limit reached", { status: 429 }); } // Attach session data to WS connection const session = getSession(req); const success = server.upgrade(req, { data: { session } }); if (success) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); } ``` 4. Update the `open` handler to subscribe to lobby channel (replace lines 138-157): ```typescript open(ws) { ws.subscribe("dashboard"); ws.subscribe("lobby"); logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`); // Send initial stats getFullDashboardStats().then(stats => { ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats })); }); // Send initial room list ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); // Start broadcast interval if this is the first client if (!statsBroadcastInterval) { statsBroadcastInterval = setInterval(async () => { try { const stats = await getFullDashboardStats(); server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats })); } catch (error) { logger.error("web", "Error in stats broadcast", error); } }, 5000); } }, ``` 5. Update the `message` handler to route game messages (replace lines 164-189): ```typescript async message(ws, message) { try { const messageStr = message.toString(); if (messageStr.length > MAX_PAYLOAD_BYTES) { logger.error("web", "Payload exceeded maximum limit"); return; } const rawData = JSON.parse(messageStr); 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); return; } if (parsed.data.type === "PING") { ws.send(JSON.stringify({ type: "PONG" })); return; } // Route game messages const gameTypes = ["CREATE_ROOM", "JOIN_ROOM", "LEAVE_ROOM", "GAME_ACTION"] as const; if (gameTypes.includes(parsed.data.type as any)) { const sessionData = (ws.data as any)?.session; if (!sessionData) { ws.send(JSON.stringify({ type: "ERROR", message: "Not authenticated" })); return; } handleGameMessage(parsed.data as any, { playerId: sessionData.discordId, username: sessionData.username, send: (data) => ws.send(data), subscribe: (channel) => ws.subscribe(channel), unsubscribe: (channel) => ws.unsubscribe(channel), publish: (channel, data) => server.publish(channel, data), }); } } catch (e) { logger.error("web", "Failed to handle message", e); } }, ``` 6. Update `close` handler to unsubscribe from lobby (replace lines 195-204): ```typescript close(ws) { ws.unsubscribe("dashboard"); ws.unsubscribe("lobby"); logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`); if (server.pendingWebSockets === 0 && statsBroadcastInterval) { clearInterval(statsBroadcastInterval); statsBroadcastInterval = undefined; } }, ``` - [ ] **Step 3: Commit** ```bash git add api/src/games/ws-handler.ts api/src/server.ts git commit -m "feat(games): integrate game WS handler into server" ``` --- ## Task 6: Auth — Remove Admin Gate, Add Enrollment Check & Roles **Files:** - Modify: `api/src/routes/auth.routes.ts` - Modify: `api/src/routes/index.ts` - [ ] **Step 1: Update Session interface and auth callback** In `api/src/routes/auth.routes.ts`: 1. Add role to Session interface (replace lines 11-16): ```typescript export interface Session { discordId: string; username: string; avatar: string | null; role: "admin" | "player"; expiresAt: number; } ``` 2. Add DrizzleClient import after line 8: ```typescript import { DrizzleClient } from "@shared/db/DrizzleClient"; import { users } from "@shared/db/schema"; import { eq } from "drizzle-orm"; ``` 3. Replace the allowlist check and session creation block (lines 147-164) with enrollment check + role assignment: ```typescript // Check enrollment — user must exist in the users table const dbUser = await DrizzleClient.query.users.findFirst({ where: eq(users.id, BigInt(user.id)), }); if (!dbUser) { logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`); // Return a page that tells them to use the bot first return new Response( `

Not Enrolled

You need to use the Aurora bot in Discord before you can access this panel.

Go back`, { status: 403, headers: { "Content-Type": "text/html" } } ); } // Determine role const adminIds = getAdminIds(); const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player"; // Create session const token = generateToken(); sessions.set(token, { discordId: user.id, username: user.username, avatar: user.avatar, role, expiresAt: Date.now() + SESSION_MAX_AGE, }); logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`); ``` 4. Update `/auth/me` response (replace lines 214-225) to include role and enrolled: ```typescript // GET /auth/me — return current session info if (pathname === "/auth/me" && method === "GET") { const session = getSession(ctx.req); if (!session) return jsonResponse({ authenticated: false, enrolled: false }); return jsonResponse({ authenticated: true, enrolled: true, user: { discordId: session.discordId, username: session.username, avatar: session.avatar, role: session.role, }, }); } ``` - [ ] **Step 2: Update route protection for role-based access** In `api/src/routes/index.ts`, update the auth check (lines 71-76) to allow players access to certain API routes while restricting admin-only routes: ```typescript // For API routes, enforce authentication if (ctx.pathname.startsWith("/api/")) { const session = getSession(req); if (!session) { return errorResponse("Unauthorized", 401); } // Admin-only routes: everything except /api/users/:id (own profile) and /api/stats const playerAllowedPrefixes = ["/api/stats", "/api/health"]; const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p)); // Players can access their own user data const isOwnUserRoute = ctx.pathname.match(/^\/api\/users\/\d+/) && session.role === "player"; if (session.role === "player" && !isPlayerAllowed && !isOwnUserRoute) { return errorResponse("Admin access required", 403); } } ``` Update the import at line 7 to also import `getSession`: ```typescript import { authRoutes, isAuthenticated, getSession } from "./auth.routes"; ``` - [ ] **Step 3: Commit** ```bash git add api/src/routes/auth.routes.ts api/src/routes/index.ts git commit -m "feat(auth): add enrollment check, role-based sessions, and player access" ``` --- ## Task 7: Panel — Install React Router & Update Auth Hook **Files:** - Modify: `panel/package.json` (via bun add) - Modify: `panel/src/lib/useAuth.ts` - Create: `panel/src/pages/NotEnrolled.tsx` - [ ] **Step 1: Install react-router-dom** Run: `cd panel && bun add react-router-dom` - [ ] **Step 2: Update useAuth hook to include role and enrolled** ```typescript // panel/src/lib/useAuth.ts import { useState, useEffect } from "react"; export interface AuthUser { discordId: string; username: string; avatar: string | null; role: "admin" | "player"; } interface AuthState { loading: boolean; user: AuthUser | null; enrolled: boolean; } export function useAuth(): AuthState & { logout: () => Promise } { const [state, setState] = useState({ loading: true, user: null, enrolled: true }); useEffect(() => { fetch("/auth/me", { credentials: "same-origin" }) .then((r) => r.json()) .then((data: { authenticated: boolean; enrolled: boolean; user?: AuthUser }) => { setState({ loading: false, user: data.authenticated ? data.user! : null, enrolled: data.enrolled ?? true, }); }) .catch(() => setState({ loading: false, user: null, enrolled: true })); }, []); const logout = async () => { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); setState({ loading: false, user: null, enrolled: true }); }; return { ...state, logout }; } ``` - [ ] **Step 3: Create NotEnrolled page** ```typescript // panel/src/pages/NotEnrolled.tsx export default function NotEnrolled() { return (
Aurora

You need to use the Aurora bot in Discord before you can access this panel.

Use /enroll in any server with Aurora to get started.

); } ``` - [ ] **Step 4: Commit** ```bash git add panel/package.json panel/bun.lock panel/src/lib/useAuth.ts panel/src/pages/NotEnrolled.tsx git commit -m "feat(panel): add react-router-dom, update auth hook with roles, add NotEnrolled page" ``` --- ## Task 8: Panel — React Router & Layout Refactor **Files:** - Modify: `panel/src/App.tsx` - Modify: `panel/src/components/Layout.tsx` - [ ] **Step 1: Refactor Layout to accept role and use React Router navigation** ```typescript // panel/src/components/Layout.tsx import { LayoutDashboard, Users, Package, Shield, Scroll, Gift, ArrowLeftRight, GraduationCap, Settings, LogOut, ChevronLeft, ChevronRight, Gamepad2, Trophy, } from "lucide-react"; import { useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { cn } from "../lib/utils"; import type { AuthUser } from "../lib/useAuth"; interface NavItem { path: string; label: string; icon: React.ComponentType<{ className?: string }>; } const adminNavItems: NavItem[] = [ { path: "/admin", label: "Dashboard", icon: LayoutDashboard }, { path: "/admin/users", label: "Users", icon: Users }, { path: "/admin/items", label: "Items", icon: Package }, { path: "/admin/classes", label: "Classes", icon: GraduationCap }, { path: "/admin/quests", label: "Quests", icon: Scroll }, { path: "/admin/lootdrops", label: "Lootdrops", icon: Gift }, { path: "/admin/moderation", label: "Moderation", icon: Shield }, { path: "/admin/transactions", label: "Transactions", icon: ArrowLeftRight }, { path: "/admin/settings", label: "Settings", icon: Settings }, { path: "/games", label: "Games", icon: Gamepad2 }, ]; const playerNavItems: NavItem[] = [ { path: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { path: "/games", label: "Games", icon: Gamepad2 }, { path: "/leaderboards", label: "Leaderboards", icon: Trophy }, ]; export default function Layout({ user, logout, children, }: { user: AuthUser; logout: () => Promise; children: React.ReactNode; }) { const [collapsed, setCollapsed] = useState(false); const location = useLocation(); const navigate = useNavigate(); const navItems = user.role === "admin" ? adminNavItems : playerNavItems; const avatarUrl = user.avatar ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64` : null; function isActive(path: string): boolean { if (path === "/admin" && location.pathname === "/admin") return true; if (path === "/dashboard" && location.pathname === "/dashboard") return true; if (path !== "/admin" && path !== "/dashboard" && location.pathname.startsWith(path)) return true; return false; } return (
{children}
); } ``` - [ ] **Step 2: Rewrite App.tsx with React Router** ```typescript // panel/src/App.tsx import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { useAuth } from "./lib/useAuth"; import { Loader2 } from "lucide-react"; import Layout from "./components/Layout"; import Dashboard from "./pages/Dashboard"; import Settings from "./pages/Settings"; import Users from "./pages/Users"; import Items from "./pages/Items"; import PlaceholderPage from "./pages/PlaceholderPage"; import NotEnrolled from "./pages/NotEnrolled"; import PlayerDashboard from "./pages/PlayerDashboard"; import { GameLobby } from "./games/GameLobby"; import { GameRoom } from "./games/GameRoom"; const placeholders: Record = { classes: { title: "Classes", description: "Manage academy classes, assign Discord roles, and track class balances.", }, quests: { title: "Quests", description: "Configure quests with trigger events, targets, and XP/balance rewards.", }, lootdrops: { title: "Lootdrops", description: "View active lootdrops, spawn new drops, and manage lootdrop history.", }, moderation: { title: "Moderation", description: "Review moderation cases — warnings, timeouts, kicks, bans — and manage appeals.", }, transactions: { title: "Transactions", description: "Browse the economy transaction log with filtering by user, type, and date.", }, leaderboards: { title: "Leaderboards", description: "View top players by level, wealth, and net worth.", }, }; function AppRoutes() { const { loading, user, enrolled, logout } = useAuth(); if (loading) { return (
); } if (!user && !enrolled) { return ; } if (!user) { return (
Aurora

Welcome to Aurora

Sign in with Discord
); } return ( {/* Root redirect based on role */} } /> {/* Player routes */} } /> } /> {/* Game routes (both roles) */} } /> } /> {/* Admin routes */} {user.role === "admin" && ( <> } /> } /> } /> } /> } /> } /> } /> } /> } /> )} {/* Catch-all */} } /> ); } export default function App() { return ( ); } ``` - [ ] **Step 3: Verify the app compiles** Run: `cd panel && bun run build` Expected: Build succeeds (GameLobby, GameRoom, and PlayerDashboard don't exist yet — create stubs first if needed, or do this step after Task 9-11). If it fails, create minimal stub exports for the missing components: ```typescript // panel/src/games/GameLobby.tsx (stub) export function GameLobby() { return
Game Lobby — coming soon
; } // panel/src/games/GameRoom.tsx (stub) export function GameRoom({ userId }: { userId: string }) { return
Game Room — coming soon
; } // panel/src/pages/PlayerDashboard.tsx (stub) export default function PlayerDashboard({ userId }: { userId: string }) { return
Player Dashboard — coming soon
; } ``` - [ ] **Step 4: Commit** ```bash git add panel/src/App.tsx panel/src/components/Layout.tsx panel/src/games/ panel/src/pages/PlayerDashboard.tsx git commit -m "feat(panel): migrate to React Router, role-based layout and routing" ``` --- ## Task 9: Panel — useWebSocket Hook **Files:** - Create: `panel/src/lib/useWebSocket.ts` - [ ] **Step 1: Implement shared WebSocket hook** ```typescript // panel/src/lib/useWebSocket.ts import { useEffect, useRef, useCallback, useState } from "react"; type MessageHandler = (data: any) => void; interface WebSocketState { connected: boolean; send: (data: unknown) => void; subscribe: (handler: MessageHandler) => () => void; } let globalWs: WebSocket | null = null; let globalHandlers = new Set(); let globalConnected = false; let reconnectTimer: Timer | null = null; let reconnectAttempt = 0; function getWsUrl(): string { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}/ws`; } function connect(): void { if (globalWs?.readyState === WebSocket.OPEN || globalWs?.readyState === WebSocket.CONNECTING) return; const ws = new WebSocket(getWsUrl()); ws.onopen = () => { globalConnected = true; reconnectAttempt = 0; globalHandlers.forEach(h => h({ type: "__WS_CONNECTED" })); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); globalHandlers.forEach(h => h(data)); } catch { // ignore parse errors } }; ws.onclose = () => { globalConnected = false; globalWs = null; globalHandlers.forEach(h => h({ type: "__WS_DISCONNECTED" })); // Reconnect with exponential backoff (max 30s) const delay = Math.min(1000 * 2 ** reconnectAttempt, 30000); reconnectAttempt++; reconnectTimer = setTimeout(connect, delay); }; ws.onerror = () => { ws.close(); }; globalWs = ws; } function disconnect(): void { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } globalWs?.close(); globalWs = null; globalConnected = false; } export function useWebSocket(): WebSocketState { const [connected, setConnected] = useState(globalConnected); const refCount = useRef(0); useEffect(() => { refCount.current++; if (refCount.current === 1 && !globalWs) { connect(); } const handler: MessageHandler = (data) => { if (data.type === "__WS_CONNECTED") setConnected(true); if (data.type === "__WS_DISCONNECTED") setConnected(false); }; globalHandlers.add(handler); return () => { globalHandlers.delete(handler); refCount.current--; if (refCount.current === 0) { disconnect(); } }; }, []); const send = useCallback((data: unknown) => { if (globalWs?.readyState === WebSocket.OPEN) { globalWs.send(JSON.stringify(data)); } }, []); const subscribe = useCallback((handler: MessageHandler) => { globalHandlers.add(handler); return () => { globalHandlers.delete(handler); }; }, []); return { connected, send, subscribe }; } ``` - [ ] **Step 2: Commit** ```bash git add panel/src/lib/useWebSocket.ts git commit -m "feat(panel): add shared useWebSocket hook with reconnection" ``` --- ## Task 10: Panel — useGameRoom Hook **Files:** - Create: `panel/src/lib/useGameRoom.ts` - [ ] **Step 1: Implement per-room hook** ```typescript // panel/src/lib/useGameRoom.ts import { useEffect, useState, useCallback } from "react"; import { useWebSocket } from "./useWebSocket"; interface PlayerInfo { discordId: string; username: string; } interface GameRoomState { gameState: unknown; players: PlayerInfo[]; spectators: PlayerInfo[]; roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found"; isSpectator: boolean; gameOver: { winner: string | null; reason: string } | null; error: string | null; } export function useGameRoom(roomId: string, userId: string) { const { send, subscribe, connected } = useWebSocket(); const [state, setState] = useState({ gameState: null, players: [], spectators: [], roomStatus: "connecting", isSpectator: false, gameOver: null, error: null, }); useEffect(() => { if (!connected) return; // Join room — try as player first, server will tell us if we're spectating send({ type: "JOIN_ROOM", roomId, as: "player" }); const unsubscribe = subscribe((msg: any) => { if (msg.roomId && msg.roomId !== roomId) return; switch (msg.type) { case "GAME_STATE": setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" })); break; case "GAME_STARTED": setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" })); break; case "GAME_UPDATE": setState(prev => ({ ...prev, gameState: msg.state })); break; case "PLAYER_JOINED": setState(prev => { if (msg.as === "spectator") { const isMe = msg.player.discordId === userId; return { ...prev, spectators: [...prev.spectators.filter(s => s.discordId !== msg.player.discordId), msg.player], isSpectator: isMe ? true : prev.isSpectator, roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus, }; } return { ...prev, players: [...prev.players.filter(p => p.discordId !== msg.player.discordId), msg.player], roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus, }; }); break; case "PLAYER_LEFT": setState(prev => ({ ...prev, players: prev.players.filter(p => p.discordId !== msg.playerId), spectators: prev.spectators.filter(s => s.discordId !== msg.playerId), })); break; case "GAME_ENDED": setState(prev => ({ ...prev, roomStatus: "finished", gameOver: { winner: msg.winner, reason: msg.reason }, })); break; case "ERROR": // If we tried to join as player and it failed, retry as spectator if (msg.message === "Game already started" || msg.message === "Room is full") { send({ type: "JOIN_ROOM", roomId, as: "spectator" }); } else if (msg.message === "Room not found") { setState(prev => ({ ...prev, roomStatus: "not_found" })); } else { setState(prev => ({ ...prev, error: msg.message })); } break; case "ROOM_LIST_UPDATE": // ignore in room context break; } }); return () => { send({ type: "LEAVE_ROOM", roomId }); unsubscribe(); }; }, [roomId, connected, userId, send, subscribe]); const sendAction = useCallback((action: unknown) => { send({ type: "GAME_ACTION", roomId, action }); setState(prev => ({ ...prev, error: null })); }, [roomId, send]); const leaveRoom = useCallback(() => { send({ type: "LEAVE_ROOM", roomId }); }, [roomId, send]); return { ...state, sendAction, leaveRoom }; } ``` - [ ] **Step 2: Commit** ```bash git add panel/src/lib/useGameRoom.ts git commit -m "feat(panel): add useGameRoom hook for per-room game state" ``` --- ## Task 11: Panel — Client-Side Game Registry & Chess UI **Files:** - Create: `panel/src/games/registry.ts` - Create: `panel/src/games/chess/index.ts` - Create: `panel/src/games/chess/ChessBoard.tsx` - [ ] **Step 1: Create client-side game UI registry** ```typescript // panel/src/games/registry.ts import type { ComponentType } from "react"; export interface GameUIProps { state: any; myPlayerId: string; isSpectator: boolean; onAction: (action: unknown) => void; players: { discordId: string; username: string }[]; } export interface GameUIPlugin { slug: string; name: string; icon: string; component: ComponentType; } const plugins = new Map(); export const gameUIRegistry = { register(plugin: GameUIPlugin) { plugins.set(plugin.slug, plugin); }, get(slug: string): GameUIPlugin | undefined { return plugins.get(slug); }, list(): GameUIPlugin[] { return Array.from(plugins.values()); }, }; ``` - [ ] **Step 2: Create ChessBoard component** ```typescript // panel/src/games/chess/ChessBoard.tsx import { useState } from "react"; import type { GameUIProps } from "../registry"; interface Piece { type: string; color: "white" | "black"; } interface ChessState { board: (Piece | null)[][]; currentTurn: "white" | "black"; players: { white: string; black: string }; moveHistory: string[]; status: string; winner: string | null; } const PIECE_SYMBOLS: Record> = { white: { king: "♔", queen: "♕", rook: "♖", bishop: "♗", knight: "♘", pawn: "♙" }, black: { king: "♚", queen: "♛", rook: "♜", bishop: "♝", knight: "♞", pawn: "♟" }, }; export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) { const chess = state as ChessState; const [selected, setSelected] = useState<[number, number] | null>(null); if (!chess?.board) { return
Waiting for game to start...
; } const myColor = chess.players.white === myPlayerId ? "white" : chess.players.black === myPlayerId ? "black" : null; const isMyTurn = myColor === chess.currentTurn && !isSpectator; function handleSquareClick(row: number, col: number) { if (isSpectator || !isMyTurn) return; if (selected) { // Attempt move onAction({ type: "move", from: selected, to: [row, col] }); setSelected(null); } else { // Select piece const piece = chess.board[row][col]; if (piece && piece.color === myColor) { setSelected([row, col]); } } } function handleForfeit() { onAction({ type: "forfeit" }); } const opponentId = myColor === "white" ? chess.players.black : chess.players.white; const opponent = players.find(p => p.discordId === opponentId); const me = players.find(p => p.discordId === myPlayerId); return (
{/* Board */}
{/* Opponent label */}
{opponent?.username?.[0]?.toUpperCase() ?? "?"}
{opponent?.username ?? "Opponent"} · {myColor === "white" ? "Black" : "White"}
{/* Board grid */}
{chess.board.map((row, r) => row.map((piece, c) => { const isLight = (r + c) % 2 === 0; const isSelected = selected?.[0] === r && selected?.[1] === c; return ( ); }) )}
{/* Player label */}
{me?.username?.[0]?.toUpperCase() ?? "?"}
{me?.username ?? "You"} · {myColor ?? "Spectator"} {isMyTurn && ( Your turn )}
{/* Side panel */}
{/* Move history */}
Move History
{chess.moveHistory.length === 0 ? (
No moves yet
) : (
{chess.moveHistory.map((move, i) => ( {i % 2 === 0 && {Math.floor(i / 2) + 1}. } {move}{" "} ))}
)}
{/* Forfeit button */} {!isSpectator && chess.status === "playing" && ( )}
); } ``` - [ ] **Step 3: Register chess UI plugin** ```typescript // panel/src/games/chess/index.ts import { gameUIRegistry } from "../registry"; import { ChessBoard } from "./ChessBoard"; gameUIRegistry.register({ slug: "chess", name: "Chess", icon: "♟", component: ChessBoard, }); ``` - [ ] **Step 4: Commit** ```bash git add panel/src/games/registry.ts panel/src/games/chess/ git commit -m "feat(panel): add game UI registry and chess board component" ``` --- ## Task 12: Panel — GameLobby & GameRoom Pages **Files:** - Modify: `panel/src/games/GameLobby.tsx` - Modify: `panel/src/games/GameRoom.tsx` - [ ] **Step 1: Implement GameLobby** ```typescript // panel/src/games/GameLobby.tsx import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useWebSocket } from "../lib/useWebSocket"; import { gameUIRegistry } from "./registry"; // Ensure chess plugin is registered import "./chess"; interface RoomSummary { id: string; gameSlug: string; gameName: string; host: string; playerCount: number; maxPlayers: number; spectatorCount: number; status: "waiting" | "playing" | "finished"; } export function GameLobby() { const { send, subscribe, connected } = useWebSocket(); const navigate = useNavigate(); const [rooms, setRooms] = useState([]); const [filter, setFilter] = useState(null); const [showCreate, setShowCreate] = useState(false); const gameTypes = gameUIRegistry.list(); useEffect(() => { if (!connected) return; const unsubscribe = subscribe((msg: any) => { if (msg.type === "ROOM_LIST_UPDATE") { setRooms(msg.rooms); } if (msg.type === "ROOM_CREATED") { navigate(`/${msg.gameSlug}/${msg.roomId}`); } }); return unsubscribe; }, [connected, subscribe, navigate]); const filteredRooms = filter ? rooms.filter(r => r.gameSlug === filter) : rooms; const activeRooms = filteredRooms.filter(r => r.status !== "finished"); function createRoom(gameSlug: string) { send({ type: "CREATE_ROOM", gameType: gameSlug }); setShowCreate(false); } return (
{/* Header */}

Games

Browse and create game rooms

{/* Filter tabs */}
{gameTypes.map(g => ( ))}
{/* Room list */}
Active Rooms ({activeRooms.length})
{activeRooms.length === 0 ? (
No active rooms. Create one to get started!
) : (
{activeRooms.map(room => { const plugin = gameUIRegistry.get(room.gameSlug); return (
{plugin?.icon ?? "🎮"}
{room.gameName}
{room.status === "waiting" ? "Waiting" : "Playing"} {room.playerCount}/{room.maxPlayers} players {room.spectatorCount > 0 && · 👁 {room.spectatorCount}}
); })}
)}
{/* Create room dialog */} {showCreate && (
setShowCreate(false)}>
e.stopPropagation()}>

Create a Room

{gameTypes.map(g => ( ))}
)}
); } ``` - [ ] **Step 2: Implement GameRoom** ```typescript // panel/src/games/GameRoom.tsx import { useParams, useNavigate } from "react-router-dom"; import { useGameRoom } from "../lib/useGameRoom"; import { gameUIRegistry } from "./registry"; import { Loader2 } from "lucide-react"; import "./chess"; export function GameRoom({ userId }: { userId: string }) { const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>(); const navigate = useNavigate(); const { gameState, players, spectators, roomStatus, isSpectator, gameOver, error, sendAction, leaveRoom, } = useGameRoom(roomId!, userId); const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined; if (!plugin) { return (
Unknown Game

The game type "{gameSlug}" doesn't exist.

); } if (roomStatus === "not_found") { return (
Room Not Found

This room no longer exists or has expired.

); } if (roomStatus === "connecting") { return (
); } const GameComponent = plugin.component; return (
{/* Room header */}
{plugin.icon}

{plugin.name}

{roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"} {isSpectator && Spectating} 👁 {spectators.length}
{/* Error banner */} {error && (
{error}
)} {/* Game over banner */} {gameOver && (
{gameOver.winner ? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}` : "Draw!"}
Reason: {gameOver.reason}
)} {/* Waiting state */} {roomStatus === "waiting" && (
Waiting for players ({players.length}/{plugin.name === "Chess" ? 2 : "?"})
Share this URL to invite: {window.location.href}
)} {/* Game component */} {(roomStatus === "playing" || roomStatus === "finished") && gameState && ( )}
); } ``` - [ ] **Step 3: Verify the full app builds** Run: `cd panel && bun run build` Expected: Build succeeds - [ ] **Step 4: Commit** ```bash git add panel/src/games/GameLobby.tsx panel/src/games/GameRoom.tsx git commit -m "feat(panel): implement GameLobby and GameRoom pages" ``` --- ## Task 13: Panel — Player Dashboard **Files:** - Modify: `panel/src/pages/PlayerDashboard.tsx` - [ ] **Step 1: Implement PlayerDashboard** ```typescript // panel/src/pages/PlayerDashboard.tsx import { useState, useEffect } from "react"; import { get } from "../lib/api"; import { Loader2 } from "lucide-react"; interface UserData { id: string; username: string; level: number; xp: string; balance: string; className: string | null; } interface InventoryItem { itemId: string; name: string; quantity: number; rarity: string; } export default function PlayerDashboard({ userId }: { userId: string }) { const [user, setUser] = useState(null); const [inventory, setInventory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function load() { try { const [userData, invData] = await Promise.all([ get(`/api/users/${userId}`), get<{ items: InventoryItem[] }>(`/api/users/${userId}/inventory`).catch(() => ({ items: [] })), ]); setUser(userData); setInventory(invData.items ?? []); } catch (e) { setError("Failed to load profile"); } finally { setLoading(false); } } load(); }, [userId]); if (loading) { return (
); } if (error || !user) { return (
{error ?? "Could not load your profile."}
); } return (

Dashboard

{/* Stat cards */}
{/* Inventory preview */}
Inventory ({inventory.length})
{inventory.length === 0 ? (
No items yet
) : (
{inventory.slice(0, 10).map((item, i) => (
{item.name}
{item.rarity} {item.quantity > 1 && ( x{item.quantity} )}
))} {inventory.length > 10 && (
and {inventory.length - 10} more
)}
)}
); } function StatCard({ label, value, accent, subtitle }: { label: string; value: string; accent: string; subtitle?: string }) { const borderColor: Record = { primary: "border-l-primary", gold: "border-l-gold", info: "border-l-info", success: "border-l-success", }; return (
{label}
{value}
{subtitle &&
{subtitle}
}
); } function formatNumber(val: string): string { const n = Number(val); if (isNaN(n)) return val; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return n.toLocaleString(); } function rarityColor(rarity: string): string { switch (rarity?.toUpperCase()) { case "C": return "bg-gray-500/20 text-gray-400"; case "R": return "bg-blue-500/20 text-blue-400"; case "SR": return "bg-purple-500/20 text-purple-400"; case "SSR": return "bg-amber-500/20 text-amber-400"; default: return "bg-gray-500/20 text-gray-400"; } } ``` - [ ] **Step 2: Commit** ```bash git add panel/src/pages/PlayerDashboard.tsx git commit -m "feat(panel): implement player dashboard with stats and inventory" ``` --- ## Task 14: Register Chess Plugin on Server Startup **Files:** - Modify: `bot/index.ts` (or wherever the server starts) - [ ] **Step 1: Find and read the entry point** Run: `head -30 bot/index.ts` to understand how the server is started. - [ ] **Step 2: Register chess plugin before server starts** Add at the top of the entry point file, before `createWebServer` is called: ```typescript import { gameRegistry } from "@shared/games/registry"; import { chessPlugin } from "@shared/games/chess/plugin"; gameRegistry.register(chessPlugin); ``` - [ ] **Step 3: Verify the server starts cleanly** Run: `bun --watch bot/index.ts` — confirm no import errors. Ctrl+C to stop. - [ ] **Step 4: Commit** ```bash git add bot/index.ts git commit -m "feat(games): register chess plugin on server startup" ``` --- ## Task 15: End-to-End Smoke Test - [ ] **Step 1: Build the panel** Run: `cd panel && bun run build` Expected: Build succeeds with no errors - [ ] **Step 2: Run all existing tests** Run: `bun test` Expected: All existing tests still pass (no regressions) - [ ] **Step 3: Run game-specific tests** Run: `bun test shared/games/ api/src/games/` Expected: All game tests pass - [ ] **Step 4: Manual smoke test checklist** Start the server (`bun --watch bot/index.ts`) and open the panel: 1. Navigate to `/auth/discord` — OAuth flow should work 2. As a player: should land on `/dashboard` with stats 3. Navigate to `/games` — should see empty lobby 4. Click "Create Room" → Chess — should navigate to `/chess/` 5. Copy the URL and open in a second browser/incognito — should be able to join as player 2 6. Game should auto-start when both players join 7. Make moves — board should update in real-time for both players 8. Open a third browser — should be able to spectate 9. Forfeit — game should end with winner displayed 10. As an admin: should land on `/admin` with existing dashboard 11. Admin should see "Games" in sidebar and be able to access `/games` - [ ] **Step 5: Final commit if any fixes were needed** ```bash git add -A git commit -m "fix: address smoke test issues" ```