game ready
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user