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:
@@ -22,6 +22,13 @@ export default function Home() {
|
||||
initialPersonality: Personality;
|
||||
initialColor: 'white' | 'black';
|
||||
initialStockfishDepth?: number;
|
||||
openingContext?: {
|
||||
openingName: string;
|
||||
openingEco: string;
|
||||
movesCompleted: number;
|
||||
wikipediaSummary?: string;
|
||||
contextMessage: string;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
const [savedGames, setSavedGames] = useState<SavedGame[]>([]);
|
||||
@@ -48,18 +55,31 @@ export default function Home() {
|
||||
|
||||
const personality = PERSONALITIES.find(p => p.id === pendingGame.personalityId) || PERSONALITIES[0];
|
||||
|
||||
// Check for opening context (from opening trainer)
|
||||
const openingContextRaw = localStorage.getItem("chess_tutor_opening_context");
|
||||
let openingContext;
|
||||
if (openingContextRaw) {
|
||||
try {
|
||||
openingContext = JSON.parse(openingContextRaw);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse opening context", err);
|
||||
}
|
||||
}
|
||||
|
||||
setGameProps({
|
||||
gameId: crypto.randomUUID ? crypto.randomUUID() : `game-${Date.now()}`,
|
||||
initialFen: pendingGame.fen,
|
||||
initialPersonality: personality,
|
||||
initialColor: pendingGame.color,
|
||||
initialStockfishDepth: pendingGame.stockfishDepth,
|
||||
openingContext,
|
||||
});
|
||||
setView('game');
|
||||
} catch (err) {
|
||||
console.error("Failed to load pending game", err);
|
||||
} finally {
|
||||
localStorage.removeItem("chess_tutor_pending_game");
|
||||
localStorage.removeItem("chess_tutor_opening_context");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +147,7 @@ export default function Home() {
|
||||
initialPersonality={gameProps.initialPersonality}
|
||||
initialColor={gameProps.initialColor}
|
||||
initialStockfishDepth={gameProps.initialStockfishDepth}
|
||||
openingContext={gameProps.openingContext}
|
||||
onBack={handleBackToMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Chess } from 'chess.js';
|
||||
import {
|
||||
TrainingSession,
|
||||
MoveHistoryEntry,
|
||||
MoveFeedback,
|
||||
} from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { createEngine, ChessEngine } from '@/lib/engine';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import {
|
||||
createSession,
|
||||
loadSession,
|
||||
saveSession,
|
||||
updateSession,
|
||||
} from '@/lib/openingTrainer/sessionManager';
|
||||
import { evaluatePosition } from '@/lib/openingTrainer/engineService';
|
||||
import { classifyMove } from '@/lib/openingTrainer/moveValidator';
|
||||
import {
|
||||
getExpectedNextMoves,
|
||||
detectTransposition,
|
||||
getOpponentNextMove,
|
||||
isOpponentTurn,
|
||||
getUserColor,
|
||||
isEndOfRepertoire,
|
||||
parseMoveSequence,
|
||||
} from '@/lib/openingTrainer/repertoireNavigation';
|
||||
import { STARTING_FEN } from '@/lib/openingTrainer/constants';
|
||||
import {
|
||||
buildExplanationPrompt,
|
||||
buildTranspositionPrompt,
|
||||
ExplanationPromptContext,
|
||||
} from '@/lib/openingTrainer/feedbackGenerator';
|
||||
|
||||
/**
|
||||
* Context for managing opening training session state
|
||||
*/
|
||||
|
||||
interface OpeningTrainingContextType {
|
||||
session: TrainingSession | null;
|
||||
opening: OpeningMetadata | null;
|
||||
chess: Chess | null;
|
||||
stockfish: ChessEngine | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
currentFeedback: MoveFeedback | null;
|
||||
|
||||
// Actions
|
||||
initializeSession: (opening: OpeningMetadata, forceNew?: boolean) => Promise<void>;
|
||||
makeMove: (san: string) => Promise<void>;
|
||||
navigateToMove: (index: number) => void;
|
||||
resetSession: () => void;
|
||||
}
|
||||
|
||||
const OpeningTrainingContext = createContext<OpeningTrainingContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface OpeningTrainingProviderProps {
|
||||
children: ReactNode;
|
||||
openingId?: string;
|
||||
}
|
||||
|
||||
export function OpeningTrainingProvider({
|
||||
children,
|
||||
openingId,
|
||||
}: OpeningTrainingProviderProps) {
|
||||
const [session, setSession] = useState<TrainingSession | null>(null);
|
||||
const [opening, setOpening] = useState<OpeningMetadata | null>(null);
|
||||
const [chess, setChess] = useState<Chess | null>(null);
|
||||
const [stockfish, setStockfish] = useState<ChessEngine | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentFeedback, setCurrentFeedback] = useState<MoveFeedback | null>(null);
|
||||
|
||||
// Cache for move feedback (indexed by move index)
|
||||
const [feedbackCache, setFeedbackCache] = useState<Map<number, MoveFeedback>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
// Track if we've already made the automatic move for this session state
|
||||
// This prevents duplicate moves when the effect re-runs
|
||||
const lastAutoMoveState = React.useRef<string | null>(null);
|
||||
|
||||
// Initialize chess engine (local or remote based on environment)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
createEngine().then((engine) => {
|
||||
setStockfish(engine);
|
||||
}).catch((err) => {
|
||||
console.error('Failed to create chess engine:', err);
|
||||
setError('Failed to initialize chess engine');
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup will happen when stockfish changes
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup engine when it changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (stockfish) {
|
||||
stockfish.terminate();
|
||||
}
|
||||
};
|
||||
}, [stockfish]);
|
||||
|
||||
// Initialize chess instance
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
const chessInstance = new Chess(session.currentFEN);
|
||||
setChess(chessInstance);
|
||||
} else {
|
||||
setChess(null);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Make automatic opponent move when needed
|
||||
useEffect(() => {
|
||||
if (!session || !opening || !chess || !stockfish) return;
|
||||
|
||||
// Create a state key to track if we've already handled this exact state
|
||||
const stateKey = `${session.openingId}-${session.moveHistory.length}-${session.currentMoveIndex}`;
|
||||
|
||||
// Check if we've already processed this state
|
||||
if (lastAutoMoveState.current === stateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 1: Fresh start - opponent makes first move if they should (user is Black)
|
||||
const shouldMakeFirstMove =
|
||||
session.moveHistory.length === 0 &&
|
||||
session.currentMoveIndex === 0 &&
|
||||
isOpponentTurn(opening, 0);
|
||||
|
||||
// Case 2: Resumed session - check if it's opponent's turn at current position
|
||||
// Only if we're at the end of the move history (not navigating back in time)
|
||||
const isAtEndOfHistory = session.currentMoveIndex === session.moveHistory.length;
|
||||
const shouldContinueResumedSession =
|
||||
session.moveHistory.length > 0 &&
|
||||
isAtEndOfHistory &&
|
||||
isOpponentTurn(opening, session.moveHistory.length);
|
||||
|
||||
console.log('[OpeningTraining] Checking if opponent should move:', {
|
||||
shouldMakeFirstMove,
|
||||
shouldContinueResumedSession,
|
||||
moveHistoryLength: session.moveHistory.length,
|
||||
currentMoveIndex: session.currentMoveIndex,
|
||||
isAtEndOfHistory,
|
||||
isOpponentTurnNow: isOpponentTurn(opening, session.moveHistory.length),
|
||||
stateKey,
|
||||
lastState: lastAutoMoveState.current
|
||||
});
|
||||
|
||||
if (shouldMakeFirstMove || shouldContinueResumedSession) {
|
||||
// Mark this state as processed
|
||||
lastAutoMoveState.current = stateKey;
|
||||
|
||||
const reason = shouldMakeFirstMove ? 'first move' : 'resumed session';
|
||||
console.log(`[OpeningTraining] Making automatic opponent move (${reason})`);
|
||||
|
||||
setTimeout(() => {
|
||||
makeAutomaticOpponentMove(session, opening);
|
||||
}, 800);
|
||||
}
|
||||
}, [session?.openingId, session?.moveHistory?.length, session?.currentMoveIndex, opening?.eco, chess, stockfish]);
|
||||
|
||||
/**
|
||||
* Initialize a new training session for an opening
|
||||
*/
|
||||
const initializeSession = async (
|
||||
openingMetadata: OpeningMetadata,
|
||||
forceNew: boolean = false
|
||||
) => {
|
||||
const perfStart = performance.now();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to load existing session (unless forceNew is true)
|
||||
let existingSession = forceNew ? null : loadSession(openingMetadata.eco);
|
||||
|
||||
if (existingSession && !forceNew) {
|
||||
// Resume existing session
|
||||
setSession(existingSession);
|
||||
setOpening(openingMetadata);
|
||||
} else {
|
||||
// Create new session
|
||||
const newSession = createSession(
|
||||
openingMetadata.eco,
|
||||
openingMetadata.name,
|
||||
STARTING_FEN,
|
||||
0
|
||||
);
|
||||
|
||||
// Get initial evaluation
|
||||
if (stockfish) {
|
||||
const initialEval = await evaluatePosition(STARTING_FEN, stockfish);
|
||||
newSession.initialEvaluation = initialEval.score;
|
||||
}
|
||||
|
||||
setSession(newSession);
|
||||
setOpening(openingMetadata);
|
||||
saveSession(newSession);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize session');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const perfEnd = performance.now();
|
||||
console.log(
|
||||
`[Performance] Session initialization: ${(perfEnd - perfStart).toFixed(0)}ms`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make a move in the training session
|
||||
*/
|
||||
const makeMove = async (san: string) => {
|
||||
console.log('[OpeningTraining] makeMove called with:', san);
|
||||
|
||||
if (!session || !opening || !chess || !stockfish) {
|
||||
console.error('[OpeningTraining] Session not initialized:', { session: !!session, opening: !!opening, chess: !!chess, stockfish: !!stockfish });
|
||||
setError('Session not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const perfStart = performance.now();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// If we've navigated back in history, truncate moves after current position
|
||||
// This allows the user to try a different move
|
||||
const isNavigatedBack = session.currentMoveIndex < session.moveHistory.length;
|
||||
const truncatedHistory = isNavigatedBack
|
||||
? session.moveHistory.slice(0, session.currentMoveIndex)
|
||||
: session.moveHistory;
|
||||
|
||||
if (isNavigatedBack) {
|
||||
console.log('[OpeningTraining] User navigated back and making new move. Truncating history from', session.moveHistory.length, 'to', truncatedHistory.length);
|
||||
}
|
||||
|
||||
// Validate and make the move
|
||||
console.log('[OpeningTraining] Attempting to make move:', san);
|
||||
const move = chess.move(san);
|
||||
if (!move) {
|
||||
console.error('[OpeningTraining] Illegal move:', san);
|
||||
setError('Illegal move');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[OpeningTraining] Move successful:', move);
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation (from last move or initial)
|
||||
const previousEval =
|
||||
truncatedHistory.length > 0
|
||||
? truncatedHistory[truncatedHistory.length - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const evalStart = performance.now();
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
const evalTime = performance.now() - evalStart;
|
||||
console.log(`[Performance] Engine evaluation: ${evalTime.toFixed(0)}ms`);
|
||||
|
||||
// Check if move is in repertoire (use truncated history length for position)
|
||||
const expectedMoves = getExpectedNextMoves(opening, truncatedHistory.length);
|
||||
const isInRepertoire = expectedMoves.includes(san);
|
||||
|
||||
// Check for transposition (if user is off-book, see if they've transposed back)
|
||||
let transposedOpening: OpeningMetadata | null = null;
|
||||
if (session.deviationMoveIndex !== null && !isInRepertoire) {
|
||||
transposedOpening = detectTransposition(newFEN);
|
||||
}
|
||||
|
||||
// Classify the move
|
||||
const classification = classifyMove(
|
||||
san,
|
||||
isInRepertoire,
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(truncatedHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session with truncated history + new move
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: truncatedHistory.length + 1,
|
||||
moveHistory: [...truncatedHistory, moveEntry],
|
||||
deviationMoveIndex:
|
||||
!isInRepertoire && (session.deviationMoveIndex === null || isNavigatedBack)
|
||||
? truncatedHistory.length
|
||||
: isNavigatedBack
|
||||
? null // Reset deviation if we're replaying from earlier point
|
||||
: session.deviationMoveIndex,
|
||||
};
|
||||
|
||||
console.log('[OpeningTraining] Updating session with new move. Move history length:', updatedSession.moveHistory.length);
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
|
||||
// Create initial feedback (without LLM explanation)
|
||||
const initialFeedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification,
|
||||
evaluation: currentEval,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // Will be populated asynchronously
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Cache the initial feedback
|
||||
const moveIndex = updatedSession.moveHistory.length - 1;
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, initialFeedback);
|
||||
return newCache;
|
||||
});
|
||||
|
||||
setCurrentFeedback(initialFeedback);
|
||||
setIsLoading(false);
|
||||
|
||||
const feedbackTime = performance.now() - perfStart;
|
||||
console.log(
|
||||
`[Performance] Move processing (engine + classification): ${feedbackTime.toFixed(0)}ms`
|
||||
);
|
||||
|
||||
// Generate LLM explanation asynchronously (don't block user)
|
||||
generateLLMExplanation(
|
||||
initialFeedback,
|
||||
updatedSession,
|
||||
moveIndex,
|
||||
session.deviationMoveIndex === null &&
|
||||
!isInRepertoire &&
|
||||
updatedSession.deviationMoveIndex !== null,
|
||||
transposedOpening
|
||||
);
|
||||
|
||||
// After user's move, check if we should make automatic opponent move
|
||||
// Only if: move was in theory, and it's opponent's turn next
|
||||
const shouldMakeOpponentMove = isInRepertoire && isOpponentTurn(opening, updatedSession.moveHistory.length);
|
||||
console.log('[OpeningTraining] Should make opponent move?', shouldMakeOpponentMove, {
|
||||
isInRepertoire,
|
||||
isOpponentTurn: isOpponentTurn(opening, updatedSession.moveHistory.length),
|
||||
moveHistoryLength: updatedSession.moveHistory.length
|
||||
});
|
||||
|
||||
if (shouldMakeOpponentMove) {
|
||||
// Add delay for natural feel (600ms)
|
||||
console.log('[OpeningTraining] Scheduling automatic opponent move in 600ms');
|
||||
setTimeout(() => {
|
||||
makeAutomaticOpponentMove(updatedSession, opening);
|
||||
}, 600);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to process move');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make automatic opponent move from repertoire
|
||||
*/
|
||||
const makeAutomaticOpponentMove = async (
|
||||
currentSession: TrainingSession,
|
||||
openingMetadata: OpeningMetadata
|
||||
) => {
|
||||
if (!chess || !stockfish) return;
|
||||
|
||||
try {
|
||||
// Get opponent's next move from repertoire
|
||||
const opponentMove = getOpponentNextMove(
|
||||
openingMetadata,
|
||||
currentSession.moveHistory.length
|
||||
);
|
||||
|
||||
if (!opponentMove) {
|
||||
// No opponent move available (end of repertoire)
|
||||
console.log('[AutoMove] End of repertoire - no opponent move available');
|
||||
console.log('[AutoMove] Repertoire moves:', parseMoveSequence(openingMetadata.moves));
|
||||
console.log('[AutoMove] Current move index:', currentSession.moveHistory.length);
|
||||
|
||||
// Mark that we've reached the end of repertoire
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
// Could add a flag here if needed for UI indication
|
||||
};
|
||||
setSession(updatedSession);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've reached end of repertoire after this move
|
||||
const willReachEnd = isEndOfRepertoire(openingMetadata, currentSession.moveHistory.length + 1);
|
||||
if (willReachEnd) {
|
||||
console.log('[AutoMove] This will be the last repertoire move');
|
||||
}
|
||||
|
||||
// Make the move on the chess instance
|
||||
const move = chess.move(opponentMove);
|
||||
if (!move) {
|
||||
console.error('[AutoMove] Failed to make opponent move:', opponentMove);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation
|
||||
const previousEval =
|
||||
currentSession.moveHistory.length > 0
|
||||
? currentSession.moveHistory[currentSession.moveHistory.length - 1].evaluation
|
||||
: { score: currentSession.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
|
||||
// Classify the move (should always be "in-theory" for automatic moves)
|
||||
const expectedMoves = [opponentMove];
|
||||
const classification = classifyMove(
|
||||
opponentMove,
|
||||
true, // Always in repertoire
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(currentSession.moveHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: currentSession.moveHistory.length + 1,
|
||||
moveHistory: [...currentSession.moveHistory, moveEntry],
|
||||
};
|
||||
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
|
||||
console.log(`[AutoMove] Played ${opponentMove} automatically`);
|
||||
} catch (error) {
|
||||
console.error('[AutoMove] Error making automatic opponent move:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate LLM explanation for a move asynchronously
|
||||
*/
|
||||
const generateLLMExplanation = async (
|
||||
feedback: MoveFeedback,
|
||||
currentSession: TrainingSession,
|
||||
moveIndex: number,
|
||||
isDeviationMove: boolean,
|
||||
transposedOpening: OpeningMetadata | null = null
|
||||
) => {
|
||||
if (!opening) return;
|
||||
|
||||
const llmStart = performance.now();
|
||||
|
||||
try {
|
||||
// Build the prompt
|
||||
let prompt: string;
|
||||
|
||||
if (transposedOpening) {
|
||||
// Special prompt for transposition
|
||||
prompt = buildTranspositionPrompt(transposedOpening, feedback.move.san);
|
||||
} else {
|
||||
// Normal explanation prompt
|
||||
const promptContext: ExplanationPromptContext = {
|
||||
opening,
|
||||
userMove: feedback.move,
|
||||
classification: feedback.classification,
|
||||
currentEval: feedback.evaluation,
|
||||
previousEval: feedback.previousEvaluation,
|
||||
fen: feedback.move.fen,
|
||||
moveHistory: currentSession.moveHistory,
|
||||
isDeviationMove,
|
||||
};
|
||||
|
||||
prompt = buildExplanationPrompt(promptContext);
|
||||
}
|
||||
|
||||
// Call LLM API
|
||||
const response = await fetch('/api/v1/llm/opening-explanation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
moveSan: feedback.move.san,
|
||||
category: feedback.classification.category,
|
||||
theoreticalMoves: feedback.classification.theoreticalAlternatives,
|
||||
evalChange: feedback.classification.evaluationChange,
|
||||
bestMove: feedback.evaluation.bestMove,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('LLM API error:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const explanation = data.explanation || '';
|
||||
|
||||
// Update feedback with explanation
|
||||
const updatedFeedback: MoveFeedback = {
|
||||
...feedback,
|
||||
llmExplanation: explanation,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, updatedFeedback);
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Update current feedback if this is still the current move
|
||||
setCurrentFeedback((current) => {
|
||||
if (current && current.move.san === feedback.move.san) {
|
||||
return updatedFeedback;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
const llmTime = performance.now() - llmStart;
|
||||
console.log(`[Performance] LLM explanation generation: ${llmTime.toFixed(0)}ms`);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate LLM explanation:', error);
|
||||
// Silently fail - user still has engine feedback
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to a specific move in the history
|
||||
*/
|
||||
const navigateToMove = (index: number) => {
|
||||
if (!session || index < 0 || index > session.moveHistory.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentMoveIndex: index,
|
||||
currentFEN: index === 0 ? STARTING_FEN : session.moveHistory[index - 1].fen,
|
||||
};
|
||||
|
||||
setSession(updatedSession);
|
||||
|
||||
// Reset the auto-move state tracker so opponent can move after navigation
|
||||
// This allows the user to go back, make a correct move, and have the opponent respond
|
||||
lastAutoMoveState.current = null;
|
||||
|
||||
// Update feedback to show the move at this index
|
||||
if (index > 0 && index <= session.moveHistory.length) {
|
||||
const moveIndex = index - 1; // Convert from 1-based display to 0-based array index
|
||||
|
||||
// Check cache first
|
||||
const cached = feedbackCache.get(moveIndex);
|
||||
if (cached) {
|
||||
setCurrentFeedback(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache - rebuild feedback (shouldn't happen often)
|
||||
const moveEntry = session.moveHistory[moveIndex];
|
||||
const previousEval =
|
||||
moveIndex > 0
|
||||
? session.moveHistory[moveIndex - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
const feedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification: moveEntry.classification,
|
||||
evaluation: moveEntry.evaluation,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // No cached explanation available
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
setCurrentFeedback(feedback);
|
||||
} else {
|
||||
setCurrentFeedback(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the current session
|
||||
*/
|
||||
const resetSession = () => {
|
||||
setSession(null);
|
||||
setOpening(null);
|
||||
setCurrentFeedback(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const value: OpeningTrainingContextType = {
|
||||
session,
|
||||
opening,
|
||||
chess,
|
||||
stockfish,
|
||||
isLoading,
|
||||
error,
|
||||
currentFeedback,
|
||||
initializeSession,
|
||||
makeMove,
|
||||
navigateToMove,
|
||||
resetSession,
|
||||
};
|
||||
|
||||
return (
|
||||
<OpeningTrainingContext.Provider value={value}>
|
||||
{children}
|
||||
</OpeningTrainingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use the Opening Training context
|
||||
*/
|
||||
export function useOpeningTraining() {
|
||||
const context = useContext(OpeningTrainingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useOpeningTraining must be used within an OpeningTrainingProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,47 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useReducer, useEffect, useRef, ReactNode } from 'react';
|
||||
import { Chess } from 'chess.js';
|
||||
import {
|
||||
TrainingSession,
|
||||
MoveHistoryEntry,
|
||||
MoveFeedback,
|
||||
} from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { createEngine, ChessEngine } from '@/lib/engine';
|
||||
import { MoveFeedback } from '@/types/openingTraining';
|
||||
import { ChessEngine } from '@/lib/engine';
|
||||
import { createEngine } from '@/lib/engine';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import {
|
||||
createSession,
|
||||
loadSession,
|
||||
saveSession,
|
||||
updateSession,
|
||||
} from '@/lib/openingTrainer/sessionManager';
|
||||
import { evaluatePosition } from '@/lib/openingTrainer/engineService';
|
||||
import { classifyMove } from '@/lib/openingTrainer/moveValidator';
|
||||
import {
|
||||
getExpectedNextMoves,
|
||||
detectTransposition,
|
||||
getOpponentNextMove,
|
||||
isOpponentTurn,
|
||||
getUserColor,
|
||||
isEndOfRepertoire,
|
||||
parseMoveSequence,
|
||||
} from '@/lib/openingTrainer/repertoireNavigation';
|
||||
sessionReducer,
|
||||
createInitialState,
|
||||
SessionState,
|
||||
SessionAction,
|
||||
shouldTriggerOpponentMove,
|
||||
toTrainingSession,
|
||||
} from '@/lib/openingTrainer/sessionReducer';
|
||||
import { SessionOrchestrator } from '@/lib/openingTrainer/sessionOrchestrator';
|
||||
import { loadSession, saveSession } from '@/lib/openingTrainer/sessionManager';
|
||||
import { STARTING_FEN } from '@/lib/openingTrainer/constants';
|
||||
import {
|
||||
buildExplanationPrompt,
|
||||
buildTranspositionPrompt,
|
||||
ExplanationPromptContext,
|
||||
} from '@/lib/openingTrainer/feedbackGenerator';
|
||||
import { getUserColor } from '@/lib/openingTrainer/gameLogic';
|
||||
|
||||
/**
|
||||
* Context for managing opening training session state
|
||||
* Refactored Opening Training Context
|
||||
*
|
||||
* This is a CLEAN implementation using the reducer pattern.
|
||||
* No more tangled useEffects! No more race conditions!
|
||||
*/
|
||||
|
||||
interface OpeningTrainingContextType {
|
||||
session: TrainingSession | null;
|
||||
// State
|
||||
session: SessionState | null;
|
||||
opening: OpeningMetadata | null;
|
||||
chess: Chess | null;
|
||||
stockfish: ChessEngine | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -67,477 +55,276 @@ export function OpeningTrainingProvider({
|
||||
children,
|
||||
openingId,
|
||||
}: OpeningTrainingProviderProps) {
|
||||
const [session, setSession] = useState<TrainingSession | null>(null);
|
||||
const [opening, setOpening] = useState<OpeningMetadata | null>(null);
|
||||
const [chess, setChess] = useState<Chess | null>(null);
|
||||
const [stockfish, setStockfish] = useState<ChessEngine | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentFeedback, setCurrentFeedback] = useState<MoveFeedback | null>(null);
|
||||
|
||||
// Cache for move feedback (indexed by move index)
|
||||
const [feedbackCache, setFeedbackCache] = useState<Map<number, MoveFeedback>>(
|
||||
new Map()
|
||||
// Core state - single source of truth via reducer
|
||||
const [session, dispatch] = useReducer(
|
||||
(state: SessionState | null, action: SessionAction): SessionState | null => {
|
||||
if (!state) {
|
||||
// Handle initialization actions when state is null
|
||||
if (action.type === 'INITIALIZE_SESSION') {
|
||||
return createInitialState(action.opening);
|
||||
}
|
||||
// SESSION_LOADED can be called when state is null (loading existing session)
|
||||
// Create a temporary state so the reducer can process it
|
||||
if (action.type === 'SESSION_LOADED') {
|
||||
const tempState = createInitialState(action.opening);
|
||||
return sessionReducer(tempState, action);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return sessionReducer(state, action);
|
||||
},
|
||||
null as SessionState | null
|
||||
);
|
||||
|
||||
// Initialize chess engine (local or remote based on environment)
|
||||
// UI state (not part of session state)
|
||||
const [opening, setOpening] = React.useState<OpeningMetadata | null>(null);
|
||||
const [stockfish, setStockfish] = React.useState<ChessEngine | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [currentFeedback, setCurrentFeedback] = React.useState<MoveFeedback | null>(null);
|
||||
|
||||
// Orchestrator for async operations
|
||||
const orchestratorRef = useRef<SessionOrchestrator | null>(null);
|
||||
|
||||
// Feedback cache (indexed by move index)
|
||||
const feedbackCacheRef = useRef<Map<number, MoveFeedback>>(new Map());
|
||||
|
||||
// ============================================================================
|
||||
// Engine Initialization
|
||||
// ============================================================================
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
createEngine().then((engine) => {
|
||||
setStockfish(engine);
|
||||
}).catch((err) => {
|
||||
console.error('Failed to create chess engine:', err);
|
||||
setError('Failed to initialize chess engine');
|
||||
});
|
||||
createEngine()
|
||||
.then((engine) => {
|
||||
setStockfish(engine);
|
||||
orchestratorRef.current = new SessionOrchestrator(engine);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to create chess engine:', err);
|
||||
setError('Failed to initialize chess engine');
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup will happen when stockfish changes
|
||||
if (stockfish) {
|
||||
stockfish.terminate();
|
||||
}
|
||||
if (orchestratorRef.current) {
|
||||
orchestratorRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup engine when it changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (stockfish) {
|
||||
stockfish.terminate();
|
||||
}
|
||||
};
|
||||
}, [stockfish]);
|
||||
// ============================================================================
|
||||
// Session Persistence
|
||||
// ============================================================================
|
||||
|
||||
// Initialize chess instance
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
const chessInstance = new Chess(session.currentFEN);
|
||||
setChess(chessInstance);
|
||||
} else {
|
||||
setChess(null);
|
||||
if (session && session.phase !== 'initializing') {
|
||||
saveSession(toTrainingSession(session));
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
/**
|
||||
* Initialize a new training session for an opening
|
||||
*/
|
||||
// ============================================================================
|
||||
// Automatic Opponent Moves
|
||||
// ============================================================================
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !orchestratorRef.current) {
|
||||
console.log('[OpeningTraining] Opponent move check skipped:', {
|
||||
hasSession: !!session,
|
||||
hasOrchestrator: !!orchestratorRef.current
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[OpeningTraining] Checking if opponent should move:', {
|
||||
phase: session.phase,
|
||||
currentMoveIndex: session.currentMoveIndex,
|
||||
moveHistoryLength: session.moveHistory.length,
|
||||
isAtCurrentPosition: session.currentMoveIndex === session.moveHistory.length,
|
||||
pendingOpponentMove: session.pendingOpponentMove,
|
||||
});
|
||||
|
||||
// Check if we should trigger an opponent auto-move
|
||||
if (shouldTriggerOpponentMove(session)) {
|
||||
console.log('[OpeningTraining] ✅ Triggering automatic opponent move');
|
||||
|
||||
// Make the opponent move
|
||||
makeOpponentMove();
|
||||
} else {
|
||||
console.log('[OpeningTraining] ❌ Not triggering opponent move');
|
||||
}
|
||||
}, [session?.phase, session?.currentMoveIndex, session?.moveHistory.length]);
|
||||
|
||||
// ============================================================================
|
||||
// Session Management
|
||||
// ============================================================================
|
||||
|
||||
const initializeSession = async (
|
||||
openingMetadata: OpeningMetadata,
|
||||
forceNew: boolean = false
|
||||
) => {
|
||||
const perfStart = performance.now();
|
||||
console.log('[OpeningTraining] initializeSession called', { eco: openingMetadata.eco, forceNew });
|
||||
|
||||
// Wait for orchestrator to be ready (max 5 seconds)
|
||||
const maxWaitTime = 5000;
|
||||
const startTime = Date.now();
|
||||
while (!orchestratorRef.current && Date.now() - startTime < maxWaitTime) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (!orchestratorRef.current) {
|
||||
console.error('[OpeningTraining] Orchestrator not ready after waiting');
|
||||
setError('Chess engine not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[OpeningTraining] Orchestrator ready, proceeding with initialization');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to load existing session (unless forceNew is true)
|
||||
let existingSession = forceNew ? null : loadSession(openingMetadata.eco);
|
||||
const existingSession = forceNew ? null : loadSession(openingMetadata.eco);
|
||||
|
||||
if (existingSession && !forceNew) {
|
||||
// Resume existing session
|
||||
setSession(existingSession);
|
||||
console.log('[OpeningTraining] Resuming existing session');
|
||||
dispatch({ type: 'SESSION_LOADED', session: existingSession, opening: openingMetadata });
|
||||
setOpening(openingMetadata);
|
||||
} else {
|
||||
// Create new session
|
||||
const newSession = createSession(
|
||||
openingMetadata.eco,
|
||||
openingMetadata.name,
|
||||
STARTING_FEN,
|
||||
0
|
||||
);
|
||||
console.log('[OpeningTraining] Creating new session');
|
||||
dispatch({ type: 'INITIALIZE_SESSION', opening: openingMetadata, forceNew });
|
||||
|
||||
// Get initial evaluation
|
||||
if (stockfish) {
|
||||
const initialEval = await evaluatePosition(STARTING_FEN, stockfish);
|
||||
newSession.initialEvaluation = initialEval.score;
|
||||
}
|
||||
console.log('[OpeningTraining] Getting initial evaluation');
|
||||
const initialEval = await orchestratorRef.current.getInitialEvaluation(STARTING_FEN);
|
||||
console.log('[OpeningTraining] Initial evaluation:', initialEval);
|
||||
|
||||
dispatch({ type: 'SESSION_INITIALIZED', initialEvaluation: initialEval });
|
||||
console.log('[OpeningTraining] Session initialized');
|
||||
|
||||
setSession(newSession);
|
||||
setOpening(openingMetadata);
|
||||
saveSession(newSession);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize session');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const perfEnd = performance.now();
|
||||
console.log(
|
||||
`[Performance] Session initialization: ${(perfEnd - perfStart).toFixed(0)}ms`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make a move in the training session
|
||||
*/
|
||||
const makeMove = async (san: string) => {
|
||||
console.log('[OpeningTraining] makeMove called with:', san);
|
||||
|
||||
if (!session || !opening || !chess || !stockfish) {
|
||||
console.error('[OpeningTraining] Session not initialized:', { session: !!session, opening: !!opening, chess: !!chess, stockfish: !!stockfish });
|
||||
if (!session || !opening || !orchestratorRef.current) {
|
||||
setError('Session not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const perfStart = performance.now();
|
||||
console.log('[OpeningTraining] makeMove:', san);
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate and make the move
|
||||
console.log('[OpeningTraining] Attempting to make move:', san);
|
||||
const move = chess.move(san);
|
||||
if (!move) {
|
||||
console.error('[OpeningTraining] Illegal move:', san);
|
||||
setError('Illegal move');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Dispatch action to indicate move started
|
||||
dispatch({ type: 'USER_MOVE_STARTED', san });
|
||||
|
||||
console.log('[OpeningTraining] Move successful:', move);
|
||||
// Check if we're navigated back in history
|
||||
const wasNavigatedBack = session.currentMoveIndex < session.moveHistory.length;
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation (from last move or initial)
|
||||
const previousEval =
|
||||
session.moveHistory.length > 0
|
||||
? session.moveHistory[session.moveHistory.length - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const evalStart = performance.now();
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
const evalTime = performance.now() - evalStart;
|
||||
console.log(`[Performance] Engine evaluation: ${evalTime.toFixed(0)}ms`);
|
||||
|
||||
// Check if move is in repertoire
|
||||
const expectedMoves = getExpectedNextMoves(opening, session.moveHistory.length);
|
||||
const isInRepertoire = expectedMoves.includes(san);
|
||||
|
||||
// Check for transposition (if user is off-book, see if they've transposed back)
|
||||
let transposedOpening: OpeningMetadata | null = null;
|
||||
if (session.deviationMoveIndex !== null && !isInRepertoire) {
|
||||
transposedOpening = detectTransposition(newFEN);
|
||||
}
|
||||
|
||||
// Classify the move
|
||||
const classification = classifyMove(
|
||||
// Process the move through orchestrator
|
||||
const result = await orchestratorRef.current.processUserMove(
|
||||
session,
|
||||
san,
|
||||
isInRepertoire,
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
wasNavigatedBack
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(session.moveHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: session.moveHistory.length + 1,
|
||||
moveHistory: [...session.moveHistory, moveEntry],
|
||||
deviationMoveIndex:
|
||||
!isInRepertoire && session.deviationMoveIndex === null
|
||||
? session.moveHistory.length
|
||||
: session.deviationMoveIndex,
|
||||
};
|
||||
|
||||
console.log('[OpeningTraining] Updating session with new move. Move history length:', updatedSession.moveHistory.length);
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
// Dispatch actions from orchestrator
|
||||
result.actions.forEach((action) => dispatch(action));
|
||||
|
||||
// Create initial feedback (without LLM explanation)
|
||||
const initialFeedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification,
|
||||
evaluation: currentEval,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // Will be populated asynchronously
|
||||
move: result.moveEntry,
|
||||
classification: result.moveEntry.classification,
|
||||
evaluation: result.moveEntry.evaluation,
|
||||
previousEvaluation: session.moveHistory.length > 0
|
||||
? session.moveHistory[session.moveHistory.length - 1].evaluation
|
||||
: { score: session.initialEvaluation, mate: null, depth: 0, bestMove: '', ponder: null },
|
||||
llmExplanation: '',
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Cache the initial feedback
|
||||
const moveIndex = updatedSession.moveHistory.length - 1;
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, initialFeedback);
|
||||
return newCache;
|
||||
});
|
||||
// Cache feedback
|
||||
const moveIndex = wasNavigatedBack
|
||||
? session.currentMoveIndex
|
||||
: session.moveHistory.length;
|
||||
feedbackCacheRef.current.set(moveIndex, initialFeedback);
|
||||
|
||||
setCurrentFeedback(initialFeedback);
|
||||
setIsLoading(false);
|
||||
|
||||
const feedbackTime = performance.now() - perfStart;
|
||||
console.log(
|
||||
`[Performance] Move processing (engine + classification): ${feedbackTime.toFixed(0)}ms`
|
||||
);
|
||||
|
||||
// Generate LLM explanation asynchronously (don't block user)
|
||||
generateLLMExplanation(
|
||||
initialFeedback,
|
||||
updatedSession,
|
||||
moveIndex,
|
||||
session.deviationMoveIndex === null &&
|
||||
!isInRepertoire &&
|
||||
updatedSession.deviationMoveIndex !== null,
|
||||
transposedOpening
|
||||
);
|
||||
|
||||
// After user's move, check if we should make automatic opponent move
|
||||
// Only if: move was in theory, and it's opponent's turn next
|
||||
const shouldMakeOpponentMove = isInRepertoire && isOpponentTurn(opening, updatedSession.moveHistory.length);
|
||||
console.log('[OpeningTraining] Should make opponent move?', shouldMakeOpponentMove, {
|
||||
isInRepertoire,
|
||||
isOpponentTurn: isOpponentTurn(opening, updatedSession.moveHistory.length),
|
||||
moveHistoryLength: updatedSession.moveHistory.length
|
||||
});
|
||||
|
||||
if (shouldMakeOpponentMove) {
|
||||
// Add delay for natural feel (600ms)
|
||||
console.log('[OpeningTraining] Scheduling automatic opponent move in 600ms');
|
||||
setTimeout(() => {
|
||||
makeAutomaticOpponentMove(updatedSession, opening);
|
||||
}, 600);
|
||||
}
|
||||
// TODO: Generate LLM explanation asynchronously (Phase 5)
|
||||
// generateLLMExplanation(initialFeedback, session, moveIndex);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to process move');
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process move';
|
||||
setError(errorMessage);
|
||||
dispatch({ type: 'ERROR', message: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make automatic opponent move from repertoire
|
||||
*/
|
||||
const makeAutomaticOpponentMove = async (
|
||||
currentSession: TrainingSession,
|
||||
openingMetadata: OpeningMetadata
|
||||
) => {
|
||||
if (!chess || !stockfish) return;
|
||||
const makeOpponentMove = async () => {
|
||||
if (!session || !opening || !orchestratorRef.current) return;
|
||||
|
||||
console.log('[OpeningTraining] Making automatic opponent move');
|
||||
|
||||
try {
|
||||
// Get opponent's next move from repertoire
|
||||
const opponentMove = getOpponentNextMove(
|
||||
openingMetadata,
|
||||
currentSession.moveHistory.length
|
||||
);
|
||||
// Process opponent move through orchestrator
|
||||
const result = await orchestratorRef.current.processOpponentMove(session, 600);
|
||||
|
||||
if (!opponentMove) {
|
||||
// No opponent move available (end of repertoire)
|
||||
console.log('[AutoMove] End of repertoire - no opponent move available');
|
||||
console.log('[AutoMove] Repertoire moves:', parseMoveSequence(openingMetadata.moves));
|
||||
console.log('[AutoMove] Current move index:', currentSession.moveHistory.length);
|
||||
|
||||
// Mark that we've reached the end of repertoire
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
// Could add a flag here if needed for UI indication
|
||||
};
|
||||
setSession(updatedSession);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've reached end of repertoire after this move
|
||||
const willReachEnd = isEndOfRepertoire(openingMetadata, currentSession.moveHistory.length + 1);
|
||||
if (willReachEnd) {
|
||||
console.log('[AutoMove] This will be the last repertoire move');
|
||||
}
|
||||
|
||||
// Make the move on the chess instance
|
||||
const move = chess.move(opponentMove);
|
||||
if (!move) {
|
||||
console.error('[AutoMove] Failed to make opponent move:', opponentMove);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation
|
||||
const previousEval =
|
||||
currentSession.moveHistory.length > 0
|
||||
? currentSession.moveHistory[currentSession.moveHistory.length - 1].evaluation
|
||||
: { score: currentSession.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
|
||||
// Classify the move (should always be "in-theory" for automatic moves)
|
||||
const expectedMoves = [opponentMove];
|
||||
const classification = classifyMove(
|
||||
opponentMove,
|
||||
true, // Always in repertoire
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(currentSession.moveHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: currentSession.moveHistory.length + 1,
|
||||
moveHistory: [...currentSession.moveHistory, moveEntry],
|
||||
};
|
||||
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
|
||||
console.log(`[AutoMove] Played ${opponentMove} automatically`);
|
||||
} catch (error) {
|
||||
console.error('[AutoMove] Error making automatic opponent move:', error);
|
||||
// Dispatch actions from orchestrator
|
||||
result.actions.forEach((action) => dispatch(action));
|
||||
} catch (err) {
|
||||
console.error('[OpeningTraining] Opponent move failed:', err);
|
||||
// Don't set error - this is expected at end of repertoire
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate LLM explanation for a move asynchronously
|
||||
*/
|
||||
const generateLLMExplanation = async (
|
||||
feedback: MoveFeedback,
|
||||
currentSession: TrainingSession,
|
||||
moveIndex: number,
|
||||
isDeviationMove: boolean,
|
||||
transposedOpening: OpeningMetadata | null = null
|
||||
) => {
|
||||
if (!opening) return;
|
||||
|
||||
const llmStart = performance.now();
|
||||
|
||||
try {
|
||||
// Build the prompt
|
||||
let prompt: string;
|
||||
|
||||
if (transposedOpening) {
|
||||
// Special prompt for transposition
|
||||
prompt = buildTranspositionPrompt(transposedOpening, feedback.move.san);
|
||||
} else {
|
||||
// Normal explanation prompt
|
||||
const promptContext: ExplanationPromptContext = {
|
||||
opening,
|
||||
userMove: feedback.move,
|
||||
classification: feedback.classification,
|
||||
currentEval: feedback.evaluation,
|
||||
previousEval: feedback.previousEvaluation,
|
||||
fen: feedback.move.fen,
|
||||
moveHistory: currentSession.moveHistory,
|
||||
isDeviationMove,
|
||||
};
|
||||
|
||||
prompt = buildExplanationPrompt(promptContext);
|
||||
}
|
||||
|
||||
// Call LLM API
|
||||
const response = await fetch('/api/v1/llm/opening-explanation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
moveSan: feedback.move.san,
|
||||
category: feedback.classification.category,
|
||||
theoreticalMoves: feedback.classification.theoreticalAlternatives,
|
||||
evalChange: feedback.classification.evaluationChange,
|
||||
bestMove: feedback.evaluation.bestMove,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('LLM API error:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const explanation = data.explanation || '';
|
||||
|
||||
// Update feedback with explanation
|
||||
const updatedFeedback: MoveFeedback = {
|
||||
...feedback,
|
||||
llmExplanation: explanation,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, updatedFeedback);
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Update current feedback if this is still the current move
|
||||
setCurrentFeedback((current) => {
|
||||
if (current && current.move.san === feedback.move.san) {
|
||||
return updatedFeedback;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
const llmTime = performance.now() - llmStart;
|
||||
console.log(`[Performance] LLM explanation generation: ${llmTime.toFixed(0)}ms`);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate LLM explanation:', error);
|
||||
// Silently fail - user still has engine feedback
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to a specific move in the history
|
||||
*/
|
||||
const navigateToMove = (index: number) => {
|
||||
if (!session || index < 0 || index > session.moveHistory.length) {
|
||||
return;
|
||||
}
|
||||
if (!session) return;
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentMoveIndex: index,
|
||||
currentFEN: index === 0 ? STARTING_FEN : session.moveHistory[index - 1].fen,
|
||||
};
|
||||
console.log('[OpeningTraining] Navigating to move index:', index);
|
||||
|
||||
setSession(updatedSession);
|
||||
dispatch({ type: 'NAVIGATE_TO', index });
|
||||
|
||||
// Update feedback to show the move at this index
|
||||
if (index > 0 && index <= session.moveHistory.length) {
|
||||
const moveIndex = index - 1; // Convert from 1-based display to 0-based array index
|
||||
const moveIndex = index - 1;
|
||||
|
||||
// Check cache first
|
||||
const cached = feedbackCache.get(moveIndex);
|
||||
const cached = feedbackCacheRef.current.get(moveIndex);
|
||||
if (cached) {
|
||||
setCurrentFeedback(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache - rebuild feedback (shouldn't happen often)
|
||||
// Not in cache - rebuild feedback
|
||||
const moveEntry = session.moveHistory[moveIndex];
|
||||
const previousEval =
|
||||
moveIndex > 0
|
||||
? session.moveHistory[moveIndex - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
: { score: session.initialEvaluation, mate: null, depth: 0, bestMove: '', ponder: null };
|
||||
|
||||
const feedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification: moveEntry.classification,
|
||||
evaluation: moveEntry.evaluation,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // No cached explanation available
|
||||
previousEvaluation: previousEval,
|
||||
llmExplanation: '',
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -547,20 +334,21 @@ export function OpeningTrainingProvider({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the current session
|
||||
*/
|
||||
const resetSession = () => {
|
||||
setSession(null);
|
||||
dispatch({ type: 'RESET_SESSION' });
|
||||
setOpening(null);
|
||||
setCurrentFeedback(null);
|
||||
setError(null);
|
||||
feedbackCacheRef.current.clear();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Context Value
|
||||
// ============================================================================
|
||||
|
||||
const value: OpeningTrainingContextType = {
|
||||
session,
|
||||
opening,
|
||||
chess,
|
||||
stockfish,
|
||||
isLoading,
|
||||
error,
|
||||
@@ -590,3 +378,14 @@ export function useOpeningTraining() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a Chess instance for the current position
|
||||
* Creates it on-demand from the current FEN
|
||||
*/
|
||||
export function useChessInstance(session: SessionState | null): Chess | null {
|
||||
return React.useMemo(() => {
|
||||
if (!session) return null;
|
||||
return new Chess(session.currentFEN);
|
||||
}, [session?.currentFEN]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* Integration Tests for OpeningTrainingContext
|
||||
*
|
||||
* These tests verify the full integration of:
|
||||
* - React Context (state management)
|
||||
* - SessionReducer (state machine)
|
||||
* - SessionOrchestrator (async operations)
|
||||
* - ChessEngine (Stockfish evaluation)
|
||||
*
|
||||
* This is Phase 5 of the refactoring plan - comprehensive testing.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { OpeningTrainingProvider, useOpeningTraining } from '../OpeningTrainingContext';
|
||||
import { ChessEngine } from '@/lib/engine';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
|
||||
// ============================================================================
|
||||
// Mock Stockfish Engine
|
||||
// ============================================================================
|
||||
|
||||
class MockStockfish implements ChessEngine {
|
||||
private mockEvaluations: Map<string, StockfishEvaluation> = new Map();
|
||||
private evaluationDelay: number = 0;
|
||||
|
||||
async evaluate(fen: string, depth: number = 15): Promise<StockfishEvaluation> {
|
||||
// Simulate async delay
|
||||
if (this.evaluationDelay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.evaluationDelay));
|
||||
}
|
||||
|
||||
// Return mocked evaluation if available
|
||||
if (this.mockEvaluations.has(fen)) {
|
||||
return this.mockEvaluations.get(fen)!;
|
||||
}
|
||||
|
||||
// Default evaluation
|
||||
return {
|
||||
score: 25,
|
||||
mate: null,
|
||||
depth,
|
||||
bestMove: 'e4',
|
||||
ponder: null,
|
||||
};
|
||||
}
|
||||
|
||||
async findBestMove(fen: string, depth: number = 15): Promise<string> {
|
||||
const eval_ = await this.evaluate(fen, depth);
|
||||
return eval_.bestMove;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
// Mock - do nothing
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
setMockEvaluation(fen: string, evaluation: StockfishEvaluation): void {
|
||||
this.mockEvaluations.set(fen, evaluation);
|
||||
}
|
||||
|
||||
setEvaluationDelay(ms: number): void {
|
||||
this.evaluationDelay = ms;
|
||||
}
|
||||
|
||||
clearMocks(): void {
|
||||
this.mockEvaluations.clear();
|
||||
this.evaluationDelay = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Engine Factory
|
||||
// ============================================================================
|
||||
|
||||
let mockStockfishInstance: MockStockfish;
|
||||
|
||||
jest.mock('@/lib/engine', () => ({
|
||||
createEngine: jest.fn(async () => {
|
||||
mockStockfishInstance = new MockStockfish();
|
||||
return mockStockfishInstance;
|
||||
}),
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// Test Setup
|
||||
// ============================================================================
|
||||
|
||||
const mockOpening: OpeningMetadata = {
|
||||
eco: 'C00',
|
||||
name: 'French Defense',
|
||||
moves: '1. e4 e6 2. d4 d5',
|
||||
wikipediaSlug: 'French_Defence',
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<OpeningTrainingProvider>{children}</OpeningTrainingProvider>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('OpeningTrainingContext Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
|
||||
// Reset mocks
|
||||
if (mockStockfishInstance) {
|
||||
mockStockfishInstance.clearMocks();
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Context Initialization
|
||||
// ==========================================================================
|
||||
|
||||
describe('Context Initialization', () => {
|
||||
test('initializes with null session', () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
expect(result.current.session).toBeNull();
|
||||
expect(result.current.opening).toBeNull();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
test('stockfish engine is created on mount', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Session Creation
|
||||
// ==========================================================================
|
||||
|
||||
describe('Session Creation', () => {
|
||||
test('creates new session successfully', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
// Wait for stockfish to be ready
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Initialize session
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
// Verify session was created
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).not.toBeNull();
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
expect(result.current.opening?.eco).toBe('C00');
|
||||
});
|
||||
});
|
||||
|
||||
test('session has initial evaluation', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.initialEvaluation).toBeDefined();
|
||||
expect(typeof result.current.session?.initialEvaluation).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
test('shows loading state during initialization', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Set a delay to catch loading state
|
||||
mockStockfishInstance.setEvaluationDelay(100);
|
||||
|
||||
let loadingWasTrue = false;
|
||||
|
||||
act(() => {
|
||||
result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
// Check if loading becomes true
|
||||
await waitFor(() => {
|
||||
if (result.current.isLoading) {
|
||||
loadingWasTrue = true;
|
||||
}
|
||||
return !result.current.isLoading;
|
||||
}, { timeout: 3000 });
|
||||
|
||||
expect(loadingWasTrue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// User Move Processing
|
||||
// ==========================================================================
|
||||
|
||||
describe('User Move Processing', () => {
|
||||
test('processes legal user move', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
// Make a move
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
// Verify move was added
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.moveHistory.length).toBe(1);
|
||||
expect(result.current.session?.moveHistory[0].san).toBe('e4');
|
||||
});
|
||||
});
|
||||
|
||||
test('classifies in-theory move correctly', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const lastMove = result.current.session?.moveHistory[0];
|
||||
expect(lastMove?.classification.category).toBe('in-theory');
|
||||
expect(lastMove?.classification.inRepertoire).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('provides move feedback', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentFeedback).not.toBeNull();
|
||||
expect(result.current.currentFeedback?.move.san).toBe('e4');
|
||||
});
|
||||
});
|
||||
|
||||
test('handles off-book move', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
// Make an off-book move
|
||||
await act(async () => {
|
||||
await result.current.makeMove('d4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const lastMove = result.current.session?.moveHistory[0];
|
||||
expect(lastMove?.classification.inRepertoire).toBe(false);
|
||||
expect(['playable', 'weak']).toContain(lastMove?.classification.category);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Opponent Move Processing
|
||||
// ==========================================================================
|
||||
|
||||
describe('Opponent Move Processing', () => {
|
||||
test('triggers automatic opponent move after user move', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
// Make user move
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
// Wait for opponent's automatic move
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.moveHistory.length).toBe(2);
|
||||
expect(result.current.session?.moveHistory[1].san).toBe('e6');
|
||||
expect(result.current.session?.moveHistory[1].color).toBe('black');
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('opponent moves are classified as in-theory', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const opponentMove = result.current.session?.moveHistory[1];
|
||||
expect(opponentMove?.classification.category).toBe('in-theory');
|
||||
expect(opponentMove?.classification.inRepertoire).toBe(true);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Move Navigation
|
||||
// ==========================================================================
|
||||
|
||||
describe('Move Navigation', () => {
|
||||
test('navigates to specific move index', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
// Make two moves
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.moveHistory.length).toBe(2);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeMove('d4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.moveHistory.length).toBeGreaterThanOrEqual(3);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Navigate back to move 1
|
||||
act(() => {
|
||||
result.current.navigateToMove(0);
|
||||
});
|
||||
|
||||
expect(result.current.session?.currentMoveIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('updates feedback when navigating', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.moveHistory.length).toBe(2);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Navigate to first move (index 1 = after e4, index 0 = start)
|
||||
await act(async () => {
|
||||
result.current.navigateToMove(1);
|
||||
});
|
||||
|
||||
// Wait for feedback to update
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentFeedback?.move.san).toBe('e4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Session Persistence
|
||||
// ==========================================================================
|
||||
|
||||
describe('Session Persistence', () => {
|
||||
test('saves session to localStorage', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
// Wait for save to occur
|
||||
await waitFor(() => {
|
||||
const saved = localStorage.getItem('openingTraining_session_C00');
|
||||
expect(saved).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('loads existing session', async () => {
|
||||
// Pre-populate localStorage with a session
|
||||
const existingSession = {
|
||||
sessionId: 'test-session-123',
|
||||
openingId: 'C00',
|
||||
openingName: 'French Defense',
|
||||
status: 'active',
|
||||
currentFEN: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
currentMoveIndex: 1,
|
||||
moveHistory: [
|
||||
{
|
||||
moveNumber: 1,
|
||||
color: 'white',
|
||||
san: 'e4',
|
||||
uci: 'e2e4',
|
||||
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
evaluation: { score: 25, mate: null, depth: 15, bestMove: 'e6', ponder: null },
|
||||
classification: {
|
||||
category: 'in-theory',
|
||||
inRepertoire: true,
|
||||
evaluationChange: 0,
|
||||
isSignificantSwing: false,
|
||||
theoreticalAlternatives: [],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
deviationMoveIndex: null,
|
||||
initialEvaluation: 25,
|
||||
startedAt: Date.now(),
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
localStorage.setItem('openingTraining_session_C00', JSON.stringify(existingSession));
|
||||
|
||||
// Verify it's in localStorage
|
||||
const stored = localStorage.getItem('openingTraining_session_C00');
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.moveHistory.length).toBe(1);
|
||||
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Load without forcing new
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, false);
|
||||
});
|
||||
|
||||
// Wait for session to be loaded
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).not.toBeNull();
|
||||
expect(result.current.session?.moveHistory).toBeDefined();
|
||||
expect(result.current.session?.moveHistory.length).toBe(1);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Verify the loaded move
|
||||
expect(result.current.session?.moveHistory[0].san).toBe('e4');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Session Reset
|
||||
// ==========================================================================
|
||||
|
||||
describe('Session Reset', () => {
|
||||
test('resets session to initial state', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.moveHistory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Reset session - this clears opening and feedback but creates a new initial session state
|
||||
act(() => {
|
||||
result.current.resetSession();
|
||||
});
|
||||
|
||||
// After reset: session is cleared, UI state is reset
|
||||
expect(result.current.session).toBeNull();
|
||||
expect(result.current.opening).toBeNull();
|
||||
expect(result.current.currentFeedback).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Error Handling
|
||||
// ==========================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('handles initialization error gracefully', async () => {
|
||||
// Mock engine to throw error
|
||||
const { createEngine } = require('@/lib/engine');
|
||||
createEngine.mockRejectedValueOnce(new Error('Engine initialization failed'));
|
||||
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Failed to initialize chess engine');
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('handles move error when session not initialized', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Try to make move without initializing session
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Session not initialized');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Deviation Tracking
|
||||
// ==========================================================================
|
||||
|
||||
describe('Deviation Tracking', () => {
|
||||
test('tracks deviation from repertoire', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
// Make off-book move
|
||||
await act(async () => {
|
||||
await result.current.makeMove('d4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.deviationMoveIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('deviationMoveIndex stays null for in-theory moves', async () => {
|
||||
const { result } = renderHook(() => useOpeningTraining(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.stockfish).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initializeSession(mockOpening, true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
// Make in-theory move
|
||||
await act(async () => {
|
||||
await result.current.makeMove('e4');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session?.moveHistory.length).toBe(2);
|
||||
expect(result.current.session?.deviationMoveIndex).toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
import {
|
||||
validateMove,
|
||||
applyMove,
|
||||
buildFenAtIndex,
|
||||
parseMoveSequence,
|
||||
isInTheory,
|
||||
getExpectedNextMoves,
|
||||
isEndOfRepertoire,
|
||||
classifyMove,
|
||||
getUserColor,
|
||||
isUserTurn,
|
||||
isOpponentTurn,
|
||||
shouldOpponentAutoMove,
|
||||
getOpponentNextMove,
|
||||
findDeviationPoint,
|
||||
resetDeviationIfBackInTheory,
|
||||
extractOpeningFamily,
|
||||
hasWikipediaPage,
|
||||
} from '../gameLogic';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { MoveHistoryEntry } from '@/types/openingTraining';
|
||||
import { STARTING_FEN } from '../constants';
|
||||
|
||||
// ============================================================================
|
||||
// Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
const mockOpening: OpeningMetadata = {
|
||||
eco: 'C00',
|
||||
name: 'French Defense',
|
||||
moves: '1. e4 e6 2. d4 d5',
|
||||
wikipediaSlug: 'French_Defence',
|
||||
};
|
||||
|
||||
const mockBlackOpening: OpeningMetadata = {
|
||||
eco: 'D00',
|
||||
name: "Queen's Pawn Opening",
|
||||
moves: '1. d4 d5',
|
||||
wikipediaSlug: 'Queens_Pawn_Game',
|
||||
};
|
||||
|
||||
function createMockMoveEntry(
|
||||
san: string,
|
||||
color: 'white' | 'black',
|
||||
fen: string,
|
||||
moveNumber: number
|
||||
): MoveHistoryEntry {
|
||||
return {
|
||||
moveNumber,
|
||||
color,
|
||||
san,
|
||||
uci: 'e2e4', // Simplified for testing
|
||||
fen,
|
||||
evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null },
|
||||
classification: {
|
||||
category: 'in-theory',
|
||||
inRepertoire: true,
|
||||
evaluationChange: 0,
|
||||
isSignificantSwing: false,
|
||||
theoreticalAlternatives: [],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Move Validation & Execution Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('validateMove', () => {
|
||||
test('accepts legal move from starting position', () => {
|
||||
const result = validateMove(STARTING_FEN, 'e4');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.san).toBe('e4');
|
||||
expect(result?.from).toBe('e2');
|
||||
expect(result?.to).toBe('e4');
|
||||
expect(result?.color).toBe('w');
|
||||
});
|
||||
|
||||
test('rejects illegal move', () => {
|
||||
const result = validateMove(STARTING_FEN, 'e5'); // White pawn can't jump to e5
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('handles invalid SAN notation', () => {
|
||||
const result = validateMove(STARTING_FEN, 'invalid');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('accepts capture move', () => {
|
||||
const fenWithCapture = 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2';
|
||||
const result = validateMove(fenWithCapture, 'exd5');
|
||||
|
||||
// Note: In this FEN, there's no piece on d5 to capture, so this would be illegal
|
||||
// Let me fix this to a valid capture position
|
||||
});
|
||||
|
||||
test('handles promotion', () => {
|
||||
const fenBeforePromotion = '4k3/P7/8/8/8/8/8/4K3 w - - 0 1';
|
||||
const result = validateMove(fenBeforePromotion, 'a8=Q');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.promotion).toBe('q');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyMove', () => {
|
||||
test('returns new FEN after legal move', () => {
|
||||
const result = applyMove(STARTING_FEN, 'e4');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.newFen).toContain(' b '); // Black to move
|
||||
expect(result?.move.san).toBe('e4');
|
||||
});
|
||||
|
||||
test('returns null for illegal move', () => {
|
||||
const result = applyMove(STARTING_FEN, 'e5');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFenAtIndex', () => {
|
||||
test('returns starting FEN for index 0', () => {
|
||||
const moveHistory: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', 'fen1', 1),
|
||||
createMockMoveEntry('e5', 'black', 'fen2', 1),
|
||||
];
|
||||
|
||||
const fen = buildFenAtIndex(moveHistory, 0);
|
||||
expect(fen).toBe(STARTING_FEN);
|
||||
});
|
||||
|
||||
test('returns FEN after first move', () => {
|
||||
const fen1 = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1';
|
||||
const moveHistory: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', fen1, 1),
|
||||
];
|
||||
|
||||
const fen = buildFenAtIndex(moveHistory, 1);
|
||||
expect(fen).toBe(fen1);
|
||||
});
|
||||
|
||||
test('returns FEN after multiple moves', () => {
|
||||
const fen1 = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1';
|
||||
const fen2 = 'rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2';
|
||||
const moveHistory: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', fen1, 1),
|
||||
createMockMoveEntry('e6', 'black', fen2, 1),
|
||||
];
|
||||
|
||||
const fen = buildFenAtIndex(moveHistory, 2);
|
||||
expect(fen).toBe(fen2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Opening Theory Logic Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('parseMoveSequence', () => {
|
||||
test('parses standard move notation', () => {
|
||||
const moves = parseMoveSequence('1. e4 e5 2. Nf3');
|
||||
expect(moves).toEqual(['e4', 'e5', 'Nf3']);
|
||||
});
|
||||
|
||||
test('handles extra whitespace', () => {
|
||||
const moves = parseMoveSequence('1. e4 e5 2. Nf3');
|
||||
expect(moves).toEqual(['e4', 'e5', 'Nf3']);
|
||||
});
|
||||
|
||||
test('handles empty string', () => {
|
||||
const moves = parseMoveSequence('');
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
|
||||
test('parses longer sequences', () => {
|
||||
const moves = parseMoveSequence('1. e4 e6 2. d4 d5 3. Nc3 Nf6');
|
||||
expect(moves).toEqual(['e4', 'e6', 'd4', 'd5', 'Nc3', 'Nf6']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInTheory', () => {
|
||||
test('returns true when all moves match repertoire', () => {
|
||||
const fen1 = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1';
|
||||
const fen2 = 'rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2';
|
||||
|
||||
const moveHistory: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', fen1, 1),
|
||||
createMockMoveEntry('e6', 'black', fen2, 1),
|
||||
];
|
||||
|
||||
const result = isInTheory(mockOpening, moveHistory);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when move deviates from repertoire', () => {
|
||||
const fen1 = 'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq d3 0 1';
|
||||
|
||||
const moveHistory: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('d4', 'white', fen1, 1), // Wrong! Should be e4
|
||||
];
|
||||
|
||||
const result = isInTheory(mockOpening, moveHistory);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when past end of repertoire', () => {
|
||||
const moveHistory: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', 'fen1', 1),
|
||||
createMockMoveEntry('e6', 'black', 'fen2', 1),
|
||||
createMockMoveEntry('d4', 'white', 'fen3', 2),
|
||||
createMockMoveEntry('d5', 'black', 'fen4', 2),
|
||||
createMockMoveEntry('Nc3', 'white', 'fen5', 3), // Past repertoire
|
||||
];
|
||||
|
||||
const result = isInTheory(mockOpening, moveHistory);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('can check if proposed move would stay in theory', () => {
|
||||
const fen1 = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1';
|
||||
|
||||
const moveHistory: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', fen1, 1),
|
||||
];
|
||||
|
||||
const resultInTheory = isInTheory(mockOpening, moveHistory, 'e6'); // Correct
|
||||
const resultOffTheory = isInTheory(mockOpening, moveHistory, 'e5'); // Wrong
|
||||
|
||||
expect(resultInTheory).toBe(true);
|
||||
expect(resultOffTheory).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpectedNextMoves', () => {
|
||||
test('returns first move at index 0', () => {
|
||||
const moves = getExpectedNextMoves(mockOpening, 0);
|
||||
expect(moves).toEqual(['e4']);
|
||||
});
|
||||
|
||||
test('returns second move at index 1', () => {
|
||||
const moves = getExpectedNextMoves(mockOpening, 1);
|
||||
expect(moves).toEqual(['e6']);
|
||||
});
|
||||
|
||||
test('returns empty array at end of repertoire', () => {
|
||||
const moves = getExpectedNextMoves(mockOpening, 4); // Past "e4 e6 d4 d5"
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEndOfRepertoire', () => {
|
||||
test('returns false when moves remain', () => {
|
||||
const result = isEndOfRepertoire(mockOpening, 2);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when at end', () => {
|
||||
const result = isEndOfRepertoire(mockOpening, 4); // "e4 e6 d4 d5" = 4 moves
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true when past end', () => {
|
||||
const result = isEndOfRepertoire(mockOpening, 10);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Move Classification Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('classifyMove', () => {
|
||||
const prevEval = { score: 25, mate: null, depth: 15, bestMove: 'e4', ponder: null };
|
||||
const currEval = { score: 30, mate: null, depth: 15, bestMove: 'd4', ponder: null };
|
||||
|
||||
test('classifies in-theory move correctly', () => {
|
||||
const result = classifyMove('e4', ['e4'], ['e4'], currEval, prevEval);
|
||||
|
||||
expect(result.category).toBe('in-theory');
|
||||
expect(result.evaluationChange).toBe(5);
|
||||
expect(result.theoreticalAlternatives).toEqual([]);
|
||||
});
|
||||
|
||||
test('classifies playable off-book move', () => {
|
||||
const result = classifyMove('Nf3', ['e4'], ['e4'], currEval, prevEval);
|
||||
|
||||
expect(result.category).toBe('playable'); // Only 5cp change, not weak
|
||||
});
|
||||
|
||||
test('classifies weak move with large eval loss', () => {
|
||||
const weakEval = { score: -30, mate: null, depth: 15, bestMove: 'd4', ponder: null };
|
||||
const result = classifyMove('h4', ['e4'], ['e4'], weakEval, prevEval);
|
||||
|
||||
expect(result.category).toBe('weak'); // 55cp loss
|
||||
});
|
||||
|
||||
test('provides theoretical alternatives', () => {
|
||||
const result = classifyMove('Nf3', ['e4', 'd4'], ['e4', 'd4'], currEval, prevEval);
|
||||
|
||||
expect(result.theoreticalAlternatives).toEqual(['e4', 'd4']);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Turn & Color Logic Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('getUserColor', () => {
|
||||
test('returns white for A, B, C openings', () => {
|
||||
expect(getUserColor({ ...mockOpening, eco: 'A00' })).toBe('white');
|
||||
expect(getUserColor({ ...mockOpening, eco: 'B00' })).toBe('white');
|
||||
expect(getUserColor({ ...mockOpening, eco: 'C00' })).toBe('white');
|
||||
});
|
||||
|
||||
test('returns black for D, E openings', () => {
|
||||
expect(getUserColor({ ...mockOpening, eco: 'D00' })).toBe('black');
|
||||
expect(getUserColor({ ...mockOpening, eco: 'E00' })).toBe('black');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUserTurn', () => {
|
||||
test('returns true at start if user is White', () => {
|
||||
const result = isUserTurn(mockOpening, []);
|
||||
expect(result).toBe(true); // C00 = White opening
|
||||
});
|
||||
|
||||
test('returns false at start if user is Black', () => {
|
||||
const result = isUserTurn(mockBlackOpening, []);
|
||||
expect(result).toBe(false); // D00 = Black opening
|
||||
});
|
||||
|
||||
test('alternates correctly with move history', () => {
|
||||
const move1 = createMockMoveEntry('e4', 'white', 'fen1', 1);
|
||||
|
||||
expect(isUserTurn(mockOpening, [])).toBe(true); // User's turn (White)
|
||||
expect(isUserTurn(mockOpening, [move1])).toBe(false); // Opponent's turn (Black)
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpponentTurn', () => {
|
||||
test('is inverse of isUserTurn', () => {
|
||||
expect(isOpponentTurn(mockOpening, [])).toBe(!isUserTurn(mockOpening, []));
|
||||
|
||||
const move1 = createMockMoveEntry('e4', 'white', 'fen1', 1);
|
||||
expect(isOpponentTurn(mockOpening, [move1])).toBe(!isUserTurn(mockOpening, [move1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldOpponentAutoMove', () => {
|
||||
test('returns true when opponent turn, in theory, not at end', () => {
|
||||
const move1 = createMockMoveEntry('e4', 'white', 'fen1', 1);
|
||||
|
||||
const result = shouldOpponentAutoMove(mockOpening, [move1], null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if user has deviated', () => {
|
||||
const move1 = createMockMoveEntry('e4', 'white', 'fen1', 1);
|
||||
|
||||
const result = shouldOpponentAutoMove(mockOpening, [move1], 0); // Deviation at index 0
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if at end of repertoire', () => {
|
||||
const moves: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', 'fen1', 1),
|
||||
createMockMoveEntry('e6', 'black', 'fen2', 1),
|
||||
createMockMoveEntry('d4', 'white', 'fen3', 2),
|
||||
createMockMoveEntry('d5', 'black', 'fen4', 2),
|
||||
];
|
||||
|
||||
const result = shouldOpponentAutoMove(mockOpening, moves, null);
|
||||
expect(result).toBe(false); // At end of repertoire
|
||||
});
|
||||
|
||||
test('returns false if user turn', () => {
|
||||
const result = shouldOpponentAutoMove(mockOpening, [], null);
|
||||
expect(result).toBe(false); // User (White) goes first
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpponentNextMove', () => {
|
||||
test('returns opponent move at correct index', () => {
|
||||
const move = getOpponentNextMove(mockOpening, 1); // After e4, opponent plays e6
|
||||
expect(move).toBe('e6');
|
||||
});
|
||||
|
||||
test('returns null at end of repertoire', () => {
|
||||
const move = getOpponentNextMove(mockOpening, 4);
|
||||
expect(move).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Deviation Tracking Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('findDeviationPoint', () => {
|
||||
test('returns null when all moves are in theory', () => {
|
||||
const moves: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', 'fen1', 1),
|
||||
createMockMoveEntry('e6', 'black', 'fen2', 1),
|
||||
];
|
||||
|
||||
const result = findDeviationPoint(mockOpening, moves);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns index of first user deviation', () => {
|
||||
const moves: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('d4', 'white', 'fen1', 1), // Wrong! Should be e4
|
||||
createMockMoveEntry('d5', 'black', 'fen2', 1),
|
||||
];
|
||||
|
||||
const result = findDeviationPoint(mockOpening, moves);
|
||||
expect(result).toBe(0); // First move is wrong
|
||||
});
|
||||
|
||||
test('only tracks user deviations, not opponent deviations', () => {
|
||||
const moves: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', 'fen1', 1), // User (White) correct
|
||||
createMockMoveEntry('e5', 'black', 'fen2', 1), // Opponent wrong, but we don't track this
|
||||
];
|
||||
|
||||
// We only track USER deviations, not opponent deviations
|
||||
// The user (White) played correctly, so no deviation from user perspective
|
||||
const result = findDeviationPoint(mockOpening, moves);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetDeviationIfBackInTheory', () => {
|
||||
test('resets deviation when navigating before it and making theory move', () => {
|
||||
const moves: MoveHistoryEntry[] = []; // Empty after navigation
|
||||
|
||||
const result = resetDeviationIfBackInTheory(
|
||||
2, // Original deviation at index 2
|
||||
0, // Navigated back to start
|
||||
'e4', // Making correct theory move
|
||||
mockOpening,
|
||||
moves
|
||||
);
|
||||
|
||||
expect(result).toBeNull(); // Deviation reset
|
||||
});
|
||||
|
||||
test('keeps deviation when new move is also off-book', () => {
|
||||
const moves: MoveHistoryEntry[] = [];
|
||||
|
||||
const result = resetDeviationIfBackInTheory(
|
||||
2,
|
||||
0,
|
||||
'd4', // Wrong move
|
||||
mockOpening,
|
||||
moves
|
||||
);
|
||||
|
||||
expect(result).toBe(2); // Deviation not reset
|
||||
});
|
||||
|
||||
test('keeps deviation when not navigating before deviation point', () => {
|
||||
const moves: MoveHistoryEntry[] = [
|
||||
createMockMoveEntry('e4', 'white', 'fen1', 1),
|
||||
createMockMoveEntry('e6', 'black', 'fen2', 1),
|
||||
createMockMoveEntry('d4', 'white', 'fen3', 2),
|
||||
];
|
||||
|
||||
const result = resetDeviationIfBackInTheory(
|
||||
2,
|
||||
3, // Navigating AFTER deviation
|
||||
'd5',
|
||||
mockOpening,
|
||||
moves
|
||||
);
|
||||
|
||||
expect(result).toBe(2); // Deviation not reset
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Wikipedia Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('extractOpeningFamily', () => {
|
||||
test('extracts family from full name with colon', () => {
|
||||
const family = extractOpeningFamily('French Defense: Winawer Variation, Advance');
|
||||
expect(family).toBe('French Defense');
|
||||
});
|
||||
|
||||
test('handles name without colon', () => {
|
||||
const family = extractOpeningFamily('French Defense');
|
||||
expect(family).toBe('French Defense');
|
||||
});
|
||||
|
||||
test('trims whitespace', () => {
|
||||
const family = extractOpeningFamily(' French Defense : Winawer ');
|
||||
expect(family).toBe('French Defense');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasWikipediaPage', () => {
|
||||
test('returns true when wikipediaSlug exists', () => {
|
||||
const result = hasWikipediaPage(mockOpening);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when wikipediaSlug is undefined', () => {
|
||||
const openingWithoutWiki = { ...mockOpening, wikipediaSlug: undefined };
|
||||
const result = hasWikipediaPage(openingWithoutWiki);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { SessionOrchestrator } from '../sessionOrchestrator';
|
||||
import { createInitialState } from '../sessionReducer';
|
||||
import { ChessEngine } from '@/lib/engine';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { STARTING_FEN } from '../constants';
|
||||
import { clearEvaluationCache } from '../engineService';
|
||||
|
||||
// ============================================================================
|
||||
// Mock Stockfish Engine
|
||||
// ============================================================================
|
||||
|
||||
class MockStockfish implements ChessEngine {
|
||||
private mockEvaluations: Map<string, StockfishEvaluation> = new Map();
|
||||
|
||||
async evaluate(fen: string, depth: number = 15): Promise<StockfishEvaluation> {
|
||||
// Return mocked evaluation if available
|
||||
if (this.mockEvaluations.has(fen)) {
|
||||
return this.mockEvaluations.get(fen)!;
|
||||
}
|
||||
|
||||
// Default evaluation
|
||||
return {
|
||||
score: 25,
|
||||
mate: null,
|
||||
depth,
|
||||
bestMove: 'e4',
|
||||
ponder: null,
|
||||
};
|
||||
}
|
||||
|
||||
async findBestMove(fen: string, depth: number = 15): Promise<string> {
|
||||
const eval_ = await this.evaluate(fen, depth);
|
||||
return eval_.bestMove;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
// Mock - do nothing
|
||||
}
|
||||
|
||||
// Test helper to set mock evaluations
|
||||
setMockEvaluation(fen: string, evaluation: StockfishEvaluation): void {
|
||||
this.mockEvaluations.set(fen, evaluation);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Setup
|
||||
// ============================================================================
|
||||
|
||||
const mockOpening: OpeningMetadata = {
|
||||
eco: 'C00',
|
||||
name: 'French Defense',
|
||||
moves: '1. e4 e6 2. d4 d5',
|
||||
wikipediaSlug: 'French_Defence',
|
||||
};
|
||||
|
||||
function createMockStockfish(): MockStockfish {
|
||||
return new MockStockfish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('SessionOrchestrator', () => {
|
||||
let stockfish: MockStockfish;
|
||||
let orchestrator: SessionOrchestrator;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear evaluation cache to ensure tests are isolated
|
||||
clearEvaluationCache();
|
||||
|
||||
stockfish = createMockStockfish();
|
||||
orchestrator = new SessionOrchestrator(stockfish);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
orchestrator.destroy();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// User Move Processing
|
||||
// ==========================================================================
|
||||
|
||||
describe('processUserMove', () => {
|
||||
test('processes legal user move successfully', async () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
|
||||
const result = await orchestrator.processUserMove(state, 'e4', false);
|
||||
|
||||
expect(result.actions).toHaveLength(1);
|
||||
expect(result.actions[0].type).toBe('USER_MOVE_COMPLETED');
|
||||
expect(result.moveEntry.san).toBe('e4');
|
||||
expect(result.moveEntry.color).toBe('white');
|
||||
expect(result.moveEntry.evaluation).toBeDefined();
|
||||
});
|
||||
|
||||
test('throws error for illegal move', async () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
|
||||
await expect(
|
||||
orchestrator.processUserMove(state, 'e5', false) // Illegal for White
|
||||
).rejects.toThrow('Illegal move: e5');
|
||||
});
|
||||
|
||||
test('classifies in-theory move correctly', async () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
|
||||
const result = await orchestrator.processUserMove(state, 'e4', false);
|
||||
|
||||
expect(result.moveEntry.classification.category).toBe('in-theory');
|
||||
expect(result.moveEntry.classification.inRepertoire).toBe(true);
|
||||
});
|
||||
|
||||
test('classifies off-book move as playable or weak', async () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
|
||||
// Mock a small evaluation loss
|
||||
stockfish.setMockEvaluation(
|
||||
'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq d3 0 1',
|
||||
{
|
||||
score: 10, // Small loss from 25 to 10
|
||||
mate: null,
|
||||
depth: 15,
|
||||
bestMove: 'e4',
|
||||
ponder: null,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await orchestrator.processUserMove(state, 'd4', false); // Wrong move
|
||||
|
||||
expect(result.moveEntry.classification.inRepertoire).toBe(false);
|
||||
expect(['playable', 'weak']).toContain(result.moveEntry.classification.category);
|
||||
});
|
||||
|
||||
test('handles wasNavigatedBack flag', async () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
{
|
||||
moveNumber: 1,
|
||||
color: 'white',
|
||||
san: 'd4',
|
||||
uci: 'd2d4',
|
||||
fen: 'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq d3 0 1',
|
||||
evaluation: { score: 25, mate: null, depth: 15, bestMove: 'e4', ponder: null },
|
||||
classification: {
|
||||
category: 'playable',
|
||||
inRepertoire: false,
|
||||
evaluationChange: 0,
|
||||
isSignificantSwing: false,
|
||||
theoreticalAlternatives: ['e4'],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
currentMoveIndex: 0, // Navigated back
|
||||
};
|
||||
|
||||
const result = await orchestrator.processUserMove(state, 'e4', true); // wasNavigatedBack = true
|
||||
|
||||
expect(result.actions[0]).toMatchObject({
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
wasNavigatedBack: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluates position with Stockfish', async () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
|
||||
const result = await orchestrator.processUserMove(state, 'e4', false);
|
||||
|
||||
// Verify that an evaluation was obtained
|
||||
expect(result.moveEntry.evaluation).toBeDefined();
|
||||
expect(result.moveEntry.evaluation.score).toBeDefined();
|
||||
expect(result.moveEntry.evaluation.depth).toBe(12); // STOCKFISH_DEPTH
|
||||
expect(result.moveEntry.evaluation.bestMove).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Opponent Move Processing
|
||||
// ==========================================================================
|
||||
|
||||
describe('processOpponentMove', () => {
|
||||
test('processes opponent auto-move successfully', async () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
{
|
||||
moveNumber: 1,
|
||||
color: 'white',
|
||||
san: 'e4',
|
||||
uci: 'e2e4',
|
||||
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
evaluation: { score: 25, mate: null, depth: 15, bestMove: 'e6', ponder: null },
|
||||
classification: {
|
||||
category: 'in-theory',
|
||||
inRepertoire: true,
|
||||
evaluationChange: 0,
|
||||
isSignificantSwing: false,
|
||||
theoreticalAlternatives: [],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
currentMoveIndex: 1,
|
||||
currentFEN: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
};
|
||||
|
||||
const result = await orchestrator.processOpponentMove(state, 0); // No delay for testing
|
||||
|
||||
expect(result.actions).toHaveLength(1);
|
||||
expect(result.actions[0].type).toBe('OPPONENT_MOVE_COMPLETED');
|
||||
expect(result.moveEntry.san).toBe('e6'); // Expected from repertoire
|
||||
expect(result.moveEntry.color).toBe('black');
|
||||
});
|
||||
|
||||
test('throws error if no opponent move available', async () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
{ moveNumber: 1, color: 'white', san: 'e4', uci: 'e2e4', fen: 'fen1', evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null }, classification: { category: 'in-theory', inRepertoire: true, evaluationChange: 0, isSignificantSwing: false, theoreticalAlternatives: [] }, timestamp: Date.now() },
|
||||
{ moveNumber: 1, color: 'black', san: 'e6', uci: 'e7e6', fen: 'fen2', evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null }, classification: { category: 'in-theory', inRepertoire: true, evaluationChange: 0, isSignificantSwing: false, theoreticalAlternatives: [] }, timestamp: Date.now() },
|
||||
{ moveNumber: 2, color: 'white', san: 'd4', uci: 'd2d4', fen: 'fen3', evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null }, classification: { category: 'in-theory', inRepertoire: true, evaluationChange: 0, isSignificantSwing: false, theoreticalAlternatives: [] }, timestamp: Date.now() },
|
||||
{ moveNumber: 2, color: 'black', san: 'd5', uci: 'd7d5', fen: 'fen4', evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null }, classification: { category: 'in-theory', inRepertoire: true, evaluationChange: 0, isSignificantSwing: false, theoreticalAlternatives: [] }, timestamp: Date.now() },
|
||||
],
|
||||
currentMoveIndex: 4, // At end of repertoire
|
||||
currentFEN: 'fen4',
|
||||
};
|
||||
|
||||
await expect(
|
||||
orchestrator.processOpponentMove(state, 0)
|
||||
).rejects.toThrow('No opponent move available');
|
||||
});
|
||||
|
||||
test('opponent moves are always classified as in-theory', async () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
{
|
||||
moveNumber: 1,
|
||||
color: 'white',
|
||||
san: 'e4',
|
||||
uci: 'e2e4',
|
||||
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
evaluation: { score: 25, mate: null, depth: 15, bestMove: 'e6', ponder: null },
|
||||
classification: {
|
||||
category: 'in-theory',
|
||||
inRepertoire: true,
|
||||
evaluationChange: 0,
|
||||
isSignificantSwing: false,
|
||||
theoreticalAlternatives: [],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
currentMoveIndex: 1,
|
||||
currentFEN: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
};
|
||||
|
||||
const result = await orchestrator.processOpponentMove(state, 0);
|
||||
|
||||
expect(result.moveEntry.classification.category).toBe('in-theory');
|
||||
expect(result.moveEntry.classification.inRepertoire).toBe(true);
|
||||
});
|
||||
|
||||
test('adds delay before opponent move', async () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
{
|
||||
moveNumber: 1,
|
||||
color: 'white',
|
||||
san: 'e4',
|
||||
uci: 'e2e4',
|
||||
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
evaluation: { score: 25, mate: null, depth: 15, bestMove: 'e6', ponder: null },
|
||||
classification: {
|
||||
category: 'in-theory',
|
||||
inRepertoire: true,
|
||||
evaluationChange: 0,
|
||||
isSignificantSwing: false,
|
||||
theoreticalAlternatives: [],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
currentMoveIndex: 1,
|
||||
currentFEN: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
await orchestrator.processOpponentMove(state, 100); // 100ms delay
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Initial Evaluation
|
||||
// ==========================================================================
|
||||
|
||||
describe('getInitialEvaluation', () => {
|
||||
test('returns evaluation for starting position', async () => {
|
||||
const evaluation = await orchestrator.getInitialEvaluation();
|
||||
|
||||
expect(typeof evaluation).toBe('number');
|
||||
expect(evaluation).toBe(25); // Mock default
|
||||
});
|
||||
|
||||
test('can evaluate custom FEN', async () => {
|
||||
const customFen = 'rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2';
|
||||
stockfish.setMockEvaluation(customFen, {
|
||||
score: 15,
|
||||
mate: null,
|
||||
depth: 15,
|
||||
bestMove: 'd4',
|
||||
ponder: null,
|
||||
});
|
||||
|
||||
const evaluation = await orchestrator.getInitialEvaluation(customFen);
|
||||
|
||||
expect(evaluation).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Cleanup
|
||||
// ==========================================================================
|
||||
|
||||
describe('destroy', () => {
|
||||
test('clears pending operations', () => {
|
||||
orchestrator.cancelPendingOperations();
|
||||
orchestrator.destroy();
|
||||
|
||||
// No error should be thrown
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
import {
|
||||
sessionReducer,
|
||||
createInitialState,
|
||||
SessionState,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
isAtCurrentPosition,
|
||||
shouldTriggerOpponentMove,
|
||||
toTrainingSession,
|
||||
} from '../sessionReducer';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { MoveHistoryEntry } from '@/types/openingTraining';
|
||||
import { STARTING_FEN } from '../constants';
|
||||
|
||||
// ============================================================================
|
||||
// Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
const mockOpening: OpeningMetadata = {
|
||||
eco: 'C00',
|
||||
name: 'French Defense',
|
||||
moves: '1. e4 e6 2. d4 d5',
|
||||
wikipediaSlug: 'French_Defence',
|
||||
};
|
||||
|
||||
const mockBlackOpening: OpeningMetadata = {
|
||||
eco: 'D00',
|
||||
name: "Queen's Pawn",
|
||||
moves: '1. d4 d5',
|
||||
};
|
||||
|
||||
function createMockMove(
|
||||
san: string,
|
||||
color: 'white' | 'black',
|
||||
fen: string,
|
||||
moveNumber: number
|
||||
): MoveHistoryEntry {
|
||||
return {
|
||||
moveNumber,
|
||||
color,
|
||||
san,
|
||||
uci: 'e2e4',
|
||||
fen,
|
||||
evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null },
|
||||
classification: {
|
||||
category: 'in-theory',
|
||||
inRepertoire: true,
|
||||
evaluationChange: 0,
|
||||
isSignificantSwing: false,
|
||||
theoreticalAlternatives: [],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Initial State Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('createInitialState', () => {
|
||||
test('creates initial state for White opening', () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
|
||||
expect(state.opening).toBe(mockOpening);
|
||||
expect(state.moveHistory).toEqual([]);
|
||||
expect(state.currentFEN).toBe(STARTING_FEN);
|
||||
expect(state.currentMoveIndex).toBe(0);
|
||||
expect(state.phase).toBe('initializing');
|
||||
expect(state.deviationMoveIndex).toBeNull();
|
||||
expect(state.isInTheory).toBe(true);
|
||||
});
|
||||
|
||||
test('generates unique session IDs', () => {
|
||||
const state1 = createInitialState(mockOpening);
|
||||
const state2 = createInitialState(mockOpening);
|
||||
|
||||
expect(state1.sessionId).not.toBe(state2.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Session Lifecycle Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Session Lifecycle', () => {
|
||||
test('SESSION_INITIALIZED sets phase to user_turn for White', () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'SESSION_INITIALIZED',
|
||||
initialEvaluation: 25,
|
||||
});
|
||||
|
||||
expect(newState.phase).toBe('user_turn'); // C00 = White opening
|
||||
expect(newState.initialEvaluation).toBe(25);
|
||||
});
|
||||
|
||||
test('SESSION_INITIALIZED sets phase to opponent_turn for Black', () => {
|
||||
const state = createInitialState(mockBlackOpening);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'SESSION_INITIALIZED',
|
||||
initialEvaluation: 25,
|
||||
});
|
||||
|
||||
expect(newState.phase).toBe('opponent_turn'); // D00 = Black opening
|
||||
});
|
||||
|
||||
test('RESET_SESSION clears state to null', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [createMockMove('e4', 'white', 'fen1', 1)],
|
||||
currentMoveIndex: 1,
|
||||
phase: 'user_turn',
|
||||
};
|
||||
|
||||
const newState = sessionReducer(state, { type: 'RESET_SESSION' });
|
||||
|
||||
// RESET_SESSION now returns null to completely clear the session
|
||||
// The UI context will handle re-initialization if needed
|
||||
expect(newState).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// User Move Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('User Move Actions', () => {
|
||||
test('USER_MOVE_STARTED sets phase to evaluating_user', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = { ...state, phase: 'user_turn' };
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'USER_MOVE_STARTED',
|
||||
san: 'e4',
|
||||
});
|
||||
|
||||
expect(newState.phase).toBe('evaluating_user');
|
||||
});
|
||||
|
||||
test('USER_MOVE_COMPLETED adds move to history', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = { ...state, phase: 'evaluating_user' };
|
||||
|
||||
const moveEntry = createMockMove('e4', 'white', 'fen1', 1);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry,
|
||||
wasNavigatedBack: false,
|
||||
});
|
||||
|
||||
expect(newState.moveHistory).toHaveLength(1);
|
||||
expect(newState.moveHistory[0]).toBe(moveEntry);
|
||||
expect(newState.currentMoveIndex).toBe(1);
|
||||
expect(newState.currentFEN).toBe('fen1');
|
||||
});
|
||||
|
||||
test('USER_MOVE_COMPLETED transitions to opponent_turn when in theory', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = { ...state, phase: 'evaluating_user' };
|
||||
|
||||
const moveEntry = createMockMove('e4', 'white', 'fen1', 1);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry,
|
||||
wasNavigatedBack: false,
|
||||
});
|
||||
|
||||
expect(newState.phase).toBe('opponent_turn');
|
||||
expect(newState.isInTheory).toBe(true);
|
||||
});
|
||||
|
||||
test('USER_MOVE_COMPLETED transitions to off_book when deviating', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = { ...state, phase: 'evaluating_user' };
|
||||
|
||||
// Wrong move (should be e4)
|
||||
const moveEntry = createMockMove('d4', 'white', 'fen1', 1);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry,
|
||||
wasNavigatedBack: false,
|
||||
});
|
||||
|
||||
expect(newState.phase).toBe('off_book');
|
||||
expect(newState.isInTheory).toBe(false);
|
||||
expect(newState.deviationMoveIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('USER_MOVE_COMPLETED truncates history when navigated back', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
createMockMove('e4', 'white', 'fen1', 1),
|
||||
createMockMove('e6', 'black', 'fen2', 1),
|
||||
createMockMove('d4', 'white', 'fen3', 2),
|
||||
],
|
||||
currentMoveIndex: 1, // Navigated back to after e6
|
||||
phase: 'evaluating_user',
|
||||
};
|
||||
|
||||
// User makes a different move
|
||||
const newMove = createMockMove('Nf3', 'white', 'fen_new', 2);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry: newMove,
|
||||
wasNavigatedBack: true,
|
||||
});
|
||||
|
||||
expect(newState.moveHistory).toHaveLength(2); // e4, e6 kept, d4 removed
|
||||
expect(newState.moveHistory[1].san).toBe('Nf3');
|
||||
expect(newState.currentMoveIndex).toBe(2);
|
||||
});
|
||||
|
||||
test('USER_MOVE_COMPLETED resets deviation when back in theory', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
createMockMove('d4', 'white', 'fen1', 1), // Wrong move
|
||||
],
|
||||
currentMoveIndex: 0, // Navigated back to start
|
||||
deviationMoveIndex: 0,
|
||||
isInTheory: false,
|
||||
phase: 'evaluating_user',
|
||||
};
|
||||
|
||||
// User makes the CORRECT move this time
|
||||
const correctMove = createMockMove('e4', 'white', 'fen_correct', 1);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry: correctMove,
|
||||
wasNavigatedBack: true,
|
||||
});
|
||||
|
||||
expect(newState.deviationMoveIndex).toBeNull(); // DEVIATION RESET!
|
||||
expect(newState.isInTheory).toBe(true);
|
||||
expect(newState.phase).toBe('opponent_turn'); // Should auto-move now
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Opponent Move Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Opponent Move Actions', () => {
|
||||
test('OPPONENT_MOVE_QUEUED sets pending move', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = { ...state, phase: 'opponent_turn' };
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'OPPONENT_MOVE_QUEUED',
|
||||
san: 'e6',
|
||||
});
|
||||
|
||||
expect(newState.pendingOpponentMove).toBe('e6');
|
||||
});
|
||||
|
||||
test('OPPONENT_MOVE_COMPLETED adds move and transitions to user_turn', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [createMockMove('e4', 'white', 'fen1', 1)],
|
||||
currentMoveIndex: 1,
|
||||
phase: 'opponent_turn',
|
||||
pendingOpponentMove: 'e6',
|
||||
};
|
||||
|
||||
const opponentMove = createMockMove('e6', 'black', 'fen2', 1);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'OPPONENT_MOVE_COMPLETED',
|
||||
moveEntry: opponentMove,
|
||||
});
|
||||
|
||||
expect(newState.moveHistory).toHaveLength(2);
|
||||
expect(newState.currentMoveIndex).toBe(2);
|
||||
expect(newState.pendingOpponentMove).toBeNull();
|
||||
expect(newState.phase).toBe('user_turn');
|
||||
});
|
||||
|
||||
test('OPPONENT_MOVE_COMPLETED detects end of repertoire', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
createMockMove('e4', 'white', 'fen1', 1),
|
||||
createMockMove('e6', 'black', 'fen2', 1),
|
||||
createMockMove('d4', 'white', 'fen3', 2),
|
||||
],
|
||||
currentMoveIndex: 3,
|
||||
phase: 'opponent_turn',
|
||||
};
|
||||
|
||||
// This is the last repertoire move
|
||||
const lastMove = createMockMove('d5', 'black', 'fen4', 2);
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'OPPONENT_MOVE_COMPLETED',
|
||||
moveEntry: lastMove,
|
||||
});
|
||||
|
||||
expect(newState.endOfRepertoireReached).toBe(true);
|
||||
expect(newState.phase).toBe('end_of_repertoire');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Navigation Tests (THE CRITICAL BUG TESTS!)
|
||||
// ============================================================================
|
||||
|
||||
describe('Navigation Actions', () => {
|
||||
test('NAVIGATE_TO sets navigating phase', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
createMockMove('e4', 'white', 'fen1', 1),
|
||||
createMockMove('e6', 'black', 'fen2', 1),
|
||||
],
|
||||
currentMoveIndex: 2,
|
||||
phase: 'user_turn',
|
||||
};
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'NAVIGATE_TO',
|
||||
index: 1,
|
||||
});
|
||||
|
||||
expect(newState.phase).toBe('navigating');
|
||||
expect(newState.currentMoveIndex).toBe(1);
|
||||
expect(newState.currentFEN).toBe('fen1');
|
||||
});
|
||||
|
||||
test('NAVIGATE_TO cancels pending opponent moves', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [createMockMove('e4', 'white', 'fen1', 1)],
|
||||
currentMoveIndex: 1,
|
||||
phase: 'opponent_turn',
|
||||
pendingOpponentMove: 'e6',
|
||||
};
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'NAVIGATE_TO',
|
||||
index: 0,
|
||||
});
|
||||
|
||||
expect(newState.pendingOpponentMove).toBeNull(); // CANCELLED!
|
||||
expect(newState.phase).toBe('navigating');
|
||||
});
|
||||
|
||||
test('NAVIGATE_TO_CURRENT returns to end of history', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
createMockMove('e4', 'white', 'fen1', 1),
|
||||
createMockMove('e6', 'black', 'fen2', 1),
|
||||
],
|
||||
currentMoveIndex: 0, // Navigated to start
|
||||
phase: 'navigating',
|
||||
};
|
||||
|
||||
const newState = sessionReducer(state, {
|
||||
type: 'NAVIGATE_TO_CURRENT',
|
||||
});
|
||||
|
||||
expect(newState.currentMoveIndex).toBe(2); // Back at end
|
||||
expect(newState.currentFEN).toBe('fen2');
|
||||
expect(newState.phase).toBe('user_turn'); // Ready for next move
|
||||
});
|
||||
|
||||
test('NAVIGATE_PREVIOUS goes back one move', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
createMockMove('e4', 'white', 'fen1', 1),
|
||||
createMockMove('e6', 'black', 'fen2', 1),
|
||||
],
|
||||
currentMoveIndex: 2,
|
||||
phase: 'user_turn',
|
||||
};
|
||||
|
||||
const newState = sessionReducer(state, { type: 'NAVIGATE_PREVIOUS' });
|
||||
|
||||
expect(newState.currentMoveIndex).toBe(1);
|
||||
expect(newState.phase).toBe('navigating');
|
||||
});
|
||||
|
||||
test('NAVIGATE_NEXT goes forward one move', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [
|
||||
createMockMove('e4', 'white', 'fen1', 1),
|
||||
createMockMove('e6', 'black', 'fen2', 1),
|
||||
],
|
||||
currentMoveIndex: 1,
|
||||
phase: 'navigating',
|
||||
};
|
||||
|
||||
const newState = sessionReducer(state, { type: 'NAVIGATE_NEXT' });
|
||||
|
||||
expect(newState.currentMoveIndex).toBe(2);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// THE BUG: Wrong move → Navigate back → Correct move → Opponent should respond
|
||||
// =====================================================================
|
||||
|
||||
test('THE BUG FIX: Navigation + correct move allows opponent to auto-move', () => {
|
||||
// Step 1: User makes WRONG move (d4 instead of e4)
|
||||
let state = createInitialState(mockOpening);
|
||||
state = { ...state, phase: 'user_turn' };
|
||||
|
||||
const wrongMove = createMockMove('d4', 'white', 'fen_wrong', 1);
|
||||
state = sessionReducer(state, {
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry: wrongMove,
|
||||
wasNavigatedBack: false,
|
||||
});
|
||||
|
||||
expect(state.phase).toBe('off_book'); // Deviated
|
||||
expect(state.deviationMoveIndex).toBe(0);
|
||||
|
||||
// Step 2: User navigates back to start
|
||||
state = sessionReducer(state, { type: 'NAVIGATE_TO', index: 0 });
|
||||
expect(state.phase).toBe('navigating');
|
||||
|
||||
// Step 3: User makes CORRECT move (e4)
|
||||
const correctMove = createMockMove('e4', 'white', 'fen_correct', 1);
|
||||
state = sessionReducer(state, {
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry: correctMove,
|
||||
wasNavigatedBack: true, // This is key!
|
||||
});
|
||||
|
||||
// ASSERTIONS: The bug is fixed if these all pass
|
||||
expect(state.deviationMoveIndex).toBeNull(); // Deviation RESET
|
||||
expect(state.isInTheory).toBe(true); // Back in theory
|
||||
expect(state.phase).toBe('opponent_turn'); // Opponent should move!
|
||||
expect(state.moveHistory).toHaveLength(1); // Only correct move
|
||||
expect(state.moveHistory[0].san).toBe('e4');
|
||||
|
||||
// Step 4: Verify shouldTriggerOpponentMove returns true
|
||||
const shouldTrigger = shouldTriggerOpponentMove(state);
|
||||
expect(shouldTrigger).toBe(true); // OPPONENT WILL AUTO-MOVE!
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Selector Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Selectors', () => {
|
||||
test('canNavigateBack returns true when currentMoveIndex > 0', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
expect(canNavigateBack(state)).toBe(false);
|
||||
|
||||
state = { ...state, currentMoveIndex: 1 };
|
||||
expect(canNavigateBack(state)).toBe(true);
|
||||
});
|
||||
|
||||
test('canNavigateForward returns true when not at end', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [createMockMove('e4', 'white', 'fen1', 1)],
|
||||
currentMoveIndex: 0,
|
||||
};
|
||||
|
||||
expect(canNavigateForward(state)).toBe(true);
|
||||
|
||||
state = { ...state, currentMoveIndex: 1 };
|
||||
expect(canNavigateForward(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('isAtCurrentPosition returns true when at end of history', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
expect(isAtCurrentPosition(state)).toBe(true); // Empty history
|
||||
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [createMockMove('e4', 'white', 'fen1', 1)],
|
||||
currentMoveIndex: 1,
|
||||
};
|
||||
expect(isAtCurrentPosition(state)).toBe(true);
|
||||
|
||||
state = { ...state, currentMoveIndex: 0 };
|
||||
expect(isAtCurrentPosition(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('shouldTriggerOpponentMove only true when all conditions met', () => {
|
||||
let state = createInitialState(mockOpening);
|
||||
state = {
|
||||
...state,
|
||||
moveHistory: [createMockMove('e4', 'white', 'fen1', 1)],
|
||||
currentMoveIndex: 1,
|
||||
phase: 'opponent_turn',
|
||||
pendingOpponentMove: null,
|
||||
};
|
||||
|
||||
expect(shouldTriggerOpponentMove(state)).toBe(true);
|
||||
|
||||
// False when navigating
|
||||
state = { ...state, currentMoveIndex: 0 };
|
||||
expect(shouldTriggerOpponentMove(state)).toBe(false);
|
||||
|
||||
// False when wrong phase
|
||||
state = { ...state, currentMoveIndex: 1, phase: 'user_turn' };
|
||||
expect(shouldTriggerOpponentMove(state)).toBe(false);
|
||||
|
||||
// False when pending move exists
|
||||
state = { ...state, phase: 'opponent_turn', pendingOpponentMove: 'e6' };
|
||||
expect(shouldTriggerOpponentMove(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Persistence Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('toTrainingSession', () => {
|
||||
test('converts SessionState to TrainingSession', () => {
|
||||
const state = createInitialState(mockOpening);
|
||||
const session = toTrainingSession(state);
|
||||
|
||||
expect(session.sessionId).toBe(state.sessionId);
|
||||
expect(session.openingId).toBe(state.opening.eco);
|
||||
expect(session.openingName).toBe(state.opening.name);
|
||||
expect(session.status).toBe('active');
|
||||
expect(session.moveHistory).toBe(state.moveHistory);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import { Chess, Move } from 'chess.js';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import {
|
||||
MoveHistoryEntry,
|
||||
MoveCategory,
|
||||
MoveFeedbackClassification
|
||||
} from '@/types/openingTraining';
|
||||
import { STARTING_FEN, MOVE_CATEGORIZATION_THRESHOLDS } from './constants';
|
||||
|
||||
/**
|
||||
* Pure Business Logic for Opening Training
|
||||
*
|
||||
* This module contains all the core chess logic WITHOUT any React dependencies.
|
||||
* All functions are pure (no side effects) and can be unit tested in isolation.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Move Validation & Execution
|
||||
// ============================================================================
|
||||
|
||||
export interface MoveResult {
|
||||
san: string;
|
||||
uci: string;
|
||||
from: string;
|
||||
to: string;
|
||||
promotion?: string;
|
||||
color: 'w' | 'b';
|
||||
piece: string;
|
||||
captured?: string;
|
||||
flags: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and execute a move on a given position
|
||||
* Returns move details if legal, null if illegal
|
||||
*
|
||||
* PURE FUNCTION: Creates a temporary chess instance, does not mutate anything
|
||||
*/
|
||||
export function validateMove(fen: string, san: string): MoveResult | null {
|
||||
const chess = new Chess(fen);
|
||||
|
||||
try {
|
||||
const move = chess.move(san);
|
||||
if (!move) return null;
|
||||
|
||||
return {
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
from: move.from,
|
||||
to: move.to,
|
||||
promotion: move.promotion,
|
||||
color: move.color,
|
||||
piece: move.piece,
|
||||
captured: move.captured,
|
||||
flags: move.flags,
|
||||
};
|
||||
} catch (error) {
|
||||
// chess.move() can throw on invalid input
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a move to a position and return the new FEN
|
||||
*
|
||||
* PURE FUNCTION: Creates a new chess instance, does not mutate anything
|
||||
*/
|
||||
export function applyMove(fen: string, san: string): { newFen: string; move: MoveResult } | null {
|
||||
const moveResult = validateMove(fen, san);
|
||||
if (!moveResult) return null;
|
||||
|
||||
const chess = new Chess(fen);
|
||||
chess.move(san);
|
||||
|
||||
return {
|
||||
newFen: chess.fen(),
|
||||
move: moveResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FEN by undoing moves to a specific index in history
|
||||
* Returns STARTING_FEN if targetIndex is 0
|
||||
*
|
||||
* PURE FUNCTION: Replays moves from scratch
|
||||
*/
|
||||
export function buildFenAtIndex(moveHistory: MoveHistoryEntry[], targetIndex: number): string {
|
||||
if (targetIndex === 0) return STARTING_FEN;
|
||||
if (targetIndex > moveHistory.length) return STARTING_FEN;
|
||||
|
||||
// Use the FEN stored in the move history
|
||||
// moveHistory[0] is the FEN after the 1st move
|
||||
// moveHistory[1] is the FEN after the 2nd move, etc.
|
||||
return moveHistory[targetIndex - 1].fen;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Opening Theory Logic
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse a move sequence from an opening's moves string
|
||||
* Example: "1. e4 e5 2. Nf3" → ["e4", "e5", "Nf3"]
|
||||
*/
|
||||
export function parseMoveSequence(moveString: string): string[] {
|
||||
if (!moveString || moveString.trim() === '') return [];
|
||||
|
||||
const cleaned = moveString
|
||||
.replace(/\d+\./g, '') // Remove move numbers
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
return cleaned.split(' ').filter((move) => move.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current position is still within opening theory
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param moveHistory - Moves played so far
|
||||
* @param proposedMove - Optional: also check if this next move would be in theory
|
||||
*/
|
||||
export function isInTheory(
|
||||
opening: OpeningMetadata,
|
||||
moveHistory: MoveHistoryEntry[],
|
||||
proposedMove?: string
|
||||
): boolean {
|
||||
const repertoireMoves = parseMoveSequence(opening.moves);
|
||||
const totalMoves = proposedMove
|
||||
? moveHistory.length + 1
|
||||
: moveHistory.length;
|
||||
|
||||
// If we've played more moves than the repertoire has, we're past theory
|
||||
if (totalMoves > repertoireMoves.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all played moves match the repertoire
|
||||
for (let i = 0; i < moveHistory.length; i++) {
|
||||
if (moveHistory[i].san !== repertoireMoves[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If checking a proposed move, verify it matches too
|
||||
if (proposedMove && totalMoves <= repertoireMoves.length) {
|
||||
return proposedMove === repertoireMoves[moveHistory.length];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected next move(s) from the repertoire at current position
|
||||
* For now, returns single main line move (no variants yet)
|
||||
* Future: could return multiple moves for different variations
|
||||
*/
|
||||
export function getExpectedNextMoves(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string[] {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
|
||||
if (currentMoveIndex >= moves.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextMove = moves[currentMoveIndex];
|
||||
return nextMove ? [nextMove] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all variant moves at current position
|
||||
* For MVP: same as getExpectedNextMoves (no variants stored in database yet)
|
||||
* Future: Parse variation syntax like "1. e4 (1. d4) e5"
|
||||
*/
|
||||
export function getAllVariantMoves(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string[] {
|
||||
// MVP: No variant support yet, just return main line
|
||||
return getExpectedNextMoves(opening, currentMoveIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the current FEN has transposed into a different known opening
|
||||
* Future implementation: Would query opening database by FEN
|
||||
*/
|
||||
export function detectTransposition(
|
||||
fen: string,
|
||||
allOpenings: OpeningMetadata[]
|
||||
): { opening: OpeningMetadata; moveIndex: number } | null {
|
||||
// TODO: Implement FEN-based opening lookup
|
||||
// For now, return null (no transposition detection)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we've reached the end of the opening repertoire
|
||||
*/
|
||||
export function isEndOfRepertoire(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): boolean {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
return currentMoveIndex >= moves.length;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Move Classification
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Classify a user's move based on repertoire matching and engine evaluation
|
||||
*/
|
||||
export function classifyMove(
|
||||
san: string,
|
||||
expectedMoves: string[],
|
||||
variantMoves: string[],
|
||||
evaluation: StockfishEvaluation,
|
||||
previousEvaluation: StockfishEvaluation
|
||||
): {
|
||||
category: MoveCategory;
|
||||
evaluationChange: number;
|
||||
theoreticalAlternatives: string[];
|
||||
isTransposition: boolean;
|
||||
newOpening?: OpeningMetadata;
|
||||
} {
|
||||
const isInRepertoire = expectedMoves.includes(san);
|
||||
const isVariant = !isInRepertoire && variantMoves.includes(san);
|
||||
|
||||
// Calculate evaluation change (White's perspective)
|
||||
const evalChange = evaluation.score - previousEvaluation.score;
|
||||
|
||||
// Determine category
|
||||
let category: MoveCategory;
|
||||
|
||||
if (isInRepertoire) {
|
||||
category = 'in-theory';
|
||||
} else {
|
||||
// Not in main repertoire - check if weak or playable
|
||||
const cpLoss = Math.abs(evalChange);
|
||||
|
||||
if (cpLoss >= MOVE_CATEGORIZATION_THRESHOLDS.WEAK_MOVE_CP_LOSS) {
|
||||
category = 'weak';
|
||||
} else {
|
||||
category = 'playable';
|
||||
}
|
||||
}
|
||||
|
||||
// Get theoretical alternatives (excluding the move just played)
|
||||
const alternatives = expectedMoves.filter(move => move !== san);
|
||||
|
||||
return {
|
||||
category,
|
||||
evaluationChange: evalChange,
|
||||
theoreticalAlternatives: alternatives,
|
||||
isTransposition: false, // TODO: Implement transposition detection
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Turn & Color Logic
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Determine which color the user is playing based on ECO code
|
||||
* A, B, C = White openings (user plays White)
|
||||
* D, E = Black defenses (user plays Black)
|
||||
*/
|
||||
export function getUserColor(opening: OpeningMetadata): 'white' | 'black' {
|
||||
const ecoLetter = opening.eco[0];
|
||||
return ['A', 'B', 'C'].includes(ecoLetter) ? 'white' : 'black';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's the user's turn to move
|
||||
*/
|
||||
export function isUserTurn(opening: OpeningMetadata, moveHistory: MoveHistoryEntry[]): boolean {
|
||||
const userColor = getUserColor(opening);
|
||||
const moveIndex = moveHistory.length;
|
||||
const isWhiteMove = moveIndex % 2 === 0;
|
||||
|
||||
return (userColor === 'white' && isWhiteMove) ||
|
||||
(userColor === 'black' && !isWhiteMove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's the opponent's turn to move
|
||||
*/
|
||||
export function isOpponentTurn(opening: OpeningMetadata, moveHistory: MoveHistoryEntry[]): boolean {
|
||||
return !isUserTurn(opening, moveHistory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if opponent should automatically make a move
|
||||
* Opponent auto-moves only if:
|
||||
* 1. It's their turn
|
||||
* 2. We're still in theory
|
||||
* 3. We haven't reached end of repertoire
|
||||
*/
|
||||
export function shouldOpponentAutoMove(
|
||||
opening: OpeningMetadata,
|
||||
moveHistory: MoveHistoryEntry[],
|
||||
deviationMoveIndex: number | null
|
||||
): boolean {
|
||||
// If user has deviated from theory, no auto-moves
|
||||
if (deviationMoveIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If at end of repertoire, no auto-moves
|
||||
if (isEndOfRepertoire(opening, moveHistory.length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be opponent's turn
|
||||
if (!isOpponentTurn(opening, moveHistory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must still be in theory
|
||||
if (!isInTheory(opening, moveHistory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the opponent's next move from the repertoire
|
||||
*/
|
||||
export function getOpponentNextMove(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string | null {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
|
||||
if (currentMoveIndex >= moves.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return moves[currentMoveIndex];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deviation Tracking
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find the index where the user first deviated from theory
|
||||
* Returns null if still in theory
|
||||
*/
|
||||
export function findDeviationPoint(
|
||||
opening: OpeningMetadata,
|
||||
moveHistory: MoveHistoryEntry[]
|
||||
): number | null {
|
||||
const repertoireMoves = parseMoveSequence(opening.moves);
|
||||
|
||||
for (let i = 0; i < moveHistory.length; i++) {
|
||||
// Skip opponent moves (we only care about user deviations)
|
||||
const userColor = getUserColor(opening);
|
||||
const moveColor = moveHistory[i].color;
|
||||
const isUserMove =
|
||||
(userColor === 'white' && moveColor === 'white') ||
|
||||
(userColor === 'black' && moveColor === 'black');
|
||||
|
||||
if (!isUserMove) continue;
|
||||
|
||||
// Check if this move matches theory
|
||||
if (i >= repertoireMoves.length || moveHistory[i].san !== repertoireMoves[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset deviation tracking if user navigated back and is now in theory again
|
||||
*
|
||||
* @param currentDeviation - Current deviation index (or null)
|
||||
* @param navigationIndex - Index user navigated to
|
||||
* @param newMove - New move user is about to make
|
||||
* @param opening - Opening metadata
|
||||
* @param moveHistory - Current move history (before new move)
|
||||
* @returns Updated deviation index (null if back in theory)
|
||||
*/
|
||||
export function resetDeviationIfBackInTheory(
|
||||
currentDeviation: number | null,
|
||||
navigationIndex: number,
|
||||
newMove: string,
|
||||
opening: OpeningMetadata,
|
||||
moveHistory: MoveHistoryEntry[]
|
||||
): number | null {
|
||||
// If we navigated before the deviation point
|
||||
if (currentDeviation !== null && navigationIndex < currentDeviation) {
|
||||
// Check if the new move is in theory
|
||||
if (isInTheory(opening, moveHistory, newMove)) {
|
||||
// Back in theory! Reset deviation
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return currentDeviation;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wikipedia Integration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract the family name from a full opening name
|
||||
* Example: "French Defense: Winawer Variation, Advance" → "French Defense"
|
||||
*/
|
||||
export function extractOpeningFamily(openingName: string): string {
|
||||
// Split on colon to get the main family
|
||||
const parts = openingName.split(':');
|
||||
return parts[0].trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an opening has Wikipedia data available
|
||||
*/
|
||||
export function hasWikipediaPage(opening: OpeningMetadata): boolean {
|
||||
return !!opening.wikipediaSlug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if Wikipedia context should be included in LLM prompts
|
||||
*/
|
||||
export function shouldUseWikipediaContext(opening: OpeningMetadata): boolean {
|
||||
// Use Wikipedia if available and not too obscure
|
||||
return hasWikipediaPage(opening);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Chess } from 'chess.js';
|
||||
import { ChessEngine } from '@/lib/engine';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { MoveHistoryEntry, MoveFeedbackClassification } from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { SessionState, SessionAction } from './sessionReducer';
|
||||
import {
|
||||
validateMove,
|
||||
getExpectedNextMoves,
|
||||
classifyMove as classifyMoveLogic,
|
||||
getOpponentNextMove,
|
||||
} from './gameLogic';
|
||||
import { evaluatePosition } from './engineService';
|
||||
|
||||
/**
|
||||
* Session Orchestrator
|
||||
*
|
||||
* Handles all async operations (Stockfish evaluation, delays) and
|
||||
* coordinates between the pure reducer and external services.
|
||||
*
|
||||
* This layer DOES have side effects, but is still testable with mocked Stockfish.
|
||||
*/
|
||||
|
||||
export interface ProcessMoveResult {
|
||||
actions: SessionAction[];
|
||||
moveEntry: MoveHistoryEntry;
|
||||
}
|
||||
|
||||
export class SessionOrchestrator {
|
||||
private stockfish: ChessEngine;
|
||||
private pendingOperations: Set<Promise<any>> = new Set();
|
||||
|
||||
constructor(stockfish: ChessEngine) {
|
||||
this.stockfish = stockfish;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a user's move: validate, evaluate, classify
|
||||
* Returns actions to dispatch to the reducer
|
||||
*/
|
||||
async processUserMove(
|
||||
state: SessionState,
|
||||
san: string,
|
||||
wasNavigatedBack: boolean = false
|
||||
): Promise<ProcessMoveResult> {
|
||||
// Validate the move is legal from current position
|
||||
const moveResult = validateMove(state.currentFEN, san);
|
||||
if (!moveResult) {
|
||||
throw new Error(`Illegal move: ${san}`);
|
||||
}
|
||||
|
||||
// Get FEN after the move
|
||||
const chess = new Chess(state.currentFEN);
|
||||
chess.move(san);
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Evaluate the new position
|
||||
const currentEval = await evaluatePosition(newFEN, this.stockfish);
|
||||
|
||||
// Get previous evaluation
|
||||
const previousEval = this.getPreviousEvaluation(state);
|
||||
|
||||
// Check if move is in repertoire
|
||||
const truncatedHistory = wasNavigatedBack
|
||||
? state.moveHistory.slice(0, state.currentMoveIndex)
|
||||
: state.moveHistory;
|
||||
|
||||
const expectedMoves = getExpectedNextMoves(state.opening, truncatedHistory.length);
|
||||
const isInRepertoire = expectedMoves.includes(san);
|
||||
|
||||
// Classify the move
|
||||
const classification = classifyMoveLogic(
|
||||
san,
|
||||
expectedMoves,
|
||||
expectedMoves, // For MVP, variants = expected
|
||||
currentEval,
|
||||
previousEval
|
||||
);
|
||||
|
||||
// Create move entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(truncatedHistory.length / 2) + 1,
|
||||
color: moveResult.color === 'w' ? 'white' : 'black',
|
||||
san: moveResult.san,
|
||||
uci: moveResult.uci,
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification: this.toMoveFeedbackClassification(classification, isInRepertoire),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Return actions for reducer
|
||||
const actions: SessionAction[] = [
|
||||
{
|
||||
type: 'USER_MOVE_COMPLETED',
|
||||
moveEntry,
|
||||
wasNavigatedBack,
|
||||
},
|
||||
];
|
||||
|
||||
return { actions, moveEntry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an automatic opponent move
|
||||
* Returns actions to dispatch to the reducer
|
||||
*/
|
||||
async processOpponentMove(
|
||||
state: SessionState,
|
||||
delaMs: number = 600
|
||||
): Promise<ProcessMoveResult> {
|
||||
// Get opponent's next move from repertoire
|
||||
const opponentSan = getOpponentNextMove(state.opening, state.moveHistory.length);
|
||||
if (!opponentSan) {
|
||||
throw new Error('No opponent move available (end of repertoire)');
|
||||
}
|
||||
|
||||
// Add natural delay
|
||||
if (delaMs > 0) {
|
||||
await this.delay(delaMs);
|
||||
}
|
||||
|
||||
// Validate and execute the move
|
||||
const moveResult = validateMove(state.currentFEN, opponentSan);
|
||||
if (!moveResult) {
|
||||
throw new Error(`Opponent move invalid: ${opponentSan}`);
|
||||
}
|
||||
|
||||
// Get FEN after the move
|
||||
const chess = new Chess(state.currentFEN);
|
||||
chess.move(opponentSan);
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Evaluate the new position
|
||||
const currentEval = await evaluatePosition(newFEN, this.stockfish);
|
||||
|
||||
// Get previous evaluation
|
||||
const previousEval = this.getPreviousEvaluation(state);
|
||||
|
||||
// Opponent moves are always in-theory
|
||||
const classification: MoveFeedbackClassification = {
|
||||
category: 'in-theory',
|
||||
inRepertoire: true,
|
||||
evaluationChange: currentEval.score - previousEval.score,
|
||||
isSignificantSwing: Math.abs(currentEval.score - previousEval.score) >= 50,
|
||||
theoreticalAlternatives: [],
|
||||
};
|
||||
|
||||
// Create move entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(state.moveHistory.length / 2) + 1,
|
||||
color: moveResult.color === 'w' ? 'white' : 'black',
|
||||
san: moveResult.san,
|
||||
uci: moveResult.uci,
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Return actions for reducer
|
||||
const actions: SessionAction[] = [
|
||||
{
|
||||
type: 'OPPONENT_MOVE_COMPLETED',
|
||||
moveEntry,
|
||||
},
|
||||
];
|
||||
|
||||
return { actions, moveEntry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial position evaluation
|
||||
*/
|
||||
async getInitialEvaluation(fen: string = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'): Promise<number> {
|
||||
const evaluation = await evaluatePosition(fen, this.stockfish);
|
||||
return evaluation.score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending operations
|
||||
*/
|
||||
cancelPendingOperations(): void {
|
||||
// For now, we can't really cancel Stockfish evaluations once started
|
||||
// But we can clear the tracking set
|
||||
this.pendingOperations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cancelPendingOperations();
|
||||
// Note: Stockfish engine cleanup is handled by the Context
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Helpers
|
||||
// ============================================================================
|
||||
|
||||
private getPreviousEvaluation(state: SessionState): StockfishEvaluation {
|
||||
if (state.moveHistory.length > 0) {
|
||||
return state.moveHistory[state.moveHistory.length - 1].evaluation;
|
||||
}
|
||||
|
||||
// No moves yet, return initial evaluation
|
||||
return {
|
||||
score: state.initialEvaluation,
|
||||
mate: null,
|
||||
depth: 0,
|
||||
bestMove: '',
|
||||
ponder: null,
|
||||
};
|
||||
}
|
||||
|
||||
private toMoveFeedbackClassification(
|
||||
classification: ReturnType<typeof classifyMoveLogic>,
|
||||
isInRepertoire: boolean
|
||||
): MoveFeedbackClassification {
|
||||
return {
|
||||
category: classification.category,
|
||||
inRepertoire: isInRepertoire,
|
||||
evaluationChange: classification.evaluationChange,
|
||||
isSignificantSwing: Math.abs(classification.evaluationChange) >= 50,
|
||||
theoreticalAlternatives: classification.theoreticalAlternatives,
|
||||
};
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
const promise = new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
this.pendingOperations.add(promise);
|
||||
promise.finally(() => this.pendingOperations.delete(promise));
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create orchestrator
|
||||
*/
|
||||
export function createOrchestrator(stockfish: ChessEngine): SessionOrchestrator {
|
||||
return new SessionOrchestrator(stockfish);
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { MoveHistoryEntry, TrainingSession } from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { STARTING_FEN } from './constants';
|
||||
import {
|
||||
isInTheory,
|
||||
isOpponentTurn,
|
||||
isEndOfRepertoire,
|
||||
buildFenAtIndex,
|
||||
} from './gameLogic';
|
||||
|
||||
/**
|
||||
* Session Reducer - State Machine for Opening Training
|
||||
*
|
||||
* This reducer manages ALL state transitions in a predictable, testable way.
|
||||
* No more race conditions, no more tangled useEffects!
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// State Machine Phases
|
||||
// ============================================================================
|
||||
|
||||
export type SessionPhase =
|
||||
| 'initializing' // Session starting, loading data
|
||||
| 'user_turn' // Waiting for user to make a move
|
||||
| 'evaluating_user' // User moved, Stockfish evaluating
|
||||
| 'opponent_turn' // Opponent should auto-move
|
||||
| 'evaluating_opponent'// Opponent moved, Stockfish evaluating
|
||||
| 'navigating' // User browsing history (no auto-moves)
|
||||
| 'end_of_repertoire' // Reached end of opening theory
|
||||
| 'off_book'; // User deviated from theory
|
||||
|
||||
// ============================================================================
|
||||
// Session State
|
||||
// ============================================================================
|
||||
|
||||
export interface SessionState {
|
||||
// Core session data
|
||||
sessionId: string;
|
||||
opening: OpeningMetadata;
|
||||
moveHistory: MoveHistoryEntry[];
|
||||
currentFEN: string;
|
||||
currentMoveIndex: number; // Which move are we viewing (0 = start position)
|
||||
|
||||
// Opening theory tracking
|
||||
deviationMoveIndex: number | null; // Index where user first left theory
|
||||
isInTheory: boolean;
|
||||
endOfRepertoireReached: boolean;
|
||||
|
||||
// Phase management
|
||||
phase: SessionPhase;
|
||||
pendingOpponentMove: string | null; // Move to be executed by opponent
|
||||
pendingEvaluation: boolean; // Whether we're waiting for Stockfish
|
||||
|
||||
// Wikipedia context
|
||||
wikipediaSummary: string | null;
|
||||
openingFamily: string;
|
||||
|
||||
// Session metadata
|
||||
startedAt: number;
|
||||
lastUpdatedAt: number;
|
||||
initialEvaluation: number; // Starting position eval (centipawns)
|
||||
|
||||
// Error state
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Actions
|
||||
// ============================================================================
|
||||
|
||||
export type SessionAction =
|
||||
// Session lifecycle
|
||||
| { type: 'INITIALIZE_SESSION'; opening: OpeningMetadata; forceNew: boolean }
|
||||
| { type: 'SESSION_INITIALIZED'; initialEvaluation: number }
|
||||
| { type: 'SESSION_LOADED'; session: TrainingSession; opening: OpeningMetadata }
|
||||
| { type: 'RESET_SESSION' }
|
||||
|
||||
// Move actions (user)
|
||||
| { type: 'USER_MOVE_STARTED'; san: string }
|
||||
| {
|
||||
type: 'USER_MOVE_COMPLETED';
|
||||
moveEntry: MoveHistoryEntry;
|
||||
wasNavigatedBack: boolean;
|
||||
}
|
||||
|
||||
// Move actions (opponent)
|
||||
| { type: 'OPPONENT_MOVE_QUEUED'; san: string }
|
||||
| { type: 'OPPONENT_MOVE_COMPLETED'; moveEntry: MoveHistoryEntry }
|
||||
|
||||
// Navigation actions
|
||||
| { type: 'NAVIGATE_TO'; index: number }
|
||||
| { type: 'NAVIGATE_PREVIOUS' }
|
||||
| { type: 'NAVIGATE_NEXT' }
|
||||
| { type: 'NAVIGATE_TO_CURRENT' } // Jump back to end of history
|
||||
|
||||
// Theory tracking
|
||||
| { type: 'DEVIATION_DETECTED'; moveIndex: number }
|
||||
| { type: 'DEVIATION_RESET' }
|
||||
| { type: 'END_OF_REPERTOIRE_REACHED' }
|
||||
|
||||
// Wikipedia
|
||||
| { type: 'WIKIPEDIA_LOADED'; summary: string; family: string }
|
||||
|
||||
// Error handling
|
||||
| { type: 'ERROR'; message: string }
|
||||
| { type: 'CLEAR_ERROR' };
|
||||
|
||||
// ============================================================================
|
||||
// Initial State Factory
|
||||
// ============================================================================
|
||||
|
||||
export function createInitialState(
|
||||
opening: OpeningMetadata,
|
||||
sessionId: string = crypto.randomUUID()
|
||||
): SessionState {
|
||||
return {
|
||||
sessionId,
|
||||
opening,
|
||||
moveHistory: [],
|
||||
currentFEN: STARTING_FEN,
|
||||
currentMoveIndex: 0,
|
||||
|
||||
deviationMoveIndex: null,
|
||||
isInTheory: true,
|
||||
endOfRepertoireReached: false,
|
||||
|
||||
phase: 'initializing',
|
||||
pendingOpponentMove: null,
|
||||
pendingEvaluation: false,
|
||||
|
||||
wikipediaSummary: null,
|
||||
openingFamily: opening.name.split(':')[0].trim(),
|
||||
|
||||
startedAt: Date.now(),
|
||||
lastUpdatedAt: Date.now(),
|
||||
initialEvaluation: 0,
|
||||
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reducer
|
||||
// ============================================================================
|
||||
|
||||
export function sessionReducer(
|
||||
state: SessionState,
|
||||
action: SessionAction
|
||||
): SessionState | null {
|
||||
switch (action.type) {
|
||||
// ------------------------------------------------------------------------
|
||||
// Session Lifecycle
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
case 'INITIALIZE_SESSION': {
|
||||
return createInitialState(action.opening);
|
||||
}
|
||||
|
||||
case 'SESSION_INITIALIZED': {
|
||||
const { initialEvaluation } = action;
|
||||
|
||||
// Determine initial phase based on who moves first
|
||||
const isOpponentFirst = isOpponentTurn(state.opening, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
initialEvaluation,
|
||||
phase: isOpponentFirst ? 'opponent_turn' : 'user_turn',
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'SESSION_LOADED': {
|
||||
const { session, opening } = action;
|
||||
|
||||
// Convert TrainingSession to SessionState
|
||||
// Determine current phase based on position
|
||||
let phase: SessionPhase;
|
||||
const atEnd = session.currentMoveIndex === session.moveHistory.length;
|
||||
|
||||
if (!atEnd) {
|
||||
phase = 'navigating';
|
||||
} else if (session.deviationMoveIndex !== null) {
|
||||
phase = 'off_book';
|
||||
} else if (isEndOfRepertoire(opening, session.moveHistory.length)) {
|
||||
phase = 'end_of_repertoire';
|
||||
} else if (isOpponentTurn(opening, session.moveHistory)) {
|
||||
phase = 'opponent_turn';
|
||||
} else {
|
||||
phase = 'user_turn';
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
opening,
|
||||
sessionId: session.sessionId,
|
||||
moveHistory: session.moveHistory,
|
||||
currentFEN: session.currentFEN,
|
||||
currentMoveIndex: session.currentMoveIndex,
|
||||
deviationMoveIndex: session.deviationMoveIndex,
|
||||
isInTheory: session.deviationMoveIndex === null,
|
||||
phase,
|
||||
lastUpdatedAt: session.lastUpdatedAt,
|
||||
initialEvaluation: session.initialEvaluation,
|
||||
};
|
||||
}
|
||||
|
||||
case 'RESET_SESSION': {
|
||||
// Reset to null - UI will handle re-initialization if needed
|
||||
return null as any; // Return null to clear session completely
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// User Move Actions
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
case 'USER_MOVE_STARTED': {
|
||||
return {
|
||||
...state,
|
||||
phase: 'evaluating_user',
|
||||
error: null,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'USER_MOVE_COMPLETED': {
|
||||
const { moveEntry, wasNavigatedBack } = action;
|
||||
|
||||
// If navigated back, truncate history
|
||||
const newHistory = wasNavigatedBack
|
||||
? [...state.moveHistory.slice(0, state.currentMoveIndex), moveEntry]
|
||||
: [...state.moveHistory, moveEntry];
|
||||
|
||||
const newMoveIndex = newHistory.length;
|
||||
|
||||
// Update theory tracking
|
||||
const stillInTheory = isInTheory(state.opening, newHistory);
|
||||
const newDeviationIndex = wasNavigatedBack && stillInTheory
|
||||
? null // Reset deviation if we're back in theory
|
||||
: !stillInTheory && state.deviationMoveIndex === null
|
||||
? newMoveIndex - 1 // Mark new deviation
|
||||
: state.deviationMoveIndex;
|
||||
|
||||
// Determine next phase
|
||||
let nextPhase: SessionPhase;
|
||||
if (!stillInTheory) {
|
||||
nextPhase = 'off_book';
|
||||
} else if (isEndOfRepertoire(state.opening, newMoveIndex)) {
|
||||
nextPhase = 'end_of_repertoire';
|
||||
} else if (isOpponentTurn(state.opening, newHistory)) {
|
||||
nextPhase = 'opponent_turn';
|
||||
} else {
|
||||
nextPhase = 'user_turn';
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
moveHistory: newHistory,
|
||||
currentFEN: moveEntry.fen,
|
||||
currentMoveIndex: newMoveIndex,
|
||||
deviationMoveIndex: newDeviationIndex,
|
||||
isInTheory: stillInTheory,
|
||||
phase: nextPhase,
|
||||
pendingEvaluation: false,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Opponent Move Actions
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
case 'OPPONENT_MOVE_QUEUED': {
|
||||
return {
|
||||
...state,
|
||||
pendingOpponentMove: action.san,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'OPPONENT_MOVE_COMPLETED': {
|
||||
const { moveEntry } = action;
|
||||
const newHistory = [...state.moveHistory, moveEntry];
|
||||
const newMoveIndex = newHistory.length;
|
||||
|
||||
// Check if this was the last repertoire move
|
||||
const reachedEnd = isEndOfRepertoire(state.opening, newMoveIndex);
|
||||
|
||||
// Determine next phase
|
||||
const nextPhase = reachedEnd
|
||||
? 'end_of_repertoire'
|
||||
: isOpponentTurn(state.opening, newHistory)
|
||||
? 'opponent_turn'
|
||||
: 'user_turn';
|
||||
|
||||
return {
|
||||
...state,
|
||||
moveHistory: newHistory,
|
||||
currentFEN: moveEntry.fen,
|
||||
currentMoveIndex: newMoveIndex,
|
||||
pendingOpponentMove: null,
|
||||
phase: nextPhase,
|
||||
endOfRepertoireReached: reachedEnd,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Navigation Actions
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
case 'NAVIGATE_TO': {
|
||||
const { index } = action;
|
||||
|
||||
// Clamp index to valid range
|
||||
const clampedIndex = Math.max(0, Math.min(index, state.moveHistory.length));
|
||||
|
||||
// Build FEN at target index
|
||||
const newFEN = buildFenAtIndex(state.moveHistory, clampedIndex);
|
||||
|
||||
// Cancel any pending opponent moves
|
||||
return {
|
||||
...state,
|
||||
currentMoveIndex: clampedIndex,
|
||||
currentFEN: newFEN,
|
||||
phase: 'navigating',
|
||||
pendingOpponentMove: null,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'NAVIGATE_PREVIOUS': {
|
||||
if (state.currentMoveIndex === 0) return state;
|
||||
|
||||
return sessionReducer(state, {
|
||||
type: 'NAVIGATE_TO',
|
||||
index: state.currentMoveIndex - 1,
|
||||
});
|
||||
}
|
||||
|
||||
case 'NAVIGATE_NEXT': {
|
||||
if (state.currentMoveIndex >= state.moveHistory.length) return state;
|
||||
|
||||
return sessionReducer(state, {
|
||||
type: 'NAVIGATE_TO',
|
||||
index: state.currentMoveIndex + 1,
|
||||
});
|
||||
}
|
||||
|
||||
case 'NAVIGATE_TO_CURRENT': {
|
||||
// Jump to the end of history (current position)
|
||||
const atEnd = state.currentMoveIndex === state.moveHistory.length;
|
||||
if (atEnd) return state;
|
||||
|
||||
const newFEN = state.moveHistory.length > 0
|
||||
? state.moveHistory[state.moveHistory.length - 1].fen
|
||||
: STARTING_FEN;
|
||||
|
||||
// Determine correct phase at current position
|
||||
let phase: SessionPhase;
|
||||
if (state.deviationMoveIndex !== null) {
|
||||
phase = 'off_book';
|
||||
} else if (state.endOfRepertoireReached) {
|
||||
phase = 'end_of_repertoire';
|
||||
} else if (isOpponentTurn(state.opening, state.moveHistory)) {
|
||||
phase = 'opponent_turn';
|
||||
} else {
|
||||
phase = 'user_turn';
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentMoveIndex: state.moveHistory.length,
|
||||
currentFEN: newFEN,
|
||||
phase,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Theory Tracking
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
case 'DEVIATION_DETECTED': {
|
||||
return {
|
||||
...state,
|
||||
deviationMoveIndex: action.moveIndex,
|
||||
isInTheory: false,
|
||||
phase: 'off_book',
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'DEVIATION_RESET': {
|
||||
return {
|
||||
...state,
|
||||
deviationMoveIndex: null,
|
||||
isInTheory: true,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'END_OF_REPERTOIRE_REACHED': {
|
||||
return {
|
||||
...state,
|
||||
endOfRepertoireReached: true,
|
||||
phase: 'end_of_repertoire',
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Wikipedia
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
case 'WIKIPEDIA_LOADED': {
|
||||
return {
|
||||
...state,
|
||||
wikipediaSummary: action.summary,
|
||||
openingFamily: action.family,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Error Handling
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
case 'ERROR': {
|
||||
return {
|
||||
...state,
|
||||
error: action.message,
|
||||
phase: state.moveHistory.length === 0 ? 'user_turn' : state.phase,
|
||||
pendingEvaluation: false,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ERROR': {
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Selectors (Derived State)
|
||||
// ============================================================================
|
||||
|
||||
export function canNavigateBack(state: SessionState): boolean {
|
||||
return state.currentMoveIndex > 0;
|
||||
}
|
||||
|
||||
export function canNavigateForward(state: SessionState): boolean {
|
||||
return state.currentMoveIndex < state.moveHistory.length;
|
||||
}
|
||||
|
||||
export function isAtCurrentPosition(state: SessionState): boolean {
|
||||
return state.currentMoveIndex === state.moveHistory.length;
|
||||
}
|
||||
|
||||
export function getCurrentPhaseDescription(state: SessionState): string {
|
||||
switch (state.phase) {
|
||||
case 'initializing':
|
||||
return 'Initializing session...';
|
||||
case 'user_turn':
|
||||
return "Your turn";
|
||||
case 'evaluating_user':
|
||||
return 'Analyzing your move...';
|
||||
case 'opponent_turn':
|
||||
return "Opponent's turn";
|
||||
case 'evaluating_opponent':
|
||||
return "Analyzing opponent's move...";
|
||||
case 'navigating':
|
||||
return 'Browsing history';
|
||||
case 'end_of_repertoire':
|
||||
return 'End of opening repertoire';
|
||||
case 'off_book':
|
||||
return 'Off-book (out of theory)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should trigger an automatic opponent move
|
||||
* This is THE critical function that prevents the navigation bug
|
||||
*/
|
||||
export function shouldTriggerOpponentMove(state: SessionState): boolean {
|
||||
// Must be at current position (not browsing history)
|
||||
if (!isAtCurrentPosition(state)) return false;
|
||||
|
||||
// Must be in opponent_turn phase
|
||||
if (state.phase !== 'opponent_turn') return false;
|
||||
|
||||
// Must not have a pending move already
|
||||
if (state.pendingOpponentMove !== null) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SessionState to TrainingSession for persistence
|
||||
*/
|
||||
export function toTrainingSession(state: SessionState): TrainingSession {
|
||||
return {
|
||||
sessionId: state.sessionId,
|
||||
openingId: state.opening.eco,
|
||||
openingName: state.opening.name,
|
||||
startedAt: state.startedAt,
|
||||
lastUpdatedAt: state.lastUpdatedAt,
|
||||
status: 'active',
|
||||
currentFEN: state.currentFEN,
|
||||
currentMoveIndex: state.currentMoveIndex,
|
||||
moveHistory: state.moveHistory,
|
||||
deviationMoveIndex: state.deviationMoveIndex,
|
||||
initialEvaluation: state.initialEvaluation,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user