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,688 @@
|
|
|
1
|
+
"""Parser for XG text exports with ASCII board diagrams.
|
|
2
|
+
|
|
3
|
+
This parser handles the text format that XG exports with:
|
|
4
|
+
- XGID line
|
|
5
|
+
- ASCII board diagram
|
|
6
|
+
- Move analysis with equities and rollout data
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from ankigammon.models import Decision, Move, Position, Player, CubeState, DecisionType
|
|
13
|
+
from ankigammon.utils.xgid import parse_xgid
|
|
14
|
+
from ankigammon.utils.ogid import parse_ogid
|
|
15
|
+
from ankigammon.utils.gnuid import parse_gnuid
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class XGTextParser:
|
|
19
|
+
"""Parse XG text export format."""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def parse_file(file_path: str) -> List[Decision]:
|
|
23
|
+
"""
|
|
24
|
+
Parse an XG text export file.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_path: Path to XG text file
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of Decision objects
|
|
31
|
+
"""
|
|
32
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
33
|
+
content = f.read()
|
|
34
|
+
|
|
35
|
+
return XGTextParser.parse_string(content)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def parse_string(content: str) -> List[Decision]:
|
|
39
|
+
"""
|
|
40
|
+
Parse XG text export from string.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
content: Full text content
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of Decision objects
|
|
47
|
+
"""
|
|
48
|
+
decisions = []
|
|
49
|
+
|
|
50
|
+
# Split into sections by XGID, OGID, or GNUID patterns
|
|
51
|
+
# Pattern matches XGID=, OGID (base-26 format), or GNUID (base64 format)
|
|
52
|
+
sections = re.split(r'(XGID=[^\n]+|^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}[^\n]*|^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12})', content, flags=re.MULTILINE)
|
|
53
|
+
|
|
54
|
+
for i in range(1, len(sections), 2):
|
|
55
|
+
if i + 1 >= len(sections):
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
position_id_line = sections[i].strip()
|
|
59
|
+
analysis_section = sections[i + 1]
|
|
60
|
+
|
|
61
|
+
decision = XGTextParser._parse_decision_section(position_id_line, analysis_section)
|
|
62
|
+
if decision:
|
|
63
|
+
decisions.append(decision)
|
|
64
|
+
|
|
65
|
+
return decisions
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _parse_decision_section(position_id_line: str, analysis_section: str) -> Optional[Decision]:
|
|
69
|
+
"""Parse a single decision section."""
|
|
70
|
+
# Detect and parse position ID (XGID, OGID, or GNUID)
|
|
71
|
+
try:
|
|
72
|
+
# Check if it's XGID format
|
|
73
|
+
if position_id_line.startswith('XGID='):
|
|
74
|
+
position, metadata = parse_xgid(position_id_line)
|
|
75
|
+
position_id = position_id_line
|
|
76
|
+
# Check if it's OGID format (base-26 encoding)
|
|
77
|
+
elif re.match(r'^[0-9a-p]+:[0-9a-p]+:[A-Z0-9]{3}', position_id_line):
|
|
78
|
+
position, metadata = parse_ogid(position_id_line)
|
|
79
|
+
position_id = position_id_line
|
|
80
|
+
# Check if it's GNUID format (base64 encoding)
|
|
81
|
+
elif re.match(r'^[A-Za-z0-9+/]{14}:[A-Za-z0-9+/]{12}$', position_id_line):
|
|
82
|
+
position, metadata = parse_gnuid(position_id_line)
|
|
83
|
+
position_id = position_id_line
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"Unknown position ID format: {position_id_line}")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"Error parsing position ID '{position_id_line}': {e}")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# Parse game info (players, score, cube, etc.)
|
|
91
|
+
game_info = XGTextParser._parse_game_info(analysis_section)
|
|
92
|
+
if game_info:
|
|
93
|
+
# Update metadata with parsed info. XGID data takes precedence where it exists
|
|
94
|
+
# since it correctly accounts for perspective in all position encodings.
|
|
95
|
+
for key, value in game_info.items():
|
|
96
|
+
if key not in metadata or key == 'decision_type':
|
|
97
|
+
# Add values not present in XGID metadata
|
|
98
|
+
# decision_type can only come from text parsing
|
|
99
|
+
metadata[key] = value
|
|
100
|
+
|
|
101
|
+
# Parse move analysis
|
|
102
|
+
moves = XGTextParser._parse_moves(analysis_section)
|
|
103
|
+
# Note: Allow empty moves for XGID-only positions (gnubg can analyze them later)
|
|
104
|
+
# if not moves:
|
|
105
|
+
# return None
|
|
106
|
+
|
|
107
|
+
# Parse global winning chances (for cube decisions)
|
|
108
|
+
winning_chances = XGTextParser._parse_winning_chances(analysis_section)
|
|
109
|
+
|
|
110
|
+
# Determine decision type from metadata or dice presence
|
|
111
|
+
if 'decision_type' in metadata:
|
|
112
|
+
decision_type = metadata['decision_type']
|
|
113
|
+
elif 'dice' not in metadata or metadata.get('dice') is None:
|
|
114
|
+
decision_type = DecisionType.CUBE_ACTION
|
|
115
|
+
else:
|
|
116
|
+
decision_type = DecisionType.CHECKER_PLAY
|
|
117
|
+
|
|
118
|
+
# Determine Crawford status from multiple sources
|
|
119
|
+
# The crawford_jacoby field indicates Crawford rule for matches or Jacoby rule for money games
|
|
120
|
+
match_length = metadata.get('match_length', 0)
|
|
121
|
+
crawford = False
|
|
122
|
+
|
|
123
|
+
if match_length > 0:
|
|
124
|
+
if 'crawford' in metadata and metadata['crawford']:
|
|
125
|
+
crawford = True
|
|
126
|
+
elif 'crawford_jacoby' in metadata and metadata['crawford_jacoby'] > 0:
|
|
127
|
+
crawford = True
|
|
128
|
+
elif 'match_modifier' in metadata and metadata['match_modifier'] == 'C':
|
|
129
|
+
crawford = True
|
|
130
|
+
|
|
131
|
+
# Create decision
|
|
132
|
+
decision = Decision(
|
|
133
|
+
position=position,
|
|
134
|
+
xgid=position_id, # Store original position ID (XGID or OGID)
|
|
135
|
+
on_roll=metadata.get('on_roll', Player.O),
|
|
136
|
+
dice=metadata.get('dice'),
|
|
137
|
+
score_x=metadata.get('score_x', 0),
|
|
138
|
+
score_o=metadata.get('score_o', 0),
|
|
139
|
+
match_length=metadata.get('match_length', 0),
|
|
140
|
+
crawford=crawford,
|
|
141
|
+
cube_value=metadata.get('cube_value', 1),
|
|
142
|
+
cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
|
|
143
|
+
decision_type=decision_type,
|
|
144
|
+
candidate_moves=moves,
|
|
145
|
+
player_win_pct=winning_chances.get('player_win_pct'),
|
|
146
|
+
player_gammon_pct=winning_chances.get('player_gammon_pct'),
|
|
147
|
+
player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
|
|
148
|
+
opponent_win_pct=winning_chances.get('opponent_win_pct'),
|
|
149
|
+
opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
|
|
150
|
+
opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return decision
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _parse_winning_chances(text: str) -> dict:
|
|
157
|
+
"""
|
|
158
|
+
Parse global winning chances from text section.
|
|
159
|
+
|
|
160
|
+
Format:
|
|
161
|
+
Player Winning Chances: 52.68% (G:14.35% B:0.69%)
|
|
162
|
+
Opponent Winning Chances: 47.32% (G:12.42% B:0.55%)
|
|
163
|
+
|
|
164
|
+
Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
|
|
165
|
+
opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
|
|
166
|
+
"""
|
|
167
|
+
chances = {}
|
|
168
|
+
|
|
169
|
+
# Parse player winning chances
|
|
170
|
+
player_match = re.search(
|
|
171
|
+
r'Player Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
172
|
+
text,
|
|
173
|
+
re.IGNORECASE
|
|
174
|
+
)
|
|
175
|
+
if player_match:
|
|
176
|
+
chances['player_win_pct'] = float(player_match.group(1))
|
|
177
|
+
chances['player_gammon_pct'] = float(player_match.group(2))
|
|
178
|
+
chances['player_backgammon_pct'] = float(player_match.group(3))
|
|
179
|
+
|
|
180
|
+
# Parse opponent winning chances
|
|
181
|
+
opponent_match = re.search(
|
|
182
|
+
r'Opponent Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
183
|
+
text,
|
|
184
|
+
re.IGNORECASE
|
|
185
|
+
)
|
|
186
|
+
if opponent_match:
|
|
187
|
+
chances['opponent_win_pct'] = float(opponent_match.group(1))
|
|
188
|
+
chances['opponent_gammon_pct'] = float(opponent_match.group(2))
|
|
189
|
+
chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
|
|
190
|
+
|
|
191
|
+
return chances
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _parse_move_winning_chances(move_text: str) -> dict:
|
|
195
|
+
"""
|
|
196
|
+
Parse winning chances from a move's analysis section.
|
|
197
|
+
|
|
198
|
+
Format:
|
|
199
|
+
Player: 53.81% (G:17.42% B:0.87%)
|
|
200
|
+
Opponent: 46.19% (G:12.99% B:0.64%)
|
|
201
|
+
|
|
202
|
+
Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
|
|
203
|
+
opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
|
|
204
|
+
"""
|
|
205
|
+
chances = {}
|
|
206
|
+
|
|
207
|
+
# Parse player chances
|
|
208
|
+
player_match = re.search(
|
|
209
|
+
r'Player:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
210
|
+
move_text,
|
|
211
|
+
re.IGNORECASE
|
|
212
|
+
)
|
|
213
|
+
if player_match:
|
|
214
|
+
chances['player_win_pct'] = float(player_match.group(1))
|
|
215
|
+
chances['player_gammon_pct'] = float(player_match.group(2))
|
|
216
|
+
chances['player_backgammon_pct'] = float(player_match.group(3))
|
|
217
|
+
|
|
218
|
+
# Parse opponent chances
|
|
219
|
+
opponent_match = re.search(
|
|
220
|
+
r'Opponent:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
221
|
+
move_text,
|
|
222
|
+
re.IGNORECASE
|
|
223
|
+
)
|
|
224
|
+
if opponent_match:
|
|
225
|
+
chances['opponent_win_pct'] = float(opponent_match.group(1))
|
|
226
|
+
chances['opponent_gammon_pct'] = float(opponent_match.group(2))
|
|
227
|
+
chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
|
|
228
|
+
|
|
229
|
+
return chances
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _parse_game_info(text: str) -> dict:
|
|
233
|
+
"""
|
|
234
|
+
Parse game information from text section.
|
|
235
|
+
|
|
236
|
+
Extracts:
|
|
237
|
+
- Players (X:Player 2 O:Player 1)
|
|
238
|
+
- Score (Score is X:3 O:4 5 pt.(s) match.)
|
|
239
|
+
- Cube info (Cube: 2, O own cube)
|
|
240
|
+
- Turn info (X to play 63)
|
|
241
|
+
"""
|
|
242
|
+
info = {}
|
|
243
|
+
|
|
244
|
+
# Parse player designation and map to internal model
|
|
245
|
+
# Player 1 = BOTTOM player = Player.O
|
|
246
|
+
# Player 2 = TOP player = Player.X
|
|
247
|
+
xo_to_player = {}
|
|
248
|
+
player_designation = re.search(
|
|
249
|
+
r'([XO]):Player\s+(\d+)',
|
|
250
|
+
text,
|
|
251
|
+
re.IGNORECASE
|
|
252
|
+
)
|
|
253
|
+
if player_designation:
|
|
254
|
+
label = player_designation.group(1).upper() # 'X' or 'O'
|
|
255
|
+
player_num = int(player_designation.group(2)) # 1 or 2
|
|
256
|
+
|
|
257
|
+
# Map player number to internal representation
|
|
258
|
+
if player_num == 1:
|
|
259
|
+
xo_to_player[label] = Player.O
|
|
260
|
+
else:
|
|
261
|
+
xo_to_player[label] = Player.X
|
|
262
|
+
|
|
263
|
+
# Parse the other player
|
|
264
|
+
other_label = 'O' if label == 'X' else 'X'
|
|
265
|
+
other_player_designation = re.search(
|
|
266
|
+
rf'{other_label}:Player\s+(\d+)',
|
|
267
|
+
text,
|
|
268
|
+
re.IGNORECASE
|
|
269
|
+
)
|
|
270
|
+
if other_player_designation:
|
|
271
|
+
other_num = int(other_player_designation.group(1))
|
|
272
|
+
if other_num == 1:
|
|
273
|
+
xo_to_player[other_label] = Player.O
|
|
274
|
+
else:
|
|
275
|
+
xo_to_player[other_label] = Player.X
|
|
276
|
+
|
|
277
|
+
# Parse score and match length
|
|
278
|
+
# "Score is X:3 O:4 5 pt.(s) match."
|
|
279
|
+
score_match = re.search(
|
|
280
|
+
r'Score is X:(\d+)\s+O:(\d+)\s+(\d+)\s+pt',
|
|
281
|
+
text,
|
|
282
|
+
re.IGNORECASE
|
|
283
|
+
)
|
|
284
|
+
if score_match:
|
|
285
|
+
info['score_x'] = int(score_match.group(1))
|
|
286
|
+
info['score_o'] = int(score_match.group(2))
|
|
287
|
+
info['match_length'] = int(score_match.group(3))
|
|
288
|
+
|
|
289
|
+
# Check for Crawford game indicator in pip count line
|
|
290
|
+
# Format: "Pip count X: 156 O: 167 X-O: 1-4/5 Crawford"
|
|
291
|
+
crawford_match = re.search(
|
|
292
|
+
r'Pip count.*Crawford',
|
|
293
|
+
text,
|
|
294
|
+
re.IGNORECASE
|
|
295
|
+
)
|
|
296
|
+
if crawford_match:
|
|
297
|
+
info['crawford'] = True
|
|
298
|
+
|
|
299
|
+
# Check for money game
|
|
300
|
+
if 'money game' in text.lower():
|
|
301
|
+
info['match_length'] = 0
|
|
302
|
+
|
|
303
|
+
# Parse cube info
|
|
304
|
+
cube_match = re.search(
|
|
305
|
+
r'Cube:\s*(\d+)(?:,\s*([XO])\s+own\s+cube)?',
|
|
306
|
+
text,
|
|
307
|
+
re.IGNORECASE
|
|
308
|
+
)
|
|
309
|
+
if cube_match:
|
|
310
|
+
info['cube_value'] = int(cube_match.group(1))
|
|
311
|
+
owner_label = cube_match.group(2)
|
|
312
|
+
if owner_label:
|
|
313
|
+
owner_label = owner_label.upper()
|
|
314
|
+
if owner_label in xo_to_player:
|
|
315
|
+
owner_player = xo_to_player[owner_label]
|
|
316
|
+
if owner_player == Player.X:
|
|
317
|
+
info['cube_owner'] = CubeState.X_OWNS
|
|
318
|
+
else:
|
|
319
|
+
info['cube_owner'] = CubeState.O_OWNS
|
|
320
|
+
else:
|
|
321
|
+
# Fallback if player mapping not found
|
|
322
|
+
if owner_label == 'X':
|
|
323
|
+
info['cube_owner'] = CubeState.X_OWNS
|
|
324
|
+
elif owner_label == 'O':
|
|
325
|
+
info['cube_owner'] = CubeState.O_OWNS
|
|
326
|
+
else:
|
|
327
|
+
info['cube_owner'] = CubeState.CENTERED
|
|
328
|
+
|
|
329
|
+
# Parse turn info
|
|
330
|
+
turn_match = re.search(
|
|
331
|
+
r'([XO])\s+(?:to\s+play|to\s+roll|on\s+roll)(?:\s+(\d)(\d))?',
|
|
332
|
+
text,
|
|
333
|
+
re.IGNORECASE
|
|
334
|
+
)
|
|
335
|
+
if turn_match:
|
|
336
|
+
player_label = turn_match.group(1).upper()
|
|
337
|
+
|
|
338
|
+
if player_label in xo_to_player:
|
|
339
|
+
info['on_roll'] = xo_to_player[player_label]
|
|
340
|
+
else:
|
|
341
|
+
# Fallback if player mapping not found
|
|
342
|
+
info['on_roll'] = Player.X if player_label == 'X' else Player.O
|
|
343
|
+
|
|
344
|
+
dice1 = turn_match.group(2)
|
|
345
|
+
dice2 = turn_match.group(3)
|
|
346
|
+
if dice1 and dice2:
|
|
347
|
+
info['dice'] = (int(dice1), int(dice2))
|
|
348
|
+
|
|
349
|
+
# Check for cube actions
|
|
350
|
+
if any(word in text.lower() for word in ['double', 'take', 'drop', 'pass', 'beaver']):
|
|
351
|
+
# Look for cube decision indicators
|
|
352
|
+
if 'double' in text.lower() and 'to play' not in text.lower():
|
|
353
|
+
info['decision_type'] = DecisionType.CUBE_ACTION
|
|
354
|
+
|
|
355
|
+
return info
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _parse_moves(text: str) -> List[Move]:
|
|
359
|
+
"""
|
|
360
|
+
Parse move analysis from text.
|
|
361
|
+
|
|
362
|
+
Format:
|
|
363
|
+
1. XG Roller+ 11/8 11/5 eq:+0.589
|
|
364
|
+
Player: 79.46% (G:17.05% B:0.67%)
|
|
365
|
+
Opponent: 20.54% (G:2.22% B:0.06%)
|
|
366
|
+
|
|
367
|
+
2. XG Roller+ 9/3* 6/3 eq:+0.529 (-0.061)
|
|
368
|
+
Player: 76.43% (G:24.10% B:1.77%)
|
|
369
|
+
Opponent: 23.57% (G:3.32% B:0.12%)
|
|
370
|
+
|
|
371
|
+
Or for cube decisions:
|
|
372
|
+
1. XG Roller+ Double, take eq:+0.678
|
|
373
|
+
2. XG Roller+ Double, drop eq:+0.645 (-0.033)
|
|
374
|
+
3. XG Roller+ No double eq:+0.623 (-0.055)
|
|
375
|
+
"""
|
|
376
|
+
moves = []
|
|
377
|
+
|
|
378
|
+
# Find all move entries
|
|
379
|
+
# Pattern: rank. [engine] notation eq:[equity] [(error)]
|
|
380
|
+
move_pattern = re.compile(
|
|
381
|
+
r'^\s*(\d+)\.\s+(?:[\w\s+-]+?)\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
|
|
382
|
+
re.MULTILINE | re.IGNORECASE
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Split text into lines to extract following lines after each move
|
|
386
|
+
lines = text.split('\n')
|
|
387
|
+
move_matches = list(move_pattern.finditer(text))
|
|
388
|
+
|
|
389
|
+
for i, match in enumerate(move_matches):
|
|
390
|
+
rank = int(match.group(1))
|
|
391
|
+
notation = match.group(2).strip()
|
|
392
|
+
equity = float(match.group(3))
|
|
393
|
+
error_str = match.group(4)
|
|
394
|
+
|
|
395
|
+
# Parse error if present
|
|
396
|
+
if error_str:
|
|
397
|
+
xg_error = float(error_str)
|
|
398
|
+
error = abs(xg_error)
|
|
399
|
+
else:
|
|
400
|
+
xg_error = 0.0
|
|
401
|
+
error = 0.0
|
|
402
|
+
|
|
403
|
+
# Clean up notation
|
|
404
|
+
notation = XGTextParser._clean_move_notation(notation)
|
|
405
|
+
|
|
406
|
+
# Extract winning chances from the lines following this move
|
|
407
|
+
# Get the text between this match and the next move (or end)
|
|
408
|
+
start_pos = match.end()
|
|
409
|
+
if i + 1 < len(move_matches):
|
|
410
|
+
end_pos = move_matches[i + 1].start()
|
|
411
|
+
else:
|
|
412
|
+
end_pos = len(text)
|
|
413
|
+
|
|
414
|
+
move_section = text[start_pos:end_pos]
|
|
415
|
+
winning_chances = XGTextParser._parse_move_winning_chances(move_section)
|
|
416
|
+
|
|
417
|
+
moves.append(Move(
|
|
418
|
+
notation=notation,
|
|
419
|
+
equity=equity,
|
|
420
|
+
error=error,
|
|
421
|
+
rank=rank,
|
|
422
|
+
xg_error=xg_error,
|
|
423
|
+
xg_notation=notation,
|
|
424
|
+
player_win_pct=winning_chances.get('player_win_pct'),
|
|
425
|
+
player_gammon_pct=winning_chances.get('player_gammon_pct'),
|
|
426
|
+
player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
|
|
427
|
+
opponent_win_pct=winning_chances.get('opponent_win_pct'),
|
|
428
|
+
opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
|
|
429
|
+
opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
|
|
430
|
+
))
|
|
431
|
+
|
|
432
|
+
# If we didn't find moves with the standard pattern, try alternative patterns
|
|
433
|
+
if not moves:
|
|
434
|
+
moves = XGTextParser._parse_moves_fallback(text)
|
|
435
|
+
|
|
436
|
+
# If still no moves, try parsing as cube decision
|
|
437
|
+
if not moves:
|
|
438
|
+
moves = XGTextParser._parse_cube_decision(text)
|
|
439
|
+
|
|
440
|
+
# Calculate errors if not already set
|
|
441
|
+
if moves and len(moves) > 1:
|
|
442
|
+
best_equity = moves[0].equity
|
|
443
|
+
for move in moves[1:]:
|
|
444
|
+
if move.error == 0.0:
|
|
445
|
+
move.error = abs(best_equity - move.equity)
|
|
446
|
+
|
|
447
|
+
return moves
|
|
448
|
+
|
|
449
|
+
@staticmethod
|
|
450
|
+
def _parse_moves_fallback(text: str) -> List[Move]:
|
|
451
|
+
"""Fallback parser for alternative move formats."""
|
|
452
|
+
moves = []
|
|
453
|
+
|
|
454
|
+
# Try simpler pattern without engine name
|
|
455
|
+
# "1. 11/8 11/5 eq:+0.589"
|
|
456
|
+
pattern = re.compile(
|
|
457
|
+
r'^\s*(\d+)\.\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)',
|
|
458
|
+
re.MULTILINE | re.IGNORECASE
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
for match in pattern.finditer(text):
|
|
462
|
+
rank = int(match.group(1))
|
|
463
|
+
notation = match.group(2).strip()
|
|
464
|
+
equity = float(match.group(3))
|
|
465
|
+
|
|
466
|
+
notation = XGTextParser._clean_move_notation(notation)
|
|
467
|
+
|
|
468
|
+
moves.append(Move(
|
|
469
|
+
notation=notation,
|
|
470
|
+
equity=equity,
|
|
471
|
+
error=0.0,
|
|
472
|
+
rank=rank
|
|
473
|
+
))
|
|
474
|
+
|
|
475
|
+
return moves
|
|
476
|
+
|
|
477
|
+
@staticmethod
|
|
478
|
+
def _parse_cube_decision(text: str) -> List[Move]:
|
|
479
|
+
"""
|
|
480
|
+
Parse cube decision analysis from text.
|
|
481
|
+
|
|
482
|
+
Format:
|
|
483
|
+
Cubeful Equities:
|
|
484
|
+
No redouble: +0.172
|
|
485
|
+
Redouble/Take: -0.361 (-0.533)
|
|
486
|
+
Redouble/Pass: +1.000 (+0.828)
|
|
487
|
+
|
|
488
|
+
Best Cube action: No redouble / Take
|
|
489
|
+
OR: Too good to redouble / Pass
|
|
490
|
+
|
|
491
|
+
Generates all 5 cube options:
|
|
492
|
+
- No double/redouble
|
|
493
|
+
- Double/Take (Redouble/Take)
|
|
494
|
+
- Double/Pass (Redouble/Pass)
|
|
495
|
+
- Too good/Take
|
|
496
|
+
- Too good/Pass
|
|
497
|
+
"""
|
|
498
|
+
moves = []
|
|
499
|
+
|
|
500
|
+
# Look for "Cubeful Equities:" section
|
|
501
|
+
if 'Cubeful Equities:' not in text:
|
|
502
|
+
return moves
|
|
503
|
+
|
|
504
|
+
# Parse the 3 equity values from "Cubeful Equities:" section
|
|
505
|
+
# Pattern to match cube decision lines:
|
|
506
|
+
# " No redouble: +0.172"
|
|
507
|
+
# " Redouble/Take: -0.361 (-0.533)"
|
|
508
|
+
# " Redouble/Pass: +1.000 (+0.828)"
|
|
509
|
+
pattern = re.compile(
|
|
510
|
+
r'^\s*(No (?:redouble|double)|(?:Re)?[Dd]ouble/(?:Take|Pass|Drop)):\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
|
|
511
|
+
re.MULTILINE | re.IGNORECASE
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Store parsed equities in order they appear
|
|
515
|
+
xg_moves_data = []
|
|
516
|
+
for i, match in enumerate(pattern.finditer(text), 1):
|
|
517
|
+
notation = match.group(1).strip()
|
|
518
|
+
equity = float(match.group(2))
|
|
519
|
+
error_str = match.group(3)
|
|
520
|
+
|
|
521
|
+
xg_error = float(error_str) if error_str else 0.0
|
|
522
|
+
|
|
523
|
+
# Normalize notation
|
|
524
|
+
normalized = XGTextParser._clean_move_notation(notation)
|
|
525
|
+
xg_moves_data.append((normalized, equity, xg_error, i))
|
|
526
|
+
|
|
527
|
+
if not xg_moves_data:
|
|
528
|
+
return moves
|
|
529
|
+
|
|
530
|
+
# Build equity map for easy lookup
|
|
531
|
+
equity_map = {data[0]: data[1] for data in xg_moves_data}
|
|
532
|
+
|
|
533
|
+
# Parse "Best Cube action:" to determine which is actually best
|
|
534
|
+
best_action_match = re.search(
|
|
535
|
+
r'Best Cube action:\s*(.+?)(?:\n|$)',
|
|
536
|
+
text,
|
|
537
|
+
re.IGNORECASE
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
best_action_text = None
|
|
541
|
+
if best_action_match:
|
|
542
|
+
best_action_text = best_action_match.group(1).strip()
|
|
543
|
+
|
|
544
|
+
# Determine if we're using "double" or "redouble" terminology
|
|
545
|
+
# Check if any parsed notation contains "redouble"
|
|
546
|
+
use_redouble = any('redouble' in match.group(1).lower()
|
|
547
|
+
for match in pattern.finditer(text))
|
|
548
|
+
|
|
549
|
+
# Generate all 5 cube options with appropriate terminology
|
|
550
|
+
double_term = "Redouble" if use_redouble else "Double"
|
|
551
|
+
|
|
552
|
+
# Define all 5 possible cube options
|
|
553
|
+
# All options should show opponent's recommended response
|
|
554
|
+
all_options = [
|
|
555
|
+
f"No {double_term}/Take",
|
|
556
|
+
f"{double_term}/Take",
|
|
557
|
+
f"{double_term}/Pass",
|
|
558
|
+
f"Too good/Take",
|
|
559
|
+
f"Too good/Pass"
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
# Assign equities from XG's analysis
|
|
563
|
+
no_double_eq = equity_map.get("No Double", None)
|
|
564
|
+
double_take_eq = equity_map.get("Double/Take", None)
|
|
565
|
+
double_pass_eq = equity_map.get("Double/Pass", None)
|
|
566
|
+
|
|
567
|
+
# Build option list with equities
|
|
568
|
+
option_equities = {}
|
|
569
|
+
if no_double_eq is not None:
|
|
570
|
+
option_equities[f"No {double_term}/Take"] = no_double_eq
|
|
571
|
+
if double_take_eq is not None:
|
|
572
|
+
option_equities[f"{double_term}/Take"] = double_take_eq
|
|
573
|
+
if double_pass_eq is not None:
|
|
574
|
+
option_equities[f"{double_term}/Pass"] = double_pass_eq
|
|
575
|
+
|
|
576
|
+
# Assign equities for synthetic "Too good" options
|
|
577
|
+
if double_pass_eq is not None:
|
|
578
|
+
option_equities["Too good/Take"] = double_pass_eq
|
|
579
|
+
option_equities["Too good/Pass"] = double_pass_eq
|
|
580
|
+
|
|
581
|
+
# Determine best option from "Best Cube action:" text
|
|
582
|
+
best_notation = None
|
|
583
|
+
if best_action_text:
|
|
584
|
+
text_lower = best_action_text.lower()
|
|
585
|
+
if 'too good' in text_lower:
|
|
586
|
+
if 'take' in text_lower:
|
|
587
|
+
best_notation = "Too good/Take"
|
|
588
|
+
elif 'pass' in text_lower or 'drop' in text_lower:
|
|
589
|
+
best_notation = "Too good/Pass"
|
|
590
|
+
elif ('no double' in text_lower or 'no redouble' in text_lower):
|
|
591
|
+
best_notation = f"No {double_term}/Take"
|
|
592
|
+
elif ('double' in text_lower or 'redouble' in text_lower):
|
|
593
|
+
if 'take' in text_lower:
|
|
594
|
+
best_notation = f"{double_term}/Take"
|
|
595
|
+
elif 'pass' in text_lower or 'drop' in text_lower:
|
|
596
|
+
best_notation = f"{double_term}/Pass"
|
|
597
|
+
|
|
598
|
+
# Build a lookup for XG move data
|
|
599
|
+
xg_data_map = {data[0]: data for data in xg_moves_data}
|
|
600
|
+
|
|
601
|
+
# Create Move objects for all 5 options
|
|
602
|
+
for i, option in enumerate(all_options):
|
|
603
|
+
equity = option_equities.get(option, 0.0)
|
|
604
|
+
is_from_xg = not option.startswith("Too good")
|
|
605
|
+
|
|
606
|
+
# Get XG metadata for moves from analysis
|
|
607
|
+
xg_error_val = None
|
|
608
|
+
xg_order = None
|
|
609
|
+
xg_notation_val = None
|
|
610
|
+
if is_from_xg:
|
|
611
|
+
base_notation = option.replace(f"No {double_term}/Take", "No Double")
|
|
612
|
+
base_notation = base_notation.replace(f"{double_term}/Take", "Double/Take")
|
|
613
|
+
base_notation = base_notation.replace(f"{double_term}/Pass", "Double/Pass")
|
|
614
|
+
|
|
615
|
+
if base_notation in xg_data_map:
|
|
616
|
+
_, _, xg_error_val, xg_order = xg_data_map[base_notation]
|
|
617
|
+
if base_notation == "No Double":
|
|
618
|
+
xg_notation_val = f"No {double_term.lower()}"
|
|
619
|
+
else:
|
|
620
|
+
xg_notation_val = base_notation.replace("Double", double_term)
|
|
621
|
+
|
|
622
|
+
moves.append(Move(
|
|
623
|
+
notation=option,
|
|
624
|
+
equity=equity,
|
|
625
|
+
error=0.0,
|
|
626
|
+
rank=0,
|
|
627
|
+
xg_rank=xg_order,
|
|
628
|
+
xg_error=xg_error_val,
|
|
629
|
+
xg_notation=xg_notation_val,
|
|
630
|
+
from_xg_analysis=is_from_xg
|
|
631
|
+
))
|
|
632
|
+
|
|
633
|
+
# Sort by equity (highest first) to determine ranking
|
|
634
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
635
|
+
|
|
636
|
+
# Assign ranks based on best move and equity
|
|
637
|
+
if best_notation:
|
|
638
|
+
rank_counter = 1
|
|
639
|
+
for move in moves:
|
|
640
|
+
if move.notation == best_notation:
|
|
641
|
+
move.rank = 1
|
|
642
|
+
else:
|
|
643
|
+
if rank_counter == 1:
|
|
644
|
+
rank_counter = 2
|
|
645
|
+
move.rank = rank_counter
|
|
646
|
+
rank_counter += 1
|
|
647
|
+
else:
|
|
648
|
+
for i, move in enumerate(moves):
|
|
649
|
+
move.rank = i + 1
|
|
650
|
+
|
|
651
|
+
# Calculate errors relative to best move
|
|
652
|
+
if moves:
|
|
653
|
+
best_move = next((m for m in moves if m.rank == 1), moves[0])
|
|
654
|
+
best_equity = best_move.equity
|
|
655
|
+
for move in moves:
|
|
656
|
+
if move.rank != 1:
|
|
657
|
+
move.error = abs(best_equity - move.equity)
|
|
658
|
+
|
|
659
|
+
# Sort by rank for output
|
|
660
|
+
moves.sort(key=lambda m: m.rank)
|
|
661
|
+
|
|
662
|
+
return moves
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
def _clean_move_notation(notation: str) -> str:
|
|
666
|
+
"""Clean up move notation by removing engine names and normalizing cube actions."""
|
|
667
|
+
notation = re.sub(r'^(XG\s+)?(?:Roller\+*|rollout|\d+-ply)\s+', '', notation, flags=re.IGNORECASE)
|
|
668
|
+
|
|
669
|
+
# Remove extra whitespace
|
|
670
|
+
notation = re.sub(r'\s+', ' ', notation)
|
|
671
|
+
notation = notation.strip()
|
|
672
|
+
|
|
673
|
+
# Handle cube actions
|
|
674
|
+
notation_lower = notation.lower()
|
|
675
|
+
if 'double' in notation_lower and 'take' in notation_lower:
|
|
676
|
+
return "Double/Take"
|
|
677
|
+
elif 'double' in notation_lower and 'drop' in notation_lower:
|
|
678
|
+
return "Double/Drop"
|
|
679
|
+
elif 'double' in notation_lower and 'pass' in notation_lower:
|
|
680
|
+
return "Double/Pass"
|
|
681
|
+
elif 'no double' in notation_lower or 'no redouble' in notation_lower:
|
|
682
|
+
return "No Double"
|
|
683
|
+
elif 'take' in notation_lower:
|
|
684
|
+
return "Take"
|
|
685
|
+
elif 'drop' in notation_lower or 'pass' in notation_lower:
|
|
686
|
+
return "Drop"
|
|
687
|
+
|
|
688
|
+
return notation
|