103 KiB
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
// shared/games/types.ts
export interface GamePlugin<TState = unknown, TAction = unknown> {
slug: string;
name: string;
minPlayers: number;
maxPlayers: number;
createInitialState(players: string[]): TState;
handleAction(state: TState, action: TAction, playerId: string): GameResult<TState>;
getPlayerView(state: TState, playerId: string): unknown;
getSpectatorView(state: TState): unknown;
isGameOver?(state: TState): GameOverResult | null;
onPlayerDisconnect?(state: TState, playerId: string): TState;
}
export type GameResult<TState> =
| { ok: true; state: TState }
| { ok: false; error: string };
export type GameOverResult = {
winner: string | null;
reason: string;
};
- Step 2: Create the game registry
// shared/games/registry.ts
import type { GamePlugin } from "./types";
const games = new Map<string, GamePlugin>();
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
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
// 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
// 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
// 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<ChessState, ChessAction> = {
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<ChessState> {
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:
// 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
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
// api/src/games/types.ts
import { z } from "zod";
// --- Room types ---
export interface Room {
id: string;
gameSlug: string;
host: string;
players: string[];
spectators: Set<string>;
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<typeof GameWsClientSchema>;
// --- 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:
// 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
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
// 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
// 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<string, Room>();
private cleanupTimers = new Map<string, Timer>();
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
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.
// 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:
- Add import at the top (after line 13):
import { handleGameMessage, roomManager } from "./games/ws-handler";
import { getSession } from "./routes/auth.routes";
- Raise MAX_CONNECTIONS from 10 to 200 (line 94):
const MAX_CONNECTIONS = 200;
- Add session validation on WS upgrade. Replace lines 108-117:
// 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 });
}
- Update the
openhandler to subscribe to lobby channel (replace lines 138-157):
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);
}
},
- Update the
messagehandler to route game messages (replace lines 164-189):
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);
}
},
- Update
closehandler to unsubscribe from lobby (replace lines 195-204):
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
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:
- Add role to Session interface (replace lines 11-16):
export interface Session {
discordId: string;
username: string;
avatar: string | null;
role: "admin" | "player";
expiresAt: number;
}
- Add DrizzleClient import after line 8:
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users } from "@shared/db/schema";
import { eq } from "drizzle-orm";
- Replace the allowlist check and session creation block (lines 147-164) with enrollment check + role assignment:
// 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(
`<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
{ 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}`);
- Update
/auth/meresponse (replace lines 214-225) to include role and enrolled:
// 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:
// 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:
import { authRoutes, isAuthenticated, getSession } from "./auth.routes";
- Step 3: Commit
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
// 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<void> } {
const [state, setState] = useState<AuthState>({ 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
// panel/src/pages/NotEnrolled.tsx
export default function NotEnrolled() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-sm">
<div className="font-display font-bold text-3xl tracking-tight mb-1">Aurora</div>
<p className="text-sm text-text-tertiary mb-6">
You need to use the Aurora bot in Discord before you can access this panel.
</p>
<p className="text-xs text-text-disabled">
Use <span className="font-mono bg-surface px-1.5 py-0.5 rounded">/enroll</span> in any server with Aurora to get started.
</p>
</div>
</div>
);
}
- Step 4: Commit
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
// 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<void>;
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 (
<div className="min-h-screen flex">
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
collapsed ? "w-16" : "w-60"
)}
>
<div className="flex items-center h-16 px-4 border-b border-border">
<div className="font-display text-xl font-bold tracking-tight">
{collapsed ? "A" : "Aurora"}
</div>
</div>
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
{navItems.map(({ path, label, icon: Icon }) => (
<button
key={path}
onClick={() => navigate(path)}
className={cn(
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
isActive(path)
? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} />
{!collapsed && <span>{label}</span>}
</button>
))}
</nav>
<div className="border-t border-border p-3 space-y-2">
{!collapsed && (
<div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{user.username[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.username}</div>
</div>
</div>
)}
<div className={cn("flex", collapsed ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<button
onClick={logout}
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
{!collapsed && <span>Sign out</span>}
</button>
<button
onClick={() => setCollapsed((c) => !c)}
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
</aside>
<main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}>
<div className="max-w-[1600px] mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
);
}
- Step 2: Rewrite App.tsx with React Router
// 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<string, { title: string; description: string }> = {
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 (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!user && !enrolled) {
return <NotEnrolled />;
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-xs">
<div className="font-display font-bold text-3xl tracking-tight mb-1">Aurora</div>
<p className="text-sm text-muted-foreground mb-8">Welcome to Aurora</p>
<a
href={`/auth/discord?return_to=${encodeURIComponent(window.location.pathname)}`}
className="inline-flex items-center justify-center w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
>
Sign in with Discord
</a>
</div>
</div>
);
}
return (
<Layout user={user} logout={logout}>
<Routes>
{/* Root redirect based on role */}
<Route path="/" element={<Navigate to={user.role === "admin" ? "/admin" : "/dashboard"} replace />} />
{/* Player routes */}
<Route path="/dashboard" element={<PlayerDashboard userId={user.discordId} />} />
<Route path="/leaderboards" element={<PlaceholderPage {...placeholders.leaderboards} />} />
{/* Game routes (both roles) */}
<Route path="/games" element={<GameLobby />} />
<Route path="/:gameSlug/:roomId" element={<GameRoom userId={user.discordId} />} />
{/* Admin routes */}
{user.role === "admin" && (
<>
<Route path="/admin" element={<Dashboard />} />
<Route path="/admin/users" element={<Users />} />
<Route path="/admin/items" element={<Items />} />
<Route path="/admin/classes" element={<PlaceholderPage {...placeholders.classes} />} />
<Route path="/admin/quests" element={<PlaceholderPage {...placeholders.quests} />} />
<Route path="/admin/lootdrops" element={<PlaceholderPage {...placeholders.lootdrops} />} />
<Route path="/admin/moderation" element={<PlaceholderPage {...placeholders.moderation} />} />
<Route path="/admin/transactions" element={<PlaceholderPage {...placeholders.transactions} />} />
<Route path="/admin/settings" element={<Settings />} />
</>
)}
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
);
}
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
- 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:
// panel/src/games/GameLobby.tsx (stub)
export function GameLobby() { return <div>Game Lobby — coming soon</div>; }
// panel/src/games/GameRoom.tsx (stub)
export function GameRoom({ userId }: { userId: string }) { return <div>Game Room — coming soon</div>; }
// panel/src/pages/PlayerDashboard.tsx (stub)
export default function PlayerDashboard({ userId }: { userId: string }) { return <div>Player Dashboard — coming soon</div>; }
- Step 4: Commit
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
// 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<MessageHandler>();
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
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
// 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<GameRoomState>({
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
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
// 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<GameUIProps>;
}
const plugins = new Map<string, GameUIPlugin>();
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
// 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<string, Record<string, string>> = {
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 <div className="text-text-tertiary text-sm">Waiting for game to start...</div>;
}
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 (
<div className="flex gap-4">
{/* Board */}
<div>
{/* Opponent label */}
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{opponent?.username?.[0]?.toUpperCase() ?? "?"}
</div>
<span className="text-sm font-medium">{opponent?.username ?? "Opponent"}</span>
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
</div>
{/* Board grid */}
<div className="inline-grid grid-cols-8 border-2 border-border rounded overflow-hidden">
{chess.board.map((row, r) =>
row.map((piece, c) => {
const isLight = (r + c) % 2 === 0;
const isSelected = selected?.[0] === r && selected?.[1] === c;
return (
<button
key={`${r}-${c}`}
onClick={() => handleSquareClick(r, c)}
className={`w-12 h-12 flex items-center justify-center text-2xl transition-colors ${
isSelected
? "bg-primary/40"
: isLight
? "bg-raised"
: "bg-surface"
} ${isMyTurn ? "cursor-pointer hover:bg-primary/20" : "cursor-default"}`}
>
{piece ? PIECE_SYMBOLS[piece.color][piece.type] : ""}
</button>
);
})
)}
</div>
{/* Player label */}
<div className="flex items-center gap-2 mt-2">
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium border-2 border-primary">
{me?.username?.[0]?.toUpperCase() ?? "?"}
</div>
<span className="text-sm font-medium">{me?.username ?? "You"}</span>
<span className="text-xs text-text-tertiary">· {myColor ?? "Spectator"}</span>
{isMyTurn && (
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded">Your turn</span>
)}
</div>
</div>
{/* Side panel */}
<div className="flex flex-col gap-3 min-w-[180px]">
{/* Move history */}
<div className="bg-card rounded-lg border border-border">
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
<div className="px-4 py-2 max-h-64 overflow-y-auto">
{chess.moveHistory.length === 0 ? (
<div className="text-xs text-text-disabled">No moves yet</div>
) : (
<div className="text-xs text-text-tertiary font-mono leading-6">
{chess.moveHistory.map((move, i) => (
<span key={i}>
{i % 2 === 0 && <span className="text-text-disabled">{Math.floor(i / 2) + 1}. </span>}
{move}{" "}
</span>
))}
</div>
)}
</div>
</div>
{/* Forfeit button */}
{!isSpectator && chess.status === "playing" && (
<button
onClick={handleForfeit}
className="rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
>
Forfeit
</button>
)}
</div>
</div>
);
}
- Step 3: Register chess UI plugin
// 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
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
// 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<RoomSummary[]>([]);
const [filter, setFilter] = useState<string | null>(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 (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="font-display text-lg font-semibold">Games</h1>
<p className="text-sm text-text-tertiary">Browse and create game rooms</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
>
+ Create Room
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setFilter(null)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
filter === null ? "bg-primary/15 text-primary" : "bg-card text-text-tertiary hover:text-foreground"
}`}
>
All Games
</button>
{gameTypes.map(g => (
<button
key={g.slug}
onClick={() => setFilter(g.slug)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
filter === g.slug ? "bg-primary/15 text-primary" : "bg-card text-text-tertiary hover:text-foreground"
}`}
>
{g.icon} {g.name}
</button>
))}
</div>
{/* Room list */}
<div className="bg-card rounded-lg border border-border">
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
<span className="text-sm font-semibold">Active Rooms</span>
<span className="text-xs text-text-disabled">({activeRooms.length})</span>
</div>
{activeRooms.length === 0 ? (
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
No active rooms. Create one to get started!
</div>
) : (
<div className="divide-y divide-border">
{activeRooms.map(room => {
const plugin = gameUIRegistry.get(room.gameSlug);
return (
<div key={room.id} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
<div className="flex items-center gap-3">
<span className="text-lg">{plugin?.icon ?? "🎮"}</span>
<div>
<div className="text-sm font-medium">{room.gameName}</div>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
room.status === "waiting"
? "bg-warning/15 text-warning"
: "bg-success/15 text-success"
}`}>
{room.status === "waiting" ? "Waiting" : "Playing"}
</span>
<span>{room.playerCount}/{room.maxPlayers} players</span>
{room.spectatorCount > 0 && <span>· 👁 {room.spectatorCount}</span>}
</div>
</div>
</div>
<button
onClick={() => navigate(`/${room.gameSlug}/${room.id}`)}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
room.status === "waiting"
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-card border border-border text-text-tertiary hover:text-foreground"
}`}
>
{room.status === "waiting" ? "Join" : "Spectate"}
</button>
</div>
);
})}
</div>
)}
</div>
{/* Create room dialog */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-sm" onClick={e => e.stopPropagation()}>
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
<div className="space-y-2">
{gameTypes.map(g => (
<button
key={g.slug}
onClick={() => createRoom(g.slug)}
className="w-full flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm font-medium hover:bg-raised/40 transition-colors"
>
<span className="text-lg">{g.icon}</span>
<span>{g.name}</span>
</button>
))}
</div>
<button
onClick={() => setShowCreate(false)}
className="mt-4 w-full text-center text-sm text-text-tertiary hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
- Step 2: Implement GameRoom
// 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 (
<div className="text-center py-16">
<div className="text-lg font-display font-semibold mb-2">Unknown Game</div>
<p className="text-sm text-text-tertiary mb-4">The game type "{gameSlug}" doesn't exist.</p>
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
Back to Games
</button>
</div>
);
}
if (roomStatus === "not_found") {
return (
<div className="text-center py-16">
<div className="text-lg font-display font-semibold mb-2">Room Not Found</div>
<p className="text-sm text-text-tertiary mb-4">This room no longer exists or has expired.</p>
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
Back to Games
</button>
</div>
);
}
if (roomStatus === "connecting") {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
</div>
);
}
const GameComponent = plugin.component;
return (
<div>
{/* Room header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span className="text-xl">{plugin.icon}</span>
<div>
<h1 className="font-display text-base font-semibold">{plugin.name}</h1>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
roomStatus === "waiting" ? "bg-warning/15 text-warning"
: roomStatus === "playing" ? "bg-success/15 text-success"
: "bg-card text-text-tertiary"
}`}>
{roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"}
</span>
{isSpectator && <span className="text-text-disabled">Spectating</span>}
<span>👁 {spectators.length}</span>
</div>
</div>
</div>
<button
onClick={() => { leaveRoom(); navigate("/games"); }}
className="rounded-md px-3 py-1.5 text-sm font-medium bg-card border border-border text-text-tertiary hover:text-foreground transition-colors"
>
Leave
</button>
</div>
{/* Error banner */}
{error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Game over banner */}
{gameOver && (
<div className="mb-4 rounded-lg border border-primary/30 bg-primary/10 px-4 py-3">
<div className="text-sm font-semibold text-primary">
{gameOver.winner
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
: "Draw!"}
</div>
<div className="text-xs text-text-tertiary mt-1">Reason: {gameOver.reason}</div>
</div>
)}
{/* Waiting state */}
{roomStatus === "waiting" && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<div className="text-sm text-text-tertiary mb-2">
Waiting for players ({players.length}/{plugin.name === "Chess" ? 2 : "?"})
</div>
<div className="text-xs text-text-disabled">
Share this URL to invite: <span className="font-mono bg-surface px-2 py-0.5 rounded select-all">{window.location.href}</span>
</div>
</div>
)}
{/* Game component */}
{(roomStatus === "playing" || roomStatus === "finished") && gameState && (
<GameComponent
state={gameState}
myPlayerId={userId}
isSpectator={isSpectator}
onAction={sendAction}
players={players}
/>
)}
</div>
);
}
- Step 3: Verify the full app builds
Run: cd panel && bun run build
Expected: Build succeeds
- Step 4: Commit
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
// 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<UserData | null>(null);
const [inventory, setInventory] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const [userData, invData] = await Promise.all([
get<UserData>(`/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 (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
</div>
);
}
if (error || !user) {
return (
<div className="text-center py-16 text-sm text-text-tertiary">
{error ?? "Could not load your profile."}
</div>
);
}
return (
<div>
<h1 className="font-display text-lg font-semibold mb-6">Dashboard</h1>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard label="Level" value={String(user.level)} accent="primary" subtitle={user.className ?? undefined} />
<StatCard label="Gold" value={formatNumber(user.balance)} accent="gold" />
<StatCard label="XP" value={formatNumber(user.xp)} accent="info" />
<StatCard label="Items" value={String(inventory.length)} accent="success" />
</div>
{/* Inventory preview */}
<div className="bg-card rounded-lg border border-border">
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
<span className="text-sm font-semibold">Inventory</span>
<span className="text-xs text-text-disabled">({inventory.length})</span>
</div>
{inventory.length === 0 ? (
<div className="px-5 py-6 text-center text-sm text-text-tertiary">No items yet</div>
) : (
<div className="divide-y divide-border">
{inventory.slice(0, 10).map((item, i) => (
<div key={i} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
<div className="text-sm font-medium">{item.name}</div>
<div className="flex items-center gap-2">
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${rarityColor(item.rarity)}`}>
{item.rarity}
</span>
{item.quantity > 1 && (
<span className="text-xs text-text-tertiary font-mono">x{item.quantity}</span>
)}
</div>
</div>
))}
{inventory.length > 10 && (
<div className="px-5 py-2 text-xs text-text-disabled text-center">
and {inventory.length - 10} more
</div>
)}
</div>
)}
</div>
</div>
);
}
function StatCard({ label, value, accent, subtitle }: { label: string; value: string; accent: string; subtitle?: string }) {
const borderColor: Record<string, string> = {
primary: "border-l-primary",
gold: "border-l-gold",
info: "border-l-info",
success: "border-l-success",
};
return (
<div className={`bg-gradient-to-br from-card to-surface border border-border rounded-lg p-5 border-l-4 ${borderColor[accent] ?? ""}`}>
<div className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">{label}</div>
<div className="text-2xl font-bold font-display tracking-tight mt-1">{value}</div>
{subtitle && <div className="text-sm text-text-tertiary mt-0.5">{subtitle}</div>}
</div>
);
}
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
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:
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
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:
- Navigate to
/auth/discord— OAuth flow should work - As a player: should land on
/dashboardwith stats - Navigate to
/games— should see empty lobby - Click "Create Room" → Chess — should navigate to
/chess/<uuid> - Copy the URL and open in a second browser/incognito — should be able to join as player 2
- Game should auto-start when both players join
- Make moves — board should update in real-time for both players
- Open a third browser — should be able to spectate
- Forfeit — game should end with winner displayed
- As an admin: should land on
/adminwith existing dashboard - Admin should see "Games" in sidebar and be able to access
/games
- Step 5: Final commit if any fixes were needed
git add -A
git commit -m "fix: address smoke test issues"