ankigammon 1.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +391 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +216 -0
- ankigammon/anki/apkg_exporter.py +111 -0
- ankigammon/anki/card_generator.py +1325 -0
- ankigammon/anki/card_styles.py +1054 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +192 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +594 -0
- ankigammon/gui/dialogs/import_options_dialog.py +201 -0
- ankigammon/gui/dialogs/input_dialog.py +762 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +420 -0
- ankigammon/gui/dialogs/update_dialog.py +373 -0
- ankigammon/gui/format_detector.py +377 -0
- ankigammon/gui/main_window.py +1611 -0
- ankigammon/gui/resources/down-arrow.svg +3 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +402 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/update_checker.py +259 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +166 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +356 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_match_parser.py +1094 -0
- ankigammon/parsers/gnubg_parser.py +468 -0
- ankigammon/parsers/sgf_parser.py +290 -0
- ankigammon/parsers/xg_binary_parser.py +1097 -0
- ankigammon/parsers/xg_text_parser.py +688 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +391 -0
- ankigammon/renderer/animation_helper.py +191 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +791 -0
- ankigammon/settings.py +315 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +590 -0
- ankigammon/utils/gnuid.py +577 -0
- ankigammon/utils/move_parser.py +204 -0
- ankigammon/utils/ogid.py +326 -0
- ankigammon/utils/xgid.py +387 -0
- ankigammon-1.0.6.dist-info/METADATA +352 -0
- ankigammon-1.0.6.dist-info/RECORD +61 -0
- ankigammon-1.0.6.dist-info/WHEEL +5 -0
- ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
- ankigammon-1.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parser for eXtreme Gammon binary (.xg) files.
|
|
3
|
+
|
|
4
|
+
This parser wraps the xgdatatools library to convert XG binary format
|
|
5
|
+
into AnkiGammon's Decision objects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from ankigammon.models import (
|
|
13
|
+
Decision,
|
|
14
|
+
Move,
|
|
15
|
+
Position,
|
|
16
|
+
Player,
|
|
17
|
+
CubeState,
|
|
18
|
+
DecisionType
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Import xgdatatools modules from thirdparty
|
|
22
|
+
from ankigammon.thirdparty.xgdatatools import xgimport
|
|
23
|
+
from ankigammon.thirdparty.xgdatatools import xgstruct
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ParseError(Exception):
|
|
29
|
+
"""Custom exception for parsing failures"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class XGBinaryParser:
|
|
34
|
+
"""Parser for eXtreme Gammon binary (.xg) files"""
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def extract_player_names(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
|
38
|
+
"""
|
|
39
|
+
Extract player names from .xg binary file.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
file_path: Path to .xg file
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Tuple[Optional[str], Optional[str]]: (player1_name, player2_name)
|
|
46
|
+
Returns (None, None) if names cannot be extracted.
|
|
47
|
+
"""
|
|
48
|
+
path = Path(file_path)
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return (None, None)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
xg_import = xgimport.Import(str(path))
|
|
54
|
+
|
|
55
|
+
# Look for the first HeaderMatchEntry to get player names
|
|
56
|
+
for segment in xg_import.getfilesegment():
|
|
57
|
+
if segment.type == xgimport.Import.Segment.XG_GAMEFILE:
|
|
58
|
+
segment.fd.seek(0)
|
|
59
|
+
record = xgstruct.GameFileRecord(version=-1).fromstream(segment.fd)
|
|
60
|
+
|
|
61
|
+
if isinstance(record, xgstruct.HeaderMatchEntry):
|
|
62
|
+
# Try to get player names (prefer Unicode over ANSI)
|
|
63
|
+
player1 = record.get('Player1') or record.get('SPlayer1')
|
|
64
|
+
player2 = record.get('Player2') or record.get('SPlayer2')
|
|
65
|
+
|
|
66
|
+
# Decode bytes if needed
|
|
67
|
+
if isinstance(player1, bytes):
|
|
68
|
+
player1 = player1.decode('utf-8', errors='ignore')
|
|
69
|
+
if isinstance(player2, bytes):
|
|
70
|
+
player2 = player2.decode('utf-8', errors='ignore')
|
|
71
|
+
|
|
72
|
+
logger.debug(f"Extracted player names: {player1} vs {player2}")
|
|
73
|
+
return (player1, player2)
|
|
74
|
+
|
|
75
|
+
# No header found
|
|
76
|
+
return (None, None)
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.warning(f"Failed to extract player names from {file_path}: {e}")
|
|
80
|
+
return (None, None)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def parse_file(file_path: str) -> List[Decision]:
|
|
84
|
+
"""
|
|
85
|
+
Parse .xg binary file.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
file_path: Path to .xg file
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List[Decision]: Parsed decisions
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
FileNotFoundError: File not found
|
|
95
|
+
ValueError: Invalid .xg format
|
|
96
|
+
ParseError: Parsing failed
|
|
97
|
+
"""
|
|
98
|
+
path = Path(file_path)
|
|
99
|
+
if not path.exists():
|
|
100
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
101
|
+
|
|
102
|
+
logger.info(f"Parsing XG binary file: {file_path}")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# Use xgimport to read the .xg file
|
|
106
|
+
xg_import = xgimport.Import(str(path))
|
|
107
|
+
decisions = []
|
|
108
|
+
|
|
109
|
+
# Track game state across records
|
|
110
|
+
file_version = -1
|
|
111
|
+
match_length = 0
|
|
112
|
+
score_x = 0
|
|
113
|
+
score_o = 0
|
|
114
|
+
crawford = False
|
|
115
|
+
|
|
116
|
+
# Process file segments
|
|
117
|
+
for segment in xg_import.getfilesegment():
|
|
118
|
+
if segment.type == xgimport.Import.Segment.XG_GAMEFILE:
|
|
119
|
+
# Parse game file segment
|
|
120
|
+
segment.fd.seek(0)
|
|
121
|
+
|
|
122
|
+
while True:
|
|
123
|
+
record = xgstruct.GameFileRecord(version=file_version).fromstream(segment.fd)
|
|
124
|
+
if record is None:
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
# Process different record types
|
|
128
|
+
if isinstance(record, xgstruct.HeaderMatchEntry):
|
|
129
|
+
file_version = record.Version
|
|
130
|
+
match_length = record.MatchLength
|
|
131
|
+
logger.debug(f"Match header: version={file_version}, match_length={match_length}")
|
|
132
|
+
|
|
133
|
+
elif isinstance(record, xgstruct.HeaderGameEntry):
|
|
134
|
+
# XG binary stores scores from Player 1's perspective
|
|
135
|
+
# Scores are swapped during position flip when Player 2 is on roll
|
|
136
|
+
score_x = record.Score1
|
|
137
|
+
score_o = record.Score2
|
|
138
|
+
crawford = bool(record.CrawfordApply)
|
|
139
|
+
logger.debug(f"Game header: score={score_x}-{score_o}, crawford={crawford}")
|
|
140
|
+
|
|
141
|
+
elif isinstance(record, xgstruct.MoveEntry):
|
|
142
|
+
try:
|
|
143
|
+
decision = XGBinaryParser._parse_move_entry(
|
|
144
|
+
record, match_length, score_x, score_o, crawford
|
|
145
|
+
)
|
|
146
|
+
if decision:
|
|
147
|
+
decisions.append(decision)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.warning(f"Failed to parse move entry: {e}")
|
|
150
|
+
|
|
151
|
+
elif isinstance(record, xgstruct.CubeEntry):
|
|
152
|
+
try:
|
|
153
|
+
decision = XGBinaryParser._parse_cube_entry(
|
|
154
|
+
record, match_length, score_x, score_o, crawford
|
|
155
|
+
)
|
|
156
|
+
if decision:
|
|
157
|
+
decisions.append(decision)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.warning(f"Failed to parse cube entry: {e}")
|
|
160
|
+
|
|
161
|
+
if not decisions:
|
|
162
|
+
raise ParseError("No valid positions found in file")
|
|
163
|
+
|
|
164
|
+
logger.info(f"Successfully parsed {len(decisions)} decisions from {file_path}")
|
|
165
|
+
return decisions
|
|
166
|
+
|
|
167
|
+
except xgimport.Error as e:
|
|
168
|
+
raise ParseError(f"XG import error: {e}")
|
|
169
|
+
except Exception as e:
|
|
170
|
+
raise ParseError(f"Failed to parse .xg file: {e}")
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _transform_position(raw_points: List[int], on_roll: Player) -> Position:
|
|
174
|
+
"""
|
|
175
|
+
Transform XG binary position array to internal Position model.
|
|
176
|
+
|
|
177
|
+
XG binary format uses opposite sign convention from AnkiGammon:
|
|
178
|
+
- XG: Positive = O checkers, Negative = X checkers
|
|
179
|
+
- AnkiGammon: Positive = X checkers, Negative = O checkers
|
|
180
|
+
|
|
181
|
+
This method inverts all signs during the conversion. XG binary always stores
|
|
182
|
+
positions from O's (Player 1's) perspective. The caller is responsible for
|
|
183
|
+
flipping the position when it needs to be shown from X's perspective.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
raw_points: Raw 26-element position array from XG binary
|
|
187
|
+
on_roll: Player who is on roll (currently unused, kept for compatibility)
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Position object with signs inverted (still from O's perspective)
|
|
191
|
+
"""
|
|
192
|
+
position = Position()
|
|
193
|
+
|
|
194
|
+
# XG binary uses opposite sign convention - invert all signs
|
|
195
|
+
position.points = [-count for count in raw_points]
|
|
196
|
+
|
|
197
|
+
# Calculate borne-off checkers (each player starts with 15)
|
|
198
|
+
total_x = sum(count for count in position.points if count > 0)
|
|
199
|
+
total_o = sum(abs(count) for count in position.points if count < 0)
|
|
200
|
+
|
|
201
|
+
position.x_off = 15 - total_x
|
|
202
|
+
position.o_off = 15 - total_o
|
|
203
|
+
|
|
204
|
+
# Validate position
|
|
205
|
+
XGBinaryParser._validate_position(position)
|
|
206
|
+
|
|
207
|
+
return position
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _validate_position(position: Position) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Validate position to catch inversions and corruption.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
position: Position to validate
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: If position is invalid
|
|
219
|
+
"""
|
|
220
|
+
# Count checkers
|
|
221
|
+
total_x = sum(count for count in position.points if count > 0)
|
|
222
|
+
total_o = sum(abs(count) for count in position.points if count < 0)
|
|
223
|
+
|
|
224
|
+
# Each player should have at most 15 checkers on board
|
|
225
|
+
if total_x > 15:
|
|
226
|
+
raise ValueError(f"Invalid position: X has {total_x} checkers on board (max 15)")
|
|
227
|
+
if total_o > 15:
|
|
228
|
+
raise ValueError(f"Invalid position: O has {total_o} checkers on board (max 15)")
|
|
229
|
+
|
|
230
|
+
# Total checkers (on board + borne off) should be exactly 15 per player
|
|
231
|
+
if total_x + position.x_off != 15:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"Invalid position: X has {total_x} on board + {position.x_off} off = "
|
|
234
|
+
f"{total_x + position.x_off} (expected 15)"
|
|
235
|
+
)
|
|
236
|
+
if total_o + position.o_off != 15:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"Invalid position: O has {total_o} on board + {position.o_off} off = "
|
|
239
|
+
f"{total_o + position.o_off} (expected 15)"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Check bar constraints (should be <= 2 per player in normal positions)
|
|
243
|
+
x_bar = position.points[0]
|
|
244
|
+
o_bar = abs(position.points[25])
|
|
245
|
+
if x_bar > 15: # Relaxed constraint - theoretically up to 15
|
|
246
|
+
raise ValueError(f"Invalid position: X has {x_bar} checkers on bar")
|
|
247
|
+
if o_bar > 15:
|
|
248
|
+
raise ValueError(f"Invalid position: O has {o_bar} checkers on bar")
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _parse_move_entry(
|
|
252
|
+
move_entry: xgstruct.MoveEntry,
|
|
253
|
+
match_length: int,
|
|
254
|
+
score_x: int,
|
|
255
|
+
score_o: int,
|
|
256
|
+
crawford: bool
|
|
257
|
+
) -> Optional[Decision]:
|
|
258
|
+
"""
|
|
259
|
+
Convert MoveEntry to Decision object.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
move_entry: MoveEntry from xgstruct
|
|
263
|
+
match_length: Match length (0 for money game)
|
|
264
|
+
score_x: Player X score
|
|
265
|
+
score_o: Player O score
|
|
266
|
+
crawford: Crawford game flag
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Decision object or None if invalid
|
|
270
|
+
"""
|
|
271
|
+
# Determine player on roll
|
|
272
|
+
# XG uses ActiveP: 1 or 2
|
|
273
|
+
# Map to AnkiGammon: Player.O (bottom) or Player.X (top)
|
|
274
|
+
on_roll = Player.O if move_entry.ActiveP == 1 else Player.X
|
|
275
|
+
|
|
276
|
+
# Create position from XG position array
|
|
277
|
+
# XG binary format ALWAYS stores positions from O's (Player 1's) perspective
|
|
278
|
+
# We need to flip to X's perspective when X is on roll
|
|
279
|
+
position = XGBinaryParser._transform_position(
|
|
280
|
+
list(move_entry.PositionI),
|
|
281
|
+
on_roll
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Flip position if X is on roll (since XG stores from O's perspective)
|
|
285
|
+
if on_roll == Player.X:
|
|
286
|
+
# Flip the position by reversing points and swapping signs
|
|
287
|
+
flipped_points = [0] * 26
|
|
288
|
+
flipped_points[0] = -position.points[25] # X's bar = O's bar (negated)
|
|
289
|
+
flipped_points[25] = -position.points[0] # O's bar = X's bar (negated)
|
|
290
|
+
for i in range(1, 25):
|
|
291
|
+
flipped_points[i] = -position.points[25 - i]
|
|
292
|
+
position.points = flipped_points
|
|
293
|
+
position.x_off, position.o_off = position.o_off, position.x_off
|
|
294
|
+
# Swap scores to match flipped perspective
|
|
295
|
+
score_x, score_o = score_o, score_x
|
|
296
|
+
|
|
297
|
+
# Get dice
|
|
298
|
+
dice = tuple(move_entry.Dice) if move_entry.Dice else None
|
|
299
|
+
|
|
300
|
+
# Parse cube state
|
|
301
|
+
# CubeA encoding: sign indicates owner, absolute value is log2 of cube value
|
|
302
|
+
# 0 = centered at 1, ±1 = owned at 2^1=2, ±2 = owned at 2^2=4, etc.
|
|
303
|
+
if move_entry.CubeA == 0:
|
|
304
|
+
cube_value = 1
|
|
305
|
+
cube_owner = CubeState.CENTERED
|
|
306
|
+
else:
|
|
307
|
+
cube_value = 2 ** abs(move_entry.CubeA)
|
|
308
|
+
# XG binary sign convention: Positive = XG Player 1, Negative = XG Player 2
|
|
309
|
+
# Mapping: XG Player 1 → Player.O, XG Player 2 → Player.X
|
|
310
|
+
if move_entry.CubeA > 0:
|
|
311
|
+
cube_owner = CubeState.O_OWNS # XG Player 1 owns
|
|
312
|
+
else:
|
|
313
|
+
cube_owner = CubeState.X_OWNS # XG Player 2 owns
|
|
314
|
+
|
|
315
|
+
# Swap cube owner if position was flipped
|
|
316
|
+
if on_roll == Player.X:
|
|
317
|
+
if cube_owner == CubeState.X_OWNS:
|
|
318
|
+
cube_owner = CubeState.O_OWNS
|
|
319
|
+
elif cube_owner == CubeState.O_OWNS:
|
|
320
|
+
cube_owner = CubeState.X_OWNS
|
|
321
|
+
|
|
322
|
+
# Parse candidate moves from analysis
|
|
323
|
+
moves = []
|
|
324
|
+
if hasattr(move_entry, 'DataMoves') and move_entry.DataMoves:
|
|
325
|
+
data_moves = move_entry.DataMoves
|
|
326
|
+
n_moves = min(move_entry.NMoveEval, data_moves.NMoves)
|
|
327
|
+
|
|
328
|
+
for i in range(n_moves):
|
|
329
|
+
# Parse move notation with compound move combination and hit detection
|
|
330
|
+
notation = XGBinaryParser._convert_move_notation(
|
|
331
|
+
data_moves.Moves[i],
|
|
332
|
+
position,
|
|
333
|
+
on_roll
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Get equity (7-element tuple from XG)
|
|
337
|
+
# XG Format: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
|
|
338
|
+
# Indices: [0] [1] [2] [3] [4] [5] [6]
|
|
339
|
+
#
|
|
340
|
+
# These are cumulative probabilities:
|
|
341
|
+
# Lose_S (index 2) = Total losses (all types: normal + gammon + backgammon)
|
|
342
|
+
# Lose_G (index 1) = Gammon + backgammon losses (subset of Lose_S)
|
|
343
|
+
# Lose_BG (index 0) = Backgammon losses only (subset of Lose_G)
|
|
344
|
+
# Win_S (index 3) = Total wins (all types: normal + gammon + backgammon)
|
|
345
|
+
# Win_G (index 4) = Gammon + backgammon wins (subset of Win_S)
|
|
346
|
+
# Win_BG (index 5) = Backgammon wins only (subset of Win_G)
|
|
347
|
+
# Equity (index 6) = Overall equity value
|
|
348
|
+
#
|
|
349
|
+
# Note: Lose_S + Win_S = 1.0 (or very close to 1.0)
|
|
350
|
+
equity_tuple = data_moves.Eval[i]
|
|
351
|
+
equity = equity_tuple[6] # Overall equity at index 6
|
|
352
|
+
|
|
353
|
+
# Extract winning chances (convert from decimals to percentages)
|
|
354
|
+
# Store cumulative values as displayed by XG/GnuBG:
|
|
355
|
+
# "Player: 50.41% (G:15.40% B:2.03%)" means:
|
|
356
|
+
# 50.41% total wins, of which 15.40% are gammon or better,
|
|
357
|
+
# of which 2.03% are backgammon
|
|
358
|
+
opponent_win_pct = equity_tuple[2] * 100 # Total opponent wins (index 2 = Lose_S)
|
|
359
|
+
opponent_gammon_pct = equity_tuple[1] * 100 # Opp gammon+BG (index 1 = Lose_G)
|
|
360
|
+
opponent_backgammon_pct = equity_tuple[0] * 100 # Opp BG only (index 0 = Lose_BG)
|
|
361
|
+
player_win_pct = equity_tuple[3] * 100 # Total player wins (index 3 = Win_S)
|
|
362
|
+
player_gammon_pct = equity_tuple[4] * 100 # Player gammon+BG (index 4 = Win_G)
|
|
363
|
+
player_backgammon_pct = equity_tuple[5] * 100 # Player BG only (index 5 = Win_BG)
|
|
364
|
+
|
|
365
|
+
move = Move(
|
|
366
|
+
notation=notation,
|
|
367
|
+
equity=equity,
|
|
368
|
+
error=0.0, # Will be calculated based on best move
|
|
369
|
+
rank=i + 1, # Temporary rank
|
|
370
|
+
xg_rank=i + 1,
|
|
371
|
+
xg_error=0.0,
|
|
372
|
+
xg_notation=notation,
|
|
373
|
+
from_xg_analysis=True,
|
|
374
|
+
player_win_pct=player_win_pct,
|
|
375
|
+
player_gammon_pct=player_gammon_pct,
|
|
376
|
+
player_backgammon_pct=player_backgammon_pct,
|
|
377
|
+
opponent_win_pct=opponent_win_pct,
|
|
378
|
+
opponent_gammon_pct=opponent_gammon_pct,
|
|
379
|
+
opponent_backgammon_pct=opponent_backgammon_pct
|
|
380
|
+
)
|
|
381
|
+
moves.append(move)
|
|
382
|
+
|
|
383
|
+
# Mark which move was actually played
|
|
384
|
+
if hasattr(move_entry, 'Moves') and move_entry.Moves:
|
|
385
|
+
played_notation = XGBinaryParser._convert_move_notation(
|
|
386
|
+
move_entry.Moves,
|
|
387
|
+
position,
|
|
388
|
+
on_roll
|
|
389
|
+
)
|
|
390
|
+
# Normalize by sorting sub-moves for comparison
|
|
391
|
+
played_normalized = XGBinaryParser._normalize_move_notation(played_notation)
|
|
392
|
+
|
|
393
|
+
for move in moves:
|
|
394
|
+
move_normalized = XGBinaryParser._normalize_move_notation(move.notation)
|
|
395
|
+
if move_normalized == played_normalized:
|
|
396
|
+
move.was_played = True
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
# Sort moves by equity (highest first) and assign ranks
|
|
400
|
+
if moves:
|
|
401
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
402
|
+
best_equity = moves[0].equity
|
|
403
|
+
|
|
404
|
+
for i, move in enumerate(moves):
|
|
405
|
+
move.rank = i + 1
|
|
406
|
+
move.error = abs(best_equity - move.equity)
|
|
407
|
+
move.xg_error = move.equity - best_equity # Negative for worse moves
|
|
408
|
+
|
|
409
|
+
# Extract XG's error value for the played move
|
|
410
|
+
# This is the authoritative error for filtering purposes
|
|
411
|
+
xg_err_move = None
|
|
412
|
+
if hasattr(move_entry, 'ErrMove'):
|
|
413
|
+
err_move_raw = move_entry.ErrMove
|
|
414
|
+
if err_move_raw != -1000: # -1000 indicates not analyzed
|
|
415
|
+
xg_err_move = abs(err_move_raw) # Use absolute value for error magnitude
|
|
416
|
+
|
|
417
|
+
# Generate XGID for the position
|
|
418
|
+
crawford_jacoby = 1 if crawford else 0
|
|
419
|
+
xgid = position.to_xgid(
|
|
420
|
+
cube_value=cube_value,
|
|
421
|
+
cube_owner=cube_owner,
|
|
422
|
+
dice=dice,
|
|
423
|
+
on_roll=on_roll,
|
|
424
|
+
score_x=score_x,
|
|
425
|
+
score_o=score_o,
|
|
426
|
+
match_length=match_length,
|
|
427
|
+
crawford_jacoby=crawford_jacoby
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Create Decision
|
|
431
|
+
decision = Decision(
|
|
432
|
+
position=position,
|
|
433
|
+
on_roll=on_roll,
|
|
434
|
+
dice=dice,
|
|
435
|
+
score_x=score_x,
|
|
436
|
+
score_o=score_o,
|
|
437
|
+
match_length=match_length,
|
|
438
|
+
crawford=crawford,
|
|
439
|
+
cube_value=cube_value,
|
|
440
|
+
cube_owner=cube_owner,
|
|
441
|
+
decision_type=DecisionType.CHECKER_PLAY,
|
|
442
|
+
candidate_moves=moves,
|
|
443
|
+
xg_error_move=xg_err_move, # XG's authoritative error value
|
|
444
|
+
xgid=xgid
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return decision
|
|
448
|
+
|
|
449
|
+
@staticmethod
|
|
450
|
+
def _parse_cube_entry(
|
|
451
|
+
cube_entry: xgstruct.CubeEntry,
|
|
452
|
+
match_length: int,
|
|
453
|
+
score_x: int,
|
|
454
|
+
score_o: int,
|
|
455
|
+
crawford: bool
|
|
456
|
+
) -> Optional[Decision]:
|
|
457
|
+
"""
|
|
458
|
+
Convert CubeEntry to Decision object.
|
|
459
|
+
|
|
460
|
+
XG binary files contain cube entries for all cube decisions in a game,
|
|
461
|
+
but not all of them are analyzed. This method filters out unanalyzed
|
|
462
|
+
cube decisions and extracts equity values from analyzed ones.
|
|
463
|
+
|
|
464
|
+
Unanalyzed cube decisions are identified by:
|
|
465
|
+
- FlagDouble == -100 or -1000 (indicates not analyzed)
|
|
466
|
+
- All equities are 0.0 and position is empty
|
|
467
|
+
|
|
468
|
+
Analyzed cube decisions contain:
|
|
469
|
+
- equB: Equity for "No Double"
|
|
470
|
+
- equDouble: Equity for "Double/Take"
|
|
471
|
+
- equDrop: Equity for "Double/Pass" (typically -1.0 for opponent)
|
|
472
|
+
- Eval: Win probabilities for "No Double" scenario
|
|
473
|
+
- EvalDouble: Win probabilities for "Double/Take" scenario
|
|
474
|
+
|
|
475
|
+
Note: For cube decisions, the position is shown from the doubler's perspective
|
|
476
|
+
(the player who has the cube decision), regardless of whether the error was
|
|
477
|
+
made by the doubler or the responder. This ensures consistency for score
|
|
478
|
+
matrix generation and position display.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
cube_entry: CubeEntry from xgstruct
|
|
482
|
+
match_length: Match length (0 for money game)
|
|
483
|
+
score_x: Player X score
|
|
484
|
+
score_o: Player O score
|
|
485
|
+
crawford: Crawford game flag
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Decision object with 5 cube options, or None if unanalyzed
|
|
489
|
+
"""
|
|
490
|
+
# Determine player on roll from ActiveP
|
|
491
|
+
# Note: ActiveP may represent the responder for take/pass errors,
|
|
492
|
+
# but we always show cube decisions from the doubler's perspective
|
|
493
|
+
active_player = Player.O if cube_entry.ActiveP == 1 else Player.X
|
|
494
|
+
|
|
495
|
+
# Create position with perspective transformation (using active_player)
|
|
496
|
+
position = XGBinaryParser._transform_position(
|
|
497
|
+
list(cube_entry.Position),
|
|
498
|
+
active_player
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Parse cube state
|
|
502
|
+
# CubeB encoding: sign indicates owner, absolute value is log2 of cube value
|
|
503
|
+
# 0 = centered at 1, ±1 = owned at 2^1=2, ±2 = owned at 2^2=4, etc.
|
|
504
|
+
if cube_entry.CubeB == 0:
|
|
505
|
+
cube_value = 1
|
|
506
|
+
cube_owner = CubeState.CENTERED
|
|
507
|
+
else:
|
|
508
|
+
cube_value = 2 ** abs(cube_entry.CubeB)
|
|
509
|
+
# XG binary sign convention: Positive = XG Player 1, Negative = XG Player 2
|
|
510
|
+
# Mapping: XG Player 1 → Player.O, XG Player 2 → Player.X
|
|
511
|
+
if cube_entry.CubeB > 0:
|
|
512
|
+
cube_owner = CubeState.O_OWNS # XG Player 1 owns
|
|
513
|
+
else:
|
|
514
|
+
cube_owner = CubeState.X_OWNS # XG Player 2 owns
|
|
515
|
+
|
|
516
|
+
# Parse cube decisions from Doubled analysis
|
|
517
|
+
moves = []
|
|
518
|
+
if hasattr(cube_entry, 'Doubled') and cube_entry.Doubled:
|
|
519
|
+
doubled = cube_entry.Doubled
|
|
520
|
+
|
|
521
|
+
# Check if cube decision was analyzed
|
|
522
|
+
# FlagDouble -100 or -1000 indicates unanalyzed position
|
|
523
|
+
flag_double = doubled.get('FlagDouble', -100)
|
|
524
|
+
if flag_double in (-100, -1000):
|
|
525
|
+
logger.debug("Skipping unanalyzed cube decision (FlagDouble=%d)", flag_double)
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
# Extract equities
|
|
529
|
+
eq_no_double = doubled.get('equB', 0.0)
|
|
530
|
+
eq_double_take = doubled.get('equDouble', 0.0)
|
|
531
|
+
eq_double_drop = doubled.get('equDrop', -1.0)
|
|
532
|
+
|
|
533
|
+
# Validate that we have actual analysis data
|
|
534
|
+
# If all equities are zero and position is empty, skip this decision
|
|
535
|
+
if (eq_no_double == 0.0 and eq_double_take == 0.0 and
|
|
536
|
+
abs(eq_double_drop - (-1.0)) < 0.001):
|
|
537
|
+
# Check if position has any checkers
|
|
538
|
+
pos = doubled.get('Pos', None)
|
|
539
|
+
if pos and all(v == 0 for v in pos):
|
|
540
|
+
logger.debug("Skipping cube decision with no analysis data")
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
# Extract winning chances
|
|
544
|
+
eval_no_double = doubled.get('Eval', None)
|
|
545
|
+
eval_double = doubled.get('EvalDouble', None)
|
|
546
|
+
|
|
547
|
+
# Create 5 cube options (similar to XGTextParser)
|
|
548
|
+
cube_options = []
|
|
549
|
+
|
|
550
|
+
# 1. No double
|
|
551
|
+
if eval_no_double:
|
|
552
|
+
cube_options.append({
|
|
553
|
+
'notation': 'No Double/Take',
|
|
554
|
+
'equity': eq_no_double,
|
|
555
|
+
'xg_notation': 'No double',
|
|
556
|
+
'from_xg': True,
|
|
557
|
+
'eval': eval_no_double
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
# 2. Double/Take
|
|
561
|
+
if eval_double:
|
|
562
|
+
cube_options.append({
|
|
563
|
+
'notation': 'Double/Take',
|
|
564
|
+
'equity': eq_double_take,
|
|
565
|
+
'xg_notation': 'Double/Take',
|
|
566
|
+
'from_xg': True,
|
|
567
|
+
'eval': eval_double
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
# 3. Double/Pass
|
|
571
|
+
cube_options.append({
|
|
572
|
+
'notation': 'Double/Pass',
|
|
573
|
+
'equity': eq_double_drop,
|
|
574
|
+
'xg_notation': 'Double/Pass',
|
|
575
|
+
'from_xg': True,
|
|
576
|
+
'eval': None
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
# 4 & 5. Too good options (synthetic)
|
|
580
|
+
cube_options.append({
|
|
581
|
+
'notation': 'Too good/Take',
|
|
582
|
+
'equity': eq_double_drop,
|
|
583
|
+
'xg_notation': None,
|
|
584
|
+
'from_xg': False,
|
|
585
|
+
'eval': None
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
cube_options.append({
|
|
589
|
+
'notation': 'Too good/Pass',
|
|
590
|
+
'equity': eq_double_drop,
|
|
591
|
+
'xg_notation': None,
|
|
592
|
+
'from_xg': False,
|
|
593
|
+
'eval': None
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
# Create Move objects
|
|
597
|
+
for i, opt in enumerate(cube_options):
|
|
598
|
+
eval_data = opt.get('eval')
|
|
599
|
+
|
|
600
|
+
# Extract winning chances if available
|
|
601
|
+
player_win_pct = None
|
|
602
|
+
player_gammon_pct = None
|
|
603
|
+
player_backgammon_pct = None
|
|
604
|
+
opponent_win_pct = None
|
|
605
|
+
opponent_gammon_pct = None
|
|
606
|
+
opponent_backgammon_pct = None
|
|
607
|
+
|
|
608
|
+
if eval_data and len(eval_data) >= 7:
|
|
609
|
+
# Same format as MoveEntry: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
|
|
610
|
+
# Cumulative probabilities where Lose_S and Win_S are totals
|
|
611
|
+
opponent_win_pct = eval_data[2] * 100 # Total opponent wins (Lose_S)
|
|
612
|
+
opponent_gammon_pct = eval_data[1] * 100 # Opp gammon+BG (Lose_G)
|
|
613
|
+
opponent_backgammon_pct = eval_data[0] * 100 # Opp BG only (Lose_BG)
|
|
614
|
+
player_win_pct = eval_data[3] * 100 # Total player wins (Win_S)
|
|
615
|
+
player_gammon_pct = eval_data[4] * 100 # Player gammon+BG (Win_G)
|
|
616
|
+
player_backgammon_pct = eval_data[5] * 100 # Player BG only (Win_BG)
|
|
617
|
+
|
|
618
|
+
move = Move(
|
|
619
|
+
notation=opt['notation'],
|
|
620
|
+
equity=opt['equity'],
|
|
621
|
+
error=0.0,
|
|
622
|
+
rank=0, # Will be assigned later
|
|
623
|
+
xg_rank=i + 1 if opt['from_xg'] else None,
|
|
624
|
+
xg_error=None,
|
|
625
|
+
xg_notation=opt['xg_notation'],
|
|
626
|
+
from_xg_analysis=opt['from_xg'],
|
|
627
|
+
player_win_pct=player_win_pct,
|
|
628
|
+
player_gammon_pct=player_gammon_pct,
|
|
629
|
+
player_backgammon_pct=player_backgammon_pct,
|
|
630
|
+
opponent_win_pct=opponent_win_pct,
|
|
631
|
+
opponent_gammon_pct=opponent_gammon_pct,
|
|
632
|
+
opponent_backgammon_pct=opponent_backgammon_pct
|
|
633
|
+
)
|
|
634
|
+
moves.append(move)
|
|
635
|
+
|
|
636
|
+
# Mark which cube action was actually played
|
|
637
|
+
# Double: 0=no double, 1=doubled
|
|
638
|
+
# Take: 0=pass, 1=take, 2=beaver
|
|
639
|
+
if hasattr(cube_entry, 'Double') and hasattr(cube_entry, 'Take'):
|
|
640
|
+
if cube_entry.Double == 0:
|
|
641
|
+
# No double was the action taken
|
|
642
|
+
played_action = 'No Double/Take'
|
|
643
|
+
elif cube_entry.Double == 1:
|
|
644
|
+
if cube_entry.Take == 1:
|
|
645
|
+
# Doubled and taken
|
|
646
|
+
played_action = 'Double/Take'
|
|
647
|
+
else:
|
|
648
|
+
# Doubled and passed
|
|
649
|
+
played_action = 'Double/Pass'
|
|
650
|
+
else:
|
|
651
|
+
played_action = None
|
|
652
|
+
|
|
653
|
+
if played_action:
|
|
654
|
+
for move in moves:
|
|
655
|
+
if move.notation == played_action:
|
|
656
|
+
move.was_played = True
|
|
657
|
+
break
|
|
658
|
+
|
|
659
|
+
# Determine best move and assign ranks
|
|
660
|
+
# Cube decision logic must account for perfect opponent response.
|
|
661
|
+
# Key insight: equDouble represents equity if opponent TAKES, but opponent
|
|
662
|
+
# will only take if it's correct for them.
|
|
663
|
+
#
|
|
664
|
+
# Algorithm:
|
|
665
|
+
# 1. Determine opponent's correct response: take or pass?
|
|
666
|
+
# - If equDouble > equDrop: opponent should PASS (taking is worse for them)
|
|
667
|
+
# - If equDouble < equDrop: opponent should TAKE (taking is better for them)
|
|
668
|
+
# 2. Compare equB (No Double) vs the correct doubling equity
|
|
669
|
+
# - If opponent passes: compare equB vs equDrop (Double/Pass)
|
|
670
|
+
# - If opponent takes: compare equB vs equDouble (Double/Take)
|
|
671
|
+
if moves:
|
|
672
|
+
# Find the three main cube options
|
|
673
|
+
no_double_move = None
|
|
674
|
+
double_take_move = None
|
|
675
|
+
double_pass_move = None
|
|
676
|
+
|
|
677
|
+
for move in moves:
|
|
678
|
+
if move.notation == "No Double/Take":
|
|
679
|
+
no_double_move = move
|
|
680
|
+
elif move.notation == "Double/Take":
|
|
681
|
+
double_take_move = move
|
|
682
|
+
elif move.notation == "Double/Pass":
|
|
683
|
+
double_pass_move = move
|
|
684
|
+
|
|
685
|
+
if no_double_move and double_take_move and double_pass_move:
|
|
686
|
+
# Step 1: Determine opponent's correct response
|
|
687
|
+
# If equDouble > equDrop, opponent should pass (taking gives them worse equity)
|
|
688
|
+
if double_take_move.equity > double_pass_move.equity:
|
|
689
|
+
# Opponent should PASS
|
|
690
|
+
# Compare No Double vs Double/Pass
|
|
691
|
+
if no_double_move.equity >= double_pass_move.equity:
|
|
692
|
+
best_move_notation = "Too good/Pass"
|
|
693
|
+
best_equity = no_double_move.equity
|
|
694
|
+
else:
|
|
695
|
+
best_move_notation = "Double/Pass"
|
|
696
|
+
best_equity = double_pass_move.equity
|
|
697
|
+
else:
|
|
698
|
+
# Opponent should TAKE
|
|
699
|
+
# Compare No Double vs Double/Take
|
|
700
|
+
if no_double_move.equity >= double_take_move.equity:
|
|
701
|
+
if no_double_move.equity > double_pass_move.equity:
|
|
702
|
+
best_move_notation = "Too good/Take"
|
|
703
|
+
else:
|
|
704
|
+
best_move_notation = "No Double/Take"
|
|
705
|
+
best_equity = no_double_move.equity
|
|
706
|
+
else:
|
|
707
|
+
best_move_notation = "Double/Take"
|
|
708
|
+
best_equity = double_take_move.equity
|
|
709
|
+
elif no_double_move:
|
|
710
|
+
best_move_notation = "No Double/Take"
|
|
711
|
+
best_equity = no_double_move.equity
|
|
712
|
+
elif double_take_move:
|
|
713
|
+
best_move_notation = "Double/Take"
|
|
714
|
+
best_equity = double_take_move.equity
|
|
715
|
+
else:
|
|
716
|
+
# Fallback: sort by equity
|
|
717
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
718
|
+
best_move_notation = moves[0].notation
|
|
719
|
+
best_equity = moves[0].equity
|
|
720
|
+
|
|
721
|
+
# Assign rank 1 to best move
|
|
722
|
+
for move in moves:
|
|
723
|
+
if move.notation == best_move_notation:
|
|
724
|
+
move.rank = 1
|
|
725
|
+
move.error = 0.0
|
|
726
|
+
if move.from_xg_analysis:
|
|
727
|
+
move.xg_error = 0.0
|
|
728
|
+
|
|
729
|
+
# Assign ranks 2-5 to other moves based on equity
|
|
730
|
+
other_moves = [m for m in moves if m.notation != best_move_notation]
|
|
731
|
+
other_moves.sort(key=lambda m: m.equity, reverse=True)
|
|
732
|
+
|
|
733
|
+
for i, move in enumerate(other_moves):
|
|
734
|
+
move.rank = i + 2 # Ranks 2, 3, 4, 5
|
|
735
|
+
move.error = abs(best_equity - move.equity)
|
|
736
|
+
if move.from_xg_analysis:
|
|
737
|
+
move.xg_error = move.equity - best_equity
|
|
738
|
+
|
|
739
|
+
# Extract decision-level winning chances from "No Double" evaluation
|
|
740
|
+
# This represents the current position's winning chances
|
|
741
|
+
decision_player_win_pct = None
|
|
742
|
+
decision_player_gammon_pct = None
|
|
743
|
+
decision_player_backgammon_pct = None
|
|
744
|
+
decision_opponent_win_pct = None
|
|
745
|
+
decision_opponent_gammon_pct = None
|
|
746
|
+
decision_opponent_backgammon_pct = None
|
|
747
|
+
|
|
748
|
+
if eval_no_double and len(eval_no_double) >= 7:
|
|
749
|
+
# Same format as MoveEntry: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
|
|
750
|
+
decision_opponent_win_pct = eval_no_double[2] * 100 # Total opponent wins
|
|
751
|
+
decision_opponent_gammon_pct = eval_no_double[1] * 100 # Opp gammon+BG
|
|
752
|
+
decision_opponent_backgammon_pct = eval_no_double[0] * 100 # Opp BG only
|
|
753
|
+
decision_player_win_pct = eval_no_double[3] * 100 # Total player wins
|
|
754
|
+
decision_player_gammon_pct = eval_no_double[4] * 100 # Player gammon+BG
|
|
755
|
+
decision_player_backgammon_pct = eval_no_double[5] * 100 # Player BG only
|
|
756
|
+
|
|
757
|
+
# Extract cube and take errors from XG binary data
|
|
758
|
+
# ErrCube: error made by doubler on double/no double decision
|
|
759
|
+
# ErrTake: error made by responder on take/pass decision
|
|
760
|
+
# Value of -1000 indicates not analyzed
|
|
761
|
+
cube_error = None
|
|
762
|
+
take_error = None
|
|
763
|
+
if hasattr(cube_entry, 'ErrCube'):
|
|
764
|
+
err_cube_raw = cube_entry.ErrCube
|
|
765
|
+
if err_cube_raw != -1000:
|
|
766
|
+
cube_error = err_cube_raw
|
|
767
|
+
if hasattr(cube_entry, 'ErrTake'):
|
|
768
|
+
err_take_raw = cube_entry.ErrTake
|
|
769
|
+
if err_take_raw != -1000:
|
|
770
|
+
take_error = err_take_raw
|
|
771
|
+
|
|
772
|
+
# Determine who the doubler is (the player making the cube decision)
|
|
773
|
+
# For cube decisions, we show the position from the doubler's perspective,
|
|
774
|
+
# even if the error was made by the responder on the take/pass decision.
|
|
775
|
+
#
|
|
776
|
+
# Key relationships:
|
|
777
|
+
# - ActiveP = the player who had the cube decision (on roll)
|
|
778
|
+
# - cube_error = error made by ActiveP on the double/no double decision
|
|
779
|
+
# - take_error = error made by the opponent of ActiveP on the take/pass decision
|
|
780
|
+
#
|
|
781
|
+
# The doubler is determined by:
|
|
782
|
+
# 1. If cube is owned by X: only X can redouble (X is the doubler)
|
|
783
|
+
# 2. If cube is owned by O: only O can redouble (O is the doubler)
|
|
784
|
+
# 3. If cube is centered: ActiveP is the doubler (had the cube decision)
|
|
785
|
+
|
|
786
|
+
# Check the actual cube action taken in the game
|
|
787
|
+
doubled_in_game = hasattr(cube_entry, 'Double') and cube_entry.Double == 1
|
|
788
|
+
|
|
789
|
+
if doubled_in_game:
|
|
790
|
+
# A double occurred in the game - determine who doubled
|
|
791
|
+
if cube_owner == CubeState.X_OWNS:
|
|
792
|
+
# X owns cube and redoubled
|
|
793
|
+
doubler = Player.X
|
|
794
|
+
elif cube_owner == CubeState.O_OWNS:
|
|
795
|
+
# O owns cube and redoubled
|
|
796
|
+
doubler = Player.O
|
|
797
|
+
else:
|
|
798
|
+
# Cube is centered - ActiveP is the doubler
|
|
799
|
+
doubler = active_player
|
|
800
|
+
else:
|
|
801
|
+
# No double occurred - determine who had the cube decision
|
|
802
|
+
if cube_owner == CubeState.X_OWNS:
|
|
803
|
+
# X owns cube - X had the decision (chose not to redouble)
|
|
804
|
+
doubler = Player.X
|
|
805
|
+
elif cube_owner == CubeState.O_OWNS:
|
|
806
|
+
# O owns cube - O had the decision (chose not to redouble)
|
|
807
|
+
doubler = Player.O
|
|
808
|
+
else:
|
|
809
|
+
# Cube is centered - ActiveP had the cube decision (chose not to double)
|
|
810
|
+
doubler = active_player
|
|
811
|
+
|
|
812
|
+
# Always use doubler as on_roll for cube decisions
|
|
813
|
+
on_roll = doubler
|
|
814
|
+
|
|
815
|
+
# XG binary always stores positions from O's (Player 1's) perspective
|
|
816
|
+
# If the doubler is X, flip the position to show it from X's perspective
|
|
817
|
+
if doubler == Player.X:
|
|
818
|
+
logger.debug(
|
|
819
|
+
f"Flipping position from O's perspective to X's perspective (doubler is X)"
|
|
820
|
+
)
|
|
821
|
+
# Flip the position by reversing points and swapping signs
|
|
822
|
+
flipped_points = [0] * 26
|
|
823
|
+
# Swap the bars
|
|
824
|
+
flipped_points[0] = -position.points[25] # X's bar = O's bar (negated)
|
|
825
|
+
flipped_points[25] = -position.points[0] # O's bar = X's bar (negated)
|
|
826
|
+
# Reverse and negate board points
|
|
827
|
+
for i in range(1, 25):
|
|
828
|
+
flipped_points[i] = -position.points[25 - i]
|
|
829
|
+
|
|
830
|
+
position.points = flipped_points
|
|
831
|
+
# Swap borne-off counts
|
|
832
|
+
position.x_off, position.o_off = position.o_off, position.x_off
|
|
833
|
+
# Swap scores to match flipped perspective
|
|
834
|
+
score_x, score_o = score_o, score_x
|
|
835
|
+
# Swap cube owner to match flipped perspective
|
|
836
|
+
if cube_owner == CubeState.X_OWNS:
|
|
837
|
+
cube_owner = CubeState.O_OWNS
|
|
838
|
+
elif cube_owner == CubeState.O_OWNS:
|
|
839
|
+
cube_owner = CubeState.X_OWNS
|
|
840
|
+
|
|
841
|
+
# Generate XGID for the position
|
|
842
|
+
crawford_jacoby = 1 if crawford else 0
|
|
843
|
+
xgid = position.to_xgid(
|
|
844
|
+
cube_value=cube_value,
|
|
845
|
+
cube_owner=cube_owner,
|
|
846
|
+
dice=None, # No dice for cube decisions
|
|
847
|
+
on_roll=on_roll,
|
|
848
|
+
score_x=score_x,
|
|
849
|
+
score_o=score_o,
|
|
850
|
+
match_length=match_length,
|
|
851
|
+
crawford_jacoby=crawford_jacoby
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
# Create Decision
|
|
855
|
+
decision = Decision(
|
|
856
|
+
position=position,
|
|
857
|
+
on_roll=on_roll,
|
|
858
|
+
dice=None, # No dice for cube decisions
|
|
859
|
+
score_x=score_x,
|
|
860
|
+
score_o=score_o,
|
|
861
|
+
match_length=match_length,
|
|
862
|
+
crawford=crawford,
|
|
863
|
+
cube_value=cube_value,
|
|
864
|
+
cube_owner=cube_owner,
|
|
865
|
+
decision_type=DecisionType.CUBE_ACTION,
|
|
866
|
+
candidate_moves=moves,
|
|
867
|
+
cube_error=cube_error,
|
|
868
|
+
take_error=take_error,
|
|
869
|
+
xgid=xgid,
|
|
870
|
+
player_win_pct=decision_player_win_pct,
|
|
871
|
+
player_gammon_pct=decision_player_gammon_pct,
|
|
872
|
+
player_backgammon_pct=decision_player_backgammon_pct,
|
|
873
|
+
opponent_win_pct=decision_opponent_win_pct,
|
|
874
|
+
opponent_gammon_pct=decision_opponent_gammon_pct,
|
|
875
|
+
opponent_backgammon_pct=decision_opponent_backgammon_pct
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
return decision
|
|
879
|
+
|
|
880
|
+
@staticmethod
|
|
881
|
+
def _normalize_move_notation(notation: str) -> str:
|
|
882
|
+
"""
|
|
883
|
+
Normalize move notation by sorting sub-moves.
|
|
884
|
+
|
|
885
|
+
This handles cases where "7/6 12/8" and "12/8 7/6" represent the same move
|
|
886
|
+
but with sub-moves in different order.
|
|
887
|
+
|
|
888
|
+
Args:
|
|
889
|
+
notation: Move notation string (e.g., "12/8 7/6")
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
Normalized notation with sub-moves sorted (e.g., "7/6 12/8")
|
|
893
|
+
"""
|
|
894
|
+
if not notation or notation == "Cannot move":
|
|
895
|
+
return notation
|
|
896
|
+
|
|
897
|
+
# Split into sub-moves
|
|
898
|
+
parts = notation.split()
|
|
899
|
+
|
|
900
|
+
# Sort sub-moves for consistent comparison
|
|
901
|
+
# Sort by from point (descending), then by to point
|
|
902
|
+
parts.sort(reverse=True)
|
|
903
|
+
|
|
904
|
+
return " ".join(parts)
|
|
905
|
+
|
|
906
|
+
@staticmethod
|
|
907
|
+
def _convert_move_notation(
|
|
908
|
+
xg_moves: Tuple[int, ...],
|
|
909
|
+
position: Optional[Position] = None,
|
|
910
|
+
on_roll: Optional[Player] = None
|
|
911
|
+
) -> str:
|
|
912
|
+
"""
|
|
913
|
+
Convert XG move notation to readable format with compound move combination and hit detection.
|
|
914
|
+
|
|
915
|
+
XG binary uses 0-based indexing for board points in move notation, while standard
|
|
916
|
+
backgammon notation uses 1-based indexing. This method adds 1 to all board point
|
|
917
|
+
numbers during conversion.
|
|
918
|
+
|
|
919
|
+
XG binary stores compound moves as separate sub-moves (e.g., 20/16 16/15), but
|
|
920
|
+
standard notation combines them (e.g., 20/15*). This function:
|
|
921
|
+
1. Converts 0-based to 1-based point numbering
|
|
922
|
+
2. Combines consecutive sub-moves into compound moves
|
|
923
|
+
3. Detects and marks hits with *
|
|
924
|
+
4. Detects duplicate moves and uses (2), (3), (4) notation for doublets
|
|
925
|
+
|
|
926
|
+
XG format: [from1, to1, from2, to2, from3, to3, from4, to4]
|
|
927
|
+
Special values:
|
|
928
|
+
- -1: End of move list OR bearing off (when used as destination)
|
|
929
|
+
- 24: Bar (both players when entering)
|
|
930
|
+
- 0-23: Board points (0-based, add 1 for standard notation)
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
xg_moves: Tuple of 8 integers
|
|
934
|
+
position: Position object for hit detection (optional)
|
|
935
|
+
on_roll: Player making the move (optional)
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
Move notation string (e.g., "20/15*", "bar/22", "15/9(2)")
|
|
939
|
+
Returns "Cannot move" for illegal/blocked positions (all zeros)
|
|
940
|
+
"""
|
|
941
|
+
if not xg_moves or len(xg_moves) < 2:
|
|
942
|
+
return ""
|
|
943
|
+
|
|
944
|
+
# Check for illegal/blocked move (all zeros)
|
|
945
|
+
if all(x == 0 for x in xg_moves):
|
|
946
|
+
return "Cannot move"
|
|
947
|
+
|
|
948
|
+
# Pass 1: Parse all sub-moves
|
|
949
|
+
sub_moves = []
|
|
950
|
+
for i in range(0, len(xg_moves), 2):
|
|
951
|
+
from_point = xg_moves[i]
|
|
952
|
+
|
|
953
|
+
# -1 indicates end of move
|
|
954
|
+
if from_point == -1:
|
|
955
|
+
break
|
|
956
|
+
|
|
957
|
+
if i + 1 >= len(xg_moves):
|
|
958
|
+
break
|
|
959
|
+
|
|
960
|
+
to_point = xg_moves[i + 1]
|
|
961
|
+
sub_moves.append((from_point, to_point))
|
|
962
|
+
|
|
963
|
+
if not sub_moves:
|
|
964
|
+
return ""
|
|
965
|
+
|
|
966
|
+
# Pass 2: Build adjacency map for chain detection
|
|
967
|
+
# Map from to_point -> list of indices that start from that point
|
|
968
|
+
from_point_map = {}
|
|
969
|
+
for idx, (from_point, to_point) in enumerate(sub_moves):
|
|
970
|
+
if from_point not in from_point_map:
|
|
971
|
+
from_point_map[from_point] = []
|
|
972
|
+
from_point_map[from_point].append(idx)
|
|
973
|
+
|
|
974
|
+
# Pass 3: Build chains with intermediate hit detection.
|
|
975
|
+
# Stop chain building at intermediate hits to preserve hit markers in notation.
|
|
976
|
+
used = [False] * len(sub_moves)
|
|
977
|
+
combined_moves = []
|
|
978
|
+
|
|
979
|
+
# Track destination points that have been hit.
|
|
980
|
+
# Only the first checker to land on a point can hit a blot.
|
|
981
|
+
destinations_hit = set()
|
|
982
|
+
|
|
983
|
+
# Sort sub-moves by from_point descending to process in order
|
|
984
|
+
sorted_indices = sorted(range(len(sub_moves)),
|
|
985
|
+
key=lambda i: sub_moves[i][0],
|
|
986
|
+
reverse=True)
|
|
987
|
+
|
|
988
|
+
for start_idx in sorted_indices:
|
|
989
|
+
if used[start_idx]:
|
|
990
|
+
continue
|
|
991
|
+
|
|
992
|
+
# Start a new chain
|
|
993
|
+
from_point, to_point = sub_moves[start_idx]
|
|
994
|
+
used[start_idx] = True
|
|
995
|
+
|
|
996
|
+
# Build a chain of intermediate points for hit checking
|
|
997
|
+
chain_points = [from_point, to_point]
|
|
998
|
+
|
|
999
|
+
# Extend the chain as far as possible, checking for hits at each step
|
|
1000
|
+
while to_point in from_point_map:
|
|
1001
|
+
# Find an unused move that starts from current to_point
|
|
1002
|
+
extended = False
|
|
1003
|
+
for next_idx in from_point_map[to_point]:
|
|
1004
|
+
if not used[next_idx]:
|
|
1005
|
+
# Check for hit at current destination before extending chain.
|
|
1006
|
+
# Only mark as hit if this is the first checker to this destination.
|
|
1007
|
+
hit_at_current = False
|
|
1008
|
+
if position and on_roll and 0 <= to_point <= 23:
|
|
1009
|
+
if to_point not in destinations_hit:
|
|
1010
|
+
checker_count = position.points[to_point + 1]
|
|
1011
|
+
if checker_count == 1:
|
|
1012
|
+
hit_at_current = True
|
|
1013
|
+
# Don't add to destinations_hit here - let final check handle it
|
|
1014
|
+
|
|
1015
|
+
if hit_at_current:
|
|
1016
|
+
# Stop extending to preserve hit marker at this point.
|
|
1017
|
+
break
|
|
1018
|
+
|
|
1019
|
+
_, next_to = sub_moves[next_idx]
|
|
1020
|
+
to_point = next_to
|
|
1021
|
+
chain_points.append(to_point)
|
|
1022
|
+
used[next_idx] = True
|
|
1023
|
+
extended = True
|
|
1024
|
+
break
|
|
1025
|
+
|
|
1026
|
+
if not extended:
|
|
1027
|
+
break
|
|
1028
|
+
|
|
1029
|
+
# Check for hit at the final destination.
|
|
1030
|
+
# Only mark as hit if this is the first checker to this destination.
|
|
1031
|
+
hit = False
|
|
1032
|
+
if position and on_roll and 0 <= to_point <= 23:
|
|
1033
|
+
if to_point not in destinations_hit:
|
|
1034
|
+
# Convert 0-based to 1-based for position lookup
|
|
1035
|
+
checker_count = position.points[to_point + 1]
|
|
1036
|
+
# Hit occurs if opponent has exactly 1 checker at destination.
|
|
1037
|
+
# After perspective transform, opponent checkers are always positive.
|
|
1038
|
+
if checker_count == 1:
|
|
1039
|
+
hit = True
|
|
1040
|
+
destinations_hit.add(to_point)
|
|
1041
|
+
|
|
1042
|
+
combined_moves.append((from_point, to_point, hit))
|
|
1043
|
+
|
|
1044
|
+
# Pass 4: Count duplicates and format
|
|
1045
|
+
from collections import Counter
|
|
1046
|
+
|
|
1047
|
+
# Count occurrences of each move (excluding hit marker for counting)
|
|
1048
|
+
move_counts = Counter((from_point, to_point) for from_point, to_point, _ in combined_moves)
|
|
1049
|
+
|
|
1050
|
+
# Track how many of each move we've seen (for numbering)
|
|
1051
|
+
move_seen = {}
|
|
1052
|
+
|
|
1053
|
+
# Sort combined moves for consistent output
|
|
1054
|
+
combined_moves.sort(key=lambda m: m[0], reverse=True)
|
|
1055
|
+
|
|
1056
|
+
# Format as notation strings
|
|
1057
|
+
parts = []
|
|
1058
|
+
for from_point, to_point, hit in combined_moves:
|
|
1059
|
+
move_key = (from_point, to_point)
|
|
1060
|
+
count = move_counts[move_key]
|
|
1061
|
+
|
|
1062
|
+
# Track this occurrence
|
|
1063
|
+
if move_key not in move_seen:
|
|
1064
|
+
move_seen[move_key] = 0
|
|
1065
|
+
move_seen[move_key] += 1
|
|
1066
|
+
occurrence = move_seen[move_key]
|
|
1067
|
+
|
|
1068
|
+
# Convert special values to standard backgammon notation
|
|
1069
|
+
# Handle from_point
|
|
1070
|
+
if from_point == 24:
|
|
1071
|
+
from_str = "bar" # Bar for both players
|
|
1072
|
+
else:
|
|
1073
|
+
from_str = str(from_point + 1) # Convert 0-based to 1-based (0→1, 23→24)
|
|
1074
|
+
|
|
1075
|
+
# Handle to_point
|
|
1076
|
+
if to_point == -1:
|
|
1077
|
+
to_str = "off" # Bearing off
|
|
1078
|
+
elif to_point == 24:
|
|
1079
|
+
to_str = "bar" # Opponent hit and sent to bar
|
|
1080
|
+
else:
|
|
1081
|
+
to_str = str(to_point + 1) # Convert 0-based to 1-based (0→1, 23→24)
|
|
1082
|
+
|
|
1083
|
+
# Build notation
|
|
1084
|
+
notation = f"{from_str}/{to_str}"
|
|
1085
|
+
if hit:
|
|
1086
|
+
notation += "*"
|
|
1087
|
+
|
|
1088
|
+
# Add doublet notation if this is the first occurrence and count > 1
|
|
1089
|
+
if occurrence == 1 and count > 1:
|
|
1090
|
+
notation += f"({count})"
|
|
1091
|
+
elif occurrence > 1:
|
|
1092
|
+
# Skip duplicate occurrences (already counted in first one)
|
|
1093
|
+
continue
|
|
1094
|
+
|
|
1095
|
+
parts.append(notation)
|
|
1096
|
+
|
|
1097
|
+
return " ".join(parts) if parts else ""
|