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>
This commit is contained in:
Stefan
2025-12-08 15:05:05 +01:00
parent 1e78d8114e
commit 8ed402bbe0
34 changed files with 111269 additions and 480 deletions
+141 -16
View File
@@ -1,19 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { Chess } from 'chess.js';
import { Chessboard } from 'react-chessboard';
import { OpeningMetadata } from '@/lib/openings';
import { useOpeningTraining } from '@/contexts/OpeningTrainingContext';
import { useOpeningTraining, useChessInstance } from '@/contexts/OpeningTrainingContext';
import { loadSession } from '@/lib/openingTrainer/sessionManager';
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/repertoireNavigation';
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/gameLogic';
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
import WikipediaSummary from './WikipediaSummary';
import DeviationDialog from './DeviationDialog';
import { Tutor } from '@/components/Tutor';
import { Personality } from '@/lib/personalities';
import { SupportedLanguage } from '@/lib/i18n/translations';
import { useRouter } from 'next/navigation';
interface OpeningTrainerProps {
opening: OpeningMetadata;
@@ -23,9 +25,10 @@ interface OpeningTrainerProps {
}
export default function OpeningTrainer({ opening, personality, apiKey, language }: OpeningTrainerProps) {
const router = useRouter();
const {
session,
chess,
stockfish,
currentFeedback,
initializeSession,
@@ -33,6 +36,9 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
navigateToMove,
} = useOpeningTraining();
// Get Chess instance on-demand from current FEN
const chess = useChessInstance(session);
const [boardOrientation, setBoardOrientation] = useState<'white' | 'black'>(
'white'
);
@@ -42,6 +48,51 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
const [existingSession, setExistingSession] = useState<any>(null);
const [wikipediaSummary, setWikipediaSummary] = useState<WikipediaSummaryType | null>(null);
// Tutor message control - track when tutor last spoke
const [lastTutorMessageMoveIndex, setLastTutorMessageMoveIndex] = useState<number>(-1);
// Deviation handling
const [showDeviationDialog, setShowDeviationDialog] = useState(false);
// ============================================================================
// Tutor Message Guardrail (computed values - must be before early returns)
// ============================================================================
const moveCount = session?.moveHistory.length ?? 0;
const userColor = getUserColor(opening);
// Determine when tutor should be allowed to speak
const shouldTutorSpeak = useMemo(() => {
if (!session || moveCount === 0) {
// At start, tutor can give initial greeting
return lastTutorMessageMoveIndex === -1;
}
// Check if we've had new moves since tutor last spoke
const newMovesSinceLastMessage = moveCount - lastTutorMessageMoveIndex;
if (session.deviationMoveIndex !== null) {
// Off-book: Tutor speaks immediately after player's deviation
return newMovesSinceLastMessage >= 1;
}
// In theory: Wait for both player AND opponent to move
const isAtEndOfRepertoire = session.phase === 'end_of_repertoire';
const movesNeeded = isAtEndOfRepertoire ? 1 : 2;
return newMovesSinceLastMessage >= movesNeeded;
}, [session, moveCount, lastTutorMessageMoveIndex]);
// Detect deviation and show dialog
useEffect(() => {
if (session?.deviationMoveIndex !== null && !showDeviationDialog) {
const timer = setTimeout(() => {
setShowDeviationDialog(true);
}, 500);
return () => clearTimeout(timer);
}
}, [session?.deviationMoveIndex, showDeviationDialog]);
useEffect(() => {
checkForExistingSession();
}, [opening.eco]);
@@ -120,12 +171,19 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
const testChess = new Chess();
testChess.loadPgn(chess.pgn());
// Try to make the move on the clone
const move = testChess.move({
from: sourceSquare,
to: targetSquare,
promotion: 'q', // Always promote to queen for simplicity
});
// Try to make the move on the clone (this can throw for invalid moves)
let move;
try {
move = testChess.move({
from: sourceSquare,
to: targetSquare,
promotion: 'q', // Always promote to queen for simplicity
});
} catch (moveError) {
// Invalid move format or illegal move - silently reject
console.log('Invalid move attempt:', { from: sourceSquare, to: targetSquare });
return false;
}
if (move === null) {
// Illegal move
@@ -136,11 +194,69 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
makeMove(move.san);
return true;
} catch (error) {
console.error('Move error:', error);
console.error('Unexpected error in handlePieceDrop:', error);
return false;
}
};
// ============================================================================
// Deviation Dialog Handlers
// ============================================================================
const handleUndoDeviation = () => {
if (!session || session.deviationMoveIndex === null) return;
// Navigate back to the move before deviation
navigateToMove(session.deviationMoveIndex - 1);
setShowDeviationDialog(false);
// After a brief delay, make another legal move to continue in theory
// This allows the player to try again
};
const handleStartGameFromPosition = () => {
if (!session || !chess) return;
// Store the game start data in localStorage with the expected key
// The home page (/) will pick this up and start the game
const gameStartData = {
fen: session.currentFEN,
personalityId: personality.id,
color: getUserColor(opening),
stockfishDepth: 15,
};
localStorage.setItem('chess_tutor_pending_game', JSON.stringify(gameStartData));
// Optional: Store additional context for the tutor
const openingContext = {
openingName: opening.name,
openingEco: opening.eco,
movesCompleted: session.deviationMoveIndex || session.moveHistory.length,
wikipediaSummary: wikipediaSummary?.extract,
contextMessage: `You've studied the ${opening.name} (${opening.eco}) up to move ${
session.deviationMoveIndex || session.moveHistory.length
}. Let's continue playing from here!`,
};
localStorage.setItem('chess_tutor_opening_context', JSON.stringify(openingContext));
// Navigate to home page which will start the game
router.push('/');
};
const handleContinueExploring = () => {
// User wants to continue exploring off-book moves
// Just close the dialog and let them continue
setShowDeviationDialog(false);
};
const handleTutorMessageSent = () => {
// Called when tutor successfully sends a message
// Update the tracking to prevent rapid-fire messages
setLastTutorMessageMoveIndex(moveCount);
};
// Session recovery dialog
if (showRecoveryDialog && existingSession) {
return (
@@ -228,10 +344,6 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
}
const currentPosition = chess.fen();
const moveCount = session.moveHistory.length;
// Determine user's color based on opening ECO code
const userColor = getUserColor(opening);
// Build opening practice mode prop for Tutor
const repertoireMoves = parseMoveSequence(opening.moves);
@@ -285,7 +397,9 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
evaluationChange: currentFeedback.classification.evaluationChange,
theoreticalAlternatives: currentFeedback.classification.theoreticalAlternatives
} : null,
wikipediaSummary: wikipediaSummary?.extract || undefined
wikipediaSummary: wikipediaSummary?.extract || undefined,
shouldTutorSpeak,
onTutorMessageSent: handleTutorMessageSent,
};
return (
@@ -466,6 +580,17 @@ export default function OpeningTrainer({ opening, personality, apiKey, language
)}
</div>
</div>
{/* Deviation Dialog */}
{showDeviationDialog && session?.deviationMoveIndex !== null && (
<DeviationDialog
openingName={opening.name}
movesCompleted={session.deviationMoveIndex}
onUndo={handleUndoDeviation}
onStartGame={handleStartGameFromPosition}
onContinueExploring={handleContinueExploring}
/>
)}
</div>
);
}