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:
@@ -1,16 +1,35 @@
|
||||
import mitt from "mitt";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { Room, RoomSummary } from "./types";
|
||||
|
||||
const ROOM_CONFIG = {
|
||||
WAITING_CLEANUP_MS: 60_000,
|
||||
FINISHED_CLEANUP_MS: 60_000,
|
||||
} as const;
|
||||
|
||||
type ActionResult =
|
||||
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
|
||||
type JoinResult = { ok: true; started: boolean } | { ok: false; error: string };
|
||||
type JoinResult =
|
||||
| { ok: true; joinedAs: "player" | "spectator"; started: boolean }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type RoomEvents = {
|
||||
"room:created": { roomId: string; gameSlug: string; hostId: string };
|
||||
"player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" };
|
||||
"game:started": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:ended": { roomId: string; winner: string | null; reason: string };
|
||||
"player:left": { roomId: string; playerId: string };
|
||||
"room:list:changed": void;
|
||||
};
|
||||
|
||||
export class RoomManager {
|
||||
private rooms = new Map<string, Room>();
|
||||
private cleanupTimers = new Map<string, Timer>();
|
||||
readonly emitter = mitt<RoomEvents>();
|
||||
|
||||
createRoom(gameSlug: string, hostId: string): CreateResult {
|
||||
const plugin = gameRegistry.get(gameSlug);
|
||||
@@ -29,38 +48,55 @@ export class RoomManager {
|
||||
};
|
||||
|
||||
this.rooms.set(id, room);
|
||||
this.scheduleCleanup(id, 60_000);
|
||||
this.scheduleCleanup(id, ROOM_CONFIG.WAITING_CLEANUP_MS);
|
||||
|
||||
this.emitter.emit("room:created", { roomId: id, gameSlug, hostId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
|
||||
return { ok: true, roomId: id };
|
||||
}
|
||||
|
||||
joinRoom(roomId: string, playerId: string, as: "player" | "spectator", role?: string): JoinResult {
|
||||
joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
|
||||
if (as === "spectator") {
|
||||
if (preferAs === "spectator" || room.status !== "waiting") {
|
||||
room.spectators.add(playerId);
|
||||
return { ok: true, started: false };
|
||||
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
||||
}
|
||||
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game already started" };
|
||||
if (room.players.includes(playerId)) {
|
||||
return { ok: true, joinedAs: "player", started: false };
|
||||
}
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
if (room.players.length >= plugin.maxPlayers && role !== "admin") return { ok: false, error: "Room is full" };
|
||||
if (room.players.includes(playerId) && role !== "admin") return { ok: true, started: room.status === "waiting" };
|
||||
if (room.players.includes(playerId) && role === "admin") return { ok: false, error: "Already a player in this game" };
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
if (!room.players.includes(playerId) || role === "admin") {
|
||||
room.players.push(playerId);
|
||||
if (room.players.length >= plugin.maxPlayers && !isAdmin) {
|
||||
// Downgrade to spectator — room is full but still waiting, so game hasn't started
|
||||
room.spectators.add(playerId);
|
||||
return { ok: true, joinedAs: "spectator", started: false };
|
||||
}
|
||||
|
||||
room.players.push(playerId);
|
||||
|
||||
if (room.players.length >= plugin.maxPlayers) {
|
||||
room.state = plugin.createInitialState(room.players);
|
||||
room.status = "playing";
|
||||
this.clearCleanup(roomId);
|
||||
return { ok: true, started: true };
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of room.players) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: true };
|
||||
}
|
||||
|
||||
return { ok: true, started: false };
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: false };
|
||||
}
|
||||
|
||||
handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
|
||||
@@ -77,7 +113,19 @@ export class RoomManager {
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, 60_000);
|
||||
this.scheduleCleanup(roomId, ROOM_CONFIG.FINISHED_CLEANUP_MS);
|
||||
}
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of room.players) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
|
||||
|
||||
if (gameOver) {
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
return { ok: true, state: room.state, gameOver };
|
||||
@@ -87,30 +135,59 @@ export class RoomManager {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
if (room.spectators.has(playerId)) {
|
||||
room.spectators.delete(playerId);
|
||||
return;
|
||||
}
|
||||
room.spectators.delete(playerId);
|
||||
|
||||
const playerIdx = room.players.indexOf(playerId);
|
||||
if (playerIdx === -1) return;
|
||||
room.players.splice(playerIdx, 1);
|
||||
if (playerIdx !== -1) {
|
||||
room.players.splice(playerIdx, 1);
|
||||
|
||||
if (room.status === "playing") {
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
if (plugin.onPlayerDisconnect) {
|
||||
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, 60_000);
|
||||
if (room.status === "playing") {
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
if (plugin.onPlayerDisconnect) {
|
||||
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, ROOM_CONFIG.FINISHED_CLEANUP_MS);
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (room.players.length === 0 && room.status === "waiting") {
|
||||
this.deleteRoom(roomId);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (room.players.length === 0 && room.status === "waiting") {
|
||||
this.deleteRoom(roomId);
|
||||
this.emitter.emit("player:left", { roomId, playerId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
fillRoom(roomId: string, adminId: string): { ok: true } | { ok: false; error: string } {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||
if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
while (room.players.length < plugin.maxPlayers) {
|
||||
room.players.push(adminId);
|
||||
}
|
||||
|
||||
room.state = plugin.createInitialState(room.players);
|
||||
room.status = "playing";
|
||||
this.clearCleanup(roomId);
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of new Set(room.players)) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
|
||||
Reference in New Issue
Block a user