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,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 { gameUIRegistry } from "./registry";
|
||||
import { Loader2 } from "lucide-react";
|
||||
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 }) {
|
||||
const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player";
|
||||
|
||||
const {
|
||||
gameState, players, spectators, roomStatus,
|
||||
isSpectator, gameOver, error, sendAction, leaveRoom,
|
||||
} = useGameRoom(roomId!, userId, role);
|
||||
isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom,
|
||||
} = useGameRoom(roomId!, userId, role, preferAs);
|
||||
|
||||
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
||||
|
||||
@@ -40,8 +74,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
|
||||
if (roomStatus === "connecting") {
|
||||
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" />
|
||||
<p className="text-sm text-text-tertiary">
|
||||
{preferAs === "spectator" ? "Joining as spectator..." : "Joining room..."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +113,20 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
</button>
|
||||
</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 && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
@@ -93,19 +144,53 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roomStatus === "waiting" && (
|
||||
<div className="bg-card rounded-lg border border-border p-5 md:p-8 text-center">
|
||||
<div className="text-sm text-text-tertiary mb-2">
|
||||
Waiting for players ({players.length}/2)
|
||||
</div>
|
||||
<div className="text-xs text-text-disabled">
|
||||
Share this URL to invite:
|
||||
<span className="block mt-1 font-mono bg-surface px-2 py-1 rounded select-all text-[11px] break-all">{window.location.href}</span>
|
||||
</div>
|
||||
{roomStatus === "finished" && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => { leaveRoom(); navigate("/games"); }}
|
||||
className="rounded-md bg-primary text-primary-foreground px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Back to Lobby
|
||||
</button>
|
||||
</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
|
||||
state={gameState}
|
||||
myPlayerId={userId}
|
||||
|
||||
Reference in New Issue
Block a user