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:
Stefan
2025-12-07 18:40:58 +01:00
parent 52dd32eb15
commit 002ed92bea
57 changed files with 7015 additions and 234280 deletions
+592
View File
@@ -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;
}