Skip to content

Commit 66df47f

Browse files
Merge pull request #119 from CSSLab/copilot/fix-118
Fix position evaluation graph to start from opening end position
2 parents 4e17081 + 97e8866 commit 66df47f

4 files changed

Lines changed: 164 additions & 3 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { GameTree, GameNode } from 'src/types'
2+
import { Chess } from 'chess.ts'
3+
4+
/**
5+
* Test to verify that evaluation chart generation starts from the correct position
6+
* This test validates the fix for issue #118 where the position evaluation graph
7+
* was showing pre-opening moves that the player didn't actually play.
8+
*/
9+
describe('useOpeningDrillController evaluation chart generation', () => {
10+
// Helper function to simulate the extractNodeAnalysis logic
11+
const extractNodeAnalysisFromPosition = (
12+
startingNode: GameNode,
13+
playerColor: 'white' | 'black',
14+
) => {
15+
const moveAnalyses: Array<{
16+
move: string
17+
san: string
18+
fen: string
19+
isPlayerMove: boolean
20+
evaluation: number
21+
moveNumber: number
22+
}> = []
23+
const evaluationChart: Array<{
24+
moveNumber: number
25+
evaluation: number
26+
isPlayerMove: boolean
27+
}> = []
28+
29+
const extractNodeAnalysis = (
30+
node: GameNode,
31+
path: GameNode[] = [],
32+
): void => {
33+
const currentPath = [...path, node]
34+
35+
if (node.move && node.san) {
36+
const moveIndex = currentPath.length - 2
37+
const isPlayerMove =
38+
playerColor === 'white' ? moveIndex % 2 === 0 : moveIndex % 2 === 1
39+
40+
// Mock evaluation data
41+
const evaluation = Math.random() * 200 - 100 // Random evaluation between -100 and 100
42+
43+
const moveAnalysis = {
44+
move: node.move,
45+
san: node.san,
46+
fen: node.fen,
47+
isPlayerMove,
48+
evaluation,
49+
moveNumber: Math.ceil((moveIndex + 1) / 2),
50+
}
51+
52+
moveAnalyses.push(moveAnalysis)
53+
54+
const evaluationPoint = {
55+
moveNumber: moveAnalysis.moveNumber,
56+
evaluation,
57+
isPlayerMove,
58+
}
59+
60+
evaluationChart.push(evaluationPoint)
61+
}
62+
63+
if (node.children.length > 0) {
64+
extractNodeAnalysis(node.children[0], currentPath)
65+
}
66+
}
67+
68+
extractNodeAnalysis(startingNode)
69+
return { moveAnalyses, evaluationChart }
70+
}
71+
72+
it('should start analysis from opening end node rather than game root', () => {
73+
// Create a game tree representing: 1. e4 e5 2. Nf3 Nc6 (opening) 3. Bb5 a6 (drill moves)
74+
const chess = new Chess()
75+
const gameTree = new GameTree(chess.fen())
76+
77+
// Add opening moves (these should NOT be included in evaluation chart)
78+
chess.move('e4')
79+
const e4Node = gameTree.addMainMove(
80+
gameTree.getRoot(),
81+
chess.fen(),
82+
'e2e4',
83+
'e4',
84+
)!
85+
86+
chess.move('e5')
87+
const e5Node = gameTree.addMainMove(e4Node, chess.fen(), 'e7e5', 'e5')!
88+
89+
chess.move('Nf3')
90+
const nf3Node = gameTree.addMainMove(e5Node, chess.fen(), 'g1f3', 'Nf3')!
91+
92+
chess.move('Nc6')
93+
const nc6Node = gameTree.addMainMove(nf3Node, chess.fen(), 'b8c6', 'Nc6')! // This is the opening end
94+
95+
// Add drill moves (these SHOULD be included in evaluation chart)
96+
chess.move('Bb5')
97+
const bb5Node = gameTree.addMainMove(nc6Node, chess.fen(), 'f1b5', 'Bb5')!
98+
99+
chess.move('a6')
100+
const a6Node = gameTree.addMainMove(bb5Node, chess.fen(), 'a7a6', 'a6')!
101+
102+
// Test starting from game root (old behavior - should include all moves)
103+
const { moveAnalyses: rootAnalyses, evaluationChart: rootChart } =
104+
extractNodeAnalysisFromPosition(gameTree.getRoot(), 'white')
105+
106+
// Test starting from opening end (new behavior - should only include drill moves)
107+
const {
108+
moveAnalyses: openingEndAnalyses,
109+
evaluationChart: openingEndChart,
110+
} = extractNodeAnalysisFromPosition(nc6Node, 'white')
111+
112+
// Verify that starting from root includes all moves (including opening)
113+
expect(rootAnalyses).toHaveLength(6) // e4, e5, Nf3, Nc6, Bb5, a6
114+
expect(rootChart).toHaveLength(6)
115+
116+
// Verify that starting from opening end only includes post-opening moves
117+
// Note: This includes the last opening move (Nc6) which provides context for the evaluation chart
118+
expect(openingEndAnalyses).toHaveLength(3) // Nc6 (last opening move), Bb5, a6
119+
expect(openingEndChart).toHaveLength(3)
120+
121+
// Verify the moves are correct - the first should be the last opening move, then drill moves
122+
expect(openingEndAnalyses[0].san).toBe('Nc6') // Last opening move
123+
expect(openingEndAnalyses[1].san).toBe('Bb5') // First drill move
124+
expect(openingEndAnalyses[1].isPlayerMove).toBe(true) // White's move
125+
expect(openingEndAnalyses[2].san).toBe('a6') // Second drill move
126+
expect(openingEndAnalyses[2].isPlayerMove).toBe(false) // Black's move
127+
128+
// Verify evaluation chart matches move analyses
129+
expect(openingEndChart[0].moveNumber).toBe(openingEndAnalyses[0].moveNumber)
130+
expect(openingEndChart[1].moveNumber).toBe(openingEndAnalyses[1].moveNumber)
131+
})
132+
133+
it('should handle the case where opening end node is null', () => {
134+
const chess = new Chess()
135+
const gameTree = new GameTree(chess.fen())
136+
137+
// Add some moves
138+
chess.move('e4')
139+
const e4Node = gameTree.addMainMove(
140+
gameTree.getRoot(),
141+
chess.fen(),
142+
'e2e4',
143+
'e4',
144+
)!
145+
146+
// Test with null opening end node (should fallback to root)
147+
const startingNode = null || gameTree.getRoot() // Simulates the fallback logic
148+
const { moveAnalyses, evaluationChart } = extractNodeAnalysisFromPosition(
149+
startingNode,
150+
'white',
151+
)
152+
153+
expect(moveAnalyses).toHaveLength(1)
154+
expect(evaluationChart).toHaveLength(1)
155+
expect(moveAnalyses[0].san).toBe('e4')
156+
})
157+
})

