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