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