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.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +391 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +216 -0
- ankigammon/anki/apkg_exporter.py +111 -0
- ankigammon/anki/card_generator.py +1325 -0
- ankigammon/anki/card_styles.py +1054 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +192 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +594 -0
- ankigammon/gui/dialogs/import_options_dialog.py +201 -0
- ankigammon/gui/dialogs/input_dialog.py +762 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +420 -0
- ankigammon/gui/dialogs/update_dialog.py +373 -0
- ankigammon/gui/format_detector.py +377 -0
- ankigammon/gui/main_window.py +1611 -0
- ankigammon/gui/resources/down-arrow.svg +3 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +402 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/update_checker.py +259 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +166 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +356 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_match_parser.py +1094 -0
- ankigammon/parsers/gnubg_parser.py +468 -0
- ankigammon/parsers/sgf_parser.py +290 -0
- ankigammon/parsers/xg_binary_parser.py +1097 -0
- ankigammon/parsers/xg_text_parser.py +688 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +391 -0
- ankigammon/renderer/animation_helper.py +191 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +791 -0
- ankigammon/settings.py +315 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +590 -0
- ankigammon/utils/gnuid.py +577 -0
- ankigammon/utils/move_parser.py +204 -0
- ankigammon/utils/ogid.py +326 -0
- ankigammon/utils/xgid.py +387 -0
- ankigammon-1.0.6.dist-info/METADATA +352 -0
- ankigammon-1.0.6.dist-info/RECORD +61 -0
- ankigammon-1.0.6.dist-info/WHEEL +5 -0
- ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
- 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"]
|