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,729 @@
1
+ """Parser for XG text exports with ASCII board diagrams.
2
+
3
+ This parser handles the text format that XG exports with:
4
+ - XGID line
5
+ - ASCII board diagram
6
+ - Move analysis with equities and rollout data
7
+ """
8
+
9
+ import re
10
+ from typing import List, Optional, Tuple
11
+
12
+ from ankigammon.models import Decision, Move, Position, Player, CubeState, DecisionType
13
+ from ankigammon.utils.xgid import parse_xgid
14
+ from ankigammon.utils.ogid import parse_ogid
15
+ from ankigammon.utils.gnuid import parse_gnuid
16
+
17
+
18
+ class XGTextParser:
19
+ """Parse XG text export format."""
20
+
21
+ @staticmethod
22
+ def parse_file(file_path: str) -> List[Decision]:
23
+ """
24
+ Parse an XG text export file.
25
+
26
+ Args:
27
+ file_path: Path to XG text file
28
+
29
+ Returns:
30
+ List of Decision objects
31
+ """
32
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
33
+ content = f.read()
34
+
35
+ return XGTextParser.parse_string(content)
36
+
37
+ @staticmethod
38
+ def parse_string(content: str) -> List[Decision]:
39
+ """
40
+ Parse XG text export from string.
41
+
42
+ Args:
43
+ content: Full text content
44
+
45
+ Returns:
46
+ List of Decision objects
47
+ """
48
+ decisions = []
49
+
50
+ # Split into sections by XGID, OGID, or GNUID patterns
51
+ # Pattern matches XGID=, OGID (base-26 format), or GNUID (base64 format)
52
+ sections = re.split(r'(XGID=[^\n]+|^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}[^\n]*|^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12})', content, flags=re.MULTILINE)
53
+
54
+ for i in range(1, len(sections), 2):
55
+ if i + 1 >= len(sections):
56
+ break
57
+
58
+ position_id_line = sections[i].strip()
59
+ analysis_section = sections[i + 1]
60
+
61
+ decision = XGTextParser._parse_decision_section(position_id_line, analysis_section)
62
+ if decision:
63
+ decisions.append(decision)
64
+
65
+ return decisions
66
+
67
+ @staticmethod
68
+ def _parse_decision_section(position_id_line: str, analysis_section: str) -> Optional[Decision]:
69
+ """Parse a single decision section."""
70
+ # Detect and parse position ID (XGID, OGID, or GNUID)
71
+ try:
72
+ # Check if it's XGID format
73
+ if position_id_line.startswith('XGID='):
74
+ position, metadata = parse_xgid(position_id_line)
75
+ position_id = position_id_line
76
+ # Check if it's OGID format (base-26 encoding)
77
+ elif re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', position_id_line):
78
+ position, metadata = parse_ogid(position_id_line)
79
+ position_id = position_id_line
80
+ # Check if it's GNUID format (base64 encoding)
81
+ elif re.match(r'^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12}$', position_id_line):
82
+ position, metadata = parse_gnuid(position_id_line)
83
+ position_id = position_id_line
84
+ else:
85
+ raise ValueError(f"Unknown position ID format: {position_id_line}")
86
+ except Exception as e:
87
+ print(f"Error parsing position ID '{position_id_line}': {e}")
88
+ return None
89
+
90
+ # Parse game info (players, score, cube, etc.)
91
+ game_info = XGTextParser._parse_game_info(analysis_section)
92
+ if game_info:
93
+ # Update metadata with parsed info, but DON'T overwrite XGID data!
94
+ # XGID data is authoritative because it accounts for perspective correctly
95
+ # The ASCII board representation may show cube ownership from a flipped perspective
96
+ for key, value in game_info.items():
97
+ if key not in metadata or key == 'decision_type':
98
+ # Only add if not already in metadata (from XGID)
99
+ # Exception: decision_type can only come from ASCII parsing
100
+ metadata[key] = value
101
+
102
+ # Parse move analysis
103
+ moves = XGTextParser._parse_moves(analysis_section)
104
+ # Note: Allow empty moves for XGID-only positions (gnubg can analyze them later)
105
+ # if not moves:
106
+ # return None
107
+
108
+ # Parse global winning chances (for cube decisions)
109
+ winning_chances = XGTextParser._parse_winning_chances(analysis_section)
110
+
111
+ # Determine decision type
112
+ # If not explicitly set in metadata, infer from dice:
113
+ # - No dice (dice='00' in XGID) = CUBE_ACTION
114
+ # - Has dice = CHECKER_PLAY
115
+ if 'decision_type' in metadata:
116
+ decision_type = metadata['decision_type']
117
+ elif 'dice' not in metadata or metadata.get('dice') is None:
118
+ decision_type = DecisionType.CUBE_ACTION
119
+ else:
120
+ decision_type = DecisionType.CHECKER_PLAY
121
+
122
+ # Determine Crawford status from multiple sources
123
+ # Priority: 1) Text parsing, 2) XGID crawford_jacoby, 3) OGID match_modifier, 4) GNUID crawford
124
+ # Note: crawford_jacoby field means different things in different contexts:
125
+ # - Match play (match_length > 0): crawford_jacoby = 1 means Crawford rule
126
+ # - Money game (match_length = 0): crawford_jacoby = 1 means Jacoby rule
127
+ # The crawford boolean should ONLY be set for Crawford matches, not Jacoby money games
128
+ match_length = metadata.get('match_length', 0)
129
+ crawford = False
130
+
131
+ if match_length > 0: # Only set crawford=True for match play
132
+ if 'crawford' in metadata and metadata['crawford']:
133
+ crawford = True
134
+ elif 'crawford_jacoby' in metadata and metadata['crawford_jacoby'] > 0:
135
+ crawford = True
136
+ elif 'match_modifier' in metadata and metadata['match_modifier'] == 'C':
137
+ crawford = True
138
+
139
+ # Create decision
140
+ decision = Decision(
141
+ position=position,
142
+ xgid=position_id, # Store original position ID (XGID or OGID)
143
+ on_roll=metadata.get('on_roll', Player.O),
144
+ dice=metadata.get('dice'),
145
+ score_x=metadata.get('score_x', 0),
146
+ score_o=metadata.get('score_o', 0),
147
+ match_length=metadata.get('match_length', 0),
148
+ crawford=crawford,
149
+ cube_value=metadata.get('cube_value', 1),
150
+ cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
151
+ decision_type=decision_type,
152
+ candidate_moves=moves,
153
+ player_win_pct=winning_chances.get('player_win_pct'),
154
+ player_gammon_pct=winning_chances.get('player_gammon_pct'),
155
+ player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
156
+ opponent_win_pct=winning_chances.get('opponent_win_pct'),
157
+ opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
158
+ opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
159
+ )
160
+
161
+ return decision
162
+
163
+ @staticmethod
164
+ def _parse_winning_chances(text: str) -> dict:
165
+ """
166
+ Parse global winning chances from text section.
167
+
168
+ Format:
169
+ Player Winning Chances: 52.68% (G:14.35% B:0.69%)
170
+ Opponent Winning Chances: 47.32% (G:12.42% B:0.55%)
171
+
172
+ Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
173
+ opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
174
+ """
175
+ chances = {}
176
+
177
+ # Parse player winning chances
178
+ player_match = re.search(
179
+ r'Player Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
180
+ text,
181
+ re.IGNORECASE
182
+ )
183
+ if player_match:
184
+ chances['player_win_pct'] = float(player_match.group(1))
185
+ chances['player_gammon_pct'] = float(player_match.group(2))
186
+ chances['player_backgammon_pct'] = float(player_match.group(3))
187
+
188
+ # Parse opponent winning chances
189
+ opponent_match = re.search(
190
+ r'Opponent Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
191
+ text,
192
+ re.IGNORECASE
193
+ )
194
+ if opponent_match:
195
+ chances['opponent_win_pct'] = float(opponent_match.group(1))
196
+ chances['opponent_gammon_pct'] = float(opponent_match.group(2))
197
+ chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
198
+
199
+ return chances
200
+
201
+ @staticmethod
202
+ def _parse_move_winning_chances(move_text: str) -> dict:
203
+ """
204
+ Parse winning chances from a move's analysis section.
205
+
206
+ Format:
207
+ Player: 53.81% (G:17.42% B:0.87%)
208
+ Opponent: 46.19% (G:12.99% B:0.64%)
209
+
210
+ Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
211
+ opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
212
+ """
213
+ chances = {}
214
+
215
+ # Parse player chances
216
+ player_match = re.search(
217
+ r'Player:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
218
+ move_text,
219
+ re.IGNORECASE
220
+ )
221
+ if player_match:
222
+ chances['player_win_pct'] = float(player_match.group(1))
223
+ chances['player_gammon_pct'] = float(player_match.group(2))
224
+ chances['player_backgammon_pct'] = float(player_match.group(3))
225
+
226
+ # Parse opponent chances
227
+ opponent_match = re.search(
228
+ r'Opponent:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
229
+ move_text,
230
+ re.IGNORECASE
231
+ )
232
+ if opponent_match:
233
+ chances['opponent_win_pct'] = float(opponent_match.group(1))
234
+ chances['opponent_gammon_pct'] = float(opponent_match.group(2))
235
+ chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
236
+
237
+ return chances
238
+
239
+ @staticmethod
240
+ def _parse_game_info(text: str) -> dict:
241
+ """
242
+ Parse game information from text section.
243
+
244
+ Extracts:
245
+ - Players (X:Player 2 O:Player 1)
246
+ - Score (Score is X:3 O:4 5 pt.(s) match.)
247
+ - Cube info (Cube: 2, O own cube)
248
+ - Turn info (X to play 63)
249
+ """
250
+ info = {}
251
+
252
+ # First, parse the player designation to build mapping
253
+ # "X:Player 1 O:Player 2" means X is Player 1, O is Player 2
254
+ # Player 1 = BOTTOM player = Player.O in our internal model
255
+ # Player 2 = TOP player = Player.X in our internal model
256
+ xo_to_player = {}
257
+ player_designation = re.search(
258
+ r'([XO]):Player\s+(\d+)',
259
+ text,
260
+ re.IGNORECASE
261
+ )
262
+ if player_designation:
263
+ label = player_designation.group(1).upper() # 'X' or 'O'
264
+ player_num = int(player_designation.group(2)) # 1 or 2
265
+
266
+ # Map: Player 1 = BOTTOM = Player.O, Player 2 = TOP = Player.X
267
+ if player_num == 1:
268
+ xo_to_player[label] = Player.O
269
+ else:
270
+ xo_to_player[label] = Player.X
271
+
272
+ # Also get the other player
273
+ other_label = 'O' if label == 'X' else 'X'
274
+ other_player_designation = re.search(
275
+ rf'{other_label}:Player\s+(\d+)',
276
+ text,
277
+ re.IGNORECASE
278
+ )
279
+ if other_player_designation:
280
+ other_num = int(other_player_designation.group(1))
281
+ if other_num == 1:
282
+ xo_to_player[other_label] = Player.O
283
+ else:
284
+ xo_to_player[other_label] = Player.X
285
+
286
+ # Parse score and match length
287
+ # "Score is X:3 O:4 5 pt.(s) match."
288
+ score_match = re.search(
289
+ r'Score is X:(\d+)\s+O:(\d+)\s+(\d+)\s+pt',
290
+ text,
291
+ re.IGNORECASE
292
+ )
293
+ if score_match:
294
+ info['score_x'] = int(score_match.group(1))
295
+ info['score_o'] = int(score_match.group(2))
296
+ info['match_length'] = int(score_match.group(3))
297
+
298
+ # Check for Crawford game indicator in pip count line
299
+ # Format: "Pip count X: 156 O: 167 X-O: 1-4/5 Crawford"
300
+ crawford_match = re.search(
301
+ r'Pip count.*Crawford',
302
+ text,
303
+ re.IGNORECASE
304
+ )
305
+ if crawford_match:
306
+ info['crawford'] = True
307
+
308
+ # Check for money game
309
+ if 'money game' in text.lower():
310
+ info['match_length'] = 0
311
+
312
+ # Parse cube info
313
+ # "Cube: 2, O own cube" or "Cube: 4, X own cube" or "Cube: 1"
314
+ cube_match = re.search(
315
+ r'Cube:\s*(\d+)(?:,\s*([XO])\s+own\s+cube)?',
316
+ text,
317
+ re.IGNORECASE
318
+ )
319
+ if cube_match:
320
+ info['cube_value'] = int(cube_match.group(1))
321
+ owner_label = cube_match.group(2)
322
+ if owner_label:
323
+ owner_label = owner_label.upper()
324
+ # Use mapping if available
325
+ if owner_label in xo_to_player:
326
+ owner_player = xo_to_player[owner_label]
327
+ if owner_player == Player.X:
328
+ info['cube_owner'] = CubeState.X_OWNS
329
+ else:
330
+ info['cube_owner'] = CubeState.O_OWNS
331
+ else:
332
+ # Fallback: old behavior
333
+ if owner_label == 'X':
334
+ info['cube_owner'] = CubeState.X_OWNS
335
+ elif owner_label == 'O':
336
+ info['cube_owner'] = CubeState.O_OWNS
337
+ else:
338
+ info['cube_owner'] = CubeState.CENTERED
339
+
340
+ # Parse turn info
341
+ # "X to play 63" or "O to play 52" or "X to roll" or "O on roll"
342
+ turn_match = re.search(
343
+ r'([XO])\s+(?:to\s+play|to\s+roll|on\s+roll)(?:\s+(\d)(\d))?',
344
+ text,
345
+ re.IGNORECASE
346
+ )
347
+ if turn_match:
348
+ player_label = turn_match.group(1).upper() # 'X' or 'O' from text
349
+
350
+ # Use the mapping if available, otherwise fall back to simple mapping
351
+ if player_label in xo_to_player:
352
+ info['on_roll'] = xo_to_player[player_label]
353
+ else:
354
+ # Fallback: assume X=Player.X, O=Player.O (old behavior)
355
+ info['on_roll'] = Player.X if player_label == 'X' else Player.O
356
+
357
+ dice1 = turn_match.group(2)
358
+ dice2 = turn_match.group(3)
359
+ if dice1 and dice2:
360
+ info['dice'] = (int(dice1), int(dice2))
361
+
362
+ # Check for cube actions
363
+ if any(word in text.lower() for word in ['double', 'take', 'drop', 'pass', 'beaver']):
364
+ # Look for cube decision indicators
365
+ if 'double' in text.lower() and 'to play' not in text.lower():
366
+ info['decision_type'] = DecisionType.CUBE_ACTION
367
+
368
+ return info
369
+
370
+ @staticmethod
371
+ def _parse_moves(text: str) -> List[Move]:
372
+ """
373
+ Parse move analysis from text.
374
+
375
+ Format:
376
+ 1. XG Roller+ 11/8 11/5 eq:+0.589
377
+ Player: 79.46% (G:17.05% B:0.67%)
378
+ Opponent: 20.54% (G:2.22% B:0.06%)
379
+
380
+ 2. XG Roller+ 9/3* 6/3 eq:+0.529 (-0.061)
381
+ Player: 76.43% (G:24.10% B:1.77%)
382
+ Opponent: 23.57% (G:3.32% B:0.12%)
383
+
384
+ Or for cube decisions:
385
+ 1. XG Roller+ Double, take eq:+0.678
386
+ 2. XG Roller+ Double, drop eq:+0.645 (-0.033)
387
+ 3. XG Roller+ No double eq:+0.623 (-0.055)
388
+ """
389
+ moves = []
390
+
391
+ # Find all move entries
392
+ # Pattern: rank. [engine] notation eq:[equity] [(error)]
393
+ move_pattern = re.compile(
394
+ r'^\s*(\d+)\.\s+(?:[\w\s+-]+?)\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
395
+ re.MULTILINE | re.IGNORECASE
396
+ )
397
+
398
+ # Split text into lines to extract following lines after each move
399
+ lines = text.split('\n')
400
+ move_matches = list(move_pattern.finditer(text))
401
+
402
+ for i, match in enumerate(move_matches):
403
+ rank = int(match.group(1))
404
+ notation = match.group(2).strip()
405
+ equity = float(match.group(3))
406
+ error_str = match.group(4)
407
+
408
+ # Parse error (if present in parentheses)
409
+ # For checker play, preserve the sign (negative means worse than best)
410
+ if error_str:
411
+ xg_error = float(error_str) # Preserve sign from XG
412
+ error = abs(xg_error) # Internal error (always positive)
413
+ else:
414
+ # First move has no error
415
+ xg_error = 0.0
416
+ error = 0.0
417
+
418
+ # Clean up notation
419
+ notation = XGTextParser._clean_move_notation(notation)
420
+
421
+ # Extract winning chances from the lines following this move
422
+ # Get the text between this match and the next move (or end)
423
+ start_pos = match.end()
424
+ if i + 1 < len(move_matches):
425
+ end_pos = move_matches[i + 1].start()
426
+ else:
427
+ end_pos = len(text)
428
+
429
+ move_section = text[start_pos:end_pos]
430
+ winning_chances = XGTextParser._parse_move_winning_chances(move_section)
431
+
432
+ moves.append(Move(
433
+ notation=notation,
434
+ equity=equity,
435
+ error=error,
436
+ rank=rank,
437
+ xg_error=xg_error, # Store XG's error with sign
438
+ xg_notation=notation, # For checker play, XG notation same as regular notation
439
+ player_win_pct=winning_chances.get('player_win_pct'),
440
+ player_gammon_pct=winning_chances.get('player_gammon_pct'),
441
+ player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
442
+ opponent_win_pct=winning_chances.get('opponent_win_pct'),
443
+ opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
444
+ opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
445
+ ))
446
+
447
+ # If we didn't find moves with the standard pattern, try alternative patterns
448
+ if not moves:
449
+ moves = XGTextParser._parse_moves_fallback(text)
450
+
451
+ # If still no moves, try parsing as cube decision
452
+ if not moves:
453
+ moves = XGTextParser._parse_cube_decision(text)
454
+
455
+ # Calculate errors if not already set
456
+ if moves and len(moves) > 1:
457
+ best_equity = moves[0].equity
458
+ for move in moves[1:]:
459
+ if move.error == 0.0:
460
+ move.error = abs(best_equity - move.equity)
461
+
462
+ return moves
463
+
464
+ @staticmethod
465
+ def _parse_moves_fallback(text: str) -> List[Move]:
466
+ """Fallback parser for alternative move formats."""
467
+ moves = []
468
+
469
+ # Try simpler pattern without engine name
470
+ # "1. 11/8 11/5 eq:+0.589"
471
+ pattern = re.compile(
472
+ r'^\s*(\d+)\.\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)',
473
+ re.MULTILINE | re.IGNORECASE
474
+ )
475
+
476
+ for match in pattern.finditer(text):
477
+ rank = int(match.group(1))
478
+ notation = match.group(2).strip()
479
+ equity = float(match.group(3))
480
+
481
+ notation = XGTextParser._clean_move_notation(notation)
482
+
483
+ moves.append(Move(
484
+ notation=notation,
485
+ equity=equity,
486
+ error=0.0,
487
+ rank=rank
488
+ ))
489
+
490
+ return moves
491
+
492
+ @staticmethod
493
+ def _parse_cube_decision(text: str) -> List[Move]:
494
+ """
495
+ Parse cube decision analysis from text.
496
+
497
+ Format:
498
+ Cubeful Equities:
499
+ No redouble: +0.172
500
+ Redouble/Take: -0.361 (-0.533)
501
+ Redouble/Pass: +1.000 (+0.828)
502
+
503
+ Best Cube action: No redouble / Take
504
+ OR: Too good to redouble / Pass
505
+
506
+ Generates all 5 cube options:
507
+ - No double/redouble
508
+ - Double/Take (Redouble/Take)
509
+ - Double/Pass (Redouble/Pass)
510
+ - Too good/Take
511
+ - Too good/Pass
512
+ """
513
+ moves = []
514
+
515
+ # Look for "Cubeful Equities:" section
516
+ if 'Cubeful Equities:' not in text:
517
+ return moves
518
+
519
+ # Parse the 3 equity values from "Cubeful Equities:" section
520
+ # Pattern to match cube decision lines:
521
+ # " No redouble: +0.172"
522
+ # " Redouble/Take: -0.361 (-0.533)"
523
+ # " Redouble/Pass: +1.000 (+0.828)"
524
+ pattern = re.compile(
525
+ r'^\s*(No (?:redouble|double)|(?:Re)?[Dd]ouble/(?:Take|Pass|Drop)):\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
526
+ re.MULTILINE | re.IGNORECASE
527
+ )
528
+
529
+ # Store parsed equities and XG errors in order they appear
530
+ # This preserves XG's original order (No double, Double/Take, Double/Pass)
531
+ xg_moves_data = [] # List of (normalized_notation, equity, xg_error, xg_order)
532
+ for i, match in enumerate(pattern.finditer(text), 1):
533
+ notation = match.group(1).strip()
534
+ equity = float(match.group(2))
535
+ error_str = match.group(3)
536
+
537
+ # Parse XG's error (in parentheses) - preserve the sign (+ or -)
538
+ xg_error = float(error_str) if error_str else 0.0
539
+
540
+ # Normalize notation
541
+ normalized = XGTextParser._clean_move_notation(notation)
542
+ xg_moves_data.append((normalized, equity, xg_error, i))
543
+
544
+ if not xg_moves_data:
545
+ return moves
546
+
547
+ # Build equity map for easy lookup
548
+ equity_map = {data[0]: data[1] for data in xg_moves_data}
549
+
550
+ # Parse "Best Cube action:" to determine which is actually best
551
+ best_action_match = re.search(
552
+ r'Best Cube action:\s*(.+?)(?:\n|$)',
553
+ text,
554
+ re.IGNORECASE
555
+ )
556
+
557
+ best_action_text = None
558
+ if best_action_match:
559
+ best_action_text = best_action_match.group(1).strip()
560
+
561
+ # Determine if we're using "double" or "redouble" terminology
562
+ # Check if any parsed notation contains "redouble"
563
+ use_redouble = any('redouble' in match.group(1).lower()
564
+ for match in pattern.finditer(text))
565
+
566
+ # Generate all 5 cube options with appropriate terminology
567
+ double_term = "Redouble" if use_redouble else "Double"
568
+
569
+ # Define all 5 possible cube options
570
+ # All options should show opponent's recommended response
571
+ all_options = [
572
+ f"No {double_term}/Take",
573
+ f"{double_term}/Take",
574
+ f"{double_term}/Pass",
575
+ f"Too good/Take",
576
+ f"Too good/Pass"
577
+ ]
578
+
579
+ # Assign equities and determine best move
580
+ # The equities we have from XG are: No double, Double/Take, Double/Pass
581
+ # We need to infer equities for "Too good" options
582
+
583
+ no_double_eq = equity_map.get("No Double", None)
584
+ double_take_eq = equity_map.get("Double/Take", None)
585
+ double_pass_eq = equity_map.get("Double/Pass", None)
586
+
587
+ # Build option list with equities
588
+ option_equities = {}
589
+ if no_double_eq is not None:
590
+ # "No Double/Take" means we don't double and opponent would take if we did
591
+ option_equities[f"No {double_term}/Take"] = no_double_eq
592
+ if double_take_eq is not None:
593
+ option_equities[f"{double_term}/Take"] = double_take_eq
594
+ if double_pass_eq is not None:
595
+ option_equities[f"{double_term}/Pass"] = double_pass_eq
596
+
597
+ # For "Too good" options, use the same equity as the corresponding action
598
+ # Too good/Take means we're too good to double, so opponent should drop
599
+ # This has the same practical equity as Double/Pass
600
+ if double_pass_eq is not None:
601
+ option_equities["Too good/Take"] = double_pass_eq
602
+ option_equities["Too good/Pass"] = double_pass_eq
603
+
604
+ # Determine which is the best option based on "Best Cube action:" text
605
+ # Format can be:
606
+ # "No redouble / Take" means "No redouble/Take" (we don't double, opponent would take)
607
+ # "Redouble / Take" means "Redouble/Take" (we double, opponent takes)
608
+ # "Too good to redouble / Pass" means "Too good/Pass"
609
+ best_notation = None
610
+ if best_action_text:
611
+ text_lower = best_action_text.lower()
612
+ if 'too good' in text_lower:
613
+ # "Too good to redouble / Take" or "Too good to redouble / Pass"
614
+ # The part after the slash is what opponent would do
615
+ if 'take' in text_lower:
616
+ best_notation = "Too good/Take"
617
+ elif 'pass' in text_lower or 'drop' in text_lower:
618
+ best_notation = "Too good/Pass"
619
+ elif ('no double' in text_lower or 'no redouble' in text_lower):
620
+ # "No redouble / Take" means we don't double, opponent would take
621
+ best_notation = f"No {double_term}/Take"
622
+ elif ('double' in text_lower or 'redouble' in text_lower):
623
+ # This is tricky: "Redouble / Take" vs "No redouble / Take"
624
+ # We already handled "No redouble" above, so this must be actual double
625
+ if 'take' in text_lower:
626
+ best_notation = f"{double_term}/Take"
627
+ elif 'pass' in text_lower or 'drop' in text_lower:
628
+ best_notation = f"{double_term}/Pass"
629
+
630
+ # Build a lookup for XG move data
631
+ xg_data_map = {data[0]: data for data in xg_moves_data}
632
+
633
+ # Create Move objects for all 5 options
634
+ for i, option in enumerate(all_options):
635
+ equity = option_equities.get(option, 0.0)
636
+ # Mark "Too good" options as synthetic (not from XG's analysis)
637
+ is_from_xg = not option.startswith("Too good")
638
+
639
+ # Get XG's error, order, and original notation for this move if it's from XG
640
+ xg_error_val = None
641
+ xg_order = None
642
+ xg_notation_val = None
643
+ if is_from_xg:
644
+ # Look up the original notation (without /Take suffix for No Double)
645
+ base_notation = option.replace(f"No {double_term}/Take", "No Double")
646
+ base_notation = base_notation.replace(f"{double_term}/Take", "Double/Take")
647
+ base_notation = base_notation.replace(f"{double_term}/Pass", "Double/Pass")
648
+
649
+ if base_notation in xg_data_map:
650
+ _, _, xg_error_val, xg_order = xg_data_map[base_notation]
651
+ # Store the XG notation with proper terminology
652
+ if base_notation == "No Double":
653
+ xg_notation_val = f"No {double_term.lower()}"
654
+ else:
655
+ xg_notation_val = base_notation.replace("Double", double_term)
656
+
657
+ moves.append(Move(
658
+ notation=option,
659
+ equity=equity,
660
+ error=0.0, # Will calculate below (error relative to best)
661
+ rank=0, # Will assign ranks below
662
+ xg_rank=xg_order, # Order in XG's Cubeful Equities section
663
+ xg_error=xg_error_val, # Error as shown by XG
664
+ xg_notation=xg_notation_val, # Original XG notation for analysis table
665
+ from_xg_analysis=is_from_xg
666
+ ))
667
+
668
+ # Sort by equity (highest first) to determine ranking
669
+ moves.sort(key=lambda m: m.equity, reverse=True)
670
+
671
+ # Assign ranks: best move gets rank 1, rest get 2-5 based on equity
672
+ if best_notation:
673
+ # Best move was identified from "Best Cube action:" line
674
+ rank_counter = 1
675
+ for move in moves:
676
+ if move.notation == best_notation:
677
+ move.rank = 1
678
+ else:
679
+ # Assign ranks 2-5 based on equity order, skipping the best
680
+ if rank_counter == 1:
681
+ rank_counter = 2
682
+ move.rank = rank_counter
683
+ rank_counter += 1
684
+ else:
685
+ # Best wasn't identified, rank purely by equity
686
+ for i, move in enumerate(moves):
687
+ move.rank = i + 1
688
+
689
+ # Calculate errors relative to best move (for our internal use)
690
+ if moves:
691
+ best_move = next((m for m in moves if m.rank == 1), moves[0])
692
+ best_equity = best_move.equity
693
+
694
+ for move in moves:
695
+ if move.rank != 1:
696
+ move.error = abs(best_equity - move.equity)
697
+
698
+ # Sort by rank for output
699
+ moves.sort(key=lambda m: m.rank)
700
+
701
+ return moves
702
+
703
+ @staticmethod
704
+ def _clean_move_notation(notation: str) -> str:
705
+ """Clean up move notation."""
706
+ # Remove engine names like "XG Roller+", "Roller++", "3-ply", etc.
707
+ # These appear at the start of the notation
708
+ notation = re.sub(r'^(XG\s+)?(?:Roller\+*|rollout|\d+-ply)\s+', '', notation, flags=re.IGNORECASE)
709
+
710
+ # Remove extra whitespace
711
+ notation = re.sub(r'\s+', ' ', notation)
712
+ notation = notation.strip()
713
+
714
+ # Handle cube actions
715
+ notation_lower = notation.lower()
716
+ if 'double' in notation_lower and 'take' in notation_lower:
717
+ return "Double/Take"
718
+ elif 'double' in notation_lower and 'drop' in notation_lower:
719
+ return "Double/Drop"
720
+ elif 'double' in notation_lower and 'pass' in notation_lower:
721
+ return "Double/Pass"
722
+ elif 'no double' in notation_lower or 'no redouble' in notation_lower:
723
+ return "No Double"
724
+ elif 'take' in notation_lower:
725
+ return "Take"
726
+ elif 'drop' in notation_lower or 'pass' in notation_lower:
727
+ return "Drop"
728
+
729
+ return notation