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/utils/xgid.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""XGID format parsing and encoding.
|
|
2
|
+
|
|
3
|
+
XGID (eXtreme Gammon ID) is a compact text representation of a backgammon position.
|
|
4
|
+
|
|
5
|
+
Format: XGID=PPPPPPPPPPPPPPPPPPPPPPPPPP:CV:CP:T:D:S1:S2:CJ:ML:MC
|
|
6
|
+
|
|
7
|
+
Fields:
|
|
8
|
+
1. Position (26 chars):
|
|
9
|
+
- Char 0: bar for TOP player
|
|
10
|
+
- Chars 1-24: points 1-24 (from BOTTOM player's perspective)
|
|
11
|
+
- Char 25: bar for BOTTOM player
|
|
12
|
+
- 'A'-'P': BOTTOM player's checkers (1-16)
|
|
13
|
+
- 'a'-'p': TOP player's checkers (1-16)
|
|
14
|
+
- '-': empty point
|
|
15
|
+
|
|
16
|
+
2. Cube Value (CV): 2^CV (0=1, 1=2, 2=4, etc.)
|
|
17
|
+
|
|
18
|
+
3. Cube Position (CP):
|
|
19
|
+
- 1: owned by BOTTOM player
|
|
20
|
+
- 0: centered
|
|
21
|
+
- -1: owned by TOP player
|
|
22
|
+
|
|
23
|
+
4. Turn (T):
|
|
24
|
+
- 1: BOTTOM player's turn
|
|
25
|
+
- -1: TOP player's turn
|
|
26
|
+
|
|
27
|
+
5. Dice (D):
|
|
28
|
+
- 00: player to roll or double
|
|
29
|
+
- D: player doubled, opponent must take/drop
|
|
30
|
+
- B: player doubled, opponent beavered
|
|
31
|
+
- R: doubled, beavered, and raccooned
|
|
32
|
+
- xx: rolled dice (e.g., 63, 35, 11)
|
|
33
|
+
|
|
34
|
+
6. Score 1 (S1): BOTTOM player's score
|
|
35
|
+
7. Score 2 (S2): TOP player's score
|
|
36
|
+
8. Crawford/Jacoby (CJ): Crawford rule (match) or Jacoby rule (money)
|
|
37
|
+
9. Match Length (ML): 0 for money games
|
|
38
|
+
10. Max Cube (MC): Maximum cube value (2^MC)
|
|
39
|
+
|
|
40
|
+
Note: In our internal model, we use O for BOTTOM player and X for TOP player.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import re
|
|
44
|
+
from typing import Optional, Tuple
|
|
45
|
+
|
|
46
|
+
from ankigammon.models import Position, Player, CubeState
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_xgid(xgid: str) -> Tuple[Position, dict]:
|
|
50
|
+
"""
|
|
51
|
+
Parse an XGID string into a Position and metadata.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
xgid: XGID string (e.g., "XGID=-a-B--C-dE---eE---c-e----B-:1:0:1:63:0:0:0:0:10")
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (Position, metadata_dict)
|
|
58
|
+
"""
|
|
59
|
+
# Remove "XGID=" prefix if present
|
|
60
|
+
if xgid.startswith("XGID="):
|
|
61
|
+
xgid = xgid[5:]
|
|
62
|
+
|
|
63
|
+
# Split into components
|
|
64
|
+
parts = xgid.split(':')
|
|
65
|
+
if len(parts) < 9:
|
|
66
|
+
raise ValueError(f"Invalid XGID format: expected 9+ parts, got {len(parts)}")
|
|
67
|
+
|
|
68
|
+
position_str = parts[0]
|
|
69
|
+
cube_value_log = int(parts[1])
|
|
70
|
+
cube_position = int(parts[2])
|
|
71
|
+
turn = int(parts[3])
|
|
72
|
+
dice_str = parts[4]
|
|
73
|
+
score_bottom = int(parts[5])
|
|
74
|
+
score_top = int(parts[6])
|
|
75
|
+
crawford_jacoby = int(parts[7]) if len(parts) > 7 else 0
|
|
76
|
+
match_length = int(parts[8]) if len(parts) > 8 else 0
|
|
77
|
+
max_cube = int(parts[9]) if len(parts) > 9 else 8
|
|
78
|
+
|
|
79
|
+
# Parse position (encoding is perspective-dependent based on turn)
|
|
80
|
+
position = _parse_position_string(position_str, turn)
|
|
81
|
+
|
|
82
|
+
# Parse metadata
|
|
83
|
+
metadata = {}
|
|
84
|
+
|
|
85
|
+
# Cube value (2^cube_value_log)
|
|
86
|
+
cube_value = 2 ** cube_value_log if cube_value_log >= 0 else 1
|
|
87
|
+
metadata['cube_value'] = cube_value
|
|
88
|
+
|
|
89
|
+
# Cube owner (absolute, not perspective-dependent)
|
|
90
|
+
# -1 = TOP player (X), 0 = centered, 1 = BOTTOM player (O)
|
|
91
|
+
if cube_position == 0:
|
|
92
|
+
cube_state = CubeState.CENTERED
|
|
93
|
+
elif cube_position == -1:
|
|
94
|
+
cube_state = CubeState.X_OWNS # TOP = X
|
|
95
|
+
else: # cube_position == 1
|
|
96
|
+
cube_state = CubeState.O_OWNS # BOTTOM = O
|
|
97
|
+
metadata['cube_owner'] = cube_state
|
|
98
|
+
|
|
99
|
+
# Turn: 1 = BOTTOM player (O), -1 = TOP player (X)
|
|
100
|
+
on_roll = Player.O if turn == 1 else Player.X
|
|
101
|
+
metadata['on_roll'] = on_roll
|
|
102
|
+
|
|
103
|
+
# Dice
|
|
104
|
+
dice_str = dice_str.upper().strip()
|
|
105
|
+
if dice_str == '00':
|
|
106
|
+
# Player to roll or double (no dice shown)
|
|
107
|
+
pass
|
|
108
|
+
elif dice_str in ['D', 'B', 'R']:
|
|
109
|
+
# Cube action pending
|
|
110
|
+
metadata['decision_type'] = 'cube_action'
|
|
111
|
+
elif len(dice_str) == 2 and dice_str.isdigit():
|
|
112
|
+
# Rolled dice
|
|
113
|
+
d1 = int(dice_str[0])
|
|
114
|
+
d2 = int(dice_str[1])
|
|
115
|
+
if 1 <= d1 <= 6 and 1 <= d2 <= 6:
|
|
116
|
+
metadata['dice'] = (d1, d2)
|
|
117
|
+
|
|
118
|
+
# Score: in XGID, field 5 is bottom player, field 6 is top player
|
|
119
|
+
# We map bottom=O, top=X
|
|
120
|
+
metadata['score_o'] = score_bottom
|
|
121
|
+
metadata['score_x'] = score_top
|
|
122
|
+
|
|
123
|
+
# Match length
|
|
124
|
+
metadata['match_length'] = match_length
|
|
125
|
+
|
|
126
|
+
# Crawford/Jacoby
|
|
127
|
+
metadata['crawford_jacoby'] = crawford_jacoby
|
|
128
|
+
|
|
129
|
+
# Max cube
|
|
130
|
+
metadata['max_cube'] = 2 ** max_cube if max_cube >= 0 else 256
|
|
131
|
+
|
|
132
|
+
return position, metadata
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_position_string(pos_str: str, turn: int) -> Position:
|
|
136
|
+
"""
|
|
137
|
+
Parse the position encoding part of XGID.
|
|
138
|
+
|
|
139
|
+
The 26-character position string is encoded from the perspective of the player on roll:
|
|
140
|
+
|
|
141
|
+
When turn=1 (O on roll):
|
|
142
|
+
- Char 0: X's bar, Chars 1-24: points 1-24, Char 25: O's bar
|
|
143
|
+
- lowercase = X checkers, uppercase = O checkers
|
|
144
|
+
|
|
145
|
+
When turn=-1 (X on roll):
|
|
146
|
+
- Char 0: O's bar, Chars 1-24: points in reverse (24-1), Char 25: X's bar
|
|
147
|
+
- lowercase = X checkers, uppercase = O checkers
|
|
148
|
+
|
|
149
|
+
Internal model (always consistent):
|
|
150
|
+
- points[0] = X's bar (TOP player)
|
|
151
|
+
- points[1-24] = board points (1 = O's home, 24 = X's home)
|
|
152
|
+
- points[25] = O's bar (BOTTOM player)
|
|
153
|
+
"""
|
|
154
|
+
if len(pos_str) != 26:
|
|
155
|
+
raise ValueError(f"Position string must be 26 characters, got {len(pos_str)}")
|
|
156
|
+
|
|
157
|
+
position = Position()
|
|
158
|
+
|
|
159
|
+
if turn == 1:
|
|
160
|
+
# O on roll: standard perspective
|
|
161
|
+
position.points[0] = _decode_checker_count(pos_str[0], turn)
|
|
162
|
+
position.points[25] = _decode_checker_count(pos_str[25], turn)
|
|
163
|
+
|
|
164
|
+
for i in range(1, 25):
|
|
165
|
+
position.points[i] = _decode_checker_count(pos_str[i], turn)
|
|
166
|
+
else:
|
|
167
|
+
# X on roll: flipped perspective - bars and points are reversed
|
|
168
|
+
position.points[0] = _decode_checker_count(pos_str[25], turn) # X's bar
|
|
169
|
+
position.points[25] = _decode_checker_count(pos_str[0], turn) # O's bar
|
|
170
|
+
|
|
171
|
+
# Board points: reverse mapping
|
|
172
|
+
for i in range(1, 25):
|
|
173
|
+
position.points[i] = _decode_checker_count(pos_str[25 - i], turn)
|
|
174
|
+
|
|
175
|
+
# Calculate borne-off checkers (each player starts with 15)
|
|
176
|
+
total_x = sum(count for count in position.points if count > 0)
|
|
177
|
+
total_o = sum(abs(count) for count in position.points if count < 0)
|
|
178
|
+
|
|
179
|
+
position.x_off = 15 - total_x
|
|
180
|
+
position.o_off = 15 - total_o
|
|
181
|
+
|
|
182
|
+
return position
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode_checker_count(char: str, turn: int) -> int:
|
|
186
|
+
"""
|
|
187
|
+
Decode a single character to checker count.
|
|
188
|
+
|
|
189
|
+
The uppercase/lowercase mapping depends on whose turn it is:
|
|
190
|
+
|
|
191
|
+
When turn=1 (O on roll):
|
|
192
|
+
- lowercase = X checkers (positive)
|
|
193
|
+
- uppercase = O checkers (negative)
|
|
194
|
+
|
|
195
|
+
When turn=-1 (X on roll):
|
|
196
|
+
- lowercase = O checkers (negative)
|
|
197
|
+
- uppercase = X checkers (positive)
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
char: The character to decode
|
|
201
|
+
turn: 1 if O on roll, -1 if X on roll
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Checker count (positive for X, negative for O, 0 for empty)
|
|
205
|
+
"""
|
|
206
|
+
if char == '-':
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
count = 0
|
|
210
|
+
if 'a' <= char <= 'p':
|
|
211
|
+
count = ord(char) - ord('a') + 1
|
|
212
|
+
is_lowercase = True
|
|
213
|
+
elif 'A' <= char <= 'P':
|
|
214
|
+
count = ord(char) - ord('A') + 1
|
|
215
|
+
is_lowercase = False
|
|
216
|
+
else:
|
|
217
|
+
raise ValueError(f"Invalid position character: {char}")
|
|
218
|
+
|
|
219
|
+
if turn == 1:
|
|
220
|
+
# O's perspective: lowercase=X, uppercase=O
|
|
221
|
+
return count if is_lowercase else -count
|
|
222
|
+
else:
|
|
223
|
+
# X's perspective: lowercase=O, uppercase=X
|
|
224
|
+
return -count if is_lowercase else count
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def encode_xgid(
|
|
228
|
+
position: Position,
|
|
229
|
+
cube_value: int = 1,
|
|
230
|
+
cube_owner: CubeState = CubeState.CENTERED,
|
|
231
|
+
dice: Optional[Tuple[int, int]] = None,
|
|
232
|
+
on_roll: Player = Player.O,
|
|
233
|
+
score_x: int = 0,
|
|
234
|
+
score_o: int = 0,
|
|
235
|
+
match_length: int = 0,
|
|
236
|
+
crawford_jacoby: int = 0,
|
|
237
|
+
max_cube: int = 256,
|
|
238
|
+
) -> str:
|
|
239
|
+
"""
|
|
240
|
+
Encode a position and metadata as an XGID string.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
position: The position to encode
|
|
244
|
+
cube_value: Doubling cube value
|
|
245
|
+
cube_owner: Who owns the cube
|
|
246
|
+
dice: Dice values (if any)
|
|
247
|
+
on_roll: Player on roll
|
|
248
|
+
score_x: TOP player's score
|
|
249
|
+
score_o: BOTTOM player's score
|
|
250
|
+
match_length: Match length (0 for money)
|
|
251
|
+
crawford_jacoby: Crawford/Jacoby setting
|
|
252
|
+
max_cube: Maximum cube value
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
XGID string
|
|
256
|
+
"""
|
|
257
|
+
# Turn: 1 = BOTTOM (O), -1 = TOP (X)
|
|
258
|
+
turn = 1 if on_roll == Player.O else -1
|
|
259
|
+
|
|
260
|
+
# Encode position (turn-dependent)
|
|
261
|
+
pos_str = _encode_position_string(position, turn)
|
|
262
|
+
|
|
263
|
+
# Cube value as log2
|
|
264
|
+
cube_value_log = 0
|
|
265
|
+
temp_cube = cube_value
|
|
266
|
+
while temp_cube > 1:
|
|
267
|
+
temp_cube //= 2
|
|
268
|
+
cube_value_log += 1
|
|
269
|
+
|
|
270
|
+
# Cube position: -1 = TOP (X), 0 = centered, 1 = BOTTOM (O)
|
|
271
|
+
if cube_owner == CubeState.X_OWNS:
|
|
272
|
+
cube_position = -1
|
|
273
|
+
elif cube_owner == CubeState.O_OWNS:
|
|
274
|
+
cube_position = 1
|
|
275
|
+
else:
|
|
276
|
+
cube_position = 0
|
|
277
|
+
|
|
278
|
+
# Dice
|
|
279
|
+
if dice:
|
|
280
|
+
dice_str = f"{dice[0]}{dice[1]}"
|
|
281
|
+
else:
|
|
282
|
+
dice_str = "00"
|
|
283
|
+
|
|
284
|
+
# Max cube as log2
|
|
285
|
+
max_cube_log = 0
|
|
286
|
+
temp = max_cube
|
|
287
|
+
while temp > 1:
|
|
288
|
+
temp //= 2
|
|
289
|
+
max_cube_log += 1
|
|
290
|
+
|
|
291
|
+
# Build XGID
|
|
292
|
+
xgid = (
|
|
293
|
+
f"XGID={pos_str}:"
|
|
294
|
+
f"{cube_value_log}:{cube_position}:{turn}:{dice_str}:"
|
|
295
|
+
f"{score_o}:{score_x}:"
|
|
296
|
+
f"{crawford_jacoby}:{match_length}:{max_cube_log}"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return xgid
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _encode_position_string(position: Position, turn: int) -> str:
|
|
303
|
+
"""
|
|
304
|
+
Encode a position to the 26-character XGID format.
|
|
305
|
+
|
|
306
|
+
The encoding depends on whose turn it is:
|
|
307
|
+
|
|
308
|
+
When turn=1 (O on roll):
|
|
309
|
+
- Char 0: X's bar (points[0])
|
|
310
|
+
- Chars 1-24: points in standard order (points[1-24])
|
|
311
|
+
- Char 25: O's bar (points[25])
|
|
312
|
+
|
|
313
|
+
When turn=-1 (X on roll):
|
|
314
|
+
- Char 0: O's bar (points[25])
|
|
315
|
+
- Chars 1-24: points in reversed order
|
|
316
|
+
- Char 25: X's bar (points[0])
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
position: The position to encode
|
|
320
|
+
turn: 1 if O on roll, -1 if X on roll
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
26-character position string
|
|
324
|
+
"""
|
|
325
|
+
chars = [''] * 26
|
|
326
|
+
|
|
327
|
+
if turn == 1:
|
|
328
|
+
# O on roll: standard perspective
|
|
329
|
+
chars[0] = _encode_checker_count(position.points[0], turn)
|
|
330
|
+
chars[25] = _encode_checker_count(position.points[25], turn)
|
|
331
|
+
|
|
332
|
+
for i in range(1, 25):
|
|
333
|
+
chars[i] = _encode_checker_count(position.points[i], turn)
|
|
334
|
+
else:
|
|
335
|
+
# X on roll: flipped perspective - bars and points are reversed
|
|
336
|
+
chars[0] = _encode_checker_count(position.points[25], turn) # O's bar
|
|
337
|
+
chars[25] = _encode_checker_count(position.points[0], turn) # X's bar
|
|
338
|
+
|
|
339
|
+
# Board points: reverse mapping
|
|
340
|
+
for i in range(1, 25):
|
|
341
|
+
chars[25 - i] = _encode_checker_count(position.points[i], turn)
|
|
342
|
+
|
|
343
|
+
return ''.join(chars)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _encode_checker_count(count: int, turn: int) -> str:
|
|
347
|
+
"""
|
|
348
|
+
Encode checker count to a single character.
|
|
349
|
+
|
|
350
|
+
The uppercase/lowercase mapping depends on whose turn it is:
|
|
351
|
+
|
|
352
|
+
When turn=1 (O on roll):
|
|
353
|
+
- 0 = '-'
|
|
354
|
+
- positive (X) = lowercase 'a' to 'p'
|
|
355
|
+
- negative (O) = uppercase 'A' to 'P'
|
|
356
|
+
|
|
357
|
+
When turn=-1 (X on roll):
|
|
358
|
+
- 0 = '-'
|
|
359
|
+
- positive (X) = uppercase 'A' to 'P'
|
|
360
|
+
- negative (O) = lowercase 'a' to 'p'
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
count: Checker count (positive for X, negative for O, 0 for empty)
|
|
364
|
+
turn: 1 if O on roll, -1 if X on roll
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Single character encoding
|
|
368
|
+
"""
|
|
369
|
+
if count == 0:
|
|
370
|
+
return '-'
|
|
371
|
+
|
|
372
|
+
abs_count = abs(count)
|
|
373
|
+
if abs_count > 16:
|
|
374
|
+
abs_count = 16
|
|
375
|
+
|
|
376
|
+
if turn == 1:
|
|
377
|
+
# O's perspective: lowercase=X, uppercase=O
|
|
378
|
+
if count > 0:
|
|
379
|
+
return chr(ord('a') + abs_count - 1)
|
|
380
|
+
else:
|
|
381
|
+
return chr(ord('A') + abs_count - 1)
|
|
382
|
+
else:
|
|
383
|
+
# X's perspective: uppercase=X, lowercase=O
|
|
384
|
+
if count > 0:
|
|
385
|
+
return chr(ord('A') + abs_count - 1)
|
|
386
|
+
else:
|
|
387
|
+
return chr(ord('a') + abs_count - 1)
|