Add mobile app support and opening training feature
This commit implements iOS/Android mobile app support using Capacitor and adds a comprehensive opening training feature with LLM-powered explanations. ## Mobile App Infrastructure - Add Capacitor configuration for iOS/Android builds - Create mobile build script that excludes API routes - Update Next.js config for conditional static export - Add layout components with generateStaticParams for static builds - Generate 500+ static pages for offline mobile use ## Chess Engine Abstraction - Create ChessEngine interface for pluggable implementations - Add LocalEngine (GPL - uses stockfish.js in browser) - Add RemoteEngine (proprietary - calls API server) - Factory pattern selects engine based on environment - Enables GPL compliance for web, proprietary for mobile ## Opening Training Feature - Interactive opening repertoire training - Move validation with engine-backed feedback - LLM explanations using Gemini API - Wikipedia integration for opening context - Opening family grouping (e4, d4, c4, etc.) - Session state management - Real-time move feedback with evaluation Components: - OpeningSelector: Browse and select openings by family - OpeningTrainer: Main training interface with chessboard - MoveFeedback: Display move quality and LLM explanations - WikipediaSummary: Show opening history and context - ErrorBoundary: Graceful error handling Services: - openingLoader: Load and filter opening database - engineService: Engine evaluation wrapper - moveValidator: Validate moves against repertoire - feedbackGenerator: Generate contextual feedback - wikipediaService: Fetch and cache Wikipedia data - sessionManager: Track training session state ## Wikipedia Integration - Automatic Wikipedia article fetching for openings - Client-side and server-side caching - Sanitized summaries with proper formatting - Link opening database to Wikipedia slugs - API endpoints for on-demand fetching ## Docker Improvements - Add entrypoint script for automatic data setup - Fetch Wikipedia data on first container startup - Generate opening move index automatically - Remove generated data from git (public/openings/*.json, public/wikipedia/*.json) - Add READMEs explaining data requirements - Update .gitignore for generated files ## Dual Licensing Strategy - Add LICENSING.md explaining dual licensing approach - GPL-3.0 for web builds (includes Stockfish) - Proprietary option for mobile builds (no GPL code) - Single codebase, multiple licensing models - Legal compliance documented ## API Endpoints - POST /api/v1/llm/opening-explanation - Get LLM move explanations - GET /api/v1/wikipedia/summary - Fetch Wikipedia summaries ## Type Updates - Add openingTraining types - Update Tutor component to use ChessEngine interface - Add Gemini error handling types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import {
|
||||
OPENING_TUTOR_SYSTEM_PROMPT,
|
||||
OPENING_TUTOR_TEMPERATURE,
|
||||
OPENING_TUTOR_MAX_TOKENS,
|
||||
generateFallbackExplanation,
|
||||
} from '@/lib/server/openingTutorPrompt';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 10; // 10 second timeout
|
||||
|
||||
/**
|
||||
* Opening Explanation API Endpoint
|
||||
* Generates educational explanations for chess moves using LLM
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
prompt,
|
||||
moveSan,
|
||||
category,
|
||||
theoreticalMoves,
|
||||
evalChange,
|
||||
bestMove,
|
||||
} = body;
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing prompt parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.warn('GEMINI_API_KEY not configured, using fallback explanation');
|
||||
const fallback = generateFallbackExplanation(
|
||||
category,
|
||||
moveSan,
|
||||
theoreticalMoves,
|
||||
evalChange,
|
||||
bestMove
|
||||
);
|
||||
return NextResponse.json({
|
||||
explanation: fallback,
|
||||
usedFallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Gemini API
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: 'gemini-1.5-flash',
|
||||
generationConfig: {
|
||||
temperature: OPENING_TUTOR_TEMPERATURE,
|
||||
maxOutputTokens: OPENING_TUTOR_MAX_TOKENS,
|
||||
},
|
||||
systemInstruction: OPENING_TUTOR_SYSTEM_PROMPT,
|
||||
});
|
||||
|
||||
// Generate explanation
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = result.response;
|
||||
const explanation = response.text();
|
||||
|
||||
if (!explanation || explanation.trim().length === 0) {
|
||||
// Empty response - use fallback
|
||||
const fallback = generateFallbackExplanation(
|
||||
category,
|
||||
moveSan,
|
||||
theoreticalMoves,
|
||||
evalChange,
|
||||
bestMove
|
||||
);
|
||||
return NextResponse.json({
|
||||
explanation: fallback,
|
||||
usedFallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
explanation: explanation.trim(),
|
||||
usedFallback: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('LLM explanation error:', error);
|
||||
|
||||
// Try to extract fallback params from request
|
||||
let fallbackExplanation = 'Unable to generate explanation at this time.';
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (body.moveSan && body.category) {
|
||||
fallbackExplanation = generateFallbackExplanation(
|
||||
body.category,
|
||||
body.moveSan,
|
||||
body.theoreticalMoves || [],
|
||||
body.evalChange,
|
||||
body.bestMove
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore fallback generation errors
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
explanation: fallbackExplanation,
|
||||
usedFallback: true,
|
||||
error: 'LLM request failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Wikipedia Summary API Endpoint
|
||||
* Fetches Wikipedia article summaries for chess openings
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const openingName = searchParams.get('opening');
|
||||
|
||||
if (!openingName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing opening parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Wikipedia API] Searching for:', openingName);
|
||||
|
||||
// Step 1: Use Wikipedia Search API to find the best matching article
|
||||
// This handles partial matches, redirects, and disambiguations
|
||||
const searchUrl = new URL('https://en.wikipedia.org/w/api.php');
|
||||
searchUrl.searchParams.set('action', 'opensearch');
|
||||
searchUrl.searchParams.set('search', openingName);
|
||||
searchUrl.searchParams.set('limit', '5'); // Get top 5 results
|
||||
searchUrl.searchParams.set('namespace', '0'); // Main articles only
|
||||
searchUrl.searchParams.set('format', 'json');
|
||||
|
||||
const searchResponse = await fetch(searchUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
console.error('[Wikipedia API] Search failed:', searchResponse.status);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Wikipedia search failed',
|
||||
fallback: 'No background information available for this opening.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const searchData = await searchResponse.json();
|
||||
// OpenSearch returns: [query, [titles], [descriptions], [urls]]
|
||||
const titles = searchData[1] as string[];
|
||||
const descriptions = searchData[2] as string[];
|
||||
|
||||
if (!titles || titles.length === 0) {
|
||||
console.log('[Wikipedia API] No results found for:', openingName);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Wikipedia article not found',
|
||||
fallback: 'No background information available for this opening.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find best match (prioritize chess-related articles)
|
||||
let bestMatch = titles[0]; // Default to first result
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
const title = titles[i];
|
||||
const description = descriptions[i] || '';
|
||||
|
||||
// Prioritize results with chess-related keywords
|
||||
if (
|
||||
description.toLowerCase().includes('chess') ||
|
||||
description.toLowerCase().includes('opening') ||
|
||||
title.toLowerCase().includes('chess')
|
||||
) {
|
||||
bestMatch = title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Wikipedia API] Best match:', bestMatch, 'from', titles.length, 'results');
|
||||
|
||||
// Step 2: Fetch summary for the best matching article
|
||||
const summaryUrl = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(
|
||||
bestMatch
|
||||
)}`;
|
||||
|
||||
const summaryResponse = await fetch(summaryUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!summaryResponse.ok) {
|
||||
console.error('[Wikipedia API] Summary fetch failed for:', bestMatch);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Wikipedia article not found',
|
||||
fallback: 'No background information available for this opening.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await summaryResponse.json();
|
||||
|
||||
// Format the response
|
||||
const summary = {
|
||||
openingName,
|
||||
title: data.title,
|
||||
extract: data.extract,
|
||||
url:
|
||||
data.content_urls?.desktop?.page ||
|
||||
`https://en.wikipedia.org/wiki/${encodeURIComponent(bestMatch)}`,
|
||||
fetchedAt: Date.now(),
|
||||
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
};
|
||||
|
||||
console.log('[Wikipedia API] Successfully fetched:', data.title);
|
||||
return NextResponse.json(summary);
|
||||
} catch (error) {
|
||||
console.error('[Wikipedia API] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Wikipedia summary' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Server component layout for static export
|
||||
// This allows generateStaticParams while the page remains a client component
|
||||
|
||||
export async function generateStaticParams() {
|
||||
// Generate params for root ECO codes (A00-E99)
|
||||
// This creates 500 static pages for the mobile build
|
||||
const ecoRoots = [];
|
||||
for (const letter of ['A', 'B', 'C', 'D', 'E']) {
|
||||
for (let num = 0; num <= 99; num++) {
|
||||
const eco = `${letter}${String(num).padStart(2, '0')}`;
|
||||
ecoRoots.push({ openingId: eco });
|
||||
}
|
||||
}
|
||||
return ecoRoots;
|
||||
}
|
||||
|
||||
export default function OpeningIdLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } 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 { getOpeningByEco } from '@/lib/openingTrainer/openingLoader';
|
||||
import { Personality, PERSONALITIES } from '@/lib/personalities';
|
||||
|
||||
export default function OpeningTrainingPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const openingId = params.openingId as string;
|
||||
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('en');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [opening, setOpening] = useState<OpeningMetadata | 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) {
|
||||
loadOpening();
|
||||
}
|
||||
}, [openingId, mounted]);
|
||||
|
||||
const loadOpening = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Find opening by ECO code
|
||||
const foundOpening = getOpeningByEco(openingId);
|
||||
|
||||
if (!foundOpening) {
|
||||
// Opening not found - redirect back to selection
|
||||
router.push('/learning/openings');
|
||||
return;
|
||||
}
|
||||
|
||||
setOpening(foundOpening);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
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">Loading opening training session...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!opening) {
|
||||
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">
|
||||
Opening Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
The requested opening could not be found.
|
||||
</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"
|
||||
>
|
||||
Back to Opening Selection
|
||||
</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">Back to Opening Selection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">{opening.name}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">ECO: {opening.eco}</p>
|
||||
</div>
|
||||
|
||||
<OpeningTrainerErrorBoundary>
|
||||
<OpeningTrainingProvider>
|
||||
<OpeningTrainer
|
||||
opening={opening}
|
||||
personality={selectedPersonality}
|
||||
apiKey={apiKey}
|
||||
language={language}
|
||||
/>
|
||||
</OpeningTrainingProvider>
|
||||
</OpeningTrainerErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import { getEcoRootOpenings } from '@/lib/openingTrainer/openingLoader';
|
||||
import { groupOpeningsByFamily } from '@/lib/openingTrainer/openingFamilies';
|
||||
|
||||
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');
|
||||
if (storedLang) setLanguage(storedLang as SupportedLanguage);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const t = useTranslation(language);
|
||||
|
||||
// Get ECO root openings for display
|
||||
const allOpenings = useMemo(() => {
|
||||
return getEcoRootOpenings();
|
||||
}, []);
|
||||
|
||||
// Group openings by family
|
||||
const openingFamilies = useMemo(() => {
|
||||
return groupOpeningsByFamily(allOpenings);
|
||||
}, [allOpenings]);
|
||||
|
||||
const handleSelectFamily = (familyName: string) => {
|
||||
setSelectedFamily(familyName);
|
||||
};
|
||||
|
||||
const handleBackToFamilies = () => {
|
||||
setSelectedFamily(null);
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
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-8 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.push('/learning')}
|
||||
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.backToMenu}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!selectedFamily ? (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Opening Training
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
Select an opening family to explore. Each family contains multiple variations
|
||||
with engine-backed feedback and AI-powered explanations.
|
||||
</p>
|
||||
|
||||
<FamilySelector
|
||||
families={openingFamilies}
|
||||
onSelectFamily={handleSelectFamily}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<OpeningSelector
|
||||
openings={allOpenings}
|
||||
selectedFamily={selectedFamily}
|
||||
onBackToFamilies={handleBackToFamilies}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -145,11 +145,24 @@ export default function LearningAreaPage() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||
{t.learning.comingSoon}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/learning/openings')}
|
||||
className="w-full group bg-white dark:bg-gray-800 p-8 rounded-xl hover:bg-purple-50 dark:hover:bg-gray-700 transition-all border-2 border-transparent hover:border-purple-500 dark:hover:border-purple-400 shadow-sm hover:shadow-md text-left"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-5xl group-hover:scale-110 transition-transform">
|
||||
📖
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white text-xl mb-2">
|
||||
Opening Training
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Practice opening repertoire with engine-backed feedback and AI explanations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Server component layout for static export
|
||||
// This allows generateStaticParams while the page remains a client component
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const patterns = ['pin', 'skewer', 'fork', 'discovered_attack', 'discovered_check'];
|
||||
return patterns.map((pattern) => ({
|
||||
pattern,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function TacticsPatternLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Chess, Move } from "chess.js";
|
||||
import { Chess, Move, Square } from "chess.js";
|
||||
import { Chessboard } from "react-chessboard";
|
||||
import { ArrowLeft, CheckCircle, XCircle, RefreshCw, SkipForward } from "lucide-react";
|
||||
import Header from "@/components/Header";
|
||||
@@ -153,7 +153,7 @@ export default function TacticalPracticePage() {
|
||||
if (setupError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header language={language} onLanguageChange={setLanguage} />
|
||||
<Header language={language} />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
@@ -218,7 +218,7 @@ export default function TacticalPracticePage() {
|
||||
return t.learning.patterns[mapping[pattern]];
|
||||
};
|
||||
|
||||
const onDrop = ({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) => {
|
||||
const onDrop = ({ sourceSquare, targetSquare }: { sourceSquare: Square; targetSquare: Square | null }) => {
|
||||
if (!targetSquare || feedback !== 'none') return false;
|
||||
|
||||
// Additional validation: Check if there's actually a piece on the source square
|
||||
@@ -571,7 +571,7 @@ export default function TacticalPracticePage() {
|
||||
position: fen,
|
||||
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
||||
console.log('🎲 onPieceDrop called with:', { sourceSquare, targetSquare });
|
||||
return onDrop({ sourceSquare, targetSquare });
|
||||
return onDrop({ sourceSquare: sourceSquare as Square, targetSquare: targetSquare as Square | null });
|
||||
},
|
||||
darkSquareStyle: { backgroundColor: '#779954' },
|
||||
lightSquareStyle: { backgroundColor: '#e9edcc' },
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, ExternalLink, X, Settings, Key } from 'lucide-react';
|
||||
import { GeminiErrorInfo } from '@/lib/geminiErrorHandler';
|
||||
import { ApiKeyInfo, getApiKeySourceDescription } from '@/lib/apiKeyHelper';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface GeminiErrorModalProps {
|
||||
error: GeminiErrorInfo;
|
||||
apiKeyInfo: ApiKeyInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GeminiErrorModal({ error, apiKeyInfo, onClose }: GeminiErrorModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
onClose();
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="text-red-600 dark:text-red-400" size={24} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{error.isQuotaError ? 'API Quota Exceeded' : 'API Error'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* API Key Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" size={18} />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Current API Key:
|
||||
</p>
|
||||
<p className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{apiKeyInfo.anonymized}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Source: {getApiKeySourceDescription(apiKeyInfo.source)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{error.userMessage}
|
||||
</p>
|
||||
|
||||
{error.retryAfterSeconds && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
You can try again in <strong>{error.retryAfterSeconds} seconds</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.isQuotaError && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-2">
|
||||
<strong>Solutions:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-800 dark:text-yellow-300 list-disc list-inside space-y-1">
|
||||
{apiKeyInfo.source === 'localStorage' && (
|
||||
<li>Change your API key to a different one with available quota</li>
|
||||
)}
|
||||
<li>Upgrade to a paid Gemini API plan for higher quotas</li>
|
||||
<li>Wait until tomorrow for your free tier quota to reset</li>
|
||||
<li>Use the chess tutor less frequently throughout the day</li>
|
||||
{apiKeyInfo.source === 'env' && (
|
||||
<li>Update the NEXT_PUBLIC_GEMINI_API_KEY environment variable</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Change API Key button (if from localStorage) */}
|
||||
{apiKeyInfo.source === 'localStorage' && (
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Change API Key in Settings
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="https://ai.google.dev/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
View Gemini API Pricing
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://ai.dev/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Check Your API Usage
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical details (collapsible) */}
|
||||
<details className="mt-4">
|
||||
<summary className="text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 dark:bg-gray-900 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
|
||||
{error.technicalMessage}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary for Opening Trainer components
|
||||
* Catches and handles runtime errors gracefully
|
||||
*/
|
||||
export class OpeningTrainerErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Opening Trainer Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
// Reload the page to restart the training session
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-2xl">
|
||||
<h2 className="text-xl font-bold text-red-900 mb-4">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-red-700 mb-4">
|
||||
{this.state.error?.message ||
|
||||
'An unexpected error occurred in the opening trainer.'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium"
|
||||
>
|
||||
Restart Training Session
|
||||
</button>
|
||||
<button
|
||||
onClick={() => (window.location.href = '/learning/openings')}
|
||||
className="ml-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Back to Opening Selection
|
||||
</button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-4 text-sm text-gray-600">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Error Details (Development Only)
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-gray-100 rounded overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { OpeningFamily } from '@/lib/openingTrainer/openingFamilies';
|
||||
|
||||
interface FamilySelectorProps {
|
||||
families: OpeningFamily[];
|
||||
onSelectFamily: (familyName: string) => void;
|
||||
}
|
||||
|
||||
export default function FamilySelector({ families, onSelectFamily }: FamilySelectorProps) {
|
||||
// Group families by ECO range for display
|
||||
const groupedFamilies = useMemo(() => {
|
||||
const groups: Record<string, OpeningFamily[]> = {
|
||||
'White Openings (1.e4)': [],
|
||||
'White Openings (1.d4)': [],
|
||||
'Black Defenses vs 1.e4': [],
|
||||
'Black Defenses vs 1.d4': [],
|
||||
'Other Openings': [],
|
||||
};
|
||||
|
||||
families.forEach(family => {
|
||||
const firstEco = family.ecoRange[0];
|
||||
|
||||
if (firstEco === 'C') {
|
||||
groups['White Openings (1.e4)'].push(family);
|
||||
} else if (firstEco === 'D') {
|
||||
groups['White Openings (1.d4)'].push(family);
|
||||
} else if (firstEco === 'B') {
|
||||
groups['Black Defenses vs 1.e4'].push(family);
|
||||
} else if (firstEco === 'E') {
|
||||
groups['Black Defenses vs 1.d4'].push(family);
|
||||
} else {
|
||||
groups['Other Openings'].push(family);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [families]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(groupedFamilies).map(([category, categoryFamilies]) => {
|
||||
if (categoryFamilies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{category} ({categoryFamilies.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryFamilies.map((family) => (
|
||||
<button
|
||||
key={family.name}
|
||||
onClick={() => onSelectFamily(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`}
|
||||
>
|
||||
<h3 className="font-bold text-lg text-gray-900 dark:text-white mb-3">
|
||||
{family.name}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Variations:</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{family.variationCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">ECO Range:</span>
|
||||
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{family.ecoRange}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Total Moves:</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">
|
||||
{family.totalMoves}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popularity indicator */}
|
||||
{family.popularity >= 2.5 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="inline-flex items-center text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded">
|
||||
⭐ Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { MoveFeedback as MoveFeedbackType } from '@/types/openingTraining';
|
||||
import { formatEvaluation } from '@/lib/openingTrainer/moveValidator';
|
||||
|
||||
interface MoveFeedbackProps {
|
||||
feedback: MoveFeedbackType;
|
||||
}
|
||||
|
||||
export default function MoveFeedback({ feedback }: MoveFeedbackProps) {
|
||||
const { move, classification, evaluation, previousEvaluation, llmExplanation } =
|
||||
feedback;
|
||||
|
||||
// Format evaluation change
|
||||
const evalChange = classification.evaluationChange !== 0
|
||||
? `${classification.evaluationChange > 0 ? '+' : ''}${(classification.evaluationChange / 100).toFixed(2)}`
|
||||
: '0.00';
|
||||
|
||||
// Category badge styling
|
||||
const categoryStyles = {
|
||||
'in-theory': 'bg-green-100 text-green-800 border-green-300',
|
||||
playable: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
weak: 'bg-red-100 text-red-800 border-red-300',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
Move {move.moveNumber}
|
||||
{move.color === 'white' ? '.' : '...'} {move.san}
|
||||
</h3>
|
||||
<div
|
||||
className={`px-3 py-1 rounded border text-sm font-medium ${
|
||||
categoryStyles[classification.category]
|
||||
}`}
|
||||
>
|
||||
{classification.category.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification details */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
classification.inRepertoire ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
></span>
|
||||
<span className="text-gray-700">
|
||||
{classification.inRepertoire
|
||||
? 'In repertoire'
|
||||
: 'Outside repertoire'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{classification.theoreticalAlternatives &&
|
||||
classification.theoreticalAlternatives.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Repertoire alternatives: </span>
|
||||
<span className="font-mono text-gray-900">
|
||||
{classification.theoreticalAlternatives.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Engine evaluation */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 font-medium">Evaluation:</span>
|
||||
<span className="font-mono text-gray-900 font-semibold">
|
||||
{formatEvaluation(evaluation)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{evaluation.bestMove && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 font-medium">Engine best:</span>
|
||||
<span className="font-mono text-gray-900">
|
||||
{evaluation.bestMove}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{evalChange && (
|
||||
<div
|
||||
className={`flex items-center justify-between text-sm ${
|
||||
classification.isSignificantSwing ? 'font-bold' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-600 font-medium">
|
||||
Eval change:
|
||||
{classification.isSignificantSwing && (
|
||||
<span className="ml-1 text-xs bg-orange-100 text-orange-800 px-1 rounded">
|
||||
Significant
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
evalChange.startsWith('+')
|
||||
? 'text-green-700'
|
||||
: 'text-red-700'
|
||||
}`}
|
||||
>
|
||||
{evalChange}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LLM Explanation */}
|
||||
{llmExplanation && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 text-xs font-bold">AI</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
{llmExplanation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state for explanation */}
|
||||
{!llmExplanation && classification.category !== 'in-theory' && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Generating explanation...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { loadSession } from '@/lib/openingTrainer/sessionManager';
|
||||
|
||||
interface OpeningSelectorProps {
|
||||
openings: OpeningMetadata[];
|
||||
selectedFamily?: string;
|
||||
onBackToFamilies?: () => void;
|
||||
}
|
||||
|
||||
// Helper function to count moves in an opening
|
||||
const countMoves = (movesString: string): number => {
|
||||
if (!movesString) return 0;
|
||||
// Filter out move numbers (e.g., "1.", "2.") and count actual moves
|
||||
const moves = movesString.split(' ').filter(m => !m.match(/^\d+\.$/));
|
||||
return moves.length;
|
||||
};
|
||||
|
||||
// Helper function to extract variation name (after family prefix)
|
||||
const getVariationName = (fullName: string, familyName?: string): string => {
|
||||
if (!familyName) return fullName;
|
||||
|
||||
// Remove family prefix and common delimiters
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (fullName.includes(sep)) {
|
||||
const parts = fullName.split(sep);
|
||||
if (parts.length > 1) {
|
||||
return parts.slice(1).join(sep).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no separator found, return full name
|
||||
return fullName;
|
||||
};
|
||||
|
||||
// Helper function to determine opening popularity for sorting
|
||||
const getPopularityScore = (eco: string): number => {
|
||||
// Very popular openings (most common in practice)
|
||||
const veryPopular = ['C50', 'C55', 'C60', 'C65', 'C80', 'C90', 'D00', 'D06', 'D30', 'D35', 'D37', 'E00', 'E20', 'E60', 'E90', 'B10', 'B12', 'B20', 'B30', 'B33', 'B40', 'B50', 'B90'];
|
||||
if (veryPopular.some(code => eco.startsWith(code))) return 3;
|
||||
|
||||
// Popular openings
|
||||
const popular = ['A00', 'A04', 'A10', 'A40', 'A45', 'C00', 'C01', 'C02', 'C10', 'C15', 'C20', 'C30', 'C40', 'D10', 'D20', 'D40', 'D50', 'D60', 'D70', 'D80', 'E10', 'E30', 'E40', 'E50', 'E70', 'B00', 'B01', 'B02'];
|
||||
if (popular.some(code => eco.startsWith(code))) return 2;
|
||||
|
||||
// Less common
|
||||
return 1;
|
||||
};
|
||||
|
||||
export default function OpeningSelector({ openings, selectedFamily, onBackToFamilies }: OpeningSelectorProps) {
|
||||
const [colorFilter, setColorFilter] = useState<'all' | 'white' | 'black'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Helper to extract family name from opening name
|
||||
const extractFamilyName = (openingName: string): string => {
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (openingName.includes(sep)) {
|
||||
return openingName.split(sep)[0].trim();
|
||||
}
|
||||
}
|
||||
return openingName;
|
||||
};
|
||||
|
||||
// Filter openings based on color, search query, family, and move count
|
||||
const filteredOpenings = useMemo(() => {
|
||||
return openings.filter((opening) => {
|
||||
// Filter out openings with only 1 move (not useful for training)
|
||||
const moveCount = countMoves(opening.moves);
|
||||
if (moveCount <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Family filter (if a family is selected)
|
||||
if (selectedFamily) {
|
||||
const family = extractFamilyName(opening.name);
|
||||
if (family !== selectedFamily) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Color filter (based on ECO code patterns)
|
||||
// A00-A99, B00-B99, C00-C99 are generally White openings
|
||||
// D00-D99, E00-E99 are generally Black defenses
|
||||
if (colorFilter !== 'all') {
|
||||
const ecoLetter = opening.eco[0];
|
||||
if (colorFilter === 'white' && !['A', 'B', 'C'].includes(ecoLetter)) {
|
||||
return false;
|
||||
}
|
||||
if (colorFilter === 'black' && !['D', 'E'].includes(ecoLetter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
opening.name.toLowerCase().includes(query) ||
|
||||
opening.eco.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
// Sort by move count (descending) when family is selected, otherwise by popularity
|
||||
if (selectedFamily) {
|
||||
const movesA = countMoves(a.moves);
|
||||
const movesB = countMoves(b.moves);
|
||||
if (movesA !== movesB) {
|
||||
return movesB - movesA; // More moves first
|
||||
}
|
||||
} else {
|
||||
const scoreA = getPopularityScore(a.eco);
|
||||
const scoreB = getPopularityScore(b.eco);
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA; // Higher score first
|
||||
}
|
||||
}
|
||||
return a.eco.localeCompare(b.eco); // Alphabetical by ECO
|
||||
});
|
||||
}, [openings, colorFilter, searchQuery, selectedFamily]);
|
||||
|
||||
// Group openings by ECO family (first letter)
|
||||
const groupedOpenings = useMemo(() => {
|
||||
const groups: Record<string, OpeningMetadata[]> = {
|
||||
A: [],
|
||||
B: [],
|
||||
C: [],
|
||||
D: [],
|
||||
E: [],
|
||||
};
|
||||
|
||||
filteredOpenings.forEach((opening) => {
|
||||
const family = opening.eco[0];
|
||||
if (groups[family]) {
|
||||
groups[family].push(opening);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredOpenings]);
|
||||
|
||||
// Check if an opening has an active session
|
||||
const hasActiveSession = (eco: string): boolean => {
|
||||
return loadSession(eco) !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button and header (when family is selected) */}
|
||||
{selectedFamily && onBackToFamilies && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBackToFamilies}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Families
|
||||
</button>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedFamily} - Select Variation
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Color filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setColorFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All Openings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorFilter('white')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'white'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
White
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorFilter('black')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'black'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Black
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search openings..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {filteredOpenings.length} opening{filteredOpenings.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
{/* Grouped openings */}
|
||||
{Object.entries(groupedOpenings).map(([family, familyOpenings]) => {
|
||||
if (familyOpenings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={family} className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
ECO {family} ({familyOpenings.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{familyOpenings.map((opening) => {
|
||||
const hasSession = hasActiveSession(opening.eco);
|
||||
const moveCount = countMoves(opening.moves);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={opening.eco}
|
||||
href={`/learning/openings/${opening.eco}`}
|
||||
data-opening-eco={opening.eco}
|
||||
data-testid={`opening-card-${opening.eco}`}
|
||||
className="block p-4 border 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-md transition-all"
|
||||
aria-label={`Select ${opening.name} opening`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedFamily ? getVariationName(opening.name, selectedFamily) : opening.name}
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
{hasSession && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 px-2 py-1 rounded">
|
||||
In Progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{opening.eco}</p>
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400 px-2 py-1 rounded font-medium">
|
||||
{moveCount} move{moveCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 font-mono truncate">
|
||||
{opening.moves.substring(0, 30)}
|
||||
{opening.moves.length > 30 ? '...' : ''}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredOpenings.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
No openings found matching your filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Chess } from 'chess.js';
|
||||
import { Chessboard } from 'react-chessboard';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { useOpeningTraining } from '@/contexts/OpeningTrainingContext';
|
||||
import { loadSession } from '@/lib/openingTrainer/sessionManager';
|
||||
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/repertoireNavigation';
|
||||
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
|
||||
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
|
||||
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
|
||||
import WikipediaSummary from './WikipediaSummary';
|
||||
import { Tutor } from '@/components/Tutor';
|
||||
import { Personality } from '@/lib/personalities';
|
||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
|
||||
interface OpeningTrainerProps {
|
||||
opening: OpeningMetadata;
|
||||
personality: Personality;
|
||||
apiKey: string;
|
||||
language: SupportedLanguage;
|
||||
}
|
||||
|
||||
export default function OpeningTrainer({ opening, personality, apiKey, language }: OpeningTrainerProps) {
|
||||
const {
|
||||
session,
|
||||
chess,
|
||||
stockfish,
|
||||
currentFeedback,
|
||||
initializeSession,
|
||||
makeMove,
|
||||
navigateToMove,
|
||||
} = useOpeningTraining();
|
||||
|
||||
const [boardOrientation, setBoardOrientation] = useState<'white' | 'black'>(
|
||||
'white'
|
||||
);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
||||
const [existingSession, setExistingSession] = useState<any>(null);
|
||||
const [wikipediaSummary, setWikipediaSummary] = useState<WikipediaSummaryType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkForExistingSession();
|
||||
}, [opening.eco]);
|
||||
|
||||
// Fetch Wikipedia summary for the opening (using family name for better results)
|
||||
useEffect(() => {
|
||||
const fetchWikipediaSummary = async () => {
|
||||
try {
|
||||
// Extract family name (e.g., "French Defense" from "French Defense: Exchange Variation")
|
||||
// This ensures we find the Wikipedia article for the main opening, not specific variations
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
console.log(`[Wikipedia] Looking up: "${familyName}" (from "${opening.name}")`);
|
||||
|
||||
const summary = await getWikipediaSummary(familyName);
|
||||
setWikipediaSummary(summary);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Wikipedia summary:', err);
|
||||
// Silently fail - Wikipedia is nice-to-have, not critical
|
||||
}
|
||||
};
|
||||
|
||||
fetchWikipediaSummary();
|
||||
}, [opening.name]);
|
||||
|
||||
const checkForExistingSession = () => {
|
||||
const saved = loadSession(opening.eco);
|
||||
|
||||
if (saved && saved.moveHistory.length > 0) {
|
||||
// Found existing session with moves
|
||||
setExistingSession(saved);
|
||||
setShowRecoveryDialog(true);
|
||||
setIsInitializing(false);
|
||||
} else {
|
||||
// No existing session or empty session - start fresh
|
||||
initSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initSession = async (forceNew: boolean = false) => {
|
||||
setIsInitializing(true);
|
||||
setError(null);
|
||||
setShowRecoveryDialog(false);
|
||||
|
||||
try {
|
||||
await initializeSession(opening, forceNew);
|
||||
|
||||
// Determine board orientation from opening
|
||||
// ECO D and E are typically Black defenses
|
||||
const orientation = ['D', 'E'].includes(opening.eco[0]) ? 'black' : 'white';
|
||||
setBoardOrientation(orientation);
|
||||
} catch (err) {
|
||||
console.error('Session initialization error:', err);
|
||||
setError('Failed to initialize training session');
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSession = () => {
|
||||
initSession(false);
|
||||
};
|
||||
|
||||
const handleStartFresh = () => {
|
||||
initSession(true);
|
||||
};
|
||||
|
||||
const handlePieceDrop = (
|
||||
sourceSquare: string,
|
||||
targetSquare: string
|
||||
): boolean => {
|
||||
if (!chess) return false;
|
||||
|
||||
try {
|
||||
// Create a temporary clone to test if the move is legal
|
||||
// WITHOUT modifying the actual chess instance
|
||||
const testChess = new Chess();
|
||||
testChess.loadPgn(chess.pgn());
|
||||
|
||||
// Try to make the move on the clone
|
||||
const move = testChess.move({
|
||||
from: sourceSquare,
|
||||
to: targetSquare,
|
||||
promotion: 'q', // Always promote to queen for simplicity
|
||||
});
|
||||
|
||||
if (move === null) {
|
||||
// Illegal move
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move was legal - process it on the actual chess instance via makeMove
|
||||
makeMove(move.san);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Move error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Session recovery dialog
|
||||
if (showRecoveryDialog && existingSession) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center min-h-[500px]">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Resume Training?
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You have an existing training session for this opening with{' '}
|
||||
<span className="font-semibold">
|
||||
{existingSession.moveHistory.length} move
|
||||
{existingSession.moveHistory.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
. Would you like to resume where you left off or start fresh?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleResumeSession}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Resume Session
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartFresh}
|
||||
className="w-full px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Start Fresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4 text-center">
|
||||
Last updated:{' '}
|
||||
{new Date(existingSession.lastUpdated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center min-h-[500px]">
|
||||
<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">Initializing training session...</p>
|
||||
{!stockfish && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading chess engine...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<h3 className="font-semibold text-red-900 mb-2">Error</h3>
|
||||
<p className="text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => initSession()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !chess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">No active session</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPosition = chess.fen();
|
||||
const moveCount = session.moveHistory.length;
|
||||
|
||||
// Determine user's color based on opening ECO code
|
||||
const userColor = getUserColor(opening);
|
||||
|
||||
// Build opening practice mode prop for Tutor
|
||||
const repertoireMoves = parseMoveSequence(opening.moves);
|
||||
const lastMove = session.moveHistory.length > 0
|
||||
? session.moveHistory[session.moveHistory.length - 1]
|
||||
: null;
|
||||
|
||||
// Determine which color the tutor is playing
|
||||
const tutorColor = userColor === 'white' ? 'black' : 'white';
|
||||
|
||||
// Find last user move and last tutor move
|
||||
const userMoves = session.moveHistory.filter(
|
||||
m => m.color === userColor
|
||||
);
|
||||
const tutorMoves = session.moveHistory.filter(
|
||||
m => m.color === tutorColor
|
||||
);
|
||||
|
||||
const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null;
|
||||
const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null;
|
||||
|
||||
const openingPracticeMode = {
|
||||
openingName: opening.name,
|
||||
openingEco: opening.eco,
|
||||
repertoireMoves,
|
||||
currentMoveIndex: session.moveHistory.length,
|
||||
isInTheory: session.deviationMoveIndex === null,
|
||||
deviationMoveIndex: session.deviationMoveIndex,
|
||||
lastUserMove: lastUserMove ? {
|
||||
from: lastUserMove.uci.substring(0, 2),
|
||||
to: lastUserMove.uci.substring(2, 4),
|
||||
san: lastUserMove.san,
|
||||
color: lastUserMove.color === 'white' ? 'w' : 'b',
|
||||
piece: lastUserMove.san[0].toLowerCase(),
|
||||
flags: '',
|
||||
captured: undefined,
|
||||
promotion: lastUserMove.uci.length > 4 ? lastUserMove.uci[4] : undefined
|
||||
} as any : null,
|
||||
lastTutorMove: lastTutorMove ? {
|
||||
from: lastTutorMove.uci.substring(0, 2),
|
||||
to: lastTutorMove.uci.substring(2, 4),
|
||||
san: lastTutorMove.san,
|
||||
color: lastTutorMove.color === 'white' ? 'w' : 'b',
|
||||
piece: lastTutorMove.san[0].toLowerCase(),
|
||||
flags: '',
|
||||
captured: undefined,
|
||||
promotion: lastTutorMove.uci.length > 4 ? lastTutorMove.uci[4] : undefined
|
||||
} as any : null,
|
||||
currentFeedback: currentFeedback ? {
|
||||
category: currentFeedback.classification.category,
|
||||
evaluationChange: currentFeedback.classification.evaluationChange,
|
||||
theoreticalAlternatives: currentFeedback.classification.theoreticalAlternatives
|
||||
} : null,
|
||||
wikipediaSummary: wikipediaSummary?.extract || undefined
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main board area */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Board */}
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
||||
role="region"
|
||||
aria-label="Chess board"
|
||||
>
|
||||
<Chessboard
|
||||
key={currentPosition}
|
||||
options={{
|
||||
position: currentPosition,
|
||||
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
||||
if (!targetSquare) return false;
|
||||
return handlePieceDrop(sourceSquare, targetSquare);
|
||||
},
|
||||
boardOrientation: boardOrientation,
|
||||
darkSquareStyle: { backgroundColor: '#779954' },
|
||||
lightSquareStyle: { backgroundColor: '#e9edcc' },
|
||||
animationDurationInMs: 200,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Move controls */}
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
||||
role="region"
|
||||
aria-label="Move history and navigation"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Move History</h3>
|
||||
<div className="flex gap-2" role="group" aria-label="Move navigation">
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateToMove(Math.max(0, session.currentMoveIndex - 1))
|
||||
}
|
||||
disabled={session.currentMoveIndex === 0}
|
||||
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Go to previous move"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateToMove(
|
||||
Math.min(moveCount - 1, session.currentMoveIndex + 1)
|
||||
)
|
||||
}
|
||||
disabled={session.currentMoveIndex >= moveCount - 1}
|
||||
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Go to next move"
|
||||
>
|
||||
Forward →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Move list */}
|
||||
<div
|
||||
className="space-y-2 max-h-[200px] overflow-y-auto"
|
||||
role="list"
|
||||
aria-label="List of moves played"
|
||||
>
|
||||
{moveCount === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No moves yet. Make your first move!
|
||||
</p>
|
||||
) : (
|
||||
session.moveHistory.map((move, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => navigateToMove(index)}
|
||||
role="listitem"
|
||||
className={`p-2 rounded cursor-pointer transition-colors ${
|
||||
index === session.currentMoveIndex
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-label={`Move ${move.moveNumber}${
|
||||
move.color === 'white' ? '.' : '...'
|
||||
} ${move.san}, classified as ${move.classification.category}`}
|
||||
aria-current={index === session.currentMoveIndex ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
{move.moveNumber}
|
||||
{move.color === 'white' ? '.' : '...'} {move.san}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
move.classification.category === 'in-theory'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: move.classification.category === 'playable'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{move.classification.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - tutor and info */}
|
||||
<div className="space-y-4">
|
||||
{/* Tutor Chat */}
|
||||
{apiKey ? (
|
||||
<Tutor
|
||||
game={chess}
|
||||
currentFen={currentPosition}
|
||||
userMove={null} // Will be updated in Phase 2
|
||||
computerMove={null}
|
||||
stockfish={stockfish}
|
||||
evalP0={null}
|
||||
evalP2={null}
|
||||
openingData={[]}
|
||||
missedTactics={[]}
|
||||
onAnalysisComplete={() => {}}
|
||||
apiKey={apiKey}
|
||||
personality={personality}
|
||||
language={language}
|
||||
playerColor={userColor}
|
||||
onCheckComputerMove={() => {}}
|
||||
resignationContext={null}
|
||||
openingPracticeMode={openingPracticeMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">🔑</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
API Key Required
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
To chat with your coach, please set up your Gemini API key in the settings.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/onboarding'}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Set Up API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wikipedia summary */}
|
||||
<WikipediaSummary
|
||||
openingName={opening.name}
|
||||
wikipediaSlug={opening.wikipediaSlug}
|
||||
/>
|
||||
|
||||
{/* Session info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Opening:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{opening.eco}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
|
||||
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
|
||||
|
||||
interface WikipediaSummaryProps {
|
||||
openingName: string;
|
||||
wikipediaSlug?: string; // Preferred: direct slug from database
|
||||
}
|
||||
|
||||
export default function WikipediaSummary({ openingName, wikipediaSlug }: WikipediaSummaryProps) {
|
||||
const [summary, setSummary] = useState<WikipediaSummaryType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, [openingName, wikipediaSlug]);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use slug if provided, otherwise fall back to name lookup
|
||||
const data = await getWikipediaSummary(openingName, wikipediaSlug);
|
||||
|
||||
if (!data) {
|
||||
setError('No Wikipedia article found for this opening');
|
||||
setSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load opening background information');
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-600 dark:border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">Loading opening background...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !summary) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No background information available for this opening.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-300">{summary.title}</h3>
|
||||
<a
|
||||
href={summary.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Wikipedia ↗
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{summary.extract}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Source: Wikipedia (cached {new Date(summary.fetchedAt).toLocaleDateString()})
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+236
-11
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
|
||||
import { StockfishEvaluation } from "@/lib/stockfish";
|
||||
import { ChessEngine } from "@/lib/engine";
|
||||
import { Chess, Move } from "chess.js";
|
||||
import { getGenAIModel } from "@/lib/gemini";
|
||||
import { ChatSession } from "@google/generative-ai";
|
||||
@@ -16,13 +17,16 @@ import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
import { DetectedTactic } from '@/lib/tacticDetection';
|
||||
import { useDebug } from '@/contexts/DebugContext';
|
||||
import { MoveHistoryItem } from './GameOverModal';
|
||||
import { parseGeminiError, GeminiErrorInfo, isGeminiError } from '@/lib/geminiErrorHandler';
|
||||
import { GeminiErrorModal } from './GeminiErrorModal';
|
||||
import { getApiKeyInfo } from '@/lib/apiKeyHelper';
|
||||
|
||||
interface TutorProps {
|
||||
game: Chess;
|
||||
currentFen: string;
|
||||
userMove: Move | null;
|
||||
computerMove: Move | null;
|
||||
stockfish: Stockfish | null;
|
||||
stockfish: ChessEngine | null;
|
||||
evalP0: StockfishEvaluation | null;
|
||||
evalP2: StockfishEvaluation | null;
|
||||
openingData: OpeningMetadata[];
|
||||
@@ -54,6 +58,22 @@ interface TutorProps {
|
||||
bestStreak: number;
|
||||
};
|
||||
};
|
||||
openingPracticeMode?: {
|
||||
openingName: string;
|
||||
openingEco: string;
|
||||
repertoireMoves: string[]; // Full sequence from opening database
|
||||
currentMoveIndex: number;
|
||||
isInTheory: boolean;
|
||||
deviationMoveIndex: number | null;
|
||||
lastUserMove: Move | null;
|
||||
lastTutorMove: Move | null;
|
||||
currentFeedback: {
|
||||
category: 'in-theory' | 'playable' | 'weak';
|
||||
evaluationChange: number;
|
||||
theoreticalAlternatives: string[];
|
||||
} | null;
|
||||
wikipediaSummary?: string; // Optional Wikipedia context
|
||||
};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -62,11 +82,12 @@ interface Message {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode }: TutorProps) {
|
||||
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode, openingPracticeMode }: TutorProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
|
||||
const [geminiError, setGeminiError] = useState<GeminiErrorInfo | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { addEntry } = useDebug();
|
||||
|
||||
@@ -84,15 +105,59 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
|
||||
// Track the current puzzle to detect when it changes
|
||||
const currentPuzzleRef = useRef<string | null>(null);
|
||||
|
||||
// Track last opening moves to detect when new moves are made
|
||||
const lastUserMoveRef = useRef<string | null>(null);
|
||||
const lastTutorMoveRef = useRef<string | null>(null);
|
||||
|
||||
// Initialize chat session with Personality System Prompt (only once per pattern type)
|
||||
useEffect(() => {
|
||||
if (apiKey) {
|
||||
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
|
||||
|
||||
// Build system prompt based on mode
|
||||
// NOTE: For tactical practice, we don't include the specific puzzle solution in the system prompt
|
||||
// Instead, we'll send it as a message when the puzzle changes
|
||||
const systemPrompt = tacticalPracticeMode ? `
|
||||
const systemPrompt = openingPracticeMode ? `
|
||||
You are a Chess Tutor helping a student learn the "${openingPracticeMode.openingName}" opening.
|
||||
You must strictly follow the personality defined below.
|
||||
|
||||
PERSONALITY:
|
||||
${personality.systemPrompt}
|
||||
|
||||
${openingPracticeMode.wikipediaSummary ? `OPENING BACKGROUND (from Wikipedia):
|
||||
${openingPracticeMode.wikipediaSummary}
|
||||
|
||||
Use this background to enrich your explanations, but keep responses concise.
|
||||
` : ''}
|
||||
|
||||
YOUR ROLE:
|
||||
You are BOTH the opponent AND the tutor in this opening training session.
|
||||
|
||||
1. OPPONENT: You are playing as ${tutorColorName} in the ${openingPracticeMode.openingName}.
|
||||
- You will make moves from the opening repertoire
|
||||
- Refer to your moves naturally ("I played e5", "My response is...")
|
||||
|
||||
2. TUTOR: You are teaching the student this opening.
|
||||
- The student is playing as ${playerColorName}
|
||||
- Explain the IDEAS behind each move, not just the moves themselves
|
||||
- When the student asks for help, ALWAYS provide guidance
|
||||
- When the student stays in theory, praise them and explain what's happening
|
||||
- When the student deviates, explain why the repertoire move is better
|
||||
|
||||
YOUR RESPONSIBILITIES:
|
||||
1. WELCOME: Start with a warm greeting and brief explanation of the ${openingPracticeMode.openingName}
|
||||
2. GUIDANCE: After each move, explain the ideas and plans
|
||||
3. ENCOURAGEMENT: Keep the student motivated while learning
|
||||
4. DEVIATION HANDLING: When the student leaves theory, gently correct them
|
||||
5. ANSWERING QUESTIONS: Always help when the student asks
|
||||
|
||||
CRITICAL RULES:
|
||||
- Be encouraging and supportive
|
||||
- Explain IDEAS and PLANS, not just moves
|
||||
- Keep responses concise (2-4 sentences)
|
||||
- Do NOT be repetitive - vary your language
|
||||
- You MUST respond in the following language: ${language.toUpperCase()}
|
||||
- NEVER mention "Stockfish", "engine", "computer", or "AI"
|
||||
- When you make a move, explain WHY briefly
|
||||
` : tacticalPracticeMode ? `
|
||||
You are a Chess Coach helping a student practice tactical patterns.
|
||||
You must strictly follow the personality defined below.
|
||||
|
||||
@@ -161,7 +226,9 @@ CRITICAL RULES:
|
||||
},
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ text: tacticalPracticeMode
|
||||
parts: [{ text: openingPracticeMode
|
||||
? `Understood. I will teach you the ${openingPracticeMode.openingName} opening in ${language}. I am both your opponent and your tutor. I'll explain the ideas behind each move and help you learn this opening.`
|
||||
: tacticalPracticeMode
|
||||
? `Understood. I will help you practice ${tacticalPracticeMode.patternName} in ${language}. I'll provide hints and encouragement while maintaining my personality.`
|
||||
: `Understood. I am both the opponent (${tutorColorName}) AND your tutor. I will compete against you while teaching you to improve. I will speak in ${language} and never mention engines or AI. When you ask for help, I will always provide guidance - that's my purpose.`
|
||||
}]
|
||||
@@ -171,7 +238,18 @@ CRITICAL RULES:
|
||||
setChatSession(session);
|
||||
|
||||
// Get initial greeting in the selected language
|
||||
const greetingPrompt = tacticalPracticeMode
|
||||
const greetingPrompt = openingPracticeMode
|
||||
? `Welcome the student to learn the ${openingPracticeMode.openingName}. Briefly explain the key ideas of this opening (in 2-3 sentences).
|
||||
|
||||
IMPORTANT:
|
||||
- Clarify that YOU are playing as ${tutorColorName} and the STUDENT is playing as ${playerColorName}
|
||||
- If the student is White, make it clear THEY will make the first move, not you
|
||||
- If the student is Black, explain you'll make the first move and then they'll respond
|
||||
- Don't claim you'll make a move that the student should be making
|
||||
- Be encouraging and clear about the game flow
|
||||
|
||||
Keep it in ${language}.`
|
||||
: tacticalPracticeMode
|
||||
? `Welcome the student to practice ${tacticalPracticeMode.patternName}. Briefly explain what this tactical pattern is (in 1-2 sentences). Keep it encouraging and in ${language}.`
|
||||
: `Introduce yourself briefly to start our game. Keep it short and in ${language}.`;
|
||||
|
||||
@@ -180,14 +258,23 @@ CRITICAL RULES:
|
||||
setMessages([{ role: "model", text: greetingText, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to get greeting:", err);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(err)) {
|
||||
const errorInfo = parseGeminiError(err);
|
||||
setGeminiError(errorInfo);
|
||||
}
|
||||
|
||||
// Fallback greeting
|
||||
const fallbackText = tacticalPracticeMode
|
||||
const fallbackText = openingPracticeMode
|
||||
? `Hello! Let's learn the ${openingPracticeMode.openingName} together!`
|
||||
: tacticalPracticeMode
|
||||
? `Hello! Let's practice ${tacticalPracticeMode.patternName} together!`
|
||||
: `Hello! I am ${personality.name}. Let's play!`;
|
||||
setMessages([{ role: "model", text: fallbackText, timestamp: Date.now() }]);
|
||||
});
|
||||
}
|
||||
}, [apiKey, personality, language, playerColor, patternName]);
|
||||
}, [apiKey, personality, language, playerColor, patternName, openingPracticeMode]);
|
||||
// NOTE: Removed solutionMoveKey from dependencies - we don't want to reset chat when puzzle changes
|
||||
|
||||
// Notify tutor about new puzzle (without resetting chat)
|
||||
@@ -230,9 +317,112 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
|
||||
setMessages(prev => [...prev, { role: "model", text: responseText, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to notify about new puzzle:", err);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(err)) {
|
||||
const errorInfo = parseGeminiError(err);
|
||||
setGeminiError(errorInfo);
|
||||
}
|
||||
});
|
||||
}, [solutionMoveKey, chatSession, tacticalPracticeMode, currentFen, language]);
|
||||
|
||||
// Automatic commentary for opening practice mode
|
||||
useEffect(() => {
|
||||
if (!chatSession || !openingPracticeMode) return;
|
||||
|
||||
const userMoveKey = openingPracticeMode.lastUserMove
|
||||
? `${openingPracticeMode.lastUserMove.san}-${openingPracticeMode.currentMoveIndex}`
|
||||
: null;
|
||||
const tutorMoveKey = openingPracticeMode.lastTutorMove
|
||||
? `${openingPracticeMode.lastTutorMove.san}-${openingPracticeMode.currentMoveIndex}`
|
||||
: null;
|
||||
|
||||
// Check if user made a new move
|
||||
if (userMoveKey && userMoveKey !== lastUserMoveRef.current) {
|
||||
lastUserMoveRef.current = userMoveKey;
|
||||
|
||||
// Generate commentary about user's move
|
||||
const feedback = openingPracticeMode.currentFeedback;
|
||||
const moveCommentary = `
|
||||
[SYSTEM TRIGGER: user_move_in_opening]
|
||||
|
||||
The student just played: ${openingPracticeMode.lastUserMove!.san}
|
||||
Move category: ${feedback?.category || 'unknown'}
|
||||
Position status: ${openingPracticeMode.isInTheory ? 'In theory' : 'Deviated from repertoire'}
|
||||
${feedback?.evaluationChange !== undefined ? `Evaluation change: ${feedback.evaluationChange.toFixed(2)}` : ''}
|
||||
${feedback?.theoreticalAlternatives && feedback.theoreticalAlternatives.length > 0 ? `Theory suggested: ${feedback.theoreticalAlternatives.join(', ')}` : ''}
|
||||
|
||||
INSTRUCTIONS:
|
||||
${openingPracticeMode.isInTheory
|
||||
? `- The student is following the repertoire correctly - praise them briefly
|
||||
- Explain the key idea behind this move (1-2 sentences)
|
||||
- If you're about to make the next move, you can mention it naturally`
|
||||
: `- The student deviated from theory
|
||||
- Gently point out what the repertoire move was
|
||||
- Explain why the repertoire move is preferred
|
||||
- Ask if they want to try again or continue exploring`}
|
||||
- Keep it concise (2-3 sentences max)
|
||||
- Stay in ${language}
|
||||
- Maintain your personality
|
||||
`.trim();
|
||||
|
||||
chatSession.sendMessage(moveCommentary).then(result => {
|
||||
const response = result.response.text();
|
||||
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to generate user move commentary:", err);
|
||||
if (isGeminiError(err)) {
|
||||
setGeminiError(parseGeminiError(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if tutor made a new move
|
||||
if (tutorMoveKey && tutorMoveKey !== lastTutorMoveRef.current) {
|
||||
lastTutorMoveRef.current = tutorMoveKey;
|
||||
|
||||
// Generate commentary about tutor's move
|
||||
const tutorCommentary = `
|
||||
[SYSTEM TRIGGER: tutor_move_in_opening]
|
||||
|
||||
I just played: ${openingPracticeMode.lastTutorMove!.san}
|
||||
Current position FEN: ${currentFen}
|
||||
Progress: ${openingPracticeMode.currentMoveIndex}/${openingPracticeMode.repertoireMoves.length} moves
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Explain WHY you played this move (the idea behind it)
|
||||
- Mention what it accomplishes (controls center, develops, creates threat, etc.)
|
||||
- If relevant, mention what the student should think about for their next move
|
||||
- Keep it conversational and in character
|
||||
- 2-3 sentences max
|
||||
- Respond in ${language}
|
||||
|
||||
Remember: You are both the opponent AND the tutor. Explain your move as if you're teaching.
|
||||
`.trim();
|
||||
|
||||
// Add small delay before tutor explains their move
|
||||
setTimeout(() => {
|
||||
chatSession.sendMessage(tutorCommentary).then(result => {
|
||||
const response = result.response.text();
|
||||
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to generate tutor move commentary:", err);
|
||||
if (isGeminiError(err)) {
|
||||
setGeminiError(parseGeminiError(err));
|
||||
}
|
||||
});
|
||||
}, 300); // Brief delay so the move appears first, then the explanation
|
||||
}
|
||||
}, [
|
||||
chatSession,
|
||||
openingPracticeMode?.lastUserMove?.san,
|
||||
openingPracticeMode?.lastTutorMove?.san,
|
||||
openingPracticeMode?.currentMoveIndex,
|
||||
openingPracticeMode?.isInTheory,
|
||||
currentFen,
|
||||
language
|
||||
]);
|
||||
|
||||
// Scroll chat container to bottom (not the whole page)
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
@@ -583,7 +773,33 @@ INSTRUCTIONS:
|
||||
setMessages(prev => [...prev, { role: "model", text: textResponse, timestamp: Date.now() }]);
|
||||
} catch (error) {
|
||||
console.error("Chat Error:", error);
|
||||
setMessages(prev => [...prev, { role: "model", text: "Sorry, I encountered an error.", timestamp: Date.now() }]);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(error)) {
|
||||
const errorInfo = parseGeminiError(error);
|
||||
setGeminiError(errorInfo);
|
||||
|
||||
// Show a brief error message in chat
|
||||
if (errorInfo.isQuotaError) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "⚠️ API quota exceeded. Please check the error message for details.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
} else {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "⚠️ I encountered an error. Please try again.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
}
|
||||
} else {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "Sorry, I encountered an error.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -748,6 +964,15 @@ INSTRUCTIONS:
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Gemini Error Modal */}
|
||||
{geminiError && (
|
||||
<GeminiErrorModal
|
||||
error={geminiError}
|
||||
apiKeyInfo={getApiKeyInfo()}
|
||||
onClose={() => setGeminiError(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Chess } from 'chess.js';
|
||||
import {
|
||||
TrainingSession,
|
||||
MoveHistoryEntry,
|
||||
MoveFeedback,
|
||||
} from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { createEngine, ChessEngine } from '@/lib/engine';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import {
|
||||
createSession,
|
||||
loadSession,
|
||||
saveSession,
|
||||
updateSession,
|
||||
} from '@/lib/openingTrainer/sessionManager';
|
||||
import { evaluatePosition } from '@/lib/openingTrainer/engineService';
|
||||
import { classifyMove } from '@/lib/openingTrainer/moveValidator';
|
||||
import {
|
||||
getExpectedNextMoves,
|
||||
detectTransposition,
|
||||
getOpponentNextMove,
|
||||
isOpponentTurn,
|
||||
getUserColor,
|
||||
isEndOfRepertoire,
|
||||
parseMoveSequence,
|
||||
} from '@/lib/openingTrainer/repertoireNavigation';
|
||||
import { STARTING_FEN } from '@/lib/openingTrainer/constants';
|
||||
import {
|
||||
buildExplanationPrompt,
|
||||
buildTranspositionPrompt,
|
||||
ExplanationPromptContext,
|
||||
} from '@/lib/openingTrainer/feedbackGenerator';
|
||||
|
||||
/**
|
||||
* Context for managing opening training session state
|
||||
*/
|
||||
|
||||
interface OpeningTrainingContextType {
|
||||
session: TrainingSession | null;
|
||||
opening: OpeningMetadata | null;
|
||||
chess: Chess | null;
|
||||
stockfish: ChessEngine | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
currentFeedback: MoveFeedback | null;
|
||||
|
||||
// Actions
|
||||
initializeSession: (opening: OpeningMetadata, forceNew?: boolean) => Promise<void>;
|
||||
makeMove: (san: string) => Promise<void>;
|
||||
navigateToMove: (index: number) => void;
|
||||
resetSession: () => void;
|
||||
}
|
||||
|
||||
const OpeningTrainingContext = createContext<OpeningTrainingContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface OpeningTrainingProviderProps {
|
||||
children: ReactNode;
|
||||
openingId?: string;
|
||||
}
|
||||
|
||||
export function OpeningTrainingProvider({
|
||||
children,
|
||||
openingId,
|
||||
}: OpeningTrainingProviderProps) {
|
||||
const [session, setSession] = useState<TrainingSession | null>(null);
|
||||
const [opening, setOpening] = useState<OpeningMetadata | null>(null);
|
||||
const [chess, setChess] = useState<Chess | null>(null);
|
||||
const [stockfish, setStockfish] = useState<ChessEngine | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentFeedback, setCurrentFeedback] = useState<MoveFeedback | null>(null);
|
||||
|
||||
// Cache for move feedback (indexed by move index)
|
||||
const [feedbackCache, setFeedbackCache] = useState<Map<number, MoveFeedback>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
// Initialize chess engine (local or remote based on environment)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
createEngine().then((engine) => {
|
||||
setStockfish(engine);
|
||||
}).catch((err) => {
|
||||
console.error('Failed to create chess engine:', err);
|
||||
setError('Failed to initialize chess engine');
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup will happen when stockfish changes
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup engine when it changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (stockfish) {
|
||||
stockfish.terminate();
|
||||
}
|
||||
};
|
||||
}, [stockfish]);
|
||||
|
||||
// Initialize chess instance
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
const chessInstance = new Chess(session.currentFEN);
|
||||
setChess(chessInstance);
|
||||
} else {
|
||||
setChess(null);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
/**
|
||||
* Initialize a new training session for an opening
|
||||
*/
|
||||
const initializeSession = async (
|
||||
openingMetadata: OpeningMetadata,
|
||||
forceNew: boolean = false
|
||||
) => {
|
||||
const perfStart = performance.now();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to load existing session (unless forceNew is true)
|
||||
let existingSession = forceNew ? null : loadSession(openingMetadata.eco);
|
||||
|
||||
if (existingSession && !forceNew) {
|
||||
// Resume existing session
|
||||
setSession(existingSession);
|
||||
setOpening(openingMetadata);
|
||||
} else {
|
||||
// Create new session
|
||||
const newSession = createSession(
|
||||
openingMetadata.eco,
|
||||
openingMetadata.name,
|
||||
STARTING_FEN,
|
||||
0
|
||||
);
|
||||
|
||||
// Get initial evaluation
|
||||
if (stockfish) {
|
||||
const initialEval = await evaluatePosition(STARTING_FEN, stockfish);
|
||||
newSession.initialEvaluation = initialEval.score;
|
||||
}
|
||||
|
||||
setSession(newSession);
|
||||
setOpening(openingMetadata);
|
||||
saveSession(newSession);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize session');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const perfEnd = performance.now();
|
||||
console.log(
|
||||
`[Performance] Session initialization: ${(perfEnd - perfStart).toFixed(0)}ms`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make a move in the training session
|
||||
*/
|
||||
const makeMove = async (san: string) => {
|
||||
console.log('[OpeningTraining] makeMove called with:', san);
|
||||
|
||||
if (!session || !opening || !chess || !stockfish) {
|
||||
console.error('[OpeningTraining] Session not initialized:', { session: !!session, opening: !!opening, chess: !!chess, stockfish: !!stockfish });
|
||||
setError('Session not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const perfStart = performance.now();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate and make the move
|
||||
console.log('[OpeningTraining] Attempting to make move:', san);
|
||||
const move = chess.move(san);
|
||||
if (!move) {
|
||||
console.error('[OpeningTraining] Illegal move:', san);
|
||||
setError('Illegal move');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[OpeningTraining] Move successful:', move);
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation (from last move or initial)
|
||||
const previousEval =
|
||||
session.moveHistory.length > 0
|
||||
? session.moveHistory[session.moveHistory.length - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const evalStart = performance.now();
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
const evalTime = performance.now() - evalStart;
|
||||
console.log(`[Performance] Engine evaluation: ${evalTime.toFixed(0)}ms`);
|
||||
|
||||
// Check if move is in repertoire
|
||||
const expectedMoves = getExpectedNextMoves(opening, session.moveHistory.length);
|
||||
const isInRepertoire = expectedMoves.includes(san);
|
||||
|
||||
// Check for transposition (if user is off-book, see if they've transposed back)
|
||||
let transposedOpening: OpeningMetadata | null = null;
|
||||
if (session.deviationMoveIndex !== null && !isInRepertoire) {
|
||||
transposedOpening = detectTransposition(newFEN);
|
||||
}
|
||||
|
||||
// Classify the move
|
||||
const classification = classifyMove(
|
||||
san,
|
||||
isInRepertoire,
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(session.moveHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: session.moveHistory.length + 1,
|
||||
moveHistory: [...session.moveHistory, moveEntry],
|
||||
deviationMoveIndex:
|
||||
!isInRepertoire && session.deviationMoveIndex === null
|
||||
? session.moveHistory.length
|
||||
: session.deviationMoveIndex,
|
||||
};
|
||||
|
||||
console.log('[OpeningTraining] Updating session with new move. Move history length:', updatedSession.moveHistory.length);
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
|
||||
// Create initial feedback (without LLM explanation)
|
||||
const initialFeedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification,
|
||||
evaluation: currentEval,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // Will be populated asynchronously
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Cache the initial feedback
|
||||
const moveIndex = updatedSession.moveHistory.length - 1;
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, initialFeedback);
|
||||
return newCache;
|
||||
});
|
||||
|
||||
setCurrentFeedback(initialFeedback);
|
||||
setIsLoading(false);
|
||||
|
||||
const feedbackTime = performance.now() - perfStart;
|
||||
console.log(
|
||||
`[Performance] Move processing (engine + classification): ${feedbackTime.toFixed(0)}ms`
|
||||
);
|
||||
|
||||
// Generate LLM explanation asynchronously (don't block user)
|
||||
generateLLMExplanation(
|
||||
initialFeedback,
|
||||
updatedSession,
|
||||
moveIndex,
|
||||
session.deviationMoveIndex === null &&
|
||||
!isInRepertoire &&
|
||||
updatedSession.deviationMoveIndex !== null,
|
||||
transposedOpening
|
||||
);
|
||||
|
||||
// After user's move, check if we should make automatic opponent move
|
||||
// Only if: move was in theory, and it's opponent's turn next
|
||||
const shouldMakeOpponentMove = isInRepertoire && isOpponentTurn(opening, updatedSession.moveHistory.length);
|
||||
console.log('[OpeningTraining] Should make opponent move?', shouldMakeOpponentMove, {
|
||||
isInRepertoire,
|
||||
isOpponentTurn: isOpponentTurn(opening, updatedSession.moveHistory.length),
|
||||
moveHistoryLength: updatedSession.moveHistory.length
|
||||
});
|
||||
|
||||
if (shouldMakeOpponentMove) {
|
||||
// Add delay for natural feel (600ms)
|
||||
console.log('[OpeningTraining] Scheduling automatic opponent move in 600ms');
|
||||
setTimeout(() => {
|
||||
makeAutomaticOpponentMove(updatedSession, opening);
|
||||
}, 600);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to process move');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make automatic opponent move from repertoire
|
||||
*/
|
||||
const makeAutomaticOpponentMove = async (
|
||||
currentSession: TrainingSession,
|
||||
openingMetadata: OpeningMetadata
|
||||
) => {
|
||||
if (!chess || !stockfish) return;
|
||||
|
||||
try {
|
||||
// Get opponent's next move from repertoire
|
||||
const opponentMove = getOpponentNextMove(
|
||||
openingMetadata,
|
||||
currentSession.moveHistory.length
|
||||
);
|
||||
|
||||
if (!opponentMove) {
|
||||
// No opponent move available (end of repertoire)
|
||||
console.log('[AutoMove] End of repertoire - no opponent move available');
|
||||
console.log('[AutoMove] Repertoire moves:', parseMoveSequence(openingMetadata.moves));
|
||||
console.log('[AutoMove] Current move index:', currentSession.moveHistory.length);
|
||||
|
||||
// Mark that we've reached the end of repertoire
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
// Could add a flag here if needed for UI indication
|
||||
};
|
||||
setSession(updatedSession);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've reached end of repertoire after this move
|
||||
const willReachEnd = isEndOfRepertoire(openingMetadata, currentSession.moveHistory.length + 1);
|
||||
if (willReachEnd) {
|
||||
console.log('[AutoMove] This will be the last repertoire move');
|
||||
}
|
||||
|
||||
// Make the move on the chess instance
|
||||
const move = chess.move(opponentMove);
|
||||
if (!move) {
|
||||
console.error('[AutoMove] Failed to make opponent move:', opponentMove);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation
|
||||
const previousEval =
|
||||
currentSession.moveHistory.length > 0
|
||||
? currentSession.moveHistory[currentSession.moveHistory.length - 1].evaluation
|
||||
: { score: currentSession.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
|
||||
// Classify the move (should always be "in-theory" for automatic moves)
|
||||
const expectedMoves = [opponentMove];
|
||||
const classification = classifyMove(
|
||||
opponentMove,
|
||||
true, // Always in repertoire
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(currentSession.moveHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: currentSession.moveHistory.length + 1,
|
||||
moveHistory: [...currentSession.moveHistory, moveEntry],
|
||||
};
|
||||
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
|
||||
console.log(`[AutoMove] Played ${opponentMove} automatically`);
|
||||
} catch (error) {
|
||||
console.error('[AutoMove] Error making automatic opponent move:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate LLM explanation for a move asynchronously
|
||||
*/
|
||||
const generateLLMExplanation = async (
|
||||
feedback: MoveFeedback,
|
||||
currentSession: TrainingSession,
|
||||
moveIndex: number,
|
||||
isDeviationMove: boolean,
|
||||
transposedOpening: OpeningMetadata | null = null
|
||||
) => {
|
||||
if (!opening) return;
|
||||
|
||||
const llmStart = performance.now();
|
||||
|
||||
try {
|
||||
// Build the prompt
|
||||
let prompt: string;
|
||||
|
||||
if (transposedOpening) {
|
||||
// Special prompt for transposition
|
||||
prompt = buildTranspositionPrompt(transposedOpening, feedback.move.san);
|
||||
} else {
|
||||
// Normal explanation prompt
|
||||
const promptContext: ExplanationPromptContext = {
|
||||
opening,
|
||||
userMove: feedback.move,
|
||||
classification: feedback.classification,
|
||||
currentEval: feedback.evaluation,
|
||||
previousEval: feedback.previousEvaluation,
|
||||
fen: feedback.move.fen,
|
||||
moveHistory: currentSession.moveHistory,
|
||||
isDeviationMove,
|
||||
};
|
||||
|
||||
prompt = buildExplanationPrompt(promptContext);
|
||||
}
|
||||
|
||||
// Call LLM API
|
||||
const response = await fetch('/api/v1/llm/opening-explanation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
moveSan: feedback.move.san,
|
||||
category: feedback.classification.category,
|
||||
theoreticalMoves: feedback.classification.theoreticalAlternatives,
|
||||
evalChange: feedback.classification.evaluationChange,
|
||||
bestMove: feedback.evaluation.bestMove,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('LLM API error:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const explanation = data.explanation || '';
|
||||
|
||||
// Update feedback with explanation
|
||||
const updatedFeedback: MoveFeedback = {
|
||||
...feedback,
|
||||
llmExplanation: explanation,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, updatedFeedback);
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Update current feedback if this is still the current move
|
||||
setCurrentFeedback((current) => {
|
||||
if (current && current.move.san === feedback.move.san) {
|
||||
return updatedFeedback;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
const llmTime = performance.now() - llmStart;
|
||||
console.log(`[Performance] LLM explanation generation: ${llmTime.toFixed(0)}ms`);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate LLM explanation:', error);
|
||||
// Silently fail - user still has engine feedback
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to a specific move in the history
|
||||
*/
|
||||
const navigateToMove = (index: number) => {
|
||||
if (!session || index < 0 || index > session.moveHistory.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentMoveIndex: index,
|
||||
currentFEN: index === 0 ? STARTING_FEN : session.moveHistory[index - 1].fen,
|
||||
};
|
||||
|
||||
setSession(updatedSession);
|
||||
|
||||
// Update feedback to show the move at this index
|
||||
if (index > 0 && index <= session.moveHistory.length) {
|
||||
const moveIndex = index - 1; // Convert from 1-based display to 0-based array index
|
||||
|
||||
// Check cache first
|
||||
const cached = feedbackCache.get(moveIndex);
|
||||
if (cached) {
|
||||
setCurrentFeedback(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache - rebuild feedback (shouldn't happen often)
|
||||
const moveEntry = session.moveHistory[moveIndex];
|
||||
const previousEval =
|
||||
moveIndex > 0
|
||||
? session.moveHistory[moveIndex - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
const feedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification: moveEntry.classification,
|
||||
evaluation: moveEntry.evaluation,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // No cached explanation available
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
setCurrentFeedback(feedback);
|
||||
} else {
|
||||
setCurrentFeedback(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the current session
|
||||
*/
|
||||
const resetSession = () => {
|
||||
setSession(null);
|
||||
setOpening(null);
|
||||
setCurrentFeedback(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const value: OpeningTrainingContextType = {
|
||||
session,
|
||||
opening,
|
||||
chess,
|
||||
stockfish,
|
||||
isLoading,
|
||||
error,
|
||||
currentFeedback,
|
||||
initializeSession,
|
||||
makeMove,
|
||||
navigateToMove,
|
||||
resetSession,
|
||||
};
|
||||
|
||||
return (
|
||||
<OpeningTrainingContext.Provider value={value}>
|
||||
{children}
|
||||
</OpeningTrainingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use the Opening Training context
|
||||
*/
|
||||
export function useOpeningTraining() {
|
||||
const context = useContext(OpeningTrainingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useOpeningTraining must be used within an OpeningTrainingProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Helper utilities for API key management
|
||||
*/
|
||||
|
||||
export type ApiKeySource = 'localStorage' | 'env' | 'none';
|
||||
|
||||
export interface ApiKeyInfo {
|
||||
key: string | null;
|
||||
source: ApiKeySource;
|
||||
anonymized: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize an API key by showing only first 2 and last 2 characters
|
||||
* Example: "AIzaSyABC...XYZ123" becomes "AI...23"
|
||||
*/
|
||||
export function anonymizeApiKey(key: string | null): string {
|
||||
if (!key || key.length < 8) {
|
||||
return '••••••••';
|
||||
}
|
||||
|
||||
const first2 = key.substring(0, 2);
|
||||
const last2 = key.substring(key.length - 2);
|
||||
return `${first2}${'•'.repeat(6)}${last2}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current API key and its source
|
||||
*/
|
||||
export function getApiKeyInfo(): ApiKeyInfo {
|
||||
// Check localStorage first (user-set key takes precedence)
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedKey = localStorage.getItem('gemini_api_key');
|
||||
if (storedKey && storedKey.trim()) {
|
||||
return {
|
||||
key: storedKey,
|
||||
source: 'localStorage',
|
||||
anonymized: anonymizeApiKey(storedKey),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment variable (fallback)
|
||||
const envKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY;
|
||||
if (envKey && envKey.trim()) {
|
||||
return {
|
||||
key: envKey,
|
||||
source: 'env',
|
||||
anonymized: anonymizeApiKey(envKey),
|
||||
};
|
||||
}
|
||||
|
||||
// No key found
|
||||
return {
|
||||
key: null,
|
||||
source: 'none',
|
||||
anonymized: '••••••••',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly description of API key source
|
||||
*/
|
||||
export function getApiKeySourceDescription(source: ApiKeySource): string {
|
||||
switch (source) {
|
||||
case 'localStorage':
|
||||
return 'User Settings (localStorage)';
|
||||
case 'env':
|
||||
return 'Environment Variable (.env)';
|
||||
case 'none':
|
||||
return 'Not configured';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Local Engine Implementation
|
||||
*
|
||||
* Uses stockfish.js running in the browser via Web Worker
|
||||
* This is GPL-licensed code - only included in web builds
|
||||
*
|
||||
* LICENSE: GPL-3.0 (because it uses stockfish.js)
|
||||
*/
|
||||
|
||||
import { Stockfish } from '../stockfish';
|
||||
import type { ChessEngine, EngineEvaluation } from './types';
|
||||
|
||||
/**
|
||||
* Local chess engine using stockfish.js
|
||||
* Runs engine in browser via Web Worker
|
||||
*
|
||||
* Only available in web builds - NOT included in mobile builds
|
||||
*/
|
||||
export class LocalEngine implements ChessEngine {
|
||||
private stockfish: Stockfish;
|
||||
|
||||
constructor() {
|
||||
this.stockfish = new Stockfish();
|
||||
}
|
||||
|
||||
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<EngineEvaluation> {
|
||||
// The existing Stockfish class returns the same format
|
||||
return this.stockfish.evaluate(fen, depth, multiPV);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.stockfish.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Remote Engine Implementation
|
||||
*
|
||||
* Calls API server for chess engine analysis
|
||||
* No GPL dependencies - safe for proprietary mobile builds
|
||||
*
|
||||
* LICENSE: Apache-2.0 (no GPL code)
|
||||
*/
|
||||
|
||||
import type { ChessEngine, EngineEvaluation, EngineConfig } from './types';
|
||||
|
||||
/**
|
||||
* Remote chess engine that calls API server
|
||||
* Engine runs on server, client just makes HTTP requests
|
||||
*
|
||||
* Safe for mobile apps - no GPL dependencies
|
||||
*/
|
||||
export class RemoteEngine implements ChessEngine {
|
||||
private apiUrl: string;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
constructor(config: EngineConfig = {}) {
|
||||
// Default to current origin for web builds
|
||||
// Mobile builds should provide explicit API URL via config
|
||||
this.apiUrl = config.apiUrl ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '') ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
'http://localhost:3050';
|
||||
}
|
||||
|
||||
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<EngineEvaluation> {
|
||||
// Create new abort controller for this request
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/api/v1/stockfish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ fen, depth, multiPV }),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Engine API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// API returns { evaluation: StockfishEvaluation }
|
||||
const evaluation = data.evaluation || data;
|
||||
|
||||
return {
|
||||
bestMove: evaluation.bestMove,
|
||||
ponder: evaluation.ponder,
|
||||
score: evaluation.score,
|
||||
mate: evaluation.mate,
|
||||
depth: evaluation.depth,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Engine evaluation was cancelled');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
// Abort any in-flight requests
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Chess Engine Factory
|
||||
*
|
||||
* Creates appropriate engine based on environment:
|
||||
* - Web builds: Use LocalEngine (stockfish.js in browser)
|
||||
* - Mobile builds: Use RemoteEngine (calls API server)
|
||||
*
|
||||
* Configuration via environment variables:
|
||||
* - NEXT_PUBLIC_USE_REMOTE_ENGINE=true → Force remote engine
|
||||
* - NEXT_PUBLIC_API_URL → API server URL for remote engine
|
||||
*/
|
||||
|
||||
import type { ChessEngine, EngineConfig } from './types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ChessEngine, EngineEvaluation, EngineConfig } from './types';
|
||||
|
||||
/**
|
||||
* Create a chess engine instance
|
||||
*
|
||||
* Automatically selects Local or Remote based on environment:
|
||||
* - In web builds: Uses local Stockfish (GPL-licensed)
|
||||
* - In mobile builds: Uses remote API (no GPL dependencies)
|
||||
*
|
||||
* @param config - Optional configuration for engine
|
||||
* @returns Chess engine instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic selection based on environment
|
||||
* const engine = createEngine();
|
||||
*
|
||||
* // Force remote engine
|
||||
* const remoteEngine = createEngine({ forceRemote: true });
|
||||
*
|
||||
* // Use custom API URL
|
||||
* const customEngine = createEngine({
|
||||
* apiUrl: 'https://api.myapp.com'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function createEngine(config: EngineConfig = {}): Promise<ChessEngine> {
|
||||
// Check if we should use remote engine
|
||||
const useRemote =
|
||||
config.forceRemote ||
|
||||
process.env.NEXT_PUBLIC_USE_REMOTE_ENGINE === 'true' ||
|
||||
process.env.NEXT_PUBLIC_FORCE_REMOTE_ENGINE === 'true';
|
||||
|
||||
if (useRemote) {
|
||||
// Use remote engine (API calls)
|
||||
// Safe for mobile - no GPL dependencies
|
||||
const { RemoteEngine } = await import('./RemoteEngine');
|
||||
console.log('[Engine] Using RemoteEngine (API server)');
|
||||
return new RemoteEngine(config);
|
||||
} else {
|
||||
// Use local engine (stockfish.js in browser)
|
||||
// Only in web builds - GPL licensed
|
||||
const { LocalEngine } = await import('./LocalEngine');
|
||||
console.log('[Engine] Using LocalEngine (browser Stockfish)');
|
||||
return new LocalEngine();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local engine is available
|
||||
* Returns false in mobile builds where stockfish.js is not included
|
||||
*/
|
||||
export function isLocalEngineAvailable(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
process.env.NEXT_PUBLIC_USE_REMOTE_ENGINE !== 'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current engine type being used
|
||||
*/
|
||||
export function getEngineType(): 'local' | 'remote' {
|
||||
if (process.env.NEXT_PUBLIC_USE_REMOTE_ENGINE === 'true') {
|
||||
return 'remote';
|
||||
}
|
||||
return 'local';
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Chess Engine Abstraction Layer
|
||||
*
|
||||
* This abstraction allows us to use different engine backends:
|
||||
* - LocalEngine: Uses stockfish.js in the browser (GPL - web only)
|
||||
* - RemoteEngine: Calls API server (no GPL - mobile safe)
|
||||
*/
|
||||
|
||||
export type EngineEvaluation = {
|
||||
bestMove: string;
|
||||
ponder: string | null;
|
||||
score: number; // centipawns, positive for white
|
||||
mate: number | null; // moves to mate, positive for white
|
||||
depth: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Abstract interface for chess engines
|
||||
* Both local and remote engines implement this interface
|
||||
*/
|
||||
export interface ChessEngine {
|
||||
/**
|
||||
* Evaluate a chess position
|
||||
* @param fen - Position in FEN notation
|
||||
* @param depth - Search depth (default: 15)
|
||||
* @param multiPV - Number of principal variations (default: 1)
|
||||
* @returns Engine evaluation
|
||||
*/
|
||||
evaluate(fen: string, depth?: number, multiPV?: number): Promise<EngineEvaluation>;
|
||||
|
||||
/**
|
||||
* Terminate the engine and clean up resources
|
||||
*/
|
||||
terminate(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for engine creation
|
||||
*/
|
||||
export type EngineConfig = {
|
||||
/**
|
||||
* API URL for remote engine
|
||||
* Only used when USE_REMOTE_ENGINE is true
|
||||
*/
|
||||
apiUrl?: string;
|
||||
|
||||
/**
|
||||
* Force remote engine even if local is available
|
||||
* Used for mobile builds
|
||||
*/
|
||||
forceRemote?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Centralized error handler for Gemini API errors
|
||||
*/
|
||||
|
||||
export interface GeminiErrorInfo {
|
||||
isQuotaError: boolean;
|
||||
isRateLimitError: boolean;
|
||||
userMessage: string;
|
||||
technicalMessage: string;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Gemini API error and extract useful information
|
||||
*/
|
||||
export function parseGeminiError(error: any): GeminiErrorInfo {
|
||||
const errorMessage = error?.message || error?.toString() || 'Unknown error';
|
||||
|
||||
// Check for quota exceeded (429 error)
|
||||
const isQuotaError = errorMessage.includes('quota') ||
|
||||
errorMessage.includes('429') ||
|
||||
errorMessage.includes('exceeded your current quota');
|
||||
|
||||
// Check for rate limit errors
|
||||
const isRateLimitError = errorMessage.includes('rate limit') ||
|
||||
errorMessage.includes('429');
|
||||
|
||||
// Extract retry delay if available
|
||||
let retryAfterSeconds: number | undefined;
|
||||
const retryMatch = errorMessage.match(/retry in (\d+(?:\.\d+)?)s/);
|
||||
if (retryMatch) {
|
||||
retryAfterSeconds = Math.ceil(parseFloat(retryMatch[1]));
|
||||
}
|
||||
|
||||
// Generate user-friendly message
|
||||
let userMessage: string;
|
||||
|
||||
if (isQuotaError) {
|
||||
if (errorMessage.includes('free_tier')) {
|
||||
userMessage = 'You have reached the daily limit for your free API key. Please upgrade to a paid plan or wait until tomorrow to continue.';
|
||||
} else {
|
||||
userMessage = 'You have exceeded your API quota. Please check your billing details or wait before trying again.';
|
||||
}
|
||||
} else if (isRateLimitError) {
|
||||
userMessage = retryAfterSeconds
|
||||
? `You're sending requests too quickly. Please wait ${retryAfterSeconds} seconds before trying again.`
|
||||
: 'You\'re sending requests too quickly. Please wait a moment before trying again.';
|
||||
} else if (errorMessage.includes('API key')) {
|
||||
userMessage = 'Your API key appears to be invalid. Please check your settings.';
|
||||
} else if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
|
||||
userMessage = 'Network error. Please check your internet connection and try again.';
|
||||
} else {
|
||||
userMessage = 'An error occurred while communicating with the AI. Please try again.';
|
||||
}
|
||||
|
||||
return {
|
||||
isQuotaError,
|
||||
isRateLimitError,
|
||||
userMessage,
|
||||
technicalMessage: errorMessage,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a Gemini API error
|
||||
*/
|
||||
export function isGeminiError(error: any): boolean {
|
||||
const message = error?.message || error?.toString() || '';
|
||||
return message.includes('GoogleGenerativeAI') ||
|
||||
message.includes('generativelanguage.googleapis.com') ||
|
||||
message.includes('Gemini');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Constants for the Interactive Chess Opening Training feature
|
||||
*/
|
||||
|
||||
/**
|
||||
* Move categorization thresholds
|
||||
* Based on research.md: -50cp confirmed as industry standard
|
||||
*/
|
||||
export const MOVE_CATEGORIZATION_THRESHOLDS = {
|
||||
/** Centipawn loss threshold for categorizing a move as "weak" (half a pawn) */
|
||||
WEAK_MOVE_CP_LOSS: 50,
|
||||
|
||||
/** Threshold for highlighting significant evaluation swings */
|
||||
SIGNIFICANT_SWING_CP: 50,
|
||||
|
||||
/** Centipawn equivalent for mate situations */
|
||||
MATE_SCORE_THRESHOLD: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Stockfish engine configuration
|
||||
* Based on research.md: Depth 12 targets 1.2-1.8 seconds for 2-second goal
|
||||
*/
|
||||
export const STOCKFISH_DEPTH = 12;
|
||||
|
||||
/**
|
||||
* Session persistence configuration
|
||||
*/
|
||||
export const SESSION_EXPIRY_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Wikipedia cache configuration
|
||||
*/
|
||||
export const WIKIPEDIA_CACHE_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Engine evaluation cache size
|
||||
* Limit to prevent excessive memory usage
|
||||
*/
|
||||
export const MAX_EVAL_CACHE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Starting position FEN
|
||||
*/
|
||||
export const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { ChessEngine, EngineEvaluation } from '@/lib/engine';
|
||||
import { STOCKFISH_DEPTH, MAX_EVAL_CACHE_SIZE } from './constants';
|
||||
|
||||
/**
|
||||
* Engine Service for Opening Training
|
||||
* Provides position evaluation with caching to improve performance
|
||||
* Works with both local (browser) and remote (API) engines
|
||||
*/
|
||||
|
||||
// In-memory cache for position evaluations
|
||||
// Key: FEN string, Value: EngineEvaluation
|
||||
const EVAL_CACHE = new Map<string, EngineEvaluation>();
|
||||
|
||||
/**
|
||||
* Evaluate a chess position with caching
|
||||
* Checks cache first, then evaluates with engine if not cached
|
||||
*
|
||||
* @param fen - Position in FEN notation
|
||||
* @param engine - Chess engine instance (local or remote)
|
||||
* @param depth - Search depth (default from constants)
|
||||
* @returns Engine evaluation
|
||||
*/
|
||||
export async function evaluatePosition(
|
||||
fen: string,
|
||||
engine: ChessEngine,
|
||||
depth: number = STOCKFISH_DEPTH
|
||||
): Promise<EngineEvaluation> {
|
||||
// Check cache first
|
||||
if (EVAL_CACHE.has(fen)) {
|
||||
return EVAL_CACHE.get(fen)!;
|
||||
}
|
||||
|
||||
// Evaluate with engine (local or remote)
|
||||
const evaluation = await engine.evaluate(fen, depth);
|
||||
|
||||
// Cache the result
|
||||
cacheEvaluation(fen, evaluation);
|
||||
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an evaluation to the cache
|
||||
* Implements LRU (Least Recently Used) eviction when cache is full
|
||||
*/
|
||||
function cacheEvaluation(fen: string, evaluation: EngineEvaluation): void {
|
||||
// If cache is full, remove oldest entry (first entry in Map)
|
||||
if (EVAL_CACHE.size >= MAX_EVAL_CACHE_SIZE) {
|
||||
const firstKey = EVAL_CACHE.keys().next().value;
|
||||
if (firstKey) {
|
||||
EVAL_CACHE.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
EVAL_CACHE.set(fen, evaluation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the evaluation cache
|
||||
* Useful for testing or when starting a new session
|
||||
*/
|
||||
export function clearEvaluationCache(): void {
|
||||
EVAL_CACHE.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging/monitoring
|
||||
*/
|
||||
export function getCacheStats(): {
|
||||
size: number;
|
||||
maxSize: number;
|
||||
hitRate: number;
|
||||
} {
|
||||
// Note: Hit rate tracking would require additional state
|
||||
// For now, just return size info
|
||||
return {
|
||||
size: EVAL_CACHE.size,
|
||||
maxSize: MAX_EVAL_CACHE_SIZE,
|
||||
hitRate: 0, // Would need hit/miss counters to calculate
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-cache evaluations for a sequence of positions
|
||||
* Useful for loading a repertoire line in the background
|
||||
*
|
||||
* @param fens - Array of FEN strings to evaluate
|
||||
* @param engine - Chess engine instance (local or remote)
|
||||
*/
|
||||
export async function precachePositions(
|
||||
fens: string[],
|
||||
engine: ChessEngine
|
||||
): Promise<void> {
|
||||
for (const fen of fens) {
|
||||
if (!EVAL_CACHE.has(fen)) {
|
||||
try {
|
||||
await evaluatePosition(fen, engine);
|
||||
} catch (error) {
|
||||
console.error(`Failed to precache position ${fen}:`, error);
|
||||
// Continue with next position even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position is already cached
|
||||
*/
|
||||
export function isPositionCached(fen: string): boolean {
|
||||
return EVAL_CACHE.has(fen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached evaluation without triggering a new evaluation
|
||||
* Returns undefined if not cached
|
||||
*/
|
||||
export function getCachedEvaluation(
|
||||
fen: string
|
||||
): EngineEvaluation | undefined {
|
||||
return EVAL_CACHE.get(fen);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { MoveFeedbackClassification, MoveHistoryEntry } from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { formatEvaluation } from './moveValidator';
|
||||
|
||||
/**
|
||||
* Builds a prompt for the LLM to generate an educational explanation of a chess move
|
||||
*/
|
||||
|
||||
export interface ExplanationPromptContext {
|
||||
opening: OpeningMetadata;
|
||||
userMove: MoveHistoryEntry;
|
||||
classification: MoveFeedbackClassification;
|
||||
currentEval: StockfishEvaluation;
|
||||
previousEval: StockfishEvaluation;
|
||||
fen: string;
|
||||
moveHistory: MoveHistoryEntry[];
|
||||
isDeviationMove: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a structured prompt for the LLM tutor
|
||||
* The prompt ensures the LLM:
|
||||
* 1. Never contradicts engine evaluations
|
||||
* 2. Explains strategic ideas in the position
|
||||
* 3. References engine facts when discussing move quality
|
||||
*/
|
||||
export function buildExplanationPrompt(context: ExplanationPromptContext): string {
|
||||
const {
|
||||
opening,
|
||||
userMove,
|
||||
classification,
|
||||
currentEval,
|
||||
previousEval,
|
||||
fen,
|
||||
moveHistory,
|
||||
isDeviationMove,
|
||||
} = context;
|
||||
|
||||
const evalChange = classification.evaluationChange !== 0
|
||||
? `${classification.evaluationChange > 0 ? '+' : ''}${(classification.evaluationChange / 100).toFixed(2)}`
|
||||
: '0.00';
|
||||
const currentEvalStr = formatEvaluation(currentEval);
|
||||
const previousEvalStr = formatEvaluation(previousEval);
|
||||
|
||||
// Build move history string
|
||||
const movesStr = moveHistory
|
||||
.map(
|
||||
(m, idx) =>
|
||||
`${m.moveNumber}${m.color === 'white' ? '.' : '...'} ${m.san}`
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
let prompt = `You are a chess opening tutor helping a student learn the ${opening.name} opening (ECO ${opening.eco}).
|
||||
|
||||
## Current Position
|
||||
FEN: ${fen}
|
||||
Move sequence: ${movesStr} ${userMove.san}
|
||||
|
||||
## The student just played: ${userMove.san}
|
||||
|
||||
## Engine Analysis (AUTHORITATIVE - never contradict this)
|
||||
- Previous evaluation: ${previousEvalStr}
|
||||
- Current evaluation: ${currentEvalStr}
|
||||
- Evaluation change: ${evalChange}
|
||||
- Best move according to engine: ${currentEval.bestMove || 'N/A'}
|
||||
`;
|
||||
|
||||
// Add context based on move category
|
||||
if (classification.category === 'in-theory') {
|
||||
prompt += `
|
||||
## Move Classification: IN THEORY
|
||||
This move is part of the opening repertoire. Explain:
|
||||
1. Why this move is played in this opening (strategic ideas, piece development, pawn structure)
|
||||
2. What the plan is after this move
|
||||
3. Common responses and how to continue
|
||||
|
||||
Keep it concise (2-3 sentences). Focus on teaching the IDEAS behind the move.`;
|
||||
} else if (classification.category === 'playable') {
|
||||
prompt += `
|
||||
## Move Classification: PLAYABLE (but not in repertoire)
|
||||
The student deviated from the repertoire, but the move is objectively sound (evaluation change: ${evalChange}).
|
||||
|
||||
${
|
||||
classification.theoreticalAlternatives.length > 0
|
||||
? `Repertoire move(s): ${classification.theoreticalAlternatives.join(', ')}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Explain:
|
||||
1. Acknowledge the move is playable and why (based on engine eval)
|
||||
2. Briefly explain what the repertoire move(s) aim for
|
||||
3. How the student's move differs strategically
|
||||
|
||||
Keep it concise (2-3 sentences). Be encouraging - deviations can be learning moments!`;
|
||||
} else {
|
||||
// weak move
|
||||
prompt += `
|
||||
## Move Classification: WEAK
|
||||
The engine shows this move loses significant advantage (${evalChange}).
|
||||
|
||||
Best move was: ${currentEval.bestMove}
|
||||
${
|
||||
classification.theoreticalAlternatives.length > 0
|
||||
? `Repertoire move(s): ${classification.theoreticalAlternatives.join(', ')}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Explain:
|
||||
1. Why this move is problematic (based on engine evaluation)
|
||||
2. What tactical or positional issue it creates
|
||||
3. What the better move(s) accomplish instead
|
||||
|
||||
Keep it concise (2-3 sentences). Be constructive and focus on learning.`;
|
||||
}
|
||||
|
||||
if (isDeviationMove) {
|
||||
prompt += `\n\n**NOTE**: This is the FIRST move where the student left the repertoire.`;
|
||||
}
|
||||
|
||||
prompt += `\n\n**CRITICAL RULES**:
|
||||
- NEVER contradict the engine evaluation
|
||||
- When discussing move quality, reference the engine's assessment
|
||||
- Focus on STRATEGIC IDEAS, not just memorization
|
||||
- Keep response under 60 words
|
||||
- Be encouraging and educational`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a simpler prompt for transposition scenarios
|
||||
*/
|
||||
export function buildTranspositionPrompt(
|
||||
transposedOpening: OpeningMetadata,
|
||||
currentMove: string
|
||||
): string {
|
||||
return `The position after ${currentMove} has transposed into the ${transposedOpening.name} (${transposedOpening.eco}).
|
||||
|
||||
Briefly explain (1 sentence):
|
||||
- What this transposition means
|
||||
- If it's a common occurrence
|
||||
|
||||
Keep under 30 words.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a prompt for explaining positions after leaving theory
|
||||
*/
|
||||
export function buildOffBookPrompt(context: ExplanationPromptContext): string {
|
||||
const { userMove, currentEval, fen } = context;
|
||||
|
||||
const currentEvalStr = formatEvaluation(currentEval);
|
||||
|
||||
return `You are a chess tutor. The student is now outside their opening repertoire after playing ${userMove.san}.
|
||||
|
||||
Position (FEN): ${fen}
|
||||
Engine evaluation: ${currentEvalStr}
|
||||
Best continuation: ${currentEval.bestMove || 'N/A'}
|
||||
|
||||
Provide a brief assessment (2 sentences):
|
||||
1. Evaluate the current position objectively
|
||||
2. Suggest a plan or strategic idea to pursue
|
||||
|
||||
Keep under 40 words. Reference the engine evaluation.`;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
MoveFeedbackClassification,
|
||||
MoveCategory,
|
||||
} from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { MOVE_CATEGORIZATION_THRESHOLDS } from './constants';
|
||||
|
||||
/**
|
||||
* Move Validator Module
|
||||
* Classifies user moves relative to opening theory and objective strength
|
||||
*/
|
||||
|
||||
/**
|
||||
* Classify a user's move based on repertoire matching and engine evaluation
|
||||
*
|
||||
* @param userMove - The move the user played (SAN notation)
|
||||
* @param isInRepertoire - Whether the move matches the expected repertoire line
|
||||
* @param previousEval - Engine evaluation before the move
|
||||
* @param currentEval - Engine evaluation after the move
|
||||
* @param theoreticalMoves - List of all valid theoretical moves at this position
|
||||
* @returns Classification of the move
|
||||
*/
|
||||
export function classifyMove(
|
||||
userMove: string,
|
||||
isInRepertoire: boolean,
|
||||
previousEval: StockfishEvaluation,
|
||||
currentEval: StockfishEvaluation,
|
||||
theoreticalMoves: string[] = []
|
||||
): MoveFeedbackClassification {
|
||||
// Calculate evaluation change
|
||||
// Note: Evaluation is from White's perspective
|
||||
// A positive change means better for White, negative means better for Black
|
||||
const evalChange = currentEval.score - previousEval.score;
|
||||
|
||||
// Determine move category
|
||||
let category: MoveCategory;
|
||||
|
||||
if (isInRepertoire) {
|
||||
// Move is in the opening repertoire
|
||||
category = 'in-theory';
|
||||
} else {
|
||||
// Move is not in repertoire - check if it's weak or playable
|
||||
// A move is "weak" if it loses significant material (50cp or more)
|
||||
const cpLoss = Math.abs(evalChange);
|
||||
|
||||
if (cpLoss >= MOVE_CATEGORIZATION_THRESHOLDS.WEAK_MOVE_CP_LOSS) {
|
||||
category = 'weak';
|
||||
} else {
|
||||
category = 'playable';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the evaluation swing is significant
|
||||
const isSignificantSwing =
|
||||
Math.abs(evalChange) >= MOVE_CATEGORIZATION_THRESHOLDS.SIGNIFICANT_SWING_CP;
|
||||
|
||||
// Filter out the user's move from theoretical alternatives
|
||||
const alternatives = theoreticalMoves.filter((move) => move !== userMove);
|
||||
|
||||
return {
|
||||
category,
|
||||
inRepertoire: isInRepertoire,
|
||||
evaluationChange: evalChange,
|
||||
isSignificantSwing,
|
||||
theoreticalAlternatives: alternatives,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a move is in the repertoire by comparing with expected next move(s)
|
||||
*
|
||||
* @param userMove - The move the user played (SAN notation)
|
||||
* @param expectedMoves - Array of expected next moves from repertoire (could be multiple variations)
|
||||
* @returns True if user's move matches any expected move
|
||||
*/
|
||||
export function isMoveinRepertoire(
|
||||
userMove: string,
|
||||
expectedMoves: string[]
|
||||
): boolean {
|
||||
return expectedMoves.some((expected) => expected === userMove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an evaluation represents a mate situation
|
||||
*/
|
||||
export function isMateScore(evaluation: StockfishEvaluation): boolean {
|
||||
return evaluation.mate !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable evaluation string
|
||||
*/
|
||||
export function formatEvaluation(evaluation: StockfishEvaluation): string {
|
||||
if (evaluation.mate !== null) {
|
||||
const mateIn = Math.abs(evaluation.mate);
|
||||
const side = evaluation.mate > 0 ? 'White' : 'Black';
|
||||
return `${side} mates in ${mateIn}`;
|
||||
}
|
||||
|
||||
const pawns = (evaluation.score / 100).toFixed(2);
|
||||
if (evaluation.score > 0) {
|
||||
return `+${pawns}`;
|
||||
} else if (evaluation.score < 0) {
|
||||
return pawns; // Already has minus sign
|
||||
} else {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a description of the move category for UI display
|
||||
*/
|
||||
export function getCategoryDescription(category: MoveCategory): string {
|
||||
switch (category) {
|
||||
case 'in-theory':
|
||||
return 'This move follows the opening theory';
|
||||
case 'playable':
|
||||
return 'This move is playable but not in the main repertoire';
|
||||
case 'weak':
|
||||
return 'This move is inaccurate and loses material';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a color class for styling based on category
|
||||
*/
|
||||
export function getCategoryColor(category: MoveCategory): string {
|
||||
switch (category) {
|
||||
case 'in-theory':
|
||||
return 'text-green-600';
|
||||
case 'playable':
|
||||
return 'text-yellow-600';
|
||||
case 'weak':
|
||||
return 'text-red-600';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
|
||||
/**
|
||||
* Utilities for grouping openings into families (e.g., "Italian Game", "Sicilian Defense")
|
||||
*/
|
||||
|
||||
export interface OpeningFamily {
|
||||
name: string;
|
||||
ecoRange: string; // e.g., "C50-C59"
|
||||
variationCount: number;
|
||||
variations: OpeningMetadata[];
|
||||
totalMoves: number; // Sum of moves across all variations
|
||||
popularity: number; // Derived from ECO codes
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the family name from an opening name
|
||||
* Examples:
|
||||
* "Italian Game: Classical Variation" -> "Italian Game"
|
||||
* "Sicilian Defense, Najdorf Variation" -> "Sicilian Defense"
|
||||
* "Queen's Gambit Declined" -> "Queen's Gambit"
|
||||
*/
|
||||
export function extractFamilyName(openingName: string): string {
|
||||
// Split by common delimiters
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
|
||||
for (const sep of separators) {
|
||||
if (openingName.includes(sep)) {
|
||||
return openingName.split(sep)[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If no separator, check for common patterns
|
||||
// "Queen's Gambit Declined" -> "Queen's Gambit"
|
||||
if (openingName.includes('Declined') || openingName.includes('Accepted')) {
|
||||
return openingName.replace(/\s+(Declined|Accepted).*$/, '').trim();
|
||||
}
|
||||
|
||||
// Default: return the full name (it's probably already a family name)
|
||||
return openingName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count moves in an opening
|
||||
*/
|
||||
function countMoves(movesString: string): number {
|
||||
if (!movesString) return 0;
|
||||
const moves = movesString.split(' ').filter(m => !m.match(/^\d+\.$/));
|
||||
return moves.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine popularity score for sorting
|
||||
*/
|
||||
function getPopularityScore(ecoCode: string): number {
|
||||
// Very popular openings (most common in practice)
|
||||
const veryPopular = [
|
||||
'C50', 'C55', 'C60', 'C65', 'C80', 'C90', // Italian, Spanish
|
||||
'D00', 'D06', 'D30', 'D35', 'D37', // Queen's Gambit
|
||||
'E00', 'E20', 'E60', 'E90', // Indian Defenses
|
||||
'B10', 'B12', 'B20', 'B30', 'B33', 'B40', 'B50', 'B90', // Sicilian, Caro-Kann
|
||||
];
|
||||
if (veryPopular.some(code => ecoCode.startsWith(code))) return 3;
|
||||
|
||||
// Popular openings
|
||||
const popular = [
|
||||
'A00', 'A04', 'A10', 'A40', 'A45', // English, other flank
|
||||
'C00', 'C01', 'C02', 'C10', 'C15', 'C20', 'C30', 'C40', // French, misc 1.e4
|
||||
'D10', 'D20', 'D40', 'D50', 'D60', 'D70', 'D80', // Other Queen's pawn
|
||||
'E10', 'E30', 'E40', 'E50', 'E70', // Indian variations
|
||||
'B00', 'B01', 'B02', // Other semi-open
|
||||
];
|
||||
if (popular.some(code => ecoCode.startsWith(code))) return 2;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group openings by family name
|
||||
*/
|
||||
export function groupOpeningsByFamily(openings: OpeningMetadata[]): OpeningFamily[] {
|
||||
// Filter out single-move openings first
|
||||
const validOpenings = openings.filter(opening => countMoves(opening.moves) > 1);
|
||||
|
||||
// Group by family name
|
||||
const familyMap = new Map<string, OpeningMetadata[]>();
|
||||
|
||||
validOpenings.forEach(opening => {
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
|
||||
if (!familyMap.has(familyName)) {
|
||||
familyMap.set(familyName, []);
|
||||
}
|
||||
familyMap.get(familyName)!.push(opening);
|
||||
});
|
||||
|
||||
// Convert to OpeningFamily objects
|
||||
const families: OpeningFamily[] = [];
|
||||
|
||||
familyMap.forEach((variations, familyName) => {
|
||||
// Calculate ECO range
|
||||
const ecoCodes = variations.map(v => v.eco).sort();
|
||||
const ecoRange = ecoCodes.length === 1
|
||||
? ecoCodes[0]
|
||||
: `${ecoCodes[0]}-${ecoCodes[ecoCodes.length - 1]}`;
|
||||
|
||||
// Calculate total moves and average popularity
|
||||
const totalMoves = variations.reduce((sum, v) => sum + countMoves(v.moves), 0);
|
||||
const avgPopularity = variations.reduce((sum, v) => sum + getPopularityScore(v.eco), 0) / variations.length;
|
||||
|
||||
families.push({
|
||||
name: familyName,
|
||||
ecoRange,
|
||||
variationCount: variations.length,
|
||||
variations,
|
||||
totalMoves,
|
||||
popularity: avgPopularity,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by popularity (desc), then variation count (desc), then name
|
||||
families.sort((a, b) => {
|
||||
if (a.popularity !== b.popularity) {
|
||||
return b.popularity - a.popularity;
|
||||
}
|
||||
if (a.variationCount !== b.variationCount) {
|
||||
return b.variationCount - a.variationCount;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all variations for a specific family
|
||||
*/
|
||||
export function getVariationsByFamily(
|
||||
openings: OpeningMetadata[],
|
||||
familyName: string
|
||||
): OpeningMetadata[] {
|
||||
return openings.filter(opening => {
|
||||
const extractedFamily = extractFamilyName(opening.name);
|
||||
return extractedFamily === familyName && countMoves(opening.moves) > 1;
|
||||
}).sort((a, b) => {
|
||||
// Sort variations by move count (desc) within family
|
||||
const movesA = countMoves(a.moves);
|
||||
const movesB = countMoves(b.moves);
|
||||
if (movesA !== movesB) {
|
||||
return movesB - movesA;
|
||||
}
|
||||
return a.eco.localeCompare(b.eco);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import ecoA from '../../../public/openings/ecoA.json';
|
||||
import ecoB from '../../../public/openings/ecoB.json';
|
||||
import ecoC from '../../../public/openings/ecoC.json';
|
||||
import ecoD from '../../../public/openings/ecoD.json';
|
||||
import ecoE from '../../../public/openings/ecoE.json';
|
||||
|
||||
/**
|
||||
* Centralized opening data loader to avoid duplicate imports
|
||||
*/
|
||||
|
||||
// Merge all ECO databases (keyed by FEN)
|
||||
const ALL_OPENINGS_BY_FEN = {
|
||||
...ecoA,
|
||||
...ecoB,
|
||||
...ecoC,
|
||||
...ecoD,
|
||||
...ecoE,
|
||||
} as Record<string, OpeningMetadata>;
|
||||
|
||||
// Convert to array for listing
|
||||
const OPENINGS_ARRAY = Object.values(ALL_OPENINGS_BY_FEN);
|
||||
|
||||
// Create ECO-indexed lookup for fast access (only root openings)
|
||||
const OPENINGS_BY_ECO: Record<string, OpeningMetadata> = {};
|
||||
OPENINGS_ARRAY.forEach((opening) => {
|
||||
if (opening.eco && opening.isEcoRoot === true) {
|
||||
OPENINGS_BY_ECO[opening.eco] = opening;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all openings as an array
|
||||
*/
|
||||
export function getAllOpenings(): OpeningMetadata[] {
|
||||
return OPENINGS_ARRAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ECO root openings (for the selector)
|
||||
*/
|
||||
export function getEcoRootOpenings(): OpeningMetadata[] {
|
||||
return OPENINGS_ARRAY.filter((opening) => opening.isEcoRoot === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific opening by ECO code
|
||||
* Prefers variations with more moves for better training experience
|
||||
*/
|
||||
export function getOpeningByEco(eco: string): OpeningMetadata | null {
|
||||
const rootOpening = OPENINGS_BY_ECO[eco];
|
||||
if (!rootOpening) return null;
|
||||
|
||||
// Find all openings with this ECO code
|
||||
const allVariations = OPENINGS_ARRAY.filter((opening) => opening.eco === eco);
|
||||
|
||||
// Count moves in each variation
|
||||
const variationsWithMoves = allVariations.map((opening) => ({
|
||||
opening,
|
||||
moveCount: opening.moves ? opening.moves.split(' ').filter(m => !m.match(/^\d+\.$/)).length : 0,
|
||||
}));
|
||||
|
||||
// Sort by move count descending (prefer variations with more moves)
|
||||
variationsWithMoves.sort((a, b) => b.moveCount - a.moveCount);
|
||||
|
||||
// Return the variation with the most moves (better for training)
|
||||
// But require at least 2 moves (user move + opponent response)
|
||||
const bestVariation = variationsWithMoves.find((v) => v.moveCount >= 2);
|
||||
|
||||
return bestVariation ? bestVariation.opening : rootOpening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all openings as a record keyed by FEN (for backwards compatibility)
|
||||
*/
|
||||
export function getAllOpeningsByFen(): Record<string, OpeningMetadata> {
|
||||
return ALL_OPENINGS_BY_FEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all openings as a record keyed by ECO code
|
||||
*/
|
||||
export function getAllOpeningsByEco(): Record<string, OpeningMetadata> {
|
||||
return OPENINGS_BY_ECO;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Chess } from 'chess.js';
|
||||
import { OpeningMetadata, lookupOpening } from '@/lib/openings';
|
||||
|
||||
/**
|
||||
* Repertoire Navigation Module
|
||||
* Handles tracking position within opening repertoire and detecting deviations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a move sequence from an opening's moves string
|
||||
* Example: "1. e4 e5 2. Nf3" → ["e4", "e5", "Nf3"]
|
||||
*
|
||||
* @param moveString - Move sequence from opening database
|
||||
* @returns Array of moves in SAN notation
|
||||
*/
|
||||
export function parseMoveSequence(moveString: string): string[] {
|
||||
if (!moveString || moveString.trim() === '') return [];
|
||||
|
||||
// Remove move numbers and extra whitespace
|
||||
// "1. e4 e5 2. Nf3" → "e4 e5 Nf3"
|
||||
const cleaned = moveString
|
||||
.replace(/\d+\./g, '') // Remove move numbers
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
// Split into individual moves
|
||||
return cleaned.split(' ').filter((move) => move.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected next move(s) from the repertoire at current position
|
||||
*
|
||||
* @param opening - The opening metadata from database
|
||||
* @param currentMoveIndex - Current position in the move sequence (0-based)
|
||||
* @returns Array of expected next moves (usually one, could be multiple for variations)
|
||||
*/
|
||||
export function getExpectedNextMoves(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string[] {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
|
||||
// If we're at or past the end of the repertoire
|
||||
if (currentMoveIndex >= moves.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For MVP: return single main line move
|
||||
// Future: could handle variations by checking for multiple lines
|
||||
const nextMove = moves[currentMoveIndex];
|
||||
return nextMove ? [nextMove] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position has transposed into a known opening
|
||||
* Uses FEN lookup to detect if the current position exists in the database
|
||||
*
|
||||
* @param fen - Current position in FEN notation
|
||||
* @returns Opening metadata if position is found, null otherwise
|
||||
*/
|
||||
export function detectTransposition(fen: string): OpeningMetadata | null {
|
||||
return lookupOpening(fen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the current position's FEN after making moves from the opening
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param upToMoveIndex - Play moves up to this index (exclusive)
|
||||
* @returns FEN string of the resulting position
|
||||
*/
|
||||
export function buildPositionFromOpening(
|
||||
opening: OpeningMetadata,
|
||||
upToMoveIndex: number
|
||||
): string | null {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
const chess = new Chess();
|
||||
|
||||
try {
|
||||
for (let i = 0; i < upToMoveIndex && i < moves.length; i++) {
|
||||
const move = chess.move(moves[i]);
|
||||
if (!move) {
|
||||
console.error(`Invalid move at index ${i}: ${moves[i]}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return chess.fen();
|
||||
} catch (error) {
|
||||
console.error('Error building position from opening:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of moves in the opening repertoire
|
||||
*/
|
||||
export function getRepertoireLength(opening: OpeningMetadata): number {
|
||||
return parseMoveSequence(opening.moves).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we've reached the end of the repertoire
|
||||
*/
|
||||
export function isEndOfRepertoire(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): boolean {
|
||||
const length = getRepertoireLength(opening);
|
||||
return currentMoveIndex >= length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visual representation of the current progress through the opening
|
||||
* Example: "5/12 moves" or "End of line"
|
||||
*/
|
||||
export function getRepertoireProgress(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string {
|
||||
const total = getRepertoireLength(opening);
|
||||
|
||||
if (currentMoveIndex >= total) {
|
||||
return 'End of repertoire';
|
||||
}
|
||||
|
||||
return `${currentMoveIndex}/${total} moves`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a move sequence matches the opening's mainline up to a certain point
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param playedMoves - Array of moves that have been played (SAN notation)
|
||||
* @returns True if the played moves match the opening's sequence
|
||||
*/
|
||||
export function matchesMainline(
|
||||
opening: OpeningMetadata,
|
||||
playedMoves: string[]
|
||||
): boolean {
|
||||
const expectedMoves = parseMoveSequence(opening.moves);
|
||||
|
||||
// If we've played more moves than in the repertoire, it's not a match
|
||||
if (playedMoves.length > expectedMoves.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each played move against expected
|
||||
for (let i = 0; i < playedMoves.length; i++) {
|
||||
if (playedMoves[i] !== expectedMoves[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which color the user is playing based on ECO code
|
||||
* A, B, C = White openings (user plays White)
|
||||
* D, E = Black defenses (user plays Black)
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @returns 'white' or 'black'
|
||||
*/
|
||||
export function getUserColor(opening: OpeningMetadata): 'white' | 'black' {
|
||||
const ecoLetter = opening.eco[0];
|
||||
return ['A', 'B', 'C'].includes(ecoLetter) ? 'white' : 'black';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the opponent's next move from the repertoire
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param currentMoveIndex - Current position in the move sequence (0-based)
|
||||
* @returns The opponent's next move in SAN notation, or null if not available
|
||||
*/
|
||||
export function getOpponentNextMove(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string | null {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
const userColor = getUserColor(opening);
|
||||
|
||||
// If we're at or past the end of the repertoire
|
||||
if (currentMoveIndex >= moves.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if this should be the opponent's move
|
||||
// Move index 0 = first move (1. move)
|
||||
// Move index 1 = second move (1... move)
|
||||
// etc.
|
||||
|
||||
const isWhiteMove = currentMoveIndex % 2 === 0;
|
||||
const isOpponentMove = (userColor === 'white' && !isWhiteMove) ||
|
||||
(userColor === 'black' && isWhiteMove);
|
||||
|
||||
if (!isOpponentMove) {
|
||||
// This is the user's move, not the opponent's
|
||||
return null;
|
||||
}
|
||||
|
||||
return moves[currentMoveIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's the opponent's turn to move
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param currentMoveIndex - Current position in the move sequence (0-based)
|
||||
* @returns True if it's the opponent's turn
|
||||
*/
|
||||
export function isOpponentTurn(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): boolean {
|
||||
const userColor = getUserColor(opening);
|
||||
const isWhiteMove = currentMoveIndex % 2 === 0;
|
||||
|
||||
return (userColor === 'white' && !isWhiteMove) ||
|
||||
(userColor === 'black' && isWhiteMove);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
TrainingSession,
|
||||
PersistedTrainingSession,
|
||||
} from '@/types/openingTraining';
|
||||
import { SESSION_EXPIRY_DAYS } from './constants';
|
||||
|
||||
/**
|
||||
* Session Manager for Opening Training
|
||||
* Handles creation, persistence, and recovery of training sessions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a new training session
|
||||
*/
|
||||
export function createSession(
|
||||
openingId: string,
|
||||
openingName: string,
|
||||
initialFEN: string,
|
||||
initialEvaluation: number = 0
|
||||
): TrainingSession {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
sessionId: uuidv4(),
|
||||
openingId,
|
||||
openingName,
|
||||
startedAt: now,
|
||||
lastUpdatedAt: now,
|
||||
status: 'active',
|
||||
currentFEN: initialFEN,
|
||||
currentMoveIndex: 0,
|
||||
moveHistory: [],
|
||||
deviationMoveIndex: null,
|
||||
initialEvaluation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save training session to localStorage
|
||||
*/
|
||||
export function saveSession(session: TrainingSession): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const key = `openingTraining_session_${session.openingId}`;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(session));
|
||||
} catch (error) {
|
||||
console.error('Failed to save training session:', error);
|
||||
// If quota exceeded, try clearing old sessions
|
||||
cleanupStaleSessions();
|
||||
// Retry save
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(session));
|
||||
} catch (retryError) {
|
||||
console.error('Failed to save session after cleanup:', retryError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load training session from localStorage
|
||||
* Returns null if session doesn't exist or has expired
|
||||
*/
|
||||
export function loadSession(openingId: string): TrainingSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const key = `openingTraining_session_${openingId}`;
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return null;
|
||||
|
||||
const session = JSON.parse(data) as TrainingSession;
|
||||
|
||||
// Check expiration
|
||||
const daysSinceUpdate =
|
||||
(Date.now() - session.lastUpdatedAt) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysSinceUpdate > SESSION_EXPIRY_DAYS) {
|
||||
// Session expired - remove it
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('Failed to load training session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete training session from localStorage
|
||||
*/
|
||||
export function deleteSession(openingId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const key = `openingTraining_session_${openingId}`;
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session's last updated timestamp and save
|
||||
*/
|
||||
export function updateSession(session: TrainingSession): void {
|
||||
session.lastUpdatedAt = Date.now();
|
||||
saveSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale sessions (older than SESSION_EXPIRY_DAYS)
|
||||
* Called when localStorage quota is exceeded
|
||||
*/
|
||||
export function cleanupStaleSessions(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const now = Date.now();
|
||||
const keys: string[] = [];
|
||||
|
||||
// Collect all openingTraining session keys
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith('openingTraining_session_')) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check each session and remove if expired
|
||||
keys.forEach((key) => {
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return;
|
||||
|
||||
const session = JSON.parse(data) as TrainingSession;
|
||||
const daysSinceUpdate =
|
||||
(now - session.lastUpdatedAt) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysSinceUpdate > SESSION_EXPIRY_DAYS) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, remove the corrupted entry
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active training sessions
|
||||
* Useful for displaying a list of in-progress trainings
|
||||
*/
|
||||
export function getAllActiveSessions(): TrainingSession[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
const sessions: TrainingSession[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key?.startsWith('openingTraining_session_')) continue;
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) continue;
|
||||
|
||||
const session = JSON.parse(data) as TrainingSession;
|
||||
|
||||
// Only include non-expired sessions
|
||||
const daysSinceUpdate =
|
||||
(now - session.lastUpdatedAt) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysSinceUpdate <= SESSION_EXPIRY_DAYS && session.status === 'active') {
|
||||
sessions.push(session);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { WikipediaSummary } from '@/types/openingTraining';
|
||||
|
||||
const WIKI_CACHE_KEY_PREFIX = 'wiki_summary_';
|
||||
|
||||
/**
|
||||
* Fetches Wikipedia summary for an opening
|
||||
* Priority: 1. Direct slug from database, 2. Local cache by name, 3. localStorage cache, 4. API
|
||||
*/
|
||||
export async function getWikipediaSummary(
|
||||
openingName: string,
|
||||
wikipediaSlug?: string
|
||||
): Promise<WikipediaSummary | null> {
|
||||
try {
|
||||
// If we have a slug from the database, use it directly (most reliable)
|
||||
if (wikipediaSlug) {
|
||||
const slugCached = await getLocalWikipediaCacheBySlug(wikipediaSlug);
|
||||
if (slugCached) {
|
||||
console.log('[Wikipedia] Using local cache (direct slug):', wikipediaSlug);
|
||||
return slugCached;
|
||||
}
|
||||
}
|
||||
|
||||
// Try local cached Wikipedia files by name
|
||||
const localCached = await getLocalWikipediaCache(openingName);
|
||||
if (localCached) {
|
||||
console.log('[Wikipedia] Using local cache (by name):', openingName);
|
||||
return localCached;
|
||||
}
|
||||
|
||||
// Check localStorage cache
|
||||
const cached = getCachedSummary(openingName);
|
||||
if (cached && !isCacheExpired(cached)) {
|
||||
console.log('[Wikipedia] Using localStorage cache for:', openingName);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - fetch from API
|
||||
console.log('[Wikipedia] Fetching from API for:', openingName);
|
||||
const response = await fetch(
|
||||
`/api/v1/wikipedia/summary?opening=${encodeURIComponent(openingName)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Remove stale cache entry on 404
|
||||
if (response.status === 404) {
|
||||
removeCachedSummary(openingName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary: WikipediaSummary = await response.json();
|
||||
|
||||
// Cache the fresh result
|
||||
cacheSummary(openingName, summary);
|
||||
|
||||
return summary;
|
||||
} catch (error) {
|
||||
console.error('Wikipedia service error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Wikipedia content from local cache by slug (most reliable)
|
||||
*/
|
||||
async function getLocalWikipediaCacheBySlug(
|
||||
slug: string
|
||||
): Promise<WikipediaSummary | null> {
|
||||
try {
|
||||
const cacheUrl = `/wikipedia/${slug}.json`;
|
||||
|
||||
const response = await fetch(cacheUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Convert local cache format to WikipediaSummary format
|
||||
const extract = data.sections
|
||||
.map((s: any) => {
|
||||
if (s.title === 'Introduction') {
|
||||
return s.text;
|
||||
}
|
||||
return `${s.title}\n\n${s.text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
openingName: data.openingFamily,
|
||||
title: data.title.replace(/<[^>]*>/g, ''), // Strip HTML tags
|
||||
extract: extract.substring(0, 2000), // Limit size for UI
|
||||
url: data.url,
|
||||
fetchedAt: data.fetchedAt,
|
||||
expiresAt: data.fetchedAt + 365 * 24 * 60 * 60 * 1000, // 1 year for local cache
|
||||
};
|
||||
} catch (error) {
|
||||
// File not found or error reading - not a problem, fallback to other methods
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load Wikipedia content from local cached files by name (fallback)
|
||||
*/
|
||||
async function getLocalWikipediaCache(
|
||||
openingName: string
|
||||
): Promise<WikipediaSummary | null> {
|
||||
// Convert opening name to slug
|
||||
const slug = openingName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
return await getLocalWikipediaCacheBySlug(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves cached Wikipedia summary from localStorage
|
||||
*/
|
||||
function getCachedSummary(openingName: string): WikipediaSummary | null {
|
||||
try {
|
||||
const key = getCacheKey(openingName);
|
||||
const cached = localStorage.getItem(key);
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(cached) as WikipediaSummary;
|
||||
} catch (error) {
|
||||
console.error('Error reading Wikipedia cache:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores Wikipedia summary in localStorage cache
|
||||
*/
|
||||
function cacheSummary(openingName: string, summary: WikipediaSummary): void {
|
||||
try {
|
||||
const key = getCacheKey(openingName);
|
||||
localStorage.setItem(key, JSON.stringify(summary));
|
||||
} catch (error) {
|
||||
console.error('Error caching Wikipedia summary:', error);
|
||||
// Cache failure should not break the feature
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes cached Wikipedia summary from localStorage
|
||||
*/
|
||||
function removeCachedSummary(openingName: string): void {
|
||||
try {
|
||||
const key = getCacheKey(openingName);
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('Error removing Wikipedia cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if cached summary has expired (30 days)
|
||||
*/
|
||||
function isCacheExpired(summary: WikipediaSummary): boolean {
|
||||
const now = Date.now();
|
||||
return now > summary.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates cache key for opening name
|
||||
*/
|
||||
function getCacheKey(openingName: string): string {
|
||||
return `${WIKI_CACHE_KEY_PREFIX}${openingName.toLowerCase().replace(/\s+/g, '_')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all expired Wikipedia cache entries
|
||||
* Should be called periodically (e.g., on app load)
|
||||
*/
|
||||
export function cleanupExpiredWikipediaCache(): void {
|
||||
try {
|
||||
const keys = Object.keys(localStorage);
|
||||
const wikiKeys = keys.filter((key) => key.startsWith(WIKI_CACHE_KEY_PREFIX));
|
||||
|
||||
wikiKeys.forEach((key) => {
|
||||
try {
|
||||
const cached = localStorage.getItem(key);
|
||||
if (cached) {
|
||||
const summary: WikipediaSummary = JSON.parse(cached);
|
||||
if (isCacheExpired(summary)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Remove corrupted cache entries
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up Wikipedia cache:', error);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface OpeningMetadata {
|
||||
moves: string;
|
||||
name: string;
|
||||
isEcoRoot?: boolean;
|
||||
wikipediaSlug?: string;
|
||||
aliases?: { [key: string]: string };
|
||||
meta?: {
|
||||
strengths_white?: string[];
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Server-side prompt templates for the opening tutor LLM
|
||||
* These templates ensure consistent, high-quality explanations
|
||||
*/
|
||||
|
||||
export const OPENING_TUTOR_SYSTEM_PROMPT = `You are an expert chess opening tutor with deep knowledge of opening theory, strategic ideas, and engine evaluation.
|
||||
|
||||
Your role is to help students understand chess openings through:
|
||||
1. Clear explanations of strategic ideas and plans
|
||||
2. Accurate references to engine evaluations
|
||||
3. Encouraging, educational feedback
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- NEVER contradict engine evaluations when discussing move quality
|
||||
- When a move is weak (engine shows significant loss), clearly state this based on the evaluation
|
||||
- When a move is strong, reference the engine's positive assessment
|
||||
- Focus on teaching STRATEGIC IDEAS, not just move memorization
|
||||
- Keep explanations concise and accessible
|
||||
- Be encouraging, especially when students make mistakes
|
||||
|
||||
EVALUATION INTERPRETATION:
|
||||
- Centipawn (cp) scores: +100 = 1 pawn advantage for White, -100 = 1 pawn advantage for Black
|
||||
- Mate scores: #N means mate in N moves
|
||||
- Evaluation changes of ±50cp (±0.5 pawns) are significant
|
||||
- Always interpret evaluations from the perspective of the position, not just numbers
|
||||
|
||||
EXPLANATION STYLE:
|
||||
- Use natural, conversational language
|
||||
- Explain WHY moves are good/bad, not just THAT they are
|
||||
- Connect moves to strategic themes (development, control, pawn structure, king safety, etc.)
|
||||
- When students deviate from theory, explain what the repertoire move aims for
|
||||
- Acknowledge good tries even when moves aren't optimal`;
|
||||
|
||||
export const OPENING_TUTOR_TEMPERATURE = 0.7; // Balanced between consistency and natural language
|
||||
|
||||
export const OPENING_TUTOR_MAX_TOKENS = 150; // ~60-80 words for concise responses
|
||||
|
||||
/**
|
||||
* Fallback explanation templates for when LLM is unavailable
|
||||
*/
|
||||
export const FALLBACK_EXPLANATIONS = {
|
||||
'in-theory': (moveSan: string) =>
|
||||
`${moveSan} is part of the opening repertoire. This move follows established theory for this position.`,
|
||||
|
||||
playable: (moveSan: string, theoreticalMoves: string[]) =>
|
||||
`${moveSan} is playable but not in the repertoire. ${
|
||||
theoreticalMoves.length > 0
|
||||
? `The repertoire suggests ${theoreticalMoves.join(' or ')}.`
|
||||
: ''
|
||||
}`,
|
||||
|
||||
weak: (moveSan: string, evalChange: string, bestMove: string) =>
|
||||
`${moveSan} loses significant advantage (${evalChange}). The engine prefers ${bestMove}.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a fallback explanation when LLM is unavailable
|
||||
*/
|
||||
export function generateFallbackExplanation(
|
||||
category: 'in-theory' | 'playable' | 'weak',
|
||||
moveSan: string,
|
||||
theoreticalMoves: string[] = [],
|
||||
evalChange?: string,
|
||||
bestMove?: string
|
||||
): string {
|
||||
switch (category) {
|
||||
case 'in-theory':
|
||||
return FALLBACK_EXPLANATIONS['in-theory'](moveSan);
|
||||
case 'playable':
|
||||
return FALLBACK_EXPLANATIONS.playable(moveSan, theoreticalMoves);
|
||||
case 'weak':
|
||||
return FALLBACK_EXPLANATIONS.weak(
|
||||
moveSan,
|
||||
evalChange || '',
|
||||
bestMove || 'another move'
|
||||
);
|
||||
default:
|
||||
return `Move played: ${moveSan}`;
|
||||
}
|
||||
}
|
||||
@@ -771,8 +771,8 @@ export function generateTacticExercise(params: TacticExerciseParams): TacticExer
|
||||
},
|
||||
resultPosition: { fen: chosen.resultingFen },
|
||||
pattern: chosen.expectedPattern as TacticalPattern,
|
||||
moves: chosen.moves, // Include full move sequence
|
||||
rating: chosen.rating, // Include puzzle rating
|
||||
...(('moves' in chosen) && { moves: chosen.moves }), // Include full move sequence if available
|
||||
...(('rating' in chosen) && { rating: chosen.rating }), // Include puzzle rating if available
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
|
||||
/**
|
||||
* Type definitions for the Interactive Chess Opening Training feature
|
||||
*/
|
||||
|
||||
// Move categorization types
|
||||
export type MoveCategory = 'in-theory' | 'playable' | 'weak';
|
||||
|
||||
// Training session status
|
||||
export type TrainingSessionStatus = 'active' | 'completed' | 'abandoned';
|
||||
|
||||
/**
|
||||
* Represents an active or completed opening training session
|
||||
*/
|
||||
export interface TrainingSession {
|
||||
sessionId: string;
|
||||
openingId: string;
|
||||
openingName: string;
|
||||
startedAt: number; // Unix timestamp (milliseconds)
|
||||
lastUpdatedAt: number; // Unix timestamp (milliseconds)
|
||||
status: TrainingSessionStatus;
|
||||
currentFEN: string;
|
||||
currentMoveIndex: number;
|
||||
moveHistory: MoveHistoryEntry[];
|
||||
deviationMoveIndex: number | null; // Index where user first left theory
|
||||
initialEvaluation: number; // Starting position evaluation (centipawns)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single move in the training session
|
||||
*/
|
||||
export interface MoveHistoryEntry {
|
||||
moveNumber: number; // Full move number (1, 2, 3, ...)
|
||||
color: 'white' | 'black';
|
||||
san: string; // Standard Algebraic Notation (e.g., "Nf3", "exd5")
|
||||
uci: string; // UCI notation (e.g., "e2e4", "e7e5")
|
||||
fen: string; // Position AFTER this move
|
||||
evaluation: StockfishEvaluation;
|
||||
classification: MoveFeedbackClassification;
|
||||
timestamp: number; // Unix timestamp when move was played
|
||||
}
|
||||
|
||||
/**
|
||||
* Classification of a user's move relative to opening theory and objective strength
|
||||
*/
|
||||
export interface MoveFeedbackClassification {
|
||||
category: MoveCategory;
|
||||
inRepertoire: boolean; // Whether move matches expected repertoire line
|
||||
evaluationChange: number; // Centipawn change from previous position
|
||||
isSignificantSwing: boolean; // Whether eval change exceeds threshold (±50cp)
|
||||
theoreticalAlternatives: string[]; // List of in-theory moves (empty if only one)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete feedback package for a user's move
|
||||
*/
|
||||
export interface MoveFeedback {
|
||||
move: MoveHistoryEntry;
|
||||
classification: MoveFeedbackClassification;
|
||||
evaluation: StockfishEvaluation;
|
||||
previousEvaluation: StockfishEvaluation;
|
||||
llmExplanation: string; // Natural language explanation from tutor
|
||||
generatedAt: number; // Unix timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Wikipedia summary for an opening
|
||||
*/
|
||||
export interface WikipediaSummary {
|
||||
openingName: string; // Opening name used for lookup
|
||||
title: string; // Wikipedia article title
|
||||
extract: string; // Plain text summary (2-3 paragraphs)
|
||||
url: string; // Full Wikipedia article URL
|
||||
fetchedAt: number; // Unix timestamp when fetched
|
||||
expiresAt: number; // Unix timestamp for cache expiration
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted training session (localStorage)
|
||||
* Only active sessions are persisted
|
||||
*/
|
||||
export type PersistedTrainingSession = Omit<TrainingSession, 'status'> & {
|
||||
status: 'active';
|
||||
};
|
||||
Reference in New Issue
Block a user