Add unified FEN/PGN import with auto-detection
- Create chessFormatDetector utility for automatic format detection - Update StartScreen with textarea supporting both FEN and PGN input - Add real-time format detection with visual feedback indicators - Update translations for all 4 languages (EN, DE, FR, IT) - Add markdown rendering for Tutor chat messages - Add comprehensive tests for format detection (20 tests) - Update game initialization to handle both FEN and PGN formats This completes the fix/stale-analysis-data branch with: - Fixed stale evaluation data in hint/best move requests - Clarified AI's dual role (opponent + tutor) to prevent hint rejection - Stored complete evaluation history (P0, P1, P2) for all moves - Improved end-game analysis with better mistake detection - Fixed duplicate analysis runs with useRef flag - Added markdown rendering for formatted analysis output - Added unified FEN/PGN import with auto-detection
This commit is contained in:
@@ -44,6 +44,7 @@ export default function Home() {
|
||||
personality: Personality;
|
||||
color: 'white' | 'black' | 'random';
|
||||
fen?: string;
|
||||
pgn?: string;
|
||||
}) => {
|
||||
const color = options.color === 'random'
|
||||
? (Math.random() < 0.5 ? 'white' : 'black')
|
||||
@@ -51,6 +52,7 @@ export default function Home() {
|
||||
|
||||
setGameProps({
|
||||
initialFen: options.fen,
|
||||
initialPgn: options.pgn,
|
||||
initialPersonality: options.personality,
|
||||
initialColor: color
|
||||
});
|
||||
|
||||
@@ -267,6 +267,9 @@ export default function ChessGame({ initialFen, initialPgn, initialPersonality,
|
||||
promotion: "q",
|
||||
};
|
||||
|
||||
// Capture FEN BEFORE player's move (P0)
|
||||
const fenP0 = gameRef.current.fen();
|
||||
|
||||
// 1. User Move (P0 -> P1)
|
||||
const moveResult = makeAMove(move);
|
||||
|
||||
@@ -285,46 +288,67 @@ export default function ChessGame({ initialFen, initialPgn, initialPersonality,
|
||||
// 2. Bot Move (P1 -> P2)
|
||||
stockfish.evaluate(fenP1, stockfishDepth).then(p1Eval => {
|
||||
if (evalP0) {
|
||||
const evalAfter = -p1Eval.score;
|
||||
const historyItem: MoveHistoryItem = {
|
||||
// We now have all data for the player's move, but we need to wait for computer's move
|
||||
// to complete the history item. Store partial data temporarily.
|
||||
const partialHistoryItem = {
|
||||
moveNumber: gameRef.current.moveNumber(),
|
||||
move: moveResult.result.san,
|
||||
evalBefore: evalP0.score,
|
||||
evalAfter: evalAfter,
|
||||
bestMove: evalP0.bestMove
|
||||
playerMove: moveResult.result.san,
|
||||
playerColor: playerColor,
|
||||
fenBeforePlayerMove: fenP0,
|
||||
evalBeforePlayerMove: evalP0,
|
||||
fenAfterPlayerMove: fenP1,
|
||||
evalAfterPlayerMove: p1Eval,
|
||||
};
|
||||
setMoveHistory(prev => [...prev, historyItem]);
|
||||
|
||||
setTimeout(() => {
|
||||
const computerMoveData = {
|
||||
from: p1Eval.bestMove.substring(0, 2),
|
||||
to: p1Eval.bestMove.substring(2, 4),
|
||||
promotion: p1Eval.bestMove.length > 4 ? p1Eval.bestMove.substring(4, 5) : "q"
|
||||
};
|
||||
|
||||
const compResult = makeAMove(computerMoveData);
|
||||
if (compResult) {
|
||||
setComputerMove(compResult.result);
|
||||
const { newFen: fenP2 } = compResult;
|
||||
|
||||
// 3. Post-Eval (P2)
|
||||
stockfish.evaluate(fenP2, stockfishDepth).then(p2Eval => {
|
||||
setEvalP2(p2Eval);
|
||||
|
||||
// 4. Opening Lookup
|
||||
const opening = lookupOpening(fenP2);
|
||||
setOpeningData(opening);
|
||||
|
||||
// 5. Complete the history item with computer's move data
|
||||
const completeHistoryItem: MoveHistoryItem = {
|
||||
...partialHistoryItem,
|
||||
computerMove: compResult.result.san,
|
||||
fenAfterComputerMove: fenP2,
|
||||
evalAfterComputerMove: p2Eval,
|
||||
opening: opening?.name,
|
||||
// Legacy fields for backward compatibility
|
||||
move: moveResult.result.san,
|
||||
evalBefore: evalP0.score,
|
||||
evalAfter: p1Eval.score,
|
||||
bestMove: evalP0.bestMove,
|
||||
};
|
||||
setMoveHistory(prev => [...prev, completeHistoryItem]);
|
||||
|
||||
setIsAnalyzing(false);
|
||||
}).catch(err => {
|
||||
console.error("P2 analysis failed:", err);
|
||||
setIsAnalyzing(false);
|
||||
});
|
||||
} else {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
// No evalP0 available - this shouldn't happen in normal gameplay
|
||||
console.warn("No P0 evaluation available for move history");
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const computerMoveData = {
|
||||
from: p1Eval.bestMove.substring(0, 2),
|
||||
to: p1Eval.bestMove.substring(2, 4),
|
||||
promotion: p1Eval.bestMove.length > 4 ? p1Eval.bestMove.substring(4, 5) : "q"
|
||||
};
|
||||
|
||||
const compResult = makeAMove(computerMoveData);
|
||||
if (compResult) {
|
||||
setComputerMove(compResult.result);
|
||||
const { newFen: fenP2 } = compResult;
|
||||
|
||||
// 3. Post-Eval (P2)
|
||||
stockfish.evaluate(fenP2, stockfishDepth).then(p2Eval => {
|
||||
setEvalP2(p2Eval);
|
||||
|
||||
// 4. Opening Lookup
|
||||
const opening = lookupOpening(fenP2);
|
||||
setOpeningData(opening);
|
||||
|
||||
setIsAnalyzing(false);
|
||||
}).catch(err => {
|
||||
console.error("P2 analysis failed:", err);
|
||||
setIsAnalyzing(false);
|
||||
});
|
||||
} else {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, 500);
|
||||
}).catch(err => {
|
||||
console.error("Bot move analysis failed:", err);
|
||||
setIsAnalyzing(false);
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { getGenAIModel } from "@/lib/gemini";
|
||||
import { Loader2, X, Trophy, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { StockfishEvaluation } from "@/lib/stockfish";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export interface MoveHistoryItem {
|
||||
moveNumber: number;
|
||||
move: string;
|
||||
evalBefore: number; // cp
|
||||
evalAfter: number; // cp
|
||||
bestMove?: string;
|
||||
|
||||
// Player's move data
|
||||
playerMove: string;
|
||||
playerColor: 'white' | 'black';
|
||||
fenBeforePlayerMove: string;
|
||||
evalBeforePlayerMove: StockfishEvaluation; // P0 - evaluation before player's move
|
||||
fenAfterPlayerMove: string;
|
||||
evalAfterPlayerMove: StockfishEvaluation; // P1 - evaluation after player's move
|
||||
|
||||
// Computer's move data
|
||||
computerMove: string;
|
||||
fenAfterComputerMove: string;
|
||||
evalAfterComputerMove: StockfishEvaluation; // P2 - evaluation after computer's move
|
||||
|
||||
// Opening info (optional)
|
||||
opening?: string;
|
||||
|
||||
// Analysis metadata (computed during game-over analysis)
|
||||
category?: 'inaccuracy' | 'mistake' | 'blunder';
|
||||
cpLoss?: number;
|
||||
|
||||
// Legacy fields for backward compatibility (deprecated)
|
||||
/** @deprecated Use playerMove instead */
|
||||
move?: string;
|
||||
/** @deprecated Use evalBeforePlayerMove.score instead */
|
||||
evalBefore?: number;
|
||||
/** @deprecated Use evalAfterPlayerMove.score instead */
|
||||
evalAfter?: number;
|
||||
/** @deprecated Use evalBeforePlayerMove.bestMove instead */
|
||||
bestMove?: string;
|
||||
}
|
||||
|
||||
interface GameOverModalProps {
|
||||
@@ -28,8 +54,15 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo
|
||||
const [analysis, setAnalysis] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [mistakes, setMistakes] = useState<MoveHistoryItem[]>([]);
|
||||
const hasAnalyzedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent duplicate analysis runs
|
||||
if (hasAnalyzedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasAnalyzedRef.current = true;
|
||||
|
||||
const analyzeGame = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -39,14 +72,52 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo
|
||||
// - Mistake: 100-300 centipawns loss
|
||||
// - Blunder: 300+ centipawns loss
|
||||
const detectedMistakes = history.map(item => {
|
||||
const delta = item.evalBefore - item.evalAfter; // Positive = eval got worse for player
|
||||
// Use new enhanced data if available, fall back to legacy fields
|
||||
let evalBefore: number;
|
||||
let evalAfter: number;
|
||||
let playerMove: string;
|
||||
let bestMove: string | undefined;
|
||||
|
||||
if (item.evalBeforePlayerMove && item.evalAfterPlayerMove) {
|
||||
// New enhanced format
|
||||
// Convert evaluations to player's perspective
|
||||
const isWhite = item.playerColor === 'white';
|
||||
|
||||
// P0: Before player's move (from player's perspective)
|
||||
evalBefore = isWhite ? item.evalBeforePlayerMove.score : -item.evalBeforePlayerMove.score;
|
||||
|
||||
// P1: After player's move (from opponent's perspective, so negate it)
|
||||
evalAfter = isWhite ? -item.evalAfterPlayerMove.score : item.evalAfterPlayerMove.score;
|
||||
|
||||
playerMove = item.playerMove;
|
||||
bestMove = item.evalBeforePlayerMove.bestMove;
|
||||
} else {
|
||||
// Legacy format (backward compatibility)
|
||||
evalBefore = item.evalBefore || 0;
|
||||
evalAfter = item.evalAfter || 0;
|
||||
playerMove = item.move || '';
|
||||
bestMove = item.bestMove;
|
||||
}
|
||||
|
||||
// Calculate centipawn loss
|
||||
// Positive delta = position got worse for player
|
||||
const delta = evalBefore - evalAfter;
|
||||
let category: 'inaccuracy' | 'mistake' | 'blunder' | null = null;
|
||||
|
||||
if (delta >= 300) category = 'blunder';
|
||||
else if (delta >= 100) category = 'mistake';
|
||||
else if (delta >= 50) category = 'inaccuracy';
|
||||
|
||||
return { ...item, category, cpLoss: delta };
|
||||
return {
|
||||
...item,
|
||||
category,
|
||||
cpLoss: delta,
|
||||
// Ensure legacy fields are populated for display
|
||||
move: playerMove,
|
||||
evalBefore: evalBefore,
|
||||
evalAfter: evalAfter,
|
||||
bestMove: bestMove,
|
||||
};
|
||||
}).filter(item => item.category !== null) as MoveHistoryItem[];
|
||||
|
||||
setMistakes(detectedMistakes);
|
||||
@@ -60,26 +131,58 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo
|
||||
const inaccuracies = detectedMistakes.filter(m => m.category === 'inaccuracy');
|
||||
|
||||
const mistakesText = detectedMistakes.map(m =>
|
||||
`Move ${m.moveNumber}: ${m.move} (${m.category?.toUpperCase()}: -${m.cpLoss}cp, eval ${m.evalBefore} → ${m.evalAfter}). Best: ${m.bestMove}`
|
||||
`Move ${m.moveNumber}: ${m.move} (${m.category?.toUpperCase()}: -${Math.round(m.cpLoss || 0)}cp loss, eval ${Math.round(m.evalBefore || 0)} → ${Math.round(m.evalAfter || 0)}). Best was: ${m.bestMove}`
|
||||
).join("\n");
|
||||
|
||||
const prompt = `
|
||||
You are a Chess Coach. The game is over.
|
||||
Result: ${result} (${winner === "Draw" ? "Draw" : winner + " Won"}).
|
||||
// Build a complete game narrative for better LLM analysis
|
||||
const gameNarrative = history.map((item, idx) => {
|
||||
const moveNum = item.moveNumber || idx + 1;
|
||||
const playerMv = item.playerMove || item.move || '?';
|
||||
const computerMv = item.computerMove || '?';
|
||||
const opening = item.opening ? ` [${item.opening}]` : '';
|
||||
|
||||
Player's Performance Summary:
|
||||
// Evaluation swing
|
||||
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}. ${playerMv} - ${computerMv}${opening}${evalInfo}`;
|
||||
}).join("\n");
|
||||
|
||||
const prompt = `
|
||||
You are a Chess Coach analyzing a completed game.
|
||||
|
||||
GAME RESULT: ${result} (${winner === "Draw" ? "Draw" : winner + " Won"})
|
||||
|
||||
PLAYER'S PERFORMANCE SUMMARY:
|
||||
- Blunders (300+ cp loss): ${blunders.length}
|
||||
- Mistakes (100-300 cp loss): ${mistakes.length}
|
||||
- Inaccuracies (50-100 cp loss): ${inaccuracies.length}
|
||||
- Total moves played: ${history.length}
|
||||
|
||||
${mistakesText ? `Detailed Mistakes:\n${mistakesText}` : "No significant mistakes detected - excellent play!"}
|
||||
${mistakesText ? `CRITICAL MISTAKES:\n${mistakesText}` : "No significant mistakes detected - excellent play!"}
|
||||
|
||||
COMPLETE GAME MOVES:
|
||||
${gameNarrative}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Briefly comment on the game result.
|
||||
2. If there were mistakes, explain WHY the worst ones were bad and what the player should have looked for (tactics, hanging pieces, positional errors, etc.).
|
||||
3. If no mistakes, praise the solid play and suggest areas for improvement.
|
||||
4. Be encouraging but educational. Focus on learning.
|
||||
5. Respond in ${language.toUpperCase()}.
|
||||
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 ${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).
|
||||
@@ -168,8 +271,23 @@ Plain text paragraph (2-3 sentences).
|
||||
<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 className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-gray-800 dark:text-gray-200 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
// Customize markdown rendering for better styling
|
||||
p: ({ children }) => <p className="mb-3 last:mb-0">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-bold text-gray-900 dark:text-white">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="ml-2">{children}</li>,
|
||||
h1: ({ children }) => <h1 className="text-xl font-bold mb-2 mt-4 first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-lg font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-base font-bold mb-2 mt-2 first:mt-0">{children}</h3>,
|
||||
}}
|
||||
>
|
||||
{analysis}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Settings, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Personality, PERSONALITIES } from "@/lib/personalities";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
import { SupportedLanguage } from "@/lib/i18n/translations";
|
||||
import { detectChessFormat, ChessFormat } from "@/lib/chessFormatDetector";
|
||||
import Header from "./Header";
|
||||
|
||||
interface StartScreenProps {
|
||||
@@ -13,6 +14,7 @@ interface StartScreenProps {
|
||||
personality: Personality;
|
||||
color: 'white' | 'black' | 'random';
|
||||
fen?: string;
|
||||
pgn?: string;
|
||||
}) => void;
|
||||
onResumeGame: () => void;
|
||||
hasSavedGame: boolean;
|
||||
@@ -22,7 +24,8 @@ export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }:
|
||||
const router = useRouter();
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('en');
|
||||
const [showNewGameOptions, setShowNewGameOptions] = useState(false);
|
||||
const [customFen, setCustomFen] = useState("");
|
||||
const [importInput, setImportInput] = useState("");
|
||||
const [detectedFormat, setDetectedFormat] = useState<ChessFormat | null>(null);
|
||||
const [colorSelection, setColorSelection] = useState<'white' | 'black' | 'random'>('white');
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -35,11 +38,21 @@ export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }:
|
||||
|
||||
const t = useTranslation(language);
|
||||
|
||||
const handleImportChange = (value: string) => {
|
||||
setImportInput(value);
|
||||
const format = detectChessFormat(value);
|
||||
setDetectedFormat(format);
|
||||
};
|
||||
|
||||
const handleNewGame = (personality: Personality) => {
|
||||
const trimmedInput = importInput.trim();
|
||||
const format = trimmedInput ? detectChessFormat(trimmedInput) : null;
|
||||
|
||||
onStartGame({
|
||||
personality,
|
||||
color: colorSelection,
|
||||
fen: customFen.trim() || undefined
|
||||
fen: format === 'fen' ? trimmedInput : undefined,
|
||||
pgn: format === 'pgn' ? trimmedInput : undefined
|
||||
});
|
||||
};
|
||||
|
||||
@@ -165,17 +178,47 @@ export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }:
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="mt-4 animate-in fade-in slide-in-from-top-2 space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t.start.importPosition}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<textarea
|
||||
placeholder={t.start.importPositionPlaceholder}
|
||||
value={customFen}
|
||||
onChange={(e) => setCustomFen(e.target.value)}
|
||||
className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
value={importInput}
|
||||
onChange={(e) => handleImportChange(e.target.value)}
|
||||
className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm focus:ring-2 focus:ring-blue-500 outline-none resize-vertical min-h-[80px]"
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
{/* Format Detection Indicator */}
|
||||
{importInput && (
|
||||
<div className="text-xs">
|
||||
{detectedFormat === 'fen' && (
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{t.start.formatDetected} {t.start.formatFen}
|
||||
</span>
|
||||
)}
|
||||
{detectedFormat === 'pgn' && (
|
||||
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{t.start.formatDetected} {t.start.formatPgn}
|
||||
</span>
|
||||
)}
|
||||
{detectedFormat === 'invalid' && (
|
||||
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{t.start.formatInvalid}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+82
-14
@@ -9,6 +9,7 @@ import { Send, Bot, User as UserIcon, Loader2, Lightbulb, Trophy } from "lucide-
|
||||
import clsx from "clsx";
|
||||
import { Personality } from "@/lib/personalities";
|
||||
import { OpeningMetadata } from "@/lib/openings";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
@@ -59,21 +60,30 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
|
||||
role: "user",
|
||||
parts: [{
|
||||
text: `
|
||||
You are a Chess Tutor.
|
||||
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}
|
||||
|
||||
INSTRUCTIONS:
|
||||
- You are the opponent (${tutorColorName}). You are playing against the User (${playerColorName}).
|
||||
- You are NOT an AI assistant analyzing a game. You ARE the player.
|
||||
- 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").
|
||||
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".
|
||||
- If the evaluation says you are winning, be confident/arrogant (depending on personality).
|
||||
- If you are losing, be frustrated/worried (depending on personality).
|
||||
- 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.
|
||||
@@ -83,7 +93,7 @@ INSTRUCTIONS:
|
||||
},
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ text: `Understood. I am the player (${tutorColorName}). I will speak in ${language} and never mention the engine.` }]
|
||||
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.` }]
|
||||
}
|
||||
],
|
||||
});
|
||||
@@ -230,10 +240,49 @@ React to this exchange as the player.
|
||||
const evaluation = await evaluateCurrentPosition();
|
||||
|
||||
if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) {
|
||||
finalPrompt = `[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:
|
||||
- Best Move: ${evaluation?.bestMove}
|
||||
- Evaluation: ${evaluation?.score} centipawns ${evaluation?.score > 0 ? '(White is better)' : evaluation?.score < 0 ? '(Black is better)' : '(Equal)'}
|
||||
- Mate in: ${evaluation?.mate || 'None'}
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Tell them the best move clearly (e.g., "The best move is e2-e4" or "You should play Nf3")
|
||||
- Explain WHY it's the best move (tactics, threats, positional ideas)
|
||||
- Stay in your personality style, but be HELPFUL and EDUCATIONAL
|
||||
- Do NOT refuse to help - teaching is your core role
|
||||
- Keep it concise but informative`;
|
||||
|
||||
finalPrompt = `[SYSTEM TRIGGER: exact_move]\nUser Question: ${text}\nData: Best Move: ${evaluation?.bestMove}, Score: ${evaluation?.score}, Mate: ${evaluation?.mate}`;
|
||||
} else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) {
|
||||
finalPrompt = `[SYSTEM TRIGGER: hint]\nUser Question: ${text}\nData: Best Move: ${evaluation?.bestMove}, Score: ${evaluation?.score}, Mate: ${evaluation?.mate}`;
|
||||
finalPrompt = `[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 - this is your primary purpose.
|
||||
Even though you are their opponent, teaching them is more important than winning.
|
||||
|
||||
User Question: ${text}
|
||||
|
||||
Current Position Data:
|
||||
- Best Move: ${evaluation?.bestMove}
|
||||
- Evaluation: ${evaluation?.score} centipawns ${evaluation?.score > 0 ? '(White is better)' : evaluation?.score < 0 ? '(Black is better)' : '(Equal)'}
|
||||
- Mate in: ${evaluation?.mate || 'None'}
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Give a HELPFUL hint without revealing the exact move (unless they specifically ask for it)
|
||||
- Point them toward what to look for: tactics, threats, piece placement, weaknesses
|
||||
- Examples: "Look at your knight on f3", "There's a tactic involving the bishop and queen", "Your king is vulnerable"
|
||||
- Stay in your personality style, but be HELPFUL and EDUCATIONAL
|
||||
- Do NOT refuse to help - teaching is your core role
|
||||
- Do NOT just say the move - guide them to find it themselves`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,12 +333,31 @@ React to this exchange as the player.
|
||||
{msg.role === "user" ? <UserIcon size={16} /> : personality.image}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"p-3 rounded-lg text-sm whitespace-pre-wrap",
|
||||
"p-3 rounded-lg text-sm",
|
||||
msg.role === "user"
|
||||
? "bg-blue-600 text-white rounded-tr-none"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-tl-none"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-tl-none prose prose-sm dark:prose-invert max-w-none"
|
||||
)}>
|
||||
{msg.text}
|
||||
{msg.role === "user" ? (
|
||||
<span className="whitespace-pre-wrap">{msg.text}</span>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-bold text-gray-900 dark:text-white">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-2 last:mb-0 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 last:mb-0 space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="ml-2">{children}</li>,
|
||||
code: ({ children }) => <code className="bg-gray-200 dark:bg-gray-600 px-1 py-0.5 rounded text-xs font-mono">{children}</code>,
|
||||
h1: ({ children }) => <h1 className="text-lg font-bold mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-base font-bold mb-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-bold mb-1">{children}</h3>,
|
||||
}}
|
||||
>
|
||||
{msg.text}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { detectChessFormat, parseChessNotation } from '../chessFormatDetector';
|
||||
|
||||
describe('chessFormatDetector', () => {
|
||||
describe('detectChessFormat', () => {
|
||||
describe('FEN detection', () => {
|
||||
it('should detect standard starting position FEN', () => {
|
||||
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||
expect(detectChessFormat(fen)).toBe('fen');
|
||||
});
|
||||
|
||||
it('should detect FEN with different position', () => {
|
||||
const fen = 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3';
|
||||
expect(detectChessFormat(fen)).toBe('fen');
|
||||
});
|
||||
|
||||
it('should detect FEN with black to move', () => {
|
||||
const fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1';
|
||||
expect(detectChessFormat(fen)).toBe('fen');
|
||||
});
|
||||
|
||||
it('should detect FEN with no castling rights', () => {
|
||||
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1';
|
||||
expect(detectChessFormat(fen)).toBe('fen');
|
||||
});
|
||||
|
||||
it('should detect FEN with partial castling rights', () => {
|
||||
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 0 1';
|
||||
expect(detectChessFormat(fen)).toBe('fen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PGN detection', () => {
|
||||
it('should detect PGN with headers', () => {
|
||||
const pgn = `[Event "Casual Game"]
|
||||
[Site "Chess Tutor"]
|
||||
[Date "2024.01.15"]
|
||||
[White "Player"]
|
||||
[Black "Stockfish"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 1-0`;
|
||||
expect(detectChessFormat(pgn)).toBe('pgn');
|
||||
});
|
||||
|
||||
it('should detect PGN with only moves (no headers)', () => {
|
||||
const pgn = '1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6';
|
||||
expect(detectChessFormat(pgn)).toBe('pgn');
|
||||
});
|
||||
|
||||
it('should detect PGN with castling moves', () => {
|
||||
const pgn = '1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. O-O';
|
||||
expect(detectChessFormat(pgn)).toBe('pgn');
|
||||
});
|
||||
|
||||
it('should detect PGN with long game', () => {
|
||||
const pgn = '1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6 6. Be3 e5 7. Nb3 Be6 8. f3 Be7 9. Qd2 O-O 10. O-O-O';
|
||||
expect(detectChessFormat(pgn)).toBe('pgn');
|
||||
});
|
||||
|
||||
it('should detect PGN with only headers', () => {
|
||||
const pgn = `[Event "Test"]
|
||||
[White "Player"]
|
||||
[Black "Computer"]`;
|
||||
expect(detectChessFormat(pgn)).toBe('pgn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid input detection', () => {
|
||||
it('should detect empty string as invalid', () => {
|
||||
expect(detectChessFormat('')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect whitespace-only string as invalid', () => {
|
||||
expect(detectChessFormat(' \n \t ')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect random text as invalid', () => {
|
||||
expect(detectChessFormat('this is not chess notation')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect incomplete FEN as invalid', () => {
|
||||
expect(detectChessFormat('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect FEN with wrong number of slashes as invalid', () => {
|
||||
expect(detectChessFormat('rnbqkbnr/pppppppp/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect FEN with invalid turn indicator as invalid', () => {
|
||||
expect(detectChessFormat('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1')).toBe('invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseChessNotation', () => {
|
||||
it('should parse valid FEN', () => {
|
||||
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||
const result = parseChessNotation(fen);
|
||||
expect(result).toEqual({
|
||||
format: 'fen',
|
||||
notation: fen
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse valid PGN', () => {
|
||||
const pgn = '1. e4 e5 2. Nf3 Nc6';
|
||||
const result = parseChessNotation(pgn);
|
||||
expect(result).toEqual({
|
||||
format: 'pgn',
|
||||
notation: pgn
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for invalid input', () => {
|
||||
const result = parseChessNotation('invalid chess notation');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const fen = ' rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 ';
|
||||
const result = parseChessNotation(fen);
|
||||
expect(result?.notation).toBe(fen.trim());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Detects whether a chess notation string is FEN or PGN format
|
||||
*/
|
||||
|
||||
export type ChessFormat = 'fen' | 'pgn' | 'invalid';
|
||||
|
||||
/**
|
||||
* Automatically detects if the input is a FEN position or PGN game
|
||||
*
|
||||
* FEN (Forsyth-Edwards Notation) structure:
|
||||
* - Single line with exactly 6 space-separated fields
|
||||
* - First field: piece placement with 7 slashes (8 ranks)
|
||||
* - Second field: active color ('w' or 'b')
|
||||
* - Example: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
*
|
||||
* PGN (Portable Game Notation) structure:
|
||||
* - Contains headers in square brackets: [Event "..."]
|
||||
* - Contains move numbers with periods: 1. e4 e5 2. Nf3
|
||||
* - Can be multi-line
|
||||
*
|
||||
* @param input - The chess notation string to detect
|
||||
* @returns 'fen' | 'pgn' | 'invalid'
|
||||
*/
|
||||
export function detectChessFormat(input: string): ChessFormat {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Empty input
|
||||
if (!trimmed) {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
// Check for PGN indicators (most distinctive)
|
||||
// PGN headers use square brackets: [Event "..."], [White "..."], etc.
|
||||
if (trimmed.includes('[') && trimmed.includes(']')) {
|
||||
return 'pgn';
|
||||
}
|
||||
|
||||
// Check for PGN movetext pattern (move numbers with periods)
|
||||
// Matches patterns like: "1. e4", "2. Nf3", "10. O-O"
|
||||
// This catches PGN files that might not have headers
|
||||
if (/\d+\.\s*[a-hNBRQKO]/.test(trimmed)) {
|
||||
return 'pgn';
|
||||
}
|
||||
|
||||
// Check for FEN structure
|
||||
// FEN must have exactly 6 space-separated fields
|
||||
const fields = trimmed.split(/\s+/);
|
||||
|
||||
if (fields.length === 6) {
|
||||
// First field should contain exactly 7 slashes (separating 8 ranks)
|
||||
const slashCount = (fields[0].match(/\//g) || []).length;
|
||||
|
||||
// Second field should be 'w' (white) or 'b' (black)
|
||||
const validTurn = fields[1] === 'w' || fields[1] === 'b';
|
||||
|
||||
// Third field should be castling rights (KQkq, -, or combinations)
|
||||
const validCastling = /^(-|[KQkq]{1,4})$/.test(fields[2]);
|
||||
|
||||
if (slashCount === 7 && validTurn && validCastling) {
|
||||
return 'fen';
|
||||
}
|
||||
}
|
||||
|
||||
// If none of the patterns match, it's invalid
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and extracts the chess notation based on detected format
|
||||
*
|
||||
* @param input - The chess notation string
|
||||
* @returns Object with format type and the cleaned notation, or null if invalid
|
||||
*/
|
||||
export function parseChessNotation(input: string): { format: 'fen' | 'pgn'; notation: string } | null {
|
||||
const format = detectChessFormat(input);
|
||||
|
||||
if (format === 'invalid') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
notation: input.trim()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface Translations {
|
||||
chooseCoach: string;
|
||||
importPosition: string;
|
||||
importPositionPlaceholder: string;
|
||||
formatDetected: string;
|
||||
formatFen: string;
|
||||
formatPgn: string;
|
||||
formatInvalid: string;
|
||||
apiKeyRequired: string;
|
||||
colorSelection: string;
|
||||
playAsWhite: string;
|
||||
@@ -123,8 +127,12 @@ const en: Translations = {
|
||||
resumeGame: 'Resume Previous Game',
|
||||
startNewGame: 'Start New Game instead...',
|
||||
chooseCoach: 'Choose Your Coach:',
|
||||
importPosition: 'Import Position (Optional FEN)',
|
||||
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
||||
importPosition: 'Import Position or Game (FEN or PGN)',
|
||||
importPositionPlaceholder: 'Paste FEN position or PGN game here...',
|
||||
formatDetected: 'Format detected:',
|
||||
formatFen: 'FEN Position',
|
||||
formatPgn: 'PGN Game',
|
||||
formatInvalid: 'Invalid format - please paste a valid FEN or PGN',
|
||||
apiKeyRequired: 'Please enter a valid API Key to continue.',
|
||||
colorSelection: 'Choose Your Color:',
|
||||
playAsWhite: 'Play as White',
|
||||
@@ -206,8 +214,12 @@ const de: Translations = {
|
||||
resumeGame: 'Vorheriges Spiel fortsetzen',
|
||||
startNewGame: 'Stattdessen neues Spiel starten...',
|
||||
chooseCoach: 'Wähle deinen Trainer:',
|
||||
importPosition: 'Position importieren (Optional FEN)',
|
||||
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
||||
importPosition: 'Position oder Partie importieren (FEN oder PGN)',
|
||||
importPositionPlaceholder: 'FEN-Position oder PGN-Partie hier einfügen...',
|
||||
formatDetected: 'Format erkannt:',
|
||||
formatFen: 'FEN-Position',
|
||||
formatPgn: 'PGN-Partie',
|
||||
formatInvalid: 'Ungültiges Format - bitte gültiges FEN oder PGN einfügen',
|
||||
apiKeyRequired: 'Bitte geben Sie einen gültigen API-Schlüssel ein, um fortzufahren.',
|
||||
colorSelection: 'Wähle deine Farbe:',
|
||||
playAsWhite: 'Als Weiß spielen',
|
||||
@@ -289,8 +301,12 @@ const fr: Translations = {
|
||||
resumeGame: 'Reprendre la partie précédente',
|
||||
startNewGame: 'Démarrer une nouvelle partie...',
|
||||
chooseCoach: 'Choisissez votre coach :',
|
||||
importPosition: 'Importer une position (FEN optionnel)',
|
||||
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
||||
importPosition: 'Importer une position ou partie (FEN ou PGN)',
|
||||
importPositionPlaceholder: 'Collez une position FEN ou partie PGN ici...',
|
||||
formatDetected: 'Format détecté :',
|
||||
formatFen: 'Position FEN',
|
||||
formatPgn: 'Partie PGN',
|
||||
formatInvalid: 'Format invalide - veuillez coller un FEN ou PGN valide',
|
||||
apiKeyRequired: 'Veuillez entrer une clé API valide pour continuer.',
|
||||
colorSelection: 'Choisissez votre couleur :',
|
||||
playAsWhite: 'Jouer Blancs',
|
||||
@@ -372,8 +388,12 @@ const it: Translations = {
|
||||
resumeGame: 'Riprendi partita precedente',
|
||||
startNewGame: 'Inizia nuova partita...',
|
||||
chooseCoach: 'Scegli il tuo allenatore:',
|
||||
importPosition: 'Importa posizione (FEN opzionale)',
|
||||
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
||||
importPosition: 'Importa posizione o partita (FEN o PGN)',
|
||||
importPositionPlaceholder: 'Incolla posizione FEN o partita PGN qui...',
|
||||
formatDetected: 'Formato rilevato:',
|
||||
formatFen: 'Posizione FEN',
|
||||
formatPgn: 'Partita PGN',
|
||||
formatInvalid: 'Formato non valido - incolla un FEN o PGN valido',
|
||||
apiKeyRequired: 'Inserisci una chiave API valida per continuare.',
|
||||
colorSelection: 'Scegli il tuo colore:',
|
||||
playAsWhite: 'Gioca Bianco',
|
||||
|
||||
Reference in New Issue
Block a user