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
+33 -17
View File
@@ -25,6 +25,13 @@ interface ChessGameProps {
initialPersonality: Personality;
initialColor: 'white' | 'black';
initialStockfishDepth?: number;
openingContext?: {
openingName: string;
openingEco: string;
movesCompleted: number;
wikipediaSummary?: string;
contextMessage: string;
};
onBack: () => void;
}
@@ -37,7 +44,7 @@ const PIECE_VALUES: Record<string, number> = {
'k': 0
};
export default function ChessGame({ gameId, initialFen, initialPgn, initialPersonality, initialColor, initialStockfishDepth, onBack }: ChessGameProps) {
export default function ChessGame({ gameId, initialFen, initialPgn, initialPersonality, initialColor, initialStockfishDepth, openingContext, onBack }: ChessGameProps) {
const gameRef = useRef(new Chess(initialFen || "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"));
const [fen, setFen] = useState(gameRef.current.fen());
const [stockfish, setStockfish] = useState<Stockfish | null>(null);
@@ -265,23 +272,31 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
}
}
// If computer is white (player is black) and it's the start of the game, make a move
// But only if we are at the start position
if (initialColor === 'black' &&
gameRef.current.fen() === "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" &&
stockfish) {
// Check if it's the computer's turn and make a move if needed
// This handles both:
// 1. Standard new games where computer plays first (player is black)
// 2. Games starting from opening trainer with custom FEN where it might be computer's turn
if (stockfish && gameRef.current.history().length === 0) {
// No moves have been made yet - check whose turn it is
const currentTurn = gameRef.current.turn(); // 'w' or 'b'
const computerTurn = initialColor === 'white' ? 'b' : 'w';
// Small delay to ensure stockfish is ready
setTimeout(() => {
stockfish.evaluate(gameRef.current.fen(), 10).then(evalResult => {
const computerMoveData = {
from: evalResult.bestMove.substring(0, 2),
to: evalResult.bestMove.substring(2, 4),
promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q"
};
makeAMove(computerMoveData);
});
}, 1000);
if (currentTurn === computerTurn) {
console.log('[ChessGame] Initial position - computer\'s turn, making move...');
// Small delay to ensure stockfish is ready
setTimeout(() => {
stockfish.evaluate(gameRef.current.fen(), 10).then(evalResult => {
const computerMoveData = {
from: evalResult.bestMove.substring(0, 2),
to: evalResult.bestMove.substring(2, 4),
promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q"
};
makeAMove(computerMoveData);
}).catch(err => {
console.error('[ChessGame] Failed to make initial computer move:', err);
});
}, 1000);
}
}
}, [initialFen, initialColor, stockfish]); // Run when these change
@@ -834,6 +849,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
playerColor={playerColor}
onCheckComputerMove={checkAndMakeComputerMove}
resignationContext={resignationContext}
openingContext={openingContext}
/>
</div>
@@ -0,0 +1,73 @@
'use client';
interface DeviationDialogProps {
openingName: string;
movesCompleted: number;
onUndo: () => void;
onStartGame: () => void;
onContinueExploring?: () => void;
}
export default function DeviationDialog({
openingName,
movesCompleted,
onUndo,
onStartGame,
onContinueExploring,
}: DeviationDialogProps) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-md mx-4">
<div className="text-center mb-6">
<div className="text-4xl mb-4">🤔</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
You've Left the Opening!
</h2>
<p className="text-gray-600 dark:text-gray-400">
You've studied <span className="font-semibold">{movesCompleted}</span> move
{movesCompleted !== 1 ? 's' : ''} of the{' '}
<span className="font-semibold">{openingName}</span>.
</p>
<p className="text-gray-600 dark:text-gray-400 mt-2">
This move isn't in your repertoire. What would you like to do?
</p>
</div>
<div className="space-y-3">
{/* Primary action: Start game */}
<button
onClick={onStartGame}
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors flex items-center justify-center gap-2"
>
<span></span>
<span>Continue Playing (Start Game)</span>
</button>
{/* Secondary action: Undo */}
<button
onClick={onUndo}
className="w-full px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition-colors flex items-center justify-center gap-2"
>
<span></span>
<span>Undo & Return to Opening</span>
</button>
{/* Optional: Continue exploring */}
{onContinueExploring && (
<button
onClick={onContinueExploring}
className="w-full px-6 py-3 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-medium transition-colors border border-gray-300 dark:border-gray-600 flex items-center justify-center gap-2"
>
<span>🔍</span>
<span>Explore This Variation</span>
</button>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-4 text-center">
Tip: You can always navigate back using the move history
</p>
</div>
</div>
);
}
+141 -16
View File
@@ -1,19 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { Chess } from 'chess.js';
import { Chessboard } from 'react-chessboard';
import { OpeningMetadata } from '@/lib/openings';
import { useOpeningTraining } from '@/contexts/OpeningTrainingContext';
import { useOpeningTraining, useChessInstance } from '@/contexts/OpeningTrainingContext';
import { loadSession } from '@/lib/openingTrainer/sessionManager';
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/repertoireNavigation';
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/gameLogic';
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
import WikipediaSummary from './WikipediaSummary';
import DeviationDialog from './DeviationDialog';
import { Tutor } from '@/components/Tutor';
import { Personality } from '@/lib/personalities';
import { SupportedLanguage } from '@/lib/i18n/translations';
import { useRouter } from 'next/navigation';
interface OpeningTrainerProps {
opening: OpeningMetadata;
@@ -23,9 +25,10 @@ interface OpeningTrainerProps {
}
export default function OpeningTrainer({ opening, personality, apiKey, language }: OpeningTrainerProps) {
const router = useRouter();
const {
session,
chess,
stockfish,
currentFeedback,
initializeSession,
@@ -33,6 +36,9 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
navigateToMove,
} = useOpeningTraining();
// Get Chess instance on-demand from current FEN
const chess = useChessInstance(session);
const [boardOrientation, setBoardOrientation] = useState<'white' | 'black'>(
'white'
);
@@ -42,6 +48,51 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
const [existingSession, setExistingSession] = useState<any>(null);
const [wikipediaSummary, setWikipediaSummary] = useState<WikipediaSummaryType | null>(null);
// Tutor message control - track when tutor last spoke
const [lastTutorMessageMoveIndex, setLastTutorMessageMoveIndex] = useState<number>(-1);
// Deviation handling
const [showDeviationDialog, setShowDeviationDialog] = useState(false);
// ============================================================================
// Tutor Message Guardrail (computed values - must be before early returns)
// ============================================================================
const moveCount = session?.moveHistory.length ?? 0;
const userColor = getUserColor(opening);
// Determine when tutor should be allowed to speak
const shouldTutorSpeak = useMemo(() => {
if (!session || moveCount === 0) {
// At start, tutor can give initial greeting
return lastTutorMessageMoveIndex === -1;
}
// Check if we've had new moves since tutor last spoke
const newMovesSinceLastMessage = moveCount - lastTutorMessageMoveIndex;
if (session.deviationMoveIndex !== null) {
// Off-book: Tutor speaks immediately after player's deviation
return newMovesSinceLastMessage >= 1;
}
// In theory: Wait for both player AND opponent to move
const isAtEndOfRepertoire = session.phase === 'end_of_repertoire';
const movesNeeded = isAtEndOfRepertoire ? 1 : 2;
return newMovesSinceLastMessage >= movesNeeded;
}, [session, moveCount, lastTutorMessageMoveIndex]);
// Detect deviation and show dialog
useEffect(() => {
if (session?.deviationMoveIndex !== null && !showDeviationDialog) {
const timer = setTimeout(() => {
setShowDeviationDialog(true);
}, 500);
return () => clearTimeout(timer);
}
}, [session?.deviationMoveIndex, showDeviationDialog]);
useEffect(() => {
checkForExistingSession();
}, [opening.eco]);
@@ -120,12 +171,19 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
const testChess = new Chess();
testChess.loadPgn(chess.pgn());
// Try to make the move on the clone
const move = testChess.move({
from: sourceSquare,
to: targetSquare,
promotion: 'q', // Always promote to queen for simplicity
});
// Try to make the move on the clone (this can throw for invalid moves)
let move;
try {
move = testChess.move({
from: sourceSquare,
to: targetSquare,
promotion: 'q', // Always promote to queen for simplicity
});
} catch (moveError) {
// Invalid move format or illegal move - silently reject
console.log('Invalid move attempt:', { from: sourceSquare, to: targetSquare });
return false;
}
if (move === null) {
// Illegal move
@@ -136,11 +194,69 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
makeMove(move.san);
return true;
} catch (error) {
console.error('Move error:', error);
console.error('Unexpected error in handlePieceDrop:', error);
return false;
}
};
// ============================================================================
// Deviation Dialog Handlers
// ============================================================================
const handleUndoDeviation = () => {
if (!session || session.deviationMoveIndex === null) return;
// Navigate back to the move before deviation
navigateToMove(session.deviationMoveIndex - 1);
setShowDeviationDialog(false);
// After a brief delay, make another legal move to continue in theory
// This allows the player to try again
};
const handleStartGameFromPosition = () => {
if (!session || !chess) return;
// Store the game start data in localStorage with the expected key
// The home page (/) will pick this up and start the game
const gameStartData = {
fen: session.currentFEN,
personalityId: personality.id,
color: getUserColor(opening),
stockfishDepth: 15,
};
localStorage.setItem('chess_tutor_pending_game', JSON.stringify(gameStartData));
// Optional: Store additional context for the tutor
const openingContext = {
openingName: opening.name,
openingEco: opening.eco,
movesCompleted: session.deviationMoveIndex || session.moveHistory.length,
wikipediaSummary: wikipediaSummary?.extract,
contextMessage: `You've studied the ${opening.name} (${opening.eco}) up to move ${
session.deviationMoveIndex || session.moveHistory.length
}. Let's continue playing from here!`,
};
localStorage.setItem('chess_tutor_opening_context', JSON.stringify(openingContext));
// Navigate to home page which will start the game
router.push('/');
};
const handleContinueExploring = () => {
// User wants to continue exploring off-book moves
// Just close the dialog and let them continue
setShowDeviationDialog(false);
};
const handleTutorMessageSent = () => {
// Called when tutor successfully sends a message
// Update the tracking to prevent rapid-fire messages
setLastTutorMessageMoveIndex(moveCount);
};
// Session recovery dialog
if (showRecoveryDialog && existingSession) {
return (
@@ -228,10 +344,6 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
}
const currentPosition = chess.fen();
const moveCount = session.moveHistory.length;
// Determine user's color based on opening ECO code
const userColor = getUserColor(opening);
// Build opening practice mode prop for Tutor
const repertoireMoves = parseMoveSequence(opening.moves);
@@ -285,7 +397,9 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
evaluationChange: currentFeedback.classification.evaluationChange,
theoreticalAlternatives: currentFeedback.classification.theoreticalAlternatives
} : null,
wikipediaSummary: wikipediaSummary?.extract || undefined
wikipediaSummary: wikipediaSummary?.extract || undefined,
shouldTutorSpeak,
onTutorMessageSent: handleTutorMessageSent,
};
return (
@@ -466,6 +580,17 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
)}
</div>
</div>
{/* Deviation Dialog */}
{showDeviationDialog && session?.deviationMoveIndex !== null && (
<DeviationDialog
openingName={opening.name}
movesCompleted={session.deviationMoveIndex}
onUndo={handleUndoDeviation}
onStartGame={handleStartGameFromPosition}
onContinueExploring={handleContinueExploring}
/>
)}
</div>
);
}
+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 (