17 Commits

Author SHA1 Message Date
syntaxbullet
0c3b289ba0 feat(panel): implement GameLobby and GameRoom pages
Some checks failed
Deploy to Production / test (push) Failing after 34s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:31:12 +02:00
syntaxbullet
f4b36a745e feat(panel): implement player dashboard with stats and inventory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:31:09 +02:00
syntaxbullet
3b53c9cb5f feat(games): register chess plugin on server startup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:30:57 +02:00
syntaxbullet
3bdb720e4a feat(panel): migrate to React Router, role-based layout and routing
Replace useState-based page switching with react-router-dom Routes.
Layout now renders admin or player nav items based on user.role.
Add stub pages for PlayerDashboard, GameLobby, and GameRoom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:29:14 +02:00
syntaxbullet
f290eeeb8a feat(panel): add game UI registry and chess board component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:29:13 +02:00
syntaxbullet
4b3f6590cc feat(panel): add useGameRoom hook for per-room game state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:28:42 +02:00
syntaxbullet
069c0b93ef feat(games): integrate game WS handler into server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:28:41 +02:00
syntaxbullet
33a1848096 feat(games): implement RoomManager with room lifecycle and tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:26:26 +02:00
syntaxbullet
55df982a0b feat(games): implement chess plugin with tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:25:54 +02:00
syntaxbullet
eb7dfaf6f5 feat(panel): add shared useWebSocket hook with reconnection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:25:52 +02:00
syntaxbullet
aa145592c5 feat(panel): add react-router-dom, update auth hook with roles, add NotEnrolled page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:23:39 +02:00
syntaxbullet
37fa5fc3c8 feat(auth): add enrollment check, role-based sessions, and player access
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:23:35 +02:00
syntaxbullet
db10ebe220 feat(games): add room types and game WS message schemas
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:23:11 +02:00
syntaxbullet
a5478dce2b feat(games): add GamePlugin interface and registry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:22:48 +02:00
syntaxbullet
29b6153777 docs: add web games platform implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:58:07 +02:00
syntaxbullet
d3e83bac66 docs: remove ambiguous options field from CREATE_ROOM message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:49:42 +02:00
syntaxbullet
40ae93f68b docs: add web games platform design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:49:21 +02:00
30 changed files with 5146 additions and 229 deletions

View File

@@ -0,0 +1,141 @@
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";
// 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");
// Room is deleted when last player leaves a waiting room
const room = manager.getRoom(create.roomId);
expect(room).toBeUndefined();
});
it("should remove a spectator from the room", () => {
const create = manager.createRoom("chess", "player1");
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);
});
});
});

View File

@@ -0,0 +1,168 @@
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 };
}
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);
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 (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);
}
}
}
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);
}
}

54
api/src/games/types.ts Normal file
View File

@@ -0,0 +1,54 @@
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 };

View File

@@ -0,0 +1,89 @@
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) {
const spectatorView = roomManager.getSpectatorView(msg.roomId);
ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_STARTED", roomId: msg.roomId, state: spectatorView }));
} else {
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;
}
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;
}
}
}

View File

