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
ankigammon/utils/xgid.py
ADDED
|
@@ -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)
|