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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user