src/components/Openings/DrillPerformanceModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1161,7 +1161,8 @@ export const DrillPerformanceModal: React.FC<Props> = ({
11611161
return moveAnalyses.filter((move) => move.isPlayerMove).length
11621162
}, [moveAnalyses])
11631163

1164-
// Filter evaluation chart to match the filtered moves
1164+
// Filter evaluation chart to start from the position before the first player move
1165+
// This provides context by showing the evaluation before the player's first contribution
11651166
const filteredEvaluationChart = useMemo(() => {
11661167
const firstPlayerMoveIndex = moveAnalyses.findIndex(
11671168
(move) => move.isPlayerMove,

src/hooks/useOpeningDrillController/useOpeningDrillController.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,10 @@ export const useOpeningDrillController = (
367367
}
368368
}
369369

370-
extractNodeAnalysis(drillGame.tree.getRoot())
370+
// Start analysis from the opening end node, not from the game root
371+
// This ensures the evaluation chart only includes post-opening moves that the player actually played
372+
const startingNode = drillGame.openingEndNode || drillGame.tree.getRoot()
373+
extractNodeAnalysis(startingNode)
371374

372375
const playerMoves = moveAnalyses.filter((m) => m.isPlayerMove)
373376
const excellentMoves = playerMoves.filter(

src/pages/openings/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,7 @@ const OpeningsPage: NextPage = () => {
11031103
exit={{ opacity: 0 }}
11041104
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
11051105
>
1106-
<div className="flex max-w-md flex-col items-center gap-4 rounded-lg bg-background-1 p-8 shadow-2xl">
1106+
<div className="flex max-w-md flex-col items-center gap-4 rounded-lg border border-white/10 bg-background-1 p-8 shadow-2xl">
11071107
<div className="h-8 w-8 animate-spin rounded-full border-4 border-human-4 border-t-transparent"></div>
11081108
<div className="text-center">
11091109
<h3 className="text-lg font-semibold">

0 commit comments

Comments
 (0)