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,239 @@
|
|
|
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
|
+
Use 0 for X bar, 25 for O bar, 26 for bearing off
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
"13/9 6/5" -> [(13, 9), (6, 5)]
|
|
26
|
+
"bar/22" -> [(0, 22)] # X entering from bar
|
|
27
|
+
"6/off" -> [(6, 26)] # Bearing off
|
|
28
|
+
"6/4(4)" -> [(6, 4), (6, 4), (6, 4), (6, 4)] # Repetition notation
|
|
29
|
+
"""
|
|
30
|
+
notation = notation.strip().lower()
|
|
31
|
+
|
|
32
|
+
# Handle special cases
|
|
33
|
+
if notation in ['double', 'take', 'drop', 'pass', 'accept', 'decline']:
|
|
34
|
+
return [] # Cube actions have no checker movement
|
|
35
|
+
|
|
36
|
+
moves = []
|
|
37
|
+
|
|
38
|
+
# Split by spaces or commas
|
|
39
|
+
parts = re.split(r'[\s,]+', notation)
|
|
40
|
+
|
|
41
|
+
for part in parts:
|
|
42
|
+
if not part or '/' not in part:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# Check for repetition notation like "6/4(4)" meaning "move 4 checkers from 6 to 4"
|
|
46
|
+
repetition_count = 1
|
|
47
|
+
repetition_match = re.search(r'\((\d+)\)$', part)
|
|
48
|
+
if repetition_match:
|
|
49
|
+
repetition_count = int(repetition_match.group(1))
|
|
50
|
+
# Remove the repetition notation from the part
|
|
51
|
+
part = re.sub(r'\(\d+\)$', '', part)
|
|
52
|
+
|
|
53
|
+
# Handle compound notation like "6/5*/3" or "24/23/22"
|
|
54
|
+
# Split by '/' and remove asterisks to get all points in the sequence
|
|
55
|
+
segments = part.split('/')
|
|
56
|
+
|
|
57
|
+
# Remove asterisks from each segment
|
|
58
|
+
segments = [seg.rstrip('*') for seg in segments]
|
|
59
|
+
|
|
60
|
+
# If there are more than 2 segments, this is compound notation
|
|
61
|
+
# Convert it to consecutive moves: "6/5/3" -> [(6,5), (5,3)]
|
|
62
|
+
if len(segments) > 2:
|
|
63
|
+
for i in range(len(segments) - 1):
|
|
64
|
+
from_str = segments[i]
|
|
65
|
+
to_str = segments[i + 1]
|
|
66
|
+
|
|
67
|
+
# Parse 'from' point
|
|
68
|
+
if 'bar' in from_str:
|
|
69
|
+
from_point = 0 # X bar (we'll adjust for O later)
|
|
70
|
+
else:
|
|
71
|
+
try:
|
|
72
|
+
from_point = int(from_str)
|
|
73
|
+
except ValueError:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Parse 'to' point
|
|
77
|
+
if 'off' in to_str:
|
|
78
|
+
to_point = 26 # Bearing off
|
|
79
|
+
elif 'bar' in to_str:
|
|
80
|
+
to_point = 0 # Will be adjusted based on context
|
|
81
|
+
else:
|
|
82
|
+
try:
|
|
83
|
+
to_point = int(to_str)
|
|
84
|
+
except ValueError:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Add the move repetition_count times
|
|
88
|
+
for _ in range(repetition_count):
|
|
89
|
+
moves.append((from_point, to_point))
|
|
90
|
+
else:
|
|
91
|
+
# Simple notation like "6/5" or "bar/22"
|
|
92
|
+
from_str = segments[0]
|
|
93
|
+
to_str = segments[1] if len(segments) > 1 else ''
|
|
94
|
+
|
|
95
|
+
if not to_str:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Parse 'from' point
|
|
99
|
+
if 'bar' in from_str:
|
|
100
|
+
from_point = 0 # X bar (we'll adjust for O later)
|
|
101
|
+
else:
|
|
102
|
+
try:
|
|
103
|
+
from_point = int(from_str)
|
|
104
|
+
except ValueError:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Parse 'to' point
|
|
108
|
+
if 'off' in to_str:
|
|
109
|
+
to_point = 26 # Bearing off
|
|
110
|
+
elif 'bar' in to_str:
|
|
111
|
+
# Hit - destination is the bar (rare in notation)
|
|
112
|
+
to_point = 0 # Will be adjusted based on context
|
|
113
|
+
else:
|
|
114
|
+
try:
|
|
115
|
+
to_point = int(to_str)
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Add the move repetition_count times (handles notation like "6/4(4)")
|
|
120
|
+
for _ in range(repetition_count):
|
|
121
|
+
moves.append((from_point, to_point))
|
|
122
|
+
|
|
123
|
+
return moves
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def apply_move(position: Position, notation: str, player: Player) -> Position:
|
|
127
|
+
"""
|
|
128
|
+
Apply a move to a position and return the resulting position.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
position: Initial position
|
|
132
|
+
notation: Move notation
|
|
133
|
+
player: Player making the move
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
New position after the move
|
|
137
|
+
"""
|
|
138
|
+
new_pos = position.copy()
|
|
139
|
+
moves = MoveParser.parse_move_notation(notation)
|
|
140
|
+
|
|
141
|
+
for from_point, to_point in moves:
|
|
142
|
+
# In backgammon notation, both players use the SAME numbering (1-24)
|
|
143
|
+
# The position array also uses this same numbering:
|
|
144
|
+
# - points[1] = point 1 (O's 1-point)
|
|
145
|
+
# - points[24] = point 24 (X's 1-point, O's 24-point)
|
|
146
|
+
#
|
|
147
|
+
# X moves from high numbers to low (24->1), O moves from low to high (1->24)
|
|
148
|
+
# No coordinate conversion is needed - notation matches position indices directly!
|
|
149
|
+
|
|
150
|
+
# The only special handling needed is for bar points:
|
|
151
|
+
# - X's bar is at position[0]
|
|
152
|
+
# - O's bar is at position[25]
|
|
153
|
+
# parse_move_notation() returns 0 for "bar", so correct it for O
|
|
154
|
+
if from_point == 0 and player == Player.O:
|
|
155
|
+
from_point = 25
|
|
156
|
+
if to_point == 0 and player == Player.X:
|
|
157
|
+
to_point = 25 # When X hits, opponent goes to bar 25
|
|
158
|
+
|
|
159
|
+
# Move checker
|
|
160
|
+
if from_point == 26: # Bearing off (from)
|
|
161
|
+
# This shouldn't happen in normal notation
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Remove from source
|
|
165
|
+
if new_pos.points[from_point] == 0:
|
|
166
|
+
# Invalid move - no checker to move
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if player == Player.X:
|
|
170
|
+
if new_pos.points[from_point] > 0:
|
|
171
|
+
new_pos.points[from_point] -= 1
|
|
172
|
+
else:
|
|
173
|
+
# Wrong player's checker
|
|
174
|
+
continue
|
|
175
|
+
else: # Player.O
|
|
176
|
+
if new_pos.points[from_point] < 0:
|
|
177
|
+
new_pos.points[from_point] += 1
|
|
178
|
+
else:
|
|
179
|
+
# Wrong player's checker
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Add to destination
|
|
183
|
+
if to_point == 26: # Bearing off
|
|
184
|
+
if player == Player.X:
|
|
185
|
+
new_pos.x_off += 1
|
|
186
|
+
else:
|
|
187
|
+
new_pos.o_off += 1
|
|
188
|
+
else:
|
|
189
|
+
# Check for hitting
|
|
190
|
+
target_count = new_pos.points[to_point]
|
|
191
|
+
|
|
192
|
+
if player == Player.X:
|
|
193
|
+
if target_count == -1:
|
|
194
|
+
# Hit O's blot
|
|
195
|
+
new_pos.points[25] -= 1 # Send to O's bar
|
|
196
|
+
new_pos.points[to_point] = 1
|
|
197
|
+
else:
|
|
198
|
+
new_pos.points[to_point] += 1
|
|
199
|
+
else: # Player.O
|
|
200
|
+
if target_count == 1:
|
|
201
|
+
# Hit X's blot
|
|
202
|
+
new_pos.points[0] += 1 # Send to X's bar
|
|
203
|
+
new_pos.points[to_point] = -1
|
|
204
|
+
else:
|
|
205
|
+
new_pos.points[to_point] -= 1
|
|
206
|
+
|
|
207
|
+
return new_pos
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def format_move(from_point: int, to_point: int, player: Player) -> str:
|
|
211
|
+
"""
|
|
212
|
+
Format a single move as notation.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
from_point: Source point (0-25, or 26 for bearing off)
|
|
216
|
+
to_point: Destination point
|
|
217
|
+
player: Player making the move
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Move notation string (e.g., "13/9", "bar/22", "6/off")
|
|
221
|
+
"""
|
|
222
|
+
# Handle special points
|
|
223
|
+
if from_point == 0 and player == Player.X:
|
|
224
|
+
from_str = "bar"
|
|
225
|
+
elif from_point == 25 and player == Player.O:
|
|
226
|
+
from_str = "bar"
|
|
227
|
+
else:
|
|
228
|
+
from_str = str(from_point)
|
|
229
|
+
|
|
230
|
+
if to_point == 26:
|
|
231
|
+
to_str = "off"
|
|
232
|
+
elif to_point == 0 and player == Player.O:
|
|
233
|
+
to_str = "bar"
|
|
234
|
+
elif to_point == 25 and player == Player.X:
|
|
235
|
+
to_str = "bar"
|
|
236
|
+
else:
|
|
237
|
+
to_str = str(to_point)
|
|
238
|
+
|
|
239
|
+
return f"{from_str}/{to_str}"
|
ankigammon/utils/ogid.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
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: 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
|
+
# Cube owner: W=White/X, B=Black/O, N=Neutral, D=Dead
|
|
108
|
+
if cube_owner_char == 'W':
|
|
109
|
+
metadata['cube_owner'] = CubeState.X_OWNS # White = X in our model
|
|
110
|
+
elif cube_owner_char == 'B':
|
|
111
|
+
metadata['cube_owner'] = CubeState.O_OWNS # Black = O in our model
|
|
112
|
+
elif cube_owner_char == 'N':
|
|
113
|
+
metadata['cube_owner'] = CubeState.CENTERED
|
|
114
|
+
else: # D or ?
|
|
115
|
+
metadata['cube_owner'] = CubeState.CENTERED # Default to centered
|
|
116
|
+
|
|
117
|
+
# Cube action (O=Offered, T=Taken, P=Passed, N=Normal)
|
|
118
|
+
metadata['cube_action'] = cube_action_char
|
|
119
|
+
|
|
120
|
+
# Parse optional fields
|
|
121
|
+
if len(parts) > 3 and parts[3]:
|
|
122
|
+
# Field 4: Dice
|
|
123
|
+
dice_str = parts[3]
|
|
124
|
+
if len(dice_str) == 2 and dice_str.isdigit():
|
|
125
|
+
d1 = int(dice_str[0])
|
|
126
|
+
d2 = int(dice_str[1])
|
|
127
|
+
if 1 <= d1 <= 6 and 1 <= d2 <= 6:
|
|
128
|
+
metadata['dice'] = (d1, d2)
|
|
129
|
+
|
|
130
|
+
if len(parts) > 4 and parts[4]:
|
|
131
|
+
# Field 5: Turn (W or B) - represents who is on roll
|
|
132
|
+
# W = White/X on roll, B = Black/O on roll
|
|
133
|
+
turn_str = parts[4].upper()
|
|
134
|
+
if turn_str == 'W':
|
|
135
|
+
metadata['on_roll'] = Player.X # White = X
|
|
136
|
+
elif turn_str == 'B':
|
|
137
|
+
metadata['on_roll'] = Player.O # Black = O
|
|
138
|
+
|
|
139
|
+
if len(parts) > 5 and parts[5]:
|
|
140
|
+
# Field 6: Game state (e.g., IW, FB)
|
|
141
|
+
metadata['game_state'] = parts[5]
|
|
142
|
+
|
|
143
|
+
if len(parts) > 6 and parts[6]:
|
|
144
|
+
# Field 7: White/X score
|
|
145
|
+
metadata['score_x'] = int(parts[6])
|
|
146
|
+
|
|
147
|
+
if len(parts) > 7 and parts[7]:
|
|
148
|
+
# Field 8: Black/O score
|
|
149
|
+
metadata['score_o'] = int(parts[7])
|
|
150
|
+
|
|
151
|
+
if len(parts) > 8 and parts[8]:
|
|
152
|
+
# Field 9: Match length (e.g., "7", "5C", "9G15")
|
|
153
|
+
match_str = parts[8]
|
|
154
|
+
match_regex = re.compile(r'(\d+)([LCG]?)(\d*)')
|
|
155
|
+
match = match_regex.match(match_str)
|
|
156
|
+
if match:
|
|
157
|
+
metadata['match_length'] = int(match.group(1))
|
|
158
|
+
if match.group(2):
|
|
159
|
+
metadata['match_modifier'] = match.group(2) # L, C, or G
|
|
160
|
+
if match.group(3):
|
|
161
|
+
metadata['match_max_games'] = int(match.group(3))
|
|
162
|
+
|
|
163
|
+
if len(parts) > 9 and parts[9]:
|
|
164
|
+
# Field 10: Move ID
|
|
165
|
+
metadata['move_id'] = int(parts[9])
|
|
166
|
+
|
|
167
|
+
if len(parts) > 10 and parts[10]:
|
|
168
|
+
# Field 11: Number of checkers per side (default 15)
|
|
169
|
+
metadata['num_checkers'] = int(parts[10])
|
|
170
|
+
|
|
171
|
+
return position, metadata
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_ogid_position(white_str: str, black_str: str) -> Position:
|
|
175
|
+
"""
|
|
176
|
+
Parse OGID position strings into a Position object.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
white_str: White/X checker positions (e.g., "11jjjjjhhhccccc")
|
|
180
|
+
black_str: Black/O checker positions (e.g., "ooddddd88866666")
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Position object with checkers placed
|
|
184
|
+
"""
|
|
185
|
+
position = Position()
|
|
186
|
+
|
|
187
|
+
# Parse White/X checkers (positive in our model)
|
|
188
|
+
for char in white_str:
|
|
189
|
+
point = _char_to_point(char)
|
|
190
|
+
position.points[point] += 1 # Positive for X
|
|
191
|
+
|
|
192
|
+
# Parse Black/O checkers (negative in our model)
|
|
193
|
+
for char in black_str:
|
|
194
|
+
point = _char_to_point(char)
|
|
195
|
+
position.points[point] -= 1 # Negative for O
|
|
196
|
+
|
|
197
|
+
# Calculate borne-off checkers (each player starts with 15)
|
|
198
|
+
total_x = sum(count for count in position.points if count > 0)
|
|
199
|
+
total_o = sum(abs(count) for count in position.points if count < 0)
|
|
200
|
+
|
|
201
|
+
position.x_off = 15 - total_x
|
|
202
|
+
position.o_off = 15 - total_o
|
|
203
|
+
|
|
204
|
+
return position
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def encode_ogid(
|
|
208
|
+
position: Position,
|
|
209
|
+
cube_value: int = 1,
|
|
210
|
+
cube_owner: CubeState = CubeState.CENTERED,
|
|
211
|
+
cube_action: str = 'N',
|
|
212
|
+
dice: Optional[Tuple[int, int]] = None,
|
|
213
|
+
on_roll: Optional[Player] = None,
|
|
214
|
+
game_state: str = '',
|
|
215
|
+
score_x: int = 0,
|
|
216
|
+
score_o: int = 0,
|
|
217
|
+
match_length: Optional[int] = None,
|
|
218
|
+
match_modifier: str = '',
|
|
219
|
+
match_max_games: Optional[int] = None,
|
|
220
|
+
move_id: Optional[int] = None,
|
|
221
|
+
num_checkers: Optional[int] = None,
|
|
222
|
+
only_position: bool = False,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""
|
|
225
|
+
Encode a position and metadata as an OGID string.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
position: The position to encode
|
|
229
|
+
cube_value: Doubling cube value
|
|
230
|
+
cube_owner: Who owns the cube
|
|
231
|
+
cube_action: Cube action (N=Normal, O=Offered, T=Taken, P=Passed)
|
|
232
|
+
dice: Dice values (if any)
|
|
233
|
+
on_roll: Player on roll
|
|
234
|
+
game_state: Game state code (e.g., "IW", "FB")
|
|
235
|
+
score_x: White/X player's score
|
|
236
|
+
score_o: Black/O player's score
|
|
237
|
+
match_length: Match length (points to win)
|
|
238
|
+
match_modifier: Match modifier (L, C, or G)
|
|
239
|
+
match_max_games: Max games for Galaxie format
|
|
240
|
+
move_id: Move sequence number
|
|
241
|
+
num_checkers: Number of checkers per side (only include if not 15)
|
|
242
|
+
only_position: If True, only encode position fields (1-3)
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
OGID string
|
|
246
|
+
"""
|
|
247
|
+
# Encode position strings
|
|
248
|
+
white_chars = [] # X/White checkers
|
|
249
|
+
black_chars = [] # O/Black checkers
|
|
250
|
+
|
|
251
|
+
for point_idx in range(26):
|
|
252
|
+
count = position.points[point_idx]
|
|
253
|
+
if count > 0:
|
|
254
|
+
# X/White checkers (positive)
|
|
255
|
+
white_chars.extend([_point_to_char(point_idx)] * count)
|
|
256
|
+
elif count < 0:
|
|
257
|
+
# O/Black checkers (negative)
|
|
258
|
+
black_chars.extend([_point_to_char(point_idx)] * abs(count))
|
|
259
|
+
|
|
260
|
+
# Sort characters (OGID format convention)
|
|
261
|
+
white_str = ''.join(sorted(white_chars))
|
|
262
|
+
black_str = ''.join(sorted(black_chars))
|
|
263
|
+
|
|
264
|
+
# Encode cube state (3 characters)
|
|
265
|
+
# Owner
|
|
266
|
+
if cube_owner == CubeState.X_OWNS:
|
|
267
|
+
cube_owner_char = 'W' # White = X
|
|
268
|
+
elif cube_owner == CubeState.O_OWNS:
|
|
269
|
+
cube_owner_char = 'B' # Black = O
|
|
270
|
+
else:
|
|
271
|
+
cube_owner_char = 'N' # Neutral
|
|
272
|
+
|
|
273
|
+
# Value as log2
|
|
274
|
+
cube_value_log = 0
|
|
275
|
+
temp = cube_value
|
|
276
|
+
while temp > 1:
|
|
277
|
+
temp //= 2
|
|
278
|
+
cube_value_log += 1
|
|
279
|
+
cube_value_char = str(cube_value_log)
|
|
280
|
+
|
|
281
|
+
# Action
|
|
282
|
+
cube_action_char = cube_action
|
|
283
|
+
|
|
284
|
+
cube_str = f"{cube_owner_char}{cube_value_char}{cube_action_char}"
|
|
285
|
+
|
|
286
|
+
# Build OGID string
|
|
287
|
+
ogid_parts = [white_str, black_str, cube_str]
|
|
288
|
+
|
|
289
|
+
# If only_position is True, return just the first 3 fields
|
|
290
|
+
if only_position:
|
|
291
|
+
return ':'.join(ogid_parts)
|
|
292
|
+
|
|
293
|
+
# Add optional fields
|
|
294
|
+
# Field 4: Dice
|
|
295
|
+
if dice:
|
|
296
|
+
ogid_parts.append(f"{dice[0]}{dice[1]}")
|
|
297
|
+
else:
|
|
298
|
+
ogid_parts.append('')
|
|
299
|
+
|
|
300
|
+
# Field 5: Turn
|
|
301
|
+
if on_roll:
|
|
302
|
+
turn_char = 'W' if on_roll == Player.X else 'B'
|
|
303
|
+
ogid_parts.append(turn_char)
|
|
304
|
+
else:
|
|
305
|
+
ogid_parts.append('')
|
|
306
|
+
|
|
307
|
+
# Field 6: Game state
|
|
308
|
+
ogid_parts.append(game_state)
|
|
309
|
+
|
|
310
|
+
# Field 7-8: Scores
|
|
311
|
+
ogid_parts.append(str(score_x))
|
|
312
|
+
ogid_parts.append(str(score_o))
|
|
313
|
+
|
|
314
|
+
# Field 9: Match length
|
|
315
|
+
if match_length is not None:
|
|
316
|
+
match_str = str(match_length)
|
|
317
|
+
if match_modifier:
|
|
318
|
+
match_str += match_modifier
|
|
319
|
+
if match_max_games is not None:
|
|
320
|
+
match_str += str(match_max_games)
|
|
321
|
+
ogid_parts.append(match_str)
|
|
322
|
+
else:
|
|
323
|
+
ogid_parts.append('')
|
|
324
|
+
|
|
325
|
+
# Field 10: Move ID
|
|
326
|
+
if move_id is not None:
|
|
327
|
+
ogid_parts.append(str(move_id))
|
|
328
|
+
else:
|
|
329
|
+
ogid_parts.append('')
|
|
330
|
+
|
|
331
|
+
# Field 11: Number of checkers (only if not 15)
|
|
332
|
+
if num_checkers is not None and num_checkers != 15:
|
|
333
|
+
ogid_parts.append(str(num_checkers))
|
|
334
|
+
|
|
335
|
+
return ':'.join(ogid_parts)
|