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.
Files changed (61) 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 +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. ankigammon-1.0.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1094 @@
1
+ """
2
+ GNU Backgammon match text export parser.
3
+
4
+ Parses 'export match text' output from gnubg into Decision objects.
5
+ """
6
+
7
+ import re
8
+ from typing import List, Optional, Tuple, Dict
9
+ from pathlib import Path
10
+
11
+ from ankigammon.models import Decision, DecisionType, Move, Player, Position, CubeState
12
+ from ankigammon.utils.gnuid import parse_gnuid
13
+
14
+
15
+ class GNUBGMatchParser:
16
+ """Parse GNU Backgammon 'export match text' output."""
17
+
18
+ @staticmethod
19
+ def extract_player_names_from_mat(mat_file_path: str) -> Tuple[str, str]:
20
+ """
21
+ Extract player names from .mat file header.
22
+
23
+ Args:
24
+ mat_file_path: Path to .mat file
25
+
26
+ Returns:
27
+ Tuple of (player1_name, player2_name)
28
+ Defaults to ("Player 1", "Player 2") if not found
29
+ """
30
+ player1 = "Player 1"
31
+ player2 = "Player 2"
32
+
33
+ try:
34
+ with open(mat_file_path, 'r', encoding='utf-8') as f:
35
+ # Read first 1000 characters (header section)
36
+ header = f.read(1000)
37
+
38
+ # Format 1: Semicolon header (OpenGammon, Backgammon Studio)
39
+ player1_match = re.search(r';\s*\[Player 1\s+"([^"]+)"\]', header, re.IGNORECASE)
40
+ player2_match = re.search(r';\s*\[Player 2\s+"([^"]+)"\]', header, re.IGNORECASE)
41
+
42
+ if player1_match:
43
+ player1 = player1_match.group(1)
44
+ if player2_match:
45
+ player2 = player2_match.group(1)
46
+
47
+ # Format 2: Score line (plain text match files)
48
+ if player1 == "Player 1" or player2 == "Player 2":
49
+ score_match = re.search(
50
+ r'^\s*([A-Za-z0-9_]+)\s*:\s*\d+\s+([A-Za-z0-9_]+)\s*:\s*\d+',
51
+ header,
52
+ re.MULTILINE
53
+ )
54
+ if score_match:
55
+ player1 = score_match.group(1)
56
+ player2 = score_match.group(2)
57
+
58
+ except Exception:
59
+ pass
60
+
61
+ return player1, player2
62
+
63
+ @staticmethod
64
+ def parse_match_files(file_paths: List[str], is_sgf_source: bool = False) -> List[Decision]:
65
+ """
66
+ Parse multiple gnubg match export files into Decision objects.
67
+
68
+ Args:
69
+ file_paths: List of paths to text files (one per game)
70
+ is_sgf_source: True if original source was SGF file (scores need swapping)
71
+
72
+ Returns:
73
+ List of Decision objects for all positions with analysis
74
+
75
+ Raises:
76
+ ValueError: If parsing fails
77
+ """
78
+ import logging
79
+ logger = logging.getLogger(__name__)
80
+
81
+ all_decisions = []
82
+
83
+ logger.info(f"\n=== Parsing {len(file_paths)} game files ===")
84
+ for i, file_path in enumerate(file_paths, 1):
85
+ logger.info(f"\nGame {i}: {Path(file_path).name}")
86
+ decisions = GNUBGMatchParser.parse_file(file_path, is_sgf_source=is_sgf_source)
87
+ logger.info(f" Parsed {len(decisions)} decisions")
88
+
89
+ # Show cube decisions for debugging
90
+ cube_decisions = [d for d in decisions if d.decision_type == DecisionType.CUBE_ACTION]
91
+ logger.info(f" Found {len(cube_decisions)} cube decisions")
92
+ if cube_decisions:
93
+ for cd in cube_decisions:
94
+ attr = cd.get_cube_error_attribution()
95
+ doubler_err = attr['doubler_error']
96
+ responder_err = attr['responder_error']
97
+ logger.info(f" Move {cd.move_number}: doubler={cd.on_roll}, doubler_error={doubler_err}, responder_error={responder_err}")
98
+
99
+ all_decisions.extend(decisions)
100
+
101
+ logger.info(f"\n=== Total: {len(all_decisions)} decisions from all games ===\n")
102
+ return all_decisions
103
+
104
+ @staticmethod
105
+ def parse_file(file_path: str, is_sgf_source: bool = False) -> List[Decision]:
106
+ """
107
+ Parse single gnubg match export file.
108
+
109
+ Args:
110
+ file_path: Path to text file
111
+ is_sgf_source: True if original source was SGF file (scores need swapping)
112
+
113
+ Returns:
114
+ List of Decision objects
115
+
116
+ Raises:
117
+ ValueError: If parsing fails
118
+ """
119
+ with open(file_path, 'r', encoding='utf-8') as f:
120
+ content = f.read()
121
+
122
+ # Extract match metadata
123
+ metadata = GNUBGMatchParser._parse_match_metadata(content)
124
+ metadata['is_sgf_source'] = is_sgf_source
125
+
126
+ # Parse all positions in the file
127
+ decisions = GNUBGMatchParser._parse_positions(content, metadata)
128
+
129
+ return decisions
130
+
131
+ @staticmethod
132
+ def _get_scores_from_metadata(pos_metadata: Dict, is_sgf_source: bool) -> Tuple[int, int]:
133
+ """
134
+ Extract scores from GNUID metadata, swapping for SGF sources.
135
+
136
+ Args:
137
+ pos_metadata: Metadata dict from GNUID parsing
138
+ is_sgf_source: True if original source was SGF file
139
+
140
+ Returns:
141
+ Tuple of (score_x, score_o) correctly mapped for the source type
142
+ """
143
+ score_x = pos_metadata.get('score_x', 0)
144
+ score_o = pos_metadata.get('score_o', 0)
145
+
146
+ # Swap scores for SGF sources due to different player encodings
147
+ if is_sgf_source:
148
+ score_x, score_o = score_o, score_x
149
+
150
+ return score_x, score_o
151
+
152
+ @staticmethod
153
+ def _parse_match_metadata(text: str) -> Dict:
154
+ """
155
+ Parse match metadata from header.
156
+
157
+ Format:
158
+ The score (after 0 games) is: chrhaase 0, Deinonychus 0 (match to 7 points)
159
+
160
+ Returns:
161
+ Dictionary with player names and match length
162
+ """
163
+ metadata = {
164
+ 'player_o_name': None,
165
+ 'player_x_name': None,
166
+ 'match_length': 0
167
+ }
168
+
169
+ # Parse score line
170
+ score_match = re.search(
171
+ r'The score.*?is:\s*(\w+)\s+(\d+),\s*(\w+)\s+(\d+)\s*\(match to (\d+) point',
172
+ text
173
+ )
174
+ if score_match:
175
+ metadata['player_o_name'] = score_match.group(1)
176
+ metadata['player_x_name'] = score_match.group(3)
177
+ metadata['match_length'] = int(score_match.group(5))
178
+
179
+ return metadata
180
+
181
+ @staticmethod
182
+ def _parse_positions(text: str, metadata: Dict) -> List[Decision]:
183
+ """
184
+ Parse all positions from match text.
185
+
186
+ Args:
187
+ text: Full match text export
188
+ metadata: Match metadata (player names, match length)
189
+
190
+ Returns:
191
+ List of Decision objects
192
+ """
193
+ decisions = []
194
+ lines = text.split('\n')
195
+ i = 0
196
+
197
+ while i < len(lines):
198
+ line = lines[i]
199
+
200
+ # Look for move number header with dice roll
201
+ # Format: "Move number 1: Deinonychus to play 64"
202
+ move_match = re.match(r'Move number (\d+):\s+(\w+) to play (\d)(\d)', line)
203
+ if move_match:
204
+ try:
205
+ # Parse checker play decision
206
+ checker_decision = GNUBGMatchParser._parse_position(
207
+ lines, i, metadata
208
+ )
209
+
210
+ # Also check for cube decision in this move
211
+ cube_decision = GNUBGMatchParser._parse_cube_decision(
212
+ lines, i, metadata
213
+ )
214
+
215
+ if cube_decision:
216
+ decisions.append(cube_decision)
217
+ if checker_decision:
218
+ decisions.append(checker_decision)
219
+ except Exception as e:
220
+ import logging
221
+ logger = logging.getLogger(__name__)
222
+ logger.warning(f"Failed to parse position at line {i}: {e}")
223
+
224
+ # Also look for cube-only moves (no dice roll)
225
+ # Format: "Move number 24: De_Luci on roll, cube decision?"
226
+ # Format: "Move number 25: De_Luci doubles to 2"
227
+ cube_only_match = re.match(r'Move number (\d+):\s+(\w+)(?:\s+on roll,\s+cube decision\?|\s+doubles)', line)
228
+ if cube_only_match:
229
+ try:
230
+ cube_decision = GNUBGMatchParser._parse_cube_decision_standalone(
231
+ lines, i, metadata
232
+ )
233
+ if cube_decision:
234
+ decisions.append(cube_decision)
235
+ except Exception as e:
236
+ import logging
237
+ logger = logging.getLogger(__name__)
238
+ logger.warning(f"Failed to parse cube decision at line {i}: {e}")
239
+
240
+ i += 1
241
+
242
+ return decisions
243
+
244
+ @staticmethod
245
+ def _parse_cube_decision(
246
+ lines: List[str],
247
+ start_idx: int,
248
+ metadata: Dict
249
+ ) -> Optional[Decision]:
250
+ """
251
+ Parse cube decision if present in this move.
252
+
253
+ Args:
254
+ lines: All lines from file
255
+ start_idx: Index of "Move number X:" line
256
+ metadata: Match metadata
257
+
258
+ Returns:
259
+ Decision object for cube action or None if no cube decision found
260
+ """
261
+ # Extract move number and player
262
+ move_line = lines[start_idx]
263
+ move_match = re.match(r'Move number (\d+):\s+(\w+) to play (\d)(\d)', move_line)
264
+ if not move_match:
265
+ return None
266
+
267
+ move_number = int(move_match.group(1))
268
+ player_name = move_match.group(2)
269
+ dice1 = int(move_match.group(3))
270
+ dice2 = int(move_match.group(4))
271
+
272
+ # Determine which player
273
+ on_roll = Player.O if player_name == metadata['player_o_name'] else Player.X
274
+
275
+ # Look for "Cube analysis" section
276
+ cube_section_idx = None
277
+ for offset in range(1, 50):
278
+ if start_idx + offset >= len(lines):
279
+ break
280
+ line = lines[start_idx + offset]
281
+ if line.strip() == "Cube analysis":
282
+ cube_section_idx = start_idx + offset
283
+ break
284
+ # Stop if we reach the next move or "Rolled XX:"
285
+ if line.startswith('Move number') or re.match(r'Rolled \d\d', line):
286
+ break
287
+
288
+ if cube_section_idx is None:
289
+ return None
290
+
291
+ # Find Position ID and Match ID
292
+ position_id = None
293
+ match_id = None
294
+ for offset in range(1, 30):
295
+ if start_idx + offset >= len(lines):
296
+ break
297
+ line = lines[start_idx + offset]
298
+ if 'Position ID:' in line:
299
+ pos_match = re.search(r'Position ID:\s+([A-Za-z0-9+/=]+)', line)
300
+ if pos_match:
301
+ position_id = pos_match.group(1)
302
+ elif 'Match ID' in line:
303
+ mat_match = re.search(r'Match ID\s*:\s+([A-Za-z0-9+/=]+)', line)
304
+ if mat_match:
305
+ match_id = mat_match.group(1)
306
+
307
+ if not position_id:
308
+ return None
309
+
310
+ # Parse position from GNUID
311
+ try:
312
+ position, pos_metadata = parse_gnuid(position_id + ":" + match_id if match_id else position_id)
313
+ except:
314
+ return None
315
+
316
+ # Get scores (swap if SGF source)
317
+ score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
318
+ pos_metadata, metadata.get('is_sgf_source', False)
319
+ )
320
+
321
+ # Generate XGID for score matrix support
322
+ xgid = position.to_xgid(
323
+ cube_value=pos_metadata.get('cube_value', 1),
324
+ cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
325
+ dice=None, # Cube decision happens before dice roll
326
+ on_roll=on_roll,
327
+ score_x=score_x,
328
+ score_o=score_o,
329
+ match_length=metadata.get('match_length', 0),
330
+ crawford_jacoby=1 if pos_metadata.get('crawford', False) else 0
331
+ )
332
+
333
+ # Extract winning chances from "Cube analysis" section
334
+ no_double_probs = None
335
+ for offset in range(1, 10):
336
+ if cube_section_idx + offset >= len(lines):
337
+ break
338
+ line = lines[cube_section_idx + offset]
339
+
340
+ if '1-ply cubeless equity' in line:
341
+ if cube_section_idx + offset + 1 < len(lines):
342
+ prob_line = lines[cube_section_idx + offset + 1]
343
+ prob_match = re.match(
344
+ r'\s*(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)\s+-\s+(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)',
345
+ prob_line
346
+ )
347
+ if prob_match:
348
+ no_double_probs = tuple(float(p) for p in prob_match.groups())
349
+ break
350
+
351
+ # Parse cube equities from "Cubeful equities:" section
352
+ equities = {}
353
+ proper_action = None
354
+
355
+ for offset in range(cube_section_idx - start_idx, cube_section_idx - start_idx + 20):
356
+ if start_idx + offset >= len(lines):
357
+ break
358
+ line = lines[start_idx + offset]
359
+
360
+ # Parse equity lines
361
+ # Format: "1. No double -0.014"
362
+ equity_match = re.match(r'\s*\d+\.\s+(.+?)\s+([+-]?\d+\.\d+)', line)
363
+ if equity_match:
364
+ action = equity_match.group(1).strip()
365
+ equity = float(equity_match.group(2))
366
+ equities[action] = equity
367
+
368
+ # Parse proper cube action
369
+ # Format: "Proper cube action: No double, take (26.0%)" or "Proper cube action: Double, take"
370
+ if 'Proper cube action:' in line:
371
+ proper_match = re.search(r'Proper cube action:\s+(.+?)(?:\s+\(|$)', line)
372
+ if proper_match:
373
+ proper_action = proper_match.group(1).strip()
374
+
375
+ # Stop at "Rolled XX:" line
376
+ if re.match(r'Rolled \d\d', line):
377
+ break
378
+
379
+ if not equities or not proper_action:
380
+ return None
381
+
382
+ # Find cube error and which action was taken
383
+ cube_error = None
384
+ take_error = None
385
+ doubled = False
386
+ cube_action_taken = None
387
+
388
+ # Search for error message before "Cube analysis" section
389
+ for offset in range(0, cube_section_idx - start_idx + 20):
390
+ if start_idx + offset >= len(lines):
391
+ break
392
+ line = lines[start_idx + offset]
393
+
394
+ # Check for doubling action
395
+ double_match = re.search(r'\*\s+\w+\s+doubles', line)
396
+ if double_match:
397
+ doubled = True
398
+
399
+ # Check for response action
400
+ response_match = re.search(r'\*\s+\w+\s+(accepts|passes|rejects)', line)
401
+ if response_match:
402
+ action = response_match.group(1)
403
+ cube_action_taken = "passes" if action == "rejects" else action
404
+
405
+ # Look for cube error messages
406
+ cube_alert_match = re.search(r'Alert: (wrong take|bad double|wrong double|missed double|wrong pass)\s+\(\s*([+-]?\d+\.\d+)\s*\)', line, re.IGNORECASE)
407
+ if cube_alert_match:
408
+ error_type = cube_alert_match.group(1).lower()
409
+ error_value = abs(float(cube_alert_match.group(2)))
410
+
411
+ if "take" in error_type or "pass" in error_type:
412
+ take_error = error_value
413
+ elif "double" in error_type or "missed" in error_type:
414
+ cube_error = error_value
415
+
416
+ if re.match(r'Rolled \d\d', line):
417
+ break
418
+ if re.match(r'\s*GNU Backgammon\s+Position ID:', line):
419
+ if cube_error is not None:
420
+ break
421
+
422
+ # Only create decision if there was an error (either doubler or responder)
423
+ if (cube_error is None or cube_error == 0.0) and (take_error is None or take_error == 0.0):
424
+ return None
425
+
426
+ # Create cube decision moves
427
+ from ankigammon.models import Move
428
+ candidate_moves = []
429
+
430
+ nd_equity = equities.get("No double", 0.0)
431
+ dt_equity = equities.get("Double, take", 0.0)
432
+ dp_equity = equities.get("Double, pass", 0.0)
433
+
434
+ best_equity = max(nd_equity, dt_equity, dp_equity)
435
+
436
+ # Determine which action was actually played
437
+ was_nd = not doubled
438
+ was_dt = doubled and cube_action_taken == "accepts"
439
+ was_dp = doubled and cube_action_taken == "passes"
440
+
441
+ # Default: if doubled but response unknown, assume take
442
+ if doubled and cube_action_taken is None:
443
+ was_dt = True
444
+
445
+ is_too_good = "too good" in proper_action.lower() if proper_action else False
446
+
447
+ # Create all 5 move options
448
+ candidate_moves.append(Move(
449
+ notation="No Double/Take",
450
+ equity=nd_equity,
451
+ error=abs(best_equity - nd_equity),
452
+ rank=1, # Will be recalculated
453
+ was_played=was_nd
454
+ ))
455
+
456
+ candidate_moves.append(Move(
457
+ notation="Double/Take",
458
+ equity=dt_equity,
459
+ error=abs(best_equity - dt_equity),
460
+ rank=1, # Will be recalculated
461
+ was_played=was_dt and not is_too_good
462
+ ))
463
+
464
+ candidate_moves.append(Move(
465
+ notation="Too Good/Take",
466
+ equity=dp_equity,
467
+ error=abs(best_equity - dp_equity),
468
+ rank=1,
469
+ was_played=was_dt and is_too_good,
470
+ from_xg_analysis=False
471
+ ))
472
+
473
+ candidate_moves.append(Move(
474
+ notation="Too Good/Pass",
475
+ equity=dp_equity,
476
+ error=abs(best_equity - dp_equity),
477
+ rank=1,
478
+ was_played=was_dp and is_too_good,
479
+ from_xg_analysis=False
480
+ ))
481
+ candidate_moves.append(Move(
482
+ notation="Double/Pass",
483
+ equity=dp_equity,
484
+ error=abs(best_equity - dp_equity),
485
+ rank=1, # Will be recalculated
486
+ was_played=was_dp and not is_too_good
487
+ ))
488
+
489
+ # Determine best move based on proper action
490
+ if proper_action and "too good to double, pass" in proper_action.lower():
491
+ best_move_notation = "Too Good/Pass"
492
+ best_equity_for_errors = nd_equity
493
+ elif proper_action and "too good to double, take" in proper_action.lower():
494
+ best_move_notation = "Too Good/Take"
495
+ best_equity_for_errors = nd_equity
496
+ elif proper_action and "no double" in proper_action.lower():
497
+ best_move_notation = "No Double/Take"
498
+ best_equity_for_errors = nd_equity
499
+ elif proper_action and "double, take" in proper_action.lower():
500
+ best_move_notation = "Double/Take"
501
+ best_equity_for_errors = dt_equity
502
+ elif proper_action and "double, pass" in proper_action.lower():
503
+ best_move_notation = "Double/Pass"
504
+ best_equity_for_errors = dp_equity
505
+ else:
506
+ best_move = max(candidate_moves, key=lambda m: m.equity)
507
+ best_move_notation = best_move.notation
508
+ best_equity_for_errors = best_move.equity
509
+
510
+ # Set ranks
511
+ for move in candidate_moves:
512
+ if move.notation == best_move_notation:
513
+ move.rank = 1
514
+ else:
515
+ better_count = sum(1 for m in candidate_moves
516
+ if m.notation != best_move_notation and m.equity > move.equity)
517
+ move.rank = 2 + better_count
518
+
519
+ # Recalculate errors based on best equity
520
+ for move in candidate_moves:
521
+ move.error = abs(best_equity_for_errors - move.equity)
522
+
523
+ # Sort by logical order for consistent display
524
+ order_map = {
525
+ "No Double/Take": 1,
526
+ "Double/Take": 2,
527
+ "Double/Pass": 3,
528
+ "Too Good/Take": 4,
529
+ "Too Good/Pass": 5
530
+ }
531
+ candidate_moves.sort(key=lambda m: order_map.get(m.notation, 99))
532
+
533
+ # Get scores (swap if SGF source)
534
+ score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
535
+ pos_metadata, metadata.get('is_sgf_source', False)
536
+ )
537
+
538
+ # Create Decision object
539
+ decision = Decision(
540
+ position=position,
541
+ on_roll=on_roll,
542
+ dice=None, # Cube decision happens before dice roll
543
+ decision_type=DecisionType.CUBE_ACTION,
544
+ candidate_moves=candidate_moves,
545
+ score_x=score_x,
546
+ score_o=score_o,
547
+ match_length=metadata.get('match_length', 0),
548
+ cube_value=pos_metadata.get('cube_value', 1),
549
+ cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
550
+ crawford=pos_metadata.get('crawford', False),
551
+ xgid=xgid,
552
+ move_number=move_number,
553
+ cube_error=cube_error,
554
+ take_error=take_error,
555
+ player_win_pct=no_double_probs[0] * 100 if no_double_probs else None,
556
+ player_gammon_pct=no_double_probs[1] * 100 if no_double_probs else None,
557
+ player_backgammon_pct=no_double_probs[2] * 100 if no_double_probs else None,
558
+ opponent_win_pct=no_double_probs[3] * 100 if no_double_probs else None,
559
+ opponent_gammon_pct=no_double_probs[4] * 100 if no_double_probs else None,
560
+ opponent_backgammon_pct=no_double_probs[5] * 100 if no_double_probs else None
561
+ )
562
+
563
+ return decision
564
+
565
+ @staticmethod
566
+ def _parse_cube_decision_standalone(
567
+ lines: List[str],
568
+ start_idx: int,
569
+ metadata: Dict
570
+ ) -> Optional[Decision]:
571
+ """
572
+ Parse standalone cube decision (cube-only move with no checker play).
573
+
574
+ These moves have formats like:
575
+ - "Move number 24: De_Luci on roll, cube decision?"
576
+ - "Move number 25: De_Luci doubles to 2"
577
+
578
+ Args:
579
+ lines: All lines from file
580
+ start_idx: Index of "Move number X:" line
581
+ metadata: Match metadata
582
+
583
+ Returns:
584
+ Decision object for cube action or None if no error found
585
+ """
586
+ # Reuse the existing _parse_cube_decision logic
587
+ # but adapt the move number extraction
588
+ move_line = lines[start_idx]
589
+
590
+ # Extract move number and player name
591
+ # Handles both formats: "on roll, cube decision?" and "doubles to 2"
592
+ move_match = re.match(r'Move number (\d+):\s+(\w+)', move_line)
593
+ if not move_match:
594
+ return None
595
+
596
+ move_number = int(move_match.group(1))
597
+ player_name = move_match.group(2)
598
+
599
+ # Determine which player
600
+ on_roll = Player.O if player_name == metadata['player_o_name'] else Player.X
601
+
602
+ # Look for "Cube analysis" section
603
+ cube_section_idx = None
604
+ for offset in range(1, 50):
605
+ if start_idx + offset >= len(lines):
606
+ break
607
+ line = lines[start_idx + offset]
608
+ if line.strip() == "Cube analysis":
609
+ cube_section_idx = start_idx + offset
610
+ break
611
+ # Stop if we reach the next move
612
+ if line.startswith('Move number'):
613
+ break
614
+
615
+ if cube_section_idx is None:
616
+ return None
617
+
618
+ # Find Position ID and Match ID
619
+ position_id = None
620
+ match_id = None
621
+ for offset in range(1, 30):
622
+ if start_idx + offset >= len(lines):
623
+ break
624
+ line = lines[start_idx + offset]
625
+ if 'Position ID:' in line:
626
+ pos_match = re.search(r'Position ID:\s+([A-Za-z0-9+/=]+)', line)
627
+ if pos_match:
628
+ position_id = pos_match.group(1)
629
+ elif 'Match ID' in line:
630
+ mat_match = re.search(r'Match ID\s*:\s+([A-Za-z0-9+/=]+)', line)
631
+ if mat_match:
632
+ match_id = mat_match.group(1)
633
+
634
+ if not position_id:
635
+ return None
636
+
637
+ # Parse position from GNUID
638
+ try:
639
+ position, pos_metadata = parse_gnuid(position_id + ":" + match_id if match_id else position_id)
640
+ except:
641
+ return None
642
+
643
+ # Get scores (swap if SGF source)
644
+ score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
645
+ pos_metadata, metadata.get('is_sgf_source', False)
646
+ )
647
+
648
+ # Generate XGID for score matrix support
649
+ xgid = position.to_xgid(
650
+ cube_value=pos_metadata.get('cube_value', 1),
651
+ cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
652
+ dice=None, # Cube decision happens before dice roll
653
+ on_roll=on_roll,
654
+ score_x=score_x,
655
+ score_o=score_o,
656
+ match_length=metadata.get('match_length', 0),
657
+ crawford_jacoby=1 if pos_metadata.get('crawford', False) else 0
658
+ )
659
+
660
+ # Extract winning chances from "Cube analysis" section
661
+ no_double_probs = None
662
+ for offset in range(1, 10):
663
+ if cube_section_idx + offset >= len(lines):
664
+ break
665
+ line = lines[cube_section_idx + offset]
666
+
667
+ if '1-ply cubeless equity' in line:
668
+ if cube_section_idx + offset + 1 < len(lines):
669
+ prob_line = lines[cube_section_idx + offset + 1]
670
+ prob_match = re.match(
671
+ r'\s*(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)\s+-\s+(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)',
672
+ prob_line
673
+ )
674
+ if prob_match:
675
+ no_double_probs = tuple(float(p) for p in prob_match.groups())
676
+ break
677
+
678
+ # Parse cube equities from "Cubeful equities:" section
679
+ equities = {}
680
+ proper_action = None
681
+
682
+ for offset in range(cube_section_idx - start_idx, cube_section_idx - start_idx + 20):
683
+ if start_idx + offset >= len(lines):
684
+ break
685
+ line = lines[start_idx + offset]
686
+
687
+ # Parse equity lines
688
+ # Format: "1. No double -0.014"
689
+ equity_match = re.match(r'\s*\d+\.\s+(.+?)\s+([+-]?\d+\.\d+)', line)
690
+ if equity_match:
691
+ action = equity_match.group(1).strip()
692
+ equity = float(equity_match.group(2))
693
+ equities[action] = equity
694
+
695
+ # Parse proper cube action
696
+ # Format: "Proper cube action: Double, pass"
697
+ if 'Proper cube action:' in line:
698
+ proper_match = re.search(r'Proper cube action:\s+(.+?)(?:\s+\(|$)', line)
699
+ if proper_match:
700
+ proper_action = proper_match.group(1).strip()
701
+
702
+ # Stop at next move
703
+ if line.startswith('Move number'):
704
+ break
705
+
706
+ if not equities or not proper_action:
707
+ return None
708
+
709
+ # Find cube error and which action was taken
710
+ cube_error = None # Doubler's error
711
+ take_error = None # Responder's error
712
+ doubled = False
713
+ cube_action_taken = None
714
+
715
+ # Search for error message before "Cube analysis" section
716
+ for offset in range(0, cube_section_idx - start_idx + 20):
717
+ if start_idx + offset >= len(lines):
718
+ break
719
+ line = lines[start_idx + offset]
720
+
721
+ # Check for doubling action
722
+ double_match = re.search(r'\*\s+\w+\s+doubles', line)
723
+ if double_match:
724
+ doubled = True
725
+
726
+ # Check for response action
727
+ response_match = re.search(r'\*\s+\w+\s+(accepts|passes|rejects)', line)
728
+ if response_match:
729
+ action = response_match.group(1)
730
+ cube_action_taken = "passes" if action == "rejects" else action
731
+
732
+ # Look for cube error messages
733
+ cube_alert_match = re.search(r'Alert: (wrong take|bad double|wrong double|missed double|wrong pass)\s+\(\s*([+-]?\d+\.\d+)\s*\)', line, re.IGNORECASE)
734
+ if cube_alert_match:
735
+ error_type = cube_alert_match.group(1).lower()
736
+ error_value = abs(float(cube_alert_match.group(2)))
737
+
738
+ if "take" in error_type or "pass" in error_type:
739
+ take_error = error_value
740
+ elif "double" in error_type or "missed" in error_type:
741
+ cube_error = error_value
742
+
743
+ # Stop when we encounter actual move content (board diagram or dice roll)
744
+ # Don't stop at "Move number" header - the error appears between the header and the content
745
+ if line.startswith('Rolled'):
746
+ break
747
+ if re.match(r'\s*GNU Backgammon\s+Position ID:', line):
748
+ # Found board diagram for next move, stop here
749
+ if cube_error is not None: # But only if we already found the error
750
+ break
751
+
752
+ # Only create decision if there was an error (either doubler or responder)
753
+ if (cube_error is None or cube_error == 0.0) and (take_error is None or take_error == 0.0):
754
+ return None
755
+
756
+ # Create cube decision moves
757
+ from ankigammon.models import Move
758
+ candidate_moves = []
759
+
760
+ nd_equity = equities.get("No double", 0.0)
761
+ dt_equity = equities.get("Double, take", 0.0)
762
+ dp_equity = equities.get("Double, pass", 0.0)
763
+
764
+ best_equity = max(nd_equity, dt_equity, dp_equity)
765
+
766
+ # Determine which action was actually played
767
+ was_nd = not doubled
768
+ was_dt = doubled and cube_action_taken == "accepts"
769
+ was_dp = doubled and cube_action_taken == "passes"
770
+
771
+ # Default: if doubled but response unknown, assume take
772
+ if doubled and cube_action_taken is None:
773
+ was_dt = True
774
+
775
+ is_too_good = "too good" in proper_action.lower() if proper_action else False
776
+
777
+ # Create all 5 move options
778
+ candidate_moves.append(Move(
779
+ notation="No Double/Take",
780
+ equity=nd_equity,
781
+ error=abs(best_equity - nd_equity),
782
+ rank=1, # Will be recalculated
783
+ was_played=was_nd
784
+ ))
785
+
786
+ candidate_moves.append(Move(
787
+ notation="Double/Take",
788
+ equity=dt_equity,
789
+ error=abs(best_equity - dt_equity),
790
+ rank=1, # Will be recalculated
791
+ was_played=was_dt and not is_too_good
792
+ ))
793
+
794
+ candidate_moves.append(Move(
795
+ notation="Too Good/Take",
796
+ equity=dp_equity,
797
+ error=abs(best_equity - dp_equity),
798
+ rank=1,
799
+ was_played=was_dt and is_too_good,
800
+ from_xg_analysis=False
801
+ ))
802
+
803
+ candidate_moves.append(Move(
804
+ notation="Too Good/Pass",
805
+ equity=dp_equity,
806
+ error=abs(best_equity - dp_equity),
807
+ rank=1,
808
+ was_played=was_dp and is_too_good,
809
+ from_xg_analysis=False
810
+ ))
811
+ candidate_moves.append(Move(
812
+ notation="Double/Pass",
813
+ equity=dp_equity,
814
+ error=abs(best_equity - dp_equity),
815
+ rank=1, # Will be recalculated
816
+ was_played=was_dp and not is_too_good
817
+ ))
818
+
819
+ # Determine best move based on proper action
820
+ if proper_action and "too good to double, pass" in proper_action.lower():
821
+ best_move_notation = "Too Good/Pass"
822
+ best_equity_for_errors = nd_equity
823
+ elif proper_action and "too good to double, take" in proper_action.lower():
824
+ best_move_notation = "Too Good/Take"
825
+ best_equity_for_errors = nd_equity
826
+ elif proper_action and "no double" in proper_action.lower():
827
+ best_move_notation = "No Double/Take"
828
+ best_equity_for_errors = nd_equity
829
+ elif proper_action and "double, take" in proper_action.lower():
830
+ best_move_notation = "Double/Take"
831
+ best_equity_for_errors = dt_equity
832
+ elif proper_action and "double, pass" in proper_action.lower():
833
+ best_move_notation = "Double/Pass"
834
+ best_equity_for_errors = dp_equity
835
+ else:
836
+ best_move = max(candidate_moves, key=lambda m: m.equity)
837
+ best_move_notation = best_move.notation
838
+ best_equity_for_errors = best_move.equity
839
+
840
+ # Set ranks
841
+ for move in candidate_moves:
842
+ if move.notation == best_move_notation:
843
+ move.rank = 1
844
+ else:
845
+ better_count = sum(1 for m in candidate_moves
846
+ if m.notation != best_move_notation and m.equity > move.equity)
847
+ move.rank = 2 + better_count
848
+
849
+ # Recalculate errors based on best equity
850
+ for move in candidate_moves:
851
+ move.error = abs(best_equity_for_errors - move.equity)
852
+
853
+ # Sort by logical order for consistent display
854
+ order_map = {
855
+ "No Double/Take": 1,
856
+ "Double/Take": 2,
857
+ "Double/Pass": 3,
858
+ "Too Good/Take": 4,
859
+ "Too Good/Pass": 5
860
+ }
861
+ candidate_moves.sort(key=lambda m: order_map.get(m.notation, 99))
862
+
863
+ # Get scores (swap if SGF source)
864
+ score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
865
+ pos_metadata, metadata.get('is_sgf_source', False)
866
+ )
867
+
868
+ # Create Decision object
869
+ decision = Decision(
870
+ position=position,
871
+ on_roll=on_roll,
872
+ dice=None, # Cube decision happens before dice roll
873
+ decision_type=DecisionType.CUBE_ACTION,
874
+ candidate_moves=candidate_moves,
875
+ score_x=score_x,
876
+ score_o=score_o,
877
+ match_length=metadata.get('match_length', 0),
878
+ cube_value=pos_metadata.get('cube_value', 1),
879
+ cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
880
+ crawford=pos_metadata.get('crawford', False),
881
+ xgid=xgid,
882
+ move_number=move_number,
883
+ cube_error=cube_error,
884
+ take_error=take_error,
885
+ player_win_pct=no_double_probs[0] * 100 if no_double_probs else None,
886
+ player_gammon_pct=no_double_probs[1] * 100 if no_double_probs else None,
887
+ player_backgammon_pct=no_double_probs[2] * 100 if no_double_probs else None,
888
+ opponent_win_pct=no_double_probs[3] * 100 if no_double_probs else None,
889
+ opponent_gammon_pct=no_double_probs[4] * 100 if no_double_probs else None,
890
+ opponent_backgammon_pct=no_double_probs[5] * 100 if no_double_probs else None
891
+ )
892
+
893
+ return decision
894
+
895
+ @staticmethod
896
+ def _parse_position(
897
+ lines: List[str],
898
+ start_idx: int,
899
+ metadata: Dict
900
+ ) -> Optional[Decision]:
901
+ """
902
+ Parse single position starting from move number line.
903
+
904
+ Args:
905
+ lines: All lines from file
906
+ start_idx: Index of "Move number X:" line
907
+ metadata: Match metadata
908
+
909
+ Returns:
910
+ Decision object or None if position has no analysis
911
+ """
912
+ # Extract move number and player
913
+ move_line = lines[start_idx]
914
+ move_match = re.match(r'Move number (\d+):\s+(\w+) to play (\d)(\d)', move_line)
915
+ if not move_match:
916
+ return None
917
+
918
+ move_number = int(move_match.group(1))
919
+ player_name = move_match.group(2)
920
+ dice1 = int(move_match.group(3))
921
+ dice2 = int(move_match.group(4))
922
+
923
+ # Determine which player
924
+ on_roll = Player.O if player_name == metadata['player_o_name'] else Player.X
925
+
926
+ # Find Position ID and Match ID lines
927
+ # Format: " GNU Backgammon Position ID: 4HPwATDgc/ABMA"
928
+ # " Match ID : cAjzAAAAAAAE"
929
+ position_id = None
930
+ match_id = None
931
+ for offset in range(1, 30): # Search next 30 lines
932
+ if start_idx + offset >= len(lines):
933
+ break
934
+ line = lines[start_idx + offset]
935
+ if 'Position ID:' in line:
936
+ pos_match = re.search(r'Position ID:\s+([A-Za-z0-9+/=]+)', line)
937
+ if pos_match:
938
+ position_id = pos_match.group(1)
939
+ elif 'Match ID' in line:
940
+ mat_match = re.search(r'Match ID\s*:\s+([A-Za-z0-9+/=]+)', line)
941
+ if mat_match:
942
+ match_id = mat_match.group(1)
943
+
944
+ if not position_id:
945
+ return None
946
+
947
+ # Parse position from GNUID
948
+ try:
949
+ position, pos_metadata = parse_gnuid(position_id + ":" + match_id if match_id else position_id)
950
+ except:
951
+ return None
952
+
953
+ # Get scores (swap if SGF source)
954
+ score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
955
+ pos_metadata, metadata.get('is_sgf_source', False)
956
+ )
957
+
958
+ # Generate XGID for score matrix support
959
+ xgid = position.to_xgid(
960
+ cube_value=pos_metadata.get('cube_value', 1),
961
+ cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
962
+ dice=(dice1, dice2),
963
+ on_roll=on_roll,
964
+ score_x=score_x,
965
+ score_o=score_o,
966
+ match_length=metadata.get('match_length', 0),
967
+ crawford_jacoby=1 if pos_metadata.get('crawford', False) else 0
968
+ )
969
+
970
+ # Find the move that was played (marked with *)
971
+ # Format: "* Deinonychus moves 24/18 13/9"
972
+ move_played = None
973
+ for offset in range(1, 40):
974
+ if start_idx + offset >= len(lines):
975
+ break
976
+ line = lines[start_idx + offset]
977
+ if line.strip().startswith('*') and 'moves' in line:
978
+ move_match = re.search(r'\* \w+ moves (.+)', line)
979
+ if move_match:
980
+ move_played = move_match.group(1).strip()
981
+ break
982
+
983
+ # Find the error value
984
+ # Format: "Rolled 64 (+0.031):"
985
+ error = None
986
+ for offset in range(1, 50):
987
+ if start_idx + offset >= len(lines):
988
+ break
989
+ line = lines[start_idx + offset]
990
+ error_match = re.match(r'Rolled \d\d \(([+-]?\d+\.\d+)\):', line)
991
+ if error_match:
992
+ error = abs(float(error_match.group(1))) # Take absolute value
993
+ break
994
+
995
+ # If no error found, this position wasn't analyzed (skip it)
996
+ if error is None:
997
+ return None
998
+
999
+ # Parse candidate moves
1000
+ candidate_moves = []
1001
+ for offset in range(1, 100):
1002
+ if start_idx + offset >= len(lines):
1003
+ break
1004
+ line = lines[start_idx + offset]
1005
+
1006
+ # Check if we've reached next position
1007
+ if line.startswith('Move number'):
1008
+ break
1009
+
1010
+ # Parse move line
1011
+ move_match = re.match(
1012
+ r'\s*\*?\s*(\d+)\.\s+Cubeful\s+\d+-ply\s+(.+?)\s+Eq\.:\s+([+-]?\d+\.\d+)(?:\s+\(\s*([+-]?\d+\.\d+)\s*\))?',
1013
+ line
1014
+ )
1015
+ if move_match:
1016
+ rank = int(move_match.group(1))
1017
+ notation = move_match.group(2).strip()
1018
+ equity = float(move_match.group(3))
1019
+ move_error = float(move_match.group(4)) if move_match.group(4) else 0.0
1020
+
1021
+ was_played = (move_played and notation == move_played)
1022
+
1023
+ # Parse probabilities from next line
1024
+ probs = None
1025
+ if start_idx + offset + 1 < len(lines):
1026
+ prob_line = lines[start_idx + offset + 1]
1027
+ prob_match = re.match(
1028
+ r'\s*(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)\s+-\s+(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)',
1029
+ prob_line
1030
+ )
1031
+ if prob_match:
1032
+ probs = tuple(float(p) for p in prob_match.groups())
1033
+
1034
+ # Create Move object
1035
+ move = Move(
1036
+ notation=notation,
1037
+ equity=equity,
1038
+ error=abs(move_error),
1039
+ rank=rank,
1040
+ was_played=was_played
1041
+ )
1042
+
1043
+ # Add probabilities if found
1044
+ if probs:
1045
+ move.player_win_pct = probs[0]
1046
+ move.player_gammon_pct = probs[1]
1047
+ move.player_backgammon_pct = probs[2]
1048
+ move.opponent_win_pct = probs[3]
1049
+ move.opponent_gammon_pct = probs[4]
1050
+ move.opponent_backgammon_pct = probs[5]
1051
+
1052
+ candidate_moves.append(move)
1053
+
1054
+ if not candidate_moves:
1055
+ return None
1056
+
1057
+ # Get scores (swap if SGF source)
1058
+ score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
1059
+ pos_metadata, metadata.get('is_sgf_source', False)
1060
+ )
1061
+
1062
+ # Create Decision object
1063
+ decision = Decision(
1064
+ position=position,
1065
+ on_roll=on_roll,
1066
+ dice=(dice1, dice2),
1067
+ decision_type=DecisionType.CHECKER_PLAY,
1068
+ candidate_moves=candidate_moves,
1069
+ score_x=score_x,
1070
+ score_o=score_o,
1071
+ match_length=metadata.get('match_length', 0),
1072
+ cube_value=pos_metadata.get('cube_value', 1),
1073
+ cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
1074
+ crawford=pos_metadata.get('crawford', False),
1075
+ xgid=xgid,
1076
+ move_number=move_number
1077
+ )
1078
+
1079
+ return decision
1080
+
1081
+
1082
+ # Helper function for easy import
1083
+ def parse_gnubg_match_files(file_paths: List[str], is_sgf_source: bool = False) -> List[Decision]:
1084
+ """
1085
+ Parse gnubg match export files into Decision objects.
1086
+
1087
+ Args:
1088
+ file_paths: List of paths to exported text files
1089
+ is_sgf_source: True if original source was SGF file (scores need swapping)
1090
+
1091
+ Returns:
1092
+ List of Decision objects
1093
+ """
1094
+ return GNUBGMatchParser.parse_match_files(file_paths, is_sgf_source=is_sgf_source)