Compare commits
17 Commits
1e978dff58
...
0c3b289ba0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3b289ba0 | ||
|
|
f4b36a745e | ||
|
|
3b53c9cb5f | ||
|
|
3bdb720e4a | ||
|
|
f290eeeb8a | ||
|
|
4b3f6590cc | ||
|
|
069c0b93ef | ||
|
|
33a1848096 | ||
|
|
55df982a0b | ||
|
|
eb7dfaf6f5 | ||
|
|
aa145592c5 | ||
|
|
37fa5fc3c8 | ||
|
|
db10ebe220 | ||
|
|
a5478dce2b | ||
|
|
29b6153777 | ||
|
|
d3e83bac66 | ||
|
|
40ae93f68b |
141
api/src/games/RoomManager.test.ts
Normal file
141
api/src/games/RoomManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
api/src/games/RoomManager.ts
Normal file
168
api/src/games/RoomManager.ts
Normal 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
54
api/src/games/types.ts
Normal 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 };
|
||||
89
api/src/games/ws-handler.ts
Normal file
89
api/src/games/ws-handler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -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=="],
|
||||
|
||||
2838
docs/superpowers/plans/2026-04-02-web-games-platform.md
Normal file
2838
docs/superpowers/plans/2026-04-02-web-games-platform.md
Normal file
File diff suppressed because it is too large
Load Diff
341
docs/superpowers/specs/2026-04-02-web-games-platform-design.md
Normal file
341
docs/superpowers/specs/2026-04-02-web-games-platform-design.md
Normal 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.
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
163
panel/src/games/GameLobby.tsx
Normal file
163
panel/src/games/GameLobby.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
panel/src/games/GameRoom.tsx
Normal file
118
panel/src/games/GameRoom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
panel/src/games/chess/ChessBoard.tsx
Normal file
133
panel/src/games/chess/ChessBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
panel/src/games/chess/index.ts
Normal file
9
panel/src/games/chess/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { gameUIRegistry } from "../registry";
|
||||
import { ChessBoard } from "./ChessBoard";
|
||||
|
||||
gameUIRegistry.register({
|
||||
slug: "chess",
|
||||
name: "Chess",
|
||||
icon: "♟",
|
||||
component: ChessBoard,
|
||||
});
|
||||
30
panel/src/games/registry.ts
Normal file
30
panel/src/games/registry.ts
Normal 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());
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
115
panel/src/lib/useGameRoom.ts
Normal file
115
panel/src/lib/useGameRoom.ts
Normal 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 };
|
||||
}
|
||||
100
panel/src/lib/useWebSocket.ts
Normal file
100
panel/src/lib/useWebSocket.ts
Normal 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 };
|
||||
}
|
||||
15
panel/src/pages/NotEnrolled.tsx
Normal file
15
panel/src/pages/NotEnrolled.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
panel/src/pages/PlayerDashboard.tsx
Normal file
138
panel/src/pages/PlayerDashboard.tsx
Normal 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";
|
||||
}
|
||||
}
|
||||
143
shared/games/chess/plugin.test.ts
Normal file
143
shared/games/chess/plugin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
156
shared/games/chess/plugin.ts
Normal file
156
shared/games/chess/plugin.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
20
shared/games/chess/types.ts
Normal file
20
shared/games/chess/types.ts
Normal 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
18
shared/games/registry.ts
Normal 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
23
shared/games/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user