refactor(games): overhaul WS game system with improved UX and solo test support
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:
syntaxbullet
2026-04-02 16:41:13 +02:00
parent 26a0e532f6
commit 70a149ab82
16 changed files with 795 additions and 283 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { useWebSocket } from "./useWebSocket";
import { useNavigate } from "react-router-dom";
interface PlayerInfo {
discordId: string;
@@ -14,10 +15,17 @@ interface GameRoomState {
isSpectator: boolean;
gameOver: { winner: string | null; reason: string } | null;
error: string | null;
sessionReplaced: boolean;
}
export function useGameRoom(roomId: string, userId: string, role?: string) {
export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") {
const { send, subscribe, connected } = useWebSocket();
const navigate = useNavigate();
const navigateRef = useRef(navigate);
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
const [state, setState] = useState<GameRoomState>({
gameState: null,
players: [],
@@ -26,17 +34,29 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
isSpectator: false,
gameOver: null,
error: null,
sessionReplaced: false,
});
useEffect(() => {
if (!connected) return;
send({ type: "JOIN_ROOM", roomId, as: "player", role: role ?? "player" });
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
const unsubscribe = subscribe((msg: any) => {
if (msg.roomId && msg.roomId !== roomId) return;
switch (msg.type) {
case "JOIN_RESULT":
setState(prev => ({
...prev,
isSpectator: msg.joinedAs === "spectator",
roomStatus: msg.roomStatus,
players: msg.players ?? prev.players,
spectators: msg.spectators ?? prev.spectators,
gameState: msg.state !== undefined ? msg.state : prev.gameState,
}));
break;
case "GAME_STATE":
setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" }));
break;
@@ -51,7 +71,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
case "PLAYER_JOINED":
setState(prev => {
if (msg.as === "spectator") {
if (msg.joinedAs === "spectator") {
const isMe = msg.player.discordId === userId;
return {
...prev,
@@ -60,7 +80,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
};
}
const isMe = msg.player.discordId === userId;
return {
...prev,
@@ -87,11 +107,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
}));
break;
case "SESSION_REPLACED":
setState(prev => ({ ...prev, sessionReplaced: true }));
break;
case "ERROR":
if (msg.message === "Game already started" || msg.message === "Room is full") {
send({ type: "JOIN_ROOM", roomId, as: "spectator" });
} else if (msg.message === "Room not found") {
if (msg.message === "Room not found") {
setState(prev => ({ ...prev, roomStatus: "not_found" }));
setTimeout(() => navigateRef.current("/games"), 2000);
} else {
setState(prev => ({ ...prev, error: msg.message }));
}
@@ -114,5 +137,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
send({ type: "LEAVE_ROOM", roomId });
}, [roomId, send]);
return { ...state, sendAction, leaveRoom };
const rejoin = useCallback(() => {
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
setState(prev => ({ ...prev, sessionReplaced: false }));
}, [roomId, preferAs, role, send]);
const fillRoom = useCallback(() => {
send({ type: "FILL_ROOM", roomId });
}, [roomId, send]);
return { ...state, sendAction, leaveRoom, rejoin, fillRoom };
}