ankigammon 1.0.0__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.
Potentially problematic release.
This version of ankigammon might be problematic. Click here for more details.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +373 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +224 -0
- ankigammon/anki/apkg_exporter.py +123 -0
- ankigammon/anki/card_generator.py +1307 -0
- ankigammon/anki/card_styles.py +1034 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +209 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +597 -0
- ankigammon/gui/dialogs/import_options_dialog.py +163 -0
- ankigammon/gui/dialogs/input_dialog.py +776 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +384 -0
- ankigammon/gui/format_detector.py +292 -0
- ankigammon/gui/main_window.py +1071 -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 +394 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +193 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +322 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_parser.py +454 -0
- ankigammon/parsers/xg_binary_parser.py +870 -0
- ankigammon/parsers/xg_text_parser.py +729 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +406 -0
- ankigammon/renderer/animation_helper.py +221 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +824 -0
- ankigammon/settings.py +239 -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 +431 -0
- ankigammon/utils/gnuid.py +622 -0
- ankigammon/utils/move_parser.py +239 -0
- ankigammon/utils/ogid.py +335 -0
- ankigammon/utils/xgid.py +419 -0
- ankigammon-1.0.0.dist-info/METADATA +370 -0
- ankigammon-1.0.0.dist-info/RECORD +56 -0
- ankigammon-1.0.0.dist-info/WHEEL +5 -0
- ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
- ankigammon-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,870 @@
|
|
|
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
|
+
score_x = record.Score1
|
|
135
|
+
score_o = record.Score2
|
|
136
|
+
crawford = bool(record.CrawfordApply)
|
|
137
|
+
logger.debug(f"Game header: score={score_x}-{score_o}, crawford={crawford}")
|
|
138
|
+
|
|
139
|
+
elif isinstance(record, xgstruct.MoveEntry):
|
|
140
|
+
try:
|
|
141
|
+
decision = XGBinaryParser._parse_move_entry(
|
|
142
|
+
record, match_length, score_x, score_o, crawford
|
|
143
|
+
)
|
|
144
|
+
if decision:
|
|
145
|
+
decisions.append(decision)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.warning(f"Failed to parse move entry: {e}")
|
|
148
|
+
|
|
149
|
+
elif isinstance(record, xgstruct.CubeEntry):
|
|
150
|
+
try:
|
|
151
|
+
decision = XGBinaryParser._parse_cube_entry(
|
|
152
|
+
record, match_length, score_x, score_o, crawford
|
|
153
|
+
)
|
|
154
|
+
if decision:
|
|
155
|
+
decisions.append(decision)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.warning(f"Failed to parse cube entry: {e}")
|
|
158
|
+
|
|
159
|
+
if not decisions:
|
|
160
|
+
raise ParseError("No valid positions found in file")
|
|
161
|
+
|
|
162
|
+
logger.info(f"Successfully parsed {len(decisions)} decisions from {file_path}")
|
|
163
|
+
return decisions
|
|
164
|
+
|
|
165
|
+
except xgimport.Error as e:
|
|
166
|
+
raise ParseError(f"XG import error: {e}")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise ParseError(f"Failed to parse .xg file: {e}")
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _transform_position(raw_points: List[int], on_roll: Player) -> Position:
|
|
172
|
+
"""
|
|
173
|
+
Transform XG binary position array to internal Position model.
|
|
174
|
+
|
|
175
|
+
XG binary format uses OPPOSITE sign convention from AnkiGammon:
|
|
176
|
+
- XG: Positive = O checkers, Negative = X checkers
|
|
177
|
+
- AnkiGammon: Positive = X checkers, Negative = O checkers
|
|
178
|
+
|
|
179
|
+
Therefore, we need to invert all signs when copying the position.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
raw_points: Raw 26-element position array from XG binary
|
|
183
|
+
on_roll: Player who is on roll
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Position object with correct internal representation
|
|
187
|
+
"""
|
|
188
|
+
position = Position()
|
|
189
|
+
|
|
190
|
+
# XG binary uses opposite sign convention - invert all signs
|
|
191
|
+
position.points = [-count for count in raw_points]
|
|
192
|
+
|
|
193
|
+
# Calculate borne-off checkers (each player starts with 15)
|
|
194
|
+
total_x = sum(count for count in position.points if count > 0)
|
|
195
|
+
total_o = sum(abs(count) for count in position.points if count < 0)
|
|
196
|
+
|
|
197
|
+
position.x_off = 15 - total_x
|
|
198
|
+
position.o_off = 15 - total_o
|
|
199
|
+
|
|
200
|
+
# Validate position
|
|
201
|
+
XGBinaryParser._validate_position(position)
|
|
202
|
+
|
|
203
|
+
return position
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _validate_position(position: Position) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Validate position to catch inversions and corruption.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
position: Position to validate
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: If position is invalid
|
|
215
|
+
"""
|
|
216
|
+
# Count checkers
|
|
217
|
+
total_x = sum(count for count in position.points if count > 0)
|
|
218
|
+
total_o = sum(abs(count) for count in position.points if count < 0)
|
|
219
|
+
|
|
220
|
+
# Each player should have at most 15 checkers on board
|
|
221
|
+
if total_x > 15:
|
|
222
|
+
raise ValueError(f"Invalid position: X has {total_x} checkers on board (max 15)")
|
|
223
|
+
if total_o > 15:
|
|
224
|
+
raise ValueError(f"Invalid position: O has {total_o} checkers on board (max 15)")
|
|
225
|
+
|
|
226
|
+
# Total checkers (on board + borne off) should be exactly 15 per player
|
|
227
|
+
if total_x + position.x_off != 15:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Invalid position: X has {total_x} on board + {position.x_off} off = "
|
|
230
|
+
f"{total_x + position.x_off} (expected 15)"
|
|
231
|
+
)
|
|
232
|
+
if total_o + position.o_off != 15:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"Invalid position: O has {total_o} on board + {position.o_off} off = "
|
|
235
|
+
f"{total_o + position.o_off} (expected 15)"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Check bar constraints (should be <= 2 per player in normal positions)
|
|
239
|
+
x_bar = position.points[0]
|
|
240
|
+
o_bar = abs(position.points[25])
|
|
241
|
+
if x_bar > 15: # Relaxed constraint - theoretically up to 15
|
|
242
|
+
raise ValueError(f"Invalid position: X has {x_bar} checkers on bar")
|
|
243
|
+
if o_bar > 15:
|
|
244
|
+
raise ValueError(f"Invalid position: O has {o_bar} checkers on bar")
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _parse_move_entry(
|
|
248
|
+
move_entry: xgstruct.MoveEntry,
|
|
249
|
+
match_length: int,
|
|
250
|
+
score_x: int,
|
|
251
|
+
score_o: int,
|
|
252
|
+
crawford: bool
|
|
253
|
+
) -> Optional[Decision]:
|
|
254
|
+
"""
|
|
255
|
+
Convert MoveEntry to Decision object.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
move_entry: MoveEntry from xgstruct
|
|
259
|
+
match_length: Match length (0 for money game)
|
|
260
|
+
score_x: Player X score
|
|
261
|
+
score_o: Player O score
|
|
262
|
+
crawford: Crawford game flag
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Decision object or None if invalid
|
|
266
|
+
"""
|
|
267
|
+
# Determine player on roll
|
|
268
|
+
# XG uses ActiveP: 1 or 2
|
|
269
|
+
# Map to AnkiGammon: Player.O (bottom) or Player.X (top)
|
|
270
|
+
on_roll = Player.O if move_entry.ActiveP == 1 else Player.X
|
|
271
|
+
|
|
272
|
+
# Create position from XG position array with perspective transformation
|
|
273
|
+
# XG binary format stores positions from the perspective of the player on roll,
|
|
274
|
+
# similar to XGID format. When X is on roll, the position needs to be flipped.
|
|
275
|
+
position = XGBinaryParser._transform_position(
|
|
276
|
+
list(move_entry.PositionI),
|
|
277
|
+
on_roll
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Get dice
|
|
281
|
+
dice = tuple(move_entry.Dice) if move_entry.Dice else None
|
|
282
|
+
|
|
283
|
+
# Parse cube state
|
|
284
|
+
cube_value = abs(move_entry.CubeA) if move_entry.CubeA != 0 else 1
|
|
285
|
+
if move_entry.CubeA > 0:
|
|
286
|
+
cube_owner = CubeState.X_OWNS # Player X owns
|
|
287
|
+
elif move_entry.CubeA < 0:
|
|
288
|
+
cube_owner = CubeState.O_OWNS # Player O owns
|
|
289
|
+
else:
|
|
290
|
+
cube_owner = CubeState.CENTERED
|
|
291
|
+
|
|
292
|
+
# Parse candidate moves from analysis
|
|
293
|
+
moves = []
|
|
294
|
+
if hasattr(move_entry, 'DataMoves') and move_entry.DataMoves:
|
|
295
|
+
data_moves = move_entry.DataMoves
|
|
296
|
+
n_moves = min(move_entry.NMoveEval, data_moves.NMoves)
|
|
297
|
+
|
|
298
|
+
for i in range(n_moves):
|
|
299
|
+
# Parse move notation with compound move combination and hit detection
|
|
300
|
+
notation = XGBinaryParser._convert_move_notation(
|
|
301
|
+
data_moves.Moves[i],
|
|
302
|
+
position,
|
|
303
|
+
on_roll
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Get equity (7-element tuple from XG)
|
|
307
|
+
# XG Format: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
|
|
308
|
+
# Indices: [0] [1] [2] [3] [4] [5] [6]
|
|
309
|
+
#
|
|
310
|
+
# IMPORTANT: Despite the naming, these are CUMULATIVE probabilities:
|
|
311
|
+
# Lose_S (index 2) = TOTAL losses (all types: normal + gammon + backgammon)
|
|
312
|
+
# Lose_G (index 1) = Gammon + backgammon losses (subset of Lose_S)
|
|
313
|
+
# Lose_BG (index 0) = Backgammon losses only (subset of Lose_G)
|
|
314
|
+
# Win_S (index 3) = TOTAL wins (all types: normal + gammon + backgammon)
|
|
315
|
+
# Win_G (index 4) = Gammon + backgammon wins (subset of Win_S)
|
|
316
|
+
# Win_BG (index 5) = Backgammon wins only (subset of Win_G)
|
|
317
|
+
# Equity (index 6) = Overall equity value
|
|
318
|
+
#
|
|
319
|
+
# Note: Lose_S + Win_S = 1.0 (or very close to 1.0)
|
|
320
|
+
equity_tuple = data_moves.Eval[i]
|
|
321
|
+
equity = equity_tuple[6] # Overall equity at index 6
|
|
322
|
+
|
|
323
|
+
# Extract winning chances (convert from decimals to percentages)
|
|
324
|
+
# Store cumulative values as displayed by XG/GnuBG:
|
|
325
|
+
# "Player: 50.41% (G:15.40% B:2.03%)" means:
|
|
326
|
+
# 50.41% total wins, of which 15.40% are gammon or better,
|
|
327
|
+
# of which 2.03% are backgammon
|
|
328
|
+
opponent_win_pct = equity_tuple[2] * 100 # Total opponent wins (index 2 = Lose_S)
|
|
329
|
+
opponent_gammon_pct = equity_tuple[1] * 100 # Opp gammon+BG (index 1 = Lose_G)
|
|
330
|
+
opponent_backgammon_pct = equity_tuple[0] * 100 # Opp BG only (index 0 = Lose_BG)
|
|
331
|
+
player_win_pct = equity_tuple[3] * 100 # Total player wins (index 3 = Win_S)
|
|
332
|
+
player_gammon_pct = equity_tuple[4] * 100 # Player gammon+BG (index 4 = Win_G)
|
|
333
|
+
player_backgammon_pct = equity_tuple[5] * 100 # Player BG only (index 5 = Win_BG)
|
|
334
|
+
|
|
335
|
+
move = Move(
|
|
336
|
+
notation=notation,
|
|
337
|
+
equity=equity,
|
|
338
|
+
error=0.0, # Will be calculated based on best move
|
|
339
|
+
rank=i + 1, # Temporary rank
|
|
340
|
+
xg_rank=i + 1,
|
|
341
|
+
xg_error=0.0,
|
|
342
|
+
xg_notation=notation,
|
|
343
|
+
from_xg_analysis=True,
|
|
344
|
+
player_win_pct=player_win_pct,
|
|
345
|
+
player_gammon_pct=player_gammon_pct,
|
|
346
|
+
player_backgammon_pct=player_backgammon_pct,
|
|
347
|
+
opponent_win_pct=opponent_win_pct,
|
|
348
|
+
opponent_gammon_pct=opponent_gammon_pct,
|
|
349
|
+
opponent_backgammon_pct=opponent_backgammon_pct
|
|
350
|
+
)
|
|
351
|
+
moves.append(move)
|
|
352
|
+
|
|
353
|
+
# Mark which move was actually played
|
|
354
|
+
if hasattr(move_entry, 'Moves') and move_entry.Moves:
|
|
355
|
+
played_notation = XGBinaryParser._convert_move_notation(
|
|
356
|
+
move_entry.Moves,
|
|
357
|
+
position,
|
|
358
|
+
on_roll
|
|
359
|
+
)
|
|
360
|
+
# Normalize by sorting sub-moves for comparison
|
|
361
|
+
played_normalized = XGBinaryParser._normalize_move_notation(played_notation)
|
|
362
|
+
|
|
363
|
+
for move in moves:
|
|
364
|
+
move_normalized = XGBinaryParser._normalize_move_notation(move.notation)
|
|
365
|
+
if move_normalized == played_normalized:
|
|
366
|
+
move.was_played = True
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
# Sort moves by equity (highest first) and assign ranks
|
|
370
|
+
if moves:
|
|
371
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
372
|
+
best_equity = moves[0].equity
|
|
373
|
+
|
|
374
|
+
for i, move in enumerate(moves):
|
|
375
|
+
move.rank = i + 1
|
|
376
|
+
move.error = abs(best_equity - move.equity)
|
|
377
|
+
move.xg_error = move.equity - best_equity # Negative for worse moves
|
|
378
|
+
|
|
379
|
+
# Generate XGID for the position
|
|
380
|
+
crawford_jacoby = 1 if crawford else 0
|
|
381
|
+
xgid = position.to_xgid(
|
|
382
|
+
cube_value=cube_value,
|
|
383
|
+
cube_owner=cube_owner,
|
|
384
|
+
dice=dice,
|
|
385
|
+
on_roll=on_roll,
|
|
386
|
+
score_x=score_x,
|
|
387
|
+
score_o=score_o,
|
|
388
|
+
match_length=match_length,
|
|
389
|
+
crawford_jacoby=crawford_jacoby
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Create Decision
|
|
393
|
+
decision = Decision(
|
|
394
|
+
position=position,
|
|
395
|
+
on_roll=on_roll,
|
|
396
|
+
dice=dice,
|
|
397
|
+
score_x=score_x,
|
|
398
|
+
score_o=score_o,
|
|
399
|
+
match_length=match_length,
|
|
400
|
+
crawford=crawford,
|
|
401
|
+
cube_value=cube_value,
|
|
402
|
+
cube_owner=cube_owner,
|
|
403
|
+
decision_type=DecisionType.CHECKER_PLAY,
|
|
404
|
+
candidate_moves=moves,
|
|
405
|
+
xgid=xgid
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return decision
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def _parse_cube_entry(
|
|
412
|
+
cube_entry: xgstruct.CubeEntry,
|
|
413
|
+
match_length: int,
|
|
414
|
+
score_x: int,
|
|
415
|
+
score_o: int,
|
|
416
|
+
crawford: bool
|
|
417
|
+
) -> Optional[Decision]:
|
|
418
|
+
"""
|
|
419
|
+
Convert CubeEntry to Decision object.
|
|
420
|
+
|
|
421
|
+
XG binary files contain cube entries for all cube decisions in a game,
|
|
422
|
+
but not all of them are analyzed. This method filters out unanalyzed
|
|
423
|
+
cube decisions and extracts equity values from analyzed ones.
|
|
424
|
+
|
|
425
|
+
Unanalyzed cube decisions are identified by:
|
|
426
|
+
- FlagDouble == -100 or -1000 (indicates not analyzed)
|
|
427
|
+
- All equities are 0.0 and position is empty
|
|
428
|
+
|
|
429
|
+
Analyzed cube decisions contain:
|
|
430
|
+
- equB: Equity for "No Double"
|
|
431
|
+
- equDouble: Equity for "Double/Take"
|
|
432
|
+
- equDrop: Equity for "Double/Pass" (typically -1.0 for opponent)
|
|
433
|
+
- Eval: Win probabilities for "No Double" scenario
|
|
434
|
+
- EvalDouble: Win probabilities for "Double/Take" scenario
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
cube_entry: CubeEntry from xgstruct
|
|
438
|
+
match_length: Match length (0 for money game)
|
|
439
|
+
score_x: Player X score
|
|
440
|
+
score_o: Player O score
|
|
441
|
+
crawford: Crawford game flag
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Decision object with 5 cube options, or None if unanalyzed
|
|
445
|
+
"""
|
|
446
|
+
# Determine player on roll
|
|
447
|
+
on_roll = Player.O if cube_entry.ActiveP == 1 else Player.X
|
|
448
|
+
|
|
449
|
+
# Create position with perspective transformation
|
|
450
|
+
position = XGBinaryParser._transform_position(
|
|
451
|
+
list(cube_entry.Position),
|
|
452
|
+
on_roll
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Parse cube state
|
|
456
|
+
cube_value = abs(cube_entry.CubeB) if cube_entry.CubeB != 0 else 1
|
|
457
|
+
if cube_entry.CubeB > 0:
|
|
458
|
+
cube_owner = CubeState.X_OWNS
|
|
459
|
+
elif cube_entry.CubeB < 0:
|
|
460
|
+
cube_owner = CubeState.O_OWNS
|
|
461
|
+
else:
|
|
462
|
+
cube_owner = CubeState.CENTERED
|
|
463
|
+
|
|
464
|
+
# Parse cube decisions from Doubled analysis
|
|
465
|
+
moves = []
|
|
466
|
+
if hasattr(cube_entry, 'Doubled') and cube_entry.Doubled:
|
|
467
|
+
doubled = cube_entry.Doubled
|
|
468
|
+
|
|
469
|
+
# Check if cube decision was analyzed
|
|
470
|
+
# FlagDouble -100 or -1000 indicates unanalyzed position
|
|
471
|
+
flag_double = doubled.get('FlagDouble', -100)
|
|
472
|
+
if flag_double in (-100, -1000):
|
|
473
|
+
logger.debug("Skipping unanalyzed cube decision (FlagDouble=%d)", flag_double)
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
# Extract equities
|
|
477
|
+
eq_no_double = doubled.get('equB', 0.0)
|
|
478
|
+
eq_double_take = doubled.get('equDouble', 0.0)
|
|
479
|
+
eq_double_drop = doubled.get('equDrop', -1.0)
|
|
480
|
+
|
|
481
|
+
# Validate that we have actual analysis data
|
|
482
|
+
# If all equities are zero and position is empty, skip this decision
|
|
483
|
+
if (eq_no_double == 0.0 and eq_double_take == 0.0 and
|
|
484
|
+
abs(eq_double_drop - (-1.0)) < 0.001):
|
|
485
|
+
# Check if position has any checkers
|
|
486
|
+
pos = doubled.get('Pos', None)
|
|
487
|
+
if pos and all(v == 0 for v in pos):
|
|
488
|
+
logger.debug("Skipping cube decision with no analysis data")
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
# Extract winning chances
|
|
492
|
+
eval_no_double = doubled.get('Eval', None)
|
|
493
|
+
eval_double = doubled.get('EvalDouble', None)
|
|
494
|
+
|
|
495
|
+
# Create 5 cube options (similar to XGTextParser)
|
|
496
|
+
cube_options = []
|
|
497
|
+
|
|
498
|
+
# 1. No double
|
|
499
|
+
if eval_no_double:
|
|
500
|
+
cube_options.append({
|
|
501
|
+
'notation': 'No Double/Take',
|
|
502
|
+
'equity': eq_no_double,
|
|
503
|
+
'xg_notation': 'No double',
|
|
504
|
+
'from_xg': True,
|
|
505
|
+
'eval': eval_no_double
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
# 2. Double/Take
|
|
509
|
+
if eval_double:
|
|
510
|
+
cube_options.append({
|
|
511
|
+
'notation': 'Double/Take',
|
|
512
|
+
'equity': eq_double_take,
|
|
513
|
+
'xg_notation': 'Double/Take',
|
|
514
|
+
'from_xg': True,
|
|
515
|
+
'eval': eval_double
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
# 3. Double/Pass
|
|
519
|
+
cube_options.append({
|
|
520
|
+
'notation': 'Double/Pass',
|
|
521
|
+
'equity': eq_double_drop,
|
|
522
|
+
'xg_notation': 'Double/Pass',
|
|
523
|
+
'from_xg': True,
|
|
524
|
+
'eval': None
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
# 4 & 5. Too good options (synthetic)
|
|
528
|
+
cube_options.append({
|
|
529
|
+
'notation': 'Too good/Take',
|
|
530
|
+
'equity': eq_double_drop,
|
|
531
|
+
'xg_notation': None,
|
|
532
|
+
'from_xg': False,
|
|
533
|
+
'eval': None
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
cube_options.append({
|
|
537
|
+
'notation': 'Too good/Pass',
|
|
538
|
+
'equity': eq_double_drop,
|
|
539
|
+
'xg_notation': None,
|
|
540
|
+
'from_xg': False,
|
|
541
|
+
'eval': None
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
# Create Move objects
|
|
545
|
+
for i, opt in enumerate(cube_options):
|
|
546
|
+
eval_data = opt.get('eval')
|
|
547
|
+
|
|
548
|
+
# Extract winning chances if available
|
|
549
|
+
player_win_pct = None
|
|
550
|
+
player_gammon_pct = None
|
|
551
|
+
player_backgammon_pct = None
|
|
552
|
+
opponent_win_pct = None
|
|
553
|
+
opponent_gammon_pct = None
|
|
554
|
+
opponent_backgammon_pct = None
|
|
555
|
+
|
|
556
|
+
if eval_data and len(eval_data) >= 7:
|
|
557
|
+
# Same format as MoveEntry: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
|
|
558
|
+
# Cumulative probabilities where Lose_S and Win_S are totals
|
|
559
|
+
opponent_win_pct = eval_data[2] * 100 # Total opponent wins (Lose_S)
|
|
560
|
+
opponent_gammon_pct = eval_data[1] * 100 # Opp gammon+BG (Lose_G)
|
|
561
|
+
opponent_backgammon_pct = eval_data[0] * 100 # Opp BG only (Lose_BG)
|
|
562
|
+
player_win_pct = eval_data[3] * 100 # Total player wins (Win_S)
|
|
563
|
+
player_gammon_pct = eval_data[4] * 100 # Player gammon+BG (Win_G)
|
|
564
|
+
player_backgammon_pct = eval_data[5] * 100 # Player BG only (Win_BG)
|
|
565
|
+
|
|
566
|
+
move = Move(
|
|
567
|
+
notation=opt['notation'],
|
|
568
|
+
equity=opt['equity'],
|
|
569
|
+
error=0.0,
|
|
570
|
+
rank=0, # Will be assigned later
|
|
571
|
+
xg_rank=i + 1 if opt['from_xg'] else None,
|
|
572
|
+
xg_error=None,
|
|
573
|
+
xg_notation=opt['xg_notation'],
|
|
574
|
+
from_xg_analysis=opt['from_xg'],
|
|
575
|
+
player_win_pct=player_win_pct,
|
|
576
|
+
player_gammon_pct=player_gammon_pct,
|
|
577
|
+
player_backgammon_pct=player_backgammon_pct,
|
|
578
|
+
opponent_win_pct=opponent_win_pct,
|
|
579
|
+
opponent_gammon_pct=opponent_gammon_pct,
|
|
580
|
+
opponent_backgammon_pct=opponent_backgammon_pct
|
|
581
|
+
)
|
|
582
|
+
moves.append(move)
|
|
583
|
+
|
|
584
|
+
# Mark which cube action was actually played
|
|
585
|
+
# Double: 0=no double, 1=doubled
|
|
586
|
+
# Take: 0=pass, 1=take, 2=beaver
|
|
587
|
+
if hasattr(cube_entry, 'Double') and hasattr(cube_entry, 'Take'):
|
|
588
|
+
if cube_entry.Double == 0:
|
|
589
|
+
# No double was the action taken
|
|
590
|
+
played_action = 'No Double/Take'
|
|
591
|
+
elif cube_entry.Double == 1:
|
|
592
|
+
if cube_entry.Take == 1:
|
|
593
|
+
# Doubled and taken
|
|
594
|
+
played_action = 'Double/Take'
|
|
595
|
+
else:
|
|
596
|
+
# Doubled and passed
|
|
597
|
+
played_action = 'Double/Pass'
|
|
598
|
+
else:
|
|
599
|
+
played_action = None
|
|
600
|
+
|
|
601
|
+
if played_action:
|
|
602
|
+
for move in moves:
|
|
603
|
+
if move.notation == played_action:
|
|
604
|
+
move.was_played = True
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
# Determine best move and assign ranks
|
|
608
|
+
# Cube decision logic must account for perfect opponent response.
|
|
609
|
+
# Key insight: equDouble represents equity if opponent TAKES, but opponent
|
|
610
|
+
# will only take if it's correct for them.
|
|
611
|
+
#
|
|
612
|
+
# Algorithm:
|
|
613
|
+
# 1. Determine opponent's correct response: take or pass?
|
|
614
|
+
# - If equDouble > equDrop: opponent should PASS (taking is worse for them)
|
|
615
|
+
# - If equDouble < equDrop: opponent should TAKE (taking is better for them)
|
|
616
|
+
# 2. Compare equB (No Double) vs the correct doubling equity
|
|
617
|
+
# - If opponent passes: compare equB vs equDrop (Double/Pass)
|
|
618
|
+
# - If opponent takes: compare equB vs equDouble (Double/Take)
|
|
619
|
+
if moves:
|
|
620
|
+
# Find the three main cube options
|
|
621
|
+
no_double_move = None
|
|
622
|
+
double_take_move = None
|
|
623
|
+
double_pass_move = None
|
|
624
|
+
|
|
625
|
+
for move in moves:
|
|
626
|
+
if move.notation == "No Double/Take":
|
|
627
|
+
no_double_move = move
|
|
628
|
+
elif move.notation == "Double/Take":
|
|
629
|
+
double_take_move = move
|
|
630
|
+
elif move.notation == "Double/Pass":
|
|
631
|
+
double_pass_move = move
|
|
632
|
+
|
|
633
|
+
if no_double_move and double_take_move and double_pass_move:
|
|
634
|
+
# Step 1: Determine opponent's correct response
|
|
635
|
+
# If equDouble > equDrop, opponent should pass (taking gives them worse equity)
|
|
636
|
+
if double_take_move.equity > double_pass_move.equity:
|
|
637
|
+
# Opponent should PASS
|
|
638
|
+
# Compare No Double vs Double/Pass
|
|
639
|
+
if no_double_move.equity >= double_pass_move.equity:
|
|
640
|
+
best_move_notation = "No Double/Take"
|
|
641
|
+
best_equity = no_double_move.equity
|
|
642
|
+
else:
|
|
643
|
+
best_move_notation = "Double/Pass"
|
|
644
|
+
best_equity = double_pass_move.equity
|
|
645
|
+
else:
|
|
646
|
+
# Opponent should TAKE
|
|
647
|
+
# Compare No Double vs Double/Take
|
|
648
|
+
if no_double_move.equity >= double_take_move.equity:
|
|
649
|
+
best_move_notation = "No Double/Take"
|
|
650
|
+
best_equity = no_double_move.equity
|
|
651
|
+
else:
|
|
652
|
+
best_move_notation = "Double/Take"
|
|
653
|
+
best_equity = double_take_move.equity
|
|
654
|
+
elif no_double_move:
|
|
655
|
+
best_move_notation = "No Double/Take"
|
|
656
|
+
best_equity = no_double_move.equity
|
|
657
|
+
elif double_take_move:
|
|
658
|
+
best_move_notation = "Double/Take"
|
|
659
|
+
best_equity = double_take_move.equity
|
|
660
|
+
else:
|
|
661
|
+
# Fallback: sort by equity
|
|
662
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
663
|
+
best_move_notation = moves[0].notation
|
|
664
|
+
best_equity = moves[0].equity
|
|
665
|
+
|
|
666
|
+
# Assign rank 1 to best move
|
|
667
|
+
for move in moves:
|
|
668
|
+
if move.notation == best_move_notation:
|
|
669
|
+
move.rank = 1
|
|
670
|
+
move.error = 0.0
|
|
671
|
+
if move.from_xg_analysis:
|
|
672
|
+
move.xg_error = 0.0
|
|
673
|
+
|
|
674
|
+
# Assign ranks 2-5 to other moves based on equity
|
|
675
|
+
other_moves = [m for m in moves if m.notation != best_move_notation]
|
|
676
|
+
other_moves.sort(key=lambda m: m.equity, reverse=True)
|
|
677
|
+
|
|
678
|
+
for i, move in enumerate(other_moves):
|
|
679
|
+
move.rank = i + 2 # Ranks 2, 3, 4, 5
|
|
680
|
+
move.error = abs(best_equity - move.equity)
|
|
681
|
+
if move.from_xg_analysis:
|
|
682
|
+
move.xg_error = move.equity - best_equity
|
|
683
|
+
|
|
684
|
+
# Generate XGID for the position
|
|
685
|
+
crawford_jacoby = 1 if crawford else 0
|
|
686
|
+
xgid = position.to_xgid(
|
|
687
|
+
cube_value=cube_value,
|
|
688
|
+
cube_owner=cube_owner,
|
|
689
|
+
dice=None, # No dice for cube decisions
|
|
690
|
+
on_roll=on_roll,
|
|
691
|
+
score_x=score_x,
|
|
692
|
+
score_o=score_o,
|
|
693
|
+
match_length=match_length,
|
|
694
|
+
crawford_jacoby=crawford_jacoby
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
# Create Decision
|
|
698
|
+
decision = Decision(
|
|
699
|
+
position=position,
|
|
700
|
+
on_roll=on_roll,
|
|
701
|
+
dice=None, # No dice for cube decisions
|
|
702
|
+
score_x=score_x,
|
|
703
|
+
score_o=score_o,
|
|
704
|
+
match_length=match_length,
|
|
705
|
+
crawford=crawford,
|
|
706
|
+
cube_value=cube_value,
|
|
707
|
+
cube_owner=cube_owner,
|
|
708
|
+
decision_type=DecisionType.CUBE_ACTION,
|
|
709
|
+
candidate_moves=moves,
|
|
710
|
+
xgid=xgid
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
return decision
|
|
714
|
+
|
|
715
|
+
@staticmethod
|
|
716
|
+
def _normalize_move_notation(notation: str) -> str:
|
|
717
|
+
"""
|
|
718
|
+
Normalize move notation by sorting sub-moves.
|
|
719
|
+
|
|
720
|
+
This handles cases where "7/6 12/8" and "12/8 7/6" represent the same move
|
|
721
|
+
but with sub-moves in different order.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
notation: Move notation string (e.g., "12/8 7/6")
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
Normalized notation with sub-moves sorted (e.g., "7/6 12/8")
|
|
728
|
+
"""
|
|
729
|
+
if not notation or notation == "Cannot move":
|
|
730
|
+
return notation
|
|
731
|
+
|
|
732
|
+
# Split into sub-moves
|
|
733
|
+
parts = notation.split()
|
|
734
|
+
|
|
735
|
+
# Sort sub-moves for consistent comparison
|
|
736
|
+
# Sort by from point (descending), then by to point
|
|
737
|
+
parts.sort(reverse=True)
|
|
738
|
+
|
|
739
|
+
return " ".join(parts)
|
|
740
|
+
|
|
741
|
+
@staticmethod
|
|
742
|
+
def _convert_move_notation(
|
|
743
|
+
xg_moves: Tuple[int, ...],
|
|
744
|
+
position: Optional[Position] = None,
|
|
745
|
+
on_roll: Optional[Player] = None
|
|
746
|
+
) -> str:
|
|
747
|
+
"""
|
|
748
|
+
Convert XG move notation to readable format with compound move combination and hit detection.
|
|
749
|
+
|
|
750
|
+
IMPORTANT: XG binary uses 0-based indexing for board points in move notation.
|
|
751
|
+
- XG binary: 0-23 for board points
|
|
752
|
+
- Standard notation: 1-24 for board points
|
|
753
|
+
- Therefore, we add 1 to convert board point numbers to standard notation
|
|
754
|
+
|
|
755
|
+
XG binary stores compound moves as separate sub-moves (e.g., 20/16 16/15)
|
|
756
|
+
but standard notation combines them (e.g., 20/15*). This function:
|
|
757
|
+
1. Converts 0-based to 1-based point numbering
|
|
758
|
+
2. Combines consecutive sub-moves into compound moves
|
|
759
|
+
3. Detects and marks hits with *
|
|
760
|
+
|
|
761
|
+
XG format: [from1, to1, from2, to2, from3, to3, from4, to4]
|
|
762
|
+
Special values:
|
|
763
|
+
- -1: End of move list OR bearing off (when used as destination)
|
|
764
|
+
- 0: X's bar (white, top player) OR illegal/blocked move if all zeros
|
|
765
|
+
- 1-23: Board points (0-based, must add 1 for standard notation)
|
|
766
|
+
- 25: O's bar (black, bottom player)
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
xg_moves: Tuple of 8 integers
|
|
770
|
+
position: Position object for hit detection (optional)
|
|
771
|
+
on_roll: Player making the move (optional)
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
Move notation string (e.g., "20/15*", "bar/22", "1/off 2/off")
|
|
775
|
+
Returns "Cannot move" for illegal/blocked positions (all zeros)
|
|
776
|
+
"""
|
|
777
|
+
if not xg_moves or len(xg_moves) < 2:
|
|
778
|
+
return ""
|
|
779
|
+
|
|
780
|
+
# Check for illegal/blocked move (all zeros)
|
|
781
|
+
if all(x == 0 for x in xg_moves):
|
|
782
|
+
return "Cannot move"
|
|
783
|
+
|
|
784
|
+
# First pass: Parse all sub-moves
|
|
785
|
+
sub_moves = []
|
|
786
|
+
for i in range(0, len(xg_moves), 2):
|
|
787
|
+
from_point = xg_moves[i]
|
|
788
|
+
|
|
789
|
+
# -1 indicates end of move
|
|
790
|
+
if from_point == -1:
|
|
791
|
+
break
|
|
792
|
+
|
|
793
|
+
if i + 1 >= len(xg_moves):
|
|
794
|
+
break
|
|
795
|
+
|
|
796
|
+
to_point = xg_moves[i + 1]
|
|
797
|
+
sub_moves.append((from_point, to_point))
|
|
798
|
+
|
|
799
|
+
if not sub_moves:
|
|
800
|
+
return ""
|
|
801
|
+
|
|
802
|
+
# Sort sub-moves to enable better combination
|
|
803
|
+
# Sort by from_point descending (highest point first)
|
|
804
|
+
# This ensures that compound moves are combined optimally
|
|
805
|
+
# Example: [(7,5), (5,3), (3,1), (3,1)] combines to [(7,1), (3,1)] = "8/2* 4/2*"
|
|
806
|
+
# Without sorting: [(5,3), (3,1), (3,1), (7,5)] combines to [(5,1), (3,1), (7,5)] = "6/2* 4/2* 8/6"
|
|
807
|
+
sub_moves.sort(key=lambda m: m[0], reverse=True)
|
|
808
|
+
|
|
809
|
+
# Second pass: Combine compound moves
|
|
810
|
+
# Look for patterns where to_point of one move equals from_point of next
|
|
811
|
+
combined_moves = []
|
|
812
|
+
i = 0
|
|
813
|
+
while i < len(sub_moves):
|
|
814
|
+
from_point, to_point = sub_moves[i]
|
|
815
|
+
|
|
816
|
+
# Look ahead to see if we can combine with next move(s)
|
|
817
|
+
j = i + 1
|
|
818
|
+
while j < len(sub_moves):
|
|
819
|
+
next_from, next_to = sub_moves[j]
|
|
820
|
+
# Can combine if this move's destination is the next move's source
|
|
821
|
+
if to_point == next_from:
|
|
822
|
+
to_point = next_to
|
|
823
|
+
j += 1
|
|
824
|
+
else:
|
|
825
|
+
break
|
|
826
|
+
|
|
827
|
+
# Check for hit if position is available
|
|
828
|
+
hit = False
|
|
829
|
+
if position and on_roll and 0 <= to_point <= 23:
|
|
830
|
+
# Convert 0-based to 1-based for position lookup
|
|
831
|
+
checker_count = position.points[to_point + 1]
|
|
832
|
+
# Hit occurs if opponent has exactly 1 checker at destination
|
|
833
|
+
if on_roll == Player.X and checker_count == -1:
|
|
834
|
+
hit = True # X hitting O
|
|
835
|
+
elif on_roll == Player.O and checker_count == 1:
|
|
836
|
+
hit = True # O hitting X
|
|
837
|
+
|
|
838
|
+
combined_moves.append((from_point, to_point, hit))
|
|
839
|
+
i = j
|
|
840
|
+
|
|
841
|
+
# Third pass: Format as notation strings
|
|
842
|
+
parts = []
|
|
843
|
+
for from_point, to_point, hit in combined_moves:
|
|
844
|
+
# Convert special values to standard backgammon notation
|
|
845
|
+
# Handle from_point
|
|
846
|
+
if from_point == 0:
|
|
847
|
+
from_str = "bar" # X's bar
|
|
848
|
+
elif from_point == 25:
|
|
849
|
+
from_str = "bar" # O's bar
|
|
850
|
+
else:
|
|
851
|
+
from_str = str(from_point + 1) # Convert 0-based to 1-based
|
|
852
|
+
|
|
853
|
+
# Handle to_point
|
|
854
|
+
if to_point == -1:
|
|
855
|
+
to_str = "off" # Bearing off
|
|
856
|
+
elif to_point == 0:
|
|
857
|
+
to_str = "bar" # X's bar (opponent hit and sent to bar)
|
|
858
|
+
elif to_point == 25:
|
|
859
|
+
to_str = "bar" # O's bar (opponent hit and sent to bar)
|
|
860
|
+
else:
|
|
861
|
+
to_str = str(to_point + 1) # Convert 0-based to 1-based
|
|
862
|
+
|
|
863
|
+
# Add hit marker if applicable
|
|
864
|
+
notation = f"{from_str}/{to_str}"
|
|
865
|
+
if hit:
|
|
866
|
+
notation += "*"
|
|
867
|
+
|
|
868
|
+
parts.append(notation)
|
|
869
|
+
|
|
870
|
+
return " ".join(parts) if parts else ""
|