diff --git a/src/hooks/useAnalysisController/useAnalysisController.ts b/src/hooks/useAnalysisController/useAnalysisController.ts index d20c1216..ce594f0b 100644 --- a/src/hooks/useAnalysisController/useAnalysisController.ts +++ b/src/hooks/useAnalysisController/useAnalysisController.ts @@ -26,6 +26,7 @@ export const useAnalysisController = ( game: AnalyzedGame, initialOrientation?: 'white' | 'black', enableAutoSave = true, + enableEngineAnalysis = true, ) => { const defaultOrientation = initialOrientation ? initialOrientation @@ -86,6 +87,7 @@ export const useAnalysisController = ( deepAnalysisController.progress.isAnalyzing ? deepAnalysisController.config.targetDepth : 18, + enableEngineAnalysis, ) const availableMoves = useMemo(() => { diff --git a/src/hooks/useAnalysisController/useEngineAnalysis.ts b/src/hooks/useAnalysisController/useEngineAnalysis.ts index 6d4c895a..1b078199 100644 --- a/src/hooks/useAnalysisController/useEngineAnalysis.ts +++ b/src/hooks/useAnalysisController/useEngineAnalysis.ts @@ -15,6 +15,7 @@ export const useEngineAnalysis = ( currentMaiaModel: string, setAnalysisState: React.Dispatch>, targetDepth = 18, + enabled = true, ) => { const maia = useContext(MaiaEngineContext) const stockfish = useContext(StockfishEngineContext) @@ -78,7 +79,7 @@ export const useEngineAnalysis = ( } useEffect(() => { - if (!currentNode) return + if (!currentNode || !enabled) return const board = new Chess(currentNode.fen) const nodeFen = currentNode.fen @@ -163,10 +164,11 @@ export const useEngineAnalysis = ( inProgressAnalyses, maia, setAnalysisState, + enabled, ]) useEffect(() => { - if (!currentNode) return + if (!currentNode || !enabled) return const shouldForceStockfishRerun = stockfishDebugRerunToken > lastConsumedStockfishRerunTokenRef.current @@ -281,5 +283,6 @@ export const useEngineAnalysis = ( stockfishDebugRerunToken, currentNode?.analysis.maia?.[currentMaiaModel]?.policy, currentNode?.mainChild?.move, + enabled, ]) } diff --git a/src/hooks/useOpeningDrillController/useOpeningDrillController.ts b/src/hooks/useOpeningDrillController/useOpeningDrillController.ts index 9dab8267..f092b69e 100644 --- a/src/hooks/useOpeningDrillController/useOpeningDrillController.ts +++ b/src/hooks/useOpeningDrillController/useOpeningDrillController.ts @@ -903,13 +903,40 @@ export const useOpeningDrillController = ( [currentMaiaModel, stockfish], ) - // Background analysis: run Maia + Stockfish on positions as they are played - // so that post-drill analysis is already complete (or nearly so) when the - // drill ends. - const backgroundAnalysisQueueRef = useRef>(new Set()) + // Background analysis: run Maia + Stockfish on drill positions as they are + // played so post-drill analysis has less (or no) work to do. + // + // Uses a simple promise-chain pattern: each node's analysis is chained onto + // a single promise ref. No queue management, no running flags — just append + // work to the chain. Nodes are tracked by FEN in a Set to avoid duplicates. + const bgChainRef = useRef>(Promise.resolve()) + const bgAnalyzedFensRef = useRef>(new Set()) + const bgCancelledRef = useRef(false) + const bgDrillIdRef = useRef(null) + const ensureMaiaRef = useRef(ensureMaiaForNode) + const ensureStockfishRef = useRef(ensureStockfishForNode) + useEffect(() => { + ensureMaiaRef.current = ensureMaiaForNode + }, [ensureMaiaForNode]) + useEffect(() => { + ensureStockfishRef.current = ensureStockfishForNode + }, [ensureStockfishForNode]) useEffect(() => { - if (!currentDrillGame || isAnalyzingDrill) return + if (!currentDrillGame || isAnalyzingDrill) { + return + } + + // If drill changed, reset for the new drill + if (currentDrillGame.id !== bgDrillIdRef.current) { + bgCancelledRef.current = true + // Let any in-flight work finish with the cancelled flag, then reset + bgChainRef.current = bgChainRef.current.then(() => { + bgCancelledRef.current = false + }) + bgAnalyzedFensRef.current = new Set() + bgDrillIdRef.current = currentDrillGame.id + } const mainLine = gameTree.getMainLine() const openingEndNode = currentDrillGame.openingEndNode @@ -918,50 +945,50 @@ export const useOpeningDrillController = ( : 0 const drillNodes = mainLine.slice(startIndex) - // Find nodes that still need analysis and haven't been queued yet - const nodesNeedingWork = drillNodes.filter((node) => { - if (backgroundAnalysisQueueRef.current.has(node.fen)) return false - const hasMaia = - node.analysis.maia && - MAIA_MODELS.every((model) => node.analysis.maia?.[model]) - const hasStockfish = - node.analysis.stockfish && - node.analysis.stockfish.depth >= DRILL_STOCKFISH_TARGET_DEPTH - return !hasMaia || !hasStockfish - }) - - if (nodesNeedingWork.length === 0) return - - let cancelled = false - - const runBackgroundAnalysis = async () => { - for (const node of nodesNeedingWork) { - if (cancelled) break - backgroundAnalysisQueueRef.current.add(node.fen) - await ensureMaiaForNode(node) - if (cancelled) break - // Let Stockfish finish its current evaluation even if new moves come in - await ensureStockfishForNode(node) - } - } - - runBackgroundAnalysis() + for (const node of drillNodes) { + if (bgAnalyzedFensRef.current.has(node.fen)) continue + bgAnalyzedFensRef.current.add(node.fen) - return () => { - cancelled = true + // Chain this node's analysis onto the promise chain. + // Wrapped in try/catch so one failure doesn't break the whole chain. + bgChainRef.current = bgChainRef.current.then(async () => { + try { + if (bgCancelledRef.current) return + console.log('[bg] maia start:', node.san || node.move || '?') + await ensureMaiaRef.current(node) + const hasMaia = !!( + node.analysis.maia && + MAIA_MODELS.every((m) => node.analysis.maia?.[m]) + ) + console.log('[bg] maia done:', hasMaia, '| sf start') + if (bgCancelledRef.current) return + await ensureStockfishRef.current(node) + console.log( + '[bg] sf done, depth:', + node.analysis.stockfish?.depth ?? 0, + ) + } catch (error) { + console.error('[bg] error analyzing node:', error) + } + }) } - }, [ - currentDrillGame, - gameTree, - isAnalyzingDrill, - ensureMaiaForNode, - ensureStockfishForNode, - // Re-run when tree grows (new moves played) - treeController.currentNode, - ]) + }, [currentDrillGame, gameTree, isAnalyzingDrill, treeController.currentNode]) + + // Stop background analysis. Signals cancellation and stops stockfish so + // ensureDrillAnalysis can use stockfish immediately. The chain's remaining + // .then() callbacks will see the cancelled flag and return quickly. + // Does NOT await the chain — avoids hanging if a step is stuck. + const stopBackgroundAnalysis = useCallback(() => { + bgCancelledRef.current = true + stockfish.stopEvaluation() + }, [stockfish]) const ensureDrillAnalysis = useCallback( async (drillGame: OpeningDrillGame): Promise => { + // Signal background to stop and give the generator a tick to clean up + stopBackgroundAnalysis() + await delay(50) + const mainLine = drillGame.tree.getMainLine() const startingNode = drillGame.openingEndNode || mainLine[0] const startIndex = startingNode @@ -1036,7 +1063,12 @@ export const useOpeningDrillController = ( return !wasCancelled }, - [ensureMaiaForNode, ensureStockfishForNode, setDrillAnalysisProgress], + [ + ensureMaiaForNode, + ensureStockfishForNode, + setDrillAnalysisProgress, + stopBackgroundAnalysis, + ], ) const cancelDrillAnalysis = useCallback(() => { diff --git a/src/pages/openings/index.tsx b/src/pages/openings/index.tsx index ee2d2cc9..52c97ff9 100644 --- a/src/pages/openings/index.tsx +++ b/src/pages/openings/index.tsx @@ -180,14 +180,26 @@ const OpeningsPage: NextPage = () => { }, controller.currentDrill?.playerColor || 'white', false, // Disable auto-saving on openings page + controller.analysisEnabled || controller.continueAnalyzingMode, // Disable engine analysis during drill play ) - // Sync analysis controller with current node + // Sync analysis controller with current node — only when analysis is active + // (post-drill continue-analyzing mode). During drill play, the analysis + // controller's auto-stockfish would conflict with background analysis. useEffect(() => { - if (controller.currentNode && analysisController.setCurrentNode) { + if ( + controller.currentNode && + analysisController.setCurrentNode && + (controller.analysisEnabled || controller.continueAnalyzingMode) + ) { analysisController.setCurrentNode(controller.currentNode) } - }, [controller.currentNode, analysisController.setCurrentNode]) + }, [ + controller.currentNode, + controller.analysisEnabled, + controller.continueAnalyzingMode, + analysisController.setCurrentNode, + ]) // Create game object for MovesContainer const gameForContainer = useMemo(() => {