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,419 @@
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
80
+ # CRITICAL: The position encoding depends on whose turn it is!
81
+ # When turn=1 (BOTTOM/O on roll), the encoding is from O's perspective
82
+ # When turn=-1 (TOP/X on roll), the encoding is from X's perspective
83
+ # We need to pass the turn to correctly interpret the position
84
+ position = _parse_position_string(position_str, turn)
85
+
86
+ # Parse metadata
87
+ metadata = {}
88
+
89
+ # Cube value (2^cube_value_log)
90
+ cube_value = 2 ** cube_value_log if cube_value_log >= 0 else 1
91
+ metadata['cube_value'] = cube_value
92
+
93
+ # Cube owner
94
+ # NOTE: Unlike position encoding, cube ownership is ABSOLUTE (not perspective-dependent)
95
+ # -1 = TOP player (X), 0 = centered, 1 = BOTTOM player (O)
96
+ # This is consistent with bar positions which are also absolute
97
+ if cube_position == 0:
98
+ cube_state = CubeState.CENTERED
99
+ elif cube_position == -1:
100
+ cube_state = CubeState.X_OWNS # TOP = X
101
+ else: # cube_position == 1
102
+ cube_state = CubeState.O_OWNS # BOTTOM = O
103
+ metadata['cube_owner'] = cube_state
104
+
105
+ # Turn: 1 = BOTTOM player (O), -1 = TOP player (X)
106
+ on_roll = Player.O if turn == 1 else Player.X
107
+ metadata['on_roll'] = on_roll
108
+
109
+ # Dice
110
+ dice_str = dice_str.upper().strip()
111
+ if dice_str == '00':
112
+ # Player to roll or double (no dice shown)
113
+ pass
114
+ elif dice_str in ['D', 'B', 'R']:
115
+ # Cube action pending
116
+ metadata['decision_type'] = 'cube_action'
117
+ elif len(dice_str) == 2 and dice_str.isdigit():
118
+ # Rolled dice
119
+ d1 = int(dice_str[0])
120
+ d2 = int(dice_str[1])
121
+ if 1 <= d1 <= 6 and 1 <= d2 <= 6:
122
+ metadata['dice'] = (d1, d2)
123
+
124
+ # Score: in XGID, field 5 is bottom player, field 6 is top player
125
+ # We map bottom=O, top=X
126
+ metadata['score_o'] = score_bottom
127
+ metadata['score_x'] = score_top
128
+
129
+ # Match length
130
+ metadata['match_length'] = match_length
131
+
132
+ # Crawford/Jacoby
133
+ metadata['crawford_jacoby'] = crawford_jacoby
134
+
135
+ # Max cube
136
+ metadata['max_cube'] = 2 ** max_cube if max_cube >= 0 else 256
137
+
138
+ return position, metadata
139
+
140
+
141
+ def _parse_position_string(pos_str: str, turn: int) -> Position:
142
+ """
143
+ Parse the position encoding part of XGID.
144
+
145
+ Format: 26 characters
146
+
147
+ CRITICAL: The ENTIRE position encoding depends on whose turn it is!
148
+
149
+ When turn=1 (O on roll - standard view):
150
+ - Char 0: X's bar (top)
151
+ - Chars 1-24: points 1-24 in standard order
152
+ - Char 25: O's bar (bottom)
153
+ - lowercase='X', uppercase='O'
154
+
155
+ When turn=-1 (X on roll - flipped view):
156
+ - Char 0: O's bar (top in X's view)
157
+ - Chars 1-24: points 24-1 in REVERSED order
158
+ - Char 25: X's bar (bottom in X's view)
159
+ - lowercase='X', uppercase='O' (stays the same)
160
+
161
+ In our internal model, we always use:
162
+ - points[0] = X's bar (TOP player in standard orientation)
163
+ - points[1-24] = board points (point 1 = O's home, point 24 = X's home)
164
+ - points[25] = O's bar (BOTTOM player in standard orientation)
165
+ """
166
+ if len(pos_str) != 26:
167
+ raise ValueError(f"Position string must be 26 characters, got {len(pos_str)}")
168
+
169
+ position = Position()
170
+
171
+ if turn == 1:
172
+ # O is on roll - encoding is from O's perspective (standard)
173
+ # Char 0: X's bar (top), Char 25: O's bar (bottom)
174
+ # Chars 1-24: points 1-24 in standard order
175
+ position.points[0] = _decode_checker_count(pos_str[0], turn)
176
+ position.points[25] = _decode_checker_count(pos_str[25], turn)
177
+
178
+ for i in range(1, 25):
179
+ position.points[i] = _decode_checker_count(pos_str[i], turn)
180
+ else:
181
+ # X is on roll - encoding is from X's perspective (FLIPPED!)
182
+ # The ENTIRE position is flipped, including bars:
183
+ # Char 0: O's bar (top in X's view), Char 25: X's bar (bottom in X's view)
184
+ # Chars 1-24: points from X's perspective -> need to reverse
185
+
186
+ # Bars need to be swapped!
187
+ position.points[0] = _decode_checker_count(pos_str[25], turn) # X's bar comes from char 25
188
+ position.points[25] = _decode_checker_count(pos_str[0], turn) # O's bar comes from char 0
189
+
190
+ # Board points - reverse the numbering
191
+ for i in range(1, 25):
192
+ # Point i in our model comes from point (25-i) in the XGID
193
+ position.points[i] = _decode_checker_count(pos_str[25 - i], turn)
194
+
195
+ # Calculate borne-off checkers (each player starts with 15)
196
+ total_x = sum(count for count in position.points if count > 0)
197
+ total_o = sum(abs(count) for count in position.points if count < 0)
198
+
199
+ position.x_off = 15 - total_x
200
+ position.o_off = 15 - total_o
201
+
202
+ return position
203
+
204
+
205
+ def _decode_checker_count(char: str, turn: int) -> int:
206
+ """
207
+ Decode a single character to checker count.
208
+
209
+ CRITICAL: The uppercase/lowercase mapping CHANGES based on whose turn it is!
210
+
211
+ When turn=1 (O on roll - O's perspective):
212
+ - lowercase = X checkers (positive)
213
+ - uppercase = O checkers (negative)
214
+
215
+ When turn=-1 (X on roll - X's perspective):
216
+ - lowercase = O checkers (negative) - FLIPPED!
217
+ - uppercase = X checkers (positive) - FLIPPED!
218
+
219
+ Args:
220
+ char: The character to decode
221
+ turn: 1 if O on roll, -1 if X on roll
222
+
223
+ Returns:
224
+ Checker count (positive for X, negative for O, 0 for empty)
225
+ """
226
+ if char == '-':
227
+ return 0
228
+
229
+ count = 0
230
+ if 'a' <= char <= 'p':
231
+ count = ord(char) - ord('a') + 1
232
+ is_lowercase = True
233
+ elif 'A' <= char <= 'P':
234
+ count = ord(char) - ord('A') + 1
235
+ is_lowercase = False
236
+ else:
237
+ raise ValueError(f"Invalid position character: {char}")
238
+
239
+ if turn == 1:
240
+ # O's perspective: lowercase=X, uppercase=O
241
+ return count if is_lowercase else -count
242
+ else:
243
+ # X's perspective: lowercase=O, uppercase=X (FLIPPED!)
244
+ return -count if is_lowercase else count
245
+
246
+
247
+ def encode_xgid(
248
+ position: Position,
249
+ cube_value: int = 1,
250
+ cube_owner: CubeState = CubeState.CENTERED,
251
+ dice: Optional[Tuple[int, int]] = None,
252
+ on_roll: Player = Player.O,
253
+ score_x: int = 0,
254
+ score_o: int = 0,
255
+ match_length: int = 0,
256
+ crawford_jacoby: int = 0,
257
+ max_cube: int = 256,
258
+ ) -> str:
259
+ """
260
+ Encode a position and metadata as an XGID string.
261
+
262
+ Args:
263
+ position: The position to encode
264
+ cube_value: Doubling cube value
265
+ cube_owner: Who owns the cube
266
+ dice: Dice values (if any)
267
+ on_roll: Player on roll
268
+ score_x: TOP player's score
269
+ score_o: BOTTOM player's score
270
+ match_length: Match length (0 for money)
271
+ crawford_jacoby: Crawford/Jacoby setting
272
+ max_cube: Maximum cube value
273
+
274
+ Returns:
275
+ XGID string
276
+ """
277
+ # Turn: 1 = BOTTOM (O), -1 = TOP (X)
278
+ turn = 1 if on_roll == Player.O else -1
279
+
280
+ # Encode position (turn-dependent)
281
+ pos_str = _encode_position_string(position, turn)
282
+
283
+ # Cube value as log2
284
+ cube_value_log = 0
285
+ temp_cube = cube_value
286
+ while temp_cube > 1:
287
+ temp_cube //= 2
288
+ cube_value_log += 1
289
+
290
+ # Cube position: -1 = TOP (X), 0 = centered, 1 = BOTTOM (O)
291
+ if cube_owner == CubeState.X_OWNS:
292
+ cube_position = -1
293
+ elif cube_owner == CubeState.O_OWNS:
294
+ cube_position = 1
295
+ else:
296
+ cube_position = 0
297
+
298
+ # Dice
299
+ if dice:
300
+ dice_str = f"{dice[0]}{dice[1]}"
301
+ else:
302
+ dice_str = "00"
303
+
304
+ # Max cube as log2
305
+ max_cube_log = 0
306
+ temp = max_cube
307
+ while temp > 1:
308
+ temp //= 2
309
+ max_cube_log += 1
310
+
311
+ # Build XGID
312
+ xgid = (
313
+ f"XGID={pos_str}:"
314
+ f"{cube_value_log}:{cube_position}:{turn}:{dice_str}:"
315
+ f"{score_o}:{score_x}:"
316
+ f"{crawford_jacoby}:{match_length}:{max_cube_log}"
317
+ )
318
+
319
+ return xgid
320
+
321
+
322
+ def _encode_position_string(position: Position, turn: int) -> str:
323
+ """
324
+ Encode a position to the 26-character XGID format.
325
+
326
+ CRITICAL: The ENTIRE position encoding depends on whose turn it is!
327
+
328
+ When turn=1 (O on roll - standard view):
329
+ - Char 0: X's bar (our points[0])
330
+ - Chars 1-24: points in standard order (our points[1-24])
331
+ - Char 25: O's bar (our points[25])
332
+
333
+ When turn=-1 (X on roll - flipped view):
334
+ - Char 0: O's bar (our points[25])
335
+ - Chars 1-24: points in REVERSED order (char 1 = points[24], char 24 = points[1])
336
+ - Char 25: X's bar (our points[0])
337
+
338
+ Args:
339
+ position: The position to encode
340
+ turn: 1 if O on roll, -1 if X on roll
341
+
342
+ Returns:
343
+ 26-character position string
344
+ """
345
+ chars = [''] * 26
346
+
347
+ if turn == 1:
348
+ # O is on roll - encoding is from O's perspective (standard)
349
+ # Char 0: X's bar (top), Char 25: O's bar (bottom)
350
+ # Chars 1-24: points 1-24 in standard order
351
+ chars[0] = _encode_checker_count(position.points[0], turn)
352
+ chars[25] = _encode_checker_count(position.points[25], turn)
353
+
354
+ for i in range(1, 25):
355
+ chars[i] = _encode_checker_count(position.points[i], turn)
356
+ else:
357
+ # X is on roll - encoding is from X's perspective (FLIPPED!)
358
+ # The ENTIRE position is flipped, including bars:
359
+ # Char 0: O's bar (top in X's view), Char 25: X's bar (bottom in X's view)
360
+ # Chars 1-24: points from X's perspective -> need to reverse
361
+
362
+ # Bars need to be swapped!
363
+ chars[0] = _encode_checker_count(position.points[25], turn) # O's bar goes to char 0
364
+ chars[25] = _encode_checker_count(position.points[0], turn) # X's bar goes to char 25
365
+
366
+ # Board points - reverse the numbering
367
+ for i in range(1, 25):
368
+ # Point i in our model goes to char (25-i) in the XGID
369
+ chars[25 - i] = _encode_checker_count(position.points[i], turn)
370
+
371
+ return ''.join(chars)
372
+
373
+
374
+ def _encode_checker_count(count: int, turn: int) -> str:
375
+ """
376
+ Encode checker count to a single character.
377
+
378
+ CRITICAL: The uppercase/lowercase mapping CHANGES based on whose turn it is!
379
+
380
+ When turn=1 (O on roll - O's perspective):
381
+ - 0 = '-'
382
+ - positive (X) = lowercase 'a' to 'p'
383
+ - negative (O) = uppercase 'A' to 'P'
384
+
385
+ When turn=-1 (X on roll - X's perspective):
386
+ - 0 = '-'
387
+ - positive (X) = uppercase 'A' to 'P' - FLIPPED!
388
+ - negative (O) = lowercase 'a' to 'p' - FLIPPED!
389
+
390
+ Args:
391
+ count: Checker count (positive for X, negative for O, 0 for empty)
392
+ turn: 1 if O on roll, -1 if X on roll
393
+
394
+ Returns:
395
+ Single character encoding
396
+ """
397
+ if count == 0:
398
+ return '-'
399
+
400
+ abs_count = abs(count)
401
+ if abs_count > 16:
402
+ abs_count = 16
403
+
404
+ if turn == 1:
405
+ # O's perspective: lowercase=X (positive), uppercase=O (negative)
406
+ if count > 0:
407
+ # X checkers -> lowercase
408
+ return chr(ord('a') + abs_count - 1)
409
+ else:
410
+ # O checkers -> uppercase
411
+ return chr(ord('A') + abs_count - 1)
412
+ else:
413
+ # X's perspective: uppercase=X (positive), lowercase=O (negative) - FLIPPED!
414
+ if count > 0:
415
+ # X checkers -> uppercase
416
+ return chr(ord('A') + abs_count - 1)
417
+ else:
418
+ # O checkers -> lowercase
419
+ return chr(ord('a') + abs_count - 1)