Files
chess-project/src/components/OpeningTrainer/OpeningTrainer.tsx
T
Claude f8cf03c888 Simplify opening training: train on entire opening family instead of single variation
- Add family training mode where users select an opening family (e.g. "Italian Game")
  and can play any variation within that family
- Create variation tree data structure to efficiently track which variations
  match the current move sequence
- FamilySelector now navigates directly to /learning/openings/family/[familyName]
  instead of showing a second selector for individual variations
- OpeningTrainer displays matching variations, possible moves, and current line
- LLM tutor explains which variation is being played and mentions alternatives
- Remove the OpeningSelector step from the flow for a simpler UX

The new flow:
1. User selects an opening family (e.g. "Sicilian Defense")
2. Training starts immediately with all variations loaded
3. User can play any move that exists in any variation
4. The UI shows which variations are still possible
5. The AI tutor guides the user through the repertoire
2026-01-03 21:52:36 +00:00

724 lines
27 KiB
TypeScript

'use client';
import { useEffect, useState, useMemo } from 'react';
import { Chess } from 'chess.js';
import { Chessboard } from 'react-chessboard';
import { OpeningMetadata } from '@/lib/openings';
import { useOpeningTraining, useChessInstance } from '@/contexts/OpeningTrainingContext';
import { loadSession } from '@/lib/openingTrainer/sessionManager';
import {
parseMoveSequence,
getUserColor,
VariationTree,
getAllPossibleNextMoves,
identifyCurrentVariation,
isMoveInVariationTree,
describeCurrentPosition,
} 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;
personality: Personality;
apiKey: string;
language: SupportedLanguage;
// Family training mode - allows multiple variations
variationTree?: VariationTree;
allVariations?: OpeningMetadata[];
}
export default function OpeningTrainer({
opening,
personality,
apiKey,
language,
variationTree,
allVariations,
}: OpeningTrainerProps) {
const router = useRouter();
const isFamilyMode = !!variationTree && !!allVariations;
const {
session,
stockfish,
currentFeedback,
initializeSession,
makeMove,
navigateToMove,
} = useOpeningTraining();
// Get Chess instance on-demand from current FEN
const chess = useChessInstance(session);
const [boardOrientation, setBoardOrientation] = useState<'white' | 'black'>(
'white'
);
const [isInitializing, setIsInitializing] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
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]);
// Fetch Wikipedia summary for the opening (using family name for better results)
useEffect(() => {
const fetchWikipediaSummary = async () => {
try {
// Extract family name (e.g., "French Defense" from "French Defense: Exchange Variation")
// This ensures we find the Wikipedia article for the main opening, not specific variations
const familyName = extractFamilyName(opening.name);
console.log(`[Wikipedia] Looking up: "${familyName}" (from "${opening.name}")`);
const summary = await getWikipediaSummary(familyName);
setWikipediaSummary(summary);
} catch (err) {
console.error('Failed to fetch Wikipedia summary:', err);
// Silently fail - Wikipedia is nice-to-have, not critical
}
};
fetchWikipediaSummary();
}, [opening.name]);
const checkForExistingSession = () => {
const saved = loadSession(opening.eco);
if (saved && saved.moveHistory.length > 0) {
// Found existing session with moves
setExistingSession(saved);
setShowRecoveryDialog(true);
setIsInitializing(false);
} else {
// No existing session or empty session - start fresh
initSession(false);
}
};
const initSession = async (forceNew: boolean = false) => {
setIsInitializing(true);
setError(null);
setShowRecoveryDialog(false);
try {
await initializeSession(opening, forceNew);
// Determine board orientation from opening
// ECO D and E are typically Black defenses
const orientation = ['D', 'E'].includes(opening.eco[0]) ? 'black' : 'white';
setBoardOrientation(orientation);
} catch (err) {
console.error('Session initialization error:', err);
setError('Failed to initialize training session');
} finally {
setIsInitializing(false);
}
};
const handleResumeSession = () => {
initSession(false);
};
const handleStartFresh = () => {
initSession(true);
};
const handlePieceDrop = (
sourceSquare: string,
targetSquare: string
): boolean => {
if (!chess) return false;
try {
// Create a temporary clone to test if the move is legal
// WITHOUT modifying the actual chess instance
const testChess = new Chess();
testChess.loadPgn(chess.pgn());
// 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
return false;
}
// Move was legal - process it on the actual chess instance via makeMove
makeMove(move.san);
return true;
} catch (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 (
<div className="space-y-6">
<div className="flex items-center justify-center min-h-[500px]">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Resume Training?
</h2>
<p className="text-gray-600 mb-6">
You have an existing training session for this opening with{' '}
<span className="font-semibold">
{existingSession.moveHistory.length} move
{existingSession.moveHistory.length !== 1 ? 's' : ''}
</span>
. Would you like to resume where you left off or start fresh?
</p>
<div className="space-y-3">
<button
onClick={handleResumeSession}
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Resume Session
</button>
<button
onClick={handleStartFresh}
className="w-full px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
>
Start Fresh
</button>
</div>
<p className="text-xs text-gray-500 mt-4 text-center">
Last updated:{' '}
{new Date(existingSession.lastUpdated).toLocaleString()}
</p>
</div>
</div>
</div>
);
}
if (isInitializing) {
return (
<div className="space-y-6">
<div className="flex items-center justify-center min-h-[500px]">
<div className="text-center space-y-4">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400">Initializing training session...</p>
{!stockfish && (
<p className="text-sm text-gray-500 dark:text-gray-400">Loading chess engine...</p>
)}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<h3 className="font-semibold text-red-900 mb-2">Error</h3>
<p className="text-red-700">{error}</p>
<button
onClick={() => initSession()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Retry
</button>
</div>
</div>
);
}
if (!session || !chess) {
return (
<div className="space-y-6">
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
<p className="text-gray-600 dark:text-gray-400">No active session</p>
</div>
</div>
);
}
const currentPosition = chess.fen();
// Build opening practice mode prop for Tutor
const repertoireMoves = parseMoveSequence(opening.moves);
const lastMove = session.moveHistory.length > 0
? session.moveHistory[session.moveHistory.length - 1]
: null;
// Determine which color the tutor is playing
const tutorColor = userColor === 'white' ? 'black' : 'white';
// Find last user move and last tutor move
const userMoves = session.moveHistory.filter(
m => m.color === userColor
);
const tutorMoves = session.moveHistory.filter(
m => m.color === tutorColor
);
const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null;
const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null;
// Family mode: compute current position info from variation tree
const variationPositionInfo = useMemo(() => {
if (!isFamilyMode || !variationTree) {
return null;
}
const positionDesc = describeCurrentPosition(variationTree, session.moveHistory);
const currentVariations = identifyCurrentVariation(variationTree, session.moveHistory);
const possibleMoves = getAllPossibleNextMoves(variationTree, session.moveHistory);
return {
...positionDesc,
currentVariations,
possibleMoves,
isInAnyVariation: positionDesc.matchingCount > 0,
};
}, [isFamilyMode, variationTree, session.moveHistory]);
// Get all possible next moves (for display and tutor context)
const theoreticalMoves = useMemo(() => {
if (isFamilyMode && variationPositionInfo) {
return variationPositionInfo.nextMoves;
}
// Single variation mode
const moves = parseMoveSequence(opening.moves);
const nextMove = moves[session.moveHistory.length];
return nextMove ? [nextMove] : [];
}, [isFamilyMode, variationPositionInfo, opening.moves, session.moveHistory.length]);
const openingPracticeMode = {
openingName: opening.name,
openingEco: opening.eco,
repertoireMoves,
currentMoveIndex: session.moveHistory.length,
isInTheory: isFamilyMode
? (variationPositionInfo?.isInAnyVariation ?? false)
: session.deviationMoveIndex === null,
deviationMoveIndex: session.deviationMoveIndex,
lastUserMove: lastUserMove ? {
from: lastUserMove.uci.substring(0, 2),
to: lastUserMove.uci.substring(2, 4),
san: lastUserMove.san,
color: lastUserMove.color === 'white' ? 'w' : 'b',
piece: lastUserMove.san[0].toLowerCase(),
flags: '',
captured: undefined,
promotion: lastUserMove.uci.length > 4 ? lastUserMove.uci[4] : undefined
} as any : null,
lastTutorMove: lastTutorMove ? {
from: lastTutorMove.uci.substring(0, 2),
to: lastTutorMove.uci.substring(2, 4),
san: lastTutorMove.san,
color: lastTutorMove.color === 'white' ? 'w' : 'b',
piece: lastTutorMove.san[0].toLowerCase(),
flags: '',
captured: undefined,
promotion: lastTutorMove.uci.length > 4 ? lastTutorMove.uci[4] : undefined
} as any : null,
currentFeedback: currentFeedback ? {
category: currentFeedback.classification.category,
evaluationChange: currentFeedback.classification.evaluationChange,
theoreticalAlternatives: isFamilyMode ? theoreticalMoves : currentFeedback.classification.theoreticalAlternatives
} : null,
wikipediaSummary: wikipediaSummary?.extract || undefined,
shouldTutorSpeak,
onTutorMessageSent: handleTutorMessageSent,
// Family mode specific info
isFamilyMode,
variationInfo: isFamilyMode && variationPositionInfo ? {
matchingVariations: variationPositionInfo.matchingCount,
currentVariationNames: variationPositionInfo.currentVariationNames,
possibleMoves: variationPositionInfo.nextMoves,
isEndOfLine: variationPositionInfo.isEndOfLine,
} : undefined,
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main board area */}
<div className="lg:col-span-2 space-y-4">
{/* Board */}
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
role="region"
aria-label="Chess board"
>
<Chessboard
key={currentPosition}
options={{
position: currentPosition,
onPieceDrop: ({ sourceSquare, targetSquare }) => {
if (!targetSquare) return false;
return handlePieceDrop(sourceSquare, targetSquare);
},
boardOrientation: boardOrientation,
darkSquareStyle: { backgroundColor: '#779954' },
lightSquareStyle: { backgroundColor: '#e9edcc' },
animationDurationInMs: 200,
}}
/>
</div>
{/* Move controls */}
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
role="region"
aria-label="Move history and navigation"
>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 dark:text-white">Move History</h3>
<div className="flex gap-2" role="group" aria-label="Move navigation">
<button
onClick={() =>
navigateToMove(Math.max(0, session.currentMoveIndex - 1))
}
disabled={session.currentMoveIndex === 0}
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Go to previous move"
>
Back
</button>
<button
onClick={() =>
navigateToMove(
Math.min(moveCount - 1, session.currentMoveIndex + 1)
)
}
disabled={session.currentMoveIndex >= moveCount - 1}
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Go to next move"
>
Forward
</button>
</div>
</div>
{/* Move list */}
<div
className="space-y-2 max-h-[200px] overflow-y-auto"
role="list"
aria-label="List of moves played"
>
{moveCount === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No moves yet. Make your first move!
</p>
) : (
session.moveHistory.map((move, index) => (
<div
key={index}
onClick={() => navigateToMove(index)}
role="listitem"
className={`p-2 rounded cursor-pointer transition-colors ${
index === session.currentMoveIndex
? 'bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700'
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
aria-label={`Move ${move.moveNumber}${
move.color === 'white' ? '.' : '...'
} ${move.san}, classified as ${move.classification.category}`}
aria-current={index === session.currentMoveIndex ? 'true' : undefined}
>
<div className="flex justify-between items-center">
<span className="font-mono text-sm font-semibold">
{move.moveNumber}
{move.color === 'white' ? '.' : '...'} {move.san}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
move.classification.category === 'in-theory'
? 'bg-green-100 text-green-800'
: move.classification.category === 'playable'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{move.classification.category}
</span>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Sidebar - tutor and info */}
<div className="space-y-4">
{/* Tutor Chat */}
{apiKey ? (
<Tutor
game={chess}
currentFen={currentPosition}
userMove={null} // Will be updated in Phase 2
computerMove={null}
stockfish={stockfish}
evalP0={null}
evalP2={null}
openingData={[]}
missedTactics={[]}
onAnalysisComplete={() => {}}
apiKey={apiKey}
personality={personality}
language={language}
playerColor={userColor}
onCheckComputerMove={() => {}}
resignationContext={null}
openingPracticeMode={openingPracticeMode}
/>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="text-center">
<div className="text-4xl mb-4">🔑</div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
API Key Required
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
To chat with your coach, please set up your Gemini API key in the settings.
</p>
<button
onClick={() => window.location.href = '/onboarding'}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Set Up API Key
</button>
</div>
</div>
)}
{/* Wikipedia summary */}
<WikipediaSummary
openingName={opening.name}
wikipediaSlug={opening.wikipediaSlug}
language={language}
/>
{/* Session info */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Opening:</span>
<span className="font-medium text-gray-900 dark:text-white">{opening.eco}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Moves played:</span>
<span className="font-medium text-gray-900 dark:text-white">{moveCount}</span>
</div>
{/* Family mode: show variation info */}
{isFamilyMode && variationPositionInfo && (
<>
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
<div className="flex justify-between mb-1">
<span className="text-gray-600 dark:text-gray-400">Matching variations:</span>
<span className="font-medium text-blue-600 dark:text-blue-400">
{variationPositionInfo.matchingCount}
</span>
</div>
{/* Show possible moves */}
{variationPositionInfo.nextMoves.length > 0 && (
<div className="mt-2">
<span className="text-gray-600 dark:text-gray-400 block mb-1">Possible moves:</span>
<div className="flex flex-wrap gap-1">
{variationPositionInfo.nextMoves.map((move) => (
<span
key={move}
className="inline-block px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded text-xs font-mono"
>
{move}
</span>
))}
</div>
</div>
)}
{/* Show current variation names (if narrowed down) */}
{variationPositionInfo.matchingCount > 0 && variationPositionInfo.matchingCount <= 3 && (
<div className="mt-2">
<span className="text-gray-600 dark:text-gray-400 block mb-1">Current line:</span>
<div className="space-y-1">
{variationPositionInfo.currentVariationNames.slice(0, 3).map((name) => (
<span
key={name}
className="block text-xs text-gray-700 dark:text-gray-300 truncate"
title={name}
>
{name}
</span>
))}
</div>
</div>
)}
{variationPositionInfo.isEndOfLine && (
<div className="mt-2">
<span className="inline-block px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-400 rounded text-xs">
End of repertoire line
</span>
</div>
)}
</div>
</>
)}
{/* Show off-book indicator (non-family mode or when truly off-book) */}
{!isFamilyMode && session.deviationMoveIndex !== null && (
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
Off-book since move {session.deviationMoveIndex + 1}
</span>
</div>
)}
{/* Family mode: show off-book when not in any variation */}
{isFamilyMode && variationPositionInfo && !variationPositionInfo.isInAnyVariation && moveCount > 0 && (
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
Off-book - move not in any variation
</span>
</div>
)}
</div>
</div>
{/* Deviation Dialog */}
{showDeviationDialog && session?.deviationMoveIndex !== null && (
<DeviationDialog
openingName={opening.name}
movesCompleted={session.deviationMoveIndex}
onUndo={handleUndoDeviation}
onStartGame={handleStartGameFromPosition}
onContinueExploring={handleContinueExploring}
language={language}
/>
)}
</div>
);
}