@@ -6,12 +6,16 @@
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users } from "@shared/db/schema";
import { eq } from "drizzle-orm";
// In-memory session store: token → { discordId, username, avatar, expiresAt }
// In-memory session store: token → { discordId, username, avatar, role, expiresAt }
export interface Session {
discordId: string;
username: string;
avatar: string | null;
role: "admin" | "player";
expiresAt: number;
}
@@ -144,26 +148,34 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
// Check allowlist
const adminIds = getAdminIds();
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
// 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 new Response(
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
`<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", `Admin login: ${user.username} (${user.id})`);
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
// Get return_to URL from redirect token cookie
const cookies = parseCookies(ctx.req.headers.get("cookie"));
@@ -213,13 +225,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
// GET /auth/me — return current session info
if (pathname === "/auth/me" && method === "GET") {
const session = getSession(ctx.req);
if (!session) return jsonResponse({ authenticated: false }, 401);
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,
},
});
}

View File

@@ -4,7 +4,7 @@
*/
import type { RouteContext, RouteModule } from "./types";
import { authRoutes, isAuthenticated } from "./auth.routes";
import { authRoutes, isAuthenticated, getSession } from "./auth.routes";
import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes";
@@ -70,9 +70,21 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
// For API routes, enforce authentication
if (ctx.pathname.startsWith("/api/")) {
if (!isAuthenticated(req)) {
const session = getSession(req);
if (!session) {
return errorResponse("Unauthorized", 401);
}
// Admin-only routes: everything except stats and own user data
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);
}
}
// Try protected routes

View File

@@ -12,6 +12,8 @@ import { logger } from "@shared/lib/logger";
import { handleRequest } from "./routes";
import { getFullDashboardStats } from "./routes/stats.helper";
import { join } from "path";
import { handleGameMessage, roomManager } from "./games/ws-handler";
import { getSession } from "./routes/auth.routes";
export interface WebServerConfig {
port?: number;
@@ -91,7 +93,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const { port = 3000, hostname = "localhost" } = config;
// Configuration constants
const MAX_CONNECTIONS = 10;
const MAX_CONNECTIONS = 200;
const MAX_PAYLOAD_BYTES = 16384; // 16KB
const IDLE_TIMEOUT_SECONDS = 60;
@@ -112,7 +114,8 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
return new Response("Connection limit reached", { status: 429 });
}
const success = server.upgrade(req);
const session = getSession(req);
const success = server.upgrade(req, { data: { session } });
if (success) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
}
@@ -137,6 +140,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
*/
open(ws) {
ws.subscribe("dashboard");
ws.subscribe("lobby");
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
// Send initial stats
@@ -144,6 +148,9 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
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 () => {
@@ -183,6 +190,24 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
if (parsed.data.type === "PING") {
ws.send(JSON.stringify({ type: "PONG" }));
}
// Route game messages
const gameTypes = ["CREATE_ROOM", "JOIN_ROOM", "LEAVE_ROOM", "GAME_ACTION"];
if (gameTypes.includes(parsed.data.type)) {
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);
}
@@ -194,6 +219,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
*/
close(ws) {
ws.unsubscribe("dashboard");
ws.unsubscribe("lobby");
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
// Stop broadcast interval if no clients left

View File

@@ -3,9 +3,14 @@ import { env } from "@shared/lib/env";
import { join } from "node:path";
import { initializeConfig } from "@shared/lib/config";
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
import { gameRegistry } from "@shared/games/registry";
import { chessPlugin } from "@shared/games/chess/plugin";
import { startWebServerFromRoot } from "../api/src/server";
// Register game plugins
gameRegistry.register(chessPlugin);
// Initialize config from database
await initializeConfig();

View File

@@ -30,6 +30,7 @@
"lucide-react": "^0.564.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.13.2",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
},
@@ -341,6 +342,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -471,6 +474,10 @@
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-router": ["react-router@7.13.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA=="],
"react-router-dom": ["react-router-dom@7.13.2", "", { "dependencies": { "react-router": "7.13.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
@@ -479,6 +486,8 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,341 @@
# Web Games Platform Design
**Date:** 2026-04-02
**Status:** Draft
## Overview
Extend the Aurora web panel beyond admin-only use. Enrolled bot players get a player dashboard (stats, inventory, activity) and access to a multiplayer game system. Admins keep the existing admin panel unchanged. Games are built as plugins — adding a new game requires implementing a defined interface without touching server or framework code.
## Requirements
- Discord OAuth open to all enrolled bot users (exist in `users` table), not just admins
- Two-tier experience: admins see admin panel under `/admin/*`, players see player dashboard + games
- Explicit room creation with browsing — no matchmaking
- Session-only (in-memory) game state — no database persistence for games
- Open spectating on all games
- Shareable room URLs: `/:gameSlug/:roomId`
- Plugin architecture so adding a game is self-contained
## Auth & Role System
### Changes to OAuth Flow
The current OAuth callback in `auth.routes.ts` checks `ADMIN_USER_IDS` and returns 403 for non-admins. This changes to:
1. Any Discord user can complete OAuth (remove the 403 gate)
2. After getting Discord user info, look up their Discord ID in the `users` table
3. **Not enrolled** (no row): do not create a session. Return `{ authenticated: false, enrolled: false }`
4. **Enrolled**: create a session with a `role` field — `"admin"` if their ID is in `ADMIN_USER_IDS`, otherwise `"player"`
### Session Shape
```typescript
// Before
{ discordId, username, avatar, expiresAt }
// After
{ discordId, username, avatar, role: "admin" | "player", expiresAt }
```
### `/auth/me` Response
```typescript
{ authenticated: boolean, enrolled: boolean, user?: { discordId, username, avatar, role } }
```
### Panel Behavior
- Not authenticated → redirect to Discord OAuth
- Authenticated but not enrolled → "You need to use the bot first" page
- Authenticated + enrolled as player → player experience
- Authenticated + enrolled as admin → admin experience
## Routing
### Adopting React Router
The current state-based page switching (`setPage()`) cannot handle URL paths. Adopt React Router for:
- URL-based navigation with params (`:roomId`, `:gameSlug`)
- Shareable game room links
- Clean separation of admin vs player route trees
### URL Structure
| Path | Role | Description |
|---|---|---|
| `/` | any | Redirect: admins → `/admin`, players → `/dashboard` |
| `/dashboard` | player | Player dashboard (stats, inventory, activity) |
| `/games` | player | Game lobby — browse/create rooms |
| `/:gameSlug/:roomId` | any | Game room (play or spectate) |
| `/admin` | admin | Admin dashboard (existing) |
| `/admin/users` | admin | User management (existing) |
| `/admin/items` | admin | Item management (existing) |
| `/admin/settings` | admin | Settings (existing) |
| `/admin/classes` | admin | Class management (existing) |
| `/admin/quests` | admin | Quest management (existing) |
| `/admin/lootdrops` | admin | Lootdrop management (existing) |
| `/admin/moderation` | admin | Moderation cases (existing) |
| `/admin/transactions` | admin | Transaction log (existing) |
Game room URLs (`/:gameSlug/:roomId`) are accessible to any authenticated+enrolled user — both players and admins can play.
### Layout
- Shared shell: header bar, sidebar container, user section
- **Player sidebar:** Dashboard, Games, Leaderboards
- **Admin sidebar:** existing nav items, plus a Games link so admins can play too
- Sidebar renders different nav items based on `role` from `useAuth`
## WebSocket Architecture
### Channel Design
Extend the existing `/ws` endpoint (Bun native WebSocket in `server.ts`):
| Channel | Purpose |
|---|---|
| `dashboard` | Existing admin stats broadcast (unchanged) |
| `lobby` | Room list updates — room created, closed, player count changes |
| `room:<roomId>` | Per-room game events |
### Server → Client Messages
| Type | Channel | Payload |
|---|---|---|
| `STATS_UPDATE` | `dashboard` | Existing dashboard stats |
| `ROOM_LIST_UPDATE` | `lobby` | `{ rooms: RoomSummary[] }` |
| `GAME_STATE` | `room:<id>` | Full view-filtered state snapshot |
| `GAME_UPDATE` | `room:<id>` | New view-filtered state after action |
| `PLAYER_JOINED` | `room:<id>` | `{ player: PlayerInfo, as: "player" \| "spectator" }` |
| `PLAYER_LEFT` | `room:<id>` | `{ playerId: string }` |
| `GAME_STARTED` | `room:<id>` | Initial game state |
| `GAME_ENDED` | `room:<id>` | `{ winner: string \| null, reason: string }` |
| `ERROR` | direct to sender | `{ message: string }` |
### Client → Server Messages
| Type | Payload |
|---|---|
| `JOIN_ROOM` | `{ roomId, as: "player" \| "spectator" }` |
| `LEAVE_ROOM` | `{ roomId }` |
| `GAME_ACTION` | `{ roomId, action: { type, ...data } }` |
| `CREATE_ROOM` | `{ gameType }` |
| `PING` | (existing heartbeat) |
### Connection Model
- One WebSocket connection per client (shared across dashboard, lobby, and game rooms)
- Auth: validate session cookie on WS upgrade, attach `discordId` and `role` to socket
- A player can only be *playing* in one room at a time
- Max connections limit raised from 10 to 200
## Game Plugin Interface
### Server-Side (`shared/games/`)
```typescript
interface GamePlugin<TState, TAction> {
slug: string; // URL segment: "chess", "blackjack"
name: string; // Display name: "Chess", "Blackjack"
minPlayers: number;
maxPlayers: number;
createInitialState(players: string[]): TState;
handleAction(state: TState, action: TAction, playerId: string): GameResult<TState>;
getPlayerView(state: TState, playerId: string): Partial<TState>;
getSpectatorView(state: TState): Partial<TState>;
isGameOver?(state: TState): GameOverResult | null;
onPlayerDisconnect?(state: TState, playerId: string): TState;
}
type GameResult<TState> =
| { ok: true; state: TState }
| { ok: false; error: string };
type GameOverResult = {
winner: string | null; // playerId or null for draw
reason: string;
};
```
Key properties:
- `handleAction` is a **pure function** — state in, state out. No side effects, trivially testable.
- `getPlayerView` handles information hiding — chess shows everything, blackjack hides opponent hands.
- `getSpectatorView` provides a public-safe view.
- Generic `TState`/`TAction` types give each game full type safety.
### Client-Side (`panel/src/games/`)
```typescript
interface GameUIPlugin {
slug: string;
name: string;
component: React.ComponentType<GameUIProps>;
icon?: React.ComponentType;
}
interface GameUIProps {
state: unknown;
myPlayerId: string;
isSpectator: boolean;
onAction: (action: unknown) => void;
players: PlayerInfo[];
}
```
Game components receive state and an action callback. No networking code — that's handled by the `useGameRoom` hook.
### Registry
```typescript
// shared/games/registry.ts
const games = new Map<string, GamePlugin<any, any>>();
export const gameRegistry = {
register(plugin: GamePlugin<any, any>) { games.set(plugin.slug, plugin); },
get(slug: string) { return games.get(slug); },
list() { return Array.from(games.values()); },
};
```
Server uses `gameRegistry.get(slug)` to route actions. Lobby uses `gameRegistry.list()` to show available game types. Client has an equivalent `gameUIRegistry`.
## Room Management
### Room Data Structure
```typescript
interface Room {
id: string; // UUID
gameSlug: string;
host: string; // discordId of creator
players: string[]; // discordIds of active players
spectators: Set<string>;
state: unknown; // opaque game state
status: "waiting" | "playing" | "finished";
createdAt: number;
}
```
### Lifecycle
1. **Create** — player sends `CREATE_ROOM { gameType }`. RoomManager generates UUID, creates room in `"waiting"` status, adds creator as first player. Broadcasts `ROOM_LIST_UPDATE` to lobby. Returns room ID — client navigates to `/:gameSlug/:roomId`.
2. **Join** — player sends `JOIN_ROOM { roomId, as: "player" }`. Validates room exists, not full, status is `"waiting"`. Adds to `players`. Broadcasts `PLAYER_JOINED`. If `players.length === maxPlayers`, auto-start.
3. **Start** — calls `plugin.createInitialState(players)`, sets status to `"playing"`. Sends each player their `getPlayerView`, spectators their `getSpectatorView`.
4. **Action** — player sends `GAME_ACTION { roomId, action }`. RoomManager calls `plugin.handleAction(state, action, playerId)`. If `ok`: update state, check `isGameOver()`, broadcast views. If not `ok`: send `ERROR` to that player only.
5. **End**`isGameOver()` returns a result. Set status to `"finished"`, broadcast `GAME_ENDED`. Clean up room after 60 seconds.
6. **Disconnect** — if player's WebSocket closes, call `plugin.onPlayerDisconnect()` if defined. Remove from room after a short grace period.
### Cleanup
- Rooms in `"waiting"` with no players for 60 seconds: deleted
- Rooms in `"finished"`: deleted after 60 seconds
- Server restart clears all rooms (session-only)
## Panel Pages
### Player Dashboard (`/dashboard`)
- 4 stat cards: Level, Gold, Items, Games Won (gradient cards with left-border accents matching existing dashboard style)
- Recent Activity list (card with timestamped entries)
- Inventory preview (card with item list, equipped badges)
- Data sourced from existing endpoints: `GET /api/users/:id`, `GET /api/users/:id/inventory`
### Game Lobby (`/games`)
- Filter tabs by game type (generated from `gameUIRegistry.list()`)
- Room list: each row shows game icon, room name, status badge (Waiting/Playing), player count, spectator count
- "Waiting" rooms show Join button, "Playing" rooms show Spectate button
- Create Room button opens a dialog: pick game type, room name
- Live updates via `lobby` WebSocket channel
### Game Room (`/:gameSlug/:roomId`)
- Room header: game icon, room name, status badge, spectator count, Leave/Forfeit buttons
- Center: the game plugin's React component (rendered via `gameUIRegistry.get(slug).component`)
- Side panel: Players list (with online indicators), Spectators list, game-specific info (e.g. move history for chess)
- All updates via `room:<roomId>` WebSocket channel
- Room not found: friendly error page with link back to lobby
### Shared Hook: `useGameRoom`
```typescript
function useGameRoom(roomId: string): {
gameState: unknown;
players: PlayerInfo[];
spectators: PlayerInfo[];
roomStatus: "waiting" | "playing" | "finished";
isSpectator: boolean;
myPlayerId: string;
sendAction: (action: unknown) => void;
leaveRoom: () => void;
error: string | null;
}
```
On mount: sends `JOIN_ROOM`. Subscribes to room messages. On unmount: sends `LEAVE_ROOM`. The game component just reads `gameState` and calls `sendAction`.
### Shared Hook: `useWebSocket`
Manages the single WebSocket connection for the entire panel:
- Connects after auth
- Reconnects with exponential backoff
- Routes messages to subscribers by type/channel
- Used by dashboard (existing stats), lobby (room list), and game rooms
## File Structure
```
shared/games/
types.ts — GamePlugin, GameResult, GameOverResult
registry.ts — gameRegistry singleton
chess/
plugin.ts — Chess GamePlugin implementation
types.ts — ChessState, ChessAction
plugin.test.ts
blackjack/
plugin.ts — Blackjack GamePlugin implementation
types.ts — BlackjackState, BlackjackAction
plugin.test.ts
api/src/games/
RoomManager.ts — Room CRUD, action routing, cleanup
RoomManager.test.ts
types.ts — Room, RoomEvent types
ws-handler.ts — WS message parsing, auth, routing to RoomManager
panel/src/
lib/
useWebSocket.ts — shared WS connection + message routing
useGameRoom.ts — per-room hook
games/
registry.ts — client-side GameUIPlugin registry
GameRoom.tsx — generic room wrapper + renders plugin component
GameLobby.tsx — room list + create dialog
chess/
index.ts — registers ChessUI
ChessBoard.tsx
blackjack/
index.ts — registers BlackjackUI
BlackjackTable.tsx
pages/
PlayerDashboard.tsx
```
## Adding a New Game
1. `shared/games/<name>/types.ts` — define state and action types
2. `shared/games/<name>/plugin.ts` — implement `GamePlugin` interface
3. `shared/games/<name>/plugin.test.ts` — test pure functions
4. `panel/src/games/<name>/Component.tsx` — React component
5. `panel/src/games/<name>/index.ts` — register UI plugin
No changes to: RoomManager, ws-handler, useGameRoom, GameRoom.tsx, GameLobby.tsx, routing, or WebSocket code.

View File

@@ -15,6 +15,7 @@
"lucide-react": "^0.564.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.13.2",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},

View File

@@ -1,91 +1,114 @@
import { useState } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./lib/useAuth";
import { Loader2 } from "lucide-react";
import Layout, { type Page } from "./components/Layout";
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 }> = {
users: {
title: "Users",
description: "Search, view, and manage user accounts, balances, XP, levels, and inventories.",
},
items: {
title: "Items",
description: "Create, edit, and manage game items with icons, rarities, and pricing.",
},
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.",
},
settings: {
title: "Settings",
description: "Configure bot settings for economy, leveling, commands, and guild preferences.",
},
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.",
},
};
export default function App() {
const { loading, user, logout } = useAuth();
const [page, setPage] = useState<Page>("dashboard");
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>
);
}
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>
);
}
<Layout user={user} logout={logout}>
<Routes>
<Route path="/" element={<Navigate to={user.role === "admin" ? "/admin" : "/dashboard"} replace />} />
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">Admin Panel</p>
<a
href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`}
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>
<p className="text-xs text-muted-foreground/40 mt-6">Authorized administrators only</p>
</div>
</div>
);
}
{/* Player routes */}
<Route path="/dashboard" element={<PlayerDashboard userId={user.discordId} />} />
<Route path="/leaderboards" element={<PlaceholderPage {...placeholders.leaderboards} />} />
return (
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
{page === "dashboard" ? (
<Dashboard />
) : page === "users" ? (
<Users />
) : page === "items" ? (
<Items />
) : page === "settings" ? (
<Settings />
) : (
<PlaceholderPage {...placeholders[page]!} />
)}
</Layout>
);
{/* 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 />} />
</>
)}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
);
}
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}

View File

@@ -1,140 +1,147 @@
import {
LayoutDashboard,
Users,
Package,
Shield,
Scroll,
Gift,
ArrowLeftRight,
GraduationCap,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
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";
export type Page =
| "dashboard"
| "users"
| "items"
| "classes"
| "quests"
| "lootdrops"
| "moderation"
| "transactions"
| "settings";
interface NavItem {
path: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: { page: Page; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ page: "dashboard", label: "Dashboard", icon: LayoutDashboard },
{ page: "users", label: "Users", icon: Users },
{ page: "items", label: "Items", icon: Package },
{ page: "classes", label: "Classes", icon: GraduationCap },
{ page: "quests", label: "Quests", icon: Scroll },
{ page: "lootdrops", label: "Lootdrops", icon: Gift },
{ page: "moderation", label: "Moderation", icon: Shield },
{ page: "transactions", label: "Transactions", icon: ArrowLeftRight },
{ page: "settings", label: "Settings", icon: Settings },
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,
currentPage,
onNavigate,
children,
user,
logout,
children,
}: {
user: AuthUser;
logout: () => Promise<void>;
currentPage: Page;
onNavigate: (page: Page) => void;
children: React.ReactNode;
user: AuthUser;
logout: () => Promise<void>;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
: null;
const navItems = user.role === "admin" ? adminNavItems : playerNavItems;
return (
<div className="min-h-screen flex">
{/* Sidebar */}
<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"
)}
>
{/* Logo */}
<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>
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
: null;
{/* Nav items */}
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
{navItems.map(({ page, label, icon: Icon }) => (
<button
key={page}
onClick={() => onNavigate(page)}
className={cn(
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
currentPage === page
? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)}
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"
)}
>
<Icon className={cn("w-5 h-5 shrink-0", currentPage === page && "text-primary")} />
{!collapsed && <span>{label}</span>}
</button>
))}
</nav>
{/* User & collapse */}
<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 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>
)}
<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 content */}
<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}
<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>
</main>
</div>
);
);
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useWebSocket } from "../lib/useWebSocket";
import { gameUIRegistry } from "./registry";
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>
<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>
<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>
<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>
{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>
);
}

