From f9a245742b2a156b12103e3c34502086f6ecb69d Mon Sep 17 00:00:00 2001 From: stefan-kp <65659186+stefan-kp@users.noreply.github.com> Date: Wed, 3 Dec 2025 08:45:31 +0100 Subject: [PATCH] Handle resignation coach response and add analysis option --- src/components/ChessGame.test.tsx | 4 +- src/components/ChessGame.tsx | 218 +++++++++++++++++++++--- src/components/GameOverModal.tsx | 10 +- src/components/Tutor.tsx | 56 +++++- src/components/__tests__/Tutor.test.tsx | 114 ++++++++++--- src/lib/i18n/translations.ts | 12 ++ 6 files changed, 366 insertions(+), 48 deletions(-) diff --git a/src/components/ChessGame.test.tsx b/src/components/ChessGame.test.tsx index 7b35200..560b77a 100644 --- a/src/components/ChessGame.test.tsx +++ b/src/components/ChessGame.test.tsx @@ -48,7 +48,9 @@ jest.mock("./GameAnalysisModal", () => ({ })); jest.mock("./GameOverModal", () => ({ - GameOverModal: () =>
Game Over Modal Mock
, + GameOverModal: ({ onAnalyze }: { onAnalyze: () => void }) => ( +
Game Over Modal Mock
+ ), })); jest.mock("./StartScreen", () => ({ diff --git a/src/components/ChessGame.tsx b/src/components/ChessGame.tsx index 4ea5901..7ba115c 100644 --- a/src/components/ChessGame.tsx +++ b/src/components/ChessGame.tsx @@ -13,7 +13,7 @@ import { SupportedLanguage } from "@/lib/i18n/translations"; import { lookupOpening, lookupPossibleOpenings, extractMoveSequenceFromPGN, OpeningMetadata } from "@/lib/openings"; import { GameAnalysisModal } from "./GameAnalysisModal"; import { GameOverModal, MoveHistoryItem } from "./GameOverModal"; -import { Brain, ArrowLeft, Download } from "lucide-react"; +import { Brain, ArrowLeft, Download, Flag } from "lucide-react"; import { CapturedPieces } from "./CapturedPieces"; import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection"; import { upsertSavedGame } from "@/lib/savedGames"; @@ -68,7 +68,16 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso const [gameOverState, setGameOverState] = useState<{ result: string, winner: "White" | "Black" | "Draw" } | null>(null); const [moveHistory, setMoveHistory] = useState([]); const [selectedPersonality, setSelectedPersonality] = useState(initialPersonality); + const [resignationContext, setResignationContext] = useState<{ + trigger: number; + fen: string; + evaluation: StockfishEvaluation | null; + history: MoveHistoryItem[]; + result: string; + winner: "White" | "Black" | "Draw"; + } | null>(null); const messagesEndRef = useRef(null); + const hasRebuiltHistoryRef = useRef(false); // Sound Refs const moveSound = useRef(null); @@ -110,12 +119,125 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso return () => sf.terminate(); }, []); + useEffect(() => { + hasRebuiltHistoryRef.current = false; + }, [initialPgn]); + useEffect(() => { if (typeof initialStockfishDepth === 'number') { setStockfishDepth(initialStockfishDepth); } }, [initialStockfishDepth]); + useEffect(() => { + if (!initialPgn || !stockfish) return; + if (moveHistory.length > 0 || hasRebuiltHistoryRef.current) return; + + let isCancelled = false; + hasRebuiltHistoryRef.current = true; + + const rebuildHistoryFromPgn = async () => { + try { + const setupFen = initialFen || undefined; + const parsingGame = new Chess(setupFen); + parsingGame.loadPgn(initialPgn); + const verboseMoves = parsingGame.history({ verbose: true }); + + const replayGame = new Chess(setupFen); + const playerTurnColor = playerColor === 'white' ? 'w' : 'b'; + const rebuiltHistory: MoveHistoryItem[] = []; + + for (let i = 0; i < verboseMoves.length; i++) { + const move = verboseMoves[i]; + + // Play through opponent moves until it's the player's turn + if (move.color !== playerTurnColor) { + replayGame.move(move); + continue; + } + + const moveNumber = Math.floor(i / 2) + 1; + const fenBeforePlayerMove = replayGame.fen(); + const evalBeforePlayerMove = await stockfish.evaluate(fenBeforePlayerMove, stockfishDepth); + + const playerMoveResult = replayGame.move(move); + if (!playerMoveResult) break; + + const fenAfterPlayerMove = replayGame.fen(); + const evalAfterPlayerMove = await stockfish.evaluate(fenAfterPlayerMove, stockfishDepth); + + let computerMoveSan = ''; + let fenAfterComputerMove = fenAfterPlayerMove; + let evalAfterComputerMove = evalAfterPlayerMove; + + if (i + 1 < verboseMoves.length && verboseMoves[i + 1].color !== move.color) { + const computerMove = verboseMoves[i + 1]; + const computerMoveResult = replayGame.move(computerMove); + if (computerMoveResult) { + computerMoveSan = computerMoveResult.san; + fenAfterComputerMove = replayGame.fen(); + evalAfterComputerMove = await stockfish.evaluate(fenAfterComputerMove, stockfishDepth); + i++; // Skip the computer move we just processed + } + } + + const isWhite = playerColor === 'white'; + const evalBeforePerspective = isWhite ? evalBeforePlayerMove.score : -evalBeforePlayerMove.score; + const evalAfterPerspective = isWhite ? -evalAfterPlayerMove.score : evalAfterPlayerMove.score; + const cpLoss = evalBeforePerspective - evalAfterPerspective; + + const bestMoveUci = evalBeforePlayerMove.bestMove; + const bestMoveSan = bestMoveUci ? uciToSan(fenBeforePlayerMove, bestMoveUci) : null; + const missedTactics = bestMoveUci ? detectMissedTactics({ + fen: fenBeforePlayerMove, + playerColor, + playerMoveSan: playerMoveResult.san, + bestMoveUci, + cpLoss, + }) : undefined; + + const currentPgn = replayGame.pgn(); + const moveSequence = extractMoveSequenceFromPGN(currentPgn); + const possibleOpenings = lookupPossibleOpenings(moveSequence, 5); + + rebuiltHistory.push({ + moveNumber, + playerMove: playerMoveResult.san, + playerColor, + fenBeforePlayerMove, + evalBeforePlayerMove, + fenAfterPlayerMove, + evalAfterPlayerMove, + computerMove: computerMoveSan || '...', + fenAfterComputerMove, + evalAfterComputerMove, + opening: possibleOpenings.length > 0 ? possibleOpenings[0].name : undefined, + move: playerMoveResult.san, + evalBefore: evalBeforePlayerMove.score, + evalAfter: evalAfterPlayerMove.score, + bestMove: evalBeforePlayerMove.bestMove, + bestMoveSan, + cpLoss, + missedTactics, + }); + } + + if (!isCancelled) { + setMoveHistory(rebuiltHistory); + } + } catch (error) { + console.error('Failed to rebuild move history from PGN', error); + hasRebuiltHistoryRef.current = false; + } + }; + + rebuildHistoryFromPgn(); + + return () => { + isCancelled = true; + }; + }, [initialPgn, stockfish, playerColor, stockfishDepth, initialFen, moveHistory.length]); + // Load Settings & Initial State useEffect(() => { const storedKey = localStorage.getItem("gemini_api_key"); @@ -473,9 +595,43 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso setEvalP0(null); setEvalP2(null); setOpeningData([]); + setResignationContext(null); updateCapturedPieces(); }; + const handleResign = useCallback(async () => { + if (gameOverState) return; + setIsAnalyzing(false); + + const currentFen = gameRef.current.fen(); + let evaluation: StockfishEvaluation | null = null; + + if (stockfish) { + try { + evaluation = await stockfish.evaluate(currentFen, stockfishDepth); + } catch (error) { + console.error("Failed to evaluate resignation position", error); + } + } + + const result = t.game.resignation; + const winner = playerColor === 'white' ? 'Black' : 'White' as const; + + setGameOverState({ + result, + winner, + }); + + setResignationContext({ + trigger: Date.now(), + fen: currentFen, + evaluation, + history: moveHistory, + result, + winner, + }); + }, [gameOverState, moveHistory, playerColor, stockfish, stockfishDepth, t.game.resignation]); + const handleDownloadPGN = () => { const pgn = gameRef.current.pgn(); const blob = new Blob([pgn], { type: 'text/plain' }); @@ -602,23 +758,34 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso )} - +
+ + + +
@@ -656,13 +823,14 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso language={language} playerColor={playerColor} onCheckComputerMove={checkAndMakeComputerMove} + resignationContext={resignationContext} /> {/* 4. History (Col 1-3) - Full width at bottom */}
-

Game History

+

{t.game.gameHistory}

@@ -683,8 +851,8 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso # - White - Black + {t.game.white} + {t.game.black} Eval Δ @@ -692,7 +860,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso {moveHistory.length === 0 ? ( - No moves yet. + {t.game.noMovesYet} ) : ( @@ -757,6 +925,10 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso language={language} onClose={() => setGameOverState(null)} onNewGame={handleNewGame} + onAnalyze={() => { + setGameOverState(null); + setShowAnalysisModal(true); + }} /> )} diff --git a/src/components/GameOverModal.tsx b/src/components/GameOverModal.tsx index 1a4896b..0cb59b4 100644 --- a/src/components/GameOverModal.tsx +++ b/src/components/GameOverModal.tsx @@ -54,9 +54,10 @@ interface GameOverModalProps { language: SupportedLanguage; onClose: () => void; onNewGame: () => void; + onAnalyze: () => void; } -export function GameOverModal({ result, winner, history, apiKey, language, onClose, onNewGame }: GameOverModalProps) { +export function GameOverModal({ result, winner, history, apiKey, language, onClose, onNewGame, onAnalyze }: GameOverModalProps) { const [analysis, setAnalysis] = useState(""); const [isLoading, setIsLoading] = useState(true); const [mistakes, setMistakes] = useState([]); @@ -329,6 +330,13 @@ Plain text paragraph (2-3 sentences). > Close +