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,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)