Merge branch 'vk/d2b2-opening-training'
This commit is contained in:
@@ -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 "{familyName}"
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ jest.mock("./StartScreen", () => ({
|
|||||||
|
|
||||||
describe("ChessGame Component", () => {
|
describe("ChessGame Component", () => {
|
||||||
const mockPersonality = {
|
const mockPersonality = {
|
||||||
|
id: "test",
|
||||||
name: "Test Personality",
|
name: "Test Personality",
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
image: "🤖",
|
image: "🤖",
|
||||||
|
description: "Test description",
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "@testing-library/jest-dom";
|
|||||||
|
|
||||||
describe("EvaluationBar", () => {
|
describe("EvaluationBar", () => {
|
||||||
it("renders 0.0 for initial state", () => {
|
it("renders 0.0 for initial state", () => {
|
||||||
render(<EvaluationBar score={0} />);
|
render(<EvaluationBar score={0} isPlayerWhite={true} />);
|
||||||
expect(screen.getByText("0.0")).toBeInTheDocument();
|
expect(screen.getByText("0.0")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@ describe("EvaluationBar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders mate score", () => {
|
it("renders mate score", () => {
|
||||||
render(<EvaluationBar mate={3} />);
|
render(<EvaluationBar mate={3} isPlayerWhite={true} />);
|
||||||
expect(screen.getByText("M3")).toBeInTheDocument();
|
expect(screen.getByText("M3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders negative mate score", () => {
|
it("renders negative mate score", () => {
|
||||||
render(<EvaluationBar mate={-5} />);
|
render(<EvaluationBar mate={-5} isPlayerWhite={true} />);
|
||||||
expect(screen.getByText("M5")).toBeInTheDocument();
|
expect(screen.getByText("M5")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -33,6 +52,7 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
|
|||||||
currentFeedback,
|
currentFeedback,
|
||||||
initializeSession,
|
initializeSession,
|
||||||
makeMove,
|
makeMove,
|
||||||
|
undoToMove,
|
||||||
navigateToMove,
|
navigateToMove,
|
||||||
} = useOpeningTraining();
|
} = useOpeningTraining();
|
||||||
|
|
||||||
@@ -206,12 +226,12 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
|
|||||||
const handleUndoDeviation = () => {
|
const handleUndoDeviation = () => {
|
||||||
if (!session || session.deviationMoveIndex === null) return;
|
if (!session || session.deviationMoveIndex === null) return;
|
||||||
|
|
||||||
// Navigate back to the move before deviation
|
// Undo to the move before deviation (this truncates move history)
|
||||||
navigateToMove(session.deviationMoveIndex - 1);
|
const targetIndex = session.deviationMoveIndex - 1;
|
||||||
setShowDeviationDialog(false);
|
undoToMove(targetIndex);
|
||||||
|
|
||||||
// After a brief delay, make another legal move to continue in theory
|
// Close the dialog
|
||||||
// This allows the player to try again
|
setShowDeviationDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartGameFromPosition = () => {
|
const handleStartGameFromPosition = () => {
|
||||||
@@ -257,6 +277,44 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
|
|||||||
setLastTutorMessageMoveIndex(moveCount);
|
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
|
// Session recovery dialog
|
||||||
if (showRecoveryDialog && existingSession) {
|
if (showRecoveryDialog && existingSession) {
|
||||||
return (
|
return (
|
||||||
@@ -365,12 +423,16 @@ 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;
|
||||||
|
|
||||||
|
// Note: variationPositionInfo and theoreticalMoves are computed above (before early returns)
|
||||||
|
|
||||||
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 +457,23 @@ 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,
|
} : (isFamilyMode ? {
|
||||||
|
category: 'in-theory' as const,
|
||||||
|
evaluationChange: 0,
|
||||||
|
theoreticalAlternatives: theoreticalMoves
|
||||||
|
} : 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 +646,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -46,14 +46,16 @@ describe('Tutor', () => {
|
|||||||
stockfish={stockfish}
|
stockfish={stockfish}
|
||||||
evalP0={null}
|
evalP0={null}
|
||||||
evalP2={null}
|
evalP2={null}
|
||||||
openingData={null}
|
openingData={[]}
|
||||||
missedTactics={null}
|
missedTactics={null}
|
||||||
onAnalysisComplete={() => {}}
|
onAnalysisComplete={() => {}}
|
||||||
apiKey="test-api-key"
|
apiKey="test-api-key"
|
||||||
personality={{
|
personality={{
|
||||||
|
id: "test",
|
||||||
name: "Test Personality",
|
name: "Test Personality",
|
||||||
systemPrompt: "Test Prompt",
|
systemPrompt: "Test Prompt",
|
||||||
image: "🤖"
|
image: "🤖",
|
||||||
|
description: "Test description"
|
||||||
}}
|
}}
|
||||||
language="en"
|
language="en"
|
||||||
playerColor="white"
|
playerColor="white"
|
||||||
@@ -103,14 +105,16 @@ describe('Tutor', () => {
|
|||||||
stockfish={stockfish}
|
stockfish={stockfish}
|
||||||
evalP0={null}
|
evalP0={null}
|
||||||
evalP2={null}
|
evalP2={null}
|
||||||
openingData={null}
|
openingData={[]}
|
||||||
missedTactics={null}
|
missedTactics={null}
|
||||||
onAnalysisComplete={() => {}}
|
onAnalysisComplete={() => {}}
|
||||||
apiKey="test-api-key"
|
apiKey="test-api-key"
|
||||||
personality={{
|
personality={{
|
||||||
|
id: "test",
|
||||||
name: "Test Personality",
|
name: "Test Personality",
|
||||||
systemPrompt: "Test Prompt",
|
systemPrompt: "Test Prompt",
|
||||||
image: "🤖"
|
image: "🤖",
|
||||||
|
description: "Test description"
|
||||||
}}
|
}}
|
||||||
language="en"
|
language="en"
|
||||||
playerColor="white"
|
playerColor="white"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface OpeningTrainingContextType {
|
|||||||
// Actions
|
// Actions
|
||||||
initializeSession: (opening: OpeningMetadata, forceNew?: boolean) => Promise<void>;
|
initializeSession: (opening: OpeningMetadata, forceNew?: boolean) => Promise<void>;
|
||||||
makeMove: (san: string) => Promise<void>;
|
makeMove: (san: string) => Promise<void>;
|
||||||
|
undoToMove: (index: number) => void;
|
||||||
navigateToMove: (index: number) => void;
|
navigateToMove: (index: number) => void;
|
||||||
resetSession: () => 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) => {
|
const navigateToMove = (index: number) => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
@@ -355,6 +367,7 @@ export function OpeningTrainingProvider({
|
|||||||
currentFeedback,
|
currentFeedback,
|
||||||
initializeSession,
|
initializeSession,
|
||||||
makeMove,
|
makeMove,
|
||||||
|
undoToMove,
|
||||||
navigateToMove,
|
navigateToMove,
|
||||||
resetSession,
|
resetSession,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const mockOpening: OpeningMetadata = {
|
|||||||
eco: 'C00',
|
eco: 'C00',
|
||||||
name: 'French Defense',
|
name: 'French Defense',
|
||||||
moves: '1. e4 e6 2. d4 d5',
|
moves: '1. e4 e6 2. d4 d5',
|
||||||
|
src: 'test',
|
||||||
wikipediaSlug: 'French_Defence',
|
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',
|
eco: 'C00',
|
||||||
name: 'French Defense',
|
name: 'French Defense',
|
||||||
moves: '1. e4 e6 2. d4 d5',
|
moves: '1. e4 e6 2. d4 d5',
|
||||||
|
src: 'test',
|
||||||
wikipediaSlug: 'French_Defence',
|
wikipediaSlug: 'French_Defence',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const mockBlackOpening: OpeningMetadata = {
|
|||||||
eco: 'D00',
|
eco: 'D00',
|
||||||
name: "Queen's Pawn Opening",
|
name: "Queen's Pawn Opening",
|
||||||
moves: '1. d4 d5',
|
moves: '1. d4 d5',
|
||||||
|
src: 'test',
|
||||||
wikipediaSlug: 'Queens_Pawn_Game',
|
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',
|
eco: 'C00',
|
||||||
name: 'French Defense',
|
name: 'French Defense',
|
||||||
moves: '1. e4 e6 2. d4 d5',
|
moves: '1. e4 e6 2. d4 d5',
|
||||||
|
src: 'test',
|
||||||
wikipediaSlug: 'French_Defence',
|
wikipediaSlug: 'French_Defence',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const mockOpening: OpeningMetadata = {
|
|||||||
eco: 'C00',
|
eco: 'C00',
|
||||||
name: 'French Defense',
|
name: 'French Defense',
|
||||||
moves: '1. e4 e6 2. d4 d5',
|
moves: '1. e4 e6 2. d4 d5',
|
||||||
|
src: 'test',
|
||||||
wikipediaSlug: 'French_Defence',
|
wikipediaSlug: 'French_Defence',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const mockBlackOpening: OpeningMetadata = {
|
|||||||
eco: 'D00',
|
eco: 'D00',
|
||||||
name: "Queen's Pawn",
|
name: "Queen's Pawn",
|
||||||
moves: '1. d4 d5',
|
moves: '1. d4 d5',
|
||||||
|
src: 'test',
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMockMove(
|
function createMockMove(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export type SessionAction =
|
|||||||
| { type: 'NAVIGATE_PREVIOUS' }
|
| { type: 'NAVIGATE_PREVIOUS' }
|
||||||
| { type: 'NAVIGATE_NEXT' }
|
| { type: 'NAVIGATE_NEXT' }
|
||||||
| { type: 'NAVIGATE_TO_CURRENT' } // Jump back to end of history
|
| { type: 'NAVIGATE_TO_CURRENT' } // Jump back to end of history
|
||||||
|
| { type: 'UNDO_TO_MOVE'; index: number } // Navigate and truncate history
|
||||||
|
|
||||||
// Theory tracking
|
// Theory tracking
|
||||||
| { type: 'DEVIATION_DETECTED'; moveIndex: number }
|
| { 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': {
|
case 'NAVIGATE_TO_CURRENT': {
|
||||||
// Jump to the end of history (current position)
|
// Jump to the end of history (current position)
|
||||||
const atEnd = state.currentMoveIndex === state.moveHistory.length;
|
const atEnd = state.currentMoveIndex === state.moveHistory.length;
|
||||||
|
|||||||
Reference in New Issue
Block a user