feat: improve opening trainer flow and add deviation dialog

- Add tutor message guardrail to prevent rapid-fire messages
  - Track last message by move index
  - Wait for both player and opponent moves before speaking
  - Speak immediately when player deviates from theory

- Add deviation dialog with three options:
  - Continue Playing (Start Game) - transitions to game mode
  - Undo & Return to Opening - returns to theory
  - Explore This Variation - continues off-book practice

- Implement smooth game mode transition:
  - Pass opening context from trainer to game
  - Tutor welcomes player with context about their study
  - Computer makes first move if needed in starting position

- Fix generate-test-fixtures script:
  - Add safety check to prevent overwriting real data
  - Restore full opening database (12,379 openings)
  - Rebuild move index (12,377 sequences)
  - Add clear warnings about test fixtures

- Update ChessGame to accept openingContext prop
- Update Tutor to display contextual greeting in game mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Stefan
2025-12-08 15:05:05 +01:00
parent 1e78d8114e
commit 8ed402bbe0
34 changed files with 111269 additions and 480 deletions
+140 -37
View File
@@ -45,6 +45,13 @@ interface TutorProps {
result: string;
winner: 'White' | 'Black' | 'Draw';
} | null;
openingContext?: {
openingName: string;
openingEco: string;
movesCompleted: number;
wikipediaSummary?: string;
contextMessage: string;
};
tacticalPracticeMode?: {
patternName: string;
solutionMove: { from: string; to: string; promotion?: string };
@@ -73,6 +80,8 @@ interface TutorProps {
theoreticalAlternatives: string[];
} | null;
wikipediaSummary?: string; // Optional Wikipedia context
shouldTutorSpeak?: boolean; // Guardrail: controls when tutor can send messages
onTutorMessageSent?: () => void; // Callback when tutor sends a message
};
}
@@ -82,7 +91,7 @@ interface Message {
timestamp: number;
}
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode, openingPracticeMode }: TutorProps) {
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, openingContext, tacticalPracticeMode, openingPracticeMode }: TutorProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
@@ -102,6 +111,11 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
const patternName = tacticalPracticeMode?.patternName;
const solutionMoveKey = tacticalPracticeMode ? `${tacticalPracticeMode.solutionMove.from}-${tacticalPracticeMode.solutionMove.to}` : null;
// Extract stable values from openingPracticeMode to avoid recreating chat
const openingName = openingPracticeMode?.openingName;
const openingEco = openingPracticeMode?.openingEco;
const wikipediaSummary = openingPracticeMode?.wikipediaSummary;
// Track the current puzzle to detect when it changes
const currentPuzzleRef = useRef<string | null>(null);
@@ -115,15 +129,15 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
// Build system prompt based on mode
const systemPrompt = openingPracticeMode ? `
You are a Chess Tutor helping a student learn the "${openingPracticeMode.openingName}" opening.
const systemPrompt = openingName ? `
You are a Chess Tutor helping a student learn the "${openingName}" opening.
You must strictly follow the personality defined below.
PERSONALITY:
${personality.systemPrompt}
${openingPracticeMode.wikipediaSummary ? `OPENING BACKGROUND (from Wikipedia):
${openingPracticeMode.wikipediaSummary}
${wikipediaSummary ? `OPENING BACKGROUND (from Wikipedia):
${wikipediaSummary}
Use this background to enrich your explanations, but keep responses concise.
` : ''}
@@ -131,7 +145,7 @@ Use this background to enrich your explanations, but keep responses concise.
YOUR ROLE:
You are BOTH the opponent AND the tutor in this opening training session.
1. OPPONENT: You are playing as ${tutorColorName} in the ${openingPracticeMode.openingName}.
1. OPPONENT: You are playing as ${tutorColorName} in the ${openingName}.
- You will make moves from the opening repertoire
- Refer to your moves naturally ("I played e5", "My response is...")
@@ -143,7 +157,7 @@ You are BOTH the opponent AND the tutor in this opening training session.
- When the student deviates, explain why the repertoire move is better
YOUR RESPONSIBILITIES:
1. WELCOME: Start with a warm greeting and brief explanation of the ${openingPracticeMode.openingName}
1. WELCOME: Start with a warm greeting and brief explanation of the ${openingName}
2. GUIDANCE: After each move, explain the ideas and plans
3. ENCOURAGEMENT: Keep the student motivated while learning
4. DEVIATION HANDLING: When the student leaves theory, gently correct them
@@ -226,8 +240,8 @@ CRITICAL RULES:
},
{
role: "model",
parts: [{ text: openingPracticeMode
? `Understood. I will teach you the ${openingPracticeMode.openingName} opening in ${language}. I am both your opponent and your tutor. I'll explain the ideas behind each move and help you learn this opening.`
parts: [{ text: openingName
? `Understood. I will teach you the ${openingName} opening in ${language}. I am both your opponent and your tutor. I'll explain the ideas behind each move and help you learn this opening.`
: tacticalPracticeMode
? `Understood. I will help you practice ${tacticalPracticeMode.patternName} in ${language}. I'll provide hints and encouragement while maintaining my personality.`
: `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.`
@@ -238,17 +252,29 @@ CRITICAL RULES:
setChatSession(session);
// Get initial greeting in the selected language
const greetingPrompt = openingPracticeMode
? `Welcome the student to learn the ${openingPracticeMode.openingName}. Briefly explain the key ideas of this opening (in 2-3 sentences).
const greetingPrompt = openingName
? `Welcome the student to learn the ${openingName}.
IMPORTANT:
${wikipediaSummary ? `OPENING CONTEXT (from Wikipedia):
${wikipediaSummary}
Use this information to:
- Briefly explain the opening's historical background or origin
- Mention any interesting anecdotes or notable players associated with it
- Explain the main strategic ideas and goals
` : `Since no Wikipedia information is available:
- Focus on the opening's main strategic ideas and goals
- Explain what this opening aims to accomplish
- Don't just list moves - explain the underlying concepts
`}
IMPORTANT GAME SETUP:
- Clarify that YOU are playing as ${tutorColorName} and the STUDENT is playing as ${playerColorName}
- If the student is White, make it clear THEY will make the first move, not you
- If the student is Black, explain you'll make the first move and then they'll respond
- Don't claim you'll make a move that the student should be making
- Be encouraging and clear about the game flow
- Be encouraging and welcoming
Keep it in ${language}.`
Keep your response to 3-4 sentences, be engaging, and respond in ${language}.`
: tacticalPracticeMode
? `Welcome the student to practice ${tacticalPracticeMode.patternName}. Briefly explain what this tactical pattern is (in 1-2 sentences). Keep it encouraging and in ${language}.`
: `Introduce yourself briefly to start our game. Keep it short and in ${language}.`;
@@ -266,15 +292,15 @@ Keep it in ${language}.`
}
// Fallback greeting
const fallbackText = openingPracticeMode
? `Hello! Let's learn the ${openingPracticeMode.openingName} together!`
const fallbackText = openingName
? `Hello! Let's learn the ${openingName} together!`
: tacticalPracticeMode
? `Hello! Let's practice ${tacticalPracticeMode.patternName} together!`
: `Hello! I am ${personality.name}. Let's play!`;
setMessages([{ role: "model", text: fallbackText, timestamp: Date.now() }]);
});
}
}, [apiKey, personality, language, playerColor, patternName, openingPracticeMode]);
}, [apiKey, personality, language, playerColor, patternName, openingName, wikipediaSummary]);
// NOTE: Removed solutionMoveKey from dependencies - we don't want to reset chat when puzzle changes
// Notify tutor about new puzzle (without resetting chat)
@@ -326,15 +352,30 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
});
}, [solutionMoveKey, chatSession, tacticalPracticeMode, currentFen, language]);
// Extract stable values for opening practice commentary
const lastUserMoveSan = openingPracticeMode?.lastUserMove?.san;
const lastTutorMoveSan = openingPracticeMode?.lastTutorMove?.san;
const currentMoveIndex = openingPracticeMode?.currentMoveIndex ?? 0;
const isInTheory = openingPracticeMode?.isInTheory ?? true;
const currentFeedback = openingPracticeMode?.currentFeedback;
const repertoireMovesLength = openingPracticeMode?.repertoireMoves?.length ?? 0;
// Automatic commentary for opening practice mode
useEffect(() => {
if (!chatSession || !openingPracticeMode) return;
if (!chatSession || !openingName) return;
const userMoveKey = openingPracticeMode.lastUserMove
? `${openingPracticeMode.lastUserMove.san}-${openingPracticeMode.currentMoveIndex}`
// Guardrail: Check if tutor is allowed to speak
const shouldSpeak = openingPracticeMode?.shouldTutorSpeak ?? true;
if (!shouldSpeak) {
console.log('[Tutor] Guardrail: Not allowed to speak yet');
return;
}
const userMoveKey = lastUserMoveSan
? `${lastUserMoveSan}-${currentMoveIndex}`
: null;
const tutorMoveKey = openingPracticeMode.lastTutorMove
? `${openingPracticeMode.lastTutorMove.san}-${openingPracticeMode.currentMoveIndex}`
const tutorMoveKey = lastTutorMoveSan
? `${lastTutorMoveSan}-${currentMoveIndex}`
: null;
// Check if user made a new move
@@ -342,18 +383,17 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
lastUserMoveRef.current = userMoveKey;
// Generate commentary about user's move
const feedback = openingPracticeMode.currentFeedback;
const moveCommentary = `
[SYSTEM TRIGGER: user_move_in_opening]
The student just played: ${openingPracticeMode.lastUserMove!.san}
Move category: ${feedback?.category || 'unknown'}
Position status: ${openingPracticeMode.isInTheory ? 'In theory' : 'Deviated from repertoire'}
${feedback?.evaluationChange !== undefined ? `Evaluation change: ${feedback.evaluationChange.toFixed(2)}` : ''}
${feedback?.theoreticalAlternatives && feedback.theoreticalAlternatives.length > 0 ? `Theory suggested: ${feedback.theoreticalAlternatives.join(', ')}` : ''}
The student just played: ${lastUserMoveSan}
Move category: ${currentFeedback?.category || 'unknown'}
Position status: ${isInTheory ? 'In theory' : 'Deviated from repertoire'}
${currentFeedback?.evaluationChange !== undefined ? `Evaluation change: ${currentFeedback.evaluationChange.toFixed(2)}` : ''}
${currentFeedback?.theoreticalAlternatives && currentFeedback.theoreticalAlternatives.length > 0 ? `Theory suggested: ${currentFeedback.theoreticalAlternatives.join(', ')}` : ''}
INSTRUCTIONS:
${openingPracticeMode.isInTheory
${isInTheory
? `- The student is following the repertoire correctly - praise them briefly
- Explain the key idea behind this move (1-2 sentences)
- If you're about to make the next move, you can mention it naturally`
@@ -369,6 +409,9 @@ ${openingPracticeMode.isInTheory
chatSession.sendMessage(moveCommentary).then(result => {
const response = result.response.text();
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
// Notify parent that tutor sent a message
openingPracticeMode?.onTutorMessageSent?.();
}).catch(err => {
console.error("Failed to generate user move commentary:", err);
if (isGeminiError(err)) {
@@ -385,9 +428,9 @@ ${openingPracticeMode.isInTheory
const tutorCommentary = `
[SYSTEM TRIGGER: tutor_move_in_opening]
I just played: ${openingPracticeMode.lastTutorMove!.san}
I just played: ${lastTutorMoveSan}
Current position FEN: ${currentFen}
Progress: ${openingPracticeMode.currentMoveIndex}/${openingPracticeMode.repertoireMoves.length} moves
Progress: ${currentMoveIndex}/${repertoireMovesLength} moves
INSTRUCTIONS:
- Explain WHY you played this move (the idea behind it)
@@ -405,6 +448,9 @@ Remember: You are both the opponent AND the tutor. Explain your move as if you'r
chatSession.sendMessage(tutorCommentary).then(result => {
const response = result.response.text();
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
// Notify parent that tutor sent a message
openingPracticeMode?.onTutorMessageSent?.();
}).catch(err => {
console.error("Failed to generate tutor move commentary:", err);
if (isGeminiError(err)) {
@@ -415,12 +461,15 @@ Remember: You are both the opponent AND the tutor. Explain your move as if you'r
}
}, [
chatSession,
openingPracticeMode?.lastUserMove?.san,
openingPracticeMode?.lastTutorMove?.san,
openingPracticeMode?.currentMoveIndex,
openingPracticeMode?.isInTheory,
lastUserMoveSan,
lastTutorMoveSan,
currentMoveIndex,
isInTheory,
currentFen,
language
language,
openingName,
currentFeedback,
repertoireMovesLength
]);
// Scroll chat container to bottom (not the whole page)
@@ -863,6 +912,60 @@ INSTRUCTIONS:
handleResignationMessage();
}, [chatSession, language, personality.name, resignationContext?.trigger, resignationContext?.evaluation, resignationContext?.fen, resignationContext?.result, resignationContext?.winner, stockfish]);
// Opening Context Message (when transitioning from opening trainer to game mode)
useEffect(() => {
const handleOpeningContextMessage = async () => {
if (!openingContext || !chatSession) return;
// Only send this message once when the context is first loaded
// We can check if messages array is still just the greeting
if (messages.length > 1) return;
setIsLoading(true);
try {
let evaluation = null;
if (stockfish) {
evaluation = await stockfish.evaluate(currentFen, 15);
}
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: opening_training_transition]
The student has just transitioned from opening training to a real game.
OPENING TRAINING CONTEXT:
- Opening Studied: ${openingContext.openingName} (${openingContext.openingEco})
- Moves Completed in Training: ${openingContext.movesCompleted}
${openingContext.wikipediaSummary ? `- Opening Background: ${openingContext.wikipediaSummary}` : ''}
CURRENT POSITION:
- FEN: ${currentFen}
- ENGINE EVALUATION: White ${whiteEval}, Black ${blackEval}
INSTRUCTIONS:
- Welcome the student to the game continuation
- Acknowledge that they've studied the ${openingContext.openingName} up to move ${openingContext.movesCompleted}
- Briefly mention what to focus on next (based on the opening's typical plans)
- Encourage them to apply what they've learned
- Keep it concise (3-4 sentences max)
- Respond in ${language.toUpperCase()}
- Stay in your personality (${personality.name})
`.trim();
await sendMessageToChat(prompt, true);
} catch (error) {
console.error("Failed to send opening context message", error);
} finally {
setIsLoading(false);
}
};
handleOpeningContextMessage();
}, [chatSession, openingContext, stockfish, currentFen, language, personality.name]);
if (!apiKey) return null;
return (