Merge branch 'vk/d2b2-opening-training'

This commit is contained in:
Stefan
2026-01-04 11:47:47 +01:00
19 changed files with 1333 additions and 54 deletions
@@ -0,0 +1,194 @@
'use client';
import { useEffect, useState, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import Header from '@/components/Header';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { SupportedLanguage } from '@/lib/i18n/translations';
import { OpeningMetadata } from '@/lib/openings';
import { OpeningTrainingProvider } from '@/contexts/OpeningTrainingContext';
import OpeningTrainer from '@/components/OpeningTrainer/OpeningTrainer';
import { OpeningTrainerErrorBoundary } from '@/components/OpeningTrainer/ErrorBoundary';
import { getOpeningsByFamily } from '@/lib/openingTrainer/openingLoader';
import { buildVariationTree, VariationTree } from '@/lib/openingTrainer/gameLogic';
import { Personality, PERSONALITIES } from '@/lib/personalities';
/**
* Family Training Page
*
* This page enables training on an entire opening family (e.g., "Italian Game")
* instead of a single specific variation. Users can play any moves that exist
* in any variation, and the tutor will guide them through the repertoire.
*/
export default function FamilyTrainingPage() {
const params = useParams();
const router = useRouter();
const familyName = decodeURIComponent(params.familyName as string);
const [language, setLanguage] = useState<SupportedLanguage>('en');
const [mounted, setMounted] = useState(false);
const [variations, setVariations] = useState<OpeningMetadata[]>([]);
const [variationTree, setVariationTree] = useState<VariationTree | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(PERSONALITIES[0]);
const [apiKey, setApiKey] = useState<string>('');
useEffect(() => {
const storedLang = localStorage.getItem('chess_tutor_language');
if (storedLang) setLanguage(storedLang as SupportedLanguage);
// Load API key
const storedApiKey = localStorage.getItem('gemini_api_key');
if (storedApiKey) setApiKey(storedApiKey);
// Load personality
const storedPersonalityId = localStorage.getItem('chess_tutor_personality');
if (storedPersonalityId) {
const personality = PERSONALITIES.find(p => p.id === storedPersonalityId);
if (personality) setSelectedPersonality(personality);
}
setMounted(true);
}, []);
const t = useTranslation(language);
useEffect(() => {
if (mounted) {
loadFamilyVariations();
}
}, [familyName, mounted]);
const loadFamilyVariations = () => {
setIsLoading(true);
// Get all variations for this family
const familyVariations = getOpeningsByFamily(familyName);
if (familyVariations.length === 0) {
// No variations found - redirect back to selection
router.push('/learning/openings');
return;
}
setVariations(familyVariations);
// Build the variation tree for efficient lookup
const tree = buildVariationTree(familyVariations, familyName);
setVariationTree(tree);
setIsLoading(false);
};
// Create a "representative" opening for the family
// Uses the first ECO code and combines info from all variations
const familyOpening: OpeningMetadata | null = useMemo(() => {
if (variations.length === 0) return null;
// Get all unique ECO codes
const ecoCodes = [...new Set(variations.map((v: OpeningMetadata) => v.eco))].sort();
const ecoRange = ecoCodes.length === 1
? ecoCodes[0]
: `${ecoCodes[0]}-${ecoCodes[ecoCodes.length - 1]}`;
// Find the variation with the most moves (for the initial repertoire display)
const longestVariation = variations[0]; // Already sorted by move count
// Create a combined opening metadata
return {
eco: longestVariation.eco,
name: familyName,
moves: longestVariation.moves, // Use longest for display, but tree handles all
src: 'family',
isEcoRoot: true,
wikipediaSlug: longestVariation.wikipediaSlug,
};
}, [variations, familyName]);
if (!mounted) return null;
if (isLoading) {
return (
<>
<Header language={language} />
<div className="flex-grow bg-gray-100 dark:bg-gray-900 p-4 flex flex-col">
<div className="max-w-6xl mx-auto w-full">
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center space-y-4">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400">{t.learning.openingTrainer.loadingSession}</p>
</div>
</div>
</div>
</div>
</>
);
}
if (!familyOpening || !variationTree) {
return (
<>
<Header language={language} />
<div className="flex-grow bg-gray-100 dark:bg-gray-900 p-4 flex flex-col">
<div className="max-w-6xl mx-auto w-full">
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t.learning.openingTrainer.openingNotFound}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No variations found for &quot;{familyName}&quot;
</p>
<button
onClick={() => router.push('/learning/openings')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{t.learning.openingTrainer.backToOpeningSelection}
</button>
</div>
</div>
</div>
</>
);
}
return (
<>
<Header language={language} />
<div className="flex-grow bg-gray-100 dark:bg-gray-900 p-4 flex flex-col">
<div className="max-w-6xl mx-auto w-full">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => router.push('/learning/openings')}
className="p-2 md:px-4 md:py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors flex items-center gap-2"
>
<ArrowLeft size={20} />
<span className="hidden md:inline">{t.learning.openingTrainer.backToOpeningSelection}</span>
</button>
</div>
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">{familyName}</h1>
<p className="text-gray-600 dark:text-gray-400">
{variations.length} variation{variations.length !== 1 ? 's' : ''} available
</p>
</div>
<OpeningTrainerErrorBoundary>
<OpeningTrainingProvider>
<OpeningTrainer
opening={familyOpening}
personality={selectedPersonality}
apiKey={apiKey}
language={language}
variationTree={variationTree}
allVariations={variations}
/>
</OpeningTrainingProvider>
</OpeningTrainerErrorBoundary>
</div>
</div>
</>
);
}
+8 -31
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import Header from '@/components/Header';
import OpeningSelector from '@/components/OpeningTrainer/OpeningSelector';
import FamilySelector from '@/components/OpeningTrainer/FamilySelector';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { SupportedLanguage } from '@/lib/i18n/translations';
@@ -15,7 +14,6 @@ export default function OpeningsPage() {
const router = useRouter();
const [language, setLanguage] = useState<SupportedLanguage>('en');
const [mounted, setMounted] = useState(false);
const [selectedFamily, setSelectedFamily] = useState<string | null>(null);
useEffect(() => {
const storedLang = localStorage.getItem('chess_tutor_language');
@@ -35,14 +33,6 @@ export default function OpeningsPage() {
return groupOpeningsByFamily(allOpenings);
}, [allOpenings]);
const handleSelectFamily = (familyName: string) => {
setSelectedFamily(familyName);
};
const handleBackToFamilies = () => {
setSelectedFamily(null);
};
if (!mounted) return null;
return (
@@ -61,28 +51,15 @@ export default function OpeningsPage() {
</button>
</div>
{!selectedFamily ? (
<>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Opening Training
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Select an opening family to explore. Each family contains multiple variations
with engine-backed feedback and AI-powered explanations.
</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Opening Training
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Select an opening family to train. You can play any variation within the family,
and your AI coach will guide you through the different lines.
</p>
<FamilySelector
families={openingFamilies}
onSelectFamily={handleSelectFamily}
/>
</>
) : (
<OpeningSelector
openings={allOpenings}
selectedFamily={selectedFamily}
onBackToFamilies={handleBackToFamilies}
/>
)}
<FamilySelector families={openingFamilies} />
</div>
</div>
</>
+2
View File
@@ -67,9 +67,11 @@ jest.mock("./StartScreen", () => ({
describe("ChessGame Component", () => {
const mockPersonality = {
id: "test",
name: "Test Personality",
systemPrompt: "You are a helpful assistant.",
image: "🤖",
description: "Test description",
};
beforeEach(() => {
+3 -3
View File
@@ -4,7 +4,7 @@ import "@testing-library/jest-dom";
describe("EvaluationBar", () => {
it("renders 0.0 for initial state", () => {
render(<EvaluationBar score={0} />);
render(<EvaluationBar score={0} isPlayerWhite={true} />);
expect(screen.getByText("0.0")).toBeInTheDocument();
});
@@ -19,12 +19,12 @@ describe("EvaluationBar", () => {
});
it("renders mate score", () => {
render(<EvaluationBar mate={3} />);
render(<EvaluationBar mate={3} isPlayerWhite={true} />);
expect(screen.getByText("M3")).toBeInTheDocument();
});
it("renders negative mate score", () => {
render(<EvaluationBar mate={-5} />);
render(<EvaluationBar mate={-5} isPlayerWhite={true} />);
expect(screen.getByText("M5")).toBeInTheDocument();
});
});
@@ -1,14 +1,23 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { OpeningFamily } from '@/lib/openingTrainer/openingFamilies';
interface FamilySelectorProps {
families: OpeningFamily[];
onSelectFamily: (familyName: string) => void;
onSelectFamily?: (familyName: string) => void; // Made optional - now navigates directly
}
export default function FamilySelector({ families, onSelectFamily }: FamilySelectorProps) {
const router = useRouter();
const handleSelectFamily = (familyName: string) => {
// Navigate directly to family training page
const encodedName = encodeURIComponent(familyName);
router.push(`/learning/openings/family/${encodedName}`);
};
// Group families by ECO range for display
const groupedFamilies = useMemo(() => {
const groups: Record<string, OpeningFamily[]> = {
@@ -52,7 +61,7 @@ export default function FamilySelector({ families, onSelectFamily }: FamilySelec
{categoryFamilies.map((family) => (
<button
key={family.name}
onClick={() => onSelectFamily(family.name)}
onClick={() => handleSelectFamily(family.name)}
className="block p-6 border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-lg transition-all text-left"
aria-label={`Select ${family.name} opening family`}
>
+153 -11
View File
@@ -6,7 +6,15 @@ import { Chessboard } from 'react-chessboard';
import { OpeningMetadata } from '@/lib/openings';
import { useOpeningTraining, useChessInstance } from '@/contexts/OpeningTrainingContext';
import { loadSession } from '@/lib/openingTrainer/sessionManager';
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/gameLogic';
import {
parseMoveSequence,
getUserColor,
VariationTree,
getAllPossibleNextMoves,
identifyCurrentVariation,
isMoveInVariationTree,
describeCurrentPosition,
} from '@/lib/openingTrainer/gameLogic';
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
@@ -22,10 +30,21 @@ interface OpeningTrainerProps {
personality: Personality;
apiKey: string;
language: SupportedLanguage;
// Family training mode - allows multiple variations
variationTree?: VariationTree;
allVariations?: OpeningMetadata[];
}
export default function OpeningTrainer({ opening, personality, apiKey, language }: OpeningTrainerProps) {
export default function OpeningTrainer({
opening,
personality,
apiKey,
language,
variationTree,
allVariations,
}: OpeningTrainerProps) {
const router = useRouter();
const isFamilyMode = !!variationTree && !!allVariations;
const {
session,
@@ -33,6 +52,7 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
currentFeedback,
initializeSession,
makeMove,
undoToMove,
navigateToMove,
} = useOpeningTraining();
@@ -206,12 +226,12 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
const handleUndoDeviation = () => {
if (!session || session.deviationMoveIndex === null) return;
// Navigate back to the move before deviation
navigateToMove(session.deviationMoveIndex - 1);
setShowDeviationDialog(false);
// Undo to the move before deviation (this truncates move history)
const targetIndex = session.deviationMoveIndex - 1;
undoToMove(targetIndex);
// After a brief delay, make another legal move to continue in theory
// This allows the player to try again
// Close the dialog
setShowDeviationDialog(false);
};
const handleStartGameFromPosition = () => {
@@ -257,6 +277,44 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
setLastTutorMessageMoveIndex(moveCount);
};
// ============================================================================
// Family mode hooks - MUST be called before any early returns
// ============================================================================
// Family mode: compute current position info from variation tree
// IMPORTANT: Always call useMemo, even if not in family mode (React Hooks rule)
const variationPositionInfo = useMemo(() => {
if (!isFamilyMode || !variationTree || !session) {
return null;
}
const positionDesc = describeCurrentPosition(variationTree, session.moveHistory);
const currentVariations = identifyCurrentVariation(variationTree, session.moveHistory);
const possibleMoves = getAllPossibleNextMoves(variationTree, session.moveHistory);
return {
...positionDesc,
currentVariations,
possibleMoves,
isInAnyVariation: positionDesc.matchingCount > 0,
};
}, [isFamilyMode, variationTree, session]);
// Get all possible next moves (for display and tutor context)
const theoreticalMoves = useMemo(() => {
if (isFamilyMode && variationPositionInfo) {
return variationPositionInfo.nextMoves;
}
// Single variation mode
if (!session) return [];
const moves = parseMoveSequence(opening.moves);
const nextMove = moves[session.moveHistory.length];
return nextMove ? [nextMove] : [];
}, [isFamilyMode, variationPositionInfo, opening.moves, session]);
// ============================================================================
// Early returns for loading/error states
// ============================================================================
// Session recovery dialog
if (showRecoveryDialog && existingSession) {
return (
@@ -365,12 +423,16 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null;
const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null;
// Note: variationPositionInfo and theoreticalMoves are computed above (before early returns)
const openingPracticeMode = {
openingName: opening.name,
openingEco: opening.eco,
repertoireMoves,
currentMoveIndex: session.moveHistory.length,
isInTheory: session.deviationMoveIndex === null,
isInTheory: isFamilyMode
? (variationPositionInfo?.isInAnyVariation ?? false)
: session.deviationMoveIndex === null,
deviationMoveIndex: session.deviationMoveIndex,
lastUserMove: lastUserMove ? {
from: lastUserMove.uci.substring(0, 2),
@@ -395,11 +457,23 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
currentFeedback: currentFeedback ? {
category: currentFeedback.classification.category,
evaluationChange: currentFeedback.classification.evaluationChange,
theoreticalAlternatives: currentFeedback.classification.theoreticalAlternatives
} : null,
theoreticalAlternatives: isFamilyMode ? theoreticalMoves : currentFeedback.classification.theoreticalAlternatives
} : (isFamilyMode ? {
category: 'in-theory' as const,
evaluationChange: 0,
theoreticalAlternatives: theoreticalMoves
} : null),
wikipediaSummary: wikipediaSummary?.extract || undefined,
shouldTutorSpeak,
onTutorMessageSent: handleTutorMessageSent,
// Family mode specific info
isFamilyMode,
variationInfo: isFamilyMode && variationPositionInfo ? {
matchingVariations: variationPositionInfo.matchingCount,
currentVariationNames: variationPositionInfo.currentVariationNames,
possibleMoves: variationPositionInfo.nextMoves,
isEndOfLine: variationPositionInfo.isEndOfLine,
} : undefined,
};
return (
@@ -572,13 +646,81 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
<span className="text-gray-600 dark:text-gray-400">Moves played:</span>
<span className="font-medium text-gray-900 dark:text-white">{moveCount}</span>
</div>
{session.deviationMoveIndex !== null && (
{/* Family mode: show variation info */}
{isFamilyMode && variationPositionInfo && (
<>
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
<div className="flex justify-between mb-1">
<span className="text-gray-600 dark:text-gray-400">Matching variations:</span>
<span className="font-medium text-blue-600 dark:text-blue-400">
{variationPositionInfo.matchingCount}
</span>
</div>
{/* Show possible moves */}
{variationPositionInfo.nextMoves.length > 0 && (
<div className="mt-2">
<span className="text-gray-600 dark:text-gray-400 block mb-1">Possible moves:</span>
<div className="flex flex-wrap gap-1">
{variationPositionInfo.nextMoves.map((move) => (
<span
key={move}
className="inline-block px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded text-xs font-mono"
>
{move}
</span>
))}
</div>
</div>
)}
{/* Show current variation names (if narrowed down) */}
{variationPositionInfo.matchingCount > 0 && variationPositionInfo.matchingCount <= 3 && (
<div className="mt-2">
<span className="text-gray-600 dark:text-gray-400 block mb-1">Current line:</span>
<div className="space-y-1">
{variationPositionInfo.currentVariationNames.slice(0, 3).map((name) => (
<span
key={name}
className="block text-xs text-gray-700 dark:text-gray-300 truncate"
title={name}
>
{name}
</span>
))}
</div>
</div>
)}
{variationPositionInfo.isEndOfLine && (
<div className="mt-2">
<span className="inline-block px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-400 rounded text-xs">
End of repertoire line
</span>
</div>
)}
</div>
</>
)}
{/* Show off-book indicator (non-family mode or when truly off-book) */}
{!isFamilyMode && session.deviationMoveIndex !== null && (
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
Off-book since move {session.deviationMoveIndex + 1}
</span>
</div>
)}
{/* Family mode: show off-book when not in any variation */}
{isFamilyMode && variationPositionInfo && !variationPositionInfo.isInAnyVariation && moveCount > 0 && (
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
Off-book - move not in any variation
</span>
</div>
)}
</div>
</div>
+35 -3
View File
@@ -82,6 +82,14 @@ interface TutorProps {
wikipediaSummary?: string; // Optional Wikipedia context
shouldTutorSpeak?: boolean; // Guardrail: controls when tutor can send messages
onTutorMessageSent?: () => void; // Callback when tutor sends a message
// Family mode: training with multiple variations
isFamilyMode?: boolean;
variationInfo?: {
matchingVariations: number;
currentVariationNames: string[];
possibleMoves: string[];
isEndOfLine: boolean;
};
};
}
@@ -359,6 +367,8 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
const isInTheory = openingPracticeMode?.isInTheory ?? true;
const currentFeedback = openingPracticeMode?.currentFeedback;
const repertoireMovesLength = openingPracticeMode?.repertoireMoves?.length ?? 0;
const isFamilyMode = openingPracticeMode?.isFamilyMode ?? false;
const variationInfo = openingPracticeMode?.variationInfo;
// Automatic commentary for opening practice mode
useEffect(() => {
@@ -382,6 +392,14 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
if (userMoveKey && userMoveKey !== lastUserMoveRef.current) {
lastUserMoveRef.current = userMoveKey;
// Build variation context for family mode
const variationContext = isFamilyMode && variationInfo ? `
Variation info:
- Matching variations: ${variationInfo.matchingVariations}
- Current line(s): ${variationInfo.currentVariationNames.slice(0, 3).join(', ')}${variationInfo.currentVariationNames.length > 3 ? '...' : ''}
- Possible next moves: ${variationInfo.possibleMoves.join(', ') || 'none (end of line)'}
${variationInfo.isEndOfLine ? '- This is the end of this variation line' : ''}` : '';
// Generate commentary about user's move
const moveCommentary = `
[SYSTEM TRIGGER: user_move_in_opening]
@@ -391,13 +409,25 @@ 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(', ')}` : ''}
${variationContext}
INSTRUCTIONS:
${isInTheory
? `- The student is following the repertoire correctly - praise them briefly
? isFamilyMode
? `- The student is playing a valid move in the ${openingName} family
- Tell them which specific variation(s) they're now in (if narrowed down)
- Explain the key idea behind this move (1-2 sentences)
- If there are multiple possible moves at this position, you can briefly mention alternatives
- If you're about to make the next move, explain what it accomplishes`
: `- 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`
: `- The student deviated from theory
: isFamilyMode
? `- The student played a move not in any known variation of ${openingName}
- Gently mention which moves would have been in theory (${currentFeedback?.theoreticalAlternatives?.join(' or ') || 'the main lines'})
- Explain why those moves are preferred in the ${openingName}
- Encourage them to explore or try a different move`
: `- The student deviated from theory
- Gently point out what the repertoire move was
- Explain why the repertoire move is preferred
- Ask if they want to try again or continue exploring`}
@@ -469,7 +499,9 @@ Remember: You are both the opponent AND the tutor. Explain your move as if you'r
language,
openingName,
currentFeedback,
repertoireMovesLength
repertoireMovesLength,
isFamilyMode,
variationInfo
]);
// Scroll chat container to bottom (not the whole page)
+8 -4
View File
@@ -46,14 +46,16 @@ describe('Tutor', () => {
stockfish={stockfish}
evalP0={null}
evalP2={null}
openingData={null}
openingData={[]}
missedTactics={null}
onAnalysisComplete={() => {}}
apiKey="test-api-key"
personality={{
id: "test",
name: "Test Personality",
systemPrompt: "Test Prompt",
image: "🤖"
image: "🤖",
description: "Test description"
}}
language="en"
playerColor="white"
@@ -103,14 +105,16 @@ describe('Tutor', () => {
stockfish={stockfish}
evalP0={null}
evalP2={null}
openingData={null}
openingData={[]}
missedTactics={null}
onAnalysisComplete={() => {}}
apiKey="test-api-key"
personality={{
id: "test",
name: "Test Personality",
systemPrompt: "Test Prompt",
image: "🤖"
image: "🤖",
description: "Test description"
}}
language="en"
playerColor="white"
+13
View File
@@ -38,6 +38,7 @@ interface OpeningTrainingContextType {
// Actions
initializeSession: (opening: OpeningMetadata, forceNew?: boolean) => Promise<void>;
makeMove: (san: string) => Promise<void>;
undoToMove: (index: number) => void;
navigateToMove: (index: number) => void;
resetSession: () => void;
}
@@ -294,6 +295,17 @@ export function OpeningTrainingProvider({
}
};
const undoToMove = (index: number) => {
if (!session) return;
console.log('[OpeningTraining] Undoing to move index:', index);
dispatch({ type: 'UNDO_TO_MOVE', index });
// Clear feedback when undoing
setCurrentFeedback(null);
};
const navigateToMove = (index: number) => {
if (!session) return;
@@ -355,6 +367,7 @@ export function OpeningTrainingProvider({
currentFeedback,
initializeSession,
makeMove,
undoToMove,
navigateToMove,
resetSession,
};
@@ -96,6 +96,7 @@ const mockOpening: OpeningMetadata = {
eco: 'C00',
name: 'French Defense',
moves: '1. e4 e6 2. d4 d5',
src: 'test',
wikipediaSlug: 'French_Defence',
};
@@ -0,0 +1,381 @@
/**
* Integration tests for Family Training Mode
* Tests the variation tree and multi-variation support
*/
import {
buildVariationTree,
getTreeNodeAtPosition,
getMatchingVariations,
getAllPossibleNextMoves,
isMoveInVariationTree,
identifyCurrentVariation,
describeCurrentPosition,
} from '../gameLogic';
import { OpeningMetadata } from '@/lib/openings';
import { MoveHistoryEntry } from '@/types/openingTraining';
// ============================================================================
// Test Data - Italian Game variations
// ============================================================================
const italianGameVariations: OpeningMetadata[] = [
{
eco: 'C50',
name: 'Italian Game',
moves: '1. e4 e5 2. Nf3 Nc6 3. Bc4',
src: 'test',
isEcoRoot: true,
},
{
eco: 'C53',
name: 'Italian Game: Classical Variation',
moves: '1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3',
src: 'test',
},
{
eco: 'C54',
name: 'Italian Game: Giuoco Piano',
moves: '1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d4',
src: 'test',
},
{
eco: 'C55',
name: 'Italian Game: Two Knights Defense',
moves: '1. e4 e5 2. Nf3 Nc6 3. Bc4 Nf6',
src: 'test',
},
];
function createMoveEntry(
san: string,
color: 'white' | 'black',
moveNumber: number
): MoveHistoryEntry {
return {
moveNumber,
color,
san,
uci: 'e2e4',
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
timestamp: Date.now(),
evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null },
classification: {
category: 'in-theory',
inRepertoire: true,
evaluationChange: 0,
isSignificantSwing: false,
theoreticalAlternatives: [],
},
};
}
// ============================================================================
// Tests
// ============================================================================
describe('Family Training Mode - Variation Tree', () => {
describe('buildVariationTree', () => {
it('should build a tree from multiple variations', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
expect(tree.familyName).toBe('Italian Game');
expect(tree.allVariations).toHaveLength(4);
expect(tree.children.size).toBe(1); // Only 1. e4 at root
expect(tree.children.has('e4')).toBe(true);
});
it('should create correct branching structure', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// After 1. e4 e5 2. Nf3 Nc6 3. Bc4, there should be 2 branches
const e4Node = tree.children.get('e4')!;
const e5Node = e4Node.children.get('e5')!;
const nf3Node = e5Node.children.get('Nf3')!;
const nc6Node = nf3Node.children.get('Nc6')!;
const bc4Node = nc6Node.children.get('Bc4')!;
// After Bc4, should have Bc5 and Nf6 as options
expect(bc4Node.children.size).toBe(2);
expect(bc4Node.children.has('Bc5')).toBe(true);
expect(bc4Node.children.has('Nf6')).toBe(true);
});
it('should track variations at each node', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const e4Node = tree.children.get('e4')!;
// All variations start with e4
expect(e4Node.variations).toHaveLength(4);
});
it('should mark end of line correctly', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// Navigate to end of "Italian Game" (shortest variation)
const e4Node = tree.children.get('e4')!;
const e5Node = e4Node.children.get('e5')!;
const nf3Node = e5Node.children.get('Nf3')!;
const nc6Node = nf3Node.children.get('Nc6')!;
const bc4Node = nc6Node.children.get('Bc4')!;
expect(bc4Node.isEndOfLine).toBe(true); // End of C50 variation
});
});
describe('getTreeNodeAtPosition', () => {
it('should return root node for empty move history', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const node = getTreeNodeAtPosition(tree, []);
expect(node).not.toBeNull();
expect(node!.variations).toHaveLength(4);
});
it('should navigate to correct position', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
];
const node = getTreeNodeAtPosition(tree, moveHistory);
expect(node).not.toBeNull();
expect(node!.move).toBe('Nf3');
});
it('should return null for moves not in any variation', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const moveHistory = [
createMoveEntry('d4', 'white', 1), // Not in Italian Game!
];
const node = getTreeNodeAtPosition(tree, moveHistory);
expect(node).toBeNull();
});
});
describe('getMatchingVariations', () => {
it('should return all variations at start', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const variations = getMatchingVariations(tree, []);
expect(variations).toHaveLength(4);
});
it('should narrow down variations as moves are played', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// After 1. e4 e5 2. Nf3 Nc6 3. Bc4 Nf6
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
createMoveEntry('Nf6', 'black', 3),
];
const variations = getMatchingVariations(tree, moveHistory);
// Only Two Knights Defense
expect(variations).toHaveLength(1);
expect(variations[0].name).toBe('Italian Game: Two Knights Defense');
});
it('should return empty array for off-book moves', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('c5', 'black', 1), // Sicilian, not Italian!
];
const variations = getMatchingVariations(tree, moveHistory);
expect(variations).toHaveLength(0);
});
});
describe('getAllPossibleNextMoves', () => {
it('should return all possible first moves', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const moves = getAllPossibleNextMoves(tree, []);
expect(moves).toHaveLength(1);
expect(moves[0].move).toBe('e4');
expect(moves[0].variations).toHaveLength(4);
});
it('should return multiple options at branch points', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// After 1. e4 e5 2. Nf3 Nc6 3. Bc4
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
];
const moves = getAllPossibleNextMoves(tree, moveHistory);
// Should have Bc5 (Classical/Giuoco Piano) and Nf6 (Two Knights)
expect(moves).toHaveLength(2);
const moveNames = moves.map(m => m.move).sort();
expect(moveNames).toEqual(['Bc5', 'Nf6']);
});
it('should return empty array at end of all variations', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// Go to end of longest variation (Giuoco Piano)
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
createMoveEntry('Bc5', 'black', 3),
createMoveEntry('c3', 'white', 4),
createMoveEntry('Nf6', 'black', 4),
createMoveEntry('d4', 'white', 5),
];
const moves = getAllPossibleNextMoves(tree, moveHistory);
expect(moves).toHaveLength(0);
});
});
describe('isMoveInVariationTree', () => {
it('should return true for valid moves', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const isValid = isMoveInVariationTree(tree, [], 'e4');
expect(isValid).toBe(true);
});
it('should return false for invalid moves', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const isValid = isMoveInVariationTree(tree, [], 'd4');
expect(isValid).toBe(false);
});
it('should validate moves at branch points', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
];
expect(isMoveInVariationTree(tree, moveHistory, 'Bc5')).toBe(true);
expect(isMoveInVariationTree(tree, moveHistory, 'Nf6')).toBe(true);
expect(isMoveInVariationTree(tree, moveHistory, 'd6')).toBe(false);
});
});
describe('describeCurrentPosition', () => {
it('should describe starting position', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const desc = describeCurrentPosition(tree, []);
expect(desc.matchingCount).toBe(4);
expect(desc.isEndOfLine).toBe(false);
expect(desc.nextMoves).toContain('e4');
});
it('should identify branch points', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
];
const desc = describeCurrentPosition(tree, moveHistory);
expect(desc.matchingCount).toBe(4); // All 4 variations pass through this position
expect(desc.nextMoves).toHaveLength(2);
expect(desc.nextMoves).toContain('Bc5');
expect(desc.nextMoves).toContain('Nf6');
});
it('should detect end of line', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// Go to actual end of a line (Giuoco Piano)
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
createMoveEntry('Bc5', 'black', 3),
createMoveEntry('c3', 'white', 4),
createMoveEntry('Nf6', 'black', 4),
createMoveEntry('d4', 'white', 5),
];
const desc = describeCurrentPosition(tree, moveHistory);
expect(desc.isEndOfLine).toBe(true); // End of Giuoco Piano
expect(desc.matchingCount).toBe(1); // Only Giuoco Piano reaches here
expect(desc.nextMoves).toHaveLength(0); // No more moves in repertoire
});
});
describe('identifyCurrentVariation', () => {
it('should identify specific variation', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// Two Knights Defense
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
createMoveEntry('Nf6', 'black', 3),
];
const result = identifyCurrentVariation(tree, moveHistory);
expect(result.exact).toHaveLength(1);
expect(result.exact[0].name).toBe('Italian Game: Two Knights Defense');
});
it('should return multiple variations when moves overlap', () => {
const tree = buildVariationTree(italianGameVariations, 'Italian Game');
// After Bc5, both Classical and Giuoco Piano are possible
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e5', 'black', 1),
createMoveEntry('Nf3', 'white', 2),
createMoveEntry('Nc6', 'black', 2),
createMoveEntry('Bc4', 'white', 3),
createMoveEntry('Bc5', 'black', 3),
];
const result = identifyCurrentVariation(tree, moveHistory);
// No exact matches at this position, but multiple possible continuations
expect(result.possible.length).toBeGreaterThanOrEqual(2);
const names = result.possible.map(v => v.name);
expect(names).toContain('Italian Game: Classical Variation');
expect(names).toContain('Italian Game: Giuoco Piano');
});
});
});
@@ -0,0 +1,132 @@
/**
* Test French Defense Family Mode
*
* This tests the actual scenario where the tutor recommends wrong moves
*/
import {
buildVariationTree,
getAllPossibleNextMoves,
parseMoveSequence,
} from '../gameLogic';
import { getOpeningsByFamily } from '../openingLoader';
import { MoveHistoryEntry } from '@/types/openingTraining';
function createMoveEntry(
san: string,
color: 'white' | 'black',
moveNumber: number
): MoveHistoryEntry {
return {
moveNumber,
color,
san,
uci: 'e2e4',
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
timestamp: Date.now(),
evaluation: { score: 0, mate: null, depth: 15, bestMove: 'e4', ponder: null },
classification: {
category: 'in-theory',
inRepertoire: true,
evaluationChange: 0,
isSignificantSwing: false,
theoreticalAlternatives: [],
},
};
}
describe('French Defense - Family Mode Bug', () => {
it('should load French Defense variations', () => {
const variations = getOpeningsByFamily('French Defense');
expect(variations.length).toBeGreaterThan(0);
console.log(`Loaded ${variations.length} French Defense variations`);
});
it('should build variation tree for French Defense', () => {
const variations = getOpeningsByFamily('French Defense');
const tree = buildVariationTree(variations, 'French Defense');
expect(tree.familyName).toBe('French Defense');
expect(tree.allVariations.length).toBe(variations.length);
});
it('should show correct moves after 1. e4 e6', () => {
const variations = getOpeningsByFamily('French Defense');
const tree = buildVariationTree(variations, 'French Defense');
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e6', 'black', 1),
];
const possibleMoves = getAllPossibleNextMoves(tree, moveHistory);
console.log('After 1. e4 e6, possible 2nd moves for White:');
console.log(possibleMoves.map(m => m.move).join(', '));
// White should have d4 as an option
const moveNames = possibleMoves.map(m => m.move);
expect(moveNames).toContain('d4');
});
it('should show correct moves after 1. e4 e6 2. d4', () => {
const variations = getOpeningsByFamily('French Defense');
const tree = buildVariationTree(variations, 'French Defense');
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e6', 'black', 1),
createMoveEntry('d4', 'white', 2),
];
const possibleMoves = getAllPossibleNextMoves(tree, moveHistory);
console.log('After 1. e4 e6 2. d4, possible moves for Black:');
console.log(possibleMoves.map(m => m.move).join(', '));
// Black should have d5 as the main option
const moveNames = possibleMoves.map(m => m.move);
expect(moveNames).toContain('d5');
});
it('should show correct moves after 1. e4 e6 2. d4 d5', () => {
const variations = getOpeningsByFamily('French Defense');
const tree = buildVariationTree(variations, 'French Defense');
const moveHistory = [
createMoveEntry('e4', 'white', 1),
createMoveEntry('e6', 'black', 1),
createMoveEntry('d4', 'white', 2),
createMoveEntry('d5', 'black', 2),
];
const possibleMoves = getAllPossibleNextMoves(tree, moveHistory);
console.log('After 1. e4 e6 2. d4 d5, possible 3rd moves for White:');
const moveNames = possibleMoves.map(m => m.move).sort();
console.log(moveNames.join(', '));
// These are the moves from the earlier test
const expectedMoves = ['Nd2', 'Nc3', 'e5', 'exd5', 'Qe2', 'Nf3', 'c4', 'Be3', 'Nh3', 'Bd3'];
expectedMoves.forEach(move => {
expect(moveNames).toContain(move);
});
// Make sure we don't have any random moves
expect(moveNames.length).toBeGreaterThan(0);
console.log(`Total ${moveNames.length} possible moves found`);
});
it('should parse moves correctly from opening string', () => {
// Test the parseMoveSequence function
const testOpening = '1. e4 e6 2. d4 d5 3. Nd2';
const moves = parseMoveSequence(testOpening);
console.log('Parsed moves from "1. e4 e6 2. d4 d5 3. Nd2":');
console.log(moves);
expect(moves).toEqual(['e4', 'e6', 'd4', 'd5', 'Nd2']);
});
});
@@ -29,6 +29,7 @@ const mockOpening: OpeningMetadata = {
eco: 'C00',
name: 'French Defense',
moves: '1. e4 e6 2. d4 d5',
src: 'test',
wikipediaSlug: 'French_Defence',
};
@@ -36,6 +37,7 @@ const mockBlackOpening: OpeningMetadata = {
eco: 'D00',
name: "Queen's Pawn Opening",
moves: '1. d4 d5',
src: 'test',
wikipediaSlug: 'Queens_Pawn_Game',
};
@@ -0,0 +1,83 @@
import { getOpeningsByFamily, getAllOpenings } from '../openingLoader';
describe('openingLoader - French Defense', () => {
it('should load French Defense variations', () => {
const frenchVariations = getOpeningsByFamily('French Defense');
console.log('French Defense variations found:', frenchVariations.length);
console.log('First 5 variations:');
frenchVariations.slice(0, 5).forEach(v => {
console.log(` - ${v.name} (${v.eco}): ${v.moves}`);
});
expect(frenchVariations.length).toBeGreaterThan(0);
});
it('should have moves starting with e4 e6', () => {
const frenchVariations = getOpeningsByFamily('French Defense');
// All French Defense variations should start with 1. e4 e6 (allowing for extra spaces)
const allStartCorrectly = frenchVariations.every(v => {
const normalized = v.moves.replace(/\s+/g, ' ').trim();
return normalized.startsWith('1. e4 e6') || normalized.startsWith('1. e4 c5'); // Marshall Gambit starts differently
});
if (!allStartCorrectly) {
console.log('Variations NOT starting with e4 e6:');
frenchVariations
.filter(v => {
const normalized = v.moves.replace(/\s+/g, ' ').trim();
return !normalized.startsWith('1. e4 e6') && !normalized.startsWith('1. e4 c5');
})
.slice(0, 3)
.forEach(v => {
console.log(` - ${v.name}: ${v.moves}`);
});
}
expect(allStartCorrectly).toBe(true);
});
it('should find variation after 1. e4 e6 2. d4', () => {
const frenchVariations = getOpeningsByFamily('French Defense');
// Find variations that have at least 2. d4
const withD4 = frenchVariations.filter(v =>
v.moves.includes('2. d4')
);
console.log(`Variations with 2. d4: ${withD4.length}`);
console.log('Examples:');
withD4.slice(0, 5).forEach(v => {
console.log(` - ${v.name}: ${v.moves.split(' ').slice(0, 8).join(' ')}...`);
});
expect(withD4.length).toBeGreaterThan(0);
});
it('should show what moves are available after 1. e4 e6 2. d4 d5', () => {
const frenchVariations = getOpeningsByFamily('French Defense');
// Find all variations with 1. e4 e6 2. d4 d5
const afterD5 = frenchVariations.filter(v =>
v.moves.startsWith('1. e4 e6 2. d4 d5')
);
console.log(`Variations after 1. e4 e6 2. d4 d5: ${afterD5.length}`);
// Get all possible 3rd moves for White
const thirdMoves = new Set<string>();
afterD5.forEach(v => {
const moves = v.moves.split(' ');
// Find "3." and get the next move
const thirdMoveIndex = moves.findIndex(m => m === '3.');
if (thirdMoveIndex >= 0 && moves[thirdMoveIndex + 1]) {
thirdMoves.add(moves[thirdMoveIndex + 1]);
}
});
console.log('Possible 3rd moves for White:', Array.from(thirdMoves).join(', '));
expect(thirdMoves.size).toBeGreaterThan(0);
});
});
@@ -52,6 +52,7 @@ const mockOpening: OpeningMetadata = {
eco: 'C00',
name: 'French Defense',
moves: '1. e4 e6 2. d4 d5',
src: 'test',
wikipediaSlug: 'French_Defence',
};
@@ -20,6 +20,7 @@ const mockOpening: OpeningMetadata = {
eco: 'C00',
name: 'French Defense',
moves: '1. e4 e6 2. d4 d5',
src: 'test',
wikipediaSlug: 'French_Defence',
};
@@ -27,6 +28,7 @@ const mockBlackOpening: OpeningMetadata = {
eco: 'D00',
name: "Queen's Pawn",
moves: '1. d4 d5',
src: 'test',
};
function createMockMove(
+218
View File
@@ -434,3 +434,221 @@ export function shouldUseWikipediaContext(opening: OpeningMetadata): boolean {
// Use Wikipedia if available and not too obscure
return hasWikipediaPage(opening);
}
// ============================================================================
// Multi-Variation Support (Family Training)
// ============================================================================
/**
* Node in the variation tree
* Each node represents a position after a move, with children for possible continuations
*/
export interface VariationTreeNode {
move: string; // SAN notation of the move leading to this position
children: Map<string, VariationTreeNode>; // key = SAN, value = child node
variations: OpeningMetadata[]; // Openings that pass through this position
isEndOfLine: boolean; // True if this is the end of at least one variation
}
/**
* Root of the variation tree (starting position)
*/
export interface VariationTree {
children: Map<string, VariationTreeNode>;
allVariations: OpeningMetadata[];
familyName: string;
}
/**
* Build a variation tree from multiple openings
* This allows efficient lookup of which variations are still possible
*/
export function buildVariationTree(variations: OpeningMetadata[], familyName: string): VariationTree {
const tree: VariationTree = {
children: new Map(),
allVariations: variations,
familyName,
};
for (const opening of variations) {
const moves = parseMoveSequence(opening.moves);
let currentChildren = tree.children;
for (let i = 0; i < moves.length; i++) {
const move = moves[i];
const isLast = i === moves.length - 1;
if (!currentChildren.has(move)) {
currentChildren.set(move, {
move,
children: new Map(),
variations: [],
isEndOfLine: false,
});
}
const node = currentChildren.get(move)!;
node.variations.push(opening);
if (isLast) {
node.isEndOfLine = true;
}
currentChildren = node.children;
}
}
return tree;
}
/**
* Get the node at a specific position in the variation tree
* Returns null if the move sequence doesn't exist in any variation
*/
export function getTreeNodeAtPosition(
tree: VariationTree,
moveHistory: MoveHistoryEntry[]
): VariationTreeNode | null {
if (moveHistory.length === 0) {
// Return a virtual root node
return {
move: '',
children: tree.children,
variations: tree.allVariations,
isEndOfLine: false,
};
}
let currentChildren = tree.children;
let currentNode: VariationTreeNode | null = null;
for (const entry of moveHistory) {
const node = currentChildren.get(entry.san);
if (!node) {
return null; // Move sequence not in any variation
}
currentNode = node;
currentChildren = node.children;
}
return currentNode;
}
/**
* Get all variations that match the current move history
*/
export function getMatchingVariations(
tree: VariationTree,
moveHistory: MoveHistoryEntry[]
): OpeningMetadata[] {
const node = getTreeNodeAtPosition(tree, moveHistory);
return node ? node.variations : [];
}
/**
* Get all possible next moves from the current position across all matching variations
* Returns moves with their associated variations
*/
export function getAllPossibleNextMoves(
tree: VariationTree,
moveHistory: MoveHistoryEntry[]
): Array<{ move: string; variations: OpeningMetadata[] }> {
const node = getTreeNodeAtPosition(tree, moveHistory);
if (!node) return [];
const result: Array<{ move: string; variations: OpeningMetadata[] }> = [];
for (const [move, childNode] of node.children) {
result.push({
move,
variations: childNode.variations,
});
}
return result;
}
/**
* Check if a move is in any of the loaded variations
*/
export function isMoveInVariationTree(
tree: VariationTree,
moveHistory: MoveHistoryEntry[],
proposedMove: string
): boolean {
const node = getTreeNodeAtPosition(tree, moveHistory);
if (!node) return false;
return node.children.has(proposedMove);
}
/**
* Get the specific variation name(s) that match the exact move sequence
* This identifies which variation the user is currently playing
*/
export function identifyCurrentVariation(
tree: VariationTree,
moveHistory: MoveHistoryEntry[]
): { exact: OpeningMetadata[]; possible: OpeningMetadata[] } {
const node = getTreeNodeAtPosition(tree, moveHistory);
if (!node) {
return { exact: [], possible: [] };
}
// Exact matches: variations that end exactly at this position
const exact = node.variations.filter(v => {
const moves = parseMoveSequence(v.moves);
return moves.length === moveHistory.length;
});
// Possible: variations that could continue from here
const possible = node.variations.filter(v => {
const moves = parseMoveSequence(v.moves);
return moves.length > moveHistory.length;
});
return { exact, possible };
}
/**
* Check if we're still in theory (any variation)
*/
export function isInAnyVariation(
tree: VariationTree,
moveHistory: MoveHistoryEntry[]
): boolean {
return getTreeNodeAtPosition(tree, moveHistory) !== null;
}
/**
* Get a human-readable description of the current position in the variation tree
*/
export function describeCurrentPosition(
tree: VariationTree,
moveHistory: MoveHistoryEntry[]
): {
matchingCount: number;
nextMoves: string[];
currentVariationNames: string[];
isEndOfLine: boolean;
} {
const node = getTreeNodeAtPosition(tree, moveHistory);
if (!node) {
return {
matchingCount: 0,
nextMoves: [],
currentVariationNames: [],
isEndOfLine: false,
};
}
const nextMoves = Array.from(node.children.keys());
const variationNames = [...new Set(node.variations.map(v => v.name))];
return {
matchingCount: node.variations.length,
nextMoves,
currentVariationNames: variationNames,
isEndOfLine: node.isEndOfLine && nextMoves.length === 0,
};
}
+44
View File
@@ -83,3 +83,47 @@ export function getAllOpeningsByFen(): Record<string, OpeningMetadata> {
export function getAllOpeningsByEco(): Record<string, OpeningMetadata> {
return OPENINGS_BY_ECO;
}
/**
* Extract the family name from an opening name
*/
function extractFamilyName(openingName: string): string {
const separators = [':', ',', '', '—', ' - '];
for (const sep of separators) {
if (openingName.includes(sep)) {
return openingName.split(sep)[0].trim();
}
}
// Handle "Queen's Gambit Declined" -> "Queen's Gambit"
if (openingName.includes('Declined') || openingName.includes('Accepted')) {
return openingName.replace(/\s+(Declined|Accepted).*$/, '').trim();
}
return openingName;
}
/**
* Count moves in an opening's move string
*/
function countMoves(movesString: string): number {
if (!movesString) return 0;
return movesString.split(' ').filter(m => !m.match(/^\d+\.$/)).length;
}
/**
* Get all openings belonging to a specific family
* Returns variations sorted by move count (most moves first)
*/
export function getOpeningsByFamily(familyName: string): OpeningMetadata[] {
return OPENINGS_ARRAY
.filter((opening) => {
const family = extractFamilyName(opening.name);
return family === familyName && countMoves(opening.moves) > 1;
})
.sort((a, b) => {
// Sort by move count descending (more moves = deeper line)
const movesA = countMoves(a.moves);
const movesB = countMoves(b.moves);
if (movesA !== movesB) return movesB - movesA;
return a.eco.localeCompare(b.eco);
});
}
+42
View File
@@ -93,6 +93,7 @@ export type SessionAction =
| { type: 'NAVIGATE_PREVIOUS' }
| { type: 'NAVIGATE_NEXT' }
| { type: 'NAVIGATE_TO_CURRENT' } // Jump back to end of history
| { type: 'UNDO_TO_MOVE'; index: number } // Navigate and truncate history
// Theory tracking
| { type: 'DEVIATION_DETECTED'; moveIndex: number }
@@ -348,6 +349,47 @@ export function sessionReducer(
});
}
case 'UNDO_TO_MOVE': {
const { index } = action;
// Clamp index to valid range (-1 means start position, truncate all moves)
const clampedIndex = Math.max(-1, Math.min(index, state.moveHistory.length - 1));
// Truncate move history to this point
const newHistory = clampedIndex < 0 ? [] : state.moveHistory.slice(0, clampedIndex + 1);
// Build FEN at target index
const newFEN = buildFenAtIndex(newHistory, newHistory.length);
// Reset deviation if we're now back in theory
const isNowInTheory = isInTheory(state.opening, newHistory);
const newDeviationIndex = isNowInTheory ? null : state.deviationMoveIndex;
// Determine phase
let phase: SessionPhase;
if (newDeviationIndex !== null) {
phase = 'off_book';
} else if (isEndOfRepertoire(state.opening, newHistory.length)) {
phase = 'end_of_repertoire';
} else if (isOpponentTurn(state.opening, newHistory)) {
phase = 'opponent_turn';
} else {
phase = 'user_turn';
}
return {
...state,
moveHistory: newHistory,
currentMoveIndex: newHistory.length,
currentFEN: newFEN,
deviationMoveIndex: newDeviationIndex,
isInTheory: isNowInTheory,
phase,
pendingOpponentMove: null,
lastUpdatedAt: Date.now(),
};
}
case 'NAVIGATE_TO_CURRENT': {
// Jump to the end of history (current position)
const atEnd = state.currentMoveIndex === state.moveHistory.length;