import { Chess, Move } from 'chess.js'; import { OpeningMetadata } from '@/lib/openings'; import { StockfishEvaluation } from '@/lib/stockfish'; import { MoveHistoryEntry, MoveCategory, MoveFeedbackClassification } from '@/types/openingTraining'; import { STARTING_FEN, MOVE_CATEGORIZATION_THRESHOLDS } from './constants'; /** * Pure Business Logic for Opening Training * * This module contains all the core chess logic WITHOUT any React dependencies. * All functions are pure (no side effects) and can be unit tested in isolation. */ // ============================================================================ // Move Validation & Execution // ============================================================================ export interface MoveResult { san: string; uci: string; from: string; to: string; promotion?: string; color: 'w' | 'b'; piece: string; captured?: string; flags: string; } /** * Validate and execute a move on a given position * Returns move details if legal, null if illegal * * PURE FUNCTION: Creates a temporary chess instance, does not mutate anything */ export function validateMove(fen: string, san: string): MoveResult | null { const chess = new Chess(fen); try { const move = chess.move(san); if (!move) return null; return { san: move.san, uci: move.from + move.to + (move.promotion || ''), from: move.from, to: move.to, promotion: move.promotion, color: move.color, piece: move.piece, captured: move.captured, flags: move.flags, }; } catch (error) { // chess.move() can throw on invalid input return null; } } /** * Apply a move to a position and return the new FEN * * PURE FUNCTION: Creates a new chess instance, does not mutate anything */ export function applyMove(fen: string, san: string): { newFen: string; move: MoveResult } | null { const moveResult = validateMove(fen, san); if (!moveResult) return null; const chess = new Chess(fen); chess.move(san); return { newFen: chess.fen(), move: moveResult, }; } /** * Build FEN by undoing moves to a specific index in history * Returns STARTING_FEN if targetIndex is 0 * * PURE FUNCTION: Replays moves from scratch */ export function buildFenAtIndex(moveHistory: MoveHistoryEntry[], targetIndex: number): string { if (targetIndex === 0) return STARTING_FEN; if (targetIndex > moveHistory.length) return STARTING_FEN; // Use the FEN stored in the move history // moveHistory[0] is the FEN after the 1st move // moveHistory[1] is the FEN after the 2nd move, etc. return moveHistory[targetIndex - 1].fen; } // ============================================================================ // Opening Theory Logic // ============================================================================ /** * Parse a move sequence from an opening's moves string * Example: "1. e4 e5 2. Nf3" → ["e4", "e5", "Nf3"] */ export function parseMoveSequence(moveString: string): string[] { if (!moveString || moveString.trim() === '') return []; const cleaned = moveString .replace(/\d+\./g, '') // Remove move numbers .replace(/\s+/g, ' ') // Normalize whitespace .trim(); return cleaned.split(' ').filter((move) => move.length > 0); } /** * Check if the current position is still within opening theory * * @param opening - Opening metadata * @param moveHistory - Moves played so far * @param proposedMove - Optional: also check if this next move would be in theory */ export function isInTheory( opening: OpeningMetadata, moveHistory: MoveHistoryEntry[], proposedMove?: string ): boolean { const repertoireMoves = parseMoveSequence(opening.moves); const totalMoves = proposedMove ? moveHistory.length + 1 : moveHistory.length; // If we've played more moves than the repertoire has, we're past theory if (totalMoves > repertoireMoves.length) { return false; } // Check that all played moves match the repertoire for (let i = 0; i < moveHistory.length; i++) { if (moveHistory[i].san !== repertoireMoves[i]) { return false; } } // If checking a proposed move, verify it matches too if (proposedMove && totalMoves <= repertoireMoves.length) { return proposedMove === repertoireMoves[moveHistory.length]; } return true; } /** * Get the expected next move(s) from the repertoire at current position * For now, returns single main line move (no variants yet) * Future: could return multiple moves for different variations */ export function getExpectedNextMoves( opening: OpeningMetadata, currentMoveIndex: number ): string[] { const moves = parseMoveSequence(opening.moves); if (currentMoveIndex >= moves.length) { return []; } const nextMove = moves[currentMoveIndex]; return nextMove ? [nextMove] : []; } /** * Get all variant moves at current position * For MVP: same as getExpectedNextMoves (no variants stored in database yet) * Future: Parse variation syntax like "1. e4 (1. d4) e5" */ export function getAllVariantMoves( opening: OpeningMetadata, currentMoveIndex: number ): string[] { // MVP: No variant support yet, just return main line return getExpectedNextMoves(opening, currentMoveIndex); } /** * Detect if the current FEN has transposed into a different known opening * Future implementation: Would query opening database by FEN */ export function detectTransposition( fen: string, allOpenings: OpeningMetadata[] ): { opening: OpeningMetadata; moveIndex: number } | null { // TODO: Implement FEN-based opening lookup // For now, return null (no transposition detection) return null; } /** * Check if we've reached the end of the opening repertoire */ export function isEndOfRepertoire( opening: OpeningMetadata, currentMoveIndex: number ): boolean { const moves = parseMoveSequence(opening.moves); return currentMoveIndex >= moves.length; } // ============================================================================ // Move Classification // ============================================================================ /** * Classify a user's move based on repertoire matching and engine evaluation */ export function classifyMove( san: string, expectedMoves: string[], variantMoves: string[], evaluation: StockfishEvaluation, previousEvaluation: StockfishEvaluation ): { category: MoveCategory; evaluationChange: number; theoreticalAlternatives: string[]; isTransposition: boolean; newOpening?: OpeningMetadata; } { const isInRepertoire = expectedMoves.includes(san); const isVariant = !isInRepertoire && variantMoves.includes(san); // Calculate evaluation change (White's perspective) const evalChange = evaluation.score - previousEvaluation.score; // Determine category let category: MoveCategory; if (isInRepertoire) { category = 'in-theory'; } else { // Not in main repertoire - check if weak or playable const cpLoss = Math.abs(evalChange); if (cpLoss >= MOVE_CATEGORIZATION_THRESHOLDS.WEAK_MOVE_CP_LOSS) { category = 'weak'; } else { category = 'playable'; } } // Get theoretical alternatives (excluding the move just played) const alternatives = expectedMoves.filter(move => move !== san); return { category, evaluationChange: evalChange, theoreticalAlternatives: alternatives, isTransposition: false, // TODO: Implement transposition detection }; } // ============================================================================ // Turn & Color Logic // ============================================================================ /** * 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) */ export function getUserColor(opening: OpeningMetadata): 'white' | 'black' { const ecoLetter = opening.eco[0]; return ['A', 'B', 'C'].includes(ecoLetter) ? 'white' : 'black'; } /** * Check if it's the user's turn to move */ export function isUserTurn(opening: OpeningMetadata, moveHistory: MoveHistoryEntry[]): boolean { const userColor = getUserColor(opening); const moveIndex = moveHistory.length; const isWhiteMove = moveIndex % 2 === 0; return (userColor === 'white' && isWhiteMove) || (userColor === 'black' && !isWhiteMove); } /** * Check if it's the opponent's turn to move */ export function isOpponentTurn(opening: OpeningMetadata, moveHistory: MoveHistoryEntry[]): boolean { return !isUserTurn(opening, moveHistory); } /** * Determine if opponent should automatically make a move * Opponent auto-moves only if: * 1. It's their turn * 2. We're still in theory * 3. We haven't reached end of repertoire */ export function shouldOpponentAutoMove( opening: OpeningMetadata, moveHistory: MoveHistoryEntry[], deviationMoveIndex: number | null ): boolean { // If user has deviated from theory, no auto-moves if (deviationMoveIndex !== null) { return false; } // If at end of repertoire, no auto-moves if (isEndOfRepertoire(opening, moveHistory.length)) { return false; } // Must be opponent's turn if (!isOpponentTurn(opening, moveHistory)) { return false; } // Must still be in theory if (!isInTheory(opening, moveHistory)) { return false; } return true; } /** * Get the opponent's next move from the repertoire */ export function getOpponentNextMove( opening: OpeningMetadata, currentMoveIndex: number ): string | null { const moves = parseMoveSequence(opening.moves); if (currentMoveIndex >= moves.length) { return null; } return moves[currentMoveIndex]; } // ============================================================================ // Deviation Tracking // ============================================================================ /** * Find the index where the user first deviated from theory * Returns null if still in theory */ export function findDeviationPoint( opening: OpeningMetadata, moveHistory: MoveHistoryEntry[] ): number | null { const repertoireMoves = parseMoveSequence(opening.moves); for (let i = 0; i < moveHistory.length; i++) { // Skip opponent moves (we only care about user deviations) const userColor = getUserColor(opening); const moveColor = moveHistory[i].color; const isUserMove = (userColor === 'white' && moveColor === 'white') || (userColor === 'black' && moveColor === 'black'); if (!isUserMove) continue; // Check if this move matches theory if (i >= repertoireMoves.length || moveHistory[i].san !== repertoireMoves[i]) { return i; } } return null; } /** * Reset deviation tracking if user navigated back and is now in theory again * * @param currentDeviation - Current deviation index (or null) * @param navigationIndex - Index user navigated to * @param newMove - New move user is about to make * @param opening - Opening metadata * @param moveHistory - Current move history (before new move) * @returns Updated deviation index (null if back in theory) */ export function resetDeviationIfBackInTheory( currentDeviation: number | null, navigationIndex: number, newMove: string, opening: OpeningMetadata, moveHistory: MoveHistoryEntry[] ): number | null { // If we navigated before the deviation point if (currentDeviation !== null && navigationIndex < currentDeviation) { // Check if the new move is in theory if (isInTheory(opening, moveHistory, newMove)) { // Back in theory! Reset deviation return null; } } return currentDeviation; } // ============================================================================ // Wikipedia Integration // ============================================================================ /** * Extract the family name from a full opening name * Example: "French Defense: Winawer Variation, Advance" → "French Defense" */ export function extractOpeningFamily(openingName: string): string { // Split on colon to get the main family const parts = openingName.split(':'); return parts[0].trim(); } /** * Check if an opening has Wikipedia data available */ export function hasWikipediaPage(opening: OpeningMetadata): boolean { return !!opening.wikipediaSlug; } /** * Determine if Wikipedia context should be included in LLM prompts */ export function shouldUseWikipediaContext(opening: OpeningMetadata): boolean { // Use Wikipedia if available and not too obscure return hasWikipediaPage(opening); }