Handle resignation coach response and add analysis option
This commit is contained in:
@@ -48,7 +48,9 @@ jest.mock("./GameAnalysisModal", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("./GameOverModal", () => ({
|
jest.mock("./GameOverModal", () => ({
|
||||||
GameOverModal: () => <div data-testid="game-over-modal">Game Over Modal Mock</div>,
|
GameOverModal: ({ onAnalyze }: { onAnalyze: () => void }) => (
|
||||||
|
<div data-testid="game-over-modal" onClick={onAnalyze}>Game Over Modal Mock</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("./StartScreen", () => ({
|
jest.mock("./StartScreen", () => ({
|
||||||
|
|||||||
+195
-23
@@ -13,7 +13,7 @@ import { SupportedLanguage } from "@/lib/i18n/translations";
|
|||||||
import { lookupOpening, lookupPossibleOpenings, extractMoveSequenceFromPGN, OpeningMetadata } from "@/lib/openings";
|
import { lookupOpening, lookupPossibleOpenings, extractMoveSequenceFromPGN, OpeningMetadata } from "@/lib/openings";
|
||||||
import { GameAnalysisModal } from "./GameAnalysisModal";
|
import { GameAnalysisModal } from "./GameAnalysisModal";
|
||||||
import { GameOverModal, MoveHistoryItem } from "./GameOverModal";
|
import { GameOverModal, MoveHistoryItem } from "./GameOverModal";
|
||||||
import { Brain, ArrowLeft, Download } from "lucide-react";
|
import { Brain, ArrowLeft, Download, Flag } from "lucide-react";
|
||||||
import { CapturedPieces } from "./CapturedPieces";
|
import { CapturedPieces } from "./CapturedPieces";
|
||||||
import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection";
|
import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection";
|
||||||
import { upsertSavedGame } from "@/lib/savedGames";
|
import { upsertSavedGame } from "@/lib/savedGames";
|
||||||
@@ -68,7 +68,16 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
const [gameOverState, setGameOverState] = useState<{ result: string, winner: "White" | "Black" | "Draw" } | null>(null);
|
const [gameOverState, setGameOverState] = useState<{ result: string, winner: "White" | "Black" | "Draw" } | null>(null);
|
||||||
const [moveHistory, setMoveHistory] = useState<MoveHistoryItem[]>([]);
|
const [moveHistory, setMoveHistory] = useState<MoveHistoryItem[]>([]);
|
||||||
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(initialPersonality);
|
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(initialPersonality);
|
||||||
|
const [resignationContext, setResignationContext] = useState<{
|
||||||
|
trigger: number;
|
||||||
|
fen: string;
|
||||||
|
evaluation: StockfishEvaluation | null;
|
||||||
|
history: MoveHistoryItem[];
|
||||||
|
result: string;
|
||||||
|
winner: "White" | "Black" | "Draw";
|
||||||
|
} | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasRebuiltHistoryRef = useRef(false);
|
||||||
|
|
||||||
// Sound Refs
|
// Sound Refs
|
||||||
const moveSound = useRef<HTMLAudioElement | null>(null);
|
const moveSound = useRef<HTMLAudioElement | null>(null);
|
||||||
@@ -110,12 +119,125 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
return () => sf.terminate();
|
return () => sf.terminate();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasRebuiltHistoryRef.current = false;
|
||||||
|
}, [initialPgn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof initialStockfishDepth === 'number') {
|
if (typeof initialStockfishDepth === 'number') {
|
||||||
setStockfishDepth(initialStockfishDepth);
|
setStockfishDepth(initialStockfishDepth);
|
||||||
}
|
}
|
||||||
}, [initialStockfishDepth]);
|
}, [initialStockfishDepth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialPgn || !stockfish) return;
|
||||||
|
if (moveHistory.length > 0 || hasRebuiltHistoryRef.current) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
hasRebuiltHistoryRef.current = true;
|
||||||
|
|
||||||
|
const rebuildHistoryFromPgn = async () => {
|
||||||
|
try {
|
||||||
|
const setupFen = initialFen || undefined;
|
||||||
|
const parsingGame = new Chess(setupFen);
|
||||||
|
parsingGame.loadPgn(initialPgn);
|
||||||
|
const verboseMoves = parsingGame.history({ verbose: true });
|
||||||
|
|
||||||
|
const replayGame = new Chess(setupFen);
|
||||||
|
const playerTurnColor = playerColor === 'white' ? 'w' : 'b';
|
||||||
|
const rebuiltHistory: MoveHistoryItem[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < verboseMoves.length; i++) {
|
||||||
|
const move = verboseMoves[i];
|
||||||
|
|
||||||
|
// Play through opponent moves until it's the player's turn
|
||||||
|
if (move.color !== playerTurnColor) {
|
||||||
|
replayGame.move(move);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveNumber = Math.floor(i / 2) + 1;
|
||||||
|
const fenBeforePlayerMove = replayGame.fen();
|
||||||
|
const evalBeforePlayerMove = await stockfish.evaluate(fenBeforePlayerMove, stockfishDepth);
|
||||||
|
|
||||||
|
const playerMoveResult = replayGame.move(move);
|
||||||
|
if (!playerMoveResult) break;
|
||||||
|
|
||||||
|
const fenAfterPlayerMove = replayGame.fen();
|
||||||
|
const evalAfterPlayerMove = await stockfish.evaluate(fenAfterPlayerMove, stockfishDepth);
|
||||||
|
|
||||||
|
let computerMoveSan = '';
|
||||||
|
let fenAfterComputerMove = fenAfterPlayerMove;
|
||||||
|
let evalAfterComputerMove = evalAfterPlayerMove;
|
||||||
|
|
||||||
|
if (i + 1 < verboseMoves.length && verboseMoves[i + 1].color !== move.color) {
|
||||||
|
const computerMove = verboseMoves[i + 1];
|
||||||
|
const computerMoveResult = replayGame.move(computerMove);
|
||||||
|
if (computerMoveResult) {
|
||||||
|
computerMoveSan = computerMoveResult.san;
|
||||||
|
fenAfterComputerMove = replayGame.fen();
|
||||||
|
evalAfterComputerMove = await stockfish.evaluate(fenAfterComputerMove, stockfishDepth);
|
||||||
|
i++; // Skip the computer move we just processed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWhite = playerColor === 'white';
|
||||||
|
const evalBeforePerspective = isWhite ? evalBeforePlayerMove.score : -evalBeforePlayerMove.score;
|
||||||
|
const evalAfterPerspective = isWhite ? -evalAfterPlayerMove.score : evalAfterPlayerMove.score;
|
||||||
|
const cpLoss = evalBeforePerspective - evalAfterPerspective;
|
||||||
|
|
||||||
|
const bestMoveUci = evalBeforePlayerMove.bestMove;
|
||||||
|
const bestMoveSan = bestMoveUci ? uciToSan(fenBeforePlayerMove, bestMoveUci) : null;
|
||||||
|
const missedTactics = bestMoveUci ? detectMissedTactics({
|
||||||
|
fen: fenBeforePlayerMove,
|
||||||
|
playerColor,
|
||||||
|
playerMoveSan: playerMoveResult.san,
|
||||||
|
bestMoveUci,
|
||||||
|
cpLoss,
|
||||||
|
}) : undefined;
|
||||||
|
|
||||||
|
const currentPgn = replayGame.pgn();
|
||||||
|
const moveSequence = extractMoveSequenceFromPGN(currentPgn);
|
||||||
|
const possibleOpenings = lookupPossibleOpenings(moveSequence, 5);
|
||||||
|
|
||||||
|
rebuiltHistory.push({
|
||||||
|
moveNumber,
|
||||||
|
playerMove: playerMoveResult.san,
|
||||||
|
playerColor,
|
||||||
|
fenBeforePlayerMove,
|
||||||
|
evalBeforePlayerMove,
|
||||||
|
fenAfterPlayerMove,
|
||||||
|
evalAfterPlayerMove,
|
||||||
|
computerMove: computerMoveSan || '...',
|
||||||
|
fenAfterComputerMove,
|
||||||
|
evalAfterComputerMove,
|
||||||
|
opening: possibleOpenings.length > 0 ? possibleOpenings[0].name : undefined,
|
||||||
|
move: playerMoveResult.san,
|
||||||
|
evalBefore: evalBeforePlayerMove.score,
|
||||||
|
evalAfter: evalAfterPlayerMove.score,
|
||||||
|
bestMove: evalBeforePlayerMove.bestMove,
|
||||||
|
bestMoveSan,
|
||||||
|
cpLoss,
|
||||||
|
missedTactics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCancelled) {
|
||||||
|
setMoveHistory(rebuiltHistory);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rebuild move history from PGN', error);
|
||||||
|
hasRebuiltHistoryRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuildHistoryFromPgn();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [initialPgn, stockfish, playerColor, stockfishDepth, initialFen, moveHistory.length]);
|
||||||
|
|
||||||
// Load Settings & Initial State
|
// Load Settings & Initial State
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedKey = localStorage.getItem("gemini_api_key");
|
const storedKey = localStorage.getItem("gemini_api_key");
|
||||||
@@ -473,9 +595,43 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
setEvalP0(null);
|
setEvalP0(null);
|
||||||
setEvalP2(null);
|
setEvalP2(null);
|
||||||
setOpeningData([]);
|
setOpeningData([]);
|
||||||
|
setResignationContext(null);
|
||||||
updateCapturedPieces();
|
updateCapturedPieces();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResign = useCallback(async () => {
|
||||||
|
if (gameOverState) return;
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
|
||||||
|
const currentFen = gameRef.current.fen();
|
||||||
|
let evaluation: StockfishEvaluation | null = null;
|
||||||
|
|
||||||
|
if (stockfish) {
|
||||||
|
try {
|
||||||
|
evaluation = await stockfish.evaluate(currentFen, stockfishDepth);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to evaluate resignation position", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = t.game.resignation;
|
||||||
|
const winner = playerColor === 'white' ? 'Black' : 'White' as const;
|
||||||
|
|
||||||
|
setGameOverState({
|
||||||
|
result,
|
||||||
|
winner,
|
||||||
|
});
|
||||||
|
|
||||||
|
setResignationContext({
|
||||||
|
trigger: Date.now(),
|
||||||
|
fen: currentFen,
|
||||||
|
evaluation,
|
||||||
|
history: moveHistory,
|
||||||
|
result,
|
||||||
|
winner,
|
||||||
|
});
|
||||||
|
}, [gameOverState, moveHistory, playerColor, stockfish, stockfishDepth, t.game.resignation]);
|
||||||
|
|
||||||
const handleDownloadPGN = () => {
|
const handleDownloadPGN = () => {
|
||||||
const pgn = gameRef.current.pgn();
|
const pgn = gameRef.current.pgn();
|
||||||
const blob = new Blob([pgn], { type: 'text/plain' });
|
const blob = new Blob([pgn], { type: 'text/plain' });
|
||||||
@@ -602,23 +758,34 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={() => {
|
<button
|
||||||
const game = gameRef.current;
|
onClick={() => {
|
||||||
game.undo();
|
const game = gameRef.current;
|
||||||
game.undo();
|
game.undo();
|
||||||
setFen(game.fen());
|
game.undo();
|
||||||
setUserMove(null);
|
setFen(game.fen());
|
||||||
setComputerMove(null);
|
setUserMove(null);
|
||||||
setEvalP0(null);
|
setComputerMove(null);
|
||||||
setEvalP2(null);
|
setEvalP0(null);
|
||||||
setOpeningData([]);
|
setEvalP2(null);
|
||||||
updateCapturedPieces();
|
setOpeningData([]);
|
||||||
}}
|
updateCapturedPieces();
|
||||||
className="flex items-center gap-1 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
}}
|
||||||
>
|
className="flex items-center gap-1 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
<ArrowLeft size={12} /> Undo
|
disabled={!!gameOverState}
|
||||||
</button>
|
>
|
||||||
|
<ArrowLeft size={12} /> {t.game.undoMove}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleResign}
|
||||||
|
className="flex items-center gap-1 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors"
|
||||||
|
disabled={!!gameOverState}
|
||||||
|
>
|
||||||
|
<Flag size={12} /> {t.game.resign}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -656,13 +823,14 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
language={language}
|
language={language}
|
||||||
playerColor={playerColor}
|
playerColor={playerColor}
|
||||||
onCheckComputerMove={checkAndMakeComputerMove}
|
onCheckComputerMove={checkAndMakeComputerMove}
|
||||||
|
resignationContext={resignationContext}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. History (Col 1-3) - Full width at bottom */}
|
{/* 4. History (Col 1-3) - Full width at bottom */}
|
||||||
<div className="md:col-span-3 bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg flex flex-col">
|
<div className="md:col-span-3 bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">Game History</h3>
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">{t.game.gameHistory}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDownloadModal(true)}
|
onClick={() => setShowDownloadModal(true)}
|
||||||
@@ -674,7 +842,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
onClick={() => setShowAnalysisModal(true)}
|
onClick={() => setShowAnalysisModal(true)}
|
||||||
className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-200 flex items-center gap-1"
|
className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-200 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Brain size={12} /> Analyze
|
<Brain size={12} /> {t.game.analyze}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -683,8 +851,8 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
<tr className="text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||||
<th className="py-1 px-2 w-12">#</th>
|
<th className="py-1 px-2 w-12">#</th>
|
||||||
<th className="py-1 px-2">White</th>
|
<th className="py-1 px-2">{t.game.white}</th>
|
||||||
<th className="py-1 px-2">Black</th>
|
<th className="py-1 px-2">{t.game.black}</th>
|
||||||
<th className="py-1 px-2 text-center w-20">Eval Δ</th>
|
<th className="py-1 px-2 text-center w-20">Eval Δ</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -692,7 +860,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
{moveHistory.length === 0 ? (
|
{moveHistory.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="py-4 text-center text-gray-500 italic">
|
<td colSpan={4} className="py-4 text-center text-gray-500 italic">
|
||||||
No moves yet.
|
{t.game.noMovesYet}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@@ -757,6 +925,10 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
language={language}
|
language={language}
|
||||||
onClose={() => setGameOverState(null)}
|
onClose={() => setGameOverState(null)}
|
||||||
onNewGame={handleNewGame}
|
onNewGame={handleNewGame}
|
||||||
|
onAnalyze={() => {
|
||||||
|
setGameOverState(null);
|
||||||
|
setShowAnalysisModal(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ interface GameOverModalProps {
|
|||||||
language: SupportedLanguage;
|
language: SupportedLanguage;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNewGame: () => void;
|
onNewGame: () => void;
|
||||||
|
onAnalyze: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameOverModal({ result, winner, history, apiKey, language, onClose, onNewGame }: GameOverModalProps) {
|
export function GameOverModal({ result, winner, history, apiKey, language, onClose, onNewGame, onAnalyze }: GameOverModalProps) {
|
||||||
const [analysis, setAnalysis] = useState<string>("");
|
const [analysis, setAnalysis] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [mistakes, setMistakes] = useState<MoveHistoryItem[]>([]);
|
const [mistakes, setMistakes] = useState<MoveHistoryItem[]>([]);
|
||||||
@@ -329,6 +330,13 @@ Plain text paragraph (2-3 sentences).
|
|||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAnalyze}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<Trophy size={16} />
|
||||||
|
Analyze Game
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onNewGame}
|
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"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2 shadow-sm"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useTranslation } from '@/lib/i18n/useTranslation';
|
|||||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||||
import { DetectedTactic } from '@/lib/tacticDetection';
|
import { DetectedTactic } from '@/lib/tacticDetection';
|
||||||
import { useDebug } from '@/contexts/DebugContext';
|
import { useDebug } from '@/contexts/DebugContext';
|
||||||
|
import { MoveHistoryItem } from './GameOverModal';
|
||||||
|
|
||||||
interface TutorProps {
|
interface TutorProps {
|
||||||
game: Chess;
|
game: Chess;
|
||||||
@@ -32,6 +33,14 @@ interface TutorProps {
|
|||||||
language: SupportedLanguage;
|
language: SupportedLanguage;
|
||||||
playerColor: 'white' | 'black';
|
playerColor: 'white' | 'black';
|
||||||
onCheckComputerMove: () => void;
|
onCheckComputerMove: () => void;
|
||||||
|
resignationContext?: {
|
||||||
|
trigger: number;
|
||||||
|
fen: string;
|
||||||
|
evaluation: StockfishEvaluation | null;
|
||||||
|
history: MoveHistoryItem[];
|
||||||
|
result: string;
|
||||||
|
winner: 'White' | 'Black' | 'Draw';
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -40,7 +49,7 @@ interface Message {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove }: TutorProps) {
|
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext }: TutorProps) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -460,6 +469,51 @@ INSTRUCTIONS:
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResignationMessage = async () => {
|
||||||
|
if (!resignationContext || !chatSession) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let evaluation = resignationContext.evaluation;
|
||||||
|
|
||||||
|
if (!evaluation && stockfish) {
|
||||||
|
evaluation = await stockfish.evaluate(resignationContext.fen, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = messages.map(msg => `${msg.role === "user" ? "User" : personality.name}: ${msg.text}`).join("\n");
|
||||||
|
const whiteEval = evaluation ? `${evaluation.score} cp${evaluation.mate ? ` (mate in ${evaluation.mate})` : ''}` : "N/A";
|
||||||
|
const blackEval = evaluation ? `${-evaluation.score} cp${evaluation.mate ? ` (mate in ${-evaluation.mate})` : ''}` : "N/A";
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
[SYSTEM TRIGGER: resignation]
|
||||||
|
The user just resigned. Provide a final, in-character message that acknowledges the resignation and offers a brief next step.
|
||||||
|
|
||||||
|
RESULT: ${resignationContext.result} (${resignationContext.winner})
|
||||||
|
CURRENT POSITION FEN: ${resignationContext.fen}
|
||||||
|
ENGINE EVALUATION: White ${whiteEval}, Black ${blackEval}
|
||||||
|
|
||||||
|
RECENT CONVERSATION:
|
||||||
|
${transcript || 'No prior conversation.'}
|
||||||
|
|
||||||
|
INSTRUCTIONS:
|
||||||
|
- Respond in ${language.toUpperCase()} and stay true to your personality (${personality.name}).
|
||||||
|
- React naturally to the resignation (sarcastic, encouraging, etc. based on personality).
|
||||||
|
- Offer a quick suggestion: either invite a rematch or suggest analyzing the game.
|
||||||
|
- Keep it concise (2-3 sentences).
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendMessageToChat(prompt, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send resignation message", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResignationMessage();
|
||||||
|
}, [chatSession, language, personality.name, resignationContext?.trigger, resignationContext?.evaluation, resignationContext?.fen, resignationContext?.result, resignationContext?.winner, stockfish]);
|
||||||
|
|
||||||
if (!apiKey) return null;
|
if (!apiKey) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||||
import { Tutor } from '../Tutor';
|
import { Tutor } from '../Tutor';
|
||||||
import { Stockfish } from '@/lib/stockfish';
|
import { Stockfish } from '@/lib/stockfish';
|
||||||
import { Chess } from 'chess.js';
|
import { Chess } from 'chess.js';
|
||||||
import * as gemini from '@/lib/gemini';
|
import * as gemini from '@/lib/gemini';
|
||||||
|
import { DebugProvider } from '@/contexts/DebugContext';
|
||||||
|
|
||||||
jest.mock('@/lib/stockfish');
|
jest.mock('@/lib/stockfish');
|
||||||
jest.mock('@/lib/gemini');
|
jest.mock('@/lib/gemini');
|
||||||
@@ -36,27 +37,30 @@ describe('Tutor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Tutor
|
<DebugProvider>
|
||||||
game={game}
|
<Tutor
|
||||||
currentFen={game.fen()}
|
game={game}
|
||||||
userMove={null}
|
currentFen={game.fen()}
|
||||||
computerMove={null}
|
userMove={null}
|
||||||
stockfish={stockfish}
|
computerMove={null}
|
||||||
evalP0={null}
|
stockfish={stockfish}
|
||||||
evalP2={null}
|
evalP0={null}
|
||||||
openingData={null}
|
evalP2={null}
|
||||||
missedTactics={null}
|
openingData={null}
|
||||||
onAnalysisComplete={() => {}}
|
missedTactics={null}
|
||||||
apiKey="test-api-key"
|
onAnalysisComplete={() => {}}
|
||||||
personality={{
|
apiKey="test-api-key"
|
||||||
name: "Test Personality",
|
personality={{
|
||||||
systemPrompt: "Test Prompt",
|
name: "Test Personality",
|
||||||
image: "🤖"
|
systemPrompt: "Test Prompt",
|
||||||
}}
|
image: "🤖"
|
||||||
language="en"
|
}}
|
||||||
playerColor="white"
|
language="en"
|
||||||
onCheckComputerMove={() => {}}
|
playerColor="white"
|
||||||
/>
|
onCheckComputerMove={() => {}}
|
||||||
|
resignationContext={null}
|
||||||
|
/>
|
||||||
|
</DebugProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
const hintButton = screen.getByText(/hint/i);
|
const hintButton = screen.getByText(/hint/i);
|
||||||
@@ -67,4 +71,70 @@ describe('Tutor', () => {
|
|||||||
|
|
||||||
expect(evaluateSpy).toHaveBeenCalledWith(game.fen(), 15);
|
expect(evaluateSpy).toHaveBeenCalledWith(game.fen(), 15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends a resignation follow-up message when provided context', async () => {
|
||||||
|
const sendMessage = jest.fn().mockResolvedValue({
|
||||||
|
response: {
|
||||||
|
text: () => 'Resignation response',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(gemini.getGenAIModel as jest.Mock).mockReturnValue({
|
||||||
|
startChat: jest.fn().mockReturnValue({
|
||||||
|
sendMessage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluation = {
|
||||||
|
bestMove: 'e2e4',
|
||||||
|
ponder: null,
|
||||||
|
score: 50,
|
||||||
|
mate: null,
|
||||||
|
depth: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DebugProvider>
|
||||||
|
<Tutor
|
||||||
|
game={game}
|
||||||
|
currentFen={game.fen()}
|
||||||
|
userMove={null}
|
||||||
|
computerMove={null}
|
||||||
|
stockfish={stockfish}
|
||||||
|
evalP0={null}
|
||||||
|
evalP2={null}
|
||||||
|
openingData={null}
|
||||||
|
missedTactics={null}
|
||||||
|
onAnalysisComplete={() => {}}
|
||||||
|
apiKey="test-api-key"
|
||||||
|
personality={{
|
||||||
|
name: "Test Personality",
|
||||||
|
systemPrompt: "Test Prompt",
|
||||||
|
image: "🤖"
|
||||||
|
}}
|
||||||
|
language="en"
|
||||||
|
playerColor="white"
|
||||||
|
onCheckComputerMove={() => {}}
|
||||||
|
resignationContext={{
|
||||||
|
trigger: Date.now(),
|
||||||
|
fen: game.fen(),
|
||||||
|
evaluation,
|
||||||
|
history: [],
|
||||||
|
result: 'Resignation',
|
||||||
|
winner: 'Black',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DebugProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sendMessage).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsContainResignation = sendMessage.mock.calls.some((call: any[]) =>
|
||||||
|
String(call[0]).includes('[SYSTEM TRIGGER: resignation]')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(callsContainResignation).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface Translations {
|
|||||||
stockfishStrength: string;
|
stockfishStrength: string;
|
||||||
depth: string;
|
depth: string;
|
||||||
undoMove: string;
|
undoMove: string;
|
||||||
|
resign: string;
|
||||||
|
resignation: string;
|
||||||
gameHistory: string;
|
gameHistory: string;
|
||||||
analyze: string;
|
analyze: string;
|
||||||
noMovesYet: string;
|
noMovesYet: string;
|
||||||
@@ -235,6 +237,8 @@ const en: Translations = {
|
|||||||
stockfishStrength: 'Stockfish Strength',
|
stockfishStrength: 'Stockfish Strength',
|
||||||
depth: 'Depth',
|
depth: 'Depth',
|
||||||
undoMove: 'Undo Last Move',
|
undoMove: 'Undo Last Move',
|
||||||
|
resign: 'Resign',
|
||||||
|
resignation: 'You resigned.',
|
||||||
gameHistory: 'Game History',
|
gameHistory: 'Game History',
|
||||||
analyze: 'Analyze',
|
analyze: 'Analyze',
|
||||||
noMovesYet: 'No moves yet.',
|
noMovesYet: 'No moves yet.',
|
||||||
@@ -403,6 +407,8 @@ const de: Translations = {
|
|||||||
stockfishStrength: 'Stockfish-Stärke',
|
stockfishStrength: 'Stockfish-Stärke',
|
||||||
depth: 'Tiefe',
|
depth: 'Tiefe',
|
||||||
undoMove: 'Letzten Zug rückgängig',
|
undoMove: 'Letzten Zug rückgängig',
|
||||||
|
resign: 'Aufgeben',
|
||||||
|
resignation: 'Du hast aufgegeben.',
|
||||||
gameHistory: 'Spielverlauf',
|
gameHistory: 'Spielverlauf',
|
||||||
analyze: 'Analysieren',
|
analyze: 'Analysieren',
|
||||||
noMovesYet: 'Noch keine Züge.',
|
noMovesYet: 'Noch keine Züge.',
|
||||||
@@ -571,6 +577,8 @@ const fr: Translations = {
|
|||||||
stockfishStrength: 'Force de Stockfish',
|
stockfishStrength: 'Force de Stockfish',
|
||||||
depth: 'Profondeur',
|
depth: 'Profondeur',
|
||||||
undoMove: 'Annuler le dernier coup',
|
undoMove: 'Annuler le dernier coup',
|
||||||
|
resign: 'Abandonner',
|
||||||
|
resignation: 'Vous avez abandonné.',
|
||||||
gameHistory: 'Historique de la partie',
|
gameHistory: 'Historique de la partie',
|
||||||
analyze: 'Analyser',
|
analyze: 'Analyser',
|
||||||
noMovesYet: 'Aucun coup pour le moment.',
|
noMovesYet: 'Aucun coup pour le moment.',
|
||||||
@@ -739,6 +747,8 @@ const it: Translations = {
|
|||||||
stockfishStrength: 'Forza di Stockfish',
|
stockfishStrength: 'Forza di Stockfish',
|
||||||
depth: 'Profondità',
|
depth: 'Profondità',
|
||||||
undoMove: 'Annulla ultima mossa',
|
undoMove: 'Annulla ultima mossa',
|
||||||
|
resign: 'Abbandona',
|
||||||
|
resignation: 'Hai abbandonato.',
|
||||||
gameHistory: 'Cronologia partita',
|
gameHistory: 'Cronologia partita',
|
||||||
analyze: 'Analizza',
|
analyze: 'Analizza',
|
||||||
noMovesYet: 'Nessuna mossa ancora.',
|
noMovesYet: 'Nessuna mossa ancora.',
|
||||||
@@ -907,6 +917,8 @@ const pl: Translations = {
|
|||||||
stockfishStrength: 'Siła Stockfish',
|
stockfishStrength: 'Siła Stockfish',
|
||||||
depth: 'Głębokość',
|
depth: 'Głębokość',
|
||||||
undoMove: 'Cofnij ruch',
|
undoMove: 'Cofnij ruch',
|
||||||
|
resign: 'Poddaj partię',
|
||||||
|
resignation: 'Poddano partię.',
|
||||||
gameHistory: 'Historia partii',
|
gameHistory: 'Historia partii',
|
||||||
analyze: 'Analizuj',
|
analyze: 'Analizuj',
|
||||||
noMovesYet: 'Brak ruchów.',
|
noMovesYet: 'Brak ruchów.',
|
||||||
|
|||||||
Reference in New Issue
Block a user