fix(chess): optimistic moves and forfeit UI feedback
Some checks failed
Deploy to Production / test (push) Failing after 31s
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:
@@ -22,6 +22,18 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
const lastMoveRef = useRef<{ from: string; to: string } | null>(null);
|
||||
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
|
||||
const chessRef = useRef(chess);
|
||||
useEffect(() => {
|
||||
@@ -51,9 +63,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
}, [chess?.moveHistory?.length]);
|
||||
|
||||
const game = useMemo(() => {
|
||||
if (!chess?.fen) return null;
|
||||
return new Chess(chess.fen);
|
||||
}, [chess?.fen]);
|
||||
if (!localFen) return null;
|
||||
return new Chess(localFen);
|
||||
}, [localFen]);
|
||||
|
||||
if (!game) {
|
||||
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;
|
||||
|
||||
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 };
|
||||
setSelectedSquare(null);
|
||||
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 {
|
||||
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
|
||||
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 (promotionFrom !== null) return;
|
||||
|
||||
const testGame = new Chess(chessRef.current.fen);
|
||||
const testGame = new Chess(localFenRef.current);
|
||||
|
||||
// If a square is already selected
|
||||
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}`}>
|
||||
<Chessboard
|
||||
position={chess.fen}
|
||||
position={localFen}
|
||||
onPieceDrop={onDrop}
|
||||
onPromotionPieceSelect={handlePromotion}
|
||||
onSquareClick={onSquareClick}
|
||||
@@ -334,7 +355,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
{confirmForfeit ? (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
Confirm forfeit?
|
||||
|
||||
@@ -58,7 +58,11 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
||||
break;
|
||||
|
||||
case "GAME_STATE":
|
||||
setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" }));
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
gameState: msg.state,
|
||||
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
|
||||
}));
|
||||
break;
|
||||
|
||||
case "GAME_STARTED":
|
||||
|
||||
Reference in New Issue
Block a user