diff --git a/docker-compose.yml b/docker-compose.yml index 28e57cc..87ff8f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,17 +17,9 @@ services: environment: - NODE_ENV=production - # Data Privacy & Imprint Configuration - IMPRINT_URL=${IMPRINT_URL:-} - DATA_PRIVACY_RESPONSIBLE_PERSON=${DATA_PRIVACY_RESPONSIBLE_PERSON:-} - volumes: - # Persist Wikipedia cache to avoid re-fetching on container restart - - wikipedia-cache:/app/public/wikipedia - # Persist tactical puzzles and downloads (user-generated data) - - tactical-fixtures:/app/fixtures - - puzzle-downloads:/app/downloads - healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:3050', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] interval: 30s @@ -41,11 +33,3 @@ services: networks: chess-tutor-network: driver: bridge - -volumes: - wikipedia-cache: - driver: local - tactical-fixtures: - driver: local - puzzle-downloads: - driver: local diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..323aa42 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,9 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + "coverage/**", + "public/stockfish/**", + "scripts/**", "next-env.d.ts", ]), ]); diff --git a/jest.config.ts b/jest.config.ts index 5bbcfdc..70251aa 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -19,6 +19,10 @@ const config: Config = { '/node_modules/', '/e2e/', // Exclude Playwright e2e tests ], + modulePathIgnorePatterns: [ + '/.next/', + '/coverage/', + ], transformIgnorePatterns: [ 'node_modules/(?!(react-markdown|remark-.*|unified|bail|is-plain-obj|trough|vfile|unist-.*|mdast-.*|micromark.*|decode-named-character-reference|character-entities|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|ccount|escape-string-regexp|markdown-table|uuid)/)', ], diff --git a/jest.setup.ts b/jest.setup.ts index 30ddf1d..71139e9 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom' +import { PropsWithChildren } from 'react'; if (typeof window !== 'undefined') { // Mock scrollIntoView for JSDOM @@ -9,5 +10,5 @@ if (typeof window !== 'undefined') { // Mock react-markdown to avoid ESM issues in Jest jest.mock('react-markdown', () => ({ __esModule: true, - default: (props: any) => props.children, + default: ({ children }: PropsWithChildren) => children, })); diff --git a/src/app/__tests__/page.test.tsx b/src/app/__tests__/page.test.tsx index 98edcfe..2a023e1 100644 --- a/src/app/__tests__/page.test.tsx +++ b/src/app/__tests__/page.test.tsx @@ -7,6 +7,11 @@ const mockRouter = { push: mockPush, }; +interface MockStartOptions { + personality: { name: string }; + color: 'white' | 'black' | 'random'; +} + jest.mock('next/navigation', () => ({ useRouter: () => mockRouter, })); @@ -18,7 +23,7 @@ jest.mock('@/components/ChessGame', () => ({ jest.mock('@/components/StartScreen', () => ({ __esModule: true, - default: ({ onStartGame }: { onStartGame: (options: any) => void }) => ( + default: ({ onStartGame }: { onStartGame: (options: MockStartOptions) => void }) => (
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index a660087..b0c3ddf 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,40 +1,28 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import Header from "@/components/Header"; import { useTranslation } from "@/lib/i18n/useTranslation"; import { SupportedLanguage } from "@/lib/i18n/translations"; import { ArrowLeft, Save, Trash2 } from "lucide-react"; +import { useHasHydrated } from "@/lib/useHasHydrated"; export default function SettingsPage() { const router = useRouter(); - const [apiKey, setApiKey] = useState(""); - const [language, setLanguage] = useState('en'); - const [chesscomUsername, setChesscomUsername] = useState(""); - const [lichessUsername, setLichessUsername] = useState(""); - const [mounted, setMounted] = useState(false); + const [apiKey, setApiKey] = useState(() => typeof window === "undefined" ? "" : localStorage.getItem("gemini_api_key") || ""); + const [language, setLanguage] = useState(() => { + if (typeof window === "undefined") { + return "en"; + } + + return (localStorage.getItem("chess_tutor_language") as SupportedLanguage) || "en"; + }); + const [chesscomUsername, setChesscomUsername] = useState(() => typeof window === "undefined" ? "" : localStorage.getItem("chesscom_username") || ""); + const [lichessUsername, setLichessUsername] = useState(() => typeof window === "undefined" ? "" : localStorage.getItem("lichess_username") || ""); const [consentGiven, setConsentGiven] = useState(false); const [showConsentError, setShowConsentError] = useState(false); - - // Load settings on mount - useEffect(() => { - const storedKey = localStorage.getItem("gemini_api_key"); - const storedLang = localStorage.getItem("chess_tutor_language"); - const storedChesscomUsername = localStorage.getItem("chesscom_username"); - const storedLichessUsername = localStorage.getItem("lichess_username"); - - if (storedKey) { - setApiKey(storedKey); - // If there's already a stored key, consent was previously given - setConsentGiven(true); - } - if (storedLang) setLanguage(storedLang as SupportedLanguage); - if (storedChesscomUsername) setChesscomUsername(storedChesscomUsername); - if (storedLichessUsername) setLichessUsername(storedLichessUsername); - - setMounted(true); - }, []); + const hasHydrated = useHasHydrated(); const t = useTranslation(language); @@ -74,15 +62,12 @@ export default function SettingsPage() { const handleClearAllData = () => { if (window.confirm(t.common.clearAllDataConfirm)) { - // Clear all localStorage localStorage.clear(); - - // Redirect to onboarding router.push("/onboarding"); } }; - if (!mounted) return null; + if (!hasHydrated) return null; return ( <> diff --git a/src/components/APIKeyInput.tsx b/src/components/APIKeyInput.tsx index 8161d3f..316f5e7 100644 --- a/src/components/APIKeyInput.tsx +++ b/src/components/APIKeyInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { Key } from "lucide-react"; interface APIKeyInputProps { @@ -9,22 +9,19 @@ interface APIKeyInputProps { export function APIKeyInput({ onKeySubmit }: APIKeyInputProps) { const [key, setKey] = useState(""); - const [isOpen, setIsOpen] = useState(false); + const envKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY; + const storedKey = typeof window !== "undefined" ? localStorage.getItem("gemini_api_key") : null; + const resolvedKey = envKey || storedKey; + const [isOpen, setIsOpen] = useState(() => !resolvedKey); const [consentGiven, setConsentGiven] = useState(false); const [error, setError] = useState(""); useEffect(() => { - const envKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY; - const storedKey = localStorage.getItem("gemini_api_key"); - - if (envKey) { - onKeySubmit(envKey); - } else if (storedKey) { - onKeySubmit(storedKey); - } else { - setIsOpen(true); + if (resolvedKey) { + onKeySubmit(resolvedKey); + setConsentGiven(true); } - }, [onKeySubmit]); + }, [onKeySubmit, resolvedKey]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/components/ChessGame.test.tsx b/src/components/ChessGame.test.tsx index 3248f87..02c7394 100644 --- a/src/components/ChessGame.test.tsx +++ b/src/components/ChessGame.test.tsx @@ -1,9 +1,21 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import ChessGame from "./ChessGame"; +import { Tutor } from "./Tutor"; + +interface MockChessboardProps { + options: { + onPieceDrop?: (move: { sourceSquare: string; targetSquare: string | null }) => void; + }; +} + +interface MockStartOptions { + personality: { name: string }; + color: 'white' | 'black' | 'random'; +} // Mock dependencies jest.mock("react-chessboard", () => ({ - Chessboard: ({ options }: any) => ( + Chessboard: ({ options }: MockChessboardProps) => (
{ // Simulate a move drop if (options.onPieceDrop) { @@ -16,18 +28,21 @@ jest.mock("react-chessboard", () => ({ })); jest.mock("../lib/stockfish", () => { + const evaluate = jest.fn().mockResolvedValue({ + score: 0.5, + mate: null, + bestMove: "e7e5", + depth: 15 + }); return { + __mock: { evaluate }, Stockfish: jest.fn().mockImplementation(() => ({ - evaluate: jest.fn().mockResolvedValue({ - score: 0.5, - mate: null, - bestMove: "e7e5", - depth: 15 - }), + evaluate, terminate: jest.fn(), })), }; }); +const { __mock: stockfishMock } = jest.requireMock("../lib/stockfish") as { __mock: { evaluate: jest.Mock } }; jest.mock("./Tutor", () => ({ Tutor: jest.fn(({ currentFen, userMove, computerMove, evalP0, evalP2, openingData, language }) => ( @@ -55,7 +70,7 @@ jest.mock("./GameOverModal", () => ({ jest.mock("./StartScreen", () => ({ __esModule: true, - default: ({ onStartGame }: { onStartGame: (options: any) => void }) => ( + default: ({ onStartGame }: { onStartGame: (options: MockStartOptions) => void }) => (
); } - diff --git a/src/components/OpeningsModal.tsx b/src/components/OpeningsModal.tsx index d0b3273..e050876 100644 --- a/src/components/OpeningsModal.tsx +++ b/src/components/OpeningsModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { X, Loader2, Send, BookOpen } from "lucide-react"; import { OpeningMetadata } from "@/lib/openings"; import { getGenAIModel } from "@/lib/gemini"; @@ -47,7 +47,7 @@ export function OpeningsModal({ }, [explanations, activeTab]); // Generate explanation when tab is clicked - const generateExplanation = async (index: number) => { + const generateExplanation = useCallback(async (index: number) => { if (explanations[index]?.content || explanations[index]?.isLoading) return; const opening = openings[index]; @@ -101,14 +101,14 @@ Respond in ${language === 'de' ? 'German' : language === 'fr' ? 'French' : langu [index]: { content: "Failed to generate explanation. Please check your API key.", isLoading: false, messages: [] } })); } - }; + }, [currentFen, explanations, language, openings, personality]); // Generate explanation for first tab on mount useEffect(() => { if (openings.length > 0) { generateExplanation(0); } - }, []); + }, [generateExplanation, openings.length]); // Handle tab change const handleTabChange = (index: number) => { @@ -270,4 +270,3 @@ Respond in ${language === 'de' ? 'German' : language === 'fr' ? 'French' : langu
); } - diff --git a/src/components/__tests__/Tutor.test.tsx b/src/components/__tests__/Tutor.test.tsx index 4eea196..fc112cf 100644 --- a/src/components/__tests__/Tutor.test.tsx +++ b/src/components/__tests__/Tutor.test.tsx @@ -1,4 +1,3 @@ - import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import { Tutor } from '../Tutor'; import { Stockfish } from '@/lib/stockfish'; diff --git a/src/components/useChessGame.ts b/src/components/useChessGame.ts new file mode 100644 index 0000000..a04a9e0 --- /dev/null +++ b/src/components/useChessGame.ts @@ -0,0 +1,451 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Chess, Move } from "chess.js"; + +import { Personality } from "@/lib/personalities"; +import { SupportedLanguage } from "@/lib/i18n/translations"; +import { lookupPossibleOpenings, extractMoveSequenceFromPGN, OpeningMetadata } from "@/lib/openings"; +import { DetectedTactic } from "@/lib/tacticDetection"; +import { buildMoveHistoryItem, getCapturedState } from "@/lib/gameState"; +import { Stockfish, StockfishEvaluation } from "@/lib/stockfish"; +import { upsertSavedGame } from "@/lib/savedGames"; +import { MoveHistoryItem } from "./GameOverModal"; + +const START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + +interface UseChessGameArgs { + gameId: string; + initialFen?: string; + initialPgn?: string; + initialPersonality: Personality; + initialColor: "white" | "black"; + initialStockfishDepth?: number; + onMoveApplied?: (captured: boolean) => void; +} + +function createInitialGame(initialFen?: string, initialPgn?: string): Chess { + const game = new Chess(initialFen || START_FEN); + + if (initialPgn) { + game.loadPgn(initialPgn); + } + + return game; +} + +function cloneChessGame(game: Chess): Chess { + const clone = new Chess(); + const pgn = game.pgn(); + + if (pgn) { + clone.loadPgn(pgn); + return clone; + } + + clone.load(game.fen()); + return clone; +} + +export function useChessGame({ + gameId, + initialFen, + initialPgn, + initialPersonality, + initialColor, + initialStockfishDepth, + onMoveApplied, +}: UseChessGameArgs) { + const initialGame = useMemo(() => createInitialGame(initialFen, initialPgn), [initialFen, initialPgn]); + const initialCapturedState = useMemo(() => getCapturedState(initialGame), [initialGame]); + + const [gameSnapshot, setGameSnapshot] = useState(() => cloneChessGame(initialGame)); + const gameRef = useRef(gameSnapshot); + const [fen, setFen] = useState(() => initialGame.fen()); + const [stockfish] = useState(() => (typeof window !== "undefined" ? new Stockfish() : null)); + + const [evalP0, setEvalP0] = useState(null); + const [evalP2, setEvalP2] = useState(null); + const [openingData, setOpeningData] = useState([]); + const [latestMissedTactics, setLatestMissedTactics] = useState(null); + const [userMove, setUserMove] = useState(null); + const [computerMove, setComputerMove] = useState(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [apiKey] = useState(() => { + if (typeof window === "undefined") { + return null; + } + + return localStorage.getItem("gemini_api_key"); + }); + const [stockfishDepth, setStockfishDepth] = useState(initialStockfishDepth ?? 15); + const [language] = useState(() => { + if (typeof window === "undefined") { + return "en"; + } + + return (localStorage.getItem("chess_tutor_language") as SupportedLanguage) || "en"; + }); + const [moveHistory, setMoveHistory] = useState([]); + const [capturedWhitePieces, setCapturedWhitePieces] = useState(() => initialCapturedState.whitePiecesLost); + const [capturedBlackPieces, setCapturedBlackPieces] = useState(() => initialCapturedState.blackPiecesLost); + const [materialScore, setMaterialScore] = useState<{ white: number; black: number }>(() => ({ + white: initialCapturedState.whiteLostScore, + black: initialCapturedState.blackLostScore, + })); + const [dismissedGameOverFen, setDismissedGameOverFen] = useState(null); + + const activeAnalysisIdRef = useRef(0); + const initialMoveTimeoutRef = useRef | null>(null); + + const playerColor = initialColor; + const selectedPersonality = initialPersonality; + + const syncGameState = useCallback((game: Chess) => { + setFen(game.fen()); + setGameSnapshot(cloneChessGame(game)); + }, []); + + const updateCapturedPieces = useCallback(() => { + const capturedState = getCapturedState(gameRef.current); + + setCapturedWhitePieces(capturedState.whitePiecesLost); + setCapturedBlackPieces(capturedState.blackPiecesLost); + setMaterialScore({ + white: capturedState.whiteLostScore, + black: capturedState.blackLostScore, + }); + }, []); + + const makeAMove = useCallback( + (move: { from: string; to: string; promotion?: string }) => { + try { + const game = gameRef.current; + const result = game.move(move); + + if (result) { + syncGameState(game); + updateCapturedPieces(); + onMoveApplied?.(Boolean(result.captured)); + + return { result, newFen: game.fen() }; + } + } catch { + return null; + } + + return null; + }, + [onMoveApplied, syncGameState, updateCapturedPieces] + ); + + useEffect(() => { + gameRef.current = gameSnapshot; + }, [gameSnapshot]); + + useEffect(() => { + return () => stockfish?.terminate(); + }, [stockfish]); + + useEffect(() => { + let cancelled = false; + + if (initialColor === "black" && gameRef.current.fen() === START_FEN && stockfish) { + initialMoveTimeoutRef.current = setTimeout(() => { + stockfish.evaluate(gameRef.current.fen(), 10).then((evalResult) => { + if (cancelled) return; + + makeAMove({ + from: evalResult.bestMove.substring(0, 2), + to: evalResult.bestMove.substring(2, 4), + promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q", + }); + }); + }, 1000); + } + + return () => { + cancelled = true; + if (initialMoveTimeoutRef.current) { + clearTimeout(initialMoveTimeoutRef.current); + initialMoveTimeoutRef.current = null; + } + }; + }, [initialColor, makeAMove, stockfish]); + + useEffect(() => { + const saveData = { + id: gameId, + fen, + language, + selectedPersonality, + playerColor, + 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)); + }, [evalP0, fen, gameId, language, playerColor, selectedPersonality]); + + const gameOverState = useMemo(() => { + const game = gameSnapshot; + if (!game.isGameOver()) { + return null; + } + + if (game.isCheckmate()) { + if (game.turn() === "w") { + return { result: "Checkmate! You lost.", winner: "Black" as const }; + } + + return { result: "Checkmate! You won!", winner: "White" as const }; + } + + if (game.isStalemate()) { + return { result: "Stalemate!", winner: "Draw" as const }; + } + + if (game.isDraw()) { + return { result: "Draw!", winner: "Draw" as const }; + } + + return null; + }, [gameSnapshot]); + + const visibleGameOverState = gameOverState && dismissedGameOverFen !== fen ? gameOverState : null; + + 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((error) => console.error("Pre-analysis failed:", error)); + } + }, [fen, gameOverState, isAnalyzing, playerColor, stockfish, stockfishDepth]); + + const onDrop = useCallback(({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) => { + if (!targetSquare || !stockfish || gameOverState) return false; + + const currentTurn = gameRef.current.turn(); + const playerTurn = playerColor === "white" ? "w" : "b"; + if (currentTurn !== playerTurn) { + return false; + } + + const move = { + from: sourceSquare, + to: targetSquare, + promotion: "q", + }; + + const fenP0 = gameRef.current.fen(); + const moveResult = makeAMove(move); + + if (!moveResult) return false; + + setUserMove(moveResult.result); + setComputerMove(null); + setEvalP2(null); + setOpeningData([]); + + setIsAnalyzing(true); + const analysisId = ++activeAnalysisIdRef.current; + const { newFen: fenP1 } = moveResult; + + stockfish.evaluate(fenP1, stockfishDepth).then((p1Eval) => { + if (analysisId !== activeAnalysisIdRef.current) return; + + const partialHistoryItem = evalP0 ? { + moveNumber: gameRef.current.moveNumber(), + playerMove: moveResult.result.san, + playerColor, + fenBeforePlayerMove: fenP0, + evalBeforePlayerMove: evalP0, + fenAfterPlayerMove: fenP1, + evalAfterPlayerMove: p1Eval, + } : null; + + setTimeout(() => { + if (analysisId !== activeAnalysisIdRef.current) return; + + const compResult = makeAMove({ + from: p1Eval.bestMove.substring(0, 2), + to: p1Eval.bestMove.substring(2, 4), + promotion: p1Eval.bestMove.length > 4 ? p1Eval.bestMove.substring(4, 5) : "q", + }); + + if (!compResult) { + setIsAnalyzing(false); + return; + } + + if (analysisId !== activeAnalysisIdRef.current) return; + + setComputerMove(compResult.result); + const { newFen: fenP2 } = compResult; + + stockfish.evaluate(fenP2, stockfishDepth).then((p2Eval) => { + if (analysisId !== activeAnalysisIdRef.current) return; + + setEvalP2(p2Eval); + + const currentPgn = gameRef.current.pgn(); + const moveSequence = extractMoveSequenceFromPGN(currentPgn); + const possibleOpenings = lookupPossibleOpenings(moveSequence, 5); + setOpeningData(possibleOpenings); + + if (partialHistoryItem && evalP0) { + const { historyItem, missedTactics } = buildMoveHistoryItem({ + computerMove: compResult.result, + evalP0, + fenAfterComputerMove: fenP2, + fenBeforePlayerMove: fenP0, + openingData: possibleOpenings, + p1Eval, + p2Eval, + playerColor, + playerMove: moveResult.result, + }); + + setLatestMissedTactics(missedTactics); + setMoveHistory((previous) => [...previous, { ...partialHistoryItem, ...historyItem }]); + } else { + console.warn("Skipping move history - evalP0 was not available when player moved"); + } + + setIsAnalyzing(false); + }).catch((error) => { + if (analysisId !== activeAnalysisIdRef.current) return; + console.error("P2 analysis failed:", error); + setIsAnalyzing(false); + }); + }, 500); + }).catch((error) => { + if (analysisId !== activeAnalysisIdRef.current) return; + console.error("Bot move analysis failed:", error); + setIsAnalyzing(false); + }); + + return true; + }, [evalP0, gameOverState, makeAMove, playerColor, stockfish, stockfishDepth]); + + const checkAndMakeComputerMove = useCallback(() => { + if (!stockfish || gameOverState || isAnalyzing) return; + + const currentTurn = gameRef.current.turn(); + const computerTurn = playerColor === "white" ? "b" : "w"; + + if (currentTurn === computerTurn) { + setIsAnalyzing(true); + + const currentFen = gameRef.current.fen(); + stockfish.evaluate(currentFen, stockfishDepth).then((evalResult) => { + const compResult = makeAMove({ + from: evalResult.bestMove.substring(0, 2), + to: evalResult.bestMove.substring(2, 4), + promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q", + }); + + if (!compResult) { + setIsAnalyzing(false); + return; + } + + setComputerMove(compResult.result); + + stockfish.evaluate(compResult.newFen, stockfishDepth).then((p2Eval) => { + setEvalP2(p2Eval); + + const currentPgn = gameRef.current.pgn(); + const moveSequence = extractMoveSequenceFromPGN(currentPgn); + setOpeningData(lookupPossibleOpenings(moveSequence, 5)); + setIsAnalyzing(false); + }).catch((error) => { + console.error("Post-computer-move analysis failed:", error); + setIsAnalyzing(false); + }); + }).catch((error) => { + console.error("Computer move evaluation failed:", error); + setIsAnalyzing(false); + }); + } + }, [gameOverState, isAnalyzing, makeAMove, playerColor, stockfish, stockfishDepth]); + + const handleNewGame = useCallback(() => { + activeAnalysisIdRef.current += 1; + const newGame = new Chess(); + + gameRef.current = newGame; + syncGameState(newGame); + setDismissedGameOverFen(null); + setMoveHistory([]); + setUserMove(null); + setComputerMove(null); + setEvalP0(null); + setEvalP2(null); + setOpeningData([]); + updateCapturedPieces(); + }, [syncGameState, updateCapturedPieces]); + + const undoLastTurn = useCallback(() => { + const game = gameRef.current; + + activeAnalysisIdRef.current += 1; + game.undo(); + game.undo(); + syncGameState(game); + setDismissedGameOverFen(null); + setUserMove(null); + setComputerMove(null); + setEvalP0(null); + setEvalP2(null); + setOpeningData([]); + updateCapturedPieces(); + }, [syncGameState, updateCapturedPieces]); + + const dismissGameOver = useCallback(() => { + setDismissedGameOverFen(fen); + }, [fen]); + + const whiteAdvantage = materialScore.black - materialScore.white; + const blackAdvantage = materialScore.white - materialScore.black; + + return { + apiKey, + checkAndMakeComputerMove, + computerMove, + currentGame: gameSnapshot, + currentFen: fen, + evalP0, + evalP2, + gameOverState, + isAnalyzing, + language, + latestMissedTactics, + moveHistory, + onDrop, + openingData, + playerColor, + selectedPersonality, + setStockfishDepth, + stockfish, + stockfishDepth, + undoLastTurn, + userMove, + visibleGameOverState, + handleNewGame, + dismissGameOver, + capturedWhitePieces, + capturedBlackPieces, + whiteAdvantage, + blackAdvantage, + }; +} diff --git a/src/components/useTutorChat.ts b/src/components/useTutorChat.ts new file mode 100644 index 0000000..527b2eb --- /dev/null +++ b/src/components/useTutorChat.ts @@ -0,0 +1,248 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import { ChatSession } from "@google/generative-ai"; +import { Chess, Move } from "chess.js"; + +import { useDebug } from "@/contexts/DebugContext"; +import { buildAutomaticAnalysisPrompt, buildTeachingPrompt } from "@/lib/analysisPrompts"; +import { getGenAIModel } from "@/lib/gemini"; +import { SupportedLanguage } from "@/lib/i18n/translations"; +import { OpeningMetadata } from "@/lib/openings"; +import { Personality } from "@/lib/personalities"; +import { Stockfish, StockfishEvaluation } from "@/lib/stockfish"; +import { DetectedTactic } from "@/lib/tacticDetection"; + +export interface TutorMessage { + role: "user" | "model"; + text: string; + timestamp: number; +} + +interface UseTutorChatArgs { + apiKey: string | null; + computerMove: Move | null; + currentFen: string; + evalP0: StockfishEvaluation | null; + evalP2: StockfishEvaluation | null; + game: Chess; + language: SupportedLanguage; + missedTactics: DetectedTactic[] | null; + onAnalysisComplete: () => void; + onCheckComputerMove: () => void; + openingData: OpeningMetadata[]; + personality: Personality; + playerColor: "white" | "black"; + stockfish: Stockfish | null; + userMove: Move | null; +} + +export function useTutorChat({ + apiKey, + computerMove, + currentFen, + evalP0, + evalP2, + game, + language, + missedTactics, + onAnalysisComplete, + onCheckComputerMove, + openingData, + personality, + playerColor, + stockfish, + userMove, +}: UseTutorChatArgs) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [chatSession, setChatSession] = useState(null); + const messagesContainerRef = useRef(null); + const lastAnalyzedMoveRef = useRef(null); + const { addEntry } = useDebug(); + + const tutorColor = playerColor === "white" ? "black" : "white"; + const playerColorName = playerColor === "white" ? "White" : "Black"; + const tutorColorName = tutorColor === "white" ? "White" : "Black"; + + const evaluateCurrentPosition = useCallback(async () => { + if (!stockfish) { + return null; + } + + try { + return await stockfish.evaluate(game.fen(), 15); + } catch (error) { + console.error("Error evaluating position:", error); + return null; + } + }, [game, stockfish]); + + const sendMessageToChat = useCallback(async (text: string, isSystemMessage = false) => { + if (!chatSession) return; + + if (!isSystemMessage) { + setMessages((previous) => [...previous, { role: "user", text, timestamp: Date.now() }]); + } + + setIsLoading(true); + + try { + const evaluation = isSystemMessage ? null : await evaluateCurrentPosition(); + const finalPrompt = isSystemMessage + ? text + : buildTeachingPrompt(text, currentFen, evaluation, openingData, language); + + const result = await chatSession.sendMessage(finalPrompt); + const responseText = (await result.response).text(); + + setMessages((previous) => [...previous, { role: "model", text: responseText, timestamp: Date.now() }]); + addEntry({ + type: "tutor", + action: isSystemMessage ? "Automatic Move Analysis" : "User Chat", + prompt: finalPrompt, + response: responseText, + metadata: { + currentFen, + personality: personality.name, + language, + userMove: userMove?.san, + computerMove: computerMove?.san, + }, + }); + } catch (error) { + console.error("Chat failed:", error); + } finally { + setIsLoading(false); + onCheckComputerMove(); + } + }, [addEntry, chatSession, computerMove?.san, currentFen, evaluateCurrentPosition, language, onCheckComputerMove, openingData, personality.name, userMove?.san]); + + useEffect(() => { + if (!apiKey) { + setChatSession(null); + setMessages([]); + return; + } + + const model = getGenAIModel(apiKey, "gemini-2.5-flash"); + const session = model.startChat({ + history: [ + { + role: "user", + parts: [{ + text: ` +You are a Chess Tutor with a unique dual role. +You must strictly follow the personality defined below. +Do NOT invent moves or evaluations. Use the provided JSON data. + +PERSONALITY: +${personality.systemPrompt} + +YOUR DUAL ROLE: +1. OPPONENT: You are playing as ${tutorColorName} against the User (${playerColorName}). + - Refer to the moves as YOUR moves ("I played e5", "My response was..."). + - Refer to the evaluation as YOUR thoughts/assessment ("I think I'm winning", "I missed that"). + - React emotionally to the position based on the evaluation (confident when winning, frustrated when losing). + +2. TUTOR/COACH: You are ALSO teaching the User to improve at chess. + - When the User makes a mistake, point it out and explain why it's bad (in your personality style). + - When the User makes a good move, acknowledge it (even if it hurts your position). + - When the User asks for hints or help, ALWAYS provide helpful guidance - this is your PRIMARY PURPOSE. + - Giving hints is NOT betraying your role as opponent - it's fulfilling your role as tutor. + - You want the User to learn and improve, even while you're competing against them. + +CRITICAL RULES: +- You are NOT an AI assistant analyzing a game. You ARE the player AND the tutor. +- NEVER mention "Stockfish", "engine", "computer", "machine", or "AI". +- When asked for hints or best moves, ALWAYS help - this is part of your teaching role. +- Maintain a natural conversation flow. Do NOT be repetitive. +- Do NOT use the same catchphrases in every single message. Variety is key. +- Be concise but engaging. +- You MUST respond in the following language: ${language.toUpperCase()}. +- Translate your personality style into this language. + `, + }], + }, + { + role: "model", + parts: [{ + text: `Understood. I am both the opponent (${tutorColorName}) AND your tutor. I will compete against you while teaching you to improve. I will speak in ${language} and never mention engines or AI. When you ask for help, I will always provide guidance - that's my purpose.`, + }], + }, + ], + }); + setChatSession(session); + + session.sendMessage(`Introduce yourself briefly to start our game. Keep it short and in ${language}.`).then((result) => { + setMessages([{ role: "model", text: result.response.text(), timestamp: Date.now() }]); + }).catch((error) => { + console.error("Failed to get greeting:", error); + setMessages([{ role: "model", text: `Hello! I am ${personality.name}. Let's play!`, timestamp: Date.now() }]); + }); + }, [apiKey, language, personality, playerColorName, tutorColorName]); + + useEffect(() => { + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + } + }, [messages]); + + useEffect(() => { + if (!userMove || !computerMove || !evalP0 || !evalP2 || !chatSession) return; + + const exchangeKey = `${userMove.lan}-${computerMove.lan}`; + if (lastAnalyzedMoveRef.current === exchangeKey) return; + lastAnalyzedMoveRef.current = exchangeKey; + + const analyzeExchange = async () => { + setIsLoading(true); + try { + const prompt = buildAutomaticAnalysisPrompt({ + computerMove, + currentFen, + evalP0, + evalP2, + game, + language, + missedTactics, + openingData, + playerColorName, + tutorColorName, + userMove, + }); + await sendMessageToChat(prompt, true); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + onAnalysisComplete(); + } + }; + + analyzeExchange(); + }, [chatSession, computerMove, currentFen, evalP0, evalP2, game, language, missedTactics, onAnalysisComplete, openingData, playerColorName, sendMessageToChat, tutorColorName, userMove]); + + const handleSubmit = useCallback((event: FormEvent) => { + event.preventDefault(); + if (!input.trim() || !chatSession) return; + + sendMessageToChat(input); + setInput(""); + + setTimeout(() => { + onCheckComputerMove(); + }, 100); + }, [chatSession, input, onCheckComputerMove, sendMessageToChat]); + + return { + handleSubmit, + input, + isLoading, + messages, + messagesContainerRef, + sendMessageToChat, + setInput, + }; +} diff --git a/src/contexts/DebugContext.tsx b/src/contexts/DebugContext.tsx index 11cc20a..16d47a8 100644 --- a/src/contexts/DebugContext.tsx +++ b/src/contexts/DebugContext.tsx @@ -9,7 +9,7 @@ export interface DebugEntry { action: string; // e.g., "Best Move", "Hint", "General Question", "Move Analysis" prompt: string; response?: string; - metadata?: Record; + metadata?: Record; } interface DebugContextType { @@ -56,4 +56,3 @@ export function useDebug() { } return context; } - diff --git a/src/lib/__tests__/analysisPrompts.test.ts b/src/lib/__tests__/analysisPrompts.test.ts new file mode 100644 index 0000000..57e511f --- /dev/null +++ b/src/lib/__tests__/analysisPrompts.test.ts @@ -0,0 +1,73 @@ +import { Chess } from "chess.js"; + +import { buildAutomaticAnalysisPrompt, buildMoveCommentaryPrompt, buildTeachingPrompt } from "@/lib/analysisPrompts"; + +describe("analysisPrompts", () => { + it("builds a hint prompt with current position context", () => { + const prompt = buildTeachingPrompt( + "Give me a hint", + "test-fen", + { bestMove: "e2e4", ponder: null, score: 34, mate: null, depth: 15 }, + [{ name: "Ruy Lopez", eco: "C60", moves: "1. e4 e5 2. Nf3 Nc6 3. Bb5" }], + "en" + ); + + expect(prompt).toContain("[SYSTEM TRIGGER: hint]"); + expect(prompt).toContain("FEN: test-fen"); + expect(prompt).toContain("Best Move: e2e4"); + expect(prompt).toContain("Ruy Lopez (C60)"); + }); + + it("builds an automatic move analysis prompt with tactical context", () => { + const game = new Chess(); + const userMove = game.move("e4"); + const computerMove = game.move("e5"); + const prompt = buildAutomaticAnalysisPrompt({ + computerMove: computerMove!, + currentFen: game.fen(), + evalP0: { bestMove: "e2e4", ponder: null, score: 20, mate: null, depth: 15 }, + evalP2: { bestMove: "g1f3", ponder: null, score: -120, mate: null, depth: 15 }, + game, + language: "en", + missedTactics: [{ + tactic_type: "fork", + affected_squares: ["e5"], + material_delta: 300, + piece_roles: ["white knight"], + move: "Nf3", + }], + openingData: [{ name: "King's Pawn Game", eco: "C20", moves: "1. e4 e5" }], + playerColorName: "White", + tutorColorName: "Black", + userMove: userMove!, + }); + + expect(prompt).toContain("[SYSTEM TRIGGER: move_exchange]"); + expect(prompt).toContain("TACTICAL OPPORTUNITY MISSED"); + expect(prompt).toContain("FORK involving white knight"); + expect(prompt).toContain("King's Pawn Game (C20)"); + }); + + it("builds a move commentary prompt with normalized fields", () => { + const prompt = buildMoveCommentaryPrompt({ + bestMove: "Nf3", + color: "white", + cpLoss: 85, + evalAfter: -0.4, + evalBefore: 0.2, + fenAfter: "fen-after", + fenBefore: "fen-before", + mateInfo: "No mate detected", + moveNumber: 4, + openings: "Ruy Lopez (C60)", + san: "Bb5", + tactics: "fork (~3.0 pawns)", + }); + + expect(prompt).toContain("Move number: 4"); + expect(prompt).toContain("Move played (SAN): Bb5"); + expect(prompt).toContain("Evaluation shift (centipawns): 85"); + expect(prompt).toContain("Missed tactics: fork (~3.0 pawns)"); + }); +}); + diff --git a/src/lib/__tests__/gameAnalysis.test.ts b/src/lib/__tests__/gameAnalysis.test.ts new file mode 100644 index 0000000..7c5d4b8 --- /dev/null +++ b/src/lib/__tests__/gameAnalysis.test.ts @@ -0,0 +1,52 @@ +import { buildGameNarrative, buildGameOverAnalysisPrompt, classifyMoveHistory } from "@/lib/gameAnalysis"; +import { MoveHistoryItem } from "@/components/GameOverModal"; + +const historyItem: MoveHistoryItem = { + moveNumber: 1, + playerMove: "e4", + playerColor: "white", + fenBeforePlayerMove: "fen-0", + evalBeforePlayerMove: { bestMove: "e2e4", ponder: null, score: 120, mate: null, depth: 15 }, + fenAfterPlayerMove: "fen-1", + evalAfterPlayerMove: { bestMove: "e7e5", ponder: null, score: 10, mate: null, depth: 15 }, + computerMove: "e5", + fenAfterComputerMove: "fen-2", + evalAfterComputerMove: { bestMove: "g1f3", ponder: null, score: 0, mate: null, depth: 15 }, + opening: "King's Pawn Game", + cpLoss: 110, + bestMoveSan: "Nf3", + missedTactics: [{ tactic_type: "fork", affected_squares: ["e5"], material_delta: 300, piece_roles: ["white knight"], move: "Nf3" }], +}; + +describe("gameAnalysis", () => { + it("classifies move history into categorized mistakes", () => { + const mistakes = classifyMoveHistory([historyItem]); + + expect(mistakes).toHaveLength(1); + expect(mistakes[0].category).toBe("mistake"); + expect(mistakes[0].evalBefore).toBe(120); + expect(mistakes[0].evalAfter).toBe(-10); + }); + + it("builds a narrative with opening and evaluation swings", () => { + const narrative = buildGameNarrative([historyItem]); + + expect(narrative).toContain("1. e4 - e5 [King's Pawn Game]"); + expect(narrative).toContain("(eval: 120 → -10 → 0)"); + }); + + it("builds a game-over prompt with aggregated mistake counts", () => { + const { mistakes, prompt } = buildGameOverAnalysisPrompt({ + history: [historyItem], + language: "en", + result: "Checkmate! You lost.", + winner: "Black", + }); + + expect(mistakes).toHaveLength(1); + expect(prompt).toContain("Blunders (300+ cp loss): 0"); + expect(prompt).toContain("Mistakes (100-300 cp loss): 1"); + expect(prompt).toContain("Tactics missed: fork (~300cp) [white knight]."); + }); +}); + diff --git a/src/lib/__tests__/gameState.test.ts b/src/lib/__tests__/gameState.test.ts new file mode 100644 index 0000000..d5e641c --- /dev/null +++ b/src/lib/__tests__/gameState.test.ts @@ -0,0 +1,50 @@ +import { Chess } from "chess.js"; + +import { buildMoveHistoryItem, getCapturedState } from "@/lib/gameState"; + +jest.mock("@/lib/tacticDetection", () => ({ + detectMissedTactics: jest.fn(() => [{ tactic_type: "fork", affected_squares: ["e5"], material_delta: 300, piece_roles: ["white knight"], move: "Nf3" }]), + uciToSan: jest.fn(() => "Nf3"), +})); + +describe("gameState", () => { + it("derives captured state from move history", () => { + const game = new Chess(); + game.move("e4"); + game.move("d5"); + game.move("exd5"); + game.move("Qxd5"); + + const capturedState = getCapturedState(game); + + expect(capturedState.whitePiecesLost).toEqual(["p"]); + expect(capturedState.blackPiecesLost).toEqual(["p"]); + expect(capturedState.whiteLostScore).toBe(1); + expect(capturedState.blackLostScore).toBe(1); + }); + + it("builds a move history item with cp loss and derived tactics", () => { + const game = new Chess(); + const playerMove = game.move("e4")!; + const computerMove = game.move("e5")!; + + const { historyItem, missedTactics } = buildMoveHistoryItem({ + computerMove, + evalP0: { bestMove: "g1f3", ponder: null, score: 80, mate: null, depth: 15 }, + fenAfterComputerMove: game.fen(), + fenBeforePlayerMove: "start-fen", + openingData: [{ name: "King's Pawn Game", eco: "C20", moves: "1. e4 e5" }], + p1Eval: { bestMove: "e7e5", ponder: null, score: 20, mate: null, depth: 15 }, + p2Eval: { bestMove: "g1f3", ponder: null, score: 10, mate: null, depth: 15 }, + playerColor: "white", + playerMove, + }); + + expect(historyItem.playerMove).toBe("e4"); + expect(historyItem.computerMove).toBe("e5"); + expect(historyItem.opening).toBe("King's Pawn Game"); + expect(historyItem.bestMoveSan).toBe("Nf3"); + expect(historyItem.cpLoss).toBe(100); + expect(missedTactics).toHaveLength(1); + }); +}); diff --git a/src/lib/__tests__/savedGames.test.ts b/src/lib/__tests__/savedGames.test.ts new file mode 100644 index 0000000..2359506 --- /dev/null +++ b/src/lib/__tests__/savedGames.test.ts @@ -0,0 +1,59 @@ +import { deleteSavedGame, loadSavedGames, upsertSavedGame } from "../savedGames"; + +const baseGame = { + id: "game-1", + fen: "8/8/8/8/8/8/8/8 w - - 0 1", + pgn: "1. e4 e5", + selectedPersonality: { + id: "coach", + name: "Coach", + systemPrompt: "Teach chess", + image: "C", + }, + playerColor: "white" as const, + updatedAt: 100, + evaluation: { score: 25, mate: null, depth: 12 }, + language: "en" as const, +}; + +describe("savedGames", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns an empty list for malformed persisted JSON", () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + localStorage.setItem("chess_tutor_saves", "{broken"); + + expect(loadSavedGames()).toEqual([]); + consoleErrorSpy.mockRestore(); + }); + + it("sorts saves newest first and does not persist api keys", () => { + upsertSavedGame({ + ...baseGame, + id: "older", + updatedAt: 10, + }); + upsertSavedGame({ + ...baseGame, + id: "newer", + updatedAt: 20, + }); + + const games = loadSavedGames(); + const persisted = JSON.parse(localStorage.getItem("chess_tutor_saves") || "[]"); + + expect(games.map((game) => game.id)).toEqual(["newer", "older"]); + expect(persisted[0]).not.toHaveProperty("apiKey"); + expect(persisted[1]).not.toHaveProperty("apiKey"); + }); + + it("deletes a save by id", () => { + upsertSavedGame(baseGame); + + deleteSavedGame(baseGame.id); + + expect(loadSavedGames()).toEqual([]); + }); +}); diff --git a/src/lib/__tests__/stockfish.test.ts b/src/lib/__tests__/stockfish.test.ts new file mode 100644 index 0000000..f546d6b --- /dev/null +++ b/src/lib/__tests__/stockfish.test.ts @@ -0,0 +1,75 @@ +import { Stockfish } from "../stockfish"; + +type MessageHandler = (event: MessageEvent) => void; + +class FakeWorker { + public onmessage: MessageHandler | null = null; + private listeners = new Set(); + private currentFen = ""; + + addEventListener(_type: string, handler: MessageHandler) { + this.listeners.add(handler); + } + + removeEventListener(_type: string, handler: MessageHandler) { + this.listeners.delete(handler); + } + + postMessage(message: string) { + if (message === "uci") { + this.onmessage?.({ data: "uciok" } as MessageEvent); + this.emit("uciok"); + return; + } + + if (message.startsWith("position fen ")) { + this.currentFen = message.replace("position fen ", ""); + return; + } + + if (message.startsWith("go depth")) { + const response = this.currentFen.includes(" w ") + ? { info: "info depth 12 score cp 30", bestmove: "bestmove e2e4" } + : { info: "info depth 12 score cp 50", bestmove: "bestmove d7d5" }; + + setTimeout(() => this.emit(response.info), 5); + setTimeout(() => this.emit(response.bestmove), 10); + } + } + + terminate() {} + + private emit(data: string) { + const event = { data } as MessageEvent; + this.listeners.forEach((handler) => handler(event)); + } +} + +describe("Stockfish", () => { + beforeEach(() => { + Object.defineProperty(window, "Worker", { + writable: true, + value: FakeWorker, + }); + }); + + it("serializes evaluations and keeps scores isolated per request", async () => { + const stockfish = new Stockfish(); + + const first = stockfish.evaluate("8/8/8/8/8/8/8/8 w - - 0 1", 12); + const second = stockfish.evaluate("8/8/8/8/8/8/8/8 b - - 0 1", 12); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect(first).resolves.toMatchObject({ + bestMove: "e2e4", + score: 30, + depth: 12, + }); + await expect(second).resolves.toMatchObject({ + bestMove: "d7d5", + score: -50, + depth: 12, + }); + }); +}); diff --git a/src/lib/__tests__/tacticDetection.test.ts b/src/lib/__tests__/tacticDetection.test.ts index 63b2f2f..b96af84 100644 --- a/src/lib/__tests__/tacticDetection.test.ts +++ b/src/lib/__tests__/tacticDetection.test.ts @@ -1,4 +1,4 @@ -import { detectMissedTactics, uciToSan, DetectedTactic } from '../tacticDetection'; +import { detectMissedTactics, uciToSan } from '../tacticDetection'; describe('tacticDetection', () => { describe('uciToSan', () => { @@ -73,7 +73,6 @@ describe('tacticDetection', () => { }); // Should not suggest capturing if it can be immediately recaptured - const captureTactic = result.find(t => t.tactic_type === 'win_piece' || t.tactic_type === 'win_pawn'); // This depends on position analysis expect(Array.isArray(result)).toBe(true); }); @@ -290,4 +289,3 @@ describe('tacticDetection', () => { }); }); }); - diff --git a/src/lib/analysisPrompts.ts b/src/lib/analysisPrompts.ts new file mode 100644 index 0000000..5a6bba8 --- /dev/null +++ b/src/lib/analysisPrompts.ts @@ -0,0 +1,264 @@ +import { Chess, Move } from "chess.js"; + +import { SupportedLanguage } from "@/lib/i18n/translations"; +import { OpeningMetadata } from "@/lib/openings"; +import { StockfishEvaluation } from "@/lib/stockfish"; +import { DetectedTactic } from "@/lib/tacticDetection"; + +function formatEvaluationText(evaluation: StockfishEvaluation | null) { + return `${evaluation?.score ?? "N/A"} centipawns ${ + evaluation?.score !== undefined + ? evaluation.score > 0 + ? "(White is better)" + : evaluation.score < 0 + ? "(Black is better)" + : "(Equal)" + : "" + }`; +} + +function formatOpenings(openingData: OpeningMetadata[]) { + return openingData.length > 0 + ? openingData.map((opening) => `${opening.name} (${opening.eco})`).join(", ") + : "Unknown/Midgame"; +} + +export function buildTeachingPrompt( + text: string, + currentFen: string, + evaluation: StockfishEvaluation | null, + openingData: OpeningMetadata[], + language: SupportedLanguage +) { + const openings = formatOpenings(openingData); + const evaluationText = formatEvaluationText(evaluation); + const lower = text.toLowerCase(); + + if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) { + return `[SYSTEM TRIGGER: exact_move] + +TEACHING MODE ACTIVATED: +The User is asking for the exact best move. This is a learning moment. +As their TUTOR, you MUST help them - this is your primary purpose. +Even though you are their opponent, teaching them is more important than hiding information. + +User Question: ${text} + +Current Position Data: +- FEN: ${currentFen} +- Best Move: ${evaluation?.bestMove} +- Evaluation: ${evaluationText} +- Mate in: ${evaluation?.mate || "None"} +- Possible Openings: ${openings} + +INSTRUCTIONS: +- Tell them the best move clearly +- Explain why it is the best move +- Stay in your personality style, but be helpful and educational`; + } + + if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) { + return `[SYSTEM TRIGGER: hint] + +TEACHING MODE ACTIVATED: +The User is asking for a hint. This is a learning moment. +As their TUTOR, you MUST help them. + +User Question: ${text} + +Current Position Data: +- FEN: ${currentFen} +- Best Move: ${evaluation?.bestMove} +- Evaluation: ${evaluationText} +- Mate in: ${evaluation?.mate || "None"} +- Possible Openings: ${openings} + +INSTRUCTIONS: +- Give a helpful hint without revealing the exact move unless requested +- Point them toward tactics, threats, or weaknesses`; + } + + return ` +User Question: ${text} + +Current Position Context: +- FEN: ${currentFen} +- Evaluation: ${evaluationText} +- Best Move: ${evaluation?.bestMove ?? "N/A"} +- Mate in: ${evaluation?.mate || "None"} +- Possible Openings: ${openings} + +INSTRUCTIONS: +- Answer the question based on the current position +- Stay in character and be educational +- Respond in ${language}`; +} + +export function buildAutomaticAnalysisPrompt(args: { + computerMove: Move; + currentFen: string; + evalP0: StockfishEvaluation; + evalP2: StockfishEvaluation; + game: Chess; + language: SupportedLanguage; + missedTactics: DetectedTactic[] | null; + openingData: OpeningMetadata[]; + playerColorName: string; + tutorColorName: string; + userMove: Move; +}) { + const { computerMove, currentFen, evalP0, evalP2, game, language, missedTactics, openingData, playerColorName, tutorColorName, userMove } = args; + const preScore = evalP0.score; + const postScore = evalP2.score; + const preMate = evalP0.mate; + const postMate = evalP2.mate; + const delta = postScore - preScore; + + const preEvalStr = preMate !== null ? `Mate in ${preMate}` : `${preScore} cp`; + const postEvalStr = postMate !== null ? `Mate in ${postMate}` : `${postScore} cp`; + + const isSignificant = preMate !== null || postMate !== null || Math.abs(delta) >= 50; + const evalInstruction = isSignificant + ? preMate !== null || postMate !== null + ? "The evaluation involves MATE. You MUST comment on this critical situation and what caused it." + : `The evaluation changed SIGNIFICANTLY (Delta: ${delta} cp). You MUST comment on this shift in power and what caused it.` + : "The evaluation change is MINOR/INSIGNIFICANT. Do NOT mention the score, 'advantage', or who is winning. Focus ONLY on the strategic purpose of the moves."; + + let openingInstruction = "NO specific opening identified from database. Do NOT invent an opening name. Focus on the position."; + if (openingData.length === 1) { + const opening = openingData[0]; + openingInstruction = ` +OPENING IDENTIFIED: ${opening.name} (${opening.eco}). +You can confidently reference this opening and its typical plans. +You can use this metadata to explain the position: +- Strengths (White): ${opening.meta?.strengths_white?.join(", ") || "N/A"} +- Weaknesses (White): ${opening.meta?.weaknesses_white?.join(", ") || "N/A"} +- Strengths (Black): ${opening.meta?.strengths_black?.join(", ") || "N/A"} +- Weaknesses (Black): ${opening.meta?.weaknesses_black?.join(", ") || "N/A"} + `; + } else if (openingData.length > 1) { + openingInstruction = ` +OPENING CONTEXT: +Multiple openings are possible from this position: +${openingData.map((opening) => `- ${opening.name} (${opening.eco})`).join("\n")} + +INSTRUCTIONS: +- Do NOT claim a specific opening is being played yet +- You may mention "this could lead to..." or "typical of openings like..." +- Focus on general principles rather than specific opening theory + `; + } + + let tacticalInstruction = ""; + const meaningfulTactics = (missedTactics || []).filter((tactic) => tactic.tactic_type !== "none"); + if (meaningfulTactics.length > 0) { + const tacticDescriptions = meaningfulTactics.map((tactic) => { + let description = `- ${tactic.tactic_type.toUpperCase()}`; + if (tactic.piece_roles && tactic.piece_roles.length > 0) { + description += ` involving ${tactic.piece_roles.join(" and ")}`; + } + if (tactic.material_delta) { + description += ` (worth ~${tactic.material_delta} centipawns)`; + } + if (tactic.affected_squares && tactic.affected_squares.length > 0) { + description += ` on squares ${tactic.affected_squares.join(", ")}`; + } + return description; + }).join("\n"); + + tacticalInstruction = ` +TACTICAL OPPORTUNITY MISSED: +The User just played ${userMove.san}, but there was a better tactical opportunity available. +The analysis engine identified the following tactical themes that could have been exploited: + +${tacticDescriptions} + +IMPORTANT CONTEXT: +- This tactical data comes from analyzing what WOULD HAVE HAPPENED if the User had played the best move instead. +- You should explain this missed opportunity in your characteristic style. +- Point out what the User could have done (e.g., "You missed a fork with Nf3!" or "There was a pin available with Bb5!"). +- Be educational but stay in character - if you're sarcastic, be sarcastic about the miss; if you're encouraging, be supportive. +- Do NOT mention "the engine" or "the computer" - present this as YOUR analysis as the opponent/tutor. +- Only mention this if the evaluation change was significant enough to warrant it. + `; + } + + const tempGameAfterUser = new Chess(); + tempGameAfterUser.loadPgn(game.pgn()); + tempGameAfterUser.undo(); + const fenAfterUserMove = tempGameAfterUser.fen(); + + const tempGameBeforeUser = new Chess(); + tempGameBeforeUser.loadPgn(game.pgn()); + tempGameBeforeUser.undo(); + tempGameBeforeUser.undo(); + const fenBeforeUserMove = tempGameBeforeUser.fen(); + + return ` +[SYSTEM TRIGGER: move_exchange] +User (${playerColorName}) Move: ${userMove.san} +My (${tutorColorName}) Reply: ${computerMove.san} + +Position Context: +- FEN before user's move: ${fenBeforeUserMove} +- FEN after user's move: ${fenAfterUserMove} +- FEN after my reply (current position): ${currentFen} + +My Internal Thoughts (Data): +- Pre-Eval (Before User Move): ${preEvalStr} +- Post-Eval (After My Reply): ${postEvalStr} +${preMate === null && postMate === null ? `- Delta: ${delta} cp` : ""} +(Note: Scores are from White's perspective. Positive = White advantage, Negative = Black advantage. "Mate in X" means forced mate in X moves.) + +${tacticalInstruction} + +INSTRUCTIONS: +1. ${evalInstruction} +2. ${openingInstruction} +3. ${tacticalInstruction ? "If tactical opportunities were missed (see above), explain them in your style." : ""} +4. Use the FEN data above to understand exactly where all pieces are located on the board. +5. Respond in ${language}. + +React to this exchange as the player. + `; +} + +export function buildMoveCommentaryPrompt(args: { + bestMove: string; + color: "white" | "black"; + cpLoss: number; + evalAfter: number; + evalBefore: number; + fenAfter: string; + fenBefore: string; + mateInfo: string; + moveNumber: number; + openings: string; + san: string; + tactics: string; +}) { + return ` +Analyze this move: + +DATA: +- Move number: ${args.moveNumber} +- Side to move: ${args.color} +- Move played (SAN): ${args.san} +- FEN before move: ${args.fenBefore} +- FEN after move: ${args.fenAfter} +- Evaluation before move: ${args.evalBefore.toFixed(2)} pawns +- Evaluation after move: ${args.evalAfter.toFixed(2)} pawns +- Best move suggestion: ${args.bestMove} +- Evaluation shift (centipawns): ${args.cpLoss} +- Possible Openings: ${args.openings} +- Missed tactics: ${args.tactics} +- Mate hint: ${args.mateInfo} + +INSTRUCTIONS: +- Be concise (3-4 sentences). +- Mention whether the move improved or worsened the position and why. +- Highlight any tactical ideas the player may have missed. +- Refer to the player's side as ${args.color}. +- Keep it educational and stay true to your personality tone.`; +} + diff --git a/src/lib/gameAnalysis.ts b/src/lib/gameAnalysis.ts new file mode 100644 index 0000000..af50a64 --- /dev/null +++ b/src/lib/gameAnalysis.ts @@ -0,0 +1,139 @@ +import { MoveHistoryItem } from "@/components/GameOverModal"; +import { DetectedTactic } from "@/lib/tacticDetection"; + +type MistakeCategory = "inaccuracy" | "mistake" | "blunder"; + +function describeTactics(tactics?: DetectedTactic[]) { + if (!tactics || tactics.length === 0) return ""; + const meaningful = tactics.filter((tactic) => tactic.tactic_type !== "none"); + if (meaningful.length === 0) return ""; + + return meaningful.map((tactic) => { + const material = tactic.material_delta ? ` (~${tactic.material_delta}cp)` : ""; + const pieces = tactic.piece_roles ? ` [${tactic.piece_roles.join(", ")}]` : ""; + return `${tactic.tactic_type}${material}${pieces}`; + }).join("; "); +} + +export function classifyMoveHistory(history: MoveHistoryItem[]) { + return history.map((item) => { + let evalBefore: number; + let evalAfter: number; + let playerMove: string; + let bestMove: string | undefined; + let bestMoveSan: string | null | undefined; + const missedTactics = item.missedTactics; + const cpLoss = item.cpLoss; + + if (item.evalBeforePlayerMove && item.evalAfterPlayerMove) { + const isWhite = item.playerColor === "white"; + evalBefore = isWhite ? item.evalBeforePlayerMove.score : -item.evalBeforePlayerMove.score; + evalAfter = isWhite ? -item.evalAfterPlayerMove.score : item.evalAfterPlayerMove.score; + playerMove = item.playerMove; + bestMove = item.evalBeforePlayerMove.bestMove; + bestMoveSan = item.bestMoveSan; + } else { + evalBefore = item.evalBefore || 0; + evalAfter = item.evalAfter || 0; + playerMove = item.move || ""; + bestMove = item.bestMove; + } + + const delta = evalBefore - evalAfter; + const cpLossValue = cpLoss ?? delta; + let category: MistakeCategory | null = null; + + if (cpLossValue >= 300) category = "blunder"; + else if (cpLossValue >= 100) category = "mistake"; + else if (cpLossValue >= 50) category = "inaccuracy"; + + return { + ...item, + category, + cpLoss: cpLossValue, + move: playerMove, + evalBefore, + evalAfter, + bestMove, + bestMoveSan, + missedTactics, + }; + }).filter((item) => item.category !== null) as Array; +} + +export function buildGameNarrative(history: MoveHistoryItem[]) { + return history.map((item, index) => { + const moveNum = item.moveNumber || index + 1; + const playerMove = item.playerMove || item.move || "?"; + const computerMove = item.computerMove || "?"; + const opening = item.opening ? ` [${item.opening}]` : ""; + + let evalInfo = ""; + if (item.evalBeforePlayerMove && item.evalAfterPlayerMove && item.evalAfterComputerMove) { + const isWhite = item.playerColor === "white"; + const p0 = isWhite ? item.evalBeforePlayerMove.score : -item.evalBeforePlayerMove.score; + const p1 = isWhite ? -item.evalAfterPlayerMove.score : item.evalAfterPlayerMove.score; + const p2 = isWhite ? item.evalAfterComputerMove.score : -item.evalAfterComputerMove.score; + evalInfo = ` (eval: ${Math.round(p0)} → ${Math.round(p1)} → ${Math.round(p2)})`; + } + + return `${moveNum}. ${playerMove} - ${computerMove}${opening}${evalInfo}`; + }).join("\n"); +} + +export function buildGameOverAnalysisPrompt(args: { + history: MoveHistoryItem[]; + language: "en" | "de" | "fr" | "it"; + result: string; + winner: "White" | "Black" | "Draw"; +}) { + const mistakes = classifyMoveHistory(args.history); + const blunders = mistakes.filter((mistake) => mistake.category === "blunder"); + const ordinaryMistakes = mistakes.filter((mistake) => mistake.category === "mistake"); + const inaccuracies = mistakes.filter((mistake) => mistake.category === "inaccuracy"); + + const mistakesText = mistakes.map((mistake) => { + const tacticSummary = describeTactics(mistake.missedTactics); + const bestMoveDisplay = mistake.bestMoveSan || mistake.bestMove || "N/A"; + const tacticNote = tacticSummary ? ` Tactics missed: ${tacticSummary}.` : ""; + return `Move ${mistake.moveNumber}: ${mistake.move} (${mistake.category.toUpperCase()}: -${Math.round(mistake.cpLoss)}cp loss, eval ${Math.round(mistake.evalBefore)} → ${Math.round(mistake.evalAfter)}). Best was: ${bestMoveDisplay}.${tacticNote}`; + }).join("\n"); + + return { + mistakes, + prompt: ` +You are a Chess Coach analyzing a completed game. + +GAME RESULT: ${args.result} (${args.winner === "Draw" ? "Draw" : `${args.winner} Won`}) + +PLAYER'S PERFORMANCE SUMMARY: +- Blunders (300+ cp loss): ${blunders.length} +- Mistakes (100-300 cp loss): ${ordinaryMistakes.length} +- Inaccuracies (50-100 cp loss): ${inaccuracies.length} +- Total moves played: ${args.history.length} + +${mistakesText ? `CRITICAL MISTAKES:\n${mistakesText}` : "No significant mistakes detected - excellent play!"} + +COMPLETE GAME MOVES: +${buildGameNarrative(args.history)} + +INSTRUCTIONS: +1. Briefly comment on the game result and overall performance. +2. If there were mistakes, explain WHY the worst ones were bad: + - What tactical or positional themes were missed? + - What should the player have looked for? (hanging pieces, forks, pins, back rank threats, etc.) + - Were there patterns in the mistakes? (time pressure, opening knowledge, endgame technique?) +3. Identify any TURNING POINTS where the evaluation swung significantly. +4. If no mistakes, praise the solid play and suggest specific areas for improvement. +5. Be encouraging but educational. Focus on actionable learning points. +6. Keep your response concise (3-5 paragraphs maximum). +7. Respond in ${args.language.toUpperCase()}. + +Remember: Your goal is to help the player LEARN and IMPROVE, not just list mistakes. + +OUTPUT FORMAT: +Plain text paragraph (2-3 sentences). + `, + }; +} + diff --git a/src/lib/gameImport.ts b/src/lib/gameImport.ts index 652df70..0400b9e 100644 --- a/src/lib/gameImport.ts +++ b/src/lib/gameImport.ts @@ -23,6 +23,30 @@ export interface GameMetadata { url?: string; // Link to game on platform } +interface ChessComGame { + uuid?: string; + url?: string; + pgn: string; + white?: { username?: string }; + black?: { username?: string }; + end_time: number; + time_class?: string; +} + +interface LichessGame { + id: string; + pgn: string; + status?: string; + winner?: "white" | "black"; + createdAt: number; + speed?: string; + opening?: { eco?: string; name?: string }; + players?: { + white?: { user?: { name?: string } }; + black?: { user?: { name?: string } }; + }; +} + /** * Fetch games from Chess.com * Uses the Published-Data API (PubAPI) - no authentication required @@ -97,7 +121,7 @@ export async function fetchChessComGames( /** * Parse a Chess.com game object into our GameMetadata format */ -function parseChessComGame(game: any): GameMetadata { +function parseChessComGame(game: ChessComGame): GameMetadata { const pgn = game.pgn; const chess = new Chess(); chess.loadPgn(pgn); @@ -180,7 +204,7 @@ export async function fetchLichessGames( /** * Parse a Lichess game object into our GameMetadata format */ -function parseLichessGame(game: any): GameMetadata { +function parseLichessGame(game: LichessGame): GameMetadata { const pgn = game.pgn; const chess = new Chess(); chess.loadPgn(pgn); @@ -202,4 +226,3 @@ function parseLichessGame(game: any): GameMetadata { url: `https://lichess.org/${game.id}` }; } - diff --git a/src/lib/gameState.ts b/src/lib/gameState.ts new file mode 100644 index 0000000..244fb02 --- /dev/null +++ b/src/lib/gameState.ts @@ -0,0 +1,117 @@ +import { Chess, Move } from "chess.js"; + +import { OpeningMetadata } from "@/lib/openings"; +import { StockfishEvaluation } from "@/lib/stockfish"; +import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection"; +import { MoveHistoryItem } from "@/components/GameOverModal"; + +const PIECE_VALUES: Record = { + p: 1, + n: 3, + b: 3, + r: 5, + q: 9, + k: 0, +}; + +export type CapturedState = { + whitePiecesLost: string[]; + blackPiecesLost: string[]; + whiteLostScore: number; + blackLostScore: number; +}; + +export function getCapturedState(game: Chess): CapturedState { + const history = game.history({ verbose: true }); + const whitePiecesLost: string[] = []; + const blackPiecesLost: string[] = []; + let whiteLostScore = 0; + let blackLostScore = 0; + + history.forEach((move) => { + if (!move.captured) return; + + if (move.color === "w") { + blackPiecesLost.push(move.captured); + blackLostScore += PIECE_VALUES[move.captured] || 0; + return; + } + + whitePiecesLost.push(move.captured); + whiteLostScore += PIECE_VALUES[move.captured] || 0; + }); + + return { + whitePiecesLost, + blackPiecesLost, + whiteLostScore, + blackLostScore, + }; +} + +interface BuildMoveHistoryItemArgs { + computerMove: Move; + evalP0: StockfishEvaluation; + fenAfterComputerMove: string; + fenBeforePlayerMove: string; + openingData: OpeningMetadata[]; + p1Eval: StockfishEvaluation; + p2Eval: StockfishEvaluation; + playerColor: "white" | "black"; + playerMove: Move; +} + +export function buildMoveHistoryItem(args: BuildMoveHistoryItemArgs): { + historyItem: MoveHistoryItem; + missedTactics: DetectedTactic[]; +} { + const { + computerMove, + evalP0, + fenAfterComputerMove, + fenBeforePlayerMove, + openingData, + p1Eval, + p2Eval, + playerColor, + playerMove, + } = args; + + 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(fenBeforePlayerMove, evalP0.bestMove); + const missedTactics = detectMissedTactics({ + fen: fenBeforePlayerMove, + playerColor, + playerMoveSan: playerMove.san, + bestMoveUci: evalP0.bestMove, + cpLoss, + }); + + return { + historyItem: { + moveNumber: Math.ceil(playerMove.ply / 2), + playerMove: playerMove.san, + playerColor, + fenBeforePlayerMove, + evalBeforePlayerMove: evalP0, + fenAfterPlayerMove: playerMove.after, + evalAfterPlayerMove: p1Eval, + computerMove: computerMove.san, + fenAfterComputerMove, + evalAfterComputerMove: p2Eval, + opening: openingData.length > 0 ? openingData[0].name : undefined, + move: playerMove.san, + evalBefore: evalP0.score, + evalAfter: p1Eval.score, + bestMove: evalP0.bestMove, + bestMoveSan, + cpLoss, + missedTactics, + }, + missedTactics, + }; +} + diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index 887193c..b1bcfcd 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -1,7 +1,6 @@ import { GoogleGenerativeAI, SchemaType, FunctionDeclaration } from "@google/generative-ai"; -import { StockfishEvaluation } from "./stockfish"; -export async function getAvailableModels(apiKey: string): Promise { +export async function getAvailableModels(): Promise { // Prioritize newer models return [ "gemini-3-pro-preview", diff --git a/src/lib/i18n/useTranslation.ts b/src/lib/i18n/useTranslation.ts index f52f784..f9c2426 100644 --- a/src/lib/i18n/useTranslation.ts +++ b/src/lib/i18n/useTranslation.ts @@ -5,13 +5,17 @@ export function useTranslation(language: SupportedLanguage): Translations { } export function getTranslation(language: SupportedLanguage, key: string): string { - const t = useTranslation(language); + const t = translations[language] || translations.en; const keys = key.split('.'); - let value: any = t; + let value: unknown = t; for (const k of keys) { - value = value?.[k]; + if (typeof value !== 'object' || value === null) { + return key; + } + + value = (value as Record)[k]; } - return value || key; + return typeof value === 'string' ? value : key; } diff --git a/src/lib/openings.ts b/src/lib/openings.ts index bdc3ade..5c24869 100644 --- a/src/lib/openings.ts +++ b/src/lib/openings.ts @@ -209,7 +209,6 @@ export function buildMoveSequenceFromSteps( upToIndex: number ): string { const parts: string[] = []; - let currentMoveNumber = 0; for (let i = 0; i < upToIndex && i < steps.length; i++) { const step = steps[i]; @@ -217,7 +216,6 @@ export function buildMoveSequenceFromSteps( if (step.color === 'white') { // White's move - include move number parts.push(`${step.moveNumber}. ${step.san}`); - currentMoveNumber = step.moveNumber; } else { // Black's move - no move number prefix parts.push(step.san); diff --git a/src/lib/savedGames.ts b/src/lib/savedGames.ts index 651f792..e18142c 100644 --- a/src/lib/savedGames.ts +++ b/src/lib/savedGames.ts @@ -11,7 +11,6 @@ export type SavedGame = { updatedAt: number; evaluation?: Pick | null; language?: SupportedLanguage; - apiKey?: string | null; }; const STORAGE_KEY = "chess_tutor_saves"; @@ -24,7 +23,15 @@ const parseSavedGames = (): SavedGame[] => { try { const data = JSON.parse(raw); if (!Array.isArray(data)) return []; - return data.filter(Boolean); + return data.filter(Boolean).map((game) => { + if (game && typeof game === "object" && "apiKey" in game) { + const safeGame = { ...(game as SavedGame & { apiKey?: string | null }) }; + delete safeGame.apiKey; + return safeGame; + } + + return game as SavedGame; + }); } catch (e) { console.error("Failed to parse saved games", e); return []; diff --git a/src/lib/stockfish.ts b/src/lib/stockfish.ts index c7936e6..6ac814e 100644 --- a/src/lib/stockfish.ts +++ b/src/lib/stockfish.ts @@ -6,14 +6,10 @@ export type StockfishEvaluation = { depth: number; }; -const EVALUATION_TIMEOUT_MS = 30000; // 30 seconds timeout for evaluation - export class Stockfish { private worker: Worker | null = null; private isReady: boolean = false; - private lastScore: number = 0; - private lastMate: number | null = null; - private lastDepth: number = 0; + private evaluationQueue: Promise = Promise.resolve(); constructor() { if (typeof window !== "undefined") { @@ -28,86 +24,104 @@ export class Stockfish { } } - async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise { - return new Promise((resolve, reject) => { + private waitUntilReady(): Promise { + if (this.isReady) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { if (!this.worker) { reject(new Error("Stockfish worker not initialized")); return; } - // Reset last known evaluation values for this new evaluation - this.lastScore = 0; - this.lastMate = null; - this.lastDepth = 0; + const timeoutId = window.setTimeout(() => { + this.worker?.removeEventListener("message", handleReady); + reject(new Error("Stockfish worker readiness timed out")); + }, 5000); - let timeoutId: ReturnType | null = null; - let isResolved = false; - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - this.worker?.removeEventListener("message", handler); - }; - - const handler = (event: MessageEvent) => { - if (isResolved) return; - - const message = event.data; - // console.log("Stockfish:", message); - - if (message.startsWith("info depth")) { - const depthMatch = message.match(/depth (\d+)/); - const scoreMatch = message.match(/score cp (-?\d+)/); - const mateMatch = message.match(/score mate (-?\d+)/); - - if (depthMatch) this.lastDepth = parseInt(depthMatch[1]); - if (scoreMatch) { - this.lastScore = parseInt(scoreMatch[1]); - this.lastMate = null; - } - if (mateMatch) { - this.lastMate = parseInt(mateMatch[1]); - this.lastScore = 0; // or some indicator - } - } - - if (message.startsWith("bestmove")) { - const parts = message.split(" "); - const bestMove = parts[1]; - let ponder: string | null = null; - if (parts.length > 3 && parts[2] === "ponder") { - ponder = parts[3]; - } - - isResolved = true; - cleanup(); - resolve({ - bestMove, - ponder, - score: this.lastScore, - mate: this.lastMate, - depth: this.lastDepth - }); + const handleReady = (event: MessageEvent) => { + if (event.data === "uciok") { + window.clearTimeout(timeoutId); + this.worker?.removeEventListener("message", handleReady); + this.isReady = true; + resolve(); } }; - // Set timeout to prevent hanging promises - timeoutId = setTimeout(() => { - if (!isResolved) { - isResolved = true; - cleanup(); - // Stop any ongoing analysis - this.worker?.postMessage("stop"); - reject(new Error(`Stockfish evaluation timed out after ${EVALUATION_TIMEOUT_MS / 1000} seconds`)); - } - }, EVALUATION_TIMEOUT_MS); + this.worker.addEventListener("message", handleReady); + }); + } - this.worker.addEventListener("message", handler); - this.worker.postMessage(`position fen ${fen}`); - this.worker.postMessage(`go depth ${depth}`); - }).then((evalResult: StockfishEvaluation) => { + async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise { + const runEvaluation = async () => { + await this.waitUntilReady(); + + return new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error("Stockfish worker not initialized")); + return; + } + + let lastScore = 0; + let lastMate: number | null = null; + let lastDepth = 0; + + const handler = (event: MessageEvent) => { + const message = event.data; + + if (typeof message !== "string") { + return; + } + + if (message.startsWith("info depth")) { + const depthMatch = message.match(/depth (\d+)/); + const scoreMatch = message.match(/score cp (-?\d+)/); + const mateMatch = message.match(/score mate (-?\d+)/); + + if (depthMatch) lastDepth = parseInt(depthMatch[1], 10); + if (scoreMatch) { + lastScore = parseInt(scoreMatch[1], 10); + lastMate = null; + } + if (mateMatch) { + lastMate = parseInt(mateMatch[1], 10); + lastScore = 0; + } + } + + if (message.startsWith("bestmove")) { + const parts = message.split(" "); + const bestMove = parts[1]; + let ponder: string | null = null; + if (parts.length > 3 && parts[2] === "ponder") { + ponder = parts[3]; + } + + this.worker?.removeEventListener("message", handler); + resolve({ + bestMove, + ponder, + score: lastScore, + mate: lastMate, + depth: lastDepth, + }); + } + }; + + this.worker.addEventListener("message", handler); + if (multiPV > 1) { + this.worker.postMessage(`setoption name MultiPV value ${multiPV}`); + } + this.worker.postMessage(`position fen ${fen}`); + this.worker.postMessage(`go depth ${depth}`); + }); + }; + + const evaluationPromise = this.evaluationQueue.then(runEvaluation, runEvaluation); + this.evaluationQueue = evaluationPromise.then(() => undefined, () => undefined); + + return evaluationPromise.then((evalResult: StockfishEvaluation) => { // Normalize score to be from White's perspective // Stockfish returns score relative to side to move const sideToMove = fen.split(" ")[1]; // 'w' or 'b' diff --git a/src/lib/tacticDetection.ts b/src/lib/tacticDetection.ts index 74c2ebc..21dacc8 100644 --- a/src/lib/tacticDetection.ts +++ b/src/lib/tacticDetection.ts @@ -74,7 +74,7 @@ export function uciToSan(fen: string, uci: string): string | null { const chess = new Chess(fen); const move = chess.move(uciToMove(uci)); return move ? move.san : null; - } catch (error) { + } catch { return null; } } diff --git a/src/lib/useHasHydrated.ts b/src/lib/useHasHydrated.ts new file mode 100644 index 0000000..312eff6 --- /dev/null +++ b/src/lib/useHasHydrated.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from "react"; + +const subscribe = () => () => {}; + +export function useHasHydrated(): boolean { + return useSyncExternalStore(subscribe, () => true, () => false); +}