diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx index 1447afd6..7cb0808c 100644 --- a/src/components/Analysis/ConfigurableScreens.tsx +++ b/src/components/Analysis/ConfigurableScreens.tsx @@ -20,6 +20,7 @@ interface Props { onDeleteCustomGame?: () => void onAnalyzeEntireGame?: () => void onLearnFromMistakes?: () => void + onDrillFromPosition?: () => void isAnalysisInProgress?: boolean isLearnFromMistakesActive?: boolean autoSave?: { @@ -51,6 +52,7 @@ export const ConfigurableScreens: React.FC = ({ onDeleteCustomGame, onAnalyzeEntireGame, onLearnFromMistakes, + onDrillFromPosition, isAnalysisInProgress, isLearnFromMistakesActive, autoSave, @@ -161,6 +163,7 @@ export const ConfigurableScreens: React.FC = ({ onDeleteCustomGame={onDeleteCustomGame} onAnalyzeEntireGame={onAnalyzeEntireGame} onLearnFromMistakes={onLearnFromMistakes} + onDrillFromPosition={onDrillFromPosition} isAnalysisInProgress={isAnalysisInProgress} isLearnFromMistakesActive={isLearnFromMistakesActive} autoSave={autoSave} diff --git a/src/components/Analysis/ConfigureAnalysis.tsx b/src/components/Analysis/ConfigureAnalysis.tsx index 601137bb..ef8cfeaf 100644 --- a/src/components/Analysis/ConfigureAnalysis.tsx +++ b/src/components/Analysis/ConfigureAnalysis.tsx @@ -12,6 +12,7 @@ interface Props { onDeleteCustomGame?: () => void onAnalyzeEntireGame?: () => void onLearnFromMistakes?: () => void + onDrillFromPosition?: () => void isAnalysisInProgress?: boolean isLearnFromMistakesActive?: boolean autoSave?: { @@ -30,6 +31,7 @@ export const ConfigureAnalysis: React.FC = ({ onDeleteCustomGame, onAnalyzeEntireGame, onLearnFromMistakes, + onDrillFromPosition, isAnalysisInProgress = false, isLearnFromMistakesActive = false, autoSave, @@ -90,6 +92,17 @@ export const ConfigureAnalysis: React.FC = ({ )} + {onDrillFromPosition && ( + + )} {autoSave && game.type !== 'custom-pgn' && game.type !== 'custom-fen' && diff --git a/src/components/Analysis/DrillFromPositionModal.tsx b/src/components/Analysis/DrillFromPositionModal.tsx new file mode 100644 index 00000000..283a4c3e --- /dev/null +++ b/src/components/Analysis/DrillFromPositionModal.tsx @@ -0,0 +1,273 @@ +import React, { useState, useMemo } from 'react' +import { MAIA_MODELS_WITH_NAMES } from 'src/constants/common' +import { GameBoard } from 'src/components/Board' +import { GameNode } from 'src/types' + +interface DrillFromPositionConfig { + maiaVersion: string + targetMoveNumber: number + drillCount: number + playerColor: 'white' | 'black' + position: { + fen: string + turn: string + pgn: string + } +} + +interface Props { + isOpen: boolean + onClose: () => void + onConfirm: (config: DrillFromPositionConfig) => void + currentNode: GameNode + initialPgn: string +} + +export const DrillFromPositionModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + currentNode, + initialPgn, +}) => { + // Initialize with detected player color from current position + const playerColor = useMemo(() => { + return currentNode.turn === 'w' ? 'white' : 'black' + }, [currentNode.turn]) + + const [selectedMaiaVersion, setSelectedMaiaVersion] = useState( + MAIA_MODELS_WITH_NAMES[4], // Default to Maia 1500 + ) + const [targetMoveNumber, setTargetMoveNumber] = useState(10) + const [drillCount, setDrillCount] = useState(3) + + const handleConfirm = () => { + const config: DrillFromPositionConfig = { + maiaVersion: selectedMaiaVersion.id, + targetMoveNumber, + drillCount, + playerColor, + position: { + fen: currentNode.fen, + turn: currentNode.turn || 'w', + pgn: initialPgn, + }, + } + onConfirm(config) + } + + if (!isOpen) return null + + return ( +
+
+ {/* Header */} +
+
+

+ Configure Drill from Position +

+

+ Set up your practice session from the current analysis position +

+
+ +
+ + {/* Content */} +
+ {/* Left Panel - Configuration Options */} +
+
+ {/* Maia Engine Strength */} +
+ + +

+ Choose the AI opponent strength (1100-1900 rating) +

+
+ + {/* Target Move Count */} +
+ + + setTargetMoveNumber(parseInt(e.target.value)) + } + className="w-full accent-human-4" + /> +
+ 5 moves + 20 moves +
+

+ How many moves to play in each drill session +

+
+ + {/* Number of Drills */} +
+ + setDrillCount(parseInt(e.target.value))} + className="w-full accent-human-4" + /> +
+ 1 drill + 10 drills +
+

+ Total number of practice sessions from this position +

+
+ + {/* Player Color Info */} +
+

+ Player Color +

+
+
+ + Playing as {playerColor} (to move in this position) + +
+

+ You'll practice from this position as the player to move +

+
+
+ + {/* Action Buttons */} +
+ + +
+
+ + {/* Right Panel - Position Preview */} +
+

+ Position Preview +

+ {/* Board Container */} +
+
+ +
+
+ + {/* Position Info */} +
+
+ Position: + + {currentNode.fen.split(' ').slice(0, 2).join(' ')} + +
+
+ To move: + + {currentNode.turn === 'w' ? 'White' : 'Black'} + +
+ {currentNode.san && ( +
+ Last move: + {currentNode.san} +
+ )} +
+ + {/* Drill Summary */} +
+

+ Drill Summary +

+
+
+ • Play as {playerColor} against {selectedMaiaVersion.name} +
+
• {targetMoveNumber} moves per drill session
+
+ • {drillCount} total drill{drillCount !== 1 ? 's' : ''} +
+
• Practice from current analysis position
+
+
+
+
+
+
+ ) +} diff --git a/src/hooks/useOpeningDrillController/useOpeningDrillController.ts b/src/hooks/useOpeningDrillController/useOpeningDrillController.ts index 06c1be49..107efcba 100644 --- a/src/hooks/useOpeningDrillController/useOpeningDrillController.ts +++ b/src/hooks/useOpeningDrillController/useOpeningDrillController.ts @@ -169,7 +169,9 @@ export const useOpeningDrillController = ( setAnalysisProgress({ total: 0, completed: 0, currentMove: null }) + // Use custom FEN if available, otherwise default starting position const startingFen = + currentDrill.opening.fen || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' const gameTree = new GameTree(startingFen) diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 3d7df27c..d6070223 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -35,6 +35,7 @@ import { MovesByRating } from 'src/components/Analysis/MovesByRating' import { AnalysisGameList } from 'src/components/Analysis/AnalysisGameList' import { DownloadModelModal } from 'src/components/Common/DownloadModelModal' import { CustomAnalysisModal } from 'src/components/Analysis/CustomAnalysisModal' +import { DrillFromPositionModal } from 'src/components/Analysis/DrillFromPositionModal' import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens' import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal' import { AnalysisNotification } from 'src/components/Analysis/AnalysisNotification' @@ -377,6 +378,8 @@ const Analysis: React.FC = ({ >(null) const [showCustomModal, setShowCustomModal] = useState(false) const [showAnalysisConfigModal, setShowAnalysisConfigModal] = useState(false) + const [showDrillFromPositionModal, setShowDrillFromPositionModal] = + useState(false) const [refreshTrigger, setRefreshTrigger] = useState(0) const [analysisEnabled, setAnalysisEnabled] = useState(true) // Analysis enabled by default const [lastMoveResult, setLastMoveResult] = useState< @@ -462,6 +465,62 @@ const Analysis: React.FC = ({ setAnalysisEnabled(true) // Auto-enable analysis when stopping learn mode }, [controller.learnFromMistakes]) + const handleDrillFromPosition = useCallback(() => { + // Show the drill configuration modal instead of direct navigation + setShowDrillFromPositionModal(true) + }, []) + + const getCurrentPgn = useCallback(() => { + const path = controller.currentNode?.getPath() + let pgn = '' + path?.forEach((node, index) => { + if (index === 0) return // Skip the root node + const moveIndex = index - 1 + if (moveIndex % 2 === 0) { + pgn += `${Math.floor(moveIndex / 2) + 1}. ` + } + pgn += node.san + ' ' + }) + return pgn.trim() + }, [controller.currentNode]) + + const handleDrillFromPositionConfirm = useCallback( + (config: { + maiaVersion: string + targetMoveNumber: number + drillCount: number + playerColor: 'white' | 'black' + position: { + fen: string + turn: string + pgn: string + } + }) => { + // Close the modal + setShowDrillFromPositionModal(false) + + // Navigate to openings page with user configuration + const url = + '/openings?mode=drill-from-position&fen=' + + encodeURIComponent(config.position.fen) + + '&turn=' + + encodeURIComponent(config.position.turn) + + '&pgn=' + + encodeURIComponent(config.position.pgn) + + '&maiaVersion=' + + encodeURIComponent(config.maiaVersion) + + '&targetMoveNumber=' + + encodeURIComponent(config.targetMoveNumber.toString()) + + '&drillCount=' + + encodeURIComponent(config.drillCount.toString()) + + '&playerColor=' + + encodeURIComponent(config.playerColor) + + router.push(url) + }, + [router], + ) + const handleShowSolution = useCallback(() => { controller.learnFromMistakes.showSolution() setAnalysisEnabled(true) // Auto-enable analysis when showing solution @@ -921,6 +980,7 @@ const Analysis: React.FC = ({ onDeleteCustomGame={handleDeleteCustomGame} onAnalyzeEntireGame={handleAnalyzeEntireGame} onLearnFromMistakes={handleLearnFromMistakes} + onDrillFromPosition={handleDrillFromPosition} isAnalysisInProgress={controller.gameAnalysis.progress.isAnalyzing} isLearnFromMistakesActive={ controller.learnFromMistakes.state.isActive @@ -1446,6 +1506,17 @@ const Analysis: React.FC = ({ )} + + {showDrillFromPositionModal && controller.currentNode && ( + setShowDrillFromPositionModal(false)} + onConfirm={handleDrillFromPositionConfirm} + currentNode={controller.currentNode} + initialPgn={getCurrentPgn()} + /> + )} + ) } diff --git a/src/pages/openings/index.tsx b/src/pages/openings/index.tsx index 2f84d616..fba978b8 100644 --- a/src/pages/openings/index.tsx +++ b/src/pages/openings/index.tsx @@ -21,9 +21,10 @@ import { AuthContext, MaiaEngineContext, } from 'src/contexts' -import { DrillConfiguration, AnalyzedGame } from 'src/types' +import { DrillConfiguration, AnalyzedGame, OpeningSelection } from 'src/types' import { GameNode } from 'src/types/base/tree' import { MIN_STOCKFISH_DEPTH } from 'src/constants/analysis' +import { MAIA_MODELS } from 'src/constants/common' import openings from 'src/lib/openings/openings.json' const LazyOpeningDrillAnalysis = lazy(() => @@ -59,7 +60,21 @@ import { const OpeningsPage: NextPage = () => { const router = useRouter() const { user } = useContext(AuthContext) - const [showSelectionModal, setShowSelectionModal] = useState(true) + const { + mode, + fen, + turn, + pgn, + maiaVersion, + targetMoveNumber, + drillCount, + playerColor, + } = router.query // Extract all drill configuration parameters from query + + const isDrillFromPosition = mode === 'drill-from-position' && fen + + const [showSelectionModal, setShowSelectionModal] = + useState(!isDrillFromPosition) const [isReopenedModal, setIsReopenedModal] = useState(false) const handleCloseModal = () => { @@ -69,8 +84,86 @@ const OpeningsPage: NextPage = () => { router.push('/') } } + + // Create drill configuration for drill-from-position mode + const createCustomDrillConfiguration = useCallback( + ( + fenPosition: string, + turn: string, + pgn: string, + maiaVersionParam?: string, + targetMoveNumberParam?: string, + drillCountParam?: string, + playerColorParam?: string, + ): DrillConfiguration => { + // Use provided parameters or fall back to defaults + const selectedMaiaVersion = maiaVersionParam || MAIA_MODELS[0] + const selectedTargetMoveNumber = targetMoveNumberParam + ? parseInt(targetMoveNumberParam, 10) + : 10 + const selectedDrillCount = drillCountParam + ? parseInt(drillCountParam, 10) + : 1 + const selectedPlayerColor = + (playerColorParam as 'white' | 'black') || + (turn === 'b' ? 'black' : 'white') + + const customSelection: OpeningSelection = { + id: `custom-position-${Date.now()}`, + opening: { + id: 'custom', + name: 'Custom Position', + description: 'Drill from analysis position', + fen: fenPosition, // Use the custom FEN as starting position + pgn: pgn, // Use the provided PGN + variations: [], + }, + variation: null, + playerColor: selectedPlayerColor, + maiaVersion: selectedMaiaVersion, + targetMoveNumber: selectedTargetMoveNumber, + } + + // Generate drill sequence with the requested number of drills + const drillSequence: OpeningSelection[] = [] + for (let i = 0; i < selectedDrillCount; i++) { + drillSequence.push({ + ...customSelection, + id: `custom-position-${Date.now()}-${i}`, // Unique ID for each drill + }) + } + + return { + selections: [customSelection], + drillCount: selectedDrillCount, + drillSequence: drillSequence, + } + }, + [], + ) + + // Initialize drill configuration based on mode const [drillConfiguration, setDrillConfiguration] = - useState(null) + useState(() => { + if ( + isDrillFromPosition && + typeof fen === 'string' && + typeof turn === 'string' && + typeof pgn === 'string' + ) { + return createCustomDrillConfiguration( + fen, + turn, + pgn, + typeof maiaVersion === 'string' ? maiaVersion : undefined, + typeof targetMoveNumber === 'string' ? targetMoveNumber : undefined, + typeof drillCount === 'string' ? drillCount : undefined, + typeof playerColor === 'string' ? playerColor : undefined, + ) + } + return null + }) + const [promotionFromTo, setPromotionFromTo] = useState< [string, string] | null >(null) @@ -658,9 +751,10 @@ const OpeningsPage: NextPage = () => { // Show selection modal when no drill configuration exists (after model is ready) if ( - !drillConfiguration || - drillConfiguration.selections.length === 0 || - showSelectionModal + (!drillConfiguration || + drillConfiguration.selections.length === 0 || + showSelectionModal) && + !isDrillFromPosition // Don't show selection modal in drill-from-position mode ) { return ( <> @@ -1072,10 +1166,18 @@ const OpeningsPage: NextPage = () => { return ( <> - Opening Drills – Maia Chess + + {isDrillFromPosition + ? 'Drill from Position – Maia Chess' + : 'Opening Drills – Maia Chess'} +