2839 lines
103 KiB
Markdown
2839 lines
103 KiB
Markdown
# 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<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**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```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<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:
|
|
|
|
```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<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:
|
|
|
|
```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<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**
|
|
|
|
```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(
|
|
`<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}`);
|
|
```
|
|
|
|
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<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**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```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<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**
|
|
|
|
```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<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:
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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<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**
|
|
|
|
```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/<uuid>`
|
|
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"
|
|
```
|