Files
chess-project/src/lib/openingTrainer/gameLogic.ts
T
Stefan 8ed402bbe0 feat: improve opening trainer flow and add deviation dialog
- 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>
2025-12-08 15:05:05 +01:00

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);
}