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,468 @@
1
+ """
2
+ GNU Backgammon text output parser.
3
+
4
+ Parses analysis output from gnubg-cli.exe into Decision objects.
5
+ """
6
+
7
+ import re
8
+ from typing import List, Optional
9
+
10
+ from ankigammon.models import Decision, DecisionType, Move, Player, Position
11
+ from ankigammon.utils.xgid import parse_xgid
12
+
13
+
14
+ class GNUBGParser:
15
+ """Parse GNU Backgammon analysis output."""
16
+
17
+ @staticmethod
18
+ def parse_analysis(
19
+ gnubg_output: str,
20
+ xgid: str,
21
+ decision_type: DecisionType
22
+ ) -> Decision:
23
+ """
24
+ Parse gnubg output into Decision object.
25
+
26
+ Args:
27
+ gnubg_output: Raw text output from gnubg-cli.exe
28
+ xgid: Original XGID for position reconstruction
29
+ decision_type: CHECKER_PLAY or CUBE_ACTION
30
+
31
+ Returns:
32
+ Decision object with populated candidate_moves
33
+
34
+ Raises:
35
+ ValueError: If parsing fails
36
+ """
37
+ # Parse XGID to get position and metadata
38
+ position, metadata = parse_xgid(xgid)
39
+
40
+ # Parse moves based on decision type
41
+ if decision_type == DecisionType.CHECKER_PLAY:
42
+ moves = GNUBGParser._parse_checker_play(gnubg_output)
43
+ else:
44
+ moves = GNUBGParser._parse_cube_decision(gnubg_output)
45
+
46
+ if not moves:
47
+ raise ValueError(f"No moves found in gnubg output for {decision_type.value}")
48
+
49
+ # Extract winning chances from metadata or output
50
+ winning_chances = GNUBGParser._parse_winning_chances(gnubg_output)
51
+
52
+ # Build Decision object
53
+ decision = Decision(
54
+ position=position,
55
+ on_roll=metadata.get('on_roll', Player.O),
56
+ decision_type=decision_type,
57
+ candidate_moves=moves,
58
+ dice=metadata.get('dice'),
59
+ xgid=xgid,
60
+ score_x=metadata.get('score_x', 0),
61
+ score_o=metadata.get('score_o', 0),
62
+ match_length=metadata.get('match_length', 0),
63
+ cube_value=metadata.get('cube_value', 1),
64
+ cube_owner=metadata.get('cube_owner'),
65
+ )
66
+
67
+ # Add winning chances to decision if found
68
+ if winning_chances:
69
+ decision.player_win_pct = winning_chances.get('player_win_pct')
70
+ decision.player_gammon_pct = winning_chances.get('player_gammon_pct')
71
+ decision.player_backgammon_pct = winning_chances.get('player_backgammon_pct')
72
+ decision.opponent_win_pct = winning_chances.get('opponent_win_pct')
73
+ decision.opponent_gammon_pct = winning_chances.get('opponent_gammon_pct')
74
+ decision.opponent_backgammon_pct = winning_chances.get('opponent_backgammon_pct')
75
+
76
+ return decision
77
+
78
+ @staticmethod
79
+ def _parse_checker_play(text: str) -> List[Move]:
80
+ """
81
+ Parse checker play analysis from gnubg output.
82
+
83
+ Expected format:
84
+ 1. Cubeful 4-ply 21/16 21/15 Eq.: -0.411
85
+ 0.266 0.021 0.001 - 0.734 0.048 0.001
86
+ 4-ply cubeful prune [4ply]
87
+ 2. Cubeful 4-ply 9/4 9/3 Eq.: -0.437 ( -0.025)
88
+ 0.249 0.004 0.000 - 0.751 0.021 0.000
89
+ 4-ply cubeful prune [4ply]
90
+
91
+ Args:
92
+ text: gnubg output text
93
+
94
+ Returns:
95
+ List of Move objects sorted by rank
96
+ """
97
+ moves = []
98
+ lines = text.split('\n')
99
+
100
+ # Pattern for gnubg move lines
101
+ # Matches: " 1. Cubeful 4-ply 21/16 21/15 Eq.: -0.411"
102
+ # " 2. Cubeful 4-ply 9/4 9/3 Eq.: -0.437 ( -0.025)"
103
+ move_pattern = re.compile(
104
+ r'^\s*(\d+)\.\s+(?:Cubeful\s+\d+-ply\s+)?(.*?)\s+Eq\.?:\s*([+-]?\d+\.\d+)(?:\s*\(\s*([+-]?\d+\.\d+)\))?',
105
+ re.IGNORECASE
106
+ )
107
+
108
+ # Pattern for probability line
109
+ # Matches: " 0.266 0.021 0.001 - 0.734 0.048 0.001"
110
+ prob_pattern = re.compile(
111
+ r'^\s*(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)\s*-\s*(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)'
112
+ )
113
+
114
+ for i, line in enumerate(lines):
115
+ match = move_pattern.match(line)
116
+ if match:
117
+ rank = int(match.group(1))
118
+ notation = match.group(2).strip()
119
+ equity = float(match.group(3))
120
+ error_str = match.group(4)
121
+
122
+ error = float(error_str) if error_str else 0.0
123
+ abs_error = abs(error)
124
+
125
+ # Look for probability line on next line
126
+ player_win = None
127
+ player_gammon = None
128
+ player_backgammon = None
129
+ opponent_win = None
130
+ opponent_gammon = None
131
+ opponent_backgammon = None
132
+
133
+ if i + 1 < len(lines):
134
+ prob_match = prob_pattern.match(lines[i + 1])
135
+ if prob_match:
136
+ player_win = float(prob_match.group(1)) * 100
137
+ player_gammon = float(prob_match.group(2)) * 100
138
+ player_backgammon = float(prob_match.group(3)) * 100
139
+ opponent_win = float(prob_match.group(4)) * 100
140
+ opponent_gammon = float(prob_match.group(5)) * 100
141
+ opponent_backgammon = float(prob_match.group(6)) * 100
142
+
143
+ moves.append(Move(
144
+ notation=notation,
145
+ equity=equity,
146
+ rank=rank,
147
+ error=abs_error,
148
+ xg_error=error,
149
+ xg_notation=notation,
150
+ xg_rank=rank,
151
+ from_xg_analysis=True,
152
+ player_win_pct=player_win,
153
+ player_gammon_pct=player_gammon,
154
+ player_backgammon_pct=player_backgammon,
155
+ opponent_win_pct=opponent_win,
156
+ opponent_gammon_pct=opponent_gammon,
157
+ opponent_backgammon_pct=opponent_backgammon
158
+ ))
159
+
160
+ # If no moves found, try alternative pattern
161
+ if not moves:
162
+ # Try simpler pattern without rank numbers
163
+ alt_pattern = re.compile(
164
+ r'^\s*([0-9/\s*bar]+?)\s+Eq:\s*([+-]?\d+\.\d+)',
165
+ re.MULTILINE
166
+ )
167
+ for i, match in enumerate(alt_pattern.finditer(text), 1):
168
+ notation = match.group(1).strip()
169
+ equity = float(match.group(2))
170
+
171
+ moves.append(Move(
172
+ notation=notation,
173
+ equity=equity,
174
+ rank=i,
175
+ error=0.0,
176
+ from_xg_analysis=True
177
+ ))
178
+
179
+ # Sort by equity (highest first) and recalculate errors
180
+ if moves:
181
+ moves.sort(key=lambda m: m.equity, reverse=True)
182
+ best_equity = moves[0].equity
183
+
184
+ for i, move in enumerate(moves, 1):
185
+ move.rank = i
186
+ move.error = abs(best_equity - move.equity)
187
+
188
+ return moves
189
+
190
+ @staticmethod
191
+ def _parse_cube_decision(text: str) -> List[Move]:
192
+ """
193
+ Parse cube decision analysis from gnubg output.
194
+
195
+ Expected format:
196
+ Cubeful equities:
197
+ 1. No double +0.172
198
+ 2. Double, take -0.361 (-0.533)
199
+ 3. Double, pass +1.000 (+0.828)
200
+
201
+ Proper cube action: No double
202
+
203
+ Note: Score matrix generation always uses initial double (cube=1), so
204
+ "You cannot double" should not occur. This handler is for edge cases
205
+ and non-matrix analysis. Returns move that displays as "—" in matrix.
206
+
207
+ Generates all 5 cube options (like XG parser):
208
+ - No double/Take
209
+ - Double/Take
210
+ - Double/Pass
211
+ - Too good/Take (synthetic)
212
+ - Too good/Pass (synthetic)
213
+
214
+ Args:
215
+ text: gnubg output text
216
+
217
+ Returns:
218
+ List of Move objects with all 5 cube options
219
+ """
220
+ moves = []
221
+
222
+ # Handle "You cannot double" (restricted doubling at certain match scores)
223
+ if 'You cannot double' in text or 'you cannot double' in text:
224
+ moves.append(Move(
225
+ notation="No Double/Take",
226
+ equity=0.0, # No equity info available
227
+ error=0.0,
228
+ rank=1,
229
+ xg_error=0.0,
230
+ xg_notation="No Double/Take",
231
+ xg_rank=1,
232
+ from_xg_analysis=True
233
+ ))
234
+ return moves
235
+
236
+ # Look for "Cubeful equities:" section
237
+ if 'Cubeful equities' not in text and 'cubeful equities' not in text:
238
+ return moves
239
+
240
+ # Parse the 3 equity values from gnubg
241
+ # Pattern to match cube decision lines:
242
+ # "1. No double +0.172"
243
+ # "2. Double, take -0.361 (-0.533)"
244
+ # "3. Double, pass +1.000 (+0.828)"
245
+ pattern = re.compile(
246
+ r'^\s*\d+\.\s*(No (?:re)?double|(?:Re)?[Dd]ouble,?\s*(?:take|pass|drop))\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
247
+ re.MULTILINE | re.IGNORECASE
248
+ )
249
+
250
+ # Store parsed equities in order they appear
251
+ gnubg_moves_data = [] # List of (normalized_notation, equity, gnubg_error)
252
+ for match in pattern.finditer(text):
253
+ notation = match.group(1).strip()
254
+ equity = float(match.group(2))
255
+ error_str = match.group(3)
256
+
257
+ gnubg_error = float(error_str) if error_str else 0.0
258
+
259
+ normalized = notation.replace(', ', '/').replace(',', '/')
260
+ normalized = GNUBGParser._normalize_cube_notation(normalized)
261
+
262
+ gnubg_moves_data.append((normalized, equity, gnubg_error))
263
+
264
+ if not gnubg_moves_data:
265
+ return moves
266
+
267
+ # Build equity map for easy lookup
268
+ equity_map = {data[0]: data[1] for data in gnubg_moves_data}
269
+
270
+ # Parse "Proper cube action:" to determine best move
271
+ best_action_match = re.search(
272
+ r'Proper cube action:\s*(.+?)(?:\n|$)',
273
+ text,
274
+ re.IGNORECASE
275
+ )
276
+
277
+ best_action_text = None
278
+ if best_action_match:
279
+ best_action_text = best_action_match.group(1).strip()
280
+
281
+ # Determine if using "double" or "redouble" terminology
282
+ use_redouble = any('redouble' in data[0].lower() for data in gnubg_moves_data)
283
+ double_term = "Redouble" if use_redouble else "Double"
284
+
285
+ # Generate all 5 cube options with appropriate terminology
286
+ all_options = [
287
+ f"No {double_term}/Take",
288
+ f"{double_term}/Take",
289
+ f"{double_term}/Pass",
290
+ f"Too good/Take",
291
+ f"Too good/Pass"
292
+ ]
293
+
294
+ # Assign equities
295
+ no_double_eq = equity_map.get("No Double", None)
296
+ double_take_eq = equity_map.get("Double/Take", None)
297
+ double_pass_eq = equity_map.get("Double/Pass", None)
298
+
299
+ option_equities = {}
300
+ if no_double_eq is not None:
301
+ option_equities[f"No {double_term}/Take"] = no_double_eq
302
+ if double_take_eq is not None:
303
+ option_equities[f"{double_term}/Take"] = double_take_eq
304
+ if double_pass_eq is not None:
305
+ option_equities[f"{double_term}/Pass"] = double_pass_eq
306
+
307
+ # Assign equities for synthetic "Too good" options
308
+ if double_pass_eq is not None:
309
+ option_equities["Too good/Take"] = double_pass_eq
310
+ option_equities["Too good/Pass"] = double_pass_eq
311
+
312
+ # Determine best notation from "Proper cube action:" text
313
+ best_notation = GNUBGParser._parse_best_cube_action(best_action_text, double_term)
314
+
315
+ # Create Move objects for all 5 options
316
+ for option in all_options:
317
+ equity = option_equities.get(option, 0.0)
318
+ is_from_gnubg = not option.startswith("Too good")
319
+
320
+ moves.append(Move(
321
+ notation=option,
322
+ equity=equity,
323
+ error=0.0, # Will calculate below
324
+ rank=0, # Will assign below
325
+ xg_error=None,
326
+ xg_notation=option if is_from_gnubg else None,
327
+ xg_rank=None,
328
+ from_xg_analysis=is_from_gnubg
329
+ ))
330
+
331
+ # Sort by equity (highest first) to determine ranking
332
+ moves.sort(key=lambda m: m.equity, reverse=True)
333
+
334
+ # Assign ranks
335
+ if best_notation:
336
+ rank_counter = 1
337
+ for move in moves:
338
+ if move.notation == best_notation:
339
+ move.rank = 1
340
+ else:
341
+ if rank_counter == 1:
342
+ rank_counter = 2
343
+ move.rank = rank_counter
344
+ rank_counter += 1
345
+ else:
346
+ # Best wasn't identified, rank purely by equity
347
+ for i, move in enumerate(moves, 1):
348
+ move.rank = i
349
+
350
+ # Calculate errors relative to best move
351
+ if moves:
352
+ best_move = next((m for m in moves if m.rank == 1), moves[0])
353
+ for move in moves:
354
+ move.error = abs(best_move.equity - move.equity)
355
+
356
+ return moves
357
+
358
+ @staticmethod
359
+ def _normalize_cube_notation(notation: str) -> str:
360
+ """
361
+ Normalize cube notation to standard format.
362
+
363
+ Args:
364
+ notation: Raw notation (e.g., "Double, take", "No redouble")
365
+
366
+ Returns:
367
+ Normalized notation (e.g., "Double/Take", "No Double")
368
+ """
369
+ # Standardize case
370
+ parts = notation.split('/')
371
+ result_parts = []
372
+
373
+ for part in parts:
374
+ part = part.strip().lower()
375
+
376
+ # Normalize terms
377
+ if 'no' in part and ('double' in part or 'redouble' in part):
378
+ result_parts.append("No Double")
379
+ elif 'double' in part or 'redouble' in part:
380
+ result_parts.append("Double")
381
+ elif 'take' in part:
382
+ result_parts.append("Take")
383
+ elif 'pass' in part or 'drop' in part:
384
+ result_parts.append("Pass")
385
+ elif 'too good' in part:
386
+ result_parts.append("Too good")
387
+ else:
388
+ result_parts.append(part.capitalize())
389
+
390
+ return '/'.join(result_parts)
391
+
392
+ @staticmethod
393
+ def _parse_best_cube_action(best_text: Optional[str], double_term: str) -> Optional[str]:
394
+ """
395
+ Parse "Proper cube action:" text to determine best move notation.
396
+
397
+ Args:
398
+ best_text: Text from "Proper cube action:" line
399
+ double_term: "Double" or "Redouble"
400
+
401
+ Returns:
402
+ Standardized notation matching all_options format
403
+ """
404
+ if not best_text:
405
+ return None
406
+
407
+ text_lower = best_text.lower()
408
+
409
+ if 'too good' in text_lower:
410
+ if 'take' in text_lower:
411
+ return "Too good/Take"
412
+ elif 'pass' in text_lower or 'drop' in text_lower:
413
+ return "Too good/Pass"
414
+ elif 'no double' in text_lower or 'no redouble' in text_lower:
415
+ return f"No {double_term}/Take"
416
+ elif 'double' in text_lower or 'redouble' in text_lower:
417
+ if 'take' in text_lower:
418
+ return f"{double_term}/Take"
419
+ elif 'pass' in text_lower or 'drop' in text_lower:
420
+ return f"{double_term}/Pass"
421
+
422
+ return None
423
+
424
+ @staticmethod
425
+ def _parse_winning_chances(text: str) -> dict:
426
+ """
427
+ Extract W/G/B percentages from gnubg output.
428
+
429
+ Looks for patterns like:
430
+ Cubeless equity: +0.172
431
+ Win: 52.3% G: 14.2% B: 0.8%
432
+
433
+ or:
434
+ 0.523 0.142 0.008 - 0.477 0.124 0.006
435
+
436
+ Args:
437
+ text: gnubg output text
438
+
439
+ Returns:
440
+ Dictionary with winning chance percentages (or empty dict)
441
+ """
442
+ chances = {}
443
+
444
+ # Try pattern 1: "Win: 52.3% G: 14.2% B: 0.8%"
445
+ win_pattern = re.search(
446
+ r'Win:\s*(\d+\.?\d*)%.*?G:\s*(\d+\.?\d*)%.*?B:\s*(\d+\.?\d*)%',
447
+ text,
448
+ re.IGNORECASE
449
+ )
450
+ if win_pattern:
451
+ chances['player_win_pct'] = float(win_pattern.group(1))
452
+ chances['player_gammon_pct'] = float(win_pattern.group(2))
453
+ chances['player_backgammon_pct'] = float(win_pattern.group(3))
454
+
455
+ # Try pattern 2: Decimal probabilities "0.523 0.142 0.008 - 0.477 0.124 0.006"
456
+ prob_pattern = re.search(
457
+ r'(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)\s*-\s*(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)',
458
+ text
459
+ )
460
+ if prob_pattern:
461
+ chances['player_win_pct'] = float(prob_pattern.group(1)) * 100
462
+ chances['player_gammon_pct'] = float(prob_pattern.group(2)) * 100
463
+ chances['player_backgammon_pct'] = float(prob_pattern.group(3)) * 100
464
+ chances['opponent_win_pct'] = float(prob_pattern.group(4)) * 100
465
+ chances['opponent_gammon_pct'] = float(prob_pattern.group(5)) * 100
466
+ chances['opponent_backgammon_pct'] = float(prob_pattern.group(6)) * 100
467
+
468
+ return chances