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
ankigammon/models.py ADDED
@@ -0,0 +1,356 @@
1
+ """Data models for backgammon positions, moves, and decisions."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import List, Optional, Tuple
6
+
7
+
8
+ class Player(Enum):
9
+ """Player identifier."""
10
+ X = "X" # Top player
11
+ O = "O" # Bottom player
12
+
13
+
14
+ class CubeState(Enum):
15
+ """Doubling cube state."""
16
+ CENTERED = "centered"
17
+ X_OWNS = "x_owns"
18
+ O_OWNS = "o_owns"
19
+
20
+
21
+ class DecisionType(Enum):
22
+ """Type of decision."""
23
+ CHECKER_PLAY = "checker_play"
24
+ CUBE_ACTION = "cube_action"
25
+
26
+
27
+ @dataclass
28
+ class Position:
29
+ """
30
+ Represents a backgammon position.
31
+
32
+ Board representation:
33
+ - points[0] = bar for X (top player)
34
+ - points[1-24] = board points (point 24 is X's home, point 1 is O's home)
35
+ - points[25] = bar for O (bottom player)
36
+
37
+ Positive numbers = X checkers, negative numbers = O checkers
38
+ """
39
+ points: List[int] = field(default_factory=lambda: [0] * 26)
40
+ x_off: int = 0 # Checkers borne off by X
41
+ o_off: int = 0 # Checkers borne off by O
42
+
43
+ def __post_init__(self):
44
+ """Validate position."""
45
+ if len(self.points) != 26:
46
+ raise ValueError("Position must have exactly 26 points (0=X bar, 1-24=board, 25=O bar)")
47
+
48
+ @classmethod
49
+ def from_xgid(cls, xgid: str) -> 'Position':
50
+ """
51
+ Parse a position from XGID format.
52
+ XGID format: e.g., "XGID=-b----E-C---eE---c-e----B-:1:0:1:63:0:0:0:0:10"
53
+ """
54
+ # Import here to avoid circular dependency
55
+ from ankigammon.utils.xgid import parse_xgid
56
+ position, _ = parse_xgid(xgid)
57
+ return position
58
+
59
+ @classmethod
60
+ def from_ogid(cls, ogid: str) -> 'Position':
61
+ """
62
+ Parse a position from OGID format.
63
+ OGID format: e.g., "11jjjjjhhhccccc:ooddddd88866666:N0N::W:IW:0:0:1:0"
64
+ """
65
+ # Import here to avoid circular dependency
66
+ from ankigammon.utils.ogid import parse_ogid
67
+ position, _ = parse_ogid(ogid)
68
+ return position
69
+
70
+ @classmethod
71
+ def from_gnuid(cls, gnuid: str) -> 'Position':
72
+ """
73
+ Parse a position from GNUID format.
74
+ GNUID format: e.g., "4HPwATDgc/ABMA:8IhuACAACAAE"
75
+ """
76
+ # Import here to avoid circular dependency
77
+ from ankigammon.utils.gnuid import parse_gnuid
78
+ position, _ = parse_gnuid(gnuid)
79
+ return position
80
+
81
+ def to_xgid(
82
+ self,
83
+ cube_value: int = 1,
84
+ cube_owner: 'CubeState' = None,
85
+ dice: Optional[Tuple[int, int]] = None,
86
+ on_roll: 'Player' = None,
87
+ score_x: int = 0,
88
+ score_o: int = 0,
89
+ match_length: int = 0,
90
+ crawford_jacoby: int = 0,
91
+ ) -> str:
92
+ """Convert position to XGID format."""
93
+ # Import here to avoid circular dependency
94
+ from ankigammon.utils.xgid import encode_xgid
95
+ if cube_owner is None:
96
+ cube_owner = CubeState.CENTERED
97
+ if on_roll is None:
98
+ on_roll = Player.O
99
+ return encode_xgid(
100
+ self,
101
+ cube_value=cube_value,
102
+ cube_owner=cube_owner,
103
+ dice=dice,
104
+ on_roll=on_roll,
105
+ score_x=score_x,
106
+ score_o=score_o,
107
+ match_length=match_length,
108
+ crawford_jacoby=crawford_jacoby,
109
+ )
110
+
111
+ def to_ogid(
112
+ self,
113
+ cube_value: int = 1,
114
+ cube_owner: 'CubeState' = None,
115
+ cube_action: str = 'N',
116
+ dice: Optional[Tuple[int, int]] = None,
117
+ on_roll: 'Player' = None,
118
+ game_state: str = '',
119
+ score_x: int = 0,
120
+ score_o: int = 0,
121
+ match_length: Optional[int] = None,
122
+ match_modifier: str = '',
123
+ only_position: bool = False,
124
+ ) -> str:
125
+ """Convert position to OGID format."""
126
+ # Import here to avoid circular dependency
127
+ from ankigammon.utils.ogid import encode_ogid
128
+ if cube_owner is None:
129
+ cube_owner = CubeState.CENTERED
130
+ return encode_ogid(
131
+ self,
132
+ cube_value=cube_value,
133
+ cube_owner=cube_owner,
134
+ cube_action=cube_action,
135
+ dice=dice,
136
+ on_roll=on_roll,
137
+ game_state=game_state,
138
+ score_x=score_x,
139
+ score_o=score_o,
140
+ match_length=match_length,
141
+ match_modifier=match_modifier,
142
+ only_position=only_position,
143
+ )
144
+
145
+ def to_gnuid(
146
+ self,
147
+ cube_value: int = 1,
148
+ cube_owner: 'CubeState' = None,
149
+ dice: Optional[Tuple[int, int]] = None,
150
+ on_roll: 'Player' = None,
151
+ score_x: int = 0,
152
+ score_o: int = 0,
153
+ match_length: int = 0,
154
+ crawford: bool = False,
155
+ only_position: bool = False,
156
+ ) -> str:
157
+ """Convert position to GNUID format."""
158
+ # Import here to avoid circular dependency
159
+ from ankigammon.utils.gnuid import encode_gnuid
160
+ if cube_owner is None:
161
+ cube_owner = CubeState.CENTERED
162
+ if on_roll is None:
163
+ on_roll = Player.X
164
+ return encode_gnuid(
165
+ self,
166
+ cube_value=cube_value,
167
+ cube_owner=cube_owner,
168
+ dice=dice,
169
+ on_roll=on_roll,
170
+ score_x=score_x,
171
+ score_o=score_o,
172
+ match_length=match_length,
173
+ crawford=crawford,
174
+ only_position=only_position,
175
+ )
176
+
177
+ def copy(self) -> 'Position':
178
+ """Create a deep copy of the position."""
179
+ return Position(
180
+ points=self.points.copy(),
181
+ x_off=self.x_off,
182
+ o_off=self.o_off
183
+ )
184
+
185
+
186
+ @dataclass
187
+ class Move:
188
+ """
189
+ Represents a candidate move with its analysis.
190
+ """
191
+ notation: str # Move notation for MCQ and answer display (e.g., "13/9 6/5" or "double/take")
192
+ equity: float # Equity of this move
193
+ error: float = 0.0 # Error compared to best move (0 for best)
194
+ rank: int = 1 # Rank among all candidates, including synthetic moves (1 = best)
195
+ xg_rank: Optional[int] = None # Order in XG's "Cubeful Equities:" section (1-3)
196
+ xg_error: Optional[float] = None # Error relative to first option in XG's Cubeful Equities
197
+ xg_notation: Optional[str] = None # Original notation from XG (e.g., "No double" vs "No double/Take")
198
+ resulting_position: Optional[Position] = None # Position after applying this move
199
+ from_xg_analysis: bool = True # Whether from XG's analysis (True) or synthetically generated (False)
200
+ was_played: bool = False # Whether this move was actually played in the game
201
+ # Winning chances percentages
202
+ player_win_pct: Optional[float] = None # Player winning percentage (e.g., 52.68)
203
+ player_gammon_pct: Optional[float] = None # Player gammon percentage (e.g., 14.35)
204
+ player_backgammon_pct: Optional[float] = None # Player backgammon percentage (e.g., 0.69)
205
+ opponent_win_pct: Optional[float] = None # Opponent winning percentage (e.g., 47.32)
206
+ opponent_gammon_pct: Optional[float] = None # Opponent gammon percentage (e.g., 12.42)
207
+ opponent_backgammon_pct: Optional[float] = None # Opponent backgammon percentage (e.g., 0.55)
208
+
209
+ def __str__(self) -> str:
210
+ """Human-readable representation."""
211
+ if self.rank == 1:
212
+ return f"{self.notation} (Equity: {self.equity:.3f})"
213
+ else:
214
+ return f"{self.notation} (Equity: {self.equity:.3f}, Error: {self.error:.3f})"
215
+
216
+
217
+ @dataclass
218
+ class Decision:
219
+ """
220
+ Represents a single decision point from XG analysis.
221
+ """
222
+ # Position information
223
+ position: Position
224
+ position_image_path: Optional[str] = None # Path to board image (from HTML export)
225
+ xgid: Optional[str] = None
226
+
227
+ # Game context
228
+ on_roll: Player = Player.O
229
+ dice: Optional[Tuple[int, int]] = None # Dice roll (None for cube decisions)
230
+ score_x: int = 0
231
+ score_o: int = 0
232
+ match_length: int = 0 # Match length (0 for money games)
233
+ crawford: bool = False # Whether this is a Crawford game
234
+ cube_value: int = 1
235
+ cube_owner: CubeState = CubeState.CENTERED
236
+
237
+ # Decision analysis
238
+ decision_type: DecisionType = DecisionType.CHECKER_PLAY
239
+ candidate_moves: List[Move] = field(default_factory=list)
240
+
241
+ # Cube decision errors (only for CUBE_ACTION decisions)
242
+ cube_error: Optional[float] = None # Doubler's error on double/no double decision (-1000 if not analyzed)
243
+ take_error: Optional[float] = None # Responder's error on take/pass decision (-1000 if not analyzed)
244
+
245
+ # XG binary error (for checker play from .xg files)
246
+ xg_error_move: Optional[float] = None # XG's ErrMove field - authoritative error for filtering
247
+
248
+ # Winning chances percentages (for cube decisions)
249
+ player_win_pct: Optional[float] = None
250
+ player_gammon_pct: Optional[float] = None
251
+ player_backgammon_pct: Optional[float] = None
252
+ opponent_win_pct: Optional[float] = None
253
+ opponent_gammon_pct: Optional[float] = None
254
+ opponent_backgammon_pct: Optional[float] = None
255
+
256
+ # Source metadata
257
+ source_file: Optional[str] = None
258
+ game_number: Optional[int] = None
259
+ move_number: Optional[int] = None
260
+
261
+ # User annotations
262
+ note: Optional[str] = None # User's note or explanation for this position
263
+
264
+ def get_best_move(self) -> Optional[Move]:
265
+ """Get the best move (rank 1)."""
266
+ for move in self.candidate_moves:
267
+ if move.rank == 1:
268
+ return move
269
+ return self.candidate_moves[0] if self.candidate_moves else None
270
+
271
+ def get_cube_error_attribution(self) -> dict:
272
+ """
273
+ For cube decisions, identify which player(s) made errors.
274
+
275
+ Returns:
276
+ Dictionary with:
277
+ - 'doubler_error': float or None (error made by player on roll)
278
+ - 'responder_error': float or None (error made by opponent)
279
+ - 'doubler': Player or None (who doubled)
280
+ - 'responder': Player or None (who responded)
281
+ """
282
+ if self.decision_type != DecisionType.CUBE_ACTION:
283
+ return {
284
+ 'doubler_error': None,
285
+ 'responder_error': None,
286
+ 'doubler': None,
287
+ 'responder': None
288
+ }
289
+
290
+ # Determine who doubled and who responded
291
+ doubler = self.on_roll
292
+ responder = Player.X if self.on_roll == Player.O else Player.O
293
+
294
+ # Extract errors (-1000 indicates not analyzed)
295
+ doubler_error = self.cube_error if self.cube_error and self.cube_error != -1000 else None
296
+ responder_error = self.take_error if self.take_error and self.take_error != -1000 else None
297
+
298
+ return {
299
+ 'doubler_error': doubler_error,
300
+ 'responder_error': responder_error,
301
+ 'doubler': doubler,
302
+ 'responder': responder
303
+ }
304
+
305
+ def get_short_display_text(self) -> str:
306
+ """Get short display text for list views."""
307
+ # Build score/game type string
308
+ if self.match_length > 0:
309
+ score = f"{self.score_x}-{self.score_o} of {self.match_length}"
310
+ if self.crawford:
311
+ score += " Crawford"
312
+ else:
313
+ score = "Money"
314
+
315
+ if self.decision_type == DecisionType.CHECKER_PLAY:
316
+ dice_str = f"{self.dice[0]}{self.dice[1]}" if self.dice else "—"
317
+ return f"Checker | {dice_str} | {score}"
318
+ else:
319
+ return f"Cube | {score}"
320
+
321
+ def get_metadata_text(self) -> str:
322
+ """Get formatted metadata for card display."""
323
+ dice_str = f"{self.dice[0]}{self.dice[1]}" if self.dice else "N/A"
324
+
325
+ # Display em dash for centered cube, otherwise show value
326
+ if self.cube_owner == CubeState.CENTERED:
327
+ cube_str = "—"
328
+ else:
329
+ cube_str = f"{self.cube_value}"
330
+
331
+ # Position flipping places on-roll player at bottom
332
+ player_name = "Black"
333
+
334
+ # Build metadata string based on game type
335
+ if self.match_length > 0:
336
+ match_str = f"{self.match_length}pt"
337
+ if self.crawford:
338
+ match_str += " (Crawford)"
339
+ return (
340
+ f"{player_name} | "
341
+ f"Dice: {dice_str} | "
342
+ f"Score: {self.score_x}-{self.score_o} | "
343
+ f"Cube: {cube_str} | "
344
+ f"Match: {match_str}"
345
+ )
346
+ else:
347
+ return (
348
+ f"{player_name} | "
349
+ f"Dice: {dice_str} | "
350
+ f"Cube: {cube_str} | "
351
+ f"Money"
352
+ )
353
+
354
+ def __str__(self) -> str:
355
+ """Human-readable representation."""
356
+ return f"Decision({self.decision_type.value}, {self.get_metadata_text()})"
@@ -0,0 +1,7 @@
1
+ """Parsers for XG file formats and GnuBG output."""
2
+
3
+ from ankigammon.parsers.xg_text_parser import XGTextParser
4
+ from ankigammon.parsers.gnubg_parser import GNUBGParser
5
+ from ankigammon.parsers.xg_binary_parser import XGBinaryParser
6
+
7
+ __all__ = ["XGTextParser", "GNUBGParser", "XGBinaryParser"]