"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { Chess, Move } from "chess.js"; import { Chessboard } from "react-chessboard"; import { Stockfish, StockfishEvaluation } from "@/lib/stockfish"; import { Tutor } from "./Tutor"; import { EvaluationBar } from "./EvaluationBar"; import { Personality } from "@/lib/personalities"; import Header from "./Header"; import { useTranslation } from "@/lib/i18n/useTranslation"; 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, Flag } from "lucide-react"; import { CapturedPieces } from "./CapturedPieces"; import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection"; import { upsertSavedGame } from "@/lib/savedGames"; interface ChessGameProps { gameId: string; initialFen?: string; initialPgn?: string; initialPersonality: Personality; initialColor: 'white' | 'black'; initialStockfishDepth?: number; onBack: () => void; } const PIECE_VALUES: Record = { 'p': 1, 'n': 3, 'b': 3, 'r': 5, 'q': 9, 'k': 0 }; export default function ChessGame({ gameId, initialFen, initialPgn, initialPersonality, initialColor, initialStockfishDepth, onBack }: ChessGameProps) { const gameRef = useRef(new Chess(initialFen || "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")); const [fen, setFen] = useState(gameRef.current.fen()); const [stockfish, setStockfish] = useState(null); // Analysis States const [evalP0, setEvalP0] = useState(null); const [evalP2, setEvalP2] = useState(null); // Opening Data const [openingData, setOpeningData] = useState([]); // Tactical Analysis Data const [latestMissedTactics, setLatestMissedTactics] = useState(null); const [userMove, setUserMove] = useState(null); const [computerMove, setComputerMove] = useState(null); const [isAnalyzing, setIsAnalyzing] = useState(false); const [apiKey, setApiKey] = useState(null); const [stockfishDepth, setStockfishDepth] = useState(initialStockfishDepth ?? 15); // Settings const [language, setLanguage] = useState('en'); // Game State const [playerColor, setPlayerColor] = useState<'white' | 'black'>(initialColor); const [showAnalysisModal, setShowAnalysisModal] = useState(false); const [showDownloadModal, setShowDownloadModal] = useState(false); 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); const captureSound = useRef(null); const checkSound = useRef(null); const victorySound = useRef(null); const defeatSound = useRef(null); useEffect(() => { moveSound.current = new Audio('/sounds/move.wav'); captureSound.current = new Audio('/sounds/capture.wav'); checkSound.current = new Audio('/sounds/check.wav'); victorySound.current = new Audio('/sounds/victory.wav'); defeatSound.current = new Audio('/sounds/defeat.wav'); }, []); const playMoveSound = (captured: boolean) => { if (captured) { captureSound.current?.play().catch(e => console.error("Audio play failed", e)); } else { moveSound.current?.play().catch(e => console.error("Audio play failed", e)); } }; // Removed auto-scroll to prevent page jumping when moves are added // Users can manually scroll to see move history if needed // Captured Pieces State const [capturedWhitePieces, setCapturedWhitePieces] = useState([]); const [capturedBlackPieces, setCapturedBlackPieces] = useState([]); const [materialScore, setMaterialScore] = useState<{ white: number, black: number }>({ white: 0, black: 0 }); const t = useTranslation(language); // Initialize Stockfish useEffect(() => { const sf = new Stockfish(); setStockfish(sf); 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"); const storedLang = localStorage.getItem("chess_tutor_language"); if (storedKey) setApiKey(storedKey); if (storedLang) setLanguage(storedLang as SupportedLanguage); // If initialFen is provided, ensure gameRef is synced if (initialFen && initialFen !== gameRef.current.fen()) { gameRef.current = new Chess(initialFen); setFen(initialFen); updateCapturedPieces(); // Update captured pieces for loaded game } // If initialPgn is provided, load it to restore history if (initialPgn) { try { gameRef.current.loadPgn(initialPgn); setFen(gameRef.current.fen()); updateCapturedPieces(); } catch (e) { console.error("Failed to load PGN:", e); } } // If computer is white (player is black) and it's the start of the game, make a move // But only if we are at the start position if (initialColor === 'black' && gameRef.current.fen() === "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" && stockfish) { // Small delay to ensure stockfish is ready setTimeout(() => { stockfish.evaluate(gameRef.current.fen(), 10).then(evalResult => { const computerMoveData = { from: evalResult.bestMove.substring(0, 2), to: evalResult.bestMove.substring(2, 4), promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q" }; makeAMove(computerMoveData); }); }, 1000); } }, [initialFen, initialColor, stockfish]); // Run when these change // Save Game State on Change useEffect(() => { const saveData = { id: gameId, fen, language, selectedPersonality, apiKey, playerColor, // Save player color too pgn: gameRef.current.pgn(), updatedAt: Date.now(), evaluation: evalP0 ? { score: evalP0.score, mate: evalP0.mate, depth: evalP0.depth } : null }; upsertSavedGame(saveData); localStorage.setItem("chess_tutor_save", JSON.stringify(saveData)); }, [fen, language, selectedPersonality, apiKey, playerColor, gameId, evalP0]); // Game Over Detection useEffect(() => { const game = gameRef.current; if (game.isGameOver()) { let result = ""; let winner: "White" | "Black" | "Draw" = "Draw"; if (game.isCheckmate()) { if (game.turn() === 'w') { result = "Checkmate! You lost."; winner = "Black"; if (playerColor === 'white') defeatSound.current?.play().catch(e => console.error(e)); else victorySound.current?.play().catch(e => console.error(e)); } else { result = "Checkmate! You won!"; winner = "White"; if (playerColor === 'white') victorySound.current?.play().catch(e => console.error(e)); else defeatSound.current?.play().catch(e => console.error(e)); } } else if (game.isDraw()) { result = "Draw!"; winner = "Draw"; } else if (game.isStalemate()) { result = "Stalemate!"; winner = "Draw"; } else if (game.inCheck()) { checkSound.current?.play().catch(e => console.error(e)); } setGameOverState({ result, winner }); } }, [fen, playerColor]); // Pre-Analysis (P0) useEffect(() => { const playerTurn = playerColor === 'white' ? 'w' : 'b'; if (stockfish && gameRef.current.turn() === playerTurn && !isAnalyzing && !gameOverState) { stockfish.evaluate(gameRef.current.fen(), stockfishDepth).then(evalResult => { setEvalP0(evalResult); }).catch(err => console.error("Pre-analysis failed:", err)); } }, [playerColor, fen, stockfish, stockfishDepth, isAnalyzing, gameOverState]); const updateCapturedPieces = useCallback(() => { const history = gameRef.current.history({ verbose: true }); const whitePiecesLost: string[] = []; const blackPiecesLost: string[] = []; let whiteLostScore = 0; let blackLostScore = 0; history.forEach(move => { if (move.captured) { if (move.color === 'w') { // White moved, captured a black piece. So a black piece was lost. blackPiecesLost.push(move.captured); blackLostScore += PIECE_VALUES[move.captured] || 0; } else { // Black moved, captured a white piece. So a white piece was lost. whitePiecesLost.push(move.captured); whiteLostScore += PIECE_VALUES[move.captured] || 0; } } }); setCapturedWhitePieces(whitePiecesLost); setCapturedBlackPieces(blackPiecesLost); setMaterialScore({ white: whiteLostScore, black: blackLostScore }); }, []); const makeAMove = useCallback( (move: { from: string; to: string; promotion?: string }) => { try { const game = gameRef.current; const result = game.move(move); if (result) { const newFen = game.fen(); setFen(newFen); updateCapturedPieces(); playMoveSound(!!result.captured); // If it was computer's move, update state if (game.turn() === 'w') { // Computer just moved (assuming computer is Black? No, wait) // Logic below handles turns } return { result, newFen }; } } catch (e) { return null; } return null; }, [updateCapturedPieces] ); function onDrop({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) { if (!targetSquare || !stockfish || gameOverState) return false; // Check if it's the player's turn const currentTurn = gameRef.current.turn(); // 'w' or 'b' const playerTurn = playerColor === 'white' ? 'w' : 'b'; if (currentTurn !== playerTurn) { // Not the player's turn - prevent move return false; } // Wait for pre-analysis (evalP0) to be available before allowing moves // This ensures we can properly track move history with evaluations if (!evalP0) { console.log("Waiting for position analysis before move..."); return false; } const move = { from: sourceSquare, to: targetSquare, promotion: "q", }; // Capture FEN BEFORE player's move (P0) const fenP0 = gameRef.current.fen(); // 1. User Move (P0 -> P1) const moveResult = makeAMove(move); if (!moveResult) return false; setUserMove(moveResult.result); // Reset Computer State setComputerMove(null); setEvalP2(null); setOpeningData([]); setIsAnalyzing(true); const { newFen: fenP1 } = moveResult; // 2. Bot Move (P1 -> P2) stockfish.evaluate(fenP1, stockfishDepth).then(p1Eval => { // Store partial history data if evalP0 is available const partialHistoryItem = evalP0 ? { moveNumber: gameRef.current.moveNumber(), playerMove: moveResult.result.san, playerColor: playerColor, fenBeforePlayerMove: fenP0, evalBeforePlayerMove: evalP0, fenAfterPlayerMove: fenP1, evalAfterPlayerMove: p1Eval, } : null; // Computer should ALWAYS move, even if evalP0 is missing setTimeout(() => { const computerMoveData = { from: p1Eval.bestMove.substring(0, 2), to: p1Eval.bestMove.substring(2, 4), promotion: p1Eval.bestMove.length > 4 ? p1Eval.bestMove.substring(4, 5) : "q" }; const compResult = makeAMove(computerMoveData); if (compResult) { setComputerMove(compResult.result); const { newFen: fenP2 } = compResult; // 3. Post-Eval (P2) stockfish.evaluate(fenP2, stockfishDepth).then(p2Eval => { setEvalP2(p2Eval); // 4. Opening Lookup - Get multiple possible openings const currentPgn = gameRef.current.pgn(); const moveSequence = extractMoveSequenceFromPGN(currentPgn); const possibleOpenings = lookupPossibleOpenings(moveSequence, 5); setOpeningData(possibleOpenings); // 5. Complete the history item with computer's move data (only if we have evalP0) if (partialHistoryItem && evalP0) { const isWhite = playerColor === 'white'; const evalBefore = isWhite ? evalP0.score : -evalP0.score; const evalAfterPlayerMove = isWhite ? -p1Eval.score : p1Eval.score; const cpLoss = evalBefore - evalAfterPlayerMove; const bestMoveSan = uciToSan(fenP0, evalP0.bestMove); const missedTactics = detectMissedTactics({ fen: fenP0, playerColor, playerMoveSan: moveResult.result.san, bestMoveUci: evalP0.bestMove, cpLoss, }); // Store the latest tactics for the Tutor component setLatestMissedTactics(missedTactics); const completeHistoryItem: MoveHistoryItem = { ...partialHistoryItem, computerMove: compResult.result.san, fenAfterComputerMove: fenP2, evalAfterComputerMove: p2Eval, opening: possibleOpenings.length > 0 ? possibleOpenings[0].name : undefined, // Legacy fields for backward compatibility move: moveResult.result.san, evalBefore: evalP0.score, evalAfter: p1Eval.score, bestMove: evalP0.bestMove, bestMoveSan, cpLoss, missedTactics, }; setMoveHistory(prev => [...prev, completeHistoryItem]); } else { console.warn("Skipping move history - evalP0 was not available when player moved"); } setIsAnalyzing(false); }).catch(err => { console.error("P2 analysis failed:", err); setIsAnalyzing(false); }); } else { setIsAnalyzing(false); } }, 500); }).catch(err => { console.error("Bot move analysis failed:", err); setIsAnalyzing(false); }); return true; } // Check if computer needs to move (safety net for race conditions) const checkAndMakeComputerMove = useCallback(() => { if (!stockfish || gameOverState || isAnalyzing) return; const currentTurn = gameRef.current.turn(); const computerTurn = playerColor === 'white' ? 'b' : 'w'; // If it's the computer's turn and we're not already analyzing, make a move if (currentTurn === computerTurn) { console.log("Safety check: Computer's turn detected, making move..."); setIsAnalyzing(true); const currentFen = gameRef.current.fen(); stockfish.evaluate(currentFen, stockfishDepth).then(evalResult => { const computerMoveData = { from: evalResult.bestMove.substring(0, 2), to: evalResult.bestMove.substring(2, 4), promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q" }; const compResult = makeAMove(computerMoveData); if (compResult) { setComputerMove(compResult.result); const { newFen } = compResult; // Evaluate the position after computer's move stockfish.evaluate(newFen, stockfishDepth).then(p2Eval => { setEvalP2(p2Eval); const currentPgn = gameRef.current.pgn(); const moveSequence = extractMoveSequenceFromPGN(currentPgn); const possibleOpenings = lookupPossibleOpenings(moveSequence, 5); setOpeningData(possibleOpenings); setIsAnalyzing(false); }).catch(err => { console.error("Post-computer-move analysis failed:", err); setIsAnalyzing(false); }); } else { setIsAnalyzing(false); } }).catch(err => { console.error("Computer move evaluation failed:", err); setIsAnalyzing(false); }); } }, [stockfish, gameOverState, isAnalyzing, playerColor, stockfishDepth, makeAMove]); const handleNewGame = () => { // Reset game to initial props or just reload? // For now, let's just reset the board const newGame = new Chess(); gameRef.current = newGame; setFen(newGame.fen()); setGameOverState(null); setMoveHistory([]); setUserMove(null); setComputerMove(null); setEvalP0(null); setEvalP2(null); setOpeningData([]); setResignationContext(null); updateCapturedPieces(); }; const handleResign = useCallback(async () => { if (gameOverState) return; // Show confirmation dialog const confirmed = window.confirm(t.game.resignConfirm); if (!confirmed) 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.resignConfirm, t.game.resignation]); const handleDownloadPGN = () => { const pgn = gameRef.current.pgn(); const blob = new Blob([pgn], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `chess-game-${Date.now()}.pgn`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setShowDownloadModal(false); }; const handleDownloadFEN = () => { const fen = gameRef.current.fen(); const blob = new Blob([fen], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `chess-position-${Date.now()}.fen`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setShowDownloadModal(false); }; // Determine material advantage // If Black lost more value, White has advantage const whiteAdvantage = materialScore.black - materialScore.white; const blackAdvantage = materialScore.white - materialScore.black; const [showStrengthSlider, setShowStrengthSlider] = useState(false); return ( <>
{/* 1. Header (Col 1-3) */}
{t.game.playingAs} {playerColor === 'white' ? t.game.white : t.game.black} {t.game.vs} {selectedPersonality?.name}
{/* 2. Board Area (Col 1-2) */}
{/* Desktop Eval Bar (Vertical) */}
{/* Opponent's Captured Pieces (Top) */}
0 ? blackAdvantage : null) : (whiteAdvantage > 0 ? whiteAdvantage : null)} />
onDrop({ sourceSquare, targetSquare }), darkSquareStyle: { backgroundColor: '#779954' }, lightSquareStyle: { backgroundColor: '#e9edcc' }, animationDurationInMs: 200, boardOrientation: playerColor }} />
{/* Player's Captured Pieces (Bottom) */}
0 ? whiteAdvantage : null) : (blackAdvantage > 0 ? blackAdvantage : null)} />
{/* Board Footer: Controls */}
{showStrengthSlider && (
setStockfishDepth(parseInt(e.target.value))} className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-600" />
)}
{/* Mobile Eval Bar (Horizontal) */}
{/* 3. Tutor (Col 3) - Side by side with Board on Desktop */}
{/* Note: We rely on Tutor's internal height styling or pass a class. The Tutor component has 'h-[400px] md:h-full'. Since it's in a grid cell that might stretch, 'h-full' should work if the row has height. However, the Board Area defines the row height. */} { }} apiKey={apiKey} personality={selectedPersonality} language={language} playerColor={playerColor} onCheckComputerMove={checkAndMakeComputerMove} resignationContext={resignationContext} />
{/* 4. History (Col 1-3) - Full width at bottom */}

