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.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +373 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +224 -0
- ankigammon/anki/apkg_exporter.py +123 -0
- ankigammon/anki/card_generator.py +1307 -0
- ankigammon/anki/card_styles.py +1034 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +209 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +597 -0
- ankigammon/gui/dialogs/import_options_dialog.py +163 -0
- ankigammon/gui/dialogs/input_dialog.py +776 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +384 -0
- ankigammon/gui/format_detector.py +292 -0
- ankigammon/gui/main_window.py +1071 -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 +394 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +193 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +322 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_parser.py +454 -0
- ankigammon/parsers/xg_binary_parser.py +870 -0
- ankigammon/parsers/xg_text_parser.py +729 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +406 -0
- ankigammon/renderer/animation_helper.py +221 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +824 -0
- ankigammon/settings.py +239 -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 +431 -0
- ankigammon/utils/gnuid.py +622 -0
- ankigammon/utils/move_parser.py +239 -0
- ankigammon/utils/ogid.py +335 -0
- ankigammon/utils/xgid.py +419 -0
- ankigammon-1.0.0.dist-info/METADATA +370 -0
- ankigammon-1.0.0.dist-info/RECORD +56 -0
- ankigammon-1.0.0.dist-info/WHEEL +5 -0
- ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|