diff --git a/api/src/games/GameServer.ts b/api/src/games/GameServer.ts new file mode 100644 index 0000000..67c73db --- /dev/null +++ b/api/src/games/GameServer.ts @@ -0,0 +1,257 @@ +import { RoomManager } from "./RoomManager"; +import { GameWsClientSchema } from "./types"; +import type { PlayerInfo } from "./types"; +import { logger } from "@shared/lib/logger"; +import type { Server, ServerWebSocket } from "bun"; + +export interface WsConnectionData { + session: { discordId: string; username: string; role: string }; + rooms: Set; +} + +export class GameServer { + readonly roomManager = new RoomManager(); + private connections = new Map>(); + private replacedConnections = new Map>(); + private bunServer: Server | null = null; + + constructor() { + // Subscribe to room events and route them to the right clients + + this.roomManager.emitter.on("room:created", ({ roomId, gameSlug }) => { + // The creating connection will subscribe itself; just broadcast room list + this.publishRoomListUpdate(); + }); + + this.roomManager.emitter.on("game:started", ({ roomId, spectatorView, playerViews }) => { + // Send personalised state to each player + for (const [playerId, view] of playerViews) { + this.sendToPlayer(playerId, { + type: "GAME_STATE", + roomId, + state: view, + }); + } + // Broadcast started event with spectator view to the room channel + this.publish(`room:${roomId}`, { + type: "GAME_STARTED", + roomId, + state: spectatorView, + }); + }); + + this.roomManager.emitter.on("game:updated", ({ roomId, spectatorView, playerViews }) => { + // Each player gets their personalised view directly + for (const [playerId, view] of playerViews) { + this.sendToPlayer(playerId, { + type: "GAME_STATE", + roomId, + state: view, + }); + } + // Spectators/others get the spectator view via pub/sub + this.publish(`room:${roomId}`, { + type: "GAME_UPDATE", + roomId, + state: spectatorView, + }); + }); + + this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason }) => { + this.publish(`room:${roomId}`, { + type: "GAME_ENDED", + roomId, + winner, + reason, + }); + this.publishRoomListUpdate(); + }); + + this.roomManager.emitter.on("player:left", ({ roomId, playerId }) => { + this.publish(`room:${roomId}`, { + type: "PLAYER_LEFT", + roomId, + playerId, + }); + }); + + this.roomManager.emitter.on("room:list:changed", () => { + this.publishRoomListUpdate(); + }); + } + + setServer(server: Server): void { + this.bunServer = server; + } + + handleOpen(ws: ServerWebSocket): void { + const discordId = ws.data.session.discordId; + const existing = this.connections.get(discordId); + if (existing && existing !== ws) { + this.replacedConnections.set(discordId, existing); + } + this.connections.set(discordId, ws); + ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() })); + } + + handleMessage(ws: ServerWebSocket, raw: unknown): void { + const parsed = GameWsClientSchema.safeParse(raw); + if (!parsed.success) { + ws.send(JSON.stringify({ type: "ERROR", message: "Invalid message format" })); + return; + } + + const msg = parsed.data; + const { discordId, username, role } = ws.data.session; + + switch (msg.type) { + case "CREATE_ROOM": { + const result = this.roomManager.createRoom(msg.gameType, discordId); + if (!result.ok) { + ws.send(JSON.stringify({ type: "ERROR", message: result.error })); + return; + } + ws.subscribe(`room:${result.roomId}`); + ws.data.rooms.add(result.roomId); + ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType })); + logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`); + break; + } + + case "JOIN_ROOM": { + const result = this.roomManager.joinRoom(msg.roomId, discordId, msg.preferAs, msg.role ?? role); + if (!result.ok) { + ws.send(JSON.stringify({ type: "ERROR", message: result.error })); + return; + } + + ws.subscribe(`room:${msg.roomId}`); + ws.data.rooms.add(msg.roomId); + + const room = this.roomManager.getRoom(msg.roomId); + const roomStatus = room?.status ?? "waiting"; + + // Determine the current state to send to this client + let state: unknown = undefined; + if (room && room.status === "playing") { + state = result.joinedAs === "spectator" + ? this.roomManager.getSpectatorView(msg.roomId) + : this.roomManager.getPlayerView(msg.roomId, discordId); + } + + // Build player/spectator lists for JOIN_RESULT + const resolveInfo = (ids: string[]): PlayerInfo[] => + ids.map(id => ({ + discordId: id, + username: this.connections.get(id)?.data.session.username ?? id, + })); + + const players = resolveInfo(room?.players ?? []); + const spectators = resolveInfo(Array.from(room?.spectators ?? new Set())); + + // Notify replaced connection in the same room (multi-tab detection) + const replacedWs = this.replacedConnections.get(discordId); + if (replacedWs && replacedWs.data.rooms.has(msg.roomId)) { + replacedWs.send(JSON.stringify({ type: "SESSION_REPLACED", roomId: msg.roomId })); + replacedWs.data.rooms.delete(msg.roomId); + replacedWs.unsubscribe(`room:${msg.roomId}`); + } + this.replacedConnections.delete(discordId); + + // Respond with JOIN_RESULT + ws.send(JSON.stringify({ + type: "JOIN_RESULT", + roomId: msg.roomId, + joinedAs: result.joinedAs, + roomStatus, + players, + spectators, + state, + })); + + // Notify other room members + const playerInfo: PlayerInfo = { discordId, username }; + this.publish(`room:${msg.roomId}`, { + type: "PLAYER_JOINED", + roomId: msg.roomId, + player: playerInfo, + joinedAs: result.joinedAs, + }); + + logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`); + break; + } + + case "LEAVE_ROOM": { + this.roomManager.leaveRoom(msg.roomId, discordId); + ws.unsubscribe(`room:${msg.roomId}`); + ws.data.rooms.delete(msg.roomId); + this.publish(`room:${msg.roomId}`, { + type: "PLAYER_LEFT", + roomId: msg.roomId, + playerId: discordId, + }); + break; + } + + case "GAME_ACTION": { + const result = this.roomManager.handleAction(msg.roomId, discordId, msg.action); + if (!result.ok) { + ws.send(JSON.stringify({ type: "ERROR", message: result.error })); + return; + } + // game:updated event handler will dispatch views to players and spectators + break; + } + + case "FILL_ROOM": { + if (role !== "admin") { + ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" })); + return; + } + const result = this.roomManager.fillRoom(msg.roomId, discordId); + if (!result.ok) { + ws.send(JSON.stringify({ type: "ERROR", message: result.error })); + return; + } + logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`); + break; + } + } + } + + handleClose(ws: ServerWebSocket): void { + // If this is a replaced (displaced) connection, just clean up the registry and stop + for (const [id, prevWs] of this.replacedConnections) { + if (prevWs === ws) { + this.replacedConnections.delete(id); + return; + } + } + + for (const roomId of ws.data.rooms) { + this.roomManager.leaveRoom(roomId, ws.data.session.discordId); + this.publish(`room:${roomId}`, { + type: "PLAYER_LEFT", + roomId, + playerId: ws.data.session.discordId, + }); + } + this.connections.delete(ws.data.session.discordId); + this.publishRoomListUpdate(); + } + + private publish(channel: string, message: unknown): void { + this.bunServer?.publish(channel, JSON.stringify(message)); + } + + private sendToPlayer(discordId: string, message: unknown): void { + this.connections.get(discordId)?.send(JSON.stringify(message)); + } + + private publishRoomListUpdate(): void { + this.publish("lobby", { type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() }); + } +} + +export const gameServer = new GameServer(); diff --git a/api/src/games/RoomManager.test.ts b/api/src/games/RoomManager.test.ts index 057a193..38f7e25 100644 --- a/api/src/games/RoomManager.test.ts +++ b/api/src/games/RoomManager.test.ts @@ -47,6 +47,9 @@ describe("RoomManager", () => { if (!create.ok) throw new Error("Failed to create room"); const join = manager.joinRoom(create.roomId, "player2", "player"); expect(join.ok).toBe(true); + if (join.ok) { + expect(join.joinedAs).toBe("player"); + } }); it("should auto-start when room reaches maxPlayers", () => { @@ -66,12 +69,15 @@ describe("RoomManager", () => { expect(spec.ok).toBe(true); }); - it("should reject joining full room as player", () => { + it("should downgrade to spectator when 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); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.joinedAs).toBe("spectator"); + } }); it("should reject joining nonexistent room", () => { diff --git a/api/src/games/RoomManager.ts b/api/src/games/RoomManager.ts index 6bdb37f..2ce1043 100644 --- a/api/src/games/RoomManager.ts +++ b/api/src/games/RoomManager.ts @@ -1,16 +1,35 @@ +import mitt from "mitt"; import { gameRegistry } from "@shared/games/registry"; import type { Room, RoomSummary } from "./types"; +const ROOM_CONFIG = { + WAITING_CLEANUP_MS: 60_000, + FINISHED_CLEANUP_MS: 60_000, +} as const; + 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 }; +type JoinResult = + | { ok: true; joinedAs: "player" | "spectator"; started: boolean } + | { ok: false; error: string }; + +type RoomEvents = { + "room:created": { roomId: string; gameSlug: string; hostId: string }; + "player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" }; + "game:started": { roomId: string; spectatorView: unknown; playerViews: Map }; + "game:updated": { roomId: string; spectatorView: unknown; playerViews: Map }; + "game:ended": { roomId: string; winner: string | null; reason: string }; + "player:left": { roomId: string; playerId: string }; + "room:list:changed": void; +}; export class RoomManager { private rooms = new Map(); private cleanupTimers = new Map(); + readonly emitter = mitt(); createRoom(gameSlug: string, hostId: string): CreateResult { const plugin = gameRegistry.get(gameSlug); @@ -29,38 +48,55 @@ export class RoomManager { }; this.rooms.set(id, room); - this.scheduleCleanup(id, 60_000); + this.scheduleCleanup(id, ROOM_CONFIG.WAITING_CLEANUP_MS); + + this.emitter.emit("room:created", { roomId: id, gameSlug, hostId }); + this.emitter.emit("room:list:changed"); + return { ok: true, roomId: id }; } - joinRoom(roomId: string, playerId: string, as: "player" | "spectator", role?: string): JoinResult { + joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult { const room = this.rooms.get(roomId); if (!room) return { ok: false, error: "Room not found" }; - if (as === "spectator") { + if (preferAs === "spectator" || room.status !== "waiting") { room.spectators.add(playerId); - return { ok: true, started: false }; + return { ok: true, joinedAs: "spectator", started: room.status === "playing" }; } - if (room.status !== "waiting") return { ok: false, error: "Game already started" }; + if (room.players.includes(playerId)) { + return { ok: true, joinedAs: "player", started: false }; + } const plugin = gameRegistry.get(room.gameSlug)!; - if (room.players.length >= plugin.maxPlayers && role !== "admin") return { ok: false, error: "Room is full" }; - if (room.players.includes(playerId) && role !== "admin") return { ok: true, started: room.status === "waiting" }; - if (room.players.includes(playerId) && role === "admin") return { ok: false, error: "Already a player in this game" }; + const isAdmin = role === "admin"; - if (!room.players.includes(playerId) || role === "admin") { - room.players.push(playerId); + if (room.players.length >= plugin.maxPlayers && !isAdmin) { + // Downgrade to spectator — room is full but still waiting, so game hasn't started + room.spectators.add(playerId); + return { ok: true, joinedAs: "spectator", started: false }; } + 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 }; + + const spectatorView = plugin.getSpectatorView(room.state); + const playerViews = new Map(); + for (const pid of room.players) { + playerViews.set(pid, plugin.getPlayerView(room.state, pid)); + } + this.emitter.emit("game:started", { roomId, spectatorView, playerViews }); + this.emitter.emit("room:list:changed"); + return { ok: true, joinedAs: "player", started: true }; } - return { ok: true, started: false }; + this.emitter.emit("room:list:changed"); + return { ok: true, joinedAs: "player", started: false }; } handleAction(roomId: string, playerId: string, action: unknown): ActionResult { @@ -77,7 +113,19 @@ export class RoomManager { const gameOver = plugin.isGameOver?.(room.state) ?? null; if (gameOver) { room.status = "finished"; - this.scheduleCleanup(roomId, 60_000); + this.scheduleCleanup(roomId, ROOM_CONFIG.FINISHED_CLEANUP_MS); + } + + const spectatorView = plugin.getSpectatorView(room.state); + const playerViews = new Map(); + for (const pid of room.players) { + playerViews.set(pid, plugin.getPlayerView(room.state, pid)); + } + this.emitter.emit("game:updated", { roomId, spectatorView, playerViews }); + + if (gameOver) { + this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason }); + this.emitter.emit("room:list:changed"); } return { ok: true, state: room.state, gameOver }; @@ -87,30 +135,59 @@ export class RoomManager { const room = this.rooms.get(roomId); if (!room) return; - if (room.spectators.has(playerId)) { - room.spectators.delete(playerId); - return; - } + room.spectators.delete(playerId); const playerIdx = room.players.indexOf(playerId); - if (playerIdx === -1) return; - room.players.splice(playerIdx, 1); + if (playerIdx !== -1) { + 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.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, ROOM_CONFIG.FINISHED_CLEANUP_MS); + this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason }); + } } } + + if (room.players.length === 0 && room.status === "waiting") { + this.deleteRoom(roomId); + this.emitter.emit("room:list:changed"); + return; + } } - if (room.players.length === 0 && room.status === "waiting") { - this.deleteRoom(roomId); + this.emitter.emit("player:left", { roomId, playerId }); + this.emitter.emit("room:list:changed"); + } + + fillRoom(roomId: string, adminId: string): { ok: true } | { ok: false; error: string } { + const room = this.rooms.get(roomId); + if (!room) return { ok: false, error: "Room not found" }; + if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" }; + if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" }; + + const plugin = gameRegistry.get(room.gameSlug)!; + while (room.players.length < plugin.maxPlayers) { + room.players.push(adminId); } + + room.state = plugin.createInitialState(room.players); + room.status = "playing"; + this.clearCleanup(roomId); + + const spectatorView = plugin.getSpectatorView(room.state); + const playerViews = new Map(); + for (const pid of new Set(room.players)) { + playerViews.set(pid, plugin.getPlayerView(room.state, pid)); + } + this.emitter.emit("game:started", { roomId, spectatorView, playerViews }); + this.emitter.emit("room:list:changed"); + return { ok: true }; } getRoom(roomId: string): Room | undefined { diff --git a/api/src/games/types.ts b/api/src/games/types.ts index 553e461..23d7f82 100644 --- a/api/src/games/types.ts +++ b/api/src/games/types.ts @@ -29,9 +29,15 @@ export interface PlayerInfo { 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"]), role: z.enum(["player", "admin"]).optional() }), + z.object({ + type: z.literal("JOIN_ROOM"), + roomId: z.string(), + preferAs: z.enum(["player", "spectator"]), + role: z.enum(["player", "admin"]).optional(), + }), 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()) }), + z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }), ]); export type GameWsClientMessage = z.infer; @@ -40,9 +46,11 @@ 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_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "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: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown } + | { type: "SESSION_REPLACED"; roomId: string } | { type: "ERROR"; message: string }; diff --git a/api/src/games/ws-handler.ts b/api/src/games/ws-handler.ts deleted file mode 100644 index b34d065..0000000 --- a/api/src/games/ws-handler.ts +++ /dev/null @@ -1,98 +0,0 @@ -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; - role: 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, ctx.role); - 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 }; - const joinedMsg = JSON.stringify({ type: "PLAYER_JOINED", roomId: msg.roomId, player: playerInfo, as: msg.as }); - ctx.publish(`room:${msg.roomId}`, joinedMsg); - ctx.send(joinedMsg); - - if (result.started) { - const spectatorView = roomManager.getSpectatorView(msg.roomId); - const startedMsg = JSON.stringify({ type: "GAME_STARTED", roomId: msg.roomId, state: spectatorView }); - ctx.publish(`room:${msg.roomId}`, startedMsg); - ctx.send(startedMsg); - } 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); - const updateMsg = JSON.stringify({ type: "GAME_UPDATE", roomId: msg.roomId, state: spectatorView }); - ctx.publish(`room:${msg.roomId}`, updateMsg); - ctx.send(updateMsg); - - if (result.gameOver) { - const endedMsg = JSON.stringify({ - type: "GAME_ENDED", - roomId: msg.roomId, - winner: result.gameOver.winner, - reason: result.gameOver.reason, - }); - ctx.publish(`room:${msg.roomId}`, endedMsg); - ctx.send(endedMsg); - ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); - } - break; - } - } -} diff --git a/api/src/server.ts b/api/src/server.ts index 443d9a6..fa86565 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -2,45 +2,29 @@ * @fileoverview API server factory module. * Exports a function to create and start the API server. * This allows the server to be started in-process from the main application. - * + * * Routes are organized into modular files in the ./routes directory. * Each route module handles its own validation, business logic, and responses. */ import { serve, file } from "bun"; +import type { ServerWebSocket } from "bun"; 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 { gameServer } from "./games/GameServer"; +import type { WsConnectionData } from "./games/GameServer"; import { getSession } from "./routes/auth.routes"; +import { GameWsClientSchema } from "./games/types"; -export interface WebServerConfig { - port?: number; - hostname?: string; -} +const WS_CONFIG = { + MAX_CONNECTIONS: 200, + MAX_PAYLOAD_BYTES: 16384, + IDLE_TIMEOUT_SECONDS: 60, + STATS_BROADCAST_INTERVAL_MS: 5000, +} as const; -export interface WebServerInstance { - server: ReturnType; - stop: () => Promise; - url: string; -} - -/** - * Creates and starts the API server. - * - * @param config - Server configuration options - * @param config.port - Port to listen on (default: 3000) - * @param config.hostname - Hostname to bind to (default: "localhost") - * @returns Promise resolving to server instance with stop() method - * - * @example - * const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" }); - * console.log(`Server running at ${server.url}`); - * - * // To stop the server: - * await server.stop(); - */ const MIME_TYPES: Record = { ".html": "text/html", ".js": "application/javascript", @@ -54,6 +38,17 @@ const MIME_TYPES: Record = { ".woff2": "font/woff2", }; +export interface WebServerConfig { + port?: number; + hostname?: string; +} + +export interface WebServerInstance { + server: ReturnType; + stop: () => Promise; + url: string; +} + /** * Serve static files from the panel dist directory. * Falls back to index.html for SPA routing. @@ -92,66 +87,63 @@ async function servePanelStatic(pathname: string, distDir: string): Promise { const { port = 3000, hostname = "localhost" } = config; - // Configuration constants - const MAX_CONNECTIONS = 200; - const MAX_PAYLOAD_BYTES = 16384; // 16KB - const IDLE_TIMEOUT_SECONDS = 60; - - // Interval for broadcasting stats to all connected WS clients + let activeConnections = 0; let statsBroadcastInterval: Timer | undefined; - const server = serve({ + const server = serve({ port, hostname, async fetch(req, server) { const url = new URL(req.url); - // WebSocket upgrade handling if (url.pathname === "/ws") { - const currentConnections = server.pendingWebSockets; - if (currentConnections >= MAX_CONNECTIONS) { - logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`); + if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) { + logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`); return new Response("Connection limit reached", { status: 429 }); } const session = getSession(req); - const success = server.upgrade(req, { data: { session } }); + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + const success = server.upgrade(req, { + data: { + session: { + discordId: session.discordId, + username: session.username, + role: session.role, + }, + rooms: new Set(), + }, + }); if (success) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); } - // Delegate to modular route handlers const response = await handleRequest(req, url); if (response) return response; - // Serve panel static files (production) const panelDistDir = join(import.meta.dir, "../../panel/dist"); const staticResponse = await servePanelStatic(url.pathname, panelDistDir); if (staticResponse) return staticResponse; - // No matching route found return new Response("Not Found", { status: 404 }); }, websocket: { - /** - * Called when a WebSocket client connects. - * Subscribes the client to the dashboard channel and sends initial stats. - */ - open(ws) { + open(ws: ServerWebSocket) { + activeConnections++; ws.subscribe("dashboard"); ws.subscribe("lobby"); - logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`); + logger.debug("web", `Client connected: ${ws.data.session.discordId}. Total: ${activeConnections}`); - // Send initial stats getFullDashboardStats().then(stats => { ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats })); }); - // Send initial room list - ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); + gameServer.handleOpen(ws); - // Start broadcast interval if this is the first client if (!statsBroadcastInterval) { statsBroadcastInterval = setInterval(async () => { try { @@ -160,81 +152,67 @@ export async function createWebServer(config: WebServerConfig = {}): Promise, message) { try { const messageStr = message.toString(); - // Defense-in-depth: redundant length check before parsing - if (messageStr.length > MAX_PAYLOAD_BYTES) { + if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) { logger.error("web", "Payload exceeded maximum limit"); return; } const rawData = JSON.parse(messageStr); - const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types"); - const parsed = WsMessageSchema.safeParse(rawData); - if (!parsed.success) { - logger.error("web", "Invalid message format", parsed.error.issues); + // Handle dashboard-level messages (PING, etc.) + if (rawData && typeof rawData === "object" && rawData.type === "PING") { + ws.send(JSON.stringify({ type: "PONG" })); return; } - if (parsed.data.type === "PING") { - ws.send(JSON.stringify({ type: "PONG" })); + // Route game messages — try to parse as a game client message + const gameCheck = GameWsClientSchema.safeParse(rawData); + if (gameCheck.success) { + gameServer.handleMessage(ws, rawData); + return; } - // 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, - role: sessionData.role, - send: (data) => ws.send(data), - subscribe: (channel) => ws.subscribe(channel), - unsubscribe: (channel) => ws.unsubscribe(channel), - publish: (channel, data) => server.publish(channel, data), - }); + // Fall back to full WsMessageSchema for dashboard messages that aren't PING + const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types"); + const parsed = WsMessageSchema.safeParse(rawData); + if (!parsed.success) { + logger.error("web", "Invalid message format", parsed.error.issues); } + // Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients } catch (e) { logger.error("web", "Failed to handle message", e); } }, - /** - * Called when a WebSocket client disconnects. - * Stops the broadcast interval if no clients remain. - */ - close(ws) { + close(ws: ServerWebSocket) { + activeConnections--; ws.unsubscribe("dashboard"); ws.unsubscribe("lobby"); - logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`); + logger.debug("web", `Client disconnected: ${ws.data.session.discordId}. Total remaining: ${activeConnections}`); - // Stop broadcast interval if no clients left - if (server.pendingWebSockets === 0 && statsBroadcastInterval) { + gameServer.handleClose(ws); + + if (activeConnections === 0 && statsBroadcastInterval) { clearInterval(statsBroadcastInterval); statsBroadcastInterval = undefined; } }, - maxPayloadLength: MAX_PAYLOAD_BYTES, - idleTimeout: IDLE_TIMEOUT_SECONDS, + maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES, + idleTimeout: WS_CONFIG.IDLE_TIMEOUT_SECONDS, }, }); - // Listen for real-time events from the system bus + // Wire gameServer to Bun server for pub/sub publishing + gameServer.setServer(server); + const { systemEvents, EVENTS } = await import("@shared/lib/events"); systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => { server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event })); @@ -257,7 +235,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise + + + ); +} + export function GameRoom({ userId, role }: { userId: string; role?: string }) { const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>(); const navigate = useNavigate(); + const location = useLocation(); + const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player"; + const { gameState, players, spectators, roomStatus, - isSpectator, gameOver, error, sendAction, leaveRoom, - } = useGameRoom(roomId!, userId, role); + isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, + } = useGameRoom(roomId!, userId, role, preferAs); const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined; @@ -40,8 +74,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) { if (roomStatus === "connecting") { return ( -
+
+

+ {preferAs === "spectator" ? "Joining as spectator..." : "Joining room..."} +

); } @@ -76,6 +113,20 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
+ {sessionReplaced && ( +
+

+ You opened this game in another tab. Actions from this tab are disabled. +

+ +
+ )} + {error && (
{error} @@ -93,19 +144,53 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
)} - {roomStatus === "waiting" && ( -
-
- Waiting for players ({players.length}/2) -
-
- Share this URL to invite: - {window.location.href} -
+ {roomStatus === "finished" && ( +
+
)} - {(roomStatus === "playing" || roomStatus === "finished") && gameState && ( + {roomStatus === "waiting" && ( +
+
+ Waiting for players ({players.length}/{plugin.maxPlayers}) +
+
+ {Array.from({ length: plugin.maxPlayers }).map((_, i) => { + const player = players[i]; + return ( +
+
+ {player ? player.username[0]?.toUpperCase() : "?"} +
+
+ {player ? player.username : Waiting...} +
+
+ Player {i + 1} +
+
+ ); + })} +
+ + {role === "admin" && players.length < plugin.maxPlayers && ( + + )} +
+ )} + + {(roomStatus === "playing" || roomStatus === "finished") && gameState != null && ( (null); const [promotionTo, setPromotionTo] = useState(null); + const [selectedSquare, setSelectedSquare] = useState(null); + const [confirmForfeit, setConfirmForfeit] = useState(false); const containerRef = useRef(null); + const moveHistoryRef = useRef(null); + const lastMoveRef = useRef<{ from: string; to: string } | null>(null); const [boardWidth, setBoardWidth] = useState(400); - + // Track latest state in ref to avoid stale closures const chessRef = useRef(chess); useEffect(() => { @@ -38,6 +42,14 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } return () => observer.disconnect(); }, []); + // Auto-scroll move history to bottom when new moves arrive + useEffect(() => { + const el = moveHistoryRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [chess?.moveHistory?.length]); + const game = useMemo(() => { if (!chess?.fen) return null; return new Chess(chess.fen); @@ -54,13 +66,23 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } const turn = game.turn() === "w" ? "white" : "black"; const isMyTurn = (isBothSides || myColor === turn) && !isSpectator; const boardOrientation = myColor ?? "white"; + const isGameOver = chess.status !== "playing"; 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); + // Determine if it's the opponent's turn + const isOpponentTurn = !isMyTurn && !isSpectator && !isGameOver; + + function dispatchMove(from: string, to: string, promotion?: string) { + lastMoveRef.current = { from, to }; + setSelectedSquare(null); + onAction({ type: "move", from, to, ...(promotion ? { promotion } : {}) }); + } + function onDrop(sourceSquare: string, targetSquare: string): boolean { - if (isSpectator || !isMyTurn) return false; + if (isSpectator || !isMyTurn || isGameOver) return false; const testGame = new Chess(chessRef.current.fen); @@ -71,6 +93,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) { setPromotionFrom(sourceSquare); setPromotionTo(targetSquare); + lastMoveRef.current = { from: sourceSquare, to: targetSquare }; return true; // allow the visual drop, handle promotion via dialog } } @@ -83,7 +106,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } } if (!move) return false; - onAction({ type: "move", from: sourceSquare, to: targetSquare }); + dispatchMove(sourceSquare, targetSquare); return true; } @@ -91,15 +114,79 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } if (promotionFrom && promotionTo) { // react-chessboard gives us e.g. "wQ" — extract just the piece letter lowercase const promotionPiece = piece[1]?.toLowerCase() ?? "q"; - onAction({ type: "move", from: promotionFrom, to: promotionTo, promotion: promotionPiece }); + dispatchMove(promotionFrom, promotionTo, promotionPiece); } setPromotionFrom(null); setPromotionTo(null); return true; } + function onSquareClick(square: string) { + if (isSpectator || isGameOver) return; + if (!isMyTurn) return; + + // If promotion dialog is open, ignore clicks + if (promotionFrom !== null) return; + + const testGame = new Chess(chessRef.current.fen); + + // If a square is already selected + if (selectedSquare !== null) { + // Clicking the same square again → deselect + if (square === selectedSquare) { + setSelectedSquare(null); + return; + } + + // Check if this is a valid destination from selected square + const legalMoves = testGame.moves({ square: selectedSquare as any, verbose: true }); + const validDest = legalMoves.find(m => m.to === square); + + if (validDest) { + // Check for promotion + const piece = testGame.get(selectedSquare as any); + if (piece?.type === "p") { + const targetRank = square[1]; + if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) { + setPromotionFrom(selectedSquare); + setPromotionTo(square); + lastMoveRef.current = { from: selectedSquare, to: square }; + setSelectedSquare(null); + return; + } + } + dispatchMove(selectedSquare, square); + return; + } + + // Not a valid dest — check if clicked square has a moveable piece to switch selection + const clickedPiece = testGame.get(square as any); + if (clickedPiece) { + const clickedColor = clickedPiece.color === "w" ? "white" : "black"; + const canMovePiece = isBothSides ? clickedColor === turn : clickedColor === myColor; + if (canMovePiece) { + setSelectedSquare(square); + return; + } + } + + // Otherwise deselect + setSelectedSquare(null); + return; + } + + // No square selected yet — select if we can move this piece + const clickedPiece = testGame.get(square as any); + if (!clickedPiece) return; + const clickedColor = clickedPiece.color === "w" ? "white" : "black"; + const canMovePiece = isBothSides ? clickedColor === turn : clickedColor === myColor; + if (canMovePiece) { + setSelectedSquare(square); + } + } + function isDraggablePiece({ piece }: { piece: string }): boolean { - if (isSpectator || !isMyTurn) return false; + if (isSpectator || !isMyTurn || isGameOver) return false; if (isBothSides) { const pieceColor = piece[0] === "w" ? "white" : "black"; return pieceColor === turn; @@ -108,8 +195,43 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } return pieceColor === myColor; } - // Highlight king in check + // Build custom square styles: last move, selected, legal move dots, check const customSquareStyles: Record = {}; + + // Last-move highlight + const lastMove = lastMoveRef.current; + if (lastMove) { + const highlight: React.CSSProperties = { backgroundColor: "rgba(139, 92, 246, 0.2)" }; + customSquareStyles[lastMove.from] = { ...customSquareStyles[lastMove.from], ...highlight }; + customSquareStyles[lastMove.to] = { ...customSquareStyles[lastMove.to], ...highlight }; + } + + // Selected square highlight + if (selectedSquare !== null) { + customSquareStyles[selectedSquare] = { + ...customSquareStyles[selectedSquare], + boxShadow: "inset 0 0 0 3px rgba(139,92,246,0.7)", + }; + + // Legal move dots + const legalMoves = game.moves({ square: selectedSquare as any, verbose: true }); + for (const m of legalMoves) { + const dest = m.to; + const hasPiece = game.get(dest as any) !== null; + const dotStyle: React.CSSProperties = hasPiece + ? { + background: "radial-gradient(circle, rgba(139,92,246,0.35) 85%, transparent 87%)", + borderRadius: "50%", + } + : { + background: "radial-gradient(circle, rgba(139,92,246,0.5) 25%, transparent 27%)", + borderRadius: "50%", + }; + customSquareStyles[dest] = { ...customSquareStyles[dest], ...dotStyle }; + } + } + + // Check highlight (applied last so it's not overwritten by other styles) if (game.inCheck()) { const board = game.board(); const kingColor = game.turn(); @@ -119,7 +241,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } if (sq?.type === "k" && sq.color === kingColor) { const file = String.fromCharCode(97 + c); const rank = 8 - r; - customSquareStyles[`${file}${rank}`] = { + const key = `${file}${rank}`; + customSquareStyles[key] = { + ...customSquareStyles[key], backgroundColor: "rgba(220, 38, 38, 0.45)", borderRadius: "50%", }; @@ -128,6 +252,10 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } } } + const boardBorderClass = isMyTurn && !isGameOver + ? "border-primary" + : "border-border"; + return (
{/* Board column */} @@ -139,13 +267,19 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
{opponent?.username ?? "Opponent"} · {myColor === "white" ? "Black" : "White"} + {isOpponentTurn && ( + + Their turn + + )}
-
+
{me?.username ?? "You"} · {myColor ?? "Spectator"} - {isMyTurn && ( + {isMyTurn && !isGameOver && ( Your turn )}
@@ -179,7 +313,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
Move History
-
+
{chess.moveHistory.length === 0 ? (
No moves yet
) : ( @@ -196,12 +330,32 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
{!isSpectator && chess.status === "playing" && ( - +
+ {confirmForfeit ? ( + <> + + + + ) : ( + + )} +
)}
diff --git a/panel/src/games/chess/index.ts b/panel/src/games/chess/index.ts index bd8c168..92fdb16 100644 --- a/panel/src/games/chess/index.ts +++ b/panel/src/games/chess/index.ts @@ -5,5 +5,6 @@ gameUIRegistry.register({ slug: "chess", name: "Chess", icon: "♟", + maxPlayers: 2, component: ChessBoard, }); diff --git a/panel/src/games/registry.ts b/panel/src/games/registry.ts index 41c9001..51f0ef4 100644 --- a/panel/src/games/registry.ts +++ b/panel/src/games/registry.ts @@ -12,6 +12,7 @@ export interface GameUIPlugin { slug: string; name: string; icon: string; + maxPlayers: number; component: ComponentType; } @@ -19,6 +20,9 @@ const plugins = new Map(); export const gameUIRegistry = { register(plugin: GameUIPlugin) { + if (plugins.has(plugin.slug)) { + throw new Error(`Game UI "${plugin.slug}" is already registered`); + } plugins.set(plugin.slug, plugin); }, get(slug: string): GameUIPlugin | undefined { diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index 1275e5b..b184c30 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -1,5 +1,6 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useWebSocket } from "./useWebSocket"; +import { useNavigate } from "react-router-dom"; interface PlayerInfo { discordId: string; @@ -14,10 +15,17 @@ interface GameRoomState { isSpectator: boolean; gameOver: { winner: string | null; reason: string } | null; error: string | null; + sessionReplaced: boolean; } -export function useGameRoom(roomId: string, userId: string, role?: string) { +export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") { const { send, subscribe, connected } = useWebSocket(); + const navigate = useNavigate(); + const navigateRef = useRef(navigate); + useEffect(() => { + navigateRef.current = navigate; + }, [navigate]); + const [state, setState] = useState({ gameState: null, players: [], @@ -26,17 +34,29 @@ export function useGameRoom(roomId: string, userId: string, role?: string) { isSpectator: false, gameOver: null, error: null, + sessionReplaced: false, }); useEffect(() => { if (!connected) return; - send({ type: "JOIN_ROOM", roomId, as: "player", role: role ?? "player" }); + send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" }); const unsubscribe = subscribe((msg: any) => { if (msg.roomId && msg.roomId !== roomId) return; switch (msg.type) { + case "JOIN_RESULT": + setState(prev => ({ + ...prev, + isSpectator: msg.joinedAs === "spectator", + roomStatus: msg.roomStatus, + players: msg.players ?? prev.players, + spectators: msg.spectators ?? prev.spectators, + gameState: msg.state !== undefined ? msg.state : prev.gameState, + })); + break; + case "GAME_STATE": setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" })); break; @@ -51,7 +71,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) { case "PLAYER_JOINED": setState(prev => { - if (msg.as === "spectator") { + if (msg.joinedAs === "spectator") { const isMe = msg.player.discordId === userId; return { ...prev, @@ -60,7 +80,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) { roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus, }; } - + const isMe = msg.player.discordId === userId; return { ...prev, @@ -87,11 +107,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) { })); break; + case "SESSION_REPLACED": + setState(prev => ({ ...prev, sessionReplaced: true })); + 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") { + if (msg.message === "Room not found") { setState(prev => ({ ...prev, roomStatus: "not_found" })); + setTimeout(() => navigateRef.current("/games"), 2000); } else { setState(prev => ({ ...prev, error: msg.message })); } @@ -114,5 +137,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) { send({ type: "LEAVE_ROOM", roomId }); }, [roomId, send]); - return { ...state, sendAction, leaveRoom }; + const rejoin = useCallback(() => { + send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" }); + setState(prev => ({ ...prev, sessionReplaced: false })); + }, [roomId, preferAs, role, send]); + + const fillRoom = useCallback(() => { + send({ type: "FILL_ROOM", roomId }); + }, [roomId, send]); + + return { ...state, sendAction, leaveRoom, rejoin, fillRoom }; } diff --git a/shared/games/chess/plugin.ts b/shared/games/chess/plugin.ts index 2834cdb..12ca990 100644 --- a/shared/games/chess/plugin.ts +++ b/shared/games/chess/plugin.ts @@ -23,11 +23,6 @@ export const chessPlugin: GamePlugin = { createInitialState(players: string[]): ChessState { const game = new Chess(); - - if (players[0] === players[1]) { - throw new Error("Cannot create chess game with same player for both sides"); - } - return { fen: game.fen(), players: { white: players[0]!, black: players[1]! }, @@ -42,7 +37,10 @@ export const chessPlugin: GamePlugin = { return { ok: false, error: "Game is already over" }; } + const solo = state.players.white === state.players.black; + if (action.type === "forfeit") { + if (solo) return { ok: true, state: { ...state, status: "forfeit", winner: null } }; 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; @@ -50,12 +48,14 @@ export const chessPlugin: GamePlugin = { } if (action.type === "move") { - const playerColor = getPlayerColor(state, playerId); - if (!playerColor) return { ok: false, error: "You are not a player in this game" }; - const game = new Chess(state.fen); const turn = game.turn() === "w" ? "white" : "black"; - if (playerColor !== turn) return { ok: false, error: "It is not your turn" }; + + if (!solo) { + const playerColor = getPlayerColor(state, playerId); + if (!playerColor) return { ok: false, error: "You are not a player in this game" }; + if (playerColor !== turn) return { ok: false, error: "It is not your turn" }; + } let move; try { @@ -90,7 +90,11 @@ export const chessPlugin: GamePlugin = { return { ok: false, error: "Unknown action type" }; }, - getPlayerView(state: ChessState): ChessState { return state; }, + getPlayerView(state: ChessState, playerId: string): ChessState { + const playerColor = getPlayerColor(state, playerId); + if (!playerColor) return state; + return state; + }, getSpectatorView(state: ChessState): ChessState { return state; }, isGameOver(state: ChessState): GameOverResult | null { @@ -99,6 +103,9 @@ export const chessPlugin: GamePlugin = { }, onPlayerDisconnect(state: ChessState, playerId: string): ChessState { + if (state.players.white === state.players.black) { + return { ...state, status: "forfeit", winner: null }; + } const color = getPlayerColor(state, playerId); if (!color) return state; const winner = color === "white" ? state.players.black : state.players.white; diff --git a/shared/modules/dashboard/dashboard.types.ts b/shared/modules/dashboard/dashboard.types.ts index 966cf52..7d3a2a9 100644 --- a/shared/modules/dashboard/dashboard.types.ts +++ b/shared/modules/dashboard/dashboard.types.ts @@ -104,17 +104,12 @@ export const MaintenanceModeSchema = z.object({ reason: z.string().optional(), }); -// WebSocket Message Schemas +// WebSocket Message Schemas (dashboard messages only — game messages live in api/src/games/types.ts) export const WsMessageSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("PING") }), z.object({ type: z.literal("PONG") }), z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }), z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }), - // Game messages (client → server) - z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }), - z.object({ type: z.literal("JOIN_ROOM"), roomId: z.string(), as: z.enum(["player", "spectator"]) }), - z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }), - z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.record(z.unknown()) }), ]); export type WsMessage = z.infer;