Skip to content

Commit 859f546

Browse files
feat: add text heuristic description of game board
1 parent c409b86 commit 859f546

File tree

3 files changed

+228
-1
lines changed

3 files changed

+228
-1
lines changed

Diff for: src/components/Analysis/Highlight.tsx

+36-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Tooltip } from 'react-tooltip'
2+
import { useState, useEffect, useRef } from 'react'
23
import { MovesByRating } from './MovesByRating'
4+
import { motion, AnimatePresence } from 'framer-motion'
35
import { MaiaEvaluation, StockfishEvaluation, ColorSanMapping } from 'src/types'
46

57
interface Props {
@@ -22,6 +24,7 @@ interface Props {
2224
hover: (move?: string) => void
2325
makeMove: (move: string) => void
2426
movesByRating: { [key: string]: number }[] | undefined
27+
boardDescription: string
2528
}
2629

2730
export const Highlight: React.FC<Props> = ({
@@ -32,6 +35,7 @@ export const Highlight: React.FC<Props> = ({
3235
colorSanMapping,
3336
recommendations,
3437
currentMaiaModel,
38+
boardDescription,
3539
}: Props) => {
3640
const findMatchingMove = (move: string, source: 'maia' | 'stockfish') => {
3741
if (source === 'maia') {
@@ -97,9 +101,22 @@ export const Highlight: React.FC<Props> = ({
97101
)
98102
}
99103

104+
// Track whether description exists (not its content)
105+
const hasDescriptionRef = useRef(!!boardDescription)
106+
const [animationKey, setAnimationKey] = useState(0)
107+
108+
useEffect(() => {
109+
const descriptionNowExists = !!boardDescription
110+
// Only trigger animation when presence changes (exists vs doesn't exist)
111+
if (hasDescriptionRef.current !== descriptionNowExists) {
112+
hasDescriptionRef.current = descriptionNowExists
113+
setAnimationKey((prev) => prev + 1)
114+
}
115+
}, [boardDescription])
116+
100117
return (
101118
<div className="flex h-full w-full flex-col items-start gap-1 overflow-hidden md:flex-row md:gap-0 md:rounded md:border-[0.5px] md:border-white/40">
102-
<div className="flex h-full w-full flex-col border-white/40 bg-background-1 md:w-auto md:min-w-[40%] md:border-r-[0.5px]">
119+
<div className="flex h-full w-full flex-col border-white/40 bg-background-1 md:w-auto md:min-w-[40%] md:max-w-[40%] md:border-r-[0.5px]">
103120
<div className="grid grid-cols-2">
104121
<div className="flex flex-col items-center justify-center gap-1 bg-human-3/5 py-2 md:py-3">
105122
<p className="text-center text-xs text-human-2 md:text-sm">
@@ -203,6 +220,24 @@ export const Highlight: React.FC<Props> = ({
203220
})}
204221
</div>
205222
</div>
223+
<div className="flex flex-col items-start justify-start gap-1 bg-background-1/80 px-3 py-1.5 text-sm">
224+
<AnimatePresence mode="wait">
225+
{boardDescription ? (
226+
<motion.div
227+
key={animationKey}
228+
initial={{ opacity: 0, y: 10 }}
229+
animate={{ opacity: 1, y: 0 }}
230+
exit={{ opacity: 0, y: -10 }}
231+
transition={{ duration: 0.1 }}
232+
className="w-full"
233+
>
234+
<p className="w-full whitespace-normal break-words text-xs text-secondary">
235+
{boardDescription}
236+
</p>
237+
</motion.div>
238+
) : null}
239+
</AnimatePresence>
240+
</div>
206241
</div>
207242
<div className="flex h-full w-full flex-col bg-background-1">
208243
<MovesByRating

Diff for: src/hooks/useAnalysisController/useAnalysisController.ts

+189
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,194 @@ export const useAnalysisController = (game: AnalyzedGame) => {
524524
return data
525525
}, [moveEvaluation])
526526

527+
const boardDescription = useMemo(() => {
528+
if (
529+
!controller.currentNode ||
530+
!moveEvaluation?.stockfish ||
531+
!moveEvaluation?.maia ||
532+
moveEvaluation.stockfish.depth < 12
533+
) {
534+
return ''
535+
}
536+
537+
const isBlackTurn = controller.currentNode.turn === 'b'
538+
const playerColor = isBlackTurn ? 'Black' : 'White'
539+
const opponent = isBlackTurn ? 'White' : 'Black'
540+
const stockfish = moveEvaluation.stockfish
541+
const maia = moveEvaluation.maia
542+
const topMaiaMove = Object.entries(maia.policy).sort(
543+
(a, b) => b[1] - a[1],
544+
)[0]
545+
const topStockfishMoves = Object.entries(stockfish.cp_vec)
546+
.sort((a, b) => (isBlackTurn ? a[1] - b[1] : b[1] - a[1]))
547+
.slice(0, 3)
548+
549+
const cp = stockfish.model_optimal_cp
550+
const absCP = Math.abs(cp)
551+
const cpAdvantage = cp > 0 ? 'White' : cp < 0 ? 'Black' : 'Neither player'
552+
const topStockfishMove = topStockfishMoves[0]
553+
554+
// Check if top Maia move matches top Stockfish move
555+
const maiaMatchesStockfish = topMaiaMove[0] === topStockfishMove[0]
556+
557+
// Get top few Maia moves and their cumulative probability
558+
const top3MaiaMoves = Object.entries(maia.policy)
559+
.sort((a, b) => b[1] - a[1])
560+
.slice(0, 3)
561+
const top3MaiaProbability =
562+
top3MaiaMoves.reduce((sum, [_, prob]) => sum + prob, 0) * 100
563+
564+
// Get second best moves to analyze move clarity
565+
const secondBestMaiaMove = top3MaiaMoves[1]
566+
const secondBestMaiaProbability = secondBestMaiaMove
567+
? secondBestMaiaMove[1] * 100
568+
: 0
569+
570+
// Calculate spread between first and second-best moves
571+
const probabilitySpread = topMaiaMove[1] * 100 - secondBestMaiaProbability
572+
573+
// Get move classifications
574+
const blunderProbability = blunderMeter.blunderMoves.probability
575+
const okProbability = blunderMeter.okMoves.probability
576+
const goodProbability = blunderMeter.goodMoves.probability
577+
578+
// Check for patterns in stockfish evaluation
579+
const stockfishTop3Spread =
580+
topStockfishMoves.length > 2
581+
? Math.abs(topStockfishMoves[0][1] - topStockfishMoves[2][1])
582+
: 0
583+
584+
// Get move spreads to detect sharp positions
585+
const moveCpSpread = Object.values(stockfish.cp_relative_vec).reduce(
586+
(maxDiff, cp, _, arr) => {
587+
const min = Math.min(...arr)
588+
const max = Math.max(...arr)
589+
return Math.max(maxDiff, max - min)
590+
},
591+
0,
592+
)
593+
594+
// Calculate position complexity based on distribution of move quality
595+
const isPositionComplicated =
596+
(blunderProbability > 30 && okProbability > 20 && goodProbability < 50) ||
597+
moveCpSpread > 300 ||
598+
stockfishTop3Spread > 100
599+
600+
// Check for tactical position
601+
const isTacticalPosition = moveCpSpread > 500 || stockfishTop3Spread > 150
602+
603+
// Check if there's a clear best move
604+
const topMaiaProbability = topMaiaMove[1] * 100
605+
const isClearBestMove = topMaiaProbability > 70 || probabilitySpread > 40
606+
607+
// Check if there are multiple equally good moves
608+
const hasMultipleGoodMoves =
609+
top3MaiaProbability > 75 && topMaiaProbability < 50
610+
611+
// Calculate agreement between Maia rating levels
612+
const maiaModelsAgree = Object.entries(
613+
controller.currentNode.analysis.maia || {},
614+
)
615+
.filter(([key]) => MAIA_MODELS.includes(key))
616+
.every(([_, evaluation]) => {
617+
const topMove = Object.entries(evaluation.policy).sort(
618+
(a, b) => b[1] - a[1],
619+
)[0]
620+
return topMove && topMove[0] === topMaiaMove[0]
621+
})
622+
623+
// Check if evaluation is decisive
624+
const isDecisiveAdvantage = absCP > 300
625+
const isOverwhelming = absCP > 800
626+
627+
// Check for high blunder probability
628+
const isBlunderProne = blunderProbability > 50
629+
const isVeryBlunderProne = blunderProbability > 70
630+
631+
// Check if there's forced play
632+
const isForcedPlay = topMaiaProbability > 85 && maiaMatchesStockfish
633+
634+
// Check if position is balanced but with complexity
635+
const isBalancedButComplex = absCP < 50 && isPositionComplicated
636+
637+
// Generate descriptions
638+
let evaluation = ''
639+
let suggestion = ''
640+
641+
// Evaluation description
642+
if (isOverwhelming) {
643+
evaluation = `${cpAdvantage} is completely winning and should convert without difficulty.`
644+
} else if (cp === 0) {
645+
evaluation = isBalancedButComplex
646+
? 'The position is balanced but filled with complications.'
647+
: 'The position is completely equal.'
648+
} else if (absCP < 30) {
649+
evaluation = `The evaluation is almost perfectly balanced with only the slightest edge for ${cpAdvantage}.`
650+
} else if (absCP < 80) {
651+
evaluation = `${cpAdvantage} has a slight but tangible advantage in this position.`
652+
} else if (absCP < 150) {
653+
evaluation = `${cpAdvantage} has a clear positional advantage that could be decisive with careful play.`
654+
} else if (absCP < 300) {
655+
evaluation = `${cpAdvantage} has a significant advantage that should be convertible with proper technique.`
656+
} else if (absCP < 500) {
657+
evaluation = `${cpAdvantage} has a winning position that only requires avoiding major blunders.`
658+
} else {
659+
evaluation = `${cpAdvantage} has a completely winning position that should be straightforward to convert.`
660+
}
661+
662+
// Suggestion/description of move quality
663+
if (isVeryBlunderProne) {
664+
suggestion = `This critical position is extremely treacherous with a ${blunderProbability.toFixed(0)}% chance of ${playerColor} making a significant error.`
665+
} else if (isBlunderProne && isTacticalPosition) {
666+
suggestion = `The sharp tactical nature of this position creates many opportunities for mistakes (${blunderProbability.toFixed(0)}% blunder chance).`
667+
} else if (isBlunderProne) {
668+
suggestion = `This position is quite treacherous with ${blunderProbability.toFixed(0)}% chance of ${playerColor} making a significant mistake.`
669+
} else if (isForcedPlay) {
670+
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
671+
suggestion = `${playerColor} must play ${moveSan}, as all other moves lead to a significantly worse position.`
672+
} else if (isTacticalPosition && maiaMatchesStockfish) {
673+
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
674+
suggestion = `The tactical complexity demands precision, with ${moveSan} being the only move that maintains the balance.`
675+
} else if (isPositionComplicated && hasMultipleGoodMoves) {
676+
suggestion = `This complex position offers several equally promising continuations for ${playerColor}.`
677+
} else if (isPositionComplicated) {
678+
suggestion = `This is a complex position requiring careful calculation of the many reasonable options.`
679+
} else if (isClearBestMove && maiaMatchesStockfish && maiaModelsAgree) {
680+
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
681+
suggestion = `Players of all levels agree ${moveSan} stands out as clearly best in this position.`
682+
} else if (isClearBestMove && maiaMatchesStockfish) {
683+
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
684+
suggestion = `${playerColor} should play ${moveSan}, which both human intuition and concrete calculation confirm as best.`
685+
} else if (isClearBestMove && maiaModelsAgree) {
686+
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
687+
suggestion = `Human players at all levels strongly prefer ${moveSan} (${topMaiaProbability.toFixed(0)}%), though the engine suggests otherwise.`
688+
} else if (isClearBestMove) {
689+
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
690+
suggestion = `Maia strongly suggests ${moveSan} (${topMaiaProbability.toFixed(0)}% likely), though Stockfish calculates a different approach.`
691+
} else if (goodProbability > 80) {
692+
suggestion = `This is a forgiving position where almost any reasonable move by ${playerColor} maintains the evaluation.`
693+
} else if (goodProbability > 60) {
694+
suggestion = `Most moves ${playerColor} is likely to consider will maintain the current position assessment.`
695+
} else if (maiaMatchesStockfish) {
696+
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
697+
suggestion = `Both human intuition and engine calculation agree that ${moveSan} is the best continuation here.`
698+
} else if (hasMultipleGoodMoves) {
699+
suggestion = `${playerColor} has several equally strong options, suggesting flexibility in planning.`
700+
} else if (top3MaiaProbability < 50) {
701+
suggestion = `This unusual position creates difficulties for human calculation, with no clearly favored continuation.`
702+
} else {
703+
suggestion = `There are several reasonable options for ${playerColor} to consider in this position.`
704+
}
705+
706+
return `${evaluation} ${suggestion}`
707+
}, [
708+
controller.currentNode,
709+
moveEvaluation,
710+
blunderMeter,
711+
colorSanMapping,
712+
MAIA_MODELS,
713+
])
714+
527715
const move = useMemo(() => {
528716
if (!currentMove) return undefined
529717

@@ -559,5 +747,6 @@ export const useAnalysisController = (game: AnalyzedGame) => {
559747
moveRecommendations,
560748
moveMap,
561749
blunderMeter,
750+
boardDescription,
562751
}
563752
}

Diff for: src/pages/analysis/[...id].tsx

+3
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ const Analysis: React.FC<Props> = ({
282282
moveRecommendations,
283283
moveMap,
284284
blunderMeter,
285+
boardDescription,
285286
} = useAnalysisController(analyzedGame)
286287

287288
useEffect(() => {
@@ -600,6 +601,7 @@ const Analysis: React.FC<Props> = ({
600601
}
601602
movesByRating={movesByRating}
602603
colorSanMapping={colorSanMapping}
604+
boardDescription={boardDescription}
603605
/>
604606
</div>
605607
<div className="flex h-[calc((55vh+4.5rem)/2)] flex-row gap-2">
@@ -728,6 +730,7 @@ const Analysis: React.FC<Props> = ({
728730
}
729731
movesByRating={movesByRating}
730732
colorSanMapping={colorSanMapping}
733+
boardDescription={boardDescription}
731734
/>
732735
<MoveMap
733736
moveMap={moveMap}

0 commit comments

Comments
 (0)