{t.game.gameHistory}

{moveHistory.length === 0 ? ( ) : ( moveHistory.map((item, idx) => { // Calculate evaluation change for player's move const evalBefore = item.evalBeforePlayerMove.score ?? 0; const evalAfter = item.evalAfterPlayerMove.score ?? 0; const evalChange = evalAfter - evalBefore; // Determine color based on evaluation change // Positive change = good for white, negative = good for black let evalColor = 'text-gray-500'; if (Math.abs(evalChange) > 50) { if (item.playerColor === 'white') { evalColor = evalChange > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'; } else { evalColor = evalChange < 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'; } } const evalDisplay = evalChange > 0 ? `+${(evalChange / 100).toFixed(1)}` : (evalChange / 100).toFixed(1); return ( ); }) )}
# {t.game.white} {t.game.black} Eval Δ
{t.game.noMovesYet}
{item.moveNumber}. {item.playerColor === 'white' ? item.playerMove : item.computerMove} {item.playerColor === 'black' ? item.playerMove : item.computerMove} {evalDisplay}
{showAnalysisModal && ( setShowAnalysisModal(false)} /> )} {gameOverState && ( setGameOverState(null)} onNewGame={handleNewGame} onAnalyze={() => { setGameOverState(null); setShowAnalysisModal(true); }} /> )} {showDownloadModal && (

{t.analysis.downloadTitle}

)} ); }