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.

Files changed (56) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +373 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +224 -0
  7. ankigammon/anki/apkg_exporter.py +123 -0
  8. ankigammon/anki/card_generator.py +1307 -0
  9. ankigammon/anki/card_styles.py +1034 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +209 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +597 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +163 -0
  15. ankigammon/gui/dialogs/input_dialog.py +776 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +384 -0
  18. ankigammon/gui/format_detector.py +292 -0
  19. ankigammon/gui/main_window.py +1071 -0
  20. ankigammon/gui/resources/icon.icns +0 -0
  21. ankigammon/gui/resources/icon.ico +0 -0
  22. ankigammon/gui/resources/icon.png +0 -0
  23. ankigammon/gui/resources/style.qss +394 -0
  24. ankigammon/gui/resources.py +26 -0
  25. ankigammon/gui/widgets/__init__.py +8 -0
  26. ankigammon/gui/widgets/position_list.py +193 -0
  27. ankigammon/gui/widgets/smart_input.py +268 -0
  28. ankigammon/models.py +322 -0
  29. ankigammon/parsers/__init__.py +7 -0
  30. ankigammon/parsers/gnubg_parser.py +454 -0
  31. ankigammon/parsers/xg_binary_parser.py +870 -0
  32. ankigammon/parsers/xg_text_parser.py +729 -0
  33. ankigammon/renderer/__init__.py +5 -0
  34. ankigammon/renderer/animation_controller.py +406 -0
  35. ankigammon/renderer/animation_helper.py +221 -0
  36. ankigammon/renderer/color_schemes.py +145 -0
  37. ankigammon/renderer/svg_board_renderer.py +824 -0
  38. ankigammon/settings.py +239 -0
  39. ankigammon/thirdparty/__init__.py +7 -0
  40. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  41. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  42. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  43. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  44. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  45. ankigammon/utils/__init__.py +13 -0
  46. ankigammon/utils/gnubg_analyzer.py +431 -0
  47. ankigammon/utils/gnuid.py +622 -0
  48. ankigammon/utils/move_parser.py +239 -0
  49. ankigammon/utils/ogid.py +335 -0
  50. ankigammon/utils/xgid.py +419 -0
  51. ankigammon-1.0.0.dist-info/METADATA +370 -0
  52. ankigammon-1.0.0.dist-info/RECORD +56 -0
  53. ankigammon-1.0.0.dist-info/WHEEL +5 -0
  54. ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
  55. ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
  56. 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 ""