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,622 @@
1
+ """GNUID format parsing and encoding.
2
+
3
+ GNUID (GNU Backgammon ID) is GNU Backgammon's position identification format.
4
+
5
+ Format: PositionID:MatchID
6
+
7
+ Position ID:
8
+ - 14-character Base64 string
9
+ - Encodes 10 bytes (80 bits)
10
+ - Variable-length bit encoding of checker positions
11
+ - Format per point: [player 1-bits][0][opponent 1-bits][0]
12
+
13
+ Match ID:
14
+ - 12-character Base64 string
15
+ - Encodes 9 bytes (72 bits)
16
+ - Contains cube value, owner, scores, dice, match length, etc.
17
+
18
+ Example: 4HPwATDgc/ABMA:8IhuACAACAAE
19
+ - Position: 4HPwATDgc/ABMA (starting position)
20
+ - Match: 8IhuACAACAAE (match state)
21
+
22
+ Encoding Algorithm (Position ID):
23
+ 1. Start with empty bit string
24
+ 2. For each point (from player on roll's perspective):
25
+ - Append N ones (N = player on roll's checkers)
26
+ - Append M ones (M = opponent's checkers)
27
+ - Append one zero (separator)
28
+ 3. Pad to 80 bits with zeros
29
+ 4. Pack into 10 bytes (little-endian)
30
+ 5. Base64 encode (without padding)
31
+
32
+ Note: In our internal model:
33
+ - Player.X = TOP player (positive values)
34
+ - Player.O = BOTTOM player (negative values)
35
+ """
36
+
37
+ import base64
38
+ from typing import Dict, Optional, Tuple
39
+
40
+ from ankigammon.models import Position, Player, CubeState
41
+
42
+
43
+ def parse_gnuid(gnuid: str) -> Tuple[Position, Dict]:
44
+ """
45
+ Parse a GNUID string into a Position and metadata.
46
+
47
+ Args:
48
+ gnuid: GNUID string (e.g., "4HPwATDgc/ABMA:8IhuACAACAAE")
49
+
50
+ Returns:
51
+ Tuple of (Position, metadata_dict)
52
+ """
53
+ # Remove "GNUID=" or "GNUBGID=" prefix if present
54
+ gnuid = gnuid.strip()
55
+ if gnuid.upper().startswith("GNUID="):
56
+ gnuid = gnuid[6:]
57
+ elif gnuid.upper().startswith("GNUBGID="):
58
+ gnuid = gnuid[8:]
59
+ elif gnuid.upper().startswith("GNUBGID "):
60
+ gnuid = gnuid[8:]
61
+
62
+ # Split into Position ID and Match ID
63
+ parts = gnuid.split(':')
64
+ if len(parts) < 1:
65
+ raise ValueError("Invalid GNUID format: no colon separator found")
66
+
67
+ position_id = parts[0].strip()
68
+ match_id = parts[1].strip() if len(parts) > 1 else None
69
+
70
+ # Parse position ID
71
+ if len(position_id) != 14:
72
+ raise ValueError(f"Invalid Position ID length: expected 14 chars, got {len(position_id)}")
73
+
74
+ # Decode position (GNUID Position ID always uses Player 1/X's perspective)
75
+ position = _decode_position_id(position_id)
76
+
77
+ # Parse metadata from match ID
78
+ metadata = {}
79
+ if match_id:
80
+ metadata = _decode_match_id(match_id)
81
+
82
+ return position, metadata
83
+
84
+
85
+ def _decode_position_id(position_id: str) -> Position:
86
+ """
87
+ Decode a 14-character Position ID into a Position object.
88
+
89
+ GNUID Position IDs always encode from Player 1 (X/top player)'s perspective,
90
+ regardless of who is on roll.
91
+
92
+ Args:
93
+ position_id: 14-character Base64 string
94
+
95
+ Returns:
96
+ Position object
97
+ """
98
+ # Base64 decode to 10 bytes (no padding)
99
+ try:
100
+ # Add padding if needed for decoding
101
+ position_bytes = base64.b64decode(position_id + "==")
102
+ except Exception as e:
103
+ raise ValueError(f"Invalid Position ID Base64: {e}")
104
+
105
+ if len(position_bytes) != 10:
106
+ raise ValueError(f"Invalid Position ID: expected 10 bytes, got {len(position_bytes)}")
107
+
108
+ # Convert bytes to bit string (80 bits, little-endian)
109
+ bits = []
110
+ for byte in position_bytes:
111
+ for i in range(8):
112
+ bits.append((byte >> i) & 1)
113
+
114
+ # Decode bit string into TanBoard structure
115
+ # Format: [player0-25points][player1-25points]
116
+ # Each point: [N consecutive 1s][separator 0]
117
+ # This matches GNU Backgammon's oldPositionFromKey() function
118
+ anBoard = [[0] * 25 for _ in range(2)] # [2 players][25 points]
119
+
120
+ bit_idx = 0
121
+ player = 0 # Start with player 0 (X)
122
+ point = 0 # Start with point 0
123
+
124
+ while bit_idx < len(bits) and player < 2:
125
+ # Count consecutive 1s (checkers on this point)
126
+ checker_count = 0
127
+ while bit_idx < len(bits) and bits[bit_idx] == 1:
128
+ checker_count += 1
129
+ bit_idx += 1
130
+
131
+ # Store checker count
132
+ if point < 25:
133
+ anBoard[player][point] = checker_count
134
+
135
+ # Skip separator (0-bit)
136
+ if bit_idx < len(bits) and bits[bit_idx] == 0:
137
+ bit_idx += 1
138
+
139
+ # Move to next point
140
+ point += 1
141
+ if point >= 25:
142
+ # Move to next player
143
+ player += 1
144
+ point = 0
145
+
146
+ # Convert TanBoard to our Position model
147
+ position = _convert_tanboard_to_position(anBoard)
148
+
149
+ return position
150
+
151
+
152
+ def _convert_tanboard_to_position(anBoard: list) -> Position:
153
+ """
154
+ Convert TanBoard structure to our internal Position model.
155
+
156
+ TanBoard structure (from GNU Backgammon):
157
+ - anBoard[0][0-23] = Player 0 (X/top) checkers on points 0-23
158
+ - anBoard[0][24] = Player 0 (X) bar
159
+ - anBoard[1][0-23] = Player 1 (O/bottom) checkers on points 0-23
160
+ - anBoard[1][24] = Player 1 (O) bar
161
+
162
+ Point numbering in TanBoard (player-relative):
163
+ - Player 0 (X): anBoard[0][0] = point 24, anBoard[0][23] = point 1 (reverse mapping)
164
+ - Player 1 (O): anBoard[1][0] = point 1, anBoard[1][23] = point 24 (direct mapping)
165
+
166
+ Our internal model:
167
+ - points[0] = X's bar
168
+ - points[1-24] = board points (1 = O's ace, 24 = X's ace)
169
+ - points[25] = O's bar
170
+ - Positive values = X checkers, Negative values = O checkers
171
+
172
+ Args:
173
+ anBoard: TanBoard structure [2 players][25 points]
174
+
175
+ Returns:
176
+ Position object
177
+ """
178
+ position = Position()
179
+
180
+ # Player 0 (X) - reverse numbering, positive values
181
+ for i in range(24):
182
+ our_point = 24 - i # anBoard[0][0] → point 24, anBoard[0][23] → point 1
183
+ position.points[our_point] += anBoard[0][i] # Positive for X
184
+ position.points[0] = anBoard[0][24] # X's bar
185
+
186
+ # Player 1 (O) - direct numbering, negative values
187
+ for i in range(24):
188
+ our_point = i + 1 # anBoard[1][0] → point 1, anBoard[1][23] → point 24
189
+ position.points[our_point] -= anBoard[1][i] # Negative for O
190
+ position.points[25] = -anBoard[1][24] # O's bar
191
+
192
+ # Calculate borne-off checkers
193
+ total_x = sum(count for count in position.points if count > 0)
194
+ total_o = sum(abs(count) for count in position.points if count < 0)
195
+
196
+ position.x_off = 15 - total_x
197
+ position.o_off = 15 - total_o
198
+
199
+ return position
200
+
201
+
202
+ def _convert_position_to_tanboard(position: Position) -> list:
203
+ """
204
+ Convert our internal Position model to TanBoard structure.
205
+
206
+ This is the inverse of _convert_tanboard_to_position.
207
+
208
+ Args:
209
+ position: Our internal position
210
+
211
+ Returns:
212
+ TanBoard structure [2 players][25 points]
213
+ """
214
+ anBoard = [[0] * 25 for _ in range(2)]
215
+
216
+ # Player 0 (X) - reverse mapping: point p → anBoard[0][24-p]
217
+ for our_point in range(1, 25):
218
+ if position.points[our_point] > 0:
219
+ anBoard[0][24 - our_point] = position.points[our_point]
220
+ anBoard[0][24] = position.points[0] # X's bar
221
+
222
+ # Player 1 (O) - direct mapping: point p → anBoard[1][p-1]
223
+ for our_point in range(1, 25):
224
+ if position.points[our_point] < 0:
225
+ anBoard[1][our_point - 1] = -position.points[our_point]
226
+ anBoard[1][24] = -position.points[25] if position.points[25] < 0 else 0 # O's bar
227
+
228
+ return anBoard
229
+
230
+
231
+ def _convert_gnuid_to_position(
232
+ player_checkers: list,
233
+ opponent_checkers: list,
234
+ on_roll: Player
235
+ ) -> Position:
236
+ """
237
+ Convert GNUID perspective arrays to our internal Position model.
238
+
239
+ Args:
240
+ player_checkers: Checkers from player on roll's perspective [0-25]
241
+ opponent_checkers: Opponent's checkers from same perspective [0-25]
242
+ on_roll: Player who is on roll
243
+
244
+ Returns:
245
+ Position object
246
+ """
247
+ position = Position()
248
+
249
+ # GNUID point mapping from player on roll's perspective:
250
+ # Points 0-23 are board points from player's ace point (1) through opponent's ace (24)
251
+ # Point 24 is player's bar
252
+ # Point 25 is opponent's bar
253
+
254
+ if on_roll == Player.X:
255
+ # X is on roll, so player = X, opponent = O
256
+ # GNUID point 0 = X's point 24 (X's ace, looking from X's perspective)
257
+ # GNUID point 23 = X's point 1 (O's ace, looking from X's perspective)
258
+ # GNUID point 24 = X's bar (our point 0)
259
+ # GNUID point 25 = O's bar (our point 25)
260
+
261
+ # Map board points (reverse numbering for X's perspective)
262
+ for gnuid_pt in range(24):
263
+ our_pt = 24 - gnuid_pt
264
+ position.points[our_pt] = player_checkers[gnuid_pt] # X checkers (positive)
265
+ position.points[our_pt] -= opponent_checkers[gnuid_pt] # O checkers (negative)
266
+
267
+ # Map bars
268
+ position.points[0] = player_checkers[24] # X's bar
269
+ position.points[25] = -opponent_checkers[25] # O's bar
270
+
271
+ else:
272
+ # O is on roll, so player = O, opponent = X
273
+ # GNUID point 0 = O's point 1 (O's ace)
274
+ # GNUID point 23 = O's point 24 (X's ace)
275
+ # GNUID point 24 = O's bar (our point 25)
276
+ # GNUID point 25 = X's bar (our point 0)
277
+
278
+ # Map board points (direct mapping)
279
+ for gnuid_pt in range(24):
280
+ our_pt = gnuid_pt + 1
281
+ position.points[our_pt] = -player_checkers[gnuid_pt] # O checkers (negative)
282
+ position.points[our_pt] += opponent_checkers[gnuid_pt] # X checkers (positive)
283
+
284
+ # Map bars
285
+ position.points[25] = -player_checkers[24] # O's bar
286
+ position.points[0] = opponent_checkers[25] # X's bar
287
+
288
+ # Calculate borne-off checkers
289
+ total_x = sum(count for count in position.points if count > 0)
290
+ total_o = sum(abs(count) for count in position.points if count < 0)
291
+
292
+ position.x_off = 15 - total_x
293
+ position.o_off = 15 - total_o
294
+
295
+ return position
296
+
297
+
298
+ def _decode_match_id(match_id: str) -> Dict:
299
+ """
300
+ Decode a 12-character Match ID into metadata.
301
+
302
+ Args:
303
+ match_id: 12-character Base64 string
304
+
305
+ Returns:
306
+ Dictionary with metadata fields
307
+ """
308
+ if len(match_id) != 12:
309
+ raise ValueError(f"Invalid Match ID length: expected 12 chars, got {len(match_id)}")
310
+
311
+ try:
312
+ # Base64 decode to 9 bytes
313
+ match_bytes = base64.b64decode(match_id + "=")
314
+ except Exception as e:
315
+ raise ValueError(f"Invalid Match ID Base64: {e}")
316
+
317
+ if len(match_bytes) != 9:
318
+ raise ValueError(f"Invalid Match ID: expected 9 bytes, got {len(match_bytes)}")
319
+
320
+ # Convert to bit array
321
+ bits = []
322
+ for byte in match_bytes:
323
+ for i in range(8):
324
+ bits.append((byte >> i) & 1)
325
+
326
+ # Extract fields (total 72 bits)
327
+ metadata = {}
328
+
329
+ # Bits 0-3: Cube value (log2)
330
+ cube_log = _extract_bits(bits, 0, 4)
331
+ metadata['cube_value'] = 2 ** cube_log if cube_log < 15 else 1
332
+
333
+ # Bits 4-5: Cube owner (00=player0, 01=player1, 11=centered)
334
+ cube_owner_bits = _extract_bits(bits, 4, 2)
335
+ if cube_owner_bits == 3:
336
+ metadata['cube_owner'] = CubeState.CENTERED
337
+ elif cube_owner_bits == 0:
338
+ metadata['cube_owner'] = CubeState.X_OWNS # Player 0 = X
339
+ else:
340
+ metadata['cube_owner'] = CubeState.O_OWNS # Player 1 = O
341
+
342
+ # Bit 6: Move (who rolled)
343
+ # Bit 7: Crawford
344
+ metadata['crawford'] = bits[7] == 1
345
+
346
+ # Bits 8-10: Game state
347
+ game_state = _extract_bits(bits, 8, 3)
348
+ metadata['game_state'] = game_state
349
+
350
+ # Bit 11: Turn (0=player0/X, 1=player1/O)
351
+ turn_bit = bits[11]
352
+ metadata['on_roll'] = Player.O if turn_bit == 1 else Player.X
353
+
354
+ # Bit 12: Doubled
355
+ metadata['doubled'] = bits[12] == 1
356
+
357
+ # Bits 13-14: Resigned
358
+ resign_bits = _extract_bits(bits, 13, 2)
359
+ metadata['resigned'] = resign_bits
360
+
361
+ # Bits 15-17: Die 0
362
+ die0 = _extract_bits(bits, 15, 3)
363
+ # Bits 18-20: Die 1
364
+ die1 = _extract_bits(bits, 18, 3)
365
+
366
+ if die0 > 0 and die1 > 0:
367
+ metadata['dice'] = (die0, die1)
368
+
369
+ # Bits 21-35: Match length (15 bits)
370
+ match_length = _extract_bits(bits, 21, 15)
371
+ metadata['match_length'] = match_length
372
+
373
+ # Bits 36-50: Player 0 score (15 bits)
374
+ score_0 = _extract_bits(bits, 36, 15)
375
+ metadata['score_x'] = score_0 # Player 0 = X
376
+
377
+ # Bits 51-65: Player 1 score (15 bits)
378
+ score_1 = _extract_bits(bits, 51, 15)
379
+ metadata['score_o'] = score_1 # Player 1 = O
380
+
381
+ return metadata
382
+
383
+
384
+ def _extract_bits(bits: list, start: int, count: int) -> int:
385
+ """Extract an integer from a bit array."""
386
+ value = 0
387
+ for i in range(count):
388
+ if start + i < len(bits):
389
+ value |= (bits[start + i] << i)
390
+ return value
391
+
392
+
393
+ def encode_gnuid(
394
+ position: Position,
395
+ cube_value: int = 1,
396
+ cube_owner: CubeState = CubeState.CENTERED,
397
+ dice: Optional[Tuple[int, int]] = None,
398
+ on_roll: Player = Player.X,
399
+ score_x: int = 0,
400
+ score_o: int = 0,
401
+ match_length: int = 0,
402
+ crawford: bool = False,
403
+ only_position: bool = False,
404
+ ) -> str:
405
+ """
406
+ Encode a position and metadata as a GNUID string.
407
+
408
+ Args:
409
+ position: The position to encode
410
+ cube_value: Doubling cube value
411
+ cube_owner: Who owns the cube
412
+ dice: Dice values (if any)
413
+ on_roll: Player on roll
414
+ score_x: Player X's (player 0) score
415
+ score_o: Player O's (player 1) score
416
+ match_length: Match length (0 for money)
417
+ crawford: Crawford game flag
418
+ only_position: If True, only return Position ID (no Match ID)
419
+
420
+ Returns:
421
+ GNUID string (PositionID:MatchID or just PositionID)
422
+ """
423
+ # Encode position ID (always from Player X's perspective)
424
+ position_id = _encode_position_id(position)
425
+
426
+ if only_position:
427
+ return position_id
428
+
429
+ # Encode match ID
430
+ match_id = _encode_match_id(
431
+ cube_value=cube_value,
432
+ cube_owner=cube_owner,
433
+ dice=dice,
434
+ on_roll=on_roll,
435
+ score_x=score_x,
436
+ score_o=score_o,
437
+ match_length=match_length,
438
+ crawford=crawford,
439
+ )
440
+
441
+ return f"{position_id}:{match_id}"
442
+
443
+
444
+ def _encode_position_id(position: Position) -> str:
445
+ """
446
+ Encode a Position into a 14-character Position ID.
447
+
448
+ Args:
449
+ position: The position to encode
450
+
451
+ Returns:
452
+ 14-character Base64 Position ID
453
+ """
454
+ # Convert our position to TanBoard structure
455
+ anBoard = _convert_position_to_tanboard(position)
456
+
457
+ # Build bit string - ALL player 0 points, then ALL player 1 points
458
+ bits = []
459
+
460
+ for player in range(2):
461
+ for point in range(25):
462
+ # Add checkers as 1s
463
+ for _ in range(anBoard[player][point]):
464
+ bits.append(1)
465
+ # Add separator 0
466
+ bits.append(0)
467
+
468
+ # Pad to 80 bits
469
+ while len(bits) < 80:
470
+ bits.append(0)
471
+
472
+ # Pack into 10 bytes (little-endian)
473
+ position_bytes = bytearray(10)
474
+ for i, bit in enumerate(bits[:80]):
475
+ byte_idx = i // 8
476
+ bit_idx = i % 8
477
+ position_bytes[byte_idx] |= (bit << bit_idx)
478
+
479
+ # Base64 encode (remove padding)
480
+ position_id = base64.b64encode(bytes(position_bytes)).decode('ascii').rstrip('=')
481
+
482
+ return position_id
483
+
484
+
485
+ def _convert_position_to_gnuid(position: Position, on_roll: Player) -> Tuple[list, list]:
486
+ """
487
+ Convert our internal Position to GNUID perspective arrays.
488
+
489
+ Args:
490
+ position: Our internal position
491
+ on_roll: Player on roll
492
+
493
+ Returns:
494
+ Tuple of (player_checkers[26], opponent_checkers[26])
495
+ """
496
+ player_checkers = [0] * 26
497
+ opponent_checkers = [0] * 26
498
+
499
+ if on_roll == Player.X:
500
+ # X is on roll
501
+ # GNUID point 0 = our point 24 (X's ace)
502
+ # GNUID point 23 = our point 1 (O's ace)
503
+ # GNUID point 24 = our point 0 (X's bar)
504
+ # GNUID point 25 = our point 25 (O's bar)
505
+
506
+ # Map board points (reverse)
507
+ for our_pt in range(1, 25):
508
+ gnuid_pt = 24 - our_pt
509
+ count = position.points[our_pt]
510
+ if count > 0:
511
+ player_checkers[gnuid_pt] = count # X checkers
512
+ elif count < 0:
513
+ opponent_checkers[gnuid_pt] = -count # O checkers
514
+
515
+ # Map bars
516
+ player_checkers[24] = position.points[0] # X's bar
517
+ opponent_checkers[25] = -position.points[25] # O's bar
518
+
519
+ else:
520
+ # O is on roll
521
+ # GNUID point 0 = our point 1 (O's ace)
522
+ # GNUID point 23 = our point 24 (X's ace)
523
+ # GNUID point 24 = our point 25 (O's bar)
524
+ # GNUID point 25 = our point 0 (X's bar)
525
+
526
+ # Map board points (direct)
527
+ for our_pt in range(1, 25):
528
+ gnuid_pt = our_pt - 1
529
+ count = position.points[our_pt]
530
+ if count < 0:
531
+ player_checkers[gnuid_pt] = -count # O checkers
532
+ elif count > 0:
533
+ opponent_checkers[gnuid_pt] = count # X checkers
534
+
535
+ # Map bars
536
+ player_checkers[24] = -position.points[25] # O's bar
537
+ opponent_checkers[25] = position.points[0] # X's bar
538
+
539
+ return player_checkers, opponent_checkers
540
+
541
+
542
+ def _encode_match_id(
543
+ cube_value: int,
544
+ cube_owner: CubeState,
545
+ dice: Optional[Tuple[int, int]],
546
+ on_roll: Player,
547
+ score_x: int,
548
+ score_o: int,
549
+ match_length: int,
550
+ crawford: bool,
551
+ ) -> str:
552
+ """
553
+ Encode match metadata into a 12-character Match ID.
554
+
555
+ Returns:
556
+ 12-character Base64 Match ID
557
+ """
558
+ # Build 72-bit array
559
+ bits = [0] * 72
560
+
561
+ # Bits 0-3: Cube value (log2)
562
+ cube_log = 0
563
+ temp = cube_value
564
+ while temp > 1:
565
+ temp //= 2
566
+ cube_log += 1
567
+ _set_bits(bits, 0, 4, cube_log)
568
+
569
+ # Bits 4-5: Cube owner
570
+ if cube_owner == CubeState.CENTERED:
571
+ cube_owner_val = 3
572
+ elif cube_owner == CubeState.X_OWNS:
573
+ cube_owner_val = 0 # Player 0
574
+ else:
575
+ cube_owner_val = 1 # Player 1
576
+ _set_bits(bits, 4, 2, cube_owner_val)
577
+
578
+ # Bit 6: Move (0 for now)
579
+ # Bit 7: Crawford
580
+ bits[7] = 1 if crawford else 0
581
+
582
+ # Bits 8-10: Game state (1 = playing)
583
+ _set_bits(bits, 8, 3, 1)
584
+
585
+ # Bit 11: Turn
586
+ bits[11] = 1 if on_roll == Player.O else 0
587
+
588
+ # Bit 12: Doubled (0 for now)
589
+ # Bits 13-14: Resigned (0 for now)
590
+
591
+ # Bits 15-17: Die 0
592
+ # Bits 18-20: Die 1
593
+ if dice:
594
+ _set_bits(bits, 15, 3, dice[0])
595
+ _set_bits(bits, 18, 3, dice[1])
596
+
597
+ # Bits 21-35: Match length
598
+ _set_bits(bits, 21, 15, match_length)
599
+
600
+ # Bits 36-50: Player 0 score
601
+ _set_bits(bits, 36, 15, score_x)
602
+
603
+ # Bits 51-65: Player 1 score
604
+ _set_bits(bits, 51, 15, score_o)
605
+
606
+ # Pack into 9 bytes
607
+ match_bytes = bytearray(9)
608
+ for i in range(72):
609
+ byte_idx = i // 8
610
+ bit_idx = i % 8
611
+ match_bytes[byte_idx] |= (bits[i] << bit_idx)
612
+
613
+ # Base64 encode (remove padding)
614
+ match_id = base64.b64encode(bytes(match_bytes)).decode('ascii').rstrip('=')
615
+
616
+ return match_id
617
+
618
+
619
+ def _set_bits(bits: list, start: int, count: int, value: int):
620
+ """Set bits in a bit array from an integer value."""
621
+ for i in range(count):
622
+ bits[start + i] = (value >> i) & 1