8ed402bbe0
- Add tutor message guardrail to prevent rapid-fire messages - Track last message by move index - Wait for both player and opponent moves before speaking - Speak immediately when player deviates from theory - Add deviation dialog with three options: - Continue Playing (Start Game) - transitions to game mode - Undo & Return to Opening - returns to theory - Explore This Variation - continues off-book practice - Implement smooth game mode transition: - Pass opening context from trainer to game - Tutor welcomes player with context about their study - Computer makes first move if needed in starting position - Fix generate-test-fixtures script: - Add safety check to prevent overwriting real data - Restore full opening database (12,379 openings) - Rebuild move index (12,377 sequences) - Add clear warnings about test fixtures - Update ChessGame to accept openingContext prop - Update Tutor to display contextual greeting in game mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
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);
|
|
}
|