game ready

This commit is contained in:
Stefan
2025-11-23 11:44:05 +01:00
parent 30f1e9e3a5
commit 8a6d6bd780
8 changed files with 5259 additions and 30 deletions
+13 -6
View File
@@ -46,12 +46,19 @@ jest.mock("./Tutor", () => ({
// Mock APIKeyInput to avoid portal issues or complex interactions if needed,
// but since we integrated it into the start screen, we can test the interaction directly.
jest.mock("./APIKeyInput", () => ({
APIKeyInput: ({ onKeySubmit }: any) => (
<button onClick={() => onKeySubmit("test-key")} data-testid="api-key-trigger">
Set API Key
</button>
),
APIKeyInput: ({ onKeySubmit }: any) => (
<button onClick={() => onKeySubmit("test-key")} data-testid="api-key-trigger">
Set API Key
</button>
),
}));
jest.mock("./GameAnalysisModal", () => ({
GameAnalysisModal: () => <div data-testid="analysis-modal">Analysis Modal Mock</div>,
}));
jest.mock("./GameOverModal", () => ({
GameOverModal: () => <div data-testid="game-over-modal">Game Over Modal Mock</div>,
}));
describe("ChessGame Component", () => {
+80 -3
View File
@@ -12,6 +12,7 @@ import { Personality, PERSONALITIES } from "@/lib/personalities";
import { lookupOpening, OpeningMetadata } from "@/lib/openings";
import { GameAnalysisModal } from "./GameAnalysisModal";
import { GameOverModal, MoveHistoryItem } from "./GameOverModal";
import { Brain } from "lucide-react";
export default function ChessGame() {
@@ -41,6 +42,10 @@ export default function ChessGame() {
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [customFen, setCustomFen] = useState("");
// Game Over & History State
const [gameOverState, setGameOverState] = useState<{ result: string, winner: "White" | "Black" | "Draw" } | null>(null);
const [moveHistory, setMoveHistory] = useState<MoveHistoryItem[]>([]);
// Personality State
const [selectedPersonality, setSelectedPersonality] = useState<Personality | null>(null);
@@ -64,6 +69,8 @@ export default function ChessGame() {
if (data.language) setLanguage(data.language);
if (data.selectedPersonality) setSelectedPersonality(data.selectedPersonality);
if (data.apiKey) setApiKey(data.apiKey);
// Note: We don't persist full move history yet for simplicity,
// but we could add it to localStorage if needed.
} catch (e) {
console.error("Failed to load game:", e);
}
@@ -82,14 +89,41 @@ export default function ChessGame() {
localStorage.setItem("chess_tutor_save", JSON.stringify(saveData));
}, [fen, language, selectedPersonality, apiKey, gameStarted]);
// Game Over Detection
useEffect(() => {
const game = gameRef.current;
if (game.isGameOver()) {
let result = "";
let winner: "White" | "Black" | "Draw" = "Draw";
if (game.isCheckmate()) {
if (game.turn() === 'w') {
result = "Checkmate! You lost.";
winner = "Black";
} else {
result = "Checkmate! You won!";
winner = "White";
}
} else if (game.isDraw()) {
result = "Draw!";
winner = "Draw";
} else if (game.isStalemate()) {
result = "Stalemate!";
winner = "Draw";
}
setGameOverState({ result, winner });
}
}, [fen]);
// Pre-Analysis (P0): Run whenever it's White's turn (User) and we are waiting for a move
useEffect(() => {
if (stockfish && gameRef.current.turn() === 'w' && !isAnalyzing) {
if (stockfish && gameRef.current.turn() === 'w' && !isAnalyzing && !gameOverState) {
stockfish.evaluate(gameRef.current.fen(), stockfishDepth).then(evalResult => {
setEvalP0(evalResult);
}).catch(err => console.error("Pre-analysis failed:", err));
}
}, [fen, stockfish, stockfishDepth, isAnalyzing]);
}, [fen, stockfish, stockfishDepth, isAnalyzing, gameOverState]);
const makeAMove = useCallback(
(move: { from: string; to: string; promotion?: string }) => {
@@ -111,7 +145,7 @@ export default function ChessGame() {
);
function onDrop({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) {
if (!targetSquare || !stockfish) return false;
if (!targetSquare || !stockfish || gameOverState) return false;
const move = {
from: sourceSquare,
@@ -139,6 +173,33 @@ export default function ChessGame() {
stockfish.evaluate(fenP1, stockfishDepth).then(p1Eval => {
// We don't store p1Eval for the Tutor, but we use it to decide the move
// Record User Move History (P0 -> P1)
// We compare evalP0 (Before) vs p1Eval (After)
// Note: p1Eval is from Black's perspective usually in engines, but our wrapper might normalize.
// Let's assume our wrapper returns CP relative to side to move or absolute?
// Standard Stockfish returns relative to side to move.
// So if White is winning +100:
// P0 (White to move): +100
// P1 (Black to move): -100 (Black is losing)
// So we need to negate p1Eval.score to compare with evalP0.score (if evalP0 is White's perspective).
// Actually, let's check our Stockfish wrapper. It usually returns absolute or relative.
// Assuming relative:
// P0 (White): +1.0
// P1 (Black): -1.0 (Black is down 1.0)
// So evalAfter = -p1Eval.score
if (evalP0) {
const evalAfter = -p1Eval.score; // Convert back to White's perspective
const historyItem: MoveHistoryItem = {
moveNumber: gameRef.current.moveNumber(),
move: moveResult.result.san,
evalBefore: evalP0.score,
evalAfter: evalAfter,
bestMove: evalP0.bestMove
};
setMoveHistory(prev => [...prev, historyItem]);
}
setTimeout(() => {
const computerMoveData = {
from: p1Eval.bestMove.substring(0, 2),
@@ -196,6 +257,8 @@ export default function ChessGame() {
setEvalP0(null);
setEvalP2(null);
setOpeningData(null);
setGameOverState(null);
setMoveHistory([]);
setGameStarted(true);
setCustomFen(""); // Clear input
@@ -334,6 +397,7 @@ export default function ChessGame() {
);
}
return (
<div className="flex flex-col md:flex-row gap-8 w-full max-w-6xl mx-auto p-4">
{/* API Key Input is now handled in start screen, but we keep the button for updates */}
@@ -486,6 +550,19 @@ export default function ChessGame() {
onClose={() => setShowAnalysisModal(false)}
/>
)}
{/* Game Over Modal */}
{gameOverState && (
<GameOverModal
result={gameOverState.result}
winner={gameOverState.winner}
history={moveHistory}
apiKey={apiKey}
language={language}
onClose={() => setGameOverState(null)}
onNewGame={() => handleNewGame(selectedPersonality!)}
/>
)}
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useState, useEffect } from "react";
import { getGenAIModel } from "@/lib/gemini";
import { Loader2, X, Trophy, AlertTriangle, RefreshCw } from "lucide-react";
export interface MoveHistoryItem {
moveNumber: number;
move: string;
evalBefore: number; // cp
evalAfter: number; // cp
bestMove?: string;
}
interface GameOverModalProps {
result: string; // "Checkmate", "Draw", etc.
winner: "White" | "Black" | "Draw";
history: MoveHistoryItem[];
apiKey: string | null;
language: 'en' | 'de' | 'fr' | 'it';
onClose: () => void;
onNewGame: () => void;
}
export function GameOverModal({ result, winner, history, apiKey, language, onClose, onNewGame }: GameOverModalProps) {
const [analysis, setAnalysis] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [mistakes, setMistakes] = useState<MoveHistoryItem[]>([]);
useEffect(() => {
const analyzeGame = async () => {
setIsLoading(true);
try {
// 1. Identify Mistakes (Blunders)
// A blunder is roughly a drop of > 100cp (1 pawn) or missing a mate
const detectedMistakes = history.filter(item => {
const delta = item.evalAfter - item.evalBefore;
// Note: eval is from White's perspective.
// If White moves, eval should ideally go up or stay same.
// If eval drops significantly, it's a mistake.
return delta <= -100;
});
setMistakes(detectedMistakes);
// 2. LLM Analysis
if (apiKey) {
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
const mistakesText = detectedMistakes.map(m =>
`Move ${m.moveNumber}: Played ${m.move} (Eval dropped from ${m.evalBefore} to ${m.evalAfter}). Best move was likely ${m.bestMove}.`
).join("\n");
const prompt = `
You are a Chess Coach. The game is over.
Result: ${result} (${winner === "Draw" ? "Draw" : winner + " Won"}).
Here are the player's (White) key mistakes (Blunders):
${mistakesText || "No major blunders detected."}
INSTRUCTIONS:
1. Briefly comment on the game result.
2. If there were mistakes, explain WHY they were bad and what the player should have looked for (tactics, hanging pieces, etc.).
3. If no mistakes, praise the solid play.
4. Be encouraging but educational.
5. Respond in ${language.toUpperCase()}.
OUTPUT FORMAT:
Plain text paragraph.
`;
const resultGen = await model.generateContent(prompt);
setAnalysis(resultGen.response.text());
} else {
setAnalysis("Please provide an API Key to get an AI analysis of your game.");
}
} catch (e) {
console.error("Game Over Analysis failed:", e);
setAnalysis("Failed to generate analysis.");
} finally {
setIsLoading(false);
}
};
analyzeGame();
}, [history, apiKey, language, result, winner]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-2xl w-full overflow-hidden border border-gray-200 dark:border-gray-700 animate-in fade-in zoom-in duration-300">
{/* Header */}
<div className={`p-6 text-center ${winner === "White" ? "bg-green-100 dark:bg-green-900/30" : winner === "Black" ? "bg-red-100 dark:bg-red-900/30" : "bg-gray-100 dark:bg-gray-800"}`}>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{winner === "White" ? "Victory!" : winner === "Black" ? "Defeat" : "Draw"}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-300">{result}</p>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="animate-spin text-purple-600" size={48} />
<p className="text-gray-500">Analyzing your performance...</p>
</div>
) : (
<>
{/* Mistakes List */}
{mistakes.length > 0 && (
<div className="space-y-3">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<AlertTriangle className="text-orange-500" size={20} />
Key Moments / Mistakes
</h3>
<div className="max-h-40 overflow-y-auto space-y-2 pr-2">
{mistakes.map((m, idx) => (
<div key={idx} className="p-3 bg-orange-50 dark:bg-orange-900/10 border border-orange-100 dark:border-orange-900/30 rounded-lg text-sm">
<span className="font-bold text-gray-900 dark:text-white">Move {m.moveNumber}: {m.move}</span>
<span className="mx-2 text-gray-400">|</span>
<span className="text-red-600 dark:text-red-400">Eval: {m.evalBefore} {m.evalAfter}</span>
{m.bestMove && (
<div className="text-gray-500 dark:text-gray-400 mt-1">
Best was likely: <span className="font-mono">{m.bestMove}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* AI Analysis */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<Trophy size={20} className="text-yellow-500" />
Coach's Feedback
</h3>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-gray-800 dark:text-gray-200 leading-relaxed">
{analysis}
</div>
</div>
</>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 bg-gray-50 dark:bg-gray-900">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white"
>
Close
</button>
<button
onClick={onNewGame}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2 shadow-sm"
>
<RefreshCw size={16} />
Play Again
</button>
</div>
</div>
</div>
);
}