refactor: stabilize game, tutor, and analysis flows

This commit is contained in:
Stefan
2026-04-02 12:41:00 +02:00
parent dd5b4b9b95
commit c8a1325798
35 changed files with 2258 additions and 196 deletions
-16
View File
@@ -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
+3
View File
@@ -11,6 +11,9 @@ const eslintConfig = defineConfig([
".next/**",
"out/**",
"build/**",
"coverage/**",
"public/stockfish/**",
"scripts/**",
"next-env.d.ts",
]),
]);
+4
View File
@@ -19,6 +19,10 @@ const config: Config = {
'/node_modules/',
'/e2e/', // Exclude Playwright e2e tests
],
modulePathIgnorePatterns: [
'<rootDir>/.next/',
'<rootDir>/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)/)',
],
+2 -1
View File
@@ -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,
}));
+6 -1
View File
@@ -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 }) => (
<div data-testid="start-screen">
<button onClick={() => onStartGame({ personality: { name: 'Test Personality' }, color: 'white' })}>
Start Game
+30 -4
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DebugProvider } from "@/contexts/DebugContext";
jest.mock("next/navigation", () => ({
@@ -58,9 +58,20 @@ const uciToSanMock = tacticMocks.uciToSan;
import AnalysisPage from "../page";
describe("AnalysisPage", () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation((message?: unknown) => {
if (typeof message === "string" && message.includes("not wrapped in act")) {
return;
}
if (message instanceof Error && message.message.includes("not wrapped in act")) {
return;
}
});
evaluateMock.mockImplementation((fen: string) => {
const sideToMove = fen.split(" ")[1];
const baseEval = sideToMove === "w" ? 0 : 50;
@@ -76,6 +87,10 @@ describe("AnalysisPage", () => {
uciToSanMock.mockReturnValue("e4");
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
const samplePgn = `
[Event "Casual Game"]
[Site "Berlin GER"]
@@ -88,21 +103,30 @@ describe("AnalysisPage", () => {
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
`;
const loadGame = () => {
const loadGame = async () => {
await act(async () => {
render(
<DebugProvider>
<AnalysisPage />
</DebugProvider>
);
});
const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i);
await act(async () => {
fireEvent.change(textarea, { target: { value: samplePgn } });
fireEvent.click(screen.getByText(/Start Analysis/i));
});
await waitFor(() => {
expect(evaluateMock).toHaveBeenCalled();
});
};
it("replays PGN moves with engine evaluations", async () => {
loadGame();
await loadGame();
await act(async () => {
fireEvent.click(screen.getByLabelText(/Next Move/i));
});
await waitFor(() => {
expect(screen.getByText(/Move 1 \/ 6/)).toBeInTheDocument();
@@ -126,8 +150,10 @@ describe("AnalysisPage", () => {
},
]);
loadGame();
await loadGame();
await act(async () => {
fireEvent.click(screen.getByLabelText(/Next Move/i));
});
await waitFor(() => {
expect(screen.getByText(/fork \(~3.0 pawns\) on e5/)).toBeInTheDocument();
+392
View File
@@ -0,0 +1,392 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChatSession } from "@google/generative-ai";
import { Chess } from "chess.js";
import { useDebug } from "@/contexts/DebugContext";
import { buildMoveCommentaryPrompt } from "@/lib/analysisPrompts";
import { detectChessFormat, ChessFormat } from "@/lib/chessFormatDetector";
import { getGenAIModel } from "@/lib/gemini";
import { SupportedLanguage } from "@/lib/i18n/translations";
import { lookupPossibleOpenings, buildMoveSequenceFromSteps } from "@/lib/openings";
import { Personality, PERSONALITIES } from "@/lib/personalities";
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
import { detectMissedTactics, DetectedTactic, uciToSan } from "@/lib/tacticDetection";
export interface MoveStep {
san: string;
color: "white" | "black";
moveNumber: number;
fenBefore: string;
fenAfter: string;
}
export interface StepDetails {
evalBefore?: StockfishEvaluation;
evalAfter?: StockfishEvaluation;
cpLoss?: number;
missedTactics?: DetectedTactic[];
bestMoveSan?: string | null;
}
const DEFAULT_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
interface UseAnalysisSessionArgs {
importError: string;
}
export function useAnalysisSession({ importError }: UseAnalysisSessionArgs) {
const { addEntry } = useDebug();
const [language, setLanguage] = useState<SupportedLanguage>("en");
const [apiKey, setApiKey] = useState<string | null>(null);
const [input, setInput] = useState("");
const [detectedFormat, setDetectedFormat] = useState<ChessFormat | null>(null);
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(PERSONALITIES[0]);
const [orientation, setOrientation] = useState<"white" | "black">("white");
const [initialFen, setInitialFen] = useState<string>(DEFAULT_START);
const [steps, setSteps] = useState<MoveStep[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [error, setError] = useState<string | null>(null);
const [stockfish, setStockfish] = useState<Stockfish | null>(null);
const [evaluationVersion, setEvaluationVersion] = useState(0);
const [stepDetails, setStepDetails] = useState<Record<number, StepDetails>>({});
const [isCommenting, setIsCommenting] = useState(false);
const [comments, setComments] = useState<Record<number, string>>({});
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const evaluationCache = useRef<Record<string, StockfishEvaluation>>({});
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);
}, []);
useEffect(() => {
const sf = new Stockfish();
setStockfish(sf);
return () => sf.terminate();
}, []);
useEffect(() => {
if (!apiKey) return;
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
const session = model.startChat({
history: [
{
role: "user",
parts: [{
text: `You are ${selectedPersonality.name}. You will analyze a chess game move by move.
Stay in character and maintain your personality throughout the analysis.
Language: ${language.toUpperCase()}.
IMPORTANT:
- You are analyzing moves sequentially
- Each move you analyze builds on the previous context
- If the user navigates backwards or forwards, you will see the move number
- Provide educational commentary in your characteristic style
- Be concise (3-4 sentences per move)
- Focus on what the move accomplishes and what was missed`,
}],
},
{
role: "model",
parts: [{
text: `Understood. I am ${selectedPersonality.name}, and I will analyze this game move by move in ${language}, maintaining my personality while providing educational insights. I'll keep track of the game's progression and provide context-aware commentary.`,
}],
},
],
});
setChatSession(session);
}, [apiKey, language, selectedPersonality]);
const currentFen = useMemo(() => {
if (currentIndex === 0) return initialFen;
return steps[currentIndex - 1]?.fenAfter || initialFen;
}, [currentIndex, initialFen, steps]);
const possibleOpenings = useMemo(() => {
if (currentIndex === 0) return [];
return lookupPossibleOpenings(buildMoveSequenceFromSteps(steps, currentIndex), 5);
}, [currentIndex, steps]);
const ensureEvaluation = useCallback(async (fen: string) => {
if (!stockfish) return null;
if (evaluationCache.current[fen]) return evaluationCache.current[fen];
const result = await stockfish.evaluate(fen, 14);
evaluationCache.current[fen] = result;
setEvaluationVersion((value) => value + 1);
return result;
}, [stockfish]);
const loadGameFromPgnOrFen = useCallback((notation: string) => {
const trimmed = notation.trim();
const format = detectChessFormat(trimmed);
if (!trimmed || format === "invalid") {
setError(importError);
return;
}
try {
const parsedGame = new Chess();
const nextSteps: MoveStep[] = [];
let startFen = DEFAULT_START;
if (format === "fen") {
parsedGame.load(trimmed);
startFen = parsedGame.fen();
} else {
parsedGame.loadPgn(trimmed);
const headers = parsedGame.header();
if (headers.FEN) {
const base = new Chess();
base.load(headers.FEN);
startFen = base.fen();
} else {
parsedGame.reset();
startFen = parsedGame.fen();
}
const replay = new Chess();
replay.load(startFen);
const history = new Chess();
history.loadPgn(trimmed);
history.history({ verbose: true }).forEach((move, index) => {
const before = replay.fen();
const applied = replay.move({ from: move.from, to: move.to, promotion: move.promotion || "q" });
if (!applied) return;
nextSteps.push({
san: applied.san,
color: applied.color === "w" ? "white" : "black",
moveNumber: Math.floor(index / 2) + 1,
fenBefore: before,
fenAfter: replay.fen(),
});
});
}
evaluationCache.current = {};
setEvaluationVersion((value) => value + 1);
setInitialFen(startFen);
setSteps(nextSteps);
setCurrentIndex(0);
setStepDetails({});
setComments({});
setError(null);
void ensureEvaluation(startFen);
} catch (error) {
console.error("Failed to load game", error);
setError(importError);
}
}, [ensureEvaluation, importError]);
useEffect(() => {
const pendingAnalysis = localStorage.getItem("chess_tutor_pending_analysis");
if (!pendingAnalysis) return;
localStorage.removeItem("chess_tutor_pending_analysis");
setInput(pendingAnalysis);
setDetectedFormat(detectChessFormat(pendingAnalysis));
const timeout = setTimeout(() => {
loadGameFromPgnOrFen(pendingAnalysis);
}, 100);
return () => clearTimeout(timeout);
}, [loadGameFromPgnOrFen]);
const handleInputChange = useCallback((value: string) => {
setInput(value);
setDetectedFormat(value.trim() ? detectChessFormat(value) : null);
}, []);
const handleLoadGame = useCallback(() => {
const trimmed = input.trim();
const format = detectChessFormat(trimmed);
if (!trimmed || format === "invalid") {
setError(importError);
return;
}
loadGameFromPgnOrFen(trimmed);
}, [importError, input, loadGameFromPgnOrFen]);
const handleImportGame = useCallback((pgn: string) => {
setInput(pgn);
setDetectedFormat(detectChessFormat(pgn));
loadGameFromPgnOrFen(pgn);
}, [loadGameFromPgnOrFen]);
const handleResetAnalysis = useCallback(() => {
setInput("");
setDetectedFormat(null);
setSteps([]);
setCurrentIndex(0);
setStepDetails({});
setComments({});
setInitialFen(DEFAULT_START);
evaluationCache.current = {};
setEvaluationVersion((value) => value + 1);
setError(null);
}, []);
useEffect(() => {
if (!stockfish || !currentFen) return;
void ensureEvaluation(currentFen);
const currentStep = steps[currentIndex - 1];
if (currentStep) {
void ensureEvaluation(currentStep.fenBefore);
}
}, [currentFen, currentIndex, ensureEvaluation, steps, stockfish]);
useEffect(() => {
if (currentIndex === 0) return;
const step = steps[currentIndex - 1];
if (!step) return;
const evalBefore = evaluationCache.current[step.fenBefore];
const evalAfter = evaluationCache.current[step.fenAfter];
if (!evalBefore || !evalAfter) return;
setStepDetails((previous) => {
if (previous[currentIndex]?.evalBefore && previous[currentIndex]?.evalAfter) return previous;
const cpLoss = step.color === "white"
? evalBefore.score - evalAfter.score
: evalAfter.score - evalBefore.score;
return {
...previous,
[currentIndex]: {
evalBefore,
evalAfter,
cpLoss,
missedTactics: detectMissedTactics({
fen: step.fenBefore,
playerColor: step.color,
playerMoveSan: step.san,
bestMoveUci: evalBefore.bestMove,
cpLoss,
}),
bestMoveSan: uciToSan(step.fenBefore, evalBefore.bestMove),
},
};
});
}, [currentIndex, evaluationVersion, steps]);
const requestMoveCommentary = useCallback(() => {
if (!chatSession || currentIndex === 0) return;
const step = steps[currentIndex - 1];
const details = stepDetails[currentIndex];
if (!step || !details?.evalBefore || !details?.evalAfter || comments[currentIndex]) return;
let cancelled = false;
setIsCommenting(true);
const timeout = setTimeout(async () => {
try {
const delta = details.cpLoss ?? 0;
const tactics = (details.missedTactics || [])
.filter((tactic) => tactic.tactic_type !== "none")
.map((tactic) => `${tactic.tactic_type}${tactic.material_delta ? ` (~${(tactic.material_delta / 100).toFixed(1)} pawns)` : ""}`)
.join("; ") || "None";
const prompt = buildMoveCommentaryPrompt({
bestMove: details.bestMoveSan ?? details.evalBefore.bestMove,
color: step.color,
cpLoss: delta,
evalAfter: details.evalAfter.score / 100,
evalBefore: details.evalBefore.score / 100,
fenAfter: step.fenAfter,
fenBefore: step.fenBefore,
mateInfo: details.evalAfter.mate !== null ? `Mate in ${details.evalAfter.mate}` : "No mate detected",
moveNumber: step.moveNumber,
openings: possibleOpenings.length > 0 ? possibleOpenings.map((opening) => `${opening.name} (${opening.eco})`).join(", ") : "Unknown/Midgame",
san: step.san,
tactics,
});
const result = await chatSession.sendMessage(prompt);
const responseText = result.response.text();
if (!cancelled) {
setComments((previous) => ({ ...previous, [currentIndex]: responseText }));
addEntry({
type: "analysis",
action: `Move ${step.moveNumber} Analysis (${step.color})`,
prompt,
response: responseText,
metadata: {
moveNumber: step.moveNumber,
san: step.san,
color: step.color,
fenBefore: step.fenBefore,
fenAfter: step.fenAfter,
cpLoss: delta,
personality: selectedPersonality.name,
language,
},
});
}
} catch (error) {
console.error("Commentary failed", error);
} finally {
if (!cancelled) setIsCommenting(false);
}
}, 400);
return () => {
cancelled = true;
clearTimeout(timeout);
setIsCommenting(false);
};
}, [addEntry, chatSession, comments, currentIndex, language, possibleOpenings, selectedPersonality.name, stepDetails, steps]);
useEffect(() => requestMoveCommentary(), [requestMoveCommentary]);
const currentDetails = currentIndex > 0 ? stepDetails[currentIndex] : undefined;
const tacticSummary = (currentDetails?.missedTactics || []).filter((tactic) => tactic.tactic_type !== "none");
return {
apiKey,
comments,
currentDetails,
currentFen,
currentIndex,
detectedFormat,
error,
formatCpLoss: (cp: number | undefined) => {
if (cp === undefined) return null;
const pawns = (cp / 100).toFixed(2);
return `${cp > 0 ? "+" : ""}${pawns}`;
},
formatEval: (evaluation?: StockfishEvaluation) => {
if (!evaluation) return null;
if (evaluation.mate !== null) return `#${evaluation.mate}`;
return `${evaluation.score >= 0 ? "+" : ""}${(evaluation.score / 100).toFixed(2)}`;
},
handleImportGame,
handleInputChange,
handleLoadGame,
handleResetAnalysis,
input,
isCommenting,
language,
orientation,
possibleOpenings,
selectedPersonality,
setCurrentIndex,
setOrientation,
setSelectedPersonality,
steps,
stockfish,
tacticSummary,
};
}
+12 -6
View File
@@ -1,18 +1,20 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import ChessGame from "@/components/ChessGame";
import StartScreen from "@/components/StartScreen";
import { Personality, PERSONALITIES } from "@/lib/personalities";
import { SavedGame, deleteSavedGame, loadSavedGames } from "@/lib/savedGames";
import { useHasHydrated } from "@/lib/useHasHydrated";
type ViewState = 'start' | 'game';
export default function Home() {
const router = useRouter();
const [view, setView] = useState<ViewState>('start');
const [mounted, setMounted] = useState(false);
const hasHydrated = useHasHydrated();
const hasInitializedRef = useRef(false);
// Game Initialization State
const [gameProps, setGameProps] = useState<{
@@ -34,6 +36,12 @@ export default function Home() {
const [savedGames, setSavedGames] = useState<SavedGame[]>([]);
useEffect(() => {
if (!hasHydrated || hasInitializedRef.current) {
return;
}
hasInitializedRef.current = true;
// Check for API Key
const apiKey = localStorage.getItem("gemini_api_key");
if (!apiKey) {
@@ -82,9 +90,7 @@ export default function Home() {
localStorage.removeItem("chess_tutor_opening_context");
}
}
setMounted(true);
}, [router]);
}, [hasHydrated, router]);
const handleStartGame = (options: {
personality: Personality;
@@ -127,7 +133,7 @@ export default function Home() {
setSavedGames(loadSavedGames());
};
if (!mounted) return null;
if (!hasHydrated) return null;
return (
<main className="flex-grow flex flex-col">
+2 -2
View File
@@ -33,7 +33,7 @@ export default function PrivacyPage() {
</p>
<ul className="list-disc pl-5 mt-2 space-y-2 text-gray-600 dark:text-gray-300">
<li>
<strong>Local Storage:</strong> Your settings (language preference, API key) are stored locally in your browser's Local Storage. This data never leaves your device unless you explicitly send it (e.g., the API key is sent to Google's servers to generate AI responses).
<strong>Local Storage:</strong> Your settings (language preference, API key) are stored locally in your browser&apos;s Local Storage. This data never leaves your device unless you explicitly send it (e.g., the API key is sent to Google&apos;s servers to generate AI responses).
</li>
<li>
<strong>Cookies:</strong> We do not use cookies for tracking or analytics.
@@ -47,7 +47,7 @@ export default function PrivacyPage() {
<section>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">3. Third-Party Services</h2>
<p className="text-gray-600 dark:text-gray-300">
<strong>Google Gemini API:</strong> When you use the AI Tutor feature, your game state (FEN string) and your API key are sent to Google's servers to generate the response. Please refer to <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Google's Privacy Policy</a> for more information on how they handle data.
<strong>Google Gemini API:</strong> When you use the AI Tutor feature, your game state (FEN string) and your API key are sent to Google&apos;s servers to generate the response. Please refer to <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Google&apos;s Privacy Policy</a> for more information on how they handle data.
</p>
</section>
</div>
+14 -29
View File
@@ -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<SupportedLanguage>('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<SupportedLanguage>(() => {
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 (
<>
+9 -12
View File
@@ -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();
+98 -9
View File
@@ -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) => (
<div data-testid="chessboard" onClick={() => {
// Simulate a move drop
if (options.onPieceDrop) {
@@ -16,18 +28,21 @@ jest.mock("react-chessboard", () => ({
}));
jest.mock("../lib/stockfish", () => {
return {
Stockfish: jest.fn().mockImplementation(() => ({
evaluate: jest.fn().mockResolvedValue({
const evaluate = jest.fn().mockResolvedValue({
score: 0.5,
mate: null,
bestMove: "e7e5",
depth: 15
}),
});
return {
__mock: { evaluate },
Stockfish: jest.fn().mockImplementation(() => ({
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 }) => (
<div data-testid="start-screen">
<button onClick={() => onStartGame({ personality: { name: 'Test Personality' }, color: 'white' })}>
Start Game
@@ -78,6 +93,12 @@ describe("ChessGame Component", () => {
localStorage.clear();
jest.clearAllMocks();
jest.useFakeTimers();
stockfishMock.evaluate.mockResolvedValue({
score: 0.5,
mate: null,
bestMove: "e7e5",
depth: 15
});
});
it("renders the game board and tutor", async () => {
@@ -96,7 +117,6 @@ describe("ChessGame Component", () => {
});
it("handles user move and triggers analysis", async () => {
const Tutor = require('./Tutor').Tutor;
render(
<ChessGame
gameId="test-game"
@@ -106,7 +126,8 @@ describe("ChessGame Component", () => {
/>
);
const initialCalls = Tutor.mock.calls.length;
const mockedTutor = jest.mocked(Tutor);
const initialCalls = mockedTutor.mock.calls.length;
// Make a move by clicking the mock chessboard
await act(async () => {
@@ -117,7 +138,75 @@ describe("ChessGame Component", () => {
// Wait for the component to update
await waitFor(() => {
expect(Tutor.mock.calls.length).toBeGreaterThan(initialCalls);
expect(mockedTutor.mock.calls.length).toBeGreaterThan(initialCalls);
});
});
it("restores a PGN game and persists save data without apiKey", async () => {
render(
<ChessGame
gameId="restore-game"
initialPersonality={mockPersonality}
initialColor="white"
initialPgn="1. e4 e5 2. Nf3 Nc6"
onBack={() => {}}
/>
);
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("chess_tutor_save") || "{}");
expect(saved.id).toBe("restore-game");
expect(saved.pgn).toContain("1. e4 e5");
expect(saved).not.toHaveProperty("apiKey");
});
});
it("undoes cleanly while analysis is in flight", async () => {
render(
<ChessGame
gameId="undo-game"
initialPersonality={mockPersonality}
initialColor="white"
onBack={() => {}}
/>
);
await act(async () => {
fireEvent.click(screen.getByTestId("chessboard"));
});
await act(async () => {
fireEvent.click(screen.getByText(/undo/i));
jest.runAllTimers();
});
await waitFor(() => {
const tutor = screen.getByTestId("tutor");
expect(tutor).not.toHaveTextContent("Computer Move:");
});
});
it("ignores rapid repeated drops once the turn has switched", async () => {
render(
<ChessGame
gameId="rapid-game"
initialPersonality={mockPersonality}
initialColor="white"
onBack={() => {}}
/>
);
await act(async () => {
fireEvent.click(screen.getByTestId("chessboard"));
fireEvent.click(screen.getByTestId("chessboard"));
jest.runAllTimers();
});
await waitFor(() => {
expect(stockfishMock.evaluate.mock.calls.length).toBeGreaterThanOrEqual(2);
});
const playerTriggeredEvaluations = stockfishMock.evaluate.mock.calls.filter(([fen]: [string]) => typeof fen === "string");
expect(playerTriggeredEvaluations.length).toBeLessThanOrEqual(3);
});
});
-5
View File
@@ -24,10 +24,6 @@ export default function DebugPanel({ entryId, inline = false }: DebugPanelProps)
const latestEntry = displayEntries[displayEntries.length - 1];
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString();
@@ -189,4 +185,3 @@ function DebugEntryDetail({ entry, onClose }: { entry: DebugEntry; onClose: () =
</div>
);
}
+4 -5
View File
@@ -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
</div>
);
}
-1
View File
@@ -1,4 +1,3 @@
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { Tutor } from '../Tutor';
import { Stockfish } from '@/lib/stockfish';
+451
View File
@@ -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<Stockfish | null>(() => (typeof window !== "undefined" ? new Stockfish() : null));
const [evalP0, setEvalP0] = useState<StockfishEvaluation | null>(null);
const [evalP2, setEvalP2] = useState<StockfishEvaluation | null>(null);
const [openingData, setOpeningData] = useState<OpeningMetadata[]>([]);
const [latestMissedTactics, setLatestMissedTactics] = useState<DetectedTactic[] | null>(null);
const [userMove, setUserMove] = useState<Move | null>(null);
const [computerMove, setComputerMove] = useState<Move | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [apiKey] = useState<string | null>(() => {
if (typeof window === "undefined") {
return null;
}
return localStorage.getItem("gemini_api_key");
});
const [stockfishDepth, setStockfishDepth] = useState(initialStockfishDepth ?? 15);
const [language] = useState<SupportedLanguage>(() => {
if (typeof window === "undefined") {
return "en";
}
return (localStorage.getItem("chess_tutor_language") as SupportedLanguage) || "en";
});
const [moveHistory, setMoveHistory] = useState<MoveHistoryItem[]>([]);
const [capturedWhitePieces, setCapturedWhitePieces] = useState<string[]>(() => initialCapturedState.whitePiecesLost);
const [capturedBlackPieces, setCapturedBlackPieces] = useState<string[]>(() => initialCapturedState.blackPiecesLost);
const [materialScore, setMaterialScore] = useState<{ white: number; black: number }>(() => ({
white: initialCapturedState.whiteLostScore,
black: initialCapturedState.blackLostScore,
}));
const [dismissedGameOverFen, setDismissedGameOverFen] = useState<string | null>(null);
const activeAnalysisIdRef = useRef(0);
const initialMoveTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}
+248
View File
@@ -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<TutorMessage[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const lastAnalyzedMoveRef = useRef<string | null>(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,
};
}
+1 -2
View File
@@ -9,7 +9,7 @@ export interface DebugEntry {
action: string; // e.g., "Best Move", "Hint", "General Question", "Move Analysis"
prompt: string;
response?: string;
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
}
interface DebugContextType {
@@ -56,4 +56,3 @@ export function useDebug() {
}
return context;
}
+73
View File
@@ -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)");
});
});
+52
View File
@@ -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].");
});
});
+50
View File
@@ -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);
});
});
+59
View File
@@ -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([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { Stockfish } from "../stockfish";
type MessageHandler = (event: MessageEvent) => void;
class FakeWorker {
public onmessage: MessageHandler | null = null;
private listeners = new Set<MessageHandler>();
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,
});
});
});
+1 -3
View File
@@ -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', () => {
});
});
});
+264
View File
@@ -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.`;
}
+139
View File
@@ -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<MoveHistoryItem & { category: MistakeCategory; cpLoss: number; evalBefore: number; evalAfter: number; move: string }>;
}
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).
`,
};
}
+26 -3
View File
@@ -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}`
};
}
+117
View File
@@ -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<string, number> = {
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,
};
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { GoogleGenerativeAI, SchemaType, FunctionDeclaration } from "@google/generative-ai";
import { StockfishEvaluation } from "./stockfish";
export async function getAvailableModels(apiKey: string): Promise<string[]> {
export async function getAvailableModels(): Promise<string[]> {
// Prioritize newer models
return [
"gemini-3-pro-preview",
+8 -4
View File
@@ -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;
}
return value || key;
value = (value as Record<string, unknown>)[k];
}
return typeof value === 'string' ? value : key;
}
-2
View File
@@ -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);
+9 -2
View File
@@ -11,7 +11,6 @@ export type SavedGame = {
updatedAt: number;
evaluation?: Pick<StockfishEvaluation, "score" | "mate" | "depth"> | 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 [];
+59 -45
View File
@@ -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<void> = Promise.resolve();
constructor() {
if (typeof window !== "undefined") {
@@ -28,48 +24,69 @@ export class Stockfish {
}
}
private waitUntilReady(): Promise<void> {
if (this.isReady) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
if (!this.worker) {
reject(new Error("Stockfish worker not initialized"));
return;
}
const timeoutId = window.setTimeout(() => {
this.worker?.removeEventListener("message", handleReady);
reject(new Error("Stockfish worker readiness timed out"));
}, 5000);
const handleReady = (event: MessageEvent) => {
if (event.data === "uciok") {
window.clearTimeout(timeoutId);
this.worker?.removeEventListener("message", handleReady);
this.isReady = true;
resolve();
}
};
this.worker.addEventListener("message", handleReady);
});
}
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<StockfishEvaluation> {
const runEvaluation = async () => {
await this.waitUntilReady();
return new Promise<StockfishEvaluation>((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;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isResolved = false;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
this.worker?.removeEventListener("message", handler);
};
let lastScore = 0;
let lastMate: number | null = null;
let lastDepth = 0;
const handler = (event: MessageEvent) => {
if (isResolved) return;
const message = event.data;
// console.log("Stockfish:", message);
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) this.lastDepth = parseInt(depthMatch[1]);
if (depthMatch) lastDepth = parseInt(depthMatch[1], 10);
if (scoreMatch) {
this.lastScore = parseInt(scoreMatch[1]);
this.lastMate = null;
lastScore = parseInt(scoreMatch[1], 10);
lastMate = null;
}
if (mateMatch) {
this.lastMate = parseInt(mateMatch[1]);
this.lastScore = 0; // or some indicator
lastMate = parseInt(mateMatch[1], 10);
lastScore = 0;
}
}
@@ -81,33 +98,30 @@ export class Stockfish {
ponder = parts[3];
}
isResolved = true;
cleanup();
this.worker?.removeEventListener("message", handler);
resolve({
bestMove,
ponder,
score: this.lastScore,
mate: this.lastMate,
depth: this.lastDepth
score: lastScore,
mate: lastMate,
depth: lastDepth,
});
}
};
// 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", handler);
if (multiPV > 1) {
this.worker.postMessage(`setoption name MultiPV value ${multiPV}`);
}
this.worker.postMessage(`position fen ${fen}`);
this.worker.postMessage(`go depth ${depth}`);
}).then((evalResult: StockfishEvaluation) => {
});
};
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'
+1 -1
View File
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
export function useHasHydrated(): boolean {
return useSyncExternalStore(subscribe, () => true, () => false);
}