View File

@@ -0,0 +1,118 @@
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>
<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 && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{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>
)}
{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}/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>
)}
{(roomStatus === "playing" || roomStatus === "finished") && gameState && (
<GameComponent
state={gameState}
myPlayerId={userId}
isSpectator={isSpectator}
onAction={sendAction}
players={players}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,133 @@
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) {
onAction({ type: "move", from: selected, to: [row, col] });
setSelected(null);
} else {
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">
<div>
<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>
<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>
<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>
<div className="flex flex-col gap-3 min-w-[180px]">
<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>
{!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>
);
}

View File

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

View File

@@ -0,0 +1,30 @@
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());
},
};

View File

@@ -1,35 +1,38 @@
import { useState, useEffect } from "react";
export interface AuthUser {
discordId: string;
username: string;
avatar: string | null;
discordId: string;
username: string;
avatar: string | null;
role: "admin" | "player";
}
interface AuthState {
loading: boolean;
user: AuthUser | null;
loading: boolean;
user: AuthUser | null;
enrolled: boolean;
}
export function useAuth(): AuthState & { logout: () => Promise<void> } {
const [state, setState] = useState<AuthState>({ loading: true, user: null });
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; user?: AuthUser }) => {
setState({
loading: false,
user: data.authenticated ? data.user! : null,
});
})
.catch(() => setState({ loading: false, user: null }));
}, []);
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 });
};
const logout = async () => {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
setState({ loading: false, user: null, enrolled: true });
};
return { ...state, logout };
return { ...state, logout };
}

View File

@@ -0,0 +1,115 @@
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;
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 (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;
}
});
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 };
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useCallback, useState } from "react";
type MessageHandler = (data: any) => void;
let globalWs: WebSocket | null = null;
let globalHandlers = new Set<MessageHandler>();
let globalConnected = false;
let reconnectTimer: ReturnType<typeof setTimeout> | 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" }));
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() {
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 };
}

View File

@@ -0,0 +1,15 @@
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>
);
}

View File

@@ -0,0 +1,138 @@
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>
<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>
<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";
}
}

View File

@@ -0,0 +1,143 @@
import { describe, it, expect } from "bun:test";
import { chessPlugin } from "./plugin";
import { gameRegistry } from "../registry";
const PLAYER_WHITE = "player1";
const PLAYER_BLACK = "player2";
describe("chessPlugin", () => {
describe("metadata", () => {
it("should have correct slug and player counts", () => {
expect(chessPlugin.slug).toBe("chess");
expect(chessPlugin.minPlayers).toBe(2);
expect(chessPlugin.maxPlayers).toBe(2);
});
});
describe("createInitialState", () => {
it("should create 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]);
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]);
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);
});
});
describe("chess registration", () => {
it("should register and retrieve from gameRegistry", () => {
gameRegistry.register(chessPlugin);
expect(gameRegistry.get("chess")).toBe(chessPlugin);
expect(gameRegistry.list()).toContain(chessPlugin);
});
});
});

View File

@@ -0,0 +1,156 @@
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];
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;
if (colDiff === 0 && rowDiff === direction && !target) return true;
if (colDiff === 0 && rowDiff === 2 * direction && fromRow === startRow && !target && !board[fromRow + direction][fromCol]) return true;
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" };
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 };
},
};

View File

@@ -0,0 +1,20 @@
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)[][];
currentTurn: PieceColor;
players: { white: string; black: string };
moveHistory: string[];
status: "playing" | "checkmate" | "stalemate" | "forfeit";
winner: string | null;
}
export type ChessAction =
| { type: "move"; from: [number, number]; to: [number, number] }
| { type: "forfeit" };

18
shared/games/registry.ts Normal file
View File

@@ -0,0 +1,18 @@
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());
},
};

23
shared/games/types.ts Normal file
View File

@@ -0,0 +1,23 @@
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;
};

View File

@@ -110,6 +110,11 @@ export const WsMessageSchema = z.discriminatedUnion("type", [
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()) }),
]);
export type WsMessage = z.infer<typeof WsMessageSchema>;