Simplify opening training: train on entire opening family instead of single variation

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

The new flow:
1. User selects an opening family (e.g. "Sicilian Defense")
2. Training starts immediately with all variations loaded
3. User can play any move that exists in any variation
4. The UI shows which variations are still possible
5. The AI tutor guides the user through the repertoire
This commit is contained in:
Claude
2026-01-03 21:52:36 +00:00
parent 35ba74e5c0
commit f8cf03c888
7 changed files with 640 additions and 41 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>
</>
);
}
+3 -26
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import Header from '@/components/Header'; import Header from '@/components/Header';
import OpeningSelector from '@/components/OpeningTrainer/OpeningSelector';
import FamilySelector from '@/components/OpeningTrainer/FamilySelector'; import FamilySelector from '@/components/OpeningTrainer/FamilySelector';
import { useTranslation } from '@/lib/i18n/useTranslation'; import { useTranslation } from '@/lib/i18n/useTranslation';
import { SupportedLanguage } from '@/lib/i18n/translations'; import { SupportedLanguage } from '@/lib/i18n/translations';
@@ -15,7 +14,6 @@ export default function OpeningsPage() {
const router = useRouter(); const router = useRouter();
const [language, setLanguage] = useState<SupportedLanguage>('en'); const [language, setLanguage] = useState<SupportedLanguage>('en');
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [selectedFamily, setSelectedFamily] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const storedLang = localStorage.getItem('chess_tutor_language'); const storedLang = localStorage.getItem('chess_tutor_language');
@@ -35,14 +33,6 @@ export default function OpeningsPage() {
return groupOpeningsByFamily(allOpenings); return groupOpeningsByFamily(allOpenings);
}, [allOpenings]); }, [allOpenings]);
const handleSelectFamily = (familyName: string) => {
setSelectedFamily(familyName);
};
const handleBackToFamilies = () => {
setSelectedFamily(null);
};
if (!mounted) return null; if (!mounted) return null;
return ( return (
@@ -61,28 +51,15 @@ export default function OpeningsPage() {
</button> </button>
</div> </div>
{!selectedFamily ? (
<>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Opening Training Opening Training
</h1> </h1>
<p className="text-gray-600 dark:text-gray-400 mb-8"> <p className="text-gray-600 dark:text-gray-400 mb-8">
Select an opening family to explore. Each family contains multiple variations Select an opening family to train. You can play any variation within the family,
with engine-backed feedback and AI-powered explanations. and your AI coach will guide you through the different lines.
</p> </p>
<FamilySelector <FamilySelector families={openingFamilies} />
families={openingFamilies}
onSelectFamily={handleSelectFamily}
/>
</>
) : (
<OpeningSelector
openings={allOpenings}
selectedFamily={selectedFamily}
onBackToFamilies={handleBackToFamilies}
/>
)}
</div> </div>
</div> </div>
</> </>
@@ -1,14 +1,23 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { OpeningFamily } from '@/lib/openingTrainer/openingFamilies'; import { OpeningFamily } from '@/lib/openingTrainer/openingFamilies';
interface FamilySelectorProps { interface FamilySelectorProps {
families: OpeningFamily[]; families: OpeningFamily[];
onSelectFamily: (familyName: string) => void; onSelectFamily?: (familyName: string) => void; // Made optional - now navigates directly
} }
export default function FamilySelector({ families, onSelectFamily }: FamilySelectorProps) { 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 // Group families by ECO range for display
const groupedFamilies = useMemo(() => { const groupedFamilies = useMemo(() => {
const groups: Record<string, OpeningFamily[]> = { const groups: Record<string, OpeningFamily[]> = {
@@ -52,7 +61,7 @@ export default function FamilySelector({ families, onSelectFamily }: FamilySelec
{categoryFamilies.map((family) => ( {categoryFamilies.map((family) => (
<button <button
key={family.name} 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" 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`} aria-label={`Select ${family.name} opening family`}
> >
@@ -6,7 +6,15 @@ import { Chessboard } from 'react-chessboard';
import { OpeningMetadata } from '@/lib/openings'; import { OpeningMetadata } from '@/lib/openings';
import { useOpeningTraining, useChessInstance } from '@/contexts/OpeningTrainingContext'; import { useOpeningTraining, useChessInstance } from '@/contexts/OpeningTrainingContext';
import { loadSession } from '@/lib/openingTrainer/sessionManager'; 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 { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining'; import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies'; import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
@@ -22,10 +30,21 @@ interface OpeningTrainerProps {
personality: Personality; personality: Personality;
apiKey: string; apiKey: string;
language: SupportedLanguage; 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 router = useRouter();
const isFamilyMode = !!variationTree && !!allVariations;
const { const {
session, session,
@@ -365,12 +384,42 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null; const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null;
const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null; const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null;
// Family mode: compute current position info from variation tree
const variationPositionInfo = useMemo(() => {
if (!isFamilyMode || !variationTree) {
return null;
}
const positionDesc = describeCurrentPosition(variationTree, session.moveHistory);
const currentVariations = identifyCurrentVariation(variationTree, session.moveHistory);
const possibleMoves = getAllPossibleNextMoves(variationTree, session.moveHistory);
return {
...positionDesc,
currentVariations,
possibleMoves,
isInAnyVariation: positionDesc.matchingCount > 0,
};
}, [isFamilyMode, variationTree, session.moveHistory]);
// Get all possible next moves (for display and tutor context)
const theoreticalMoves = useMemo(() => {
if (isFamilyMode && variationPositionInfo) {
return variationPositionInfo.nextMoves;
}
// Single variation mode
const moves = parseMoveSequence(opening.moves);
const nextMove = moves[session.moveHistory.length];
return nextMove ? [nextMove] : [];
}, [isFamilyMode, variationPositionInfo, opening.moves, session.moveHistory.length]);
const openingPracticeMode = { const openingPracticeMode = {
openingName: opening.name, openingName: opening.name,
openingEco: opening.eco, openingEco: opening.eco,
repertoireMoves, repertoireMoves,
currentMoveIndex: session.moveHistory.length, currentMoveIndex: session.moveHistory.length,
isInTheory: session.deviationMoveIndex === null, isInTheory: isFamilyMode
? (variationPositionInfo?.isInAnyVariation ?? false)
: session.deviationMoveIndex === null,
deviationMoveIndex: session.deviationMoveIndex, deviationMoveIndex: session.deviationMoveIndex,
lastUserMove: lastUserMove ? { lastUserMove: lastUserMove ? {
from: lastUserMove.uci.substring(0, 2), from: lastUserMove.uci.substring(0, 2),
@@ -395,11 +444,19 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
currentFeedback: currentFeedback ? { currentFeedback: currentFeedback ? {
category: currentFeedback.classification.category, category: currentFeedback.classification.category,
evaluationChange: currentFeedback.classification.evaluationChange, evaluationChange: currentFeedback.classification.evaluationChange,
theoreticalAlternatives: currentFeedback.classification.theoreticalAlternatives theoreticalAlternatives: isFamilyMode ? theoreticalMoves : currentFeedback.classification.theoreticalAlternatives
} : null, } : null,
wikipediaSummary: wikipediaSummary?.extract || undefined, wikipediaSummary: wikipediaSummary?.extract || undefined,
shouldTutorSpeak, shouldTutorSpeak,
onTutorMessageSent: handleTutorMessageSent, onTutorMessageSent: handleTutorMessageSent,
// Family mode specific info
isFamilyMode,
variationInfo: isFamilyMode && variationPositionInfo ? {
matchingVariations: variationPositionInfo.matchingCount,
currentVariationNames: variationPositionInfo.currentVariationNames,
possibleMoves: variationPositionInfo.nextMoves,
isEndOfLine: variationPositionInfo.isEndOfLine,
} : undefined,
}; };
return ( return (
@@ -572,13 +629,81 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
<span className="text-gray-600 dark:text-gray-400">Moves played:</span> <span className="text-gray-600 dark:text-gray-400">Moves played:</span>
<span className="font-medium text-gray-900 dark:text-white">{moveCount}</span> <span className="font-medium text-gray-900 dark:text-white">{moveCount}</span>
</div> </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"> <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"> <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} Off-book since move {session.deviationMoveIndex + 1}
</span> </span>
</div> </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>
</div> </div>
+34 -2
View File
@@ -82,6 +82,14 @@ interface TutorProps {
wikipediaSummary?: string; // Optional Wikipedia context wikipediaSummary?: string; // Optional Wikipedia context
shouldTutorSpeak?: boolean; // Guardrail: controls when tutor can send messages shouldTutorSpeak?: boolean; // Guardrail: controls when tutor can send messages
onTutorMessageSent?: () => void; // Callback when tutor sends a message 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 isInTheory = openingPracticeMode?.isInTheory ?? true;
const currentFeedback = openingPracticeMode?.currentFeedback; const currentFeedback = openingPracticeMode?.currentFeedback;
const repertoireMovesLength = openingPracticeMode?.repertoireMoves?.length ?? 0; const repertoireMovesLength = openingPracticeMode?.repertoireMoves?.length ?? 0;
const isFamilyMode = openingPracticeMode?.isFamilyMode ?? false;
const variationInfo = openingPracticeMode?.variationInfo;
// Automatic commentary for opening practice mode // Automatic commentary for opening practice mode
useEffect(() => { useEffect(() => {
@@ -382,6 +392,14 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
if (userMoveKey && userMoveKey !== lastUserMoveRef.current) { if (userMoveKey && userMoveKey !== lastUserMoveRef.current) {
lastUserMoveRef.current = userMoveKey; 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 // Generate commentary about user's move
const moveCommentary = ` const moveCommentary = `
[SYSTEM TRIGGER: user_move_in_opening] [SYSTEM TRIGGER: user_move_in_opening]
@@ -391,12 +409,24 @@ Move category: ${currentFeedback?.category || 'unknown'}
Position status: ${isInTheory ? 'In theory' : 'Deviated from repertoire'} Position status: ${isInTheory ? 'In theory' : 'Deviated from repertoire'}
${currentFeedback?.evaluationChange !== undefined ? `Evaluation change: ${currentFeedback.evaluationChange.toFixed(2)}` : ''} ${currentFeedback?.evaluationChange !== undefined ? `Evaluation change: ${currentFeedback.evaluationChange.toFixed(2)}` : ''}
${currentFeedback?.theoreticalAlternatives && currentFeedback.theoreticalAlternatives.length > 0 ? `Theory suggested: ${currentFeedback.theoreticalAlternatives.join(', ')}` : ''} ${currentFeedback?.theoreticalAlternatives && currentFeedback.theoreticalAlternatives.length > 0 ? `Theory suggested: ${currentFeedback.theoreticalAlternatives.join(', ')}` : ''}
${variationContext}
INSTRUCTIONS: INSTRUCTIONS:
${isInTheory ${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) - Explain the key idea behind this move (1-2 sentences)
- If you're about to make the next move, you can mention it naturally` - If you're about to make the next move, you can mention it naturally`
: 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 : `- The student deviated from theory
- Gently point out what the repertoire move was - Gently point out what the repertoire move was
- Explain why the repertoire move is preferred - Explain why the repertoire move is preferred
@@ -469,7 +499,9 @@ Remember: You are both the opponent AND the tutor. Explain your move as if you'r
language, language,
openingName, openingName,
currentFeedback, currentFeedback,
repertoireMovesLength repertoireMovesLength,
isFamilyMode,
variationInfo
]); ]);
// Scroll chat container to bottom (not the whole page) // Scroll chat container to bottom (not the whole page)
+218
View File
@@ -434,3 +434,221 @@ export function shouldUseWikipediaContext(opening: OpeningMetadata): boolean {
// Use Wikipedia if available and not too obscure // Use Wikipedia if available and not too obscure
return hasWikipediaPage(opening); 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> { export function getAllOpeningsByEco(): Record<string, OpeningMetadata> {
return OPENINGS_BY_ECO; 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);
});
}