002ed92bea
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>
224 lines
6.3 KiB
TypeScript
224 lines
6.3 KiB
TypeScript
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);
|
|
}
|