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");
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
const join = manager.joinRoom(create.roomId, "player2", "player");
|
const join = manager.joinRoom(create.roomId, "player2", "player");
|
||||||
expect(join.ok).toBe(true);
|
expect(join.ok).toBe(true);
|
||||||
|
if (join.ok) {
|
||||||
|
expect(join.joinedAs).toBe("player");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should auto-start when room reaches maxPlayers", () => {
|
it("should auto-start when room reaches maxPlayers", () => {
|
||||||
@@ -66,12 +69,15 @@ describe("RoomManager", () => {
|
|||||||
expect(spec.ok).toBe(true);
|
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");
|
const create = manager.createRoom("chess", "player1");
|
||||||
if (!create.ok) throw new Error("Failed to create room");
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
manager.joinRoom(create.roomId, "player2", "player");
|
manager.joinRoom(create.roomId, "player2", "player");
|
||||||
const result = manager.joinRoom(create.roomId, "player3", "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", () => {
|
it("should reject joining nonexistent room", () => {
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
|
import mitt from "mitt";
|
||||||
import { gameRegistry } from "@shared/games/registry";
|
import { gameRegistry } from "@shared/games/registry";
|
||||||
import type { Room, RoomSummary } from "./types";
|
import type { Room, RoomSummary } from "./types";
|
||||||
|
|
||||||
|
const ROOM_CONFIG = {
|
||||||
|
WAITING_CLEANUP_MS: 60_000,
|
||||||
|
FINISHED_CLEANUP_MS: 60_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
type ActionResult =
|
type ActionResult =
|
||||||
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null }
|
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null }
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|
||||||
type CreateResult = { ok: true; roomId: string } | { 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 {
|
export class RoomManager {
|
||||||
private rooms = new Map<string, Room>();
|
private rooms = new Map<string, Room>();
|
||||||
private cleanupTimers = new Map<string, Timer>();
|
private cleanupTimers = new Map<string, Timer>();
|
||||||
|
readonly emitter = mitt<RoomEvents>();
|
||||||
|
|
||||||
createRoom(gameSlug: string, hostId: string): CreateResult {
|
createRoom(gameSlug: string, hostId: string): CreateResult {
|
||||||
const plugin = gameRegistry.get(gameSlug);
|
const plugin = gameRegistry.get(gameSlug);
|
||||||
@@ -29,38 +48,55 @@ export class RoomManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.rooms.set(id, room);
|
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 };
|
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);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return { ok: false, error: "Room not found" };
|
if (!room) return { ok: false, error: "Room not found" };
|
||||||
|
|
||||||
if (as === "spectator") {
|
if (preferAs === "spectator" || room.status !== "waiting") {
|
||||||
room.spectators.add(playerId);
|
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)!;
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
if (room.players.length >= plugin.maxPlayers && role !== "admin") return { ok: false, error: "Room is full" };
|
const isAdmin = role === "admin";
|
||||||
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" };
|
|
||||||
|
|
||||||
if (!room.players.includes(playerId) || role === "admin") {
|
if (room.players.length >= plugin.maxPlayers && !isAdmin) {
|
||||||
room.players.push(playerId);
|
// 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) {
|
if (room.players.length >= plugin.maxPlayers) {
|
||||||
room.state = plugin.createInitialState(room.players);
|
room.state = plugin.createInitialState(room.players);
|
||||||
room.status = "playing";
|
room.status = "playing";
|
||||||
this.clearCleanup(roomId);
|
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 {
|
handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
|
||||||
@@ -77,7 +113,19 @@ export class RoomManager {
|
|||||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||||
if (gameOver) {
|
if (gameOver) {
|
||||||
room.status = "finished";
|
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 };
|
return { ok: true, state: room.state, gameOver };
|
||||||
@@ -87,30 +135,59 @@ export class RoomManager {
|
|||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
if (room.spectators.has(playerId)) {
|
room.spectators.delete(playerId);
|
||||||
room.spectators.delete(playerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playerIdx = room.players.indexOf(playerId);
|
const playerIdx = room.players.indexOf(playerId);
|
||||||
if (playerIdx === -1) return;
|
if (playerIdx !== -1) {
|
||||||
room.players.splice(playerIdx, 1);
|
room.players.splice(playerIdx, 1);
|
||||||
|
|
||||||
if (room.status === "playing") {
|
if (room.status === "playing") {
|
||||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
if (plugin.onPlayerDisconnect) {
|
if (plugin.onPlayerDisconnect) {
|
||||||
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
||||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||||
if (gameOver) {
|
if (gameOver) {
|
||||||
room.status = "finished";
|
room.status = "finished";
|
||||||
this.scheduleCleanup(roomId, 60_000);
|
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.emitter.emit("player:left", { roomId, playerId });
|
||||||
this.deleteRoom(roomId);
|
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 {
|
getRoom(roomId: string): Room | undefined {
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ export interface PlayerInfo {
|
|||||||
|
|
||||||
export const GameWsClientSchema = z.discriminatedUnion("type", [
|
export const GameWsClientSchema = z.discriminatedUnion("type", [
|
||||||
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }),
|
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("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("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>;
|
export type GameWsClientMessage = z.infer<typeof GameWsClientSchema>;
|
||||||
@@ -40,9 +46,11 @@ export type GameWsServerMessage =
|
|||||||
| { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
|
| { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
|
||||||
| { type: "GAME_STATE"; roomId: string; state: unknown }
|
| { type: "GAME_STATE"; roomId: string; state: unknown }
|
||||||
| { type: "GAME_UPDATE"; 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: "PLAYER_LEFT"; roomId: string; playerId: string }
|
||||||
| { type: "GAME_STARTED"; roomId: string; state: unknown }
|
| { type: "GAME_STARTED"; roomId: string; state: unknown }
|
||||||
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string }
|
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string }
|
||||||
| { type: "ROOM_CREATED"; roomId: string; gameSlug: 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 };
|
| { 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.
|
* @fileoverview API server factory module.
|
||||||
* Exports a function to create and start the API server.
|
* Exports a function to create and start the API server.
|
||||||
* This allows the server to be started in-process from the main application.
|
* This allows the server to be started in-process from the main application.
|
||||||
*
|
*
|
||||||
* Routes are organized into modular files in the ./routes directory.
|
* Routes are organized into modular files in the ./routes directory.
|
||||||
* Each route module handles its own validation, business logic, and responses.
|
* Each route module handles its own validation, business logic, and responses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { serve, file } from "bun";
|
import { serve, file } from "bun";
|
||||||
|
import type { ServerWebSocket } from "bun";
|
||||||
import { logger } from "@shared/lib/logger";
|
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 { gameServer } from "./games/GameServer";
|
||||||
|
import type { WsConnectionData } from "./games/GameServer";
|
||||||
import { getSession } from "./routes/auth.routes";
|
import { getSession } from "./routes/auth.routes";
|
||||||
|
import { GameWsClientSchema } from "./games/types";
|
||||||
|
|
||||||
export interface WebServerConfig {
|
const WS_CONFIG = {
|
||||||
port?: number;
|
MAX_CONNECTIONS: 200,
|
||||||
hostname?: string;
|
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> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
".js": "application/javascript",
|
".js": "application/javascript",
|
||||||
@@ -54,6 +38,17 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
".woff2": "font/woff2",
|
".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.
|
* Serve static files from the panel dist directory.
|
||||||
* Falls back to index.html for SPA routing.
|
* 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> {
|
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||||
const { port = 3000, hostname = "localhost" } = config;
|
const { port = 3000, hostname = "localhost" } = config;
|
||||||
|
|
||||||
// Configuration constants
|
let activeConnections = 0;
|
||||||
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 statsBroadcastInterval: Timer | undefined;
|
let statsBroadcastInterval: Timer | undefined;
|
||||||
|
|
||||||
const server = serve({
|
const server = serve<WsConnectionData>({
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
// WebSocket upgrade handling
|
|
||||||
if (url.pathname === "/ws") {
|
if (url.pathname === "/ws") {
|
||||||
const currentConnections = server.pendingWebSockets;
|
if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) {
|
||||||
if (currentConnections >= MAX_CONNECTIONS) {
|
logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`);
|
||||||
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
|
|
||||||
return new Response("Connection limit reached", { status: 429 });
|
return new Response("Connection limit reached", { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getSession(req);
|
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;
|
if (success) return undefined;
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate to modular route handlers
|
|
||||||
const response = await handleRequest(req, url);
|
const response = await handleRequest(req, url);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
// Serve panel static files (production)
|
|
||||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||||
if (staticResponse) return staticResponse;
|
if (staticResponse) return staticResponse;
|
||||||
|
|
||||||
// No matching route found
|
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
},
|
},
|
||||||
|
|
||||||
websocket: {
|
websocket: {
|
||||||
/**
|
open(ws: ServerWebSocket<WsConnectionData>) {
|
||||||
* Called when a WebSocket client connects.
|
activeConnections++;
|
||||||
* Subscribes the client to the dashboard channel and sends initial stats.
|
|
||||||
*/
|
|
||||||
open(ws) {
|
|
||||||
ws.subscribe("dashboard");
|
ws.subscribe("dashboard");
|
||||||
ws.subscribe("lobby");
|
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 => {
|
getFullDashboardStats().then(stats => {
|
||||||
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send initial room list
|
gameServer.handleOpen(ws);
|
||||||
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
|
|
||||||
|
|
||||||
// Start broadcast interval if this is the first client
|
|
||||||
if (!statsBroadcastInterval) {
|
if (!statsBroadcastInterval) {
|
||||||
statsBroadcastInterval = setInterval(async () => {
|
statsBroadcastInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -160,81 +152,67 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("web", "Error in stats broadcast", error);
|
logger.error("web", "Error in stats broadcast", error);
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
async message(ws: ServerWebSocket<WsConnectionData>, message) {
|
||||||
* Called when a WebSocket message is received.
|
|
||||||
* Handles PING/PONG heartbeat messages.
|
|
||||||
*/
|
|
||||||
async message(ws, message) {
|
|
||||||
try {
|
try {
|
||||||
const messageStr = message.toString();
|
const messageStr = message.toString();
|
||||||
|
|
||||||
// Defense-in-depth: redundant length check before parsing
|
if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) {
|
||||||
if (messageStr.length > MAX_PAYLOAD_BYTES) {
|
|
||||||
logger.error("web", "Payload exceeded maximum limit");
|
logger.error("web", "Payload exceeded maximum limit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData = JSON.parse(messageStr);
|
const rawData = JSON.parse(messageStr);
|
||||||
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
|
||||||
const parsed = WsMessageSchema.safeParse(rawData);
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
// Handle dashboard-level messages (PING, etc.)
|
||||||
logger.error("web", "Invalid message format", parsed.error.issues);
|
if (rawData && typeof rawData === "object" && rawData.type === "PING") {
|
||||||
|
ws.send(JSON.stringify({ type: "PONG" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.type === "PING") {
|
// Route game messages — try to parse as a game client message
|
||||||
ws.send(JSON.stringify({ type: "PONG" }));
|
const gameCheck = GameWsClientSchema.safeParse(rawData);
|
||||||
|
if (gameCheck.success) {
|
||||||
|
gameServer.handleMessage(ws, rawData);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route game messages
|
// Fall back to full WsMessageSchema for dashboard messages that aren't PING
|
||||||
const gameTypes = ["CREATE_ROOM", "JOIN_ROOM", "LEAVE_ROOM", "GAME_ACTION"];
|
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||||
if (gameTypes.includes(parsed.data.type)) {
|
const parsed = WsMessageSchema.safeParse(rawData);
|
||||||
const sessionData = (ws.data as any)?.session;
|
if (!parsed.success) {
|
||||||
if (!sessionData) {
|
logger.error("web", "Invalid message format", parsed.error.issues);
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("web", "Failed to handle message", e);
|
logger.error("web", "Failed to handle message", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
close(ws: ServerWebSocket<WsConnectionData>) {
|
||||||
* Called when a WebSocket client disconnects.
|
activeConnections--;
|
||||||
* Stops the broadcast interval if no clients remain.
|
|
||||||
*/
|
|
||||||
close(ws) {
|
|
||||||
ws.unsubscribe("dashboard");
|
ws.unsubscribe("dashboard");
|
||||||
ws.unsubscribe("lobby");
|
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
|
gameServer.handleClose(ws);
|
||||||
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
|
||||||
|
if (activeConnections === 0 && statsBroadcastInterval) {
|
||||||
clearInterval(statsBroadcastInterval);
|
clearInterval(statsBroadcastInterval);
|
||||||
statsBroadcastInterval = undefined;
|
statsBroadcastInterval = undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES,
|
||||||
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
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");
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
||||||
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: 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.
|
* Starts the web server from the main application root.
|
||||||
* Kept for backward compatibility.
|
* Kept for backward compatibility.
|
||||||
*
|
*
|
||||||
* @param webProjectPath - Deprecated, no longer used
|
* @param webProjectPath - Deprecated, no longer used
|
||||||
* @param config - Server configuration options
|
* @param config - Server configuration options
|
||||||
* @returns Promise resolving to server instance
|
* @returns Promise resolving to server instance
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -10,6 +10,7 @@
|
|||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
@@ -453,6 +454,8 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ export function GameLobby() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/${room.gameSlug}/${room.id}`)}
|
onClick={() => navigate(`/${room.gameSlug}/${room.id}`, {
|
||||||
|
state: { preferAs: room.status === "waiting" ? "player" : "spectator" }
|
||||||
|
})}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors shrink-0 ${
|
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors shrink-0 ${
|
||||||
room.status === "waiting"
|
room.status === "waiting"
|
||||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
|||||||
@@ -1,16 +1,50 @@
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useState } from "react";
|
||||||
|
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useGameRoom } from "../lib/useGameRoom";
|
import { useGameRoom } from "../lib/useGameRoom";
|
||||||
import { gameUIRegistry } from "./registry";
|
import { gameUIRegistry } from "./registry";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import "./chess";
|
import "./chess";
|
||||||
|
|
||||||
|
function CopyInviteLink({ url }: { url: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="text-xs text-text-disabled mb-1">Share this link to invite:</div>
|
||||||
|
<div className="flex items-center gap-2 w-full max-w-sm">
|
||||||
|
<span className="flex-1 font-mono bg-surface border border-border px-2 py-1.5 rounded text-[11px] text-text-tertiary truncate">
|
||||||
|
{url}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
className={`shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
copied
|
||||||
|
? "bg-success/15 text-success"
|
||||||
|
: "bg-card border border-border text-text-tertiary hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||||
const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>();
|
const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
gameState, players, spectators, roomStatus,
|
gameState, players, spectators, roomStatus,
|
||||||
isSpectator, gameOver, error, sendAction, leaveRoom,
|
isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom,
|
||||||
} = useGameRoom(roomId!, userId, role);
|
} = useGameRoom(roomId!, userId, role, preferAs);
|
||||||
|
|
||||||
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
||||||
|
|
||||||
@@ -40,8 +74,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
|
|
||||||
if (roomStatus === "connecting") {
|
if (roomStatus === "connecting") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
|
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
{preferAs === "spectator" ? "Joining as spectator..." : "Joining room..."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,6 +113,20 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sessionReplaced && (
|
||||||
|
<div className="mb-4 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-warning">
|
||||||
|
You opened this game in another tab. Actions from this tab are disabled.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={rejoin}
|
||||||
|
className="shrink-0 text-xs font-medium text-warning underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Rejoin here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
@@ -93,19 +144,53 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{roomStatus === "waiting" && (
|
{roomStatus === "finished" && (
|
||||||
<div className="bg-card rounded-lg border border-border p-5 md:p-8 text-center">
|
<div className="mt-4 text-center">
|
||||||
<div className="text-sm text-text-tertiary mb-2">
|
<button
|
||||||
Waiting for players ({players.length}/2)
|
onClick={() => { leaveRoom(); navigate("/games"); }}
|
||||||
</div>
|
className="rounded-md bg-primary text-primary-foreground px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
<div className="text-xs text-text-disabled">
|
>
|
||||||
Share this URL to invite:
|
Back to Lobby
|
||||||
<span className="block mt-1 font-mono bg-surface px-2 py-1 rounded select-all text-[11px] break-all">{window.location.href}</span>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(roomStatus === "playing" || roomStatus === "finished") && gameState && (
|
{roomStatus === "waiting" && (
|
||||||
|
<div className="bg-card rounded-lg border border-border p-5 md:p-8">
|
||||||
|
<div className="text-sm font-semibold mb-4 text-center">
|
||||||
|
Waiting for players ({players.length}/{plugin.maxPlayers})
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center mb-6">
|
||||||
|
{Array.from({ length: plugin.maxPlayers }).map((_, i) => {
|
||||||
|
const player = players[i];
|
||||||
|
return (
|
||||||
|
<div key={i} className={`flex flex-col items-center gap-2 px-4 py-3 rounded-lg border ${player ? "border-primary/40 bg-primary/5" : "border-border bg-surface"}`}>
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${player ? "bg-primary/20 text-primary" : "bg-surface text-text-disabled animate-pulse"}`}>
|
||||||
|
{player ? player.username[0]?.toUpperCase() : "?"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium">
|
||||||
|
{player ? player.username : <span className="text-text-disabled">Waiting...</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-text-disabled">
|
||||||
|
Player {i + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<CopyInviteLink url={window.location.href} />
|
||||||
|
{role === "admin" && players.length < plugin.maxPlayers && (
|
||||||
|
<button
|
||||||
|
onClick={fillRoom}
|
||||||
|
className="mt-4 w-full max-w-sm rounded-md px-4 py-2 text-sm font-medium bg-warning/10 text-warning border border-warning/30 hover:bg-warning/20 transition-colors"
|
||||||
|
>
|
||||||
|
Start Solo Test
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(roomStatus === "playing" || roomStatus === "finished") && gameState != null && (
|
||||||
<GameComponent
|
<GameComponent
|
||||||
state={gameState}
|
state={gameState}
|
||||||
myPlayerId={userId}
|
myPlayerId={userId}
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
const chess = state as ChessState;
|
const chess = state as ChessState;
|
||||||
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
|
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
|
||||||
const [promotionTo, setPromotionTo] = useState<string | null>(null);
|
const [promotionTo, setPromotionTo] = useState<string | null>(null);
|
||||||
|
const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
|
||||||
|
const [confirmForfeit, setConfirmForfeit] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const moveHistoryRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastMoveRef = useRef<{ from: string; to: string } | null>(null);
|
||||||
const [boardWidth, setBoardWidth] = useState(400);
|
const [boardWidth, setBoardWidth] = useState(400);
|
||||||
|
|
||||||
// Track latest state in ref to avoid stale closures
|
// Track latest state in ref to avoid stale closures
|
||||||
const chessRef = useRef(chess);
|
const chessRef = useRef(chess);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +42,14 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
return () => observer.disconnect();
|
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(() => {
|
const game = useMemo(() => {
|
||||||
if (!chess?.fen) return null;
|
if (!chess?.fen) return null;
|
||||||
return new Chess(chess.fen);
|
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 turn = game.turn() === "w" ? "white" : "black";
|
||||||
const isMyTurn = (isBothSides || myColor === turn) && !isSpectator;
|
const isMyTurn = (isBothSides || myColor === turn) && !isSpectator;
|
||||||
const boardOrientation = myColor ?? "white";
|
const boardOrientation = myColor ?? "white";
|
||||||
|
const isGameOver = chess.status !== "playing";
|
||||||
|
|
||||||
const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
|
const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
|
||||||
const opponent = players.find(p => p.discordId === opponentId);
|
const opponent = players.find(p => p.discordId === opponentId);
|
||||||
const me = players.find(p => p.discordId === myPlayerId);
|
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 {
|
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);
|
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")) {
|
if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) {
|
||||||
setPromotionFrom(sourceSquare);
|
setPromotionFrom(sourceSquare);
|
||||||
setPromotionTo(targetSquare);
|
setPromotionTo(targetSquare);
|
||||||
|
lastMoveRef.current = { from: sourceSquare, to: targetSquare };
|
||||||
return true; // allow the visual drop, handle promotion via dialog
|
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;
|
if (!move) return false;
|
||||||
|
|
||||||
onAction({ type: "move", from: sourceSquare, to: targetSquare });
|
dispatchMove(sourceSquare, targetSquare);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +114,79 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
if (promotionFrom && promotionTo) {
|
if (promotionFrom && promotionTo) {
|
||||||
// react-chessboard gives us e.g. "wQ" — extract just the piece letter lowercase
|
// react-chessboard gives us e.g. "wQ" — extract just the piece letter lowercase
|
||||||
const promotionPiece = piece[1]?.toLowerCase() ?? "q";
|
const promotionPiece = piece[1]?.toLowerCase() ?? "q";
|
||||||
onAction({ type: "move", from: promotionFrom, to: promotionTo, promotion: promotionPiece });
|
dispatchMove(promotionFrom, promotionTo, promotionPiece);
|
||||||
}
|
}
|
||||||
setPromotionFrom(null);
|
setPromotionFrom(null);
|
||||||
setPromotionTo(null);
|
setPromotionTo(null);
|
||||||
return true;
|
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 {
|
function isDraggablePiece({ piece }: { piece: string }): boolean {
|
||||||
if (isSpectator || !isMyTurn) return false;
|
if (isSpectator || !isMyTurn || isGameOver) return false;
|
||||||
if (isBothSides) {
|
if (isBothSides) {
|
||||||
const pieceColor = piece[0] === "w" ? "white" : "black";
|
const pieceColor = piece[0] === "w" ? "white" : "black";
|
||||||
return pieceColor === turn;
|
return pieceColor === turn;
|
||||||
@@ -108,8 +195,43 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
return pieceColor === myColor;
|
return pieceColor === myColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight king in check
|
// Build custom square styles: last move, selected, legal move dots, check
|
||||||
const customSquareStyles: Record<string, React.CSSProperties> = {};
|
const customSquareStyles: Record<string, React.CSSProperties> = {};
|
||||||
|
|
||||||
|
// 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()) {
|
if (game.inCheck()) {
|
||||||
const board = game.board();
|
const board = game.board();
|
||||||
const kingColor = game.turn();
|
const kingColor = game.turn();
|
||||||
@@ -119,7 +241,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
if (sq?.type === "k" && sq.color === kingColor) {
|
if (sq?.type === "k" && sq.color === kingColor) {
|
||||||
const file = String.fromCharCode(97 + c);
|
const file = String.fromCharCode(97 + c);
|
||||||
const rank = 8 - r;
|
const rank = 8 - r;
|
||||||
customSquareStyles[`${file}${rank}`] = {
|
const key = `${file}${rank}`;
|
||||||
|
customSquareStyles[key] = {
|
||||||
|
...customSquareStyles[key],
|
||||||
backgroundColor: "rgba(220, 38, 38, 0.45)",
|
backgroundColor: "rgba(220, 38, 38, 0.45)",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
};
|
};
|
||||||
@@ -128,6 +252,10 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boardBorderClass = isMyTurn && !isGameOver
|
||||||
|
? "border-primary"
|
||||||
|
: "border-border";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
{/* Board column */}
|
{/* Board column */}
|
||||||
@@ -139,13 +267,19 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{opponent?.username ?? "Opponent"}</span>
|
<span className="text-sm font-medium">{opponent?.username ?? "Opponent"}</span>
|
||||||
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
|
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
|
||||||
|
{isOpponentTurn && (
|
||||||
|
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded animate-pulse">
|
||||||
|
Their turn
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded overflow-hidden border-2 border-border">
|
<div className={`rounded overflow-hidden border-2 transition-colors duration-300 ${boardBorderClass}`}>
|
||||||
<Chessboard
|
<Chessboard
|
||||||
position={chess.fen}
|
position={chess.fen}
|
||||||
onPieceDrop={onDrop}
|
onPieceDrop={onDrop}
|
||||||
onPromotionPieceSelect={handlePromotion}
|
onPromotionPieceSelect={handlePromotion}
|
||||||
|
onSquareClick={onSquareClick}
|
||||||
boardOrientation={boardOrientation}
|
boardOrientation={boardOrientation}
|
||||||
isDraggablePiece={isDraggablePiece}
|
isDraggablePiece={isDraggablePiece}
|
||||||
boardWidth={boardWidth}
|
boardWidth={boardWidth}
|
||||||
@@ -169,7 +303,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{me?.username ?? "You"}</span>
|
<span className="text-sm font-medium">{me?.username ?? "You"}</span>
|
||||||
<span className="text-xs text-text-tertiary">· {myColor ?? "Spectator"}</span>
|
<span className="text-xs text-text-tertiary">· {myColor ?? "Spectator"}</span>
|
||||||
{isMyTurn && (
|
{isMyTurn && !isGameOver && (
|
||||||
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded">Your turn</span>
|
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded">Your turn</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +313,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
<div className="flex flex-col gap-3 w-full md:w-auto md:min-w-[180px]">
|
<div className="flex flex-col gap-3 w-full md:w-auto md:min-w-[180px]">
|
||||||
<div className="bg-card rounded-lg border border-border">
|
<div className="bg-card rounded-lg border border-border">
|
||||||
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
|
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
|
||||||
<div className="px-4 py-2 max-h-48 md:max-h-64 overflow-y-auto">
|
<div ref={moveHistoryRef} className="px-4 py-2 max-h-48 md:max-h-64 overflow-y-auto">
|
||||||
{chess.moveHistory.length === 0 ? (
|
{chess.moveHistory.length === 0 ? (
|
||||||
<div className="text-xs text-text-disabled">No moves yet</div>
|
<div className="text-xs text-text-disabled">No moves yet</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -196,12 +330,32 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSpectator && chess.status === "playing" && (
|
{!isSpectator && chess.status === "playing" && (
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => onAction({ type: "forfeit" })}
|
{confirmForfeit ? (
|
||||||
className="rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
|
<>
|
||||||
>
|
<button
|
||||||
Forfeit
|
onClick={() => onAction({ type: "forfeit" })}
|
||||||
</button>
|
className="flex-1 rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive border border-destructive hover:bg-destructive/25 transition-colors"
|
||||||
|
>
|
||||||
|
Confirm forfeit?
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmForfeit(false)}
|
||||||
|
className="rounded-md px-2 py-2 text-sm font-medium bg-card border border-border hover:bg-surface transition-colors text-text-tertiary"
|
||||||
|
aria-label="Cancel forfeit"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmForfeit(true)}
|
||||||
|
className="w-full rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
|
||||||
|
>
|
||||||
|
Forfeit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ gameUIRegistry.register({
|
|||||||
slug: "chess",
|
slug: "chess",
|
||||||
name: "Chess",
|
name: "Chess",
|
||||||
icon: "♟",
|
icon: "♟",
|
||||||
|
maxPlayers: 2,
|
||||||
component: ChessBoard,
|
component: ChessBoard,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface GameUIPlugin {
|
|||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
maxPlayers: number;
|
||||||
component: ComponentType<GameUIProps>;
|
component: ComponentType<GameUIProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ const plugins = new Map<string, GameUIPlugin>();
|
|||||||
|
|
||||||
export const gameUIRegistry = {
|
export const gameUIRegistry = {
|
||||||
register(plugin: GameUIPlugin) {
|
register(plugin: GameUIPlugin) {
|
||||||
|
if (plugins.has(plugin.slug)) {
|
||||||
|
throw new Error(`Game UI "${plugin.slug}" is already registered`);
|
||||||
|
}
|
||||||
plugins.set(plugin.slug, plugin);
|
plugins.set(plugin.slug, plugin);
|
||||||
},
|
},
|
||||||
get(slug: string): GameUIPlugin | undefined {
|
get(slug: string): GameUIPlugin | undefined {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useWebSocket } from "./useWebSocket";
|
import { useWebSocket } from "./useWebSocket";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
interface PlayerInfo {
|
interface PlayerInfo {
|
||||||
discordId: string;
|
discordId: string;
|
||||||
@@ -14,10 +15,17 @@ interface GameRoomState {
|
|||||||
isSpectator: boolean;
|
isSpectator: boolean;
|
||||||
gameOver: { winner: string | null; reason: string } | null;
|
gameOver: { winner: string | null; reason: string } | null;
|
||||||
error: 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 { send, subscribe, connected } = useWebSocket();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const navigateRef = useRef(navigate);
|
||||||
|
useEffect(() => {
|
||||||
|
navigateRef.current = navigate;
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const [state, setState] = useState<GameRoomState>({
|
const [state, setState] = useState<GameRoomState>({
|
||||||
gameState: null,
|
gameState: null,
|
||||||
players: [],
|
players: [],
|
||||||
@@ -26,17 +34,29 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
|||||||
isSpectator: false,
|
isSpectator: false,
|
||||||
gameOver: null,
|
gameOver: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
sessionReplaced: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connected) return;
|
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) => {
|
const unsubscribe = subscribe((msg: any) => {
|
||||||
if (msg.roomId && msg.roomId !== roomId) return;
|
if (msg.roomId && msg.roomId !== roomId) return;
|
||||||
|
|
||||||
switch (msg.type) {
|
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":
|
case "GAME_STATE":
|
||||||
setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" }));
|
setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" }));
|
||||||
break;
|
break;
|
||||||
@@ -51,7 +71,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
|||||||
|
|
||||||
case "PLAYER_JOINED":
|
case "PLAYER_JOINED":
|
||||||
setState(prev => {
|
setState(prev => {
|
||||||
if (msg.as === "spectator") {
|
if (msg.joinedAs === "spectator") {
|
||||||
const isMe = msg.player.discordId === userId;
|
const isMe = msg.player.discordId === userId;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -60,7 +80,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
|||||||
roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
|
roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMe = msg.player.discordId === userId;
|
const isMe = msg.player.discordId === userId;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -87,11 +107,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
|||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "SESSION_REPLACED":
|
||||||
|
setState(prev => ({ ...prev, sessionReplaced: true }));
|
||||||
|
break;
|
||||||
|
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
if (msg.message === "Game already started" || msg.message === "Room is full") {
|
if (msg.message === "Room not found") {
|
||||||
send({ type: "JOIN_ROOM", roomId, as: "spectator" });
|
|
||||||
} else if (msg.message === "Room not found") {
|
|
||||||
setState(prev => ({ ...prev, roomStatus: "not_found" }));
|
setState(prev => ({ ...prev, roomStatus: "not_found" }));
|
||||||
|
setTimeout(() => navigateRef.current("/games"), 2000);
|
||||||
} else {
|
} else {
|
||||||
setState(prev => ({ ...prev, error: msg.message }));
|
setState(prev => ({ ...prev, error: msg.message }));
|
||||||
}
|
}
|
||||||
@@ -114,5 +137,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
|||||||
send({ type: "LEAVE_ROOM", roomId });
|
send({ type: "LEAVE_ROOM", roomId });
|
||||||
}, [roomId, send]);
|
}, [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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
|||||||
|
|
||||||
createInitialState(players: string[]): ChessState {
|
createInitialState(players: string[]): ChessState {
|
||||||
const game = new Chess();
|
const game = new Chess();
|
||||||
|
|
||||||
if (players[0] === players[1]) {
|
|
||||||
throw new Error("Cannot create chess game with same player for both sides");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fen: game.fen(),
|
fen: game.fen(),
|
||||||
players: { white: players[0]!, black: players[1]! },
|
players: { white: players[0]!, black: players[1]! },
|
||||||
@@ -42,7 +37,10 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
|||||||
return { ok: false, error: "Game is already over" };
|
return { ok: false, error: "Game is already over" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const solo = state.players.white === state.players.black;
|
||||||
|
|
||||||
if (action.type === "forfeit") {
|
if (action.type === "forfeit") {
|
||||||
|
if (solo) return { ok: true, state: { ...state, status: "forfeit", winner: null } };
|
||||||
const color = getPlayerColor(state, playerId);
|
const color = getPlayerColor(state, playerId);
|
||||||
if (!color) return { ok: false, error: "You are not a player in this game" };
|
if (!color) return { ok: false, error: "You are not a player in this game" };
|
||||||
const winner = color === "white" ? state.players.black : state.players.white;
|
const winner = color === "white" ? state.players.black : state.players.white;
|
||||||
@@ -50,12 +48,14 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "move") {
|
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 game = new Chess(state.fen);
|
||||||
const turn = game.turn() === "w" ? "white" : "black";
|
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;
|
let move;
|
||||||
try {
|
try {
|
||||||
@@ -90,7 +90,11 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
|||||||
return { ok: false, error: "Unknown action type" };
|
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; },
|
getSpectatorView(state: ChessState): ChessState { return state; },
|
||||||
|
|
||||||
isGameOver(state: ChessState): GameOverResult | null {
|
isGameOver(state: ChessState): GameOverResult | null {
|
||||||
@@ -99,6 +103,9 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onPlayerDisconnect(state: ChessState, playerId: string): ChessState {
|
onPlayerDisconnect(state: ChessState, playerId: string): ChessState {
|
||||||
|
if (state.players.white === state.players.black) {
|
||||||
|
return { ...state, status: "forfeit", winner: null };
|
||||||
|
}
|
||||||
const color = getPlayerColor(state, playerId);
|
const color = getPlayerColor(state, playerId);
|
||||||
if (!color) return state;
|
if (!color) return state;
|
||||||
const winner = color === "white" ? state.players.black : state.players.white;
|
const winner = color === "white" ? state.players.black : state.players.white;
|
||||||
|
|||||||
@@ -104,17 +104,12 @@ export const MaintenanceModeSchema = z.object({
|
|||||||
reason: z.string().optional(),
|
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", [
|
export const WsMessageSchema = z.discriminatedUnion("type", [
|
||||||
z.object({ type: z.literal("PING") }),
|
z.object({ type: z.literal("PING") }),
|
||||||
z.object({ type: z.literal("PONG") }),
|
z.object({ type: z.literal("PONG") }),
|
||||||
z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
|
z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
|
||||||
z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
|
z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
|
||||||
// Game messages (client → server)
|
|
||||||
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }),
|
|
||||||
z.object({ type: z.literal("JOIN_ROOM"), roomId: z.string(), as: z.enum(["player", "spectator"]) }),
|
|
||||||
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
|
|
||||||
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.record(z.unknown()) }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user