fix(chess): optimistic moves and forfeit UI feedback
Some checks failed
Deploy to Production / test (push) Failing after 31s

- Add localFen/localFenRef in ChessBoard for optimistic piece placement,
  preventing snap-back while awaiting server confirmation
- Sync localFen from server state on each chess.fen update
- Guard GAME_STATE handler in useGameRoom from overwriting a finished
  roomStatus, fixing the race where GAME_ENDED (pub/sub) arrives before
  GAME_STATE (direct ws.send)
- Reset confirmForfeit immediately on forfeit dispatch for instant UI feedback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 17:06:58 +02:00
parent 70a149ab82
commit 132f92d2d9
2 changed files with 33 additions and 8 deletions

View File

@@ -22,6 +22,18 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
const lastMoveRef = useRef<{ from: string; to: string } | null>(null); const lastMoveRef = useRef<{ from: string; to: string } | null>(null);
const [boardWidth, setBoardWidth] = useState(400); const [boardWidth, setBoardWidth] = useState(400);
// Optimistic local FEN: updated immediately on valid moves, synced from server state
const [localFen, setLocalFen] = useState<string>(() => chess?.fen ?? "start");
const localFenRef = useRef<string>(localFen);
// Sync local FEN when server confirms a new state
useEffect(() => {
if (chess?.fen) {
localFenRef.current = chess.fen;
setLocalFen(chess.fen);
}
}, [chess?.fen]);
// 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(() => {
@@ -51,9 +63,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
}, [chess?.moveHistory?.length]); }, [chess?.moveHistory?.length]);
const game = useMemo(() => { const game = useMemo(() => {
if (!chess?.fen) return null; if (!localFen) return null;
return new Chess(chess.fen); return new Chess(localFen);
}, [chess?.fen]); }, [localFen]);
if (!game) { if (!game) {
return <div className="text-text-tertiary text-sm">Waiting for game to start...</div>; return <div className="text-text-tertiary text-sm">Waiting for game to start...</div>;
@@ -76,6 +88,15 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
const isOpponentTurn = !isMyTurn && !isSpectator && !isGameOver; const isOpponentTurn = !isMyTurn && !isSpectator && !isGameOver;
function dispatchMove(from: string, to: string, promotion?: string) { function dispatchMove(from: string, to: string, promotion?: string) {
// Optimistically apply the move locally so the board doesn't snap back
try {
const optimistic = new Chess(localFenRef.current);
const moved = optimistic.move({ from, to, promotion: promotion ?? "q" });
if (moved) {
localFenRef.current = optimistic.fen();
setLocalFen(optimistic.fen());
}
} catch { /* invalid move — server will reject it */ }
lastMoveRef.current = { from, to }; lastMoveRef.current = { from, to };
setSelectedSquare(null); setSelectedSquare(null);
onAction({ type: "move", from, to, ...(promotion ? { promotion } : {}) }); onAction({ type: "move", from, to, ...(promotion ? { promotion } : {}) });
@@ -84,7 +105,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
function onDrop(sourceSquare: string, targetSquare: string): boolean { function onDrop(sourceSquare: string, targetSquare: string): boolean {
if (isSpectator || !isMyTurn || isGameOver) return false; if (isSpectator || !isMyTurn || isGameOver) return false;
const testGame = new Chess(chessRef.current.fen); const testGame = new Chess(localFenRef.current);
// Check if this is a promotion move // Check if this is a promotion move
const piece = testGame.get(sourceSquare as any); const piece = testGame.get(sourceSquare as any);
@@ -128,7 +149,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
// If promotion dialog is open, ignore clicks // If promotion dialog is open, ignore clicks
if (promotionFrom !== null) return; if (promotionFrom !== null) return;
const testGame = new Chess(chessRef.current.fen); const testGame = new Chess(localFenRef.current);
// If a square is already selected // If a square is already selected
if (selectedSquare !== null) { if (selectedSquare !== null) {
@@ -276,7 +297,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
<div className={`rounded overflow-hidden border-2 transition-colors duration-300 ${boardBorderClass}`}> <div className={`rounded overflow-hidden border-2 transition-colors duration-300 ${boardBorderClass}`}>
<Chessboard <Chessboard
position={chess.fen} position={localFen}
onPieceDrop={onDrop} onPieceDrop={onDrop}
onPromotionPieceSelect={handlePromotion} onPromotionPieceSelect={handlePromotion}
onSquareClick={onSquareClick} onSquareClick={onSquareClick}
@@ -334,7 +355,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
{confirmForfeit ? ( {confirmForfeit ? (
<> <>
<button <button
onClick={() => onAction({ type: "forfeit" })} onClick={() => { onAction({ type: "forfeit" }); setConfirmForfeit(false); }}
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" 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? Confirm forfeit?

View File

@@ -58,7 +58,11 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
break; break;
case "GAME_STATE": case "GAME_STATE":
setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" })); setState(prev => ({
...prev,
gameState: msg.state,
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
}));
break; break;
case "GAME_STARTED": case "GAME_STARTED":