Skip to content
Merged
2 changes: 2 additions & 0 deletions src/hooks/useAnalysisController/useAnalysisController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const useAnalysisController = (
game: AnalyzedGame,
initialOrientation?: 'white' | 'black',
enableAutoSave = true,
enableEngineAnalysis = true,
) => {
const defaultOrientation = initialOrientation
? initialOrientation
Expand Down Expand Up @@ -86,6 +87,7 @@ export const useAnalysisController = (
deepAnalysisController.progress.isAnalyzing
? deepAnalysisController.config.targetDepth
: 18,
enableEngineAnalysis,
)

const availableMoves = useMemo(() => {
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useAnalysisController/useEngineAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const useEngineAnalysis = (
currentMaiaModel: string,
setAnalysisState: React.Dispatch<React.SetStateAction<number>>,
targetDepth = 18,
enabled = true,
) => {
const maia = useContext(MaiaEngineContext)
const stockfish = useContext(StockfishEngineContext)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -163,10 +164,11 @@ export const useEngineAnalysis = (
inProgressAnalyses,
maia,
setAnalysisState,
enabled,
])

useEffect(() => {
if (!currentNode) return
if (!currentNode || !enabled) return

const shouldForceStockfishRerun =
stockfishDebugRerunToken > lastConsumedStockfishRerunTokenRef.current
Expand Down Expand Up @@ -281,5 +283,6 @@ export const useEngineAnalysis = (
stockfishDebugRerunToken,
currentNode?.analysis.maia?.[currentMaiaModel]?.policy,
currentNode?.mainChild?.move,
enabled,
])
}
122 changes: 77 additions & 45 deletions src/hooks/useOpeningDrillController/useOpeningDrillController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>(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<void>>(Promise.resolve())
const bgAnalyzedFensRef = useRef<Set<string>>(new Set())
const bgCancelledRef = useRef(false)
const bgDrillIdRef = useRef<string | null>(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
Expand All @@ -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<boolean> => {
// 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
Expand Down Expand Up @@ -1036,7 +1063,12 @@ export const useOpeningDrillController = (

return !wasCancelled
},
[ensureMaiaForNode, ensureStockfishForNode, setDrillAnalysisProgress],
[
ensureMaiaForNode,
ensureStockfishForNode,
setDrillAnalysisProgress,
stopBackgroundAnalysis,
],
)

const cancelDrillAnalysis = useCallback(() => {
Expand Down
18 changes: 15 additions & 3 deletions src/pages/openings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading