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:
Stefan
2025-11-25 17:54:25 +01:00
parent 4d5ec5ecc3
commit cd6cab367a
10 changed files with 1736 additions and 115 deletions
+1160 -27
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
"react": "19.2.0",
"react-chessboard": "^5.8.4",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"stockfish.js": "^10.0.2",
"tailwind-merge": "^3.4.0"
},
+2
View File
@@ -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
});
+61 -37
View File
@@ -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);
+138 -20
View File
@@ -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>
</>
+51 -8
View File
@@ -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
View File
@@ -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());
});
});
});
+86
View File
@@ -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()
};
}
+28 -8
View File
@@ -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',