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,688 @@
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. XGID data takes precedence where it exists
94
+ # since it correctly accounts for perspective in all position encodings.
95
+ for key, value in game_info.items():
96
+ if key not in metadata or key == 'decision_type':
97
+ # Add values not present in XGID metadata
98
+ # decision_type can only come from text parsing
99
+ metadata[key] = value
100
+
101
+ # Parse move analysis
102
+ moves = XGTextParser._parse_moves(analysis_section)
103
+ # Note: Allow empty moves for XGID-only positions (gnubg can analyze them later)
104
+ # if not moves:
105
+ # return None
106
+
107
+ # Parse global winning chances (for cube decisions)
108
+ winning_chances = XGTextParser._parse_winning_chances(analysis_section)
109
+
110
+ # Determine decision type from metadata or dice presence
111
+ if 'decision_type' in metadata:
112
+ decision_type = metadata['decision_type']
113
+ elif 'dice' not in metadata or metadata.get('dice') is None:
114
+ decision_type = DecisionType.CUBE_ACTION
115
+ else:
116
+ decision_type = DecisionType.CHECKER_PLAY
117
+
118
+ # Determine Crawford status from multiple sources
119
+ # The crawford_jacoby field indicates Crawford rule for matches or Jacoby rule for money games
120
+ match_length = metadata.get('match_length', 0)
121
+ crawford = False
122
+
123
+ if match_length > 0:
124
+ if 'crawford' in metadata and metadata['crawford']:
125
+ crawford = True
126
+ elif 'crawford_jacoby' in metadata and metadata['crawford_jacoby'] > 0:
127
+ crawford = True
128
+ elif 'match_modifier' in metadata and metadata['match_modifier'] == 'C':
129
+ crawford = True
130
+
131
+ # Create decision
132
+ decision = Decision(
133
+ position=position,
134
+ xgid=position_id, # Store original position ID (XGID or OGID)
135
+ on_roll=metadata.get('on_roll', Player.O),
136
+ dice=metadata.get('dice'),
137
+ score_x=metadata.get('score_x', 0),
138
+ score_o=metadata.get('score_o', 0),
139
+ match_length=metadata.get('match_length', 0),
140
+ crawford=crawford,
141
+ cube_value=metadata.get('cube_value', 1),
142
+ cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
143
+ decision_type=decision_type,
144
+ candidate_moves=moves,
145
+ player_win_pct=winning_chances.get('player_win_pct'),
146
+ player_gammon_pct=winning_chances.get('player_gammon_pct'),
147
+ player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
148
+ opponent_win_pct=winning_chances.get('opponent_win_pct'),
149
+ opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
150
+ opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
151
+ )
152
+
153
+ return decision
154
+
155
+ @staticmethod
156
+ def _parse_winning_chances(text: str) -> dict:
157
+ """
158
+ Parse global winning chances from text section.
159
+
160
+ Format:
161
+ Player Winning Chances: 52.68% (G:14.35% B:0.69%)
162
+ Opponent Winning Chances: 47.32% (G:12.42% B:0.55%)
163
+
164
+ Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
165
+ opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
166
+ """
167
+ chances = {}
168
+
169
+ # Parse player winning chances
170
+ player_match = re.search(
171
+ r'Player Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
172
+ text,
173
+ re.IGNORECASE
174
+ )
175
+ if player_match:
176
+ chances['player_win_pct'] = float(player_match.group(1))
177
+ chances['player_gammon_pct'] = float(player_match.group(2))
178
+ chances['player_backgammon_pct'] = float(player_match.group(3))
179
+
180
+ # Parse opponent winning chances
181
+ opponent_match = re.search(
182
+ r'Opponent Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
183
+ text,
184
+ re.IGNORECASE
185
+ )
186
+ if opponent_match:
187
+ chances['opponent_win_pct'] = float(opponent_match.group(1))
188
+ chances['opponent_gammon_pct'] = float(opponent_match.group(2))
189
+ chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
190
+
191
+ return chances
192
+
193
+ @staticmethod
194
+ def _parse_move_winning_chances(move_text: str) -> dict:
195
+ """
196
+ Parse winning chances from a move's analysis section.
197
+
198
+ Format:
199
+ Player: 53.81% (G:17.42% B:0.87%)
200
+ Opponent: 46.19% (G:12.99% B:0.64%)
201
+
202
+ Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
203
+ opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
204
+ """
205
+ chances = {}
206
+
207
+ # Parse player chances
208
+ player_match = re.search(
209
+ r'Player:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
210
+ move_text,
211
+ re.IGNORECASE
212
+ )
213
+ if player_match:
214
+ chances['player_win_pct'] = float(player_match.group(1))
215
+ chances['player_gammon_pct'] = float(player_match.group(2))
216
+ chances['player_backgammon_pct'] = float(player_match.group(3))
217
+
218
+ # Parse opponent chances
219
+ opponent_match = re.search(
220
+ r'Opponent:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
221
+ move_text,
222
+ re.IGNORECASE
223
+ )
224
+ if opponent_match:
225
+ chances['opponent_win_pct'] = float(opponent_match.group(1))
226
+ chances['opponent_gammon_pct'] = float(opponent_match.group(2))
227
+ chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
228
+
229
+ return chances
230
+
231
+ @staticmethod
232
+ def _parse_game_info(text: str) -> dict:
233
+ """
234
+ Parse game information from text section.
235
+
236
+ Extracts:
237
+ - Players (X:Player 2 O:Player 1)
238
+ - Score (Score is X:3 O:4 5 pt.(s) match.)
239
+ - Cube info (Cube: 2, O own cube)
240
+ - Turn info (X to play 63)
241
+ """
242
+ info = {}
243
+
244
+ # Parse player designation and map to internal model
245
+ # Player 1 = BOTTOM player = Player.O
246
+ # Player 2 = TOP player = Player.X
247
+ xo_to_player = {}
248
+ player_designation = re.search(
249
+ r'([XO]):Player\s+(\d+)',
250
+ text,
251
+ re.IGNORECASE
252
+ )
253
+ if player_designation:
254
+ label = player_designation.group(1).upper() # 'X' or 'O'
255
+ player_num = int(player_designation.group(2)) # 1 or 2
256
+
257
+ # Map player number to internal representation
258
+ if player_num == 1:
259
+ xo_to_player[label] = Player.O
260
+ else:
261
+ xo_to_player[label] = Player.X
262
+
263
+ # Parse the other player
264
+ other_label = 'O' if label == 'X' else 'X'
265
+ other_player_designation = re.search(
266
+ rf'{other_label}:Player\s+(\d+)',
267
+ text,
268
+ re.IGNORECASE
269
+ )
270
+ if other_player_designation:
271
+ other_num = int(other_player_designation.group(1))
272
+ if other_num == 1:
273
+ xo_to_player[other_label] = Player.O
274
+ else:
275
+ xo_to_player[other_label] = Player.X
276
+
277
+ # Parse score and match length
278
+ # "Score is X:3 O:4 5 pt.(s) match."
279
+ score_match = re.search(
280
+ r'Score is X:(\d+)\s+O:(\d+)\s+(\d+)\s+pt',
281
+ text,
282
+ re.IGNORECASE
283
+ )
284
+ if score_match:
285
+ info['score_x'] = int(score_match.group(1))
286
+ info['score_o'] = int(score_match.group(2))
287
+ info['match_length'] = int(score_match.group(3))
288
+
289
+ # Check for Crawford game indicator in pip count line
290
+ # Format: "Pip count X: 156 O: 167 X-O: 1-4/5 Crawford"
291
+ crawford_match = re.search(
292
+ r'Pip count.*Crawford',
293
+ text,
294
+ re.IGNORECASE
295
+ )
296
+ if crawford_match:
297
+ info['crawford'] = True
298
+
299
+ # Check for money game
300
+ if 'money game' in text.lower():
301
+ info['match_length'] = 0
302
+
303
+ # Parse cube info
304
+ cube_match = re.search(
305
+ r'Cube:\s*(\d+)(?:,\s*([XO])\s+own\s+cube)?',
306
+ text,
307
+ re.IGNORECASE
308
+ )
309
+ if cube_match:
310
+ info['cube_value'] = int(cube_match.group(1))
311
+ owner_label = cube_match.group(2)
312
+ if owner_label:
313
+ owner_label = owner_label.upper()
314
+ if owner_label in xo_to_player:
315
+ owner_player = xo_to_player[owner_label]
316
+ if owner_player == Player.X:
317
+ info['cube_owner'] = CubeState.X_OWNS
318
+ else:
319
+ info['cube_owner'] = CubeState.O_OWNS
320
+ else:
321
+ # Fallback if player mapping not found
322
+ if owner_label == 'X':
323
+ info['cube_owner'] = CubeState.X_OWNS
324
+ elif owner_label == 'O':
325
+ info['cube_owner'] = CubeState.O_OWNS
326
+ else:
327
+ info['cube_owner'] = CubeState.CENTERED
328
+
329
+ # Parse turn info
330
+ turn_match = re.search(
331
+ r'([XO])\s+(?:to\s+play|to\s+roll|on\s+roll)(?:\s+(\d)(\d))?',
332
+ text,
333
+ re.IGNORECASE
334
+ )
335
+ if turn_match:
336
+ player_label = turn_match.group(1).upper()
337
+
338
+ if player_label in xo_to_player:
339
+ info['on_roll'] = xo_to_player[player_label]
340
+ else:
341
+ # Fallback if player mapping not found
342
+ info['on_roll'] = Player.X if player_label == 'X' else Player.O
343
+
344
+ dice1 = turn_match.group(2)
345
+ dice2 = turn_match.group(3)
346
+ if dice1 and dice2:
347
+ info['dice'] = (int(dice1), int(dice2))
348
+
349
+ # Check for cube actions
350
+ if any(word in text.lower() for word in ['double', 'take', 'drop', 'pass', 'beaver']):
351
+ # Look for cube decision indicators
352
+ if 'double' in text.lower() and 'to play' not in text.lower():
353
+ info['decision_type'] = DecisionType.CUBE_ACTION
354
+
355
+ return info
356
+
357
+ @staticmethod
358
+ def _parse_moves(text: str) -> List[Move]:
359
+ """
360
+ Parse move analysis from text.
361
+
362
+ Format:
363
+ 1. XG Roller+ 11/8 11/5 eq:+0.589
364
+ Player: 79.46% (G:17.05% B:0.67%)
365
+ Opponent: 20.54% (G:2.22% B:0.06%)
366
+
367
+ 2. XG Roller+ 9/3* 6/3 eq:+0.529 (-0.061)
368
+ Player: 76.43% (G:24.10% B:1.77%)
369
+ Opponent: 23.57% (G:3.32% B:0.12%)
370
+
371
+ Or for cube decisions:
372
+ 1. XG Roller+ Double, take eq:+0.678
373
+ 2. XG Roller+ Double, drop eq:+0.645 (-0.033)
374
+ 3. XG Roller+ No double eq:+0.623 (-0.055)
375
+ """
376
+ moves = []
377
+
378
+ # Find all move entries
379
+ # Pattern: rank. [engine] notation eq:[equity] [(error)]
380
+ move_pattern = re.compile(
381
+ r'^\s*(\d+)\.\s+(?:[\w\s+-]+?)\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
382
+ re.MULTILINE | re.IGNORECASE
383
+ )
384
+
385
+ # Split text into lines to extract following lines after each move
386
+ lines = text.split('\n')
387
+ move_matches = list(move_pattern.finditer(text))
388
+
389
+ for i, match in enumerate(move_matches):
390
+ rank = int(match.group(1))
391
+ notation = match.group(2).strip()
392
+ equity = float(match.group(3))
393
+ error_str = match.group(4)
394
+
395
+ # Parse error if present
396
+ if error_str:
397
+ xg_error = float(error_str)
398
+ error = abs(xg_error)
399
+ else:
400
+ xg_error = 0.0
401
+ error = 0.0
402
+
403
+ # Clean up notation
404
+ notation = XGTextParser._clean_move_notation(notation)
405
+
406
+ # Extract winning chances from the lines following this move
407
+ # Get the text between this match and the next move (or end)
408
+ start_pos = match.end()
409
+ if i + 1 < len(move_matches):
410
+ end_pos = move_matches[i + 1].start()
411
+ else:
412
+ end_pos = len(text)
413
+
414
+ move_section = text[start_pos:end_pos]
415
+ winning_chances = XGTextParser._parse_move_winning_chances(move_section)
416
+
417
+ moves.append(Move(
418
+ notation=notation,
419
+ equity=equity,
420
+ error=error,
421
+ rank=rank,
422
+ xg_error=xg_error,
423
+ xg_notation=notation,
424
+ player_win_pct=winning_chances.get('player_win_pct'),
425
+ player_gammon_pct=winning_chances.get('player_gammon_pct'),
426
+ player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
427
+ opponent_win_pct=winning_chances.get('opponent_win_pct'),
428
+ opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
429
+ opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
430
+ ))
431
+
432
+ # If we didn't find moves with the standard pattern, try alternative patterns
433
+ if not moves:
434
+ moves = XGTextParser._parse_moves_fallback(text)
435
+
436
+ # If still no moves, try parsing as cube decision
437
+ if not moves:
438
+ moves = XGTextParser._parse_cube_decision(text)
439
+
440
+ # Calculate errors if not already set
441
+ if moves and len(moves) > 1:
442
+ best_equity = moves[0].equity
443
+ for move in moves[1:]:
444
+ if move.error == 0.0:
445
+ move.error = abs(best_equity - move.equity)
446
+
447
+ return moves
448
+
449
+ @staticmethod
450
+ def _parse_moves_fallback(text: str) -> List[Move]:
451
+ """Fallback parser for alternative move formats."""
452
+ moves = []
453
+
454
+ # Try simpler pattern without engine name
455
+ # "1. 11/8 11/5 eq:+0.589"
456
+ pattern = re.compile(
457
+ r'^\s*(\d+)\.\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)',
458
+ re.MULTILINE | re.IGNORECASE
459
+ )
460
+
461
+ for match in pattern.finditer(text):
462
+ rank = int(match.group(1))
463
+ notation = match.group(2).strip()
464
+ equity = float(match.group(3))
465
+
466
+ notation = XGTextParser._clean_move_notation(notation)
467
+
468
+ moves.append(Move(
469
+ notation=notation,
470
+ equity=equity,
471
+ error=0.0,
472
+ rank=rank
473
+ ))
474
+
475
+ return moves
476
+
477
+ @staticmethod
478
+ def _parse_cube_decision(text: str) -> List[Move]:
479
+ """
480
+ Parse cube decision analysis from text.
481
+
482
+ Format:
483
+ Cubeful Equities:
484
+ No redouble: +0.172
485
+ Redouble/Take: -0.361 (-0.533)
486
+ Redouble/Pass: +1.000 (+0.828)
487
+
488
+ Best Cube action: No redouble / Take
489
+ OR: Too good to redouble / Pass
490
+
491
+ Generates all 5 cube options:
492
+ - No double/redouble
493
+ - Double/Take (Redouble/Take)
494
+ - Double/Pass (Redouble/Pass)
495
+ - Too good/Take
496
+ - Too good/Pass
497
+ """
498
+ moves = []
499
+
500
+ # Look for "Cubeful Equities:" section
501
+ if 'Cubeful Equities:' not in text:
502
+ return moves
503
+
504
+ # Parse the 3 equity values from "Cubeful Equities:" section
505
+ # Pattern to match cube decision lines:
506
+ # " No redouble: +0.172"
507
+ # " Redouble/Take: -0.361 (-0.533)"
508
+ # " Redouble/Pass: +1.000 (+0.828)"
509
+ pattern = re.compile(
510
+ r'^\s*(No (?:redouble|double)|(?:Re)?[Dd]ouble/(?:Take|Pass|Drop)):\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
511
+ re.MULTILINE | re.IGNORECASE
512
+ )
513
+
514
+ # Store parsed equities in order they appear
515
+ xg_moves_data = []
516
+ for i, match in enumerate(pattern.finditer(text), 1):
517
+ notation = match.group(1).strip()
518
+ equity = float(match.group(2))
519
+ error_str = match.group(3)
520
+
521
+ xg_error = float(error_str) if error_str else 0.0
522
+
523
+ # Normalize notation
524
+ normalized = XGTextParser._clean_move_notation(notation)
525
+ xg_moves_data.append((normalized, equity, xg_error, i))
526
+
527
+ if not xg_moves_data:
528
+ return moves
529
+
530
+ # Build equity map for easy lookup
531
+ equity_map = {data[0]: data[1] for data in xg_moves_data}
532
+
533
+ # Parse "Best Cube action:" to determine which is actually best
534
+ best_action_match = re.search(
535
+ r'Best Cube action:\s*(.+?)(?:\n|$)',
536
+ text,
537
+ re.IGNORECASE
538
+ )
539
+
540
+ best_action_text = None
541
+ if best_action_match:
542
+ best_action_text = best_action_match.group(1).strip()
543
+
544
+ # Determine if we're using "double" or "redouble" terminology
545
+ # Check if any parsed notation contains "redouble"
546
+ use_redouble = any('redouble' in match.group(1).lower()
547
+ for match in pattern.finditer(text))
548
+
549
+ # Generate all 5 cube options with appropriate terminology
550
+ double_term = "Redouble" if use_redouble else "Double"
551
+
552
+ # Define all 5 possible cube options
553
+ # All options should show opponent's recommended response
554
+ all_options = [
555
+ f"No {double_term}/Take",
556
+ f"{double_term}/Take",
557
+ f"{double_term}/Pass",
558
+ f"Too good/Take",
559
+ f"Too good/Pass"
560
+ ]
561
+
562
+ # Assign equities from XG's analysis
563
+ no_double_eq = equity_map.get("No Double", None)
564
+ double_take_eq = equity_map.get("Double/Take", None)
565
+ double_pass_eq = equity_map.get("Double/Pass", None)
566
+
567
+ # Build option list with equities
568
+ option_equities = {}
569
+ if no_double_eq is not None:
570
+ option_equities[f"No {double_term}/Take"] = no_double_eq
571
+ if double_take_eq is not None:
572
+ option_equities[f"{double_term}/Take"] = double_take_eq
573
+ if double_pass_eq is not None:
574
+ option_equities[f"{double_term}/Pass"] = double_pass_eq
575
+
576
+ # Assign equities for synthetic "Too good" options
577
+ if double_pass_eq is not None:
578
+ option_equities["Too good/Take"] = double_pass_eq
579
+ option_equities["Too good/Pass"] = double_pass_eq
580
+
581
+ # Determine best option from "Best Cube action:" text
582
+ best_notation = None
583
+ if best_action_text:
584
+ text_lower = best_action_text.lower()
585
+ if 'too good' in text_lower:
586
+ if 'take' in text_lower:
587
+ best_notation = "Too good/Take"
588
+ elif 'pass' in text_lower or 'drop' in text_lower:
589
+ best_notation = "Too good/Pass"
590
+ elif ('no double' in text_lower or 'no redouble' in text_lower):
591
+ best_notation = f"No {double_term}/Take"
592
+ elif ('double' in text_lower or 'redouble' in text_lower):
593
+ if 'take' in text_lower:
594
+ best_notation = f"{double_term}/Take"
595
+ elif 'pass' in text_lower or 'drop' in text_lower:
596
+ best_notation = f"{double_term}/Pass"
597
+
598
+ # Build a lookup for XG move data
599
+ xg_data_map = {data[0]: data for data in xg_moves_data}
600
+
601
+ # Create Move objects for all 5 options
602
+ for i, option in enumerate(all_options):
603
+ equity = option_equities.get(option, 0.0)
604
+ is_from_xg = not option.startswith("Too good")
605
+
606
+ # Get XG metadata for moves from analysis
607
+ xg_error_val = None
608
+ xg_order = None
609
+ xg_notation_val = None
610
+ if is_from_xg:
611
+ base_notation = option.replace(f"No {double_term}/Take", "No Double")
612
+ base_notation = base_notation.replace(f"{double_term}/Take", "Double/Take")
613
+ base_notation = base_notation.replace(f"{double_term}/Pass", "Double/Pass")
614
+
615
+ if base_notation in xg_data_map:
616
+ _, _, xg_error_val, xg_order = xg_data_map[base_notation]
617
+ if base_notation == "No Double":
618
+ xg_notation_val = f"No {double_term.lower()}"
619
+ else:
620
+ xg_notation_val = base_notation.replace("Double", double_term)
621
+
622
+ moves.append(Move(
623
+ notation=option,
624
+ equity=equity,
625
+ error=0.0,
626
+ rank=0,
627
+ xg_rank=xg_order,
628
+ xg_error=xg_error_val,
629
+ xg_notation=xg_notation_val,
630
+ from_xg_analysis=is_from_xg
631
+ ))
632
+
633
+ # Sort by equity (highest first) to determine ranking
634
+ moves.sort(key=lambda m: m.equity, reverse=True)
635
+
636
+ # Assign ranks based on best move and equity
637
+ if best_notation:
638
+ rank_counter = 1
639
+ for move in moves:
640
+ if move.notation == best_notation:
641
+ move.rank = 1
642
+ else:
643
+ if rank_counter == 1:
644
+ rank_counter = 2
645
+ move.rank = rank_counter
646
+ rank_counter += 1
647
+ else:
648
+ for i, move in enumerate(moves):
649
+ move.rank = i + 1
650
+
651
+ # Calculate errors relative to best move
652
+ if moves:
653
+ best_move = next((m for m in moves if m.rank == 1), moves[0])
654
+ best_equity = best_move.equity
655
+ for move in moves:
656
+ if move.rank != 1:
657
+ move.error = abs(best_equity - move.equity)
658
+
659
+ # Sort by rank for output
660
+ moves.sort(key=lambda m: m.rank)
661
+
662
+ return moves
663
+
664
+ @staticmethod
665
+ def _clean_move_notation(notation: str) -> str:
666
+ """Clean up move notation by removing engine names and normalizing cube actions."""
667
+ notation = re.sub(r'^(XG\s+)?(?:Roller\+*|rollout|\d+-ply)\s+', '', notation, flags=re.IGNORECASE)
668
+
669
+ # Remove extra whitespace
670
+ notation = re.sub(r'\s+', ' ', notation)
671
+ notation = notation.strip()
672
+
673
+ # Handle cube actions
674
+ notation_lower = notation.lower()
675
+ if 'double' in notation_lower and 'take' in notation_lower:
676
+ return "Double/Take"
677
+ elif 'double' in notation_lower and 'drop' in notation_lower:
678
+ return "Double/Drop"
679
+ elif 'double' in notation_lower and 'pass' in notation_lower:
680
+ return "Double/Pass"
681
+ elif 'no double' in notation_lower or 'no redouble' in notation_lower:
682
+ return "No Double"
683
+ elif 'take' in notation_lower:
684
+ return "Take"
685
+ elif 'drop' in notation_lower or 'pass' in notation_lower:
686
+ return "Drop"
687
+
688
+ return notation
@@ -0,0 +1,5 @@
1
+ """Board rendering utilities."""
2
+
3
+ from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
4
+
5
+ __all__ = ["SVGBoardRenderer"]