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 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?
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user