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,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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user