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,1094 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GNU Backgammon match text export parser.
|
|
3
|
+
|
|
4
|
+
Parses 'export match text' output from gnubg into Decision objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import List, Optional, Tuple, Dict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ankigammon.models import Decision, DecisionType, Move, Player, Position, CubeState
|
|
12
|
+
from ankigammon.utils.gnuid import parse_gnuid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GNUBGMatchParser:
|
|
16
|
+
"""Parse GNU Backgammon 'export match text' output."""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def extract_player_names_from_mat(mat_file_path: str) -> Tuple[str, str]:
|
|
20
|
+
"""
|
|
21
|
+
Extract player names from .mat file header.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
mat_file_path: Path to .mat file
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Tuple of (player1_name, player2_name)
|
|
28
|
+
Defaults to ("Player 1", "Player 2") if not found
|
|
29
|
+
"""
|
|
30
|
+
player1 = "Player 1"
|
|
31
|
+
player2 = "Player 2"
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(mat_file_path, 'r', encoding='utf-8') as f:
|
|
35
|
+
# Read first 1000 characters (header section)
|
|
36
|
+
header = f.read(1000)
|
|
37
|
+
|
|
38
|
+
# Format 1: Semicolon header (OpenGammon, Backgammon Studio)
|
|
39
|
+
player1_match = re.search(r';\s*\[Player 1\s+"([^"]+)"\]', header, re.IGNORECASE)
|
|
40
|
+
player2_match = re.search(r';\s*\[Player 2\s+"([^"]+)"\]', header, re.IGNORECASE)
|
|
41
|
+
|
|
42
|
+
if player1_match:
|
|
43
|
+
player1 = player1_match.group(1)
|
|
44
|
+
if player2_match:
|
|
45
|
+
player2 = player2_match.group(1)
|
|
46
|
+
|
|
47
|
+
# Format 2: Score line (plain text match files)
|
|
48
|
+
if player1 == "Player 1" or player2 == "Player 2":
|
|
49
|
+
score_match = re.search(
|
|
50
|
+
r'^\s*([A-Za-z0-9_]+)\s*:\s*\d+\s+([A-Za-z0-9_]+)\s*:\s*\d+',
|
|
51
|
+
header,
|
|
52
|
+
re.MULTILINE
|
|
53
|
+
)
|
|
54
|
+
if score_match:
|
|
55
|
+
player1 = score_match.group(1)
|
|
56
|
+
player2 = score_match.group(2)
|
|
57
|
+
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return player1, player2
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def parse_match_files(file_paths: List[str], is_sgf_source: bool = False) -> List[Decision]:
|
|
65
|
+
"""
|
|
66
|
+
Parse multiple gnubg match export files into Decision objects.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
file_paths: List of paths to text files (one per game)
|
|
70
|
+
is_sgf_source: True if original source was SGF file (scores need swapping)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of Decision objects for all positions with analysis
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If parsing fails
|
|
77
|
+
"""
|
|
78
|
+
import logging
|
|
79
|
+
logger = logging.getLogger(__name__)
|
|
80
|
+
|
|
81
|
+
all_decisions = []
|
|
82
|
+
|
|
83
|
+
logger.info(f"\n=== Parsing {len(file_paths)} game files ===")
|
|
84
|
+
for i, file_path in enumerate(file_paths, 1):
|
|
85
|
+
logger.info(f"\nGame {i}: {Path(file_path).name}")
|
|
86
|
+
decisions = GNUBGMatchParser.parse_file(file_path, is_sgf_source=is_sgf_source)
|
|
87
|
+
logger.info(f" Parsed {len(decisions)} decisions")
|
|
88
|
+
|
|
89
|
+
# Show cube decisions for debugging
|
|
90
|
+
cube_decisions = [d for d in decisions if d.decision_type == DecisionType.CUBE_ACTION]
|
|
91
|
+
logger.info(f" Found {len(cube_decisions)} cube decisions")
|
|
92
|
+
if cube_decisions:
|
|
93
|
+
for cd in cube_decisions:
|
|
94
|
+
attr = cd.get_cube_error_attribution()
|
|
95
|
+
doubler_err = attr['doubler_error']
|
|
96
|
+
responder_err = attr['responder_error']
|
|
97
|
+
logger.info(f" Move {cd.move_number}: doubler={cd.on_roll}, doubler_error={doubler_err}, responder_error={responder_err}")
|
|
98
|
+
|
|
99
|
+
all_decisions.extend(decisions)
|
|
100
|
+
|
|
101
|
+
logger.info(f"\n=== Total: {len(all_decisions)} decisions from all games ===\n")
|
|
102
|
+
return all_decisions
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def parse_file(file_path: str, is_sgf_source: bool = False) -> List[Decision]:
|
|
106
|
+
"""
|
|
107
|
+
Parse single gnubg match export file.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
file_path: Path to text file
|
|
111
|
+
is_sgf_source: True if original source was SGF file (scores need swapping)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of Decision objects
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: If parsing fails
|
|
118
|
+
"""
|
|
119
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
120
|
+
content = f.read()
|
|
121
|
+
|
|
122
|
+
# Extract match metadata
|
|
123
|
+
metadata = GNUBGMatchParser._parse_match_metadata(content)
|
|
124
|
+
metadata['is_sgf_source'] = is_sgf_source
|
|
125
|
+
|
|
126
|
+
# Parse all positions in the file
|
|
127
|
+
decisions = GNUBGMatchParser._parse_positions(content, metadata)
|
|
128
|
+
|
|
129
|
+
return decisions
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _get_scores_from_metadata(pos_metadata: Dict, is_sgf_source: bool) -> Tuple[int, int]:
|
|
133
|
+
"""
|
|
134
|
+
Extract scores from GNUID metadata, swapping for SGF sources.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
pos_metadata: Metadata dict from GNUID parsing
|
|
138
|
+
is_sgf_source: True if original source was SGF file
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Tuple of (score_x, score_o) correctly mapped for the source type
|
|
142
|
+
"""
|
|
143
|
+
score_x = pos_metadata.get('score_x', 0)
|
|
144
|
+
score_o = pos_metadata.get('score_o', 0)
|
|
145
|
+
|
|
146
|
+
# Swap scores for SGF sources due to different player encodings
|
|
147
|
+
if is_sgf_source:
|
|
148
|
+
score_x, score_o = score_o, score_x
|
|
149
|
+
|
|
150
|
+
return score_x, score_o
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _parse_match_metadata(text: str) -> Dict:
|
|
154
|
+
"""
|
|
155
|
+
Parse match metadata from header.
|
|
156
|
+
|
|
157
|
+
Format:
|
|
158
|
+
The score (after 0 games) is: chrhaase 0, Deinonychus 0 (match to 7 points)
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Dictionary with player names and match length
|
|
162
|
+
"""
|
|
163
|
+
metadata = {
|
|
164
|
+
'player_o_name': None,
|
|
165
|
+
'player_x_name': None,
|
|
166
|
+
'match_length': 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Parse score line
|
|
170
|
+
score_match = re.search(
|
|
171
|
+
r'The score.*?is:\s*(\w+)\s+(\d+),\s*(\w+)\s+(\d+)\s*\(match to (\d+) point',
|
|
172
|
+
text
|
|
173
|
+
)
|
|
174
|
+
if score_match:
|
|
175
|
+
metadata['player_o_name'] = score_match.group(1)
|
|
176
|
+
metadata['player_x_name'] = score_match.group(3)
|
|
177
|
+
metadata['match_length'] = int(score_match.group(5))
|
|
178
|
+
|
|
179
|
+
return metadata
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _parse_positions(text: str, metadata: Dict) -> List[Decision]:
|
|
183
|
+
"""
|
|
184
|
+
Parse all positions from match text.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
text: Full match text export
|
|
188
|
+
metadata: Match metadata (player names, match length)
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of Decision objects
|
|
192
|
+
"""
|
|
193
|
+
decisions = []
|
|
194
|
+
lines = text.split('\n')
|
|
195
|
+
i = 0
|
|
196
|
+
|
|
197
|
+
while i < len(lines):
|
|
198
|
+
line = lines[i]
|
|
199
|
+
|
|
200
|
+
# Look for move number header with dice roll
|
|
201
|
+
# Format: "Move number 1: Deinonychus to play 64"
|
|
202
|
+
move_match = re.match(r'Move number (\d+):\s+(\w+) to play (\d)(\d)', line)
|
|
203
|
+
if move_match:
|
|
204
|
+
try:
|
|
205
|
+
# Parse checker play decision
|
|
206
|
+
checker_decision = GNUBGMatchParser._parse_position(
|
|
207
|
+
lines, i, metadata
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Also check for cube decision in this move
|
|
211
|
+
cube_decision = GNUBGMatchParser._parse_cube_decision(
|
|
212
|
+
lines, i, metadata
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if cube_decision:
|
|
216
|
+
decisions.append(cube_decision)
|
|
217
|
+
if checker_decision:
|
|
218
|
+
decisions.append(checker_decision)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
import logging
|
|
221
|
+
logger = logging.getLogger(__name__)
|
|
222
|
+
logger.warning(f"Failed to parse position at line {i}: {e}")
|
|
223
|
+
|
|
224
|
+
# Also look for cube-only moves (no dice roll)
|
|
225
|
+
# Format: "Move number 24: De_Luci on roll, cube decision?"
|
|
226
|
+
# Format: "Move number 25: De_Luci doubles to 2"
|
|
227
|
+
cube_only_match = re.match(r'Move number (\d+):\s+(\w+)(?:\s+on roll,\s+cube decision\?|\s+doubles)', line)
|
|
228
|
+
if cube_only_match:
|
|
229
|
+
try:
|
|
230
|
+
cube_decision = GNUBGMatchParser._parse_cube_decision_standalone(
|
|
231
|
+
lines, i, metadata
|
|
232
|
+
)
|
|
233
|
+
if cube_decision:
|
|
234
|
+
decisions.append(cube_decision)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
import logging
|
|
237
|
+
logger = logging.getLogger(__name__)
|
|
238
|
+
logger.warning(f"Failed to parse cube decision at line {i}: {e}")
|
|
239
|
+
|
|
240
|
+
i += 1
|
|
241
|
+
|
|
242
|
+
return decisions
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _parse_cube_decision(
|
|
246
|
+
lines: List[str],
|
|
247
|
+
start_idx: int,
|
|
248
|
+
metadata: Dict
|
|
249
|
+
) -> Optional[Decision]:
|
|
250
|
+
"""
|
|
251
|
+
Parse cube decision if present in this move.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
lines: All lines from file
|
|
255
|
+
start_idx: Index of "Move number X:" line
|
|
256
|
+
metadata: Match metadata
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Decision object for cube action or None if no cube decision found
|
|
260
|
+
"""
|
|
261
|
+
# Extract move number and player
|
|
262
|
+
move_line = lines[start_idx]
|
|
263
|
+
move_match = re.match(r'Move number (\d+):\s+(\w+) to play (\d)(\d)', move_line)
|
|
264
|
+
if not move_match:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
move_number = int(move_match.group(1))
|
|
268
|
+
player_name = move_match.group(2)
|
|
269
|
+
dice1 = int(move_match.group(3))
|
|
270
|
+
dice2 = int(move_match.group(4))
|
|
271
|
+
|
|
272
|
+
# Determine which player
|
|
273
|
+
on_roll = Player.O if player_name == metadata['player_o_name'] else Player.X
|
|
274
|
+
|
|
275
|
+
# Look for "Cube analysis" section
|
|
276
|
+
cube_section_idx = None
|
|
277
|
+
for offset in range(1, 50):
|
|
278
|
+
if start_idx + offset >= len(lines):
|
|
279
|
+
break
|
|
280
|
+
line = lines[start_idx + offset]
|
|
281
|
+
if line.strip() == "Cube analysis":
|
|
282
|
+
cube_section_idx = start_idx + offset
|
|
283
|
+
break
|
|
284
|
+
# Stop if we reach the next move or "Rolled XX:"
|
|
285
|
+
if line.startswith('Move number') or re.match(r'Rolled \d\d', line):
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
if cube_section_idx is None:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
# Find Position ID and Match ID
|
|
292
|
+
position_id = None
|
|
293
|
+
match_id = None
|
|
294
|
+
for offset in range(1, 30):
|
|
295
|
+
if start_idx + offset >= len(lines):
|
|
296
|
+
break
|
|
297
|
+
line = lines[start_idx + offset]
|
|
298
|
+
if 'Position ID:' in line:
|
|
299
|
+
pos_match = re.search(r'Position ID:\s+([A-Za-z0-9+/=]+)', line)
|
|
300
|
+
if pos_match:
|
|
301
|
+
position_id = pos_match.group(1)
|
|
302
|
+
elif 'Match ID' in line:
|
|
303
|
+
mat_match = re.search(r'Match ID\s*:\s+([A-Za-z0-9+/=]+)', line)
|
|
304
|
+
if mat_match:
|
|
305
|
+
match_id = mat_match.group(1)
|
|
306
|
+
|
|
307
|
+
if not position_id:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
# Parse position from GNUID
|
|
311
|
+
try:
|
|
312
|
+
position, pos_metadata = parse_gnuid(position_id + ":" + match_id if match_id else position_id)
|
|
313
|
+
except:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
# Get scores (swap if SGF source)
|
|
317
|
+
score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
|
|
318
|
+
pos_metadata, metadata.get('is_sgf_source', False)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Generate XGID for score matrix support
|
|
322
|
+
xgid = position.to_xgid(
|
|
323
|
+
cube_value=pos_metadata.get('cube_value', 1),
|
|
324
|
+
cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
|
|
325
|
+
dice=None, # Cube decision happens before dice roll
|
|
326
|
+
on_roll=on_roll,
|
|
327
|
+
score_x=score_x,
|
|
328
|
+
score_o=score_o,
|
|
329
|
+
match_length=metadata.get('match_length', 0),
|
|
330
|
+
crawford_jacoby=1 if pos_metadata.get('crawford', False) else 0
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Extract winning chances from "Cube analysis" section
|
|
334
|
+
no_double_probs = None
|
|
335
|
+
for offset in range(1, 10):
|
|
336
|
+
if cube_section_idx + offset >= len(lines):
|
|
337
|
+
break
|
|
338
|
+
line = lines[cube_section_idx + offset]
|
|
339
|
+
|
|
340
|
+
if '1-ply cubeless equity' in line:
|
|
341
|
+
if cube_section_idx + offset + 1 < len(lines):
|
|
342
|
+
prob_line = lines[cube_section_idx + offset + 1]
|
|
343
|
+
prob_match = re.match(
|
|
344
|
+
r'\s*(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)\s+-\s+(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)',
|
|
345
|
+
prob_line
|
|
346
|
+
)
|
|
347
|
+
if prob_match:
|
|
348
|
+
no_double_probs = tuple(float(p) for p in prob_match.groups())
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
# Parse cube equities from "Cubeful equities:" section
|
|
352
|
+
equities = {}
|
|
353
|
+
proper_action = None
|
|
354
|
+
|
|
355
|
+
for offset in range(cube_section_idx - start_idx, cube_section_idx - start_idx + 20):
|
|
356
|
+
if start_idx + offset >= len(lines):
|
|
357
|
+
break
|
|
358
|
+
line = lines[start_idx + offset]
|
|
359
|
+
|
|
360
|
+
# Parse equity lines
|
|
361
|
+
# Format: "1. No double -0.014"
|
|
362
|
+
equity_match = re.match(r'\s*\d+\.\s+(.+?)\s+([+-]?\d+\.\d+)', line)
|
|
363
|
+
if equity_match:
|
|
364
|
+
action = equity_match.group(1).strip()
|
|
365
|
+
equity = float(equity_match.group(2))
|
|
366
|
+
equities[action] = equity
|
|
367
|
+
|
|
368
|
+
# Parse proper cube action
|
|
369
|
+
# Format: "Proper cube action: No double, take (26.0%)" or "Proper cube action: Double, take"
|
|
370
|
+
if 'Proper cube action:' in line:
|
|
371
|
+
proper_match = re.search(r'Proper cube action:\s+(.+?)(?:\s+\(|$)', line)
|
|
372
|
+
if proper_match:
|
|
373
|
+
proper_action = proper_match.group(1).strip()
|
|
374
|
+
|
|
375
|
+
# Stop at "Rolled XX:" line
|
|
376
|
+
if re.match(r'Rolled \d\d', line):
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
if not equities or not proper_action:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# Find cube error and which action was taken
|
|
383
|
+
cube_error = None
|
|
384
|
+
take_error = None
|
|
385
|
+
doubled = False
|
|
386
|
+
cube_action_taken = None
|
|
387
|
+
|
|
388
|
+
# Search for error message before "Cube analysis" section
|
|
389
|
+
for offset in range(0, cube_section_idx - start_idx + 20):
|
|
390
|
+
if start_idx + offset >= len(lines):
|
|
391
|
+
break
|
|
392
|
+
line = lines[start_idx + offset]
|
|
393
|
+
|
|
394
|
+
# Check for doubling action
|
|
395
|
+
double_match = re.search(r'\*\s+\w+\s+doubles', line)
|
|
396
|
+
if double_match:
|
|
397
|
+
doubled = True
|
|
398
|
+
|
|
399
|
+
# Check for response action
|
|
400
|
+
response_match = re.search(r'\*\s+\w+\s+(accepts|passes|rejects)', line)
|
|
401
|
+
if response_match:
|
|
402
|
+
action = response_match.group(1)
|
|
403
|
+
cube_action_taken = "passes" if action == "rejects" else action
|
|
404
|
+
|
|
405
|
+
# Look for cube error messages
|
|
406
|
+
cube_alert_match = re.search(r'Alert: (wrong take|bad double|wrong double|missed double|wrong pass)\s+\(\s*([+-]?\d+\.\d+)\s*\)', line, re.IGNORECASE)
|
|
407
|
+
if cube_alert_match:
|
|
408
|
+
error_type = cube_alert_match.group(1).lower()
|
|
409
|
+
error_value = abs(float(cube_alert_match.group(2)))
|
|
410
|
+
|
|
411
|
+
if "take" in error_type or "pass" in error_type:
|
|
412
|
+
take_error = error_value
|
|
413
|
+
elif "double" in error_type or "missed" in error_type:
|
|
414
|
+
cube_error = error_value
|
|
415
|
+
|
|
416
|
+
if re.match(r'Rolled \d\d', line):
|
|
417
|
+
break
|
|
418
|
+
if re.match(r'\s*GNU Backgammon\s+Position ID:', line):
|
|
419
|
+
if cube_error is not None:
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
# Only create decision if there was an error (either doubler or responder)
|
|
423
|
+
if (cube_error is None or cube_error == 0.0) and (take_error is None or take_error == 0.0):
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
# Create cube decision moves
|
|
427
|
+
from ankigammon.models import Move
|
|
428
|
+
candidate_moves = []
|
|
429
|
+
|
|
430
|
+
nd_equity = equities.get("No double", 0.0)
|
|
431
|
+
dt_equity = equities.get("Double, take", 0.0)
|
|
432
|
+
dp_equity = equities.get("Double, pass", 0.0)
|
|
433
|
+
|
|
434
|
+
best_equity = max(nd_equity, dt_equity, dp_equity)
|
|
435
|
+
|
|
436
|
+
# Determine which action was actually played
|
|
437
|
+
was_nd = not doubled
|
|
438
|
+
was_dt = doubled and cube_action_taken == "accepts"
|
|
439
|
+
was_dp = doubled and cube_action_taken == "passes"
|
|
440
|
+
|
|
441
|
+
# Default: if doubled but response unknown, assume take
|
|
442
|
+
if doubled and cube_action_taken is None:
|
|
443
|
+
was_dt = True
|
|
444
|
+
|
|
445
|
+
is_too_good = "too good" in proper_action.lower() if proper_action else False
|
|
446
|
+
|
|
447
|
+
# Create all 5 move options
|
|
448
|
+
candidate_moves.append(Move(
|
|
449
|
+
notation="No Double/Take",
|
|
450
|
+
equity=nd_equity,
|
|
451
|
+
error=abs(best_equity - nd_equity),
|
|
452
|
+
rank=1, # Will be recalculated
|
|
453
|
+
was_played=was_nd
|
|
454
|
+
))
|
|
455
|
+
|
|
456
|
+
candidate_moves.append(Move(
|
|
457
|
+
notation="Double/Take",
|
|
458
|
+
equity=dt_equity,
|
|
459
|
+
error=abs(best_equity - dt_equity),
|
|
460
|
+
rank=1, # Will be recalculated
|
|
461
|
+
was_played=was_dt and not is_too_good
|
|
462
|
+
))
|
|
463
|
+
|
|
464
|
+
candidate_moves.append(Move(
|
|
465
|
+
notation="Too Good/Take",
|
|
466
|
+
equity=dp_equity,
|
|
467
|
+
error=abs(best_equity - dp_equity),
|
|
468
|
+
rank=1,
|
|
469
|
+
was_played=was_dt and is_too_good,
|
|
470
|
+
from_xg_analysis=False
|
|
471
|
+
))
|
|
472
|
+
|
|
473
|
+
candidate_moves.append(Move(
|
|
474
|
+
notation="Too Good/Pass",
|
|
475
|
+
equity=dp_equity,
|
|
476
|
+
error=abs(best_equity - dp_equity),
|
|
477
|
+
rank=1,
|
|
478
|
+
was_played=was_dp and is_too_good,
|
|
479
|
+
from_xg_analysis=False
|
|
480
|
+
))
|
|
481
|
+
candidate_moves.append(Move(
|
|
482
|
+
notation="Double/Pass",
|
|
483
|
+
equity=dp_equity,
|
|
484
|
+
error=abs(best_equity - dp_equity),
|
|
485
|
+
rank=1, # Will be recalculated
|
|
486
|
+
was_played=was_dp and not is_too_good
|
|
487
|
+
))
|
|
488
|
+
|
|
489
|
+
# Determine best move based on proper action
|
|
490
|
+
if proper_action and "too good to double, pass" in proper_action.lower():
|
|
491
|
+
best_move_notation = "Too Good/Pass"
|
|
492
|
+
best_equity_for_errors = nd_equity
|
|
493
|
+
elif proper_action and "too good to double, take" in proper_action.lower():
|
|
494
|
+
best_move_notation = "Too Good/Take"
|
|
495
|
+
best_equity_for_errors = nd_equity
|
|
496
|
+
elif proper_action and "no double" in proper_action.lower():
|
|
497
|
+
best_move_notation = "No Double/Take"
|
|
498
|
+
best_equity_for_errors = nd_equity
|
|
499
|
+
elif proper_action and "double, take" in proper_action.lower():
|
|
500
|
+
best_move_notation = "Double/Take"
|
|
501
|
+
best_equity_for_errors = dt_equity
|
|
502
|
+
elif proper_action and "double, pass" in proper_action.lower():
|
|
503
|
+
best_move_notation = "Double/Pass"
|
|
504
|
+
best_equity_for_errors = dp_equity
|
|
505
|
+
else:
|
|
506
|
+
best_move = max(candidate_moves, key=lambda m: m.equity)
|
|
507
|
+
best_move_notation = best_move.notation
|
|
508
|
+
best_equity_for_errors = best_move.equity
|
|
509
|
+
|
|
510
|
+
# Set ranks
|
|
511
|
+
for move in candidate_moves:
|
|
512
|
+
if move.notation == best_move_notation:
|
|
513
|
+
move.rank = 1
|
|
514
|
+
else:
|
|
515
|
+
better_count = sum(1 for m in candidate_moves
|
|
516
|
+
if m.notation != best_move_notation and m.equity > move.equity)
|
|
517
|
+
move.rank = 2 + better_count
|
|
518
|
+
|
|
519
|
+
# Recalculate errors based on best equity
|
|
520
|
+
for move in candidate_moves:
|
|
521
|
+
move.error = abs(best_equity_for_errors - move.equity)
|
|
522
|
+
|
|
523
|
+
# Sort by logical order for consistent display
|
|
524
|
+
order_map = {
|
|
525
|
+
"No Double/Take": 1,
|
|
526
|
+
"Double/Take": 2,
|
|
527
|
+
"Double/Pass": 3,
|
|
528
|
+
"Too Good/Take": 4,
|
|
529
|
+
"Too Good/Pass": 5
|
|
530
|
+
}
|
|
531
|
+
candidate_moves.sort(key=lambda m: order_map.get(m.notation, 99))
|
|
532
|
+
|
|
533
|
+
# Get scores (swap if SGF source)
|
|
534
|
+
score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
|
|
535
|
+
pos_metadata, metadata.get('is_sgf_source', False)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Create Decision object
|
|
539
|
+
decision = Decision(
|
|
540
|
+
position=position,
|
|
541
|
+
on_roll=on_roll,
|
|
542
|
+
dice=None, # Cube decision happens before dice roll
|
|
543
|
+
decision_type=DecisionType.CUBE_ACTION,
|
|
544
|
+
candidate_moves=candidate_moves,
|
|
545
|
+
score_x=score_x,
|
|
546
|
+
score_o=score_o,
|
|
547
|
+
match_length=metadata.get('match_length', 0),
|
|
548
|
+
cube_value=pos_metadata.get('cube_value', 1),
|
|
549
|
+
cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
|
|
550
|
+
crawford=pos_metadata.get('crawford', False),
|
|
551
|
+
xgid=xgid,
|
|
552
|
+
move_number=move_number,
|
|
553
|
+
cube_error=cube_error,
|
|
554
|
+
take_error=take_error,
|
|
555
|
+
player_win_pct=no_double_probs[0] * 100 if no_double_probs else None,
|
|
556
|
+
player_gammon_pct=no_double_probs[1] * 100 if no_double_probs else None,
|
|
557
|
+
player_backgammon_pct=no_double_probs[2] * 100 if no_double_probs else None,
|
|
558
|
+
opponent_win_pct=no_double_probs[3] * 100 if no_double_probs else None,
|
|
559
|
+
opponent_gammon_pct=no_double_probs[4] * 100 if no_double_probs else None,
|
|
560
|
+
opponent_backgammon_pct=no_double_probs[5] * 100 if no_double_probs else None
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
return decision
|
|
564
|
+
|
|
565
|
+
@staticmethod
|
|
566
|
+
def _parse_cube_decision_standalone(
|
|
567
|
+
lines: List[str],
|
|
568
|
+
start_idx: int,
|
|
569
|
+
metadata: Dict
|
|
570
|
+
) -> Optional[Decision]:
|
|
571
|
+
"""
|
|
572
|
+
Parse standalone cube decision (cube-only move with no checker play).
|
|
573
|
+
|
|
574
|
+
These moves have formats like:
|
|
575
|
+
- "Move number 24: De_Luci on roll, cube decision?"
|
|
576
|
+
- "Move number 25: De_Luci doubles to 2"
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
lines: All lines from file
|
|
580
|
+
start_idx: Index of "Move number X:" line
|
|
581
|
+
metadata: Match metadata
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
Decision object for cube action or None if no error found
|
|
585
|
+
"""
|
|
586
|
+
# Reuse the existing _parse_cube_decision logic
|
|
587
|
+
# but adapt the move number extraction
|
|
588
|
+
move_line = lines[start_idx]
|
|
589
|
+
|
|
590
|
+
# Extract move number and player name
|
|
591
|
+
# Handles both formats: "on roll, cube decision?" and "doubles to 2"
|
|
592
|
+
move_match = re.match(r'Move number (\d+):\s+(\w+)', move_line)
|
|
593
|
+
if not move_match:
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
move_number = int(move_match.group(1))
|
|
597
|
+
player_name = move_match.group(2)
|
|
598
|
+
|
|
599
|
+
# Determine which player
|
|
600
|
+
on_roll = Player.O if player_name == metadata['player_o_name'] else Player.X
|
|
601
|
+
|
|
602
|
+
# Look for "Cube analysis" section
|
|
603
|
+
cube_section_idx = None
|
|
604
|
+
for offset in range(1, 50):
|
|
605
|
+
if start_idx + offset >= len(lines):
|
|
606
|
+
break
|
|
607
|
+
line = lines[start_idx + offset]
|
|
608
|
+
if line.strip() == "Cube analysis":
|
|
609
|
+
cube_section_idx = start_idx + offset
|
|
610
|
+
break
|
|
611
|
+
# Stop if we reach the next move
|
|
612
|
+
if line.startswith('Move number'):
|
|
613
|
+
break
|
|
614
|
+
|
|
615
|
+
if cube_section_idx is None:
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
# Find Position ID and Match ID
|
|
619
|
+
position_id = None
|
|
620
|
+
match_id = None
|
|
621
|
+
for offset in range(1, 30):
|
|
622
|
+
if start_idx + offset >= len(lines):
|
|
623
|
+
break
|
|
624
|
+
line = lines[start_idx + offset]
|
|
625
|
+
if 'Position ID:' in line:
|
|
626
|
+
pos_match = re.search(r'Position ID:\s+([A-Za-z0-9+/=]+)', line)
|
|
627
|
+
if pos_match:
|
|
628
|
+
position_id = pos_match.group(1)
|
|
629
|
+
elif 'Match ID' in line:
|
|
630
|
+
mat_match = re.search(r'Match ID\s*:\s+([A-Za-z0-9+/=]+)', line)
|
|
631
|
+
if mat_match:
|
|
632
|
+
match_id = mat_match.group(1)
|
|
633
|
+
|
|
634
|
+
if not position_id:
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
# Parse position from GNUID
|
|
638
|
+
try:
|
|
639
|
+
position, pos_metadata = parse_gnuid(position_id + ":" + match_id if match_id else position_id)
|
|
640
|
+
except:
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
# Get scores (swap if SGF source)
|
|
644
|
+
score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
|
|
645
|
+
pos_metadata, metadata.get('is_sgf_source', False)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Generate XGID for score matrix support
|
|
649
|
+
xgid = position.to_xgid(
|
|
650
|
+
cube_value=pos_metadata.get('cube_value', 1),
|
|
651
|
+
cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
|
|
652
|
+
dice=None, # Cube decision happens before dice roll
|
|
653
|
+
on_roll=on_roll,
|
|
654
|
+
score_x=score_x,
|
|
655
|
+
score_o=score_o,
|
|
656
|
+
match_length=metadata.get('match_length', 0),
|
|
657
|
+
crawford_jacoby=1 if pos_metadata.get('crawford', False) else 0
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Extract winning chances from "Cube analysis" section
|
|
661
|
+
no_double_probs = None
|
|
662
|
+
for offset in range(1, 10):
|
|
663
|
+
if cube_section_idx + offset >= len(lines):
|
|
664
|
+
break
|
|
665
|
+
line = lines[cube_section_idx + offset]
|
|
666
|
+
|
|
667
|
+
if '1-ply cubeless equity' in line:
|
|
668
|
+
if cube_section_idx + offset + 1 < len(lines):
|
|
669
|
+
prob_line = lines[cube_section_idx + offset + 1]
|
|
670
|
+
prob_match = re.match(
|
|
671
|
+
r'\s*(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)\s+-\s+(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)',
|
|
672
|
+
prob_line
|
|
673
|
+
)
|
|
674
|
+
if prob_match:
|
|
675
|
+
no_double_probs = tuple(float(p) for p in prob_match.groups())
|
|
676
|
+
break
|
|
677
|
+
|
|
678
|
+
# Parse cube equities from "Cubeful equities:" section
|
|
679
|
+
equities = {}
|
|
680
|
+
proper_action = None
|
|
681
|
+
|
|
682
|
+
for offset in range(cube_section_idx - start_idx, cube_section_idx - start_idx + 20):
|
|
683
|
+
if start_idx + offset >= len(lines):
|
|
684
|
+
break
|
|
685
|
+
line = lines[start_idx + offset]
|
|
686
|
+
|
|
687
|
+
# Parse equity lines
|
|
688
|
+
# Format: "1. No double -0.014"
|
|
689
|
+
equity_match = re.match(r'\s*\d+\.\s+(.+?)\s+([+-]?\d+\.\d+)', line)
|
|
690
|
+
if equity_match:
|
|
691
|
+
action = equity_match.group(1).strip()
|
|
692
|
+
equity = float(equity_match.group(2))
|
|
693
|
+
equities[action] = equity
|
|
694
|
+
|
|
695
|
+
# Parse proper cube action
|
|
696
|
+
# Format: "Proper cube action: Double, pass"
|
|
697
|
+
if 'Proper cube action:' in line:
|
|
698
|
+
proper_match = re.search(r'Proper cube action:\s+(.+?)(?:\s+\(|$)', line)
|
|
699
|
+
if proper_match:
|
|
700
|
+
proper_action = proper_match.group(1).strip()
|
|
701
|
+
|
|
702
|
+
# Stop at next move
|
|
703
|
+
if line.startswith('Move number'):
|
|
704
|
+
break
|
|
705
|
+
|
|
706
|
+
if not equities or not proper_action:
|
|
707
|
+
return None
|
|
708
|
+
|
|
709
|
+
# Find cube error and which action was taken
|
|
710
|
+
cube_error = None # Doubler's error
|
|
711
|
+
take_error = None # Responder's error
|
|
712
|
+
doubled = False
|
|
713
|
+
cube_action_taken = None
|
|
714
|
+
|
|
715
|
+
# Search for error message before "Cube analysis" section
|
|
716
|
+
for offset in range(0, cube_section_idx - start_idx + 20):
|
|
717
|
+
if start_idx + offset >= len(lines):
|
|
718
|
+
break
|
|
719
|
+
line = lines[start_idx + offset]
|
|
720
|
+
|
|
721
|
+
# Check for doubling action
|
|
722
|
+
double_match = re.search(r'\*\s+\w+\s+doubles', line)
|
|
723
|
+
if double_match:
|
|
724
|
+
doubled = True
|
|
725
|
+
|
|
726
|
+
# Check for response action
|
|
727
|
+
response_match = re.search(r'\*\s+\w+\s+(accepts|passes|rejects)', line)
|
|
728
|
+
if response_match:
|
|
729
|
+
action = response_match.group(1)
|
|
730
|
+
cube_action_taken = "passes" if action == "rejects" else action
|
|
731
|
+
|
|
732
|
+
# Look for cube error messages
|
|
733
|
+
cube_alert_match = re.search(r'Alert: (wrong take|bad double|wrong double|missed double|wrong pass)\s+\(\s*([+-]?\d+\.\d+)\s*\)', line, re.IGNORECASE)
|
|
734
|
+
if cube_alert_match:
|
|
735
|
+
error_type = cube_alert_match.group(1).lower()
|
|
736
|
+
error_value = abs(float(cube_alert_match.group(2)))
|
|
737
|
+
|
|
738
|
+
if "take" in error_type or "pass" in error_type:
|
|
739
|
+
take_error = error_value
|
|
740
|
+
elif "double" in error_type or "missed" in error_type:
|
|
741
|
+
cube_error = error_value
|
|
742
|
+
|
|
743
|
+
# Stop when we encounter actual move content (board diagram or dice roll)
|
|
744
|
+
# Don't stop at "Move number" header - the error appears between the header and the content
|
|
745
|
+
if line.startswith('Rolled'):
|
|
746
|
+
break
|
|
747
|
+
if re.match(r'\s*GNU Backgammon\s+Position ID:', line):
|
|
748
|
+
# Found board diagram for next move, stop here
|
|
749
|
+
if cube_error is not None: # But only if we already found the error
|
|
750
|
+
break
|
|
751
|
+
|
|
752
|
+
# Only create decision if there was an error (either doubler or responder)
|
|
753
|
+
if (cube_error is None or cube_error == 0.0) and (take_error is None or take_error == 0.0):
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
# Create cube decision moves
|
|
757
|
+
from ankigammon.models import Move
|
|
758
|
+
candidate_moves = []
|
|
759
|
+
|
|
760
|
+
nd_equity = equities.get("No double", 0.0)
|
|
761
|
+
dt_equity = equities.get("Double, take", 0.0)
|
|
762
|
+
dp_equity = equities.get("Double, pass", 0.0)
|
|
763
|
+
|
|
764
|
+
best_equity = max(nd_equity, dt_equity, dp_equity)
|
|
765
|
+
|
|
766
|
+
# Determine which action was actually played
|
|
767
|
+
was_nd = not doubled
|
|
768
|
+
was_dt = doubled and cube_action_taken == "accepts"
|
|
769
|
+
was_dp = doubled and cube_action_taken == "passes"
|
|
770
|
+
|
|
771
|
+
# Default: if doubled but response unknown, assume take
|
|
772
|
+
if doubled and cube_action_taken is None:
|
|
773
|
+
was_dt = True
|
|
774
|
+
|
|
775
|
+
is_too_good = "too good" in proper_action.lower() if proper_action else False
|
|
776
|
+
|
|
777
|
+
# Create all 5 move options
|
|
778
|
+
candidate_moves.append(Move(
|
|
779
|
+
notation="No Double/Take",
|
|
780
|
+
equity=nd_equity,
|
|
781
|
+
error=abs(best_equity - nd_equity),
|
|
782
|
+
rank=1, # Will be recalculated
|
|
783
|
+
was_played=was_nd
|
|
784
|
+
))
|
|
785
|
+
|
|
786
|
+
candidate_moves.append(Move(
|
|
787
|
+
notation="Double/Take",
|
|
788
|
+
equity=dt_equity,
|
|
789
|
+
error=abs(best_equity - dt_equity),
|
|
790
|
+
rank=1, # Will be recalculated
|
|
791
|
+
was_played=was_dt and not is_too_good
|
|
792
|
+
))
|
|
793
|
+
|
|
794
|
+
candidate_moves.append(Move(
|
|
795
|
+
notation="Too Good/Take",
|
|
796
|
+
equity=dp_equity,
|
|
797
|
+
error=abs(best_equity - dp_equity),
|
|
798
|
+
rank=1,
|
|
799
|
+
was_played=was_dt and is_too_good,
|
|
800
|
+
from_xg_analysis=False
|
|
801
|
+
))
|
|
802
|
+
|
|
803
|
+
candidate_moves.append(Move(
|
|
804
|
+
notation="Too Good/Pass",
|
|
805
|
+
equity=dp_equity,
|
|
806
|
+
error=abs(best_equity - dp_equity),
|
|
807
|
+
rank=1,
|
|
808
|
+
was_played=was_dp and is_too_good,
|
|
809
|
+
from_xg_analysis=False
|
|
810
|
+
))
|
|
811
|
+
candidate_moves.append(Move(
|
|
812
|
+
notation="Double/Pass",
|
|
813
|
+
equity=dp_equity,
|
|
814
|
+
error=abs(best_equity - dp_equity),
|
|
815
|
+
rank=1, # Will be recalculated
|
|
816
|
+
was_played=was_dp and not is_too_good
|
|
817
|
+
))
|
|
818
|
+
|
|
819
|
+
# Determine best move based on proper action
|
|
820
|
+
if proper_action and "too good to double, pass" in proper_action.lower():
|
|
821
|
+
best_move_notation = "Too Good/Pass"
|
|
822
|
+
best_equity_for_errors = nd_equity
|
|
823
|
+
elif proper_action and "too good to double, take" in proper_action.lower():
|
|
824
|
+
best_move_notation = "Too Good/Take"
|
|
825
|
+
best_equity_for_errors = nd_equity
|
|
826
|
+
elif proper_action and "no double" in proper_action.lower():
|
|
827
|
+
best_move_notation = "No Double/Take"
|
|
828
|
+
best_equity_for_errors = nd_equity
|
|
829
|
+
elif proper_action and "double, take" in proper_action.lower():
|
|
830
|
+
best_move_notation = "Double/Take"
|
|
831
|
+
best_equity_for_errors = dt_equity
|
|
832
|
+
elif proper_action and "double, pass" in proper_action.lower():
|
|
833
|
+
best_move_notation = "Double/Pass"
|
|
834
|
+
best_equity_for_errors = dp_equity
|
|
835
|
+
else:
|
|
836
|
+
best_move = max(candidate_moves, key=lambda m: m.equity)
|
|
837
|
+
best_move_notation = best_move.notation
|
|
838
|
+
best_equity_for_errors = best_move.equity
|
|
839
|
+
|
|
840
|
+
# Set ranks
|
|
841
|
+
for move in candidate_moves:
|
|
842
|
+
if move.notation == best_move_notation:
|
|
843
|
+
move.rank = 1
|
|
844
|
+
else:
|
|
845
|
+
better_count = sum(1 for m in candidate_moves
|
|
846
|
+
if m.notation != best_move_notation and m.equity > move.equity)
|
|
847
|
+
move.rank = 2 + better_count
|
|
848
|
+
|
|
849
|
+
# Recalculate errors based on best equity
|
|
850
|
+
for move in candidate_moves:
|
|
851
|
+
move.error = abs(best_equity_for_errors - move.equity)
|
|
852
|
+
|
|
853
|
+
# Sort by logical order for consistent display
|
|
854
|
+
order_map = {
|
|
855
|
+
"No Double/Take": 1,
|
|
856
|
+
"Double/Take": 2,
|
|
857
|
+
"Double/Pass": 3,
|
|
858
|
+
"Too Good/Take": 4,
|
|
859
|
+
"Too Good/Pass": 5
|
|
860
|
+
}
|
|
861
|
+
candidate_moves.sort(key=lambda m: order_map.get(m.notation, 99))
|
|
862
|
+
|
|
863
|
+
# Get scores (swap if SGF source)
|
|
864
|
+
score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
|
|
865
|
+
pos_metadata, metadata.get('is_sgf_source', False)
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
# Create Decision object
|
|
869
|
+
decision = Decision(
|
|
870
|
+
position=position,
|
|
871
|
+
on_roll=on_roll,
|
|
872
|
+
dice=None, # Cube decision happens before dice roll
|
|
873
|
+
decision_type=DecisionType.CUBE_ACTION,
|
|
874
|
+
candidate_moves=candidate_moves,
|
|
875
|
+
score_x=score_x,
|
|
876
|
+
score_o=score_o,
|
|
877
|
+
match_length=metadata.get('match_length', 0),
|
|
878
|
+
cube_value=pos_metadata.get('cube_value', 1),
|
|
879
|
+
cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
|
|
880
|
+
crawford=pos_metadata.get('crawford', False),
|
|
881
|
+
xgid=xgid,
|
|
882
|
+
move_number=move_number,
|
|
883
|
+
cube_error=cube_error,
|
|
884
|
+
take_error=take_error,
|
|
885
|
+
player_win_pct=no_double_probs[0] * 100 if no_double_probs else None,
|
|
886
|
+
player_gammon_pct=no_double_probs[1] * 100 if no_double_probs else None,
|
|
887
|
+
player_backgammon_pct=no_double_probs[2] * 100 if no_double_probs else None,
|
|
888
|
+
opponent_win_pct=no_double_probs[3] * 100 if no_double_probs else None,
|
|
889
|
+
opponent_gammon_pct=no_double_probs[4] * 100 if no_double_probs else None,
|
|
890
|
+
opponent_backgammon_pct=no_double_probs[5] * 100 if no_double_probs else None
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
return decision
|
|
894
|
+
|
|
895
|
+
@staticmethod
|
|
896
|
+
def _parse_position(
|
|
897
|
+
lines: List[str],
|
|
898
|
+
start_idx: int,
|
|
899
|
+
metadata: Dict
|
|
900
|
+
) -> Optional[Decision]:
|
|
901
|
+
"""
|
|
902
|
+
Parse single position starting from move number line.
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
lines: All lines from file
|
|
906
|
+
start_idx: Index of "Move number X:" line
|
|
907
|
+
metadata: Match metadata
|
|
908
|
+
|
|
909
|
+
Returns:
|
|
910
|
+
Decision object or None if position has no analysis
|
|
911
|
+
"""
|
|
912
|
+
# Extract move number and player
|
|
913
|
+
move_line = lines[start_idx]
|
|
914
|
+
move_match = re.match(r'Move number (\d+):\s+(\w+) to play (\d)(\d)', move_line)
|
|
915
|
+
if not move_match:
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
move_number = int(move_match.group(1))
|
|
919
|
+
player_name = move_match.group(2)
|
|
920
|
+
dice1 = int(move_match.group(3))
|
|
921
|
+
dice2 = int(move_match.group(4))
|
|
922
|
+
|
|
923
|
+
# Determine which player
|
|
924
|
+
on_roll = Player.O if player_name == metadata['player_o_name'] else Player.X
|
|
925
|
+
|
|
926
|
+
# Find Position ID and Match ID lines
|
|
927
|
+
# Format: " GNU Backgammon Position ID: 4HPwATDgc/ABMA"
|
|
928
|
+
# " Match ID : cAjzAAAAAAAE"
|
|
929
|
+
position_id = None
|
|
930
|
+
match_id = None
|
|
931
|
+
for offset in range(1, 30): # Search next 30 lines
|
|
932
|
+
if start_idx + offset >= len(lines):
|
|
933
|
+
break
|
|
934
|
+
line = lines[start_idx + offset]
|
|
935
|
+
if 'Position ID:' in line:
|
|
936
|
+
pos_match = re.search(r'Position ID:\s+([A-Za-z0-9+/=]+)', line)
|
|
937
|
+
if pos_match:
|
|
938
|
+
position_id = pos_match.group(1)
|
|
939
|
+
elif 'Match ID' in line:
|
|
940
|
+
mat_match = re.search(r'Match ID\s*:\s+([A-Za-z0-9+/=]+)', line)
|
|
941
|
+
if mat_match:
|
|
942
|
+
match_id = mat_match.group(1)
|
|
943
|
+
|
|
944
|
+
if not position_id:
|
|
945
|
+
return None
|
|
946
|
+
|
|
947
|
+
# Parse position from GNUID
|
|
948
|
+
try:
|
|
949
|
+
position, pos_metadata = parse_gnuid(position_id + ":" + match_id if match_id else position_id)
|
|
950
|
+
except:
|
|
951
|
+
return None
|
|
952
|
+
|
|
953
|
+
# Get scores (swap if SGF source)
|
|
954
|
+
score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
|
|
955
|
+
pos_metadata, metadata.get('is_sgf_source', False)
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# Generate XGID for score matrix support
|
|
959
|
+
xgid = position.to_xgid(
|
|
960
|
+
cube_value=pos_metadata.get('cube_value', 1),
|
|
961
|
+
cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
|
|
962
|
+
dice=(dice1, dice2),
|
|
963
|
+
on_roll=on_roll,
|
|
964
|
+
score_x=score_x,
|
|
965
|
+
score_o=score_o,
|
|
966
|
+
match_length=metadata.get('match_length', 0),
|
|
967
|
+
crawford_jacoby=1 if pos_metadata.get('crawford', False) else 0
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
# Find the move that was played (marked with *)
|
|
971
|
+
# Format: "* Deinonychus moves 24/18 13/9"
|
|
972
|
+
move_played = None
|
|
973
|
+
for offset in range(1, 40):
|
|
974
|
+
if start_idx + offset >= len(lines):
|
|
975
|
+
break
|
|
976
|
+
line = lines[start_idx + offset]
|
|
977
|
+
if line.strip().startswith('*') and 'moves' in line:
|
|
978
|
+
move_match = re.search(r'\* \w+ moves (.+)', line)
|
|
979
|
+
if move_match:
|
|
980
|
+
move_played = move_match.group(1).strip()
|
|
981
|
+
break
|
|
982
|
+
|
|
983
|
+
# Find the error value
|
|
984
|
+
# Format: "Rolled 64 (+0.031):"
|
|
985
|
+
error = None
|
|
986
|
+
for offset in range(1, 50):
|
|
987
|
+
if start_idx + offset >= len(lines):
|
|
988
|
+
break
|
|
989
|
+
line = lines[start_idx + offset]
|
|
990
|
+
error_match = re.match(r'Rolled \d\d \(([+-]?\d+\.\d+)\):', line)
|
|
991
|
+
if error_match:
|
|
992
|
+
error = abs(float(error_match.group(1))) # Take absolute value
|
|
993
|
+
break
|
|
994
|
+
|
|
995
|
+
# If no error found, this position wasn't analyzed (skip it)
|
|
996
|
+
if error is None:
|
|
997
|
+
return None
|
|
998
|
+
|
|
999
|
+
# Parse candidate moves
|
|
1000
|
+
candidate_moves = []
|
|
1001
|
+
for offset in range(1, 100):
|
|
1002
|
+
if start_idx + offset >= len(lines):
|
|
1003
|
+
break
|
|
1004
|
+
line = lines[start_idx + offset]
|
|
1005
|
+
|
|
1006
|
+
# Check if we've reached next position
|
|
1007
|
+
if line.startswith('Move number'):
|
|
1008
|
+
break
|
|
1009
|
+
|
|
1010
|
+
# Parse move line
|
|
1011
|
+
move_match = re.match(
|
|
1012
|
+
r'\s*\*?\s*(\d+)\.\s+Cubeful\s+\d+-ply\s+(.+?)\s+Eq\.:\s+([+-]?\d+\.\d+)(?:\s+\(\s*([+-]?\d+\.\d+)\s*\))?',
|
|
1013
|
+
line
|
|
1014
|
+
)
|
|
1015
|
+
if move_match:
|
|
1016
|
+
rank = int(move_match.group(1))
|
|
1017
|
+
notation = move_match.group(2).strip()
|
|
1018
|
+
equity = float(move_match.group(3))
|
|
1019
|
+
move_error = float(move_match.group(4)) if move_match.group(4) else 0.0
|
|
1020
|
+
|
|
1021
|
+
was_played = (move_played and notation == move_played)
|
|
1022
|
+
|
|
1023
|
+
# Parse probabilities from next line
|
|
1024
|
+
probs = None
|
|
1025
|
+
if start_idx + offset + 1 < len(lines):
|
|
1026
|
+
prob_line = lines[start_idx + offset + 1]
|
|
1027
|
+
prob_match = re.match(
|
|
1028
|
+
r'\s*(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)\s+-\s+(0\.\d+)\s+(0\.\d+)\s+(0\.\d+)',
|
|
1029
|
+
prob_line
|
|
1030
|
+
)
|
|
1031
|
+
if prob_match:
|
|
1032
|
+
probs = tuple(float(p) for p in prob_match.groups())
|
|
1033
|
+
|
|
1034
|
+
# Create Move object
|
|
1035
|
+
move = Move(
|
|
1036
|
+
notation=notation,
|
|
1037
|
+
equity=equity,
|
|
1038
|
+
error=abs(move_error),
|
|
1039
|
+
rank=rank,
|
|
1040
|
+
was_played=was_played
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
# Add probabilities if found
|
|
1044
|
+
if probs:
|
|
1045
|
+
move.player_win_pct = probs[0]
|
|
1046
|
+
move.player_gammon_pct = probs[1]
|
|
1047
|
+
move.player_backgammon_pct = probs[2]
|
|
1048
|
+
move.opponent_win_pct = probs[3]
|
|
1049
|
+
move.opponent_gammon_pct = probs[4]
|
|
1050
|
+
move.opponent_backgammon_pct = probs[5]
|
|
1051
|
+
|
|
1052
|
+
candidate_moves.append(move)
|
|
1053
|
+
|
|
1054
|
+
if not candidate_moves:
|
|
1055
|
+
return None
|
|
1056
|
+
|
|
1057
|
+
# Get scores (swap if SGF source)
|
|
1058
|
+
score_x, score_o = GNUBGMatchParser._get_scores_from_metadata(
|
|
1059
|
+
pos_metadata, metadata.get('is_sgf_source', False)
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Create Decision object
|
|
1063
|
+
decision = Decision(
|
|
1064
|
+
position=position,
|
|
1065
|
+
on_roll=on_roll,
|
|
1066
|
+
dice=(dice1, dice2),
|
|
1067
|
+
decision_type=DecisionType.CHECKER_PLAY,
|
|
1068
|
+
candidate_moves=candidate_moves,
|
|
1069
|
+
score_x=score_x,
|
|
1070
|
+
score_o=score_o,
|
|
1071
|
+
match_length=metadata.get('match_length', 0),
|
|
1072
|
+
cube_value=pos_metadata.get('cube_value', 1),
|
|
1073
|
+
cube_owner=pos_metadata.get('cube_owner', CubeState.CENTERED),
|
|
1074
|
+
crawford=pos_metadata.get('crawford', False),
|
|
1075
|
+
xgid=xgid,
|
|
1076
|
+
move_number=move_number
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
return decision
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
# Helper function for easy import
|
|
1083
|
+
def parse_gnubg_match_files(file_paths: List[str], is_sgf_source: bool = False) -> List[Decision]:
|
|
1084
|
+
"""
|
|
1085
|
+
Parse gnubg match export files into Decision objects.
|
|
1086
|
+
|
|
1087
|
+
Args:
|
|
1088
|
+
file_paths: List of paths to exported text files
|
|
1089
|
+
is_sgf_source: True if original source was SGF file (scores need swapping)
|
|
1090
|
+
|
|
1091
|
+
Returns:
|
|
1092
|
+
List of Decision objects
|
|
1093
|
+
"""
|
|
1094
|
+
return GNUBGMatchParser.parse_match_files(file_paths, is_sgf_source=is_sgf_source)
|