refactor(games): overhaul WS game system with improved UX and solo test support
Some checks failed
Deploy to Production / test (push) Failing after 35s
Some checks failed
Deploy to Production / test (push) Failing after 35s
Backend: - Fix session never being attached to ws.data at upgrade time - Add GameServer class: connection registry, per-connection room tracking, automatic room cleanup on disconnect via ws.data.rooms - Replace ws-handler.ts with typed event-driven architecture using mitt - Remove redundant subscription tracking from RoomManager - Add JOIN_RESULT with player/spectator lists replacing error-as-control-flow - Add SESSION_REPLACED for multi-tab same-account detection - Add FILL_ROOM command for admin solo testing (fills empty slots with host) - Fix dual-schema routing; remove game types from WsMessageSchema - Per-player personalized views sent directly after each action Chess plugin: - Allow same-player (solo) mode: skip color/turn ownership checks - Fix forfeit and disconnect handling in solo mode (winner: null) Frontend: - Click-to-move with legal move dots and last-move highlight - Auto-scroll move history, forfeit confirmation, turn-reactive board border - JOIN_RESULT initialises player/spectator lists immediately on join - Contextual connecting state, player slot cards in waiting room - Copy-invite button with Copied! flash, Back to Lobby CTA on finish - Session-replaced warning banner with Rejoin here action - Lobby passes preferAs intent through route state - Admin waiting room shows Start Solo Test button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
257
api/src/games/GameServer.ts
Normal file
257
api/src/games/GameServer.ts
Normal file
@@ -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<string>;
|
||||
}
|
||||
|
||||
export class GameServer {
|
||||
readonly roomManager = new RoomManager();
|
||||
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
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<WsConnectionData>): 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<WsConnectionData>, 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<WsConnectionData>): 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();
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"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<string, Room>();
|
||||
private cleanupTimers = new Map<string, Timer>();
|
||||
readonly emitter = mitt<RoomEvents>();
|
||||
|
||||
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<string, unknown>();
|
||||
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<string, unknown>();
|
||||
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<string, unknown>();
|
||||
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 {
|
||||
|
||||
@@ -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<typeof GameWsClientSchema>;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof serve>;
|
||||
stop: () => Promise<void>;
|
||||
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<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
@@ -54,6 +38,17 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export interface WebServerConfig {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export interface WebServerInstance {
|
||||
server: ReturnType<typeof serve>;
|
||||
stop: () => Promise<void>;
|
||||
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<Resp
|
||||
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||
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<WsConnectionData>({
|
||||
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<string>(),
|
||||
},
|
||||
});
|
||||
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<WsConnectionData>) {
|
||||
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<Web
|
||||
} catch (error) {
|
||||
logger.error("web", "Error in stats broadcast", error);
|
||||
}
|
||||
}, 5000);
|
||||
}, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when a WebSocket message is received.
|
||||
* Handles PING/PONG heartbeat messages.
|
||||
*/
|
||||
async message(ws, message) {
|
||||
async message(ws: ServerWebSocket<WsConnectionData>, 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<WsConnectionData>) {
|
||||
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<Web
|
||||
/**
|
||||
* Starts the web server from the main application root.
|
||||
* Kept for backward compatibility.
|
||||
*
|
||||
*
|
||||
* @param webProjectPath - Deprecated, no longer used
|
||||
* @param config - Server configuration options
|
||||
* @returns Promise resolving to server instance
|
||||
|
||||
Reference in New Issue
Block a user