feat(games): integrate game WS handler into server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { logger } from "@shared/lib/logger";
|
|||||||
import { handleRequest } from "./routes";
|
import { handleRequest } from "./routes";
|
||||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { handleGameMessage, roomManager } from "./games/ws-handler";
|
||||||
|
import { getSession } from "./routes/auth.routes";
|
||||||
|
|
||||||
export interface WebServerConfig {
|
export interface WebServerConfig {
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -91,7 +93,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
const { port = 3000, hostname = "localhost" } = config;
|
const { port = 3000, hostname = "localhost" } = config;
|
||||||
|
|
||||||
// Configuration constants
|
// Configuration constants
|
||||||
const MAX_CONNECTIONS = 10;
|
const MAX_CONNECTIONS = 200;
|
||||||
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
||||||
const IDLE_TIMEOUT_SECONDS = 60;
|
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 });
|
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;
|
if (success) return undefined;
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -137,6 +140,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
*/
|
*/
|
||||||
open(ws) {
|
open(ws) {
|
||||||
ws.subscribe("dashboard");
|
ws.subscribe("dashboard");
|
||||||
|
ws.subscribe("lobby");
|
||||||
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
|
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
|
||||||
|
|
||||||
// Send initial stats
|
// Send initial stats
|
||||||
@@ -144,6 +148,9 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send initial room list
|
||||||
|
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
|
||||||
|
|
||||||
// Start broadcast interval if this is the first client
|
// Start broadcast interval if this is the first client
|
||||||
if (!statsBroadcastInterval) {
|
if (!statsBroadcastInterval) {
|
||||||
statsBroadcastInterval = setInterval(async () => {
|
statsBroadcastInterval = setInterval(async () => {
|
||||||
@@ -183,6 +190,24 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
if (parsed.data.type === "PING") {
|
if (parsed.data.type === "PING") {
|
||||||
ws.send(JSON.stringify({ type: "PONG" }));
|
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) {
|
} catch (e) {
|
||||||
logger.error("web", "Failed to handle message", e);
|
logger.error("web", "Failed to handle message", e);
|
||||||
}
|
}
|
||||||
@@ -194,6 +219,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
*/
|
*/
|
||||||
close(ws) {
|
close(ws) {
|
||||||
ws.unsubscribe("dashboard");
|
ws.unsubscribe("dashboard");
|
||||||
|
ws.unsubscribe("lobby");
|
||||||
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
||||||
|
|
||||||
// Stop broadcast interval if no clients left
|
// Stop broadcast interval if no clients left
|
||||||
|
|||||||
Reference in New Issue
Block a user