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,204 @@
1
+ """Parse and apply backgammon move notation."""
2
+
3
+ import re
4
+ from typing import List, Tuple
5
+
6
+ from ankigammon.models import Position, Player
7
+
8
+
9
+ class MoveParser:
10
+ """Parse and apply backgammon move notation."""
11
+
12
+ @staticmethod
13
+ def parse_move_notation(notation: str) -> List[Tuple[int, int]]:
14
+ """
15
+ Parse move notation into list of (from, to) tuples.
16
+
17
+ Args:
18
+ notation: Move notation (e.g., "13/9 6/5", "bar/22", "6/off")
19
+
20
+ Returns:
21
+ List of (from_point, to_point) tuples
22
+ Point 0 = X bar, Point 25 = O bar, Point 26 = bearing off
23
+
24
+ Examples:
25
+ "13/9 6/5" -> [(13, 9), (6, 5)]
26
+ "bar/22" -> [(0, 22)]
27
+ "6/off" -> [(6, 26)]
28
+ "6/4(4)" -> [(6, 4), (6, 4), (6, 4), (6, 4)]
29
+ """
30
+ notation = notation.strip().lower()
31
+
32
+ if notation in ['double', 'take', 'drop', 'pass', 'accept', 'decline']:
33
+ return []
34
+
35
+ moves = []
36
+
37
+ # Split by spaces or commas
38
+ parts = re.split(r'[\s,]+', notation)
39
+
40
+ for part in parts:
41
+ if not part or '/' not in part:
42
+ continue
43
+
44
+ # Check for repetition notation like "6/4(4)"
45
+ repetition_count = 1
46
+ repetition_match = re.search(r'\((\d+)\)$', part)
47
+ if repetition_match:
48
+ repetition_count = int(repetition_match.group(1))
49
+ part = re.sub(r'\(\d+\)$', '', part)
50
+
51
+ # Handle compound notation like "6/5*/3" or "24/23/22"
52
+ segments = part.split('/')
53
+ segments = [seg.rstrip('*') for seg in segments]
54
+
55
+ # Convert compound notation to consecutive moves: "6/5/3" -> [(6,5), (5,3)]
56
+ if len(segments) > 2:
57
+ for i in range(len(segments) - 1):
58
+ from_str = segments[i]
59
+ to_str = segments[i + 1]
60
+
61
+ if 'bar' in from_str:
62
+ from_point = 0
63
+ else:
64
+ try:
65
+ from_point = int(from_str)
66
+ except ValueError:
67
+ continue
68
+
69
+ if 'off' in to_str:
70
+ to_point = 26
71
+ elif 'bar' in to_str:
72
+ to_point = 0
73
+ else:
74
+ try:
75
+ to_point = int(to_str)
76
+ except ValueError:
77
+ continue
78
+
79
+ for _ in range(repetition_count):
80
+ moves.append((from_point, to_point))
81
+ else:
82
+ # Simple notation like "6/5" or "bar/22"
83
+ from_str = segments[0]
84
+ to_str = segments[1] if len(segments) > 1 else ''
85
+
86
+ if not to_str:
87
+ continue
88
+
89
+ if 'bar' in from_str:
90
+ from_point = 0
91
+ else:
92
+ try:
93
+ from_point = int(from_str)
94
+ except ValueError:
95
+ continue
96
+
97
+ if 'off' in to_str:
98
+ to_point = 26
99
+ elif 'bar' in to_str:
100
+ to_point = 0
101
+ else:
102
+ try:
103
+ to_point = int(to_str)
104
+ except ValueError:
105
+ continue
106
+
107
+ for _ in range(repetition_count):
108
+ moves.append((from_point, to_point))
109
+
110
+ return moves
111
+
112
+ @staticmethod
113
+ def apply_move(position: Position, notation: str, player: Player) -> Position:
114
+ """
115
+ Apply a move to a position and return the resulting position.
116
+
117
+ Args:
118
+ position: Initial position
119
+ notation: Move notation
120
+ player: Player making the move
121
+
122
+ Returns:
123
+ New position after the move
124
+ """
125
+ new_pos = position.copy()
126
+ moves = MoveParser.parse_move_notation(notation)
127
+
128
+ for from_point, to_point in moves:
129
+ # Adjust bar points for player perspective
130
+ if from_point == 0 and player == Player.O:
131
+ from_point = 25
132
+ if to_point == 0 and player == Player.X:
133
+ to_point = 25
134
+
135
+ if from_point == 26:
136
+ continue
137
+
138
+ if new_pos.points[from_point] == 0:
139
+ continue
140
+
141
+ if player == Player.X:
142
+ if new_pos.points[from_point] > 0:
143
+ new_pos.points[from_point] -= 1
144
+ else:
145
+ continue
146
+ else:
147
+ if new_pos.points[from_point] < 0:
148
+ new_pos.points[from_point] += 1
149
+ else:
150
+ continue
151
+
152
+ if to_point == 26:
153
+ if player == Player.X:
154
+ new_pos.x_off += 1
155
+ else:
156
+ new_pos.o_off += 1
157
+ else:
158
+ target_count = new_pos.points[to_point]
159
+
160
+ if player == Player.X:
161
+ if target_count == -1:
162
+ new_pos.points[25] -= 1
163
+ new_pos.points[to_point] = 1
164
+ else:
165
+ new_pos.points[to_point] += 1
166
+ else:
167
+ if target_count == 1:
168
+ new_pos.points[0] += 1
169
+ new_pos.points[to_point] = -1
170
+ else:
171
+ new_pos.points[to_point] -= 1
172
+
173
+ return new_pos
174
+
175
+ @staticmethod
176
+ def format_move(from_point: int, to_point: int, player: Player) -> str:
177
+ """
178
+ Format a single move as notation.
179
+
180
+ Args:
181
+ from_point: Source point
182
+ to_point: Destination point
183
+ player: Player making the move
184
+
185
+ Returns:
186
+ Move notation string (e.g., "13/9", "bar/22", "6/off")
187
+ """
188
+ if from_point == 0 and player == Player.X:
189
+ from_str = "bar"
190
+ elif from_point == 25 and player == Player.O:
191
+ from_str = "bar"
192
+ else:
193
+ from_str = str(from_point)
194
+
195
+ if to_point == 26:
196
+ to_str = "off"
197
+ elif to_point == 0 and player == Player.O:
198
+ to_str = "bar"
199
+ elif to_point == 25 and player == Player.X:
200
+ to_str = "bar"
201
+ else:
202
+ to_str = str(to_point)
203
+
204
+ return f"{from_str}/{to_str}"
@@ -0,0 +1,326 @@
1
+ """OGID format parsing and encoding.
2
+
3
+ OGID (OpenGammon Position ID) is a colon-separated format for representing
4
+ complete backgammon board states.
5
+
6
+ Format: P1:P2:CUBE[:DICE[:TURN[:STATE[:S1[:S2[:ML[:MID[:NCHECKERS]]]]]]]]
7
+
8
+ Fields:
9
+ 1. P1 (White/X checkers): Base-26 encoded positions with repeated characters
10
+ 2. P2 (Black/O checkers): Base-26 encoded positions with repeated characters
11
+ 3. CUBE: Three-character cube state (owner, value, action)
12
+ 4. DICE: Two-character dice roll (optional)
13
+ 5. TURN: Player to move - W or B (optional)
14
+ 6. STATE: Two-character game state (optional)
15
+ 7. S1: White/X score (optional)
16
+ 8. S2: Black/O score (optional)
17
+ 9. ML: Match length with modifiers (optional)
18
+ 10. MID: Move ID (optional)
19
+ 11. NCHECKERS: Number of checkers per side (optional, default 15)
20
+
21
+ Position encoding uses base-26:
22
+ - Characters '0'-'9' = points 0-9
23
+ - Characters 'a'-'p' = points 10-25
24
+ - Point 0 = White's bar (X in our model)
25
+ - Points 1-24 = board points
26
+ - Point 25 = Black's bar (O in our model)
27
+ - Repeated characters = multiple checkers on same point
28
+
29
+ Example starting position:
30
+ White: 11jjjjjhhhccccc (2 on pt1, 5 on pt9, 3 on pt17, 5 on pt12)
31
+ Black: ooddddd88866666 (2 on pt24, 5 on pt13, 3 on pt8, 5 on pt6)
32
+ Full: 11jjjjjhhhccccc:ooddddd88866666:N0N::W:IW:0:0:1:0
33
+
34
+ Note: In our internal model:
35
+ - White = Player.X (TOP player)
36
+ - Black = Player.O (BOTTOM player)
37
+ - Positive values = X checkers
38
+ - Negative values = O checkers
39
+ """
40
+
41
+ import re
42
+ from typing import Optional, Tuple, Dict
43
+
44
+ from ankigammon.models import Position, Player, CubeState
45
+
46
+
47
+ # Character to point mapping for base-26 encoding
48
+ def _char_to_point(char: str) -> int:
49
+ """Convert a character to a point number (0-25)."""
50
+ if '0' <= char <= '9':
51
+ return ord(char) - ord('0')
52
+ elif 'a' <= char <= 'p':
53
+ return ord(char) - ord('a') + 10
54
+ else:
55
+ raise ValueError(f"Invalid position character: {char}")
56
+
57
+
58
+ def _point_to_char(point: int) -> str:
59
+ """Convert a point number (0-25) to a character."""
60
+ if 0 <= point <= 9:
61
+ return chr(ord('0') + point)
62
+ elif 10 <= point <= 25:
63
+ return chr(ord('a') + point - 10)
64
+ else:
65
+ raise ValueError(f"Invalid point number: {point}")
66
+
67
+
68
+ def parse_ogid(ogid: str) -> Tuple[Position, Dict]:
69
+ """
70
+ Parse an OGID string into a Position and metadata.
71
+
72
+ Args:
73
+ ogid: OGID string (e.g., "11jjjjjhhhccccc:ooddddd88866666:N0N::W:IW:0:0:1:0")
74
+
75
+ Returns:
76
+ Tuple of (Position, metadata_dict)
77
+ """
78
+ # Remove "OGID=" prefix if present
79
+ if ogid.upper().startswith("OGID="):
80
+ ogid = ogid[5:]
81
+
82
+ # Split into components
83
+ parts = ogid.split(':')
84
+ if len(parts) < 3:
85
+ raise ValueError(f"Invalid OGID format: expected at least 3 parts, got {len(parts)}")
86
+
87
+ white_pos = parts[0] # White/X checkers
88
+ black_pos = parts[1] # Black/O checkers
89
+ cube_str = parts[2] # Cube state
90
+
91
+ # Parse position
92
+ position = _parse_ogid_position(white_pos, black_pos)
93
+
94
+ # Parse metadata
95
+ metadata = {}
96
+
97
+ # Parse cube state (3 characters: owner, value, action)
98
+ if len(cube_str) == 3:
99
+ cube_owner_char = cube_str[0]
100
+ cube_value_char = cube_str[1]
101
+ cube_action_char = cube_str[2]
102
+
103
+ # Cube value stored as log2 (0->1, 1->2, 2->4, etc.)
104
+ cube_value_log = int(cube_value_char)
105
+ metadata['cube_value'] = 2 ** cube_value_log
106
+
107
+ # Map cube owner character to internal cube state
108
+ if cube_owner_char == 'W':
109
+ metadata['cube_owner'] = CubeState.X_OWNS
110
+ elif cube_owner_char == 'B':
111
+ metadata['cube_owner'] = CubeState.O_OWNS
112
+ elif cube_owner_char == 'N':
113
+ metadata['cube_owner'] = CubeState.CENTERED
114
+ else:
115
+ metadata['cube_owner'] = CubeState.CENTERED
116
+
117
+ metadata['cube_action'] = cube_action_char
118
+
119
+ # Parse optional fields
120
+ if len(parts) > 3 and parts[3]:
121
+ # Field 4: Dice
122
+ dice_str = parts[3]
123
+ if len(dice_str) == 2 and dice_str.isdigit():
124
+ d1 = int(dice_str[0])
125
+ d2 = int(dice_str[1])
126
+ if 1 <= d1 <= 6 and 1 <= d2 <= 6:
127
+ metadata['dice'] = (d1, d2)
128
+
129
+ if len(parts) > 4 and parts[4]:
130
+ # Field 5: Turn (W or B)
131
+ turn_str = parts[4].upper()
132
+ if turn_str == 'W':
133
+ metadata['on_roll'] = Player.X
134
+ elif turn_str == 'B':
135
+ metadata['on_roll'] = Player.O
136
+
137
+ if len(parts) > 5 and parts[5]:
138
+ # Field 6: Game state (e.g., IW, FB)
139
+ metadata['game_state'] = parts[5]
140
+
141
+ if len(parts) > 6 and parts[6]:
142
+ # Field 7: X score
143
+ metadata['score_x'] = int(parts[6])
144
+
145
+ if len(parts) > 7 and parts[7]:
146
+ # Field 8: O score
147
+ metadata['score_o'] = int(parts[7])
148
+
149
+ if len(parts) > 8 and parts[8]:
150
+ # Field 9: Match length with optional modifiers (e.g., "7", "5C", "9G15")
151
+ match_str = parts[8]
152
+ match_regex = re.compile(r'(\d+)([LCG]?)(\d*)')
153
+ match = match_regex.match(match_str)
154
+ if match:
155
+ metadata['match_length'] = int(match.group(1))
156
+ if match.group(2):
157
+ metadata['match_modifier'] = match.group(2)
158
+ if match.group(3):
159
+ metadata['match_max_games'] = int(match.group(3))
160
+
161
+ if len(parts) > 9 and parts[9]:
162
+ # Field 10: Move ID
163
+ metadata['move_id'] = int(parts[9])
164
+
165
+ if len(parts) > 10 and parts[10]:
166
+ # Field 11: Number of checkers per side (default 15)
167
+ metadata['num_checkers'] = int(parts[10])
168
+
169
+ return position, metadata
170
+
171
+
172
+ def _parse_ogid_position(white_str: str, black_str: str) -> Position:
173
+ """
174
+ Parse OGID position strings into a Position object.
175
+
176
+ Args:
177
+ white_str: X checker positions (e.g., "11jjjjjhhhccccc")
178
+ black_str: O checker positions (e.g., "ooddddd88866666")
179
+
180
+ Returns:
181
+ Position object with checkers placed
182
+ """
183
+ position = Position()
184
+
185
+ # Parse X checkers (positive values)
186
+ for char in white_str:
187
+ point = _char_to_point(char)
188
+ position.points[point] += 1
189
+
190
+ # Parse O checkers (negative values)
191
+ for char in black_str:
192
+ point = _char_to_point(char)
193
+ position.points[point] -= 1
194
+
195
+ # Calculate borne-off checkers
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 encode_ogid(
206
+ position: Position,
207
+ cube_value: int = 1,
208
+ cube_owner: CubeState = CubeState.CENTERED,
209
+ cube_action: str = 'N',
210
+ dice: Optional[Tuple[int, int]] = None,
211
+ on_roll: Optional[Player] = None,
212
+ game_state: str = '',
213
+ score_x: int = 0,
214
+ score_o: int = 0,
215
+ match_length: Optional[int] = None,
216
+ match_modifier: str = '',
217
+ match_max_games: Optional[int] = None,
218
+ move_id: Optional[int] = None,
219
+ num_checkers: Optional[int] = None,
220
+ only_position: bool = False,
221
+ ) -> str:
222
+ """
223
+ Encode a position and metadata as an OGID string.
224
+
225
+ Args:
226
+ position: The position to encode
227
+ cube_value: Doubling cube value
228
+ cube_owner: Who owns the cube
229
+ cube_action: Cube action (N=Normal, O=Offered, T=Taken, P=Passed)
230
+ dice: Dice values
231
+ on_roll: Player on roll
232
+ game_state: Game state code (e.g., "IW", "FB")
233
+ score_x: X player's score
234
+ score_o: O player's score
235
+ match_length: Match length in points
236
+ match_modifier: Match modifier (L, C, or G)
237
+ match_max_games: Max games for Galaxie format
238
+ move_id: Move sequence number
239
+ num_checkers: Number of checkers per side (only include if not 15)
240
+ only_position: If True, only encode position fields (1-3)
241
+
242
+ Returns:
243
+ OGID string
244
+ """
245
+ # Encode position strings
246
+ white_chars = []
247
+ black_chars = []
248
+
249
+ for point_idx in range(26):
250
+ count = position.points[point_idx]
251
+ if count > 0:
252
+ white_chars.extend([_point_to_char(point_idx)] * count)
253
+ elif count < 0:
254
+ black_chars.extend([_point_to_char(point_idx)] * abs(count))
255
+
256
+ # Sort characters per OGID format
257
+ white_str = ''.join(sorted(white_chars))
258
+ black_str = ''.join(sorted(black_chars))
259
+
260
+ # Encode cube state (3 characters: owner, value, action)
261
+ if cube_owner == CubeState.X_OWNS:
262
+ cube_owner_char = 'W'
263
+ elif cube_owner == CubeState.O_OWNS:
264
+ cube_owner_char = 'B'
265
+ else:
266
+ cube_owner_char = 'N'
267
+
268
+ # Convert cube value to log2
269
+ cube_value_log = 0
270
+ temp = cube_value
271
+ while temp > 1:
272
+ temp //= 2
273
+ cube_value_log += 1
274
+ cube_value_char = str(cube_value_log)
275
+
276
+ cube_action_char = cube_action
277
+
278
+ cube_str = f"{cube_owner_char}{cube_value_char}{cube_action_char}"
279
+
280
+ ogid_parts = [white_str, black_str, cube_str]
281
+
282
+ if only_position:
283
+ return ':'.join(ogid_parts)
284
+
285
+ # Field 4: Dice
286
+ if dice:
287
+ ogid_parts.append(f"{dice[0]}{dice[1]}")
288
+ else:
289
+ ogid_parts.append('')
290
+
291
+ # Field 5: Turn
292
+ if on_roll:
293
+ turn_char = 'W' if on_roll == Player.X else 'B'
294
+ ogid_parts.append(turn_char)
295
+ else:
296
+ ogid_parts.append('')
297
+
298
+ # Field 6: Game state
299
+ ogid_parts.append(game_state)
300
+
301
+ # Field 7-8: Scores
302
+ ogid_parts.append(str(score_x))
303
+ ogid_parts.append(str(score_o))
304
+
305
+ # Field 9: Match length
306
+ if match_length is not None:
307
+ match_str = str(match_length)
308
+ if match_modifier:
309
+ match_str += match_modifier
310
+ if match_max_games is not None:
311
+ match_str += str(match_max_games)
312
+ ogid_parts.append(match_str)
313
+ else:
314
+ ogid_parts.append('')
315
+
316
+ # Field 10: Move ID
317
+ if move_id is not None:
318
+ ogid_parts.append(str(move_id))
319
+ else:
320
+ ogid_parts.append('')
321
+
322
+ # Field 11: Number of checkers (only if not 15)
323
+ if num_checkers is not None and num_checkers != 15:
324
+ ogid_parts.append(str(num_checkers))
325
+
326
+ return ':'.join(ogid_parts)