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,454 @@
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
+ # Parse error (if shown)
123
+ error = float(error_str) if error_str else 0.0
124
+ abs_error = abs(error)
125
+
126
+ # Look for probability line on next line
127
+ player_win = None
128
+ player_gammon = None
129
+ player_backgammon = None
130
+ opponent_win = None
131
+ opponent_gammon = None
132
+ opponent_backgammon = None
133
+
134
+ if i + 1 < len(lines):
135
+ prob_match = prob_pattern.match(lines[i + 1])
136
+ if prob_match:
137
+ # Convert from decimal to percentage
138
+ player_win = float(prob_match.group(1)) * 100
139
+ player_gammon = float(prob_match.group(2)) * 100
140
+ player_backgammon = float(prob_match.group(3)) * 100
141
+ opponent_win = float(prob_match.group(4)) * 100
142
+ opponent_gammon = float(prob_match.group(5)) * 100
143
+ opponent_backgammon = float(prob_match.group(6)) * 100
144
+
145
+ moves.append(Move(
146
+ notation=notation,
147
+ equity=equity,
148
+ rank=rank,
149
+ error=abs_error,
150
+ xg_error=error,
151
+ xg_notation=notation,
152
+ xg_rank=rank,
153
+ from_xg_analysis=True,
154
+ player_win_pct=player_win,
155
+ player_gammon_pct=player_gammon,
156
+ player_backgammon_pct=player_backgammon,
157
+ opponent_win_pct=opponent_win,
158
+ opponent_gammon_pct=opponent_gammon,
159
+ opponent_backgammon_pct=opponent_backgammon
160
+ ))
161
+
162
+ # If no moves found, try alternative pattern
163
+ if not moves:
164
+ # Try simpler pattern without rank numbers
165
+ alt_pattern = re.compile(
166
+ r'^\s*([0-9/\s*bar]+?)\s+Eq:\s*([+-]?\d+\.\d+)',
167
+ re.MULTILINE
168
+ )
169
+ for i, match in enumerate(alt_pattern.finditer(text), 1):
170
+ notation = match.group(1).strip()
171
+ equity = float(match.group(2))
172
+
173
+ moves.append(Move(
174
+ notation=notation,
175
+ equity=equity,
176
+ rank=i,
177
+ error=0.0,
178
+ from_xg_analysis=True
179
+ ))
180
+
181
+ # Sort by equity (highest first) and recalculate errors
182
+ if moves:
183
+ moves.sort(key=lambda m: m.equity, reverse=True)
184
+ best_equity = moves[0].equity
185
+
186
+ for i, move in enumerate(moves, 1):
187
+ move.rank = i
188
+ move.error = abs(best_equity - move.equity)
189
+
190
+ return moves
191
+
192
+ @staticmethod
193
+ def _parse_cube_decision(text: str) -> List[Move]:
194
+ """
195
+ Parse cube decision analysis from gnubg output.
196
+
197
+ Expected format:
198
+ Cubeful equities:
199
+ 1. No double +0.172
200
+ 2. Double, take -0.361 (-0.533)
201
+ 3. Double, pass +1.000 (+0.828)
202
+
203
+ Proper cube action: No double
204
+
205
+ Generates all 5 cube options (like XG parser):
206
+ - No double/Take
207
+ - Double/Take
208
+ - Double/Pass
209
+ - Too good/Take (synthetic)
210
+ - Too good/Pass (synthetic)
211
+
212
+ Args:
213
+ text: gnubg output text
214
+
215
+ Returns:
216
+ List of Move objects with all 5 cube options
217
+ """
218
+ moves = []
219
+
220
+ # Look for "Cubeful equities:" section
221
+ if 'Cubeful equities' not in text and 'cubeful equities' not in text:
222
+ return moves
223
+
224
+ # Parse the 3 equity values from gnubg
225
+ # Pattern to match cube decision lines:
226
+ # "1. No double +0.172"
227
+ # "2. Double, take -0.361 (-0.533)"
228
+ # "3. Double, pass +1.000 (+0.828)"
229
+ pattern = re.compile(
230
+ r'^\s*\d+\.\s*(No (?:re)?double|(?:Re)?[Dd]ouble,?\s*(?:take|pass|drop))\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
231
+ re.MULTILINE | re.IGNORECASE
232
+ )
233
+
234
+ # Store parsed equities in order they appear
235
+ gnubg_moves_data = [] # List of (normalized_notation, equity, gnubg_error)
236
+ for match in pattern.finditer(text):
237
+ notation = match.group(1).strip()
238
+ equity = float(match.group(2))
239
+ error_str = match.group(3)
240
+
241
+ # Parse gnubg's error (in parentheses)
242
+ gnubg_error = float(error_str) if error_str else 0.0
243
+
244
+ # Normalize notation: "Double, take" -> "Double/Take"
245
+ normalized = notation.replace(', ', '/').replace(',', '/')
246
+ normalized = GNUBGParser._normalize_cube_notation(normalized)
247
+
248
+ gnubg_moves_data.append((normalized, equity, gnubg_error))
249
+
250
+ if not gnubg_moves_data:
251
+ return moves
252
+
253
+ # Build equity map for easy lookup
254
+ equity_map = {data[0]: data[1] for data in gnubg_moves_data}
255
+
256
+ # Parse "Proper cube action:" to determine best move
257
+ best_action_match = re.search(
258
+ r'Proper cube action:\s*(.+?)(?:\n|$)',
259
+ text,
260
+ re.IGNORECASE
261
+ )
262
+
263
+ best_action_text = None
264
+ if best_action_match:
265
+ best_action_text = best_action_match.group(1).strip()
266
+
267
+ # Determine if using "double" or "redouble" terminology
268
+ use_redouble = any('redouble' in data[0].lower() for data in gnubg_moves_data)
269
+ double_term = "Redouble" if use_redouble else "Double"
270
+
271
+ # Generate all 5 cube options with appropriate terminology
272
+ all_options = [
273
+ f"No {double_term}/Take",
274
+ f"{double_term}/Take",
275
+ f"{double_term}/Pass",
276
+ f"Too good/Take",
277
+ f"Too good/Pass"
278
+ ]
279
+
280
+ # Assign equities
281
+ no_double_eq = equity_map.get("No Double", None)
282
+ double_take_eq = equity_map.get("Double/Take", None)
283
+ double_pass_eq = equity_map.get("Double/Pass", None)
284
+
285
+ option_equities = {}
286
+ if no_double_eq is not None:
287
+ option_equities[f"No {double_term}/Take"] = no_double_eq
288
+ if double_take_eq is not None:
289
+ option_equities[f"{double_term}/Take"] = double_take_eq
290
+ if double_pass_eq is not None:
291
+ option_equities[f"{double_term}/Pass"] = double_pass_eq
292
+
293
+ # For "Too good" options, use same equity as Double/Pass
294
+ if double_pass_eq is not None:
295
+ option_equities["Too good/Take"] = double_pass_eq
296
+ option_equities["Too good/Pass"] = double_pass_eq
297
+
298
+ # Determine best notation from "Proper cube action:" text
299
+ best_notation = GNUBGParser._parse_best_cube_action(best_action_text, double_term)
300
+
301
+ # Create Move objects for all 5 options
302
+ for option in all_options:
303
+ equity = option_equities.get(option, 0.0)
304
+ is_from_gnubg = not option.startswith("Too good")
305
+
306
+ moves.append(Move(
307
+ notation=option,
308
+ equity=equity,
309
+ error=0.0, # Will calculate below
310
+ rank=0, # Will assign below
311
+ xg_error=None,
312
+ xg_notation=option if is_from_gnubg else None,
313
+ xg_rank=None,
314
+ from_xg_analysis=is_from_gnubg
315
+ ))
316
+
317
+ # Sort by equity (highest first) to determine ranking
318
+ moves.sort(key=lambda m: m.equity, reverse=True)
319
+
320
+ # Assign ranks
321
+ if best_notation:
322
+ rank_counter = 1
323
+ for move in moves:
324
+ if move.notation == best_notation:
325
+ move.rank = 1
326
+ else:
327
+ if rank_counter == 1:
328
+ rank_counter = 2
329
+ move.rank = rank_counter
330
+ rank_counter += 1
331
+ else:
332
+ # Best wasn't identified, rank purely by equity
333
+ for i, move in enumerate(moves, 1):
334
+ move.rank = i
335
+
336
+ # Calculate errors relative to best move
337
+ if moves:
338
+ best_move = next((m for m in moves if m.rank == 1), moves[0])
339
+ for move in moves:
340
+ move.error = abs(best_move.equity - move.equity)
341
+
342
+ return moves
343
+
344
+ @staticmethod
345
+ def _normalize_cube_notation(notation: str) -> str:
346
+ """
347
+ Normalize cube notation to standard format.
348
+
349
+ Args:
350
+ notation: Raw notation (e.g., "Double, take", "No redouble")
351
+
352
+ Returns:
353
+ Normalized notation (e.g., "Double/Take", "No Double")
354
+ """
355
+ # Standardize case
356
+ parts = notation.split('/')
357
+ result_parts = []
358
+
359
+ for part in parts:
360
+ part = part.strip().lower()
361
+
362
+ # Normalize terms
363
+ if 'no' in part and ('double' in part or 'redouble' in part):
364
+ result_parts.append("No Double")
365
+ elif 'double' in part or 'redouble' in part:
366
+ result_parts.append("Double")
367
+ elif 'take' in part:
368
+ result_parts.append("Take")
369
+ elif 'pass' in part or 'drop' in part:
370
+ result_parts.append("Pass")
371
+ elif 'too good' in part:
372
+ result_parts.append("Too good")
373
+ else:
374
+ result_parts.append(part.capitalize())
375
+
376
+ return '/'.join(result_parts)
377
+
378
+ @staticmethod
379
+ def _parse_best_cube_action(best_text: Optional[str], double_term: str) -> Optional[str]:
380
+ """
381
+ Parse "Proper cube action:" text to determine best move notation.
382
+
383
+ Args:
384
+ best_text: Text from "Proper cube action:" line
385
+ double_term: "Double" or "Redouble"
386
+
387
+ Returns:
388
+ Standardized notation matching all_options format
389
+ """
390
+ if not best_text:
391
+ return None
392
+
393
+ text_lower = best_text.lower()
394
+
395
+ if 'too good' in text_lower:
396
+ if 'take' in text_lower:
397
+ return "Too good/Take"
398
+ elif 'pass' in text_lower or 'drop' in text_lower:
399
+ return "Too good/Pass"
400
+ elif 'no double' in text_lower or 'no redouble' in text_lower:
401
+ return f"No {double_term}/Take"
402
+ elif 'double' in text_lower or 'redouble' in text_lower:
403
+ if 'take' in text_lower:
404
+ return f"{double_term}/Take"
405
+ elif 'pass' in text_lower or 'drop' in text_lower:
406
+ return f"{double_term}/Pass"
407
+
408
+ return None
409
+
410
+ @staticmethod
411
+ def _parse_winning_chances(text: str) -> dict:
412
+ """
413
+ Extract W/G/B percentages from gnubg output.
414
+
415
+ Looks for patterns like:
416
+ Cubeless equity: +0.172
417
+ Win: 52.3% G: 14.2% B: 0.8%
418
+
419
+ or:
420
+ 0.523 0.142 0.008 - 0.477 0.124 0.006
421
+
422
+ Args:
423
+ text: gnubg output text
424
+
425
+ Returns:
426
+ Dictionary with winning chance percentages (or empty dict)
427
+ """
428
+ chances = {}
429
+
430
+ # Try pattern 1: "Win: 52.3% G: 14.2% B: 0.8%"
431
+ win_pattern = re.search(
432
+ r'Win:\s*(\d+\.?\d*)%.*?G:\s*(\d+\.?\d*)%.*?B:\s*(\d+\.?\d*)%',
433
+ text,
434
+ re.IGNORECASE
435
+ )
436
+ if win_pattern:
437
+ chances['player_win_pct'] = float(win_pattern.group(1))
438
+ chances['player_gammon_pct'] = float(win_pattern.group(2))
439
+ chances['player_backgammon_pct'] = float(win_pattern.group(3))
440
+
441
+ # Try pattern 2: Decimal probabilities "0.523 0.142 0.008 - 0.477 0.124 0.006"
442
+ prob_pattern = re.search(
443
+ r'(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)\s*-\s*(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)',
444
+ text
445
+ )
446
+ if prob_pattern:
447
+ chances['player_win_pct'] = float(prob_pattern.group(1)) * 100
448
+ chances['player_gammon_pct'] = float(prob_pattern.group(2)) * 100
449
+ chances['player_backgammon_pct'] = float(prob_pattern.group(3)) * 100
450
+ chances['opponent_win_pct'] = float(prob_pattern.group(4)) * 100
451
+ chances['opponent_gammon_pct'] = float(prob_pattern.group(5)) * 100
452
+ chances['opponent_backgammon_pct'] = float(prob_pattern.group(6)) * 100
453
+
454
+ return chances