ankigammon 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ankigammon might be problematic. Click here for more details.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +373 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +224 -0
- ankigammon/anki/apkg_exporter.py +123 -0
- ankigammon/anki/card_generator.py +1307 -0
- ankigammon/anki/card_styles.py +1034 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +209 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +597 -0
- ankigammon/gui/dialogs/import_options_dialog.py +163 -0
- ankigammon/gui/dialogs/input_dialog.py +776 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +384 -0
- ankigammon/gui/format_detector.py +292 -0
- ankigammon/gui/main_window.py +1071 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +394 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +193 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +322 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_parser.py +454 -0
- ankigammon/parsers/xg_binary_parser.py +870 -0
- ankigammon/parsers/xg_text_parser.py +729 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +406 -0
- ankigammon/renderer/animation_helper.py +221 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +824 -0
- ankigammon/settings.py +239 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +431 -0
- ankigammon/utils/gnuid.py +622 -0
- ankigammon/utils/move_parser.py +239 -0
- ankigammon/utils/ogid.py +335 -0
- ankigammon/utils/xgid.py +419 -0
- ankigammon-1.0.0.dist-info/METADATA +370 -0
- ankigammon-1.0.0.dist-info/RECORD +56 -0
- ankigammon-1.0.0.dist-info/WHEEL +5 -0
- ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
- ankigammon-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,729 @@
|
|
|
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, but DON'T overwrite XGID data!
|
|
94
|
+
# XGID data is authoritative because it accounts for perspective correctly
|
|
95
|
+
# The ASCII board representation may show cube ownership from a flipped perspective
|
|
96
|
+
for key, value in game_info.items():
|
|
97
|
+
if key not in metadata or key == 'decision_type':
|
|
98
|
+
# Only add if not already in metadata (from XGID)
|
|
99
|
+
# Exception: decision_type can only come from ASCII parsing
|
|
100
|
+
metadata[key] = value
|
|
101
|
+
|
|
102
|
+
# Parse move analysis
|
|
103
|
+
moves = XGTextParser._parse_moves(analysis_section)
|
|
104
|
+
# Note: Allow empty moves for XGID-only positions (gnubg can analyze them later)
|
|
105
|
+
# if not moves:
|
|
106
|
+
# return None
|
|
107
|
+
|
|
108
|
+
# Parse global winning chances (for cube decisions)
|
|
109
|
+
winning_chances = XGTextParser._parse_winning_chances(analysis_section)
|
|
110
|
+
|
|
111
|
+
# Determine decision type
|
|
112
|
+
# If not explicitly set in metadata, infer from dice:
|
|
113
|
+
# - No dice (dice='00' in XGID) = CUBE_ACTION
|
|
114
|
+
# - Has dice = CHECKER_PLAY
|
|
115
|
+
if 'decision_type' in metadata:
|
|
116
|
+
decision_type = metadata['decision_type']
|
|
117
|
+
elif 'dice' not in metadata or metadata.get('dice') is None:
|
|
118
|
+
decision_type = DecisionType.CUBE_ACTION
|
|
119
|
+
else:
|
|
120
|
+
decision_type = DecisionType.CHECKER_PLAY
|
|
121
|
+
|
|
122
|
+
# Determine Crawford status from multiple sources
|
|
123
|
+
# Priority: 1) Text parsing, 2) XGID crawford_jacoby, 3) OGID match_modifier, 4) GNUID crawford
|
|
124
|
+
# Note: crawford_jacoby field means different things in different contexts:
|
|
125
|
+
# - Match play (match_length > 0): crawford_jacoby = 1 means Crawford rule
|
|
126
|
+
# - Money game (match_length = 0): crawford_jacoby = 1 means Jacoby rule
|
|
127
|
+
# The crawford boolean should ONLY be set for Crawford matches, not Jacoby money games
|
|
128
|
+
match_length = metadata.get('match_length', 0)
|
|
129
|
+
crawford = False
|
|
130
|
+
|
|
131
|
+
if match_length > 0: # Only set crawford=True for match play
|
|
132
|
+
if 'crawford' in metadata and metadata['crawford']:
|
|
133
|
+
crawford = True
|
|
134
|
+
elif 'crawford_jacoby' in metadata and metadata['crawford_jacoby'] > 0:
|
|
135
|
+
crawford = True
|
|
136
|
+
elif 'match_modifier' in metadata and metadata['match_modifier'] == 'C':
|
|
137
|
+
crawford = True
|
|
138
|
+
|
|
139
|
+
# Create decision
|
|
140
|
+
decision = Decision(
|
|
141
|
+
position=position,
|
|
142
|
+
xgid=position_id, # Store original position ID (XGID or OGID)
|
|
143
|
+
on_roll=metadata.get('on_roll', Player.O),
|
|
144
|
+
dice=metadata.get('dice'),
|
|
145
|
+
score_x=metadata.get('score_x', 0),
|
|
146
|
+
score_o=metadata.get('score_o', 0),
|
|
147
|
+
match_length=metadata.get('match_length', 0),
|
|
148
|
+
crawford=crawford,
|
|
149
|
+
cube_value=metadata.get('cube_value', 1),
|
|
150
|
+
cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
|
|
151
|
+
decision_type=decision_type,
|
|
152
|
+
candidate_moves=moves,
|
|
153
|
+
player_win_pct=winning_chances.get('player_win_pct'),
|
|
154
|
+
player_gammon_pct=winning_chances.get('player_gammon_pct'),
|
|
155
|
+
player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
|
|
156
|
+
opponent_win_pct=winning_chances.get('opponent_win_pct'),
|
|
157
|
+
opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
|
|
158
|
+
opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return decision
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _parse_winning_chances(text: str) -> dict:
|
|
165
|
+
"""
|
|
166
|
+
Parse global winning chances from text section.
|
|
167
|
+
|
|
168
|
+
Format:
|
|
169
|
+
Player Winning Chances: 52.68% (G:14.35% B:0.69%)
|
|
170
|
+
Opponent Winning Chances: 47.32% (G:12.42% B:0.55%)
|
|
171
|
+
|
|
172
|
+
Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
|
|
173
|
+
opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
|
|
174
|
+
"""
|
|
175
|
+
chances = {}
|
|
176
|
+
|
|
177
|
+
# Parse player winning chances
|
|
178
|
+
player_match = re.search(
|
|
179
|
+
r'Player Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
180
|
+
text,
|
|
181
|
+
re.IGNORECASE
|
|
182
|
+
)
|
|
183
|
+
if player_match:
|
|
184
|
+
chances['player_win_pct'] = float(player_match.group(1))
|
|
185
|
+
chances['player_gammon_pct'] = float(player_match.group(2))
|
|
186
|
+
chances['player_backgammon_pct'] = float(player_match.group(3))
|
|
187
|
+
|
|
188
|
+
# Parse opponent winning chances
|
|
189
|
+
opponent_match = re.search(
|
|
190
|
+
r'Opponent Winning Chances:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
191
|
+
text,
|
|
192
|
+
re.IGNORECASE
|
|
193
|
+
)
|
|
194
|
+
if opponent_match:
|
|
195
|
+
chances['opponent_win_pct'] = float(opponent_match.group(1))
|
|
196
|
+
chances['opponent_gammon_pct'] = float(opponent_match.group(2))
|
|
197
|
+
chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
|
|
198
|
+
|
|
199
|
+
return chances
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _parse_move_winning_chances(move_text: str) -> dict:
|
|
203
|
+
"""
|
|
204
|
+
Parse winning chances from a move's analysis section.
|
|
205
|
+
|
|
206
|
+
Format:
|
|
207
|
+
Player: 53.81% (G:17.42% B:0.87%)
|
|
208
|
+
Opponent: 46.19% (G:12.99% B:0.64%)
|
|
209
|
+
|
|
210
|
+
Returns dict with keys: player_win_pct, player_gammon_pct, player_backgammon_pct,
|
|
211
|
+
opponent_win_pct, opponent_gammon_pct, opponent_backgammon_pct
|
|
212
|
+
"""
|
|
213
|
+
chances = {}
|
|
214
|
+
|
|
215
|
+
# Parse player chances
|
|
216
|
+
player_match = re.search(
|
|
217
|
+
r'Player:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
218
|
+
move_text,
|
|
219
|
+
re.IGNORECASE
|
|
220
|
+
)
|
|
221
|
+
if player_match:
|
|
222
|
+
chances['player_win_pct'] = float(player_match.group(1))
|
|
223
|
+
chances['player_gammon_pct'] = float(player_match.group(2))
|
|
224
|
+
chances['player_backgammon_pct'] = float(player_match.group(3))
|
|
225
|
+
|
|
226
|
+
# Parse opponent chances
|
|
227
|
+
opponent_match = re.search(
|
|
228
|
+
r'Opponent:\s*(\d+\.?\d*)%\s*\(G:(\d+\.?\d*)%\s*B:(\d+\.?\d*)%\)',
|
|
229
|
+
move_text,
|
|
230
|
+
re.IGNORECASE
|
|
231
|
+
)
|
|
232
|
+
if opponent_match:
|
|
233
|
+
chances['opponent_win_pct'] = float(opponent_match.group(1))
|
|
234
|
+
chances['opponent_gammon_pct'] = float(opponent_match.group(2))
|
|
235
|
+
chances['opponent_backgammon_pct'] = float(opponent_match.group(3))
|
|
236
|
+
|
|
237
|
+
return chances
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _parse_game_info(text: str) -> dict:
|
|
241
|
+
"""
|
|
242
|
+
Parse game information from text section.
|
|
243
|
+
|
|
244
|
+
Extracts:
|
|
245
|
+
- Players (X:Player 2 O:Player 1)
|
|
246
|
+
- Score (Score is X:3 O:4 5 pt.(s) match.)
|
|
247
|
+
- Cube info (Cube: 2, O own cube)
|
|
248
|
+
- Turn info (X to play 63)
|
|
249
|
+
"""
|
|
250
|
+
info = {}
|
|
251
|
+
|
|
252
|
+
# First, parse the player designation to build mapping
|
|
253
|
+
# "X:Player 1 O:Player 2" means X is Player 1, O is Player 2
|
|
254
|
+
# Player 1 = BOTTOM player = Player.O in our internal model
|
|
255
|
+
# Player 2 = TOP player = Player.X in our internal model
|
|
256
|
+
xo_to_player = {}
|
|
257
|
+
player_designation = re.search(
|
|
258
|
+
r'([XO]):Player\s+(\d+)',
|
|
259
|
+
text,
|
|
260
|
+
re.IGNORECASE
|
|
261
|
+
)
|
|
262
|
+
if player_designation:
|
|
263
|
+
label = player_designation.group(1).upper() # 'X' or 'O'
|
|
264
|
+
player_num = int(player_designation.group(2)) # 1 or 2
|
|
265
|
+
|
|
266
|
+
# Map: Player 1 = BOTTOM = Player.O, Player 2 = TOP = Player.X
|
|
267
|
+
if player_num == 1:
|
|
268
|
+
xo_to_player[label] = Player.O
|
|
269
|
+
else:
|
|
270
|
+
xo_to_player[label] = Player.X
|
|
271
|
+
|
|
272
|
+
# Also get the other player
|
|
273
|
+
other_label = 'O' if label == 'X' else 'X'
|
|
274
|
+
other_player_designation = re.search(
|
|
275
|
+
rf'{other_label}:Player\s+(\d+)',
|
|
276
|
+
text,
|
|
277
|
+
re.IGNORECASE
|
|
278
|
+
)
|
|
279
|
+
if other_player_designation:
|
|
280
|
+
other_num = int(other_player_designation.group(1))
|
|
281
|
+
if other_num == 1:
|
|
282
|
+
xo_to_player[other_label] = Player.O
|
|
283
|
+
else:
|
|
284
|
+
xo_to_player[other_label] = Player.X
|
|
285
|
+
|
|
286
|
+
# Parse score and match length
|
|
287
|
+
# "Score is X:3 O:4 5 pt.(s) match."
|
|
288
|
+
score_match = re.search(
|
|
289
|
+
r'Score is X:(\d+)\s+O:(\d+)\s+(\d+)\s+pt',
|
|
290
|
+
text,
|
|
291
|
+
re.IGNORECASE
|
|
292
|
+
)
|
|
293
|
+
if score_match:
|
|
294
|
+
info['score_x'] = int(score_match.group(1))
|
|
295
|
+
info['score_o'] = int(score_match.group(2))
|
|
296
|
+
info['match_length'] = int(score_match.group(3))
|
|
297
|
+
|
|
298
|
+
# Check for Crawford game indicator in pip count line
|
|
299
|
+
# Format: "Pip count X: 156 O: 167 X-O: 1-4/5 Crawford"
|
|
300
|
+
crawford_match = re.search(
|
|
301
|
+
r'Pip count.*Crawford',
|
|
302
|
+
text,
|
|
303
|
+
re.IGNORECASE
|
|
304
|
+
)
|
|
305
|
+
if crawford_match:
|
|
306
|
+
info['crawford'] = True
|
|
307
|
+
|
|
308
|
+
# Check for money game
|
|
309
|
+
if 'money game' in text.lower():
|
|
310
|
+
info['match_length'] = 0
|
|
311
|
+
|
|
312
|
+
# Parse cube info
|
|
313
|
+
# "Cube: 2, O own cube" or "Cube: 4, X own cube" or "Cube: 1"
|
|
314
|
+
cube_match = re.search(
|
|
315
|
+
r'Cube:\s*(\d+)(?:,\s*([XO])\s+own\s+cube)?',
|
|
316
|
+
text,
|
|
317
|
+
re.IGNORECASE
|
|
318
|
+
)
|
|
319
|
+
if cube_match:
|
|
320
|
+
info['cube_value'] = int(cube_match.group(1))
|
|
321
|
+
owner_label = cube_match.group(2)
|
|
322
|
+
if owner_label:
|
|
323
|
+
owner_label = owner_label.upper()
|
|
324
|
+
# Use mapping if available
|
|
325
|
+
if owner_label in xo_to_player:
|
|
326
|
+
owner_player = xo_to_player[owner_label]
|
|
327
|
+
if owner_player == Player.X:
|
|
328
|
+
info['cube_owner'] = CubeState.X_OWNS
|
|
329
|
+
else:
|
|
330
|
+
info['cube_owner'] = CubeState.O_OWNS
|
|
331
|
+
else:
|
|
332
|
+
# Fallback: old behavior
|
|
333
|
+
if owner_label == 'X':
|
|
334
|
+
info['cube_owner'] = CubeState.X_OWNS
|
|
335
|
+
elif owner_label == 'O':
|
|
336
|
+
info['cube_owner'] = CubeState.O_OWNS
|
|
337
|
+
else:
|
|
338
|
+
info['cube_owner'] = CubeState.CENTERED
|
|
339
|
+
|
|
340
|
+
# Parse turn info
|
|
341
|
+
# "X to play 63" or "O to play 52" or "X to roll" or "O on roll"
|
|
342
|
+
turn_match = re.search(
|
|
343
|
+
r'([XO])\s+(?:to\s+play|to\s+roll|on\s+roll)(?:\s+(\d)(\d))?',
|
|
344
|
+
text,
|
|
345
|
+
re.IGNORECASE
|
|
346
|
+
)
|
|
347
|
+
if turn_match:
|
|
348
|
+
player_label = turn_match.group(1).upper() # 'X' or 'O' from text
|
|
349
|
+
|
|
350
|
+
# Use the mapping if available, otherwise fall back to simple mapping
|
|
351
|
+
if player_label in xo_to_player:
|
|
352
|
+
info['on_roll'] = xo_to_player[player_label]
|
|
353
|
+
else:
|
|
354
|
+
# Fallback: assume X=Player.X, O=Player.O (old behavior)
|
|
355
|
+
info['on_roll'] = Player.X if player_label == 'X' else Player.O
|
|
356
|
+
|
|
357
|
+
dice1 = turn_match.group(2)
|
|
358
|
+
dice2 = turn_match.group(3)
|
|
359
|
+
if dice1 and dice2:
|
|
360
|
+
info['dice'] = (int(dice1), int(dice2))
|
|
361
|
+
|
|
362
|
+
# Check for cube actions
|
|
363
|
+
if any(word in text.lower() for word in ['double', 'take', 'drop', 'pass', 'beaver']):
|
|
364
|
+
# Look for cube decision indicators
|
|
365
|
+
if 'double' in text.lower() and 'to play' not in text.lower():
|
|
366
|
+
info['decision_type'] = DecisionType.CUBE_ACTION
|
|
367
|
+
|
|
368
|
+
return info
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _parse_moves(text: str) -> List[Move]:
|
|
372
|
+
"""
|
|
373
|
+
Parse move analysis from text.
|
|
374
|
+
|
|
375
|
+
Format:
|
|
376
|
+
1. XG Roller+ 11/8 11/5 eq:+0.589
|
|
377
|
+
Player: 79.46% (G:17.05% B:0.67%)
|
|
378
|
+
Opponent: 20.54% (G:2.22% B:0.06%)
|
|
379
|
+
|
|
380
|
+
2. XG Roller+ 9/3* 6/3 eq:+0.529 (-0.061)
|
|
381
|
+
Player: 76.43% (G:24.10% B:1.77%)
|
|
382
|
+
Opponent: 23.57% (G:3.32% B:0.12%)
|
|
383
|
+
|
|
384
|
+
Or for cube decisions:
|
|
385
|
+
1. XG Roller+ Double, take eq:+0.678
|
|
386
|
+
2. XG Roller+ Double, drop eq:+0.645 (-0.033)
|
|
387
|
+
3. XG Roller+ No double eq:+0.623 (-0.055)
|
|
388
|
+
"""
|
|
389
|
+
moves = []
|
|
390
|
+
|
|
391
|
+
# Find all move entries
|
|
392
|
+
# Pattern: rank. [engine] notation eq:[equity] [(error)]
|
|
393
|
+
move_pattern = re.compile(
|
|
394
|
+
r'^\s*(\d+)\.\s+(?:[\w\s+-]+?)\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
|
|
395
|
+
re.MULTILINE | re.IGNORECASE
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Split text into lines to extract following lines after each move
|
|
399
|
+
lines = text.split('\n')
|
|
400
|
+
move_matches = list(move_pattern.finditer(text))
|
|
401
|
+
|
|
402
|
+
for i, match in enumerate(move_matches):
|
|
403
|
+
rank = int(match.group(1))
|
|
404
|
+
notation = match.group(2).strip()
|
|
405
|
+
equity = float(match.group(3))
|
|
406
|
+
error_str = match.group(4)
|
|
407
|
+
|
|
408
|
+
# Parse error (if present in parentheses)
|
|
409
|
+
# For checker play, preserve the sign (negative means worse than best)
|
|
410
|
+
if error_str:
|
|
411
|
+
xg_error = float(error_str) # Preserve sign from XG
|
|
412
|
+
error = abs(xg_error) # Internal error (always positive)
|
|
413
|
+
else:
|
|
414
|
+
# First move has no error
|
|
415
|
+
xg_error = 0.0
|
|
416
|
+
error = 0.0
|
|
417
|
+
|
|
418
|
+
# Clean up notation
|
|
419
|
+
notation = XGTextParser._clean_move_notation(notation)
|
|
420
|
+
|
|
421
|
+
# Extract winning chances from the lines following this move
|
|
422
|
+
# Get the text between this match and the next move (or end)
|
|
423
|
+
start_pos = match.end()
|
|
424
|
+
if i + 1 < len(move_matches):
|
|
425
|
+
end_pos = move_matches[i + 1].start()
|
|
426
|
+
else:
|
|
427
|
+
end_pos = len(text)
|
|
428
|
+
|
|
429
|
+
move_section = text[start_pos:end_pos]
|
|
430
|
+
winning_chances = XGTextParser._parse_move_winning_chances(move_section)
|
|
431
|
+
|
|
432
|
+
moves.append(Move(
|
|
433
|
+
notation=notation,
|
|
434
|
+
equity=equity,
|
|
435
|
+
error=error,
|
|
436
|
+
rank=rank,
|
|
437
|
+
xg_error=xg_error, # Store XG's error with sign
|
|
438
|
+
xg_notation=notation, # For checker play, XG notation same as regular notation
|
|
439
|
+
player_win_pct=winning_chances.get('player_win_pct'),
|
|
440
|
+
player_gammon_pct=winning_chances.get('player_gammon_pct'),
|
|
441
|
+
player_backgammon_pct=winning_chances.get('player_backgammon_pct'),
|
|
442
|
+
opponent_win_pct=winning_chances.get('opponent_win_pct'),
|
|
443
|
+
opponent_gammon_pct=winning_chances.get('opponent_gammon_pct'),
|
|
444
|
+
opponent_backgammon_pct=winning_chances.get('opponent_backgammon_pct'),
|
|
445
|
+
))
|
|
446
|
+
|
|
447
|
+
# If we didn't find moves with the standard pattern, try alternative patterns
|
|
448
|
+
if not moves:
|
|
449
|
+
moves = XGTextParser._parse_moves_fallback(text)
|
|
450
|
+
|
|
451
|
+
# If still no moves, try parsing as cube decision
|
|
452
|
+
if not moves:
|
|
453
|
+
moves = XGTextParser._parse_cube_decision(text)
|
|
454
|
+
|
|
455
|
+
# Calculate errors if not already set
|
|
456
|
+
if moves and len(moves) > 1:
|
|
457
|
+
best_equity = moves[0].equity
|
|
458
|
+
for move in moves[1:]:
|
|
459
|
+
if move.error == 0.0:
|
|
460
|
+
move.error = abs(best_equity - move.equity)
|
|
461
|
+
|
|
462
|
+
return moves
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _parse_moves_fallback(text: str) -> List[Move]:
|
|
466
|
+
"""Fallback parser for alternative move formats."""
|
|
467
|
+
moves = []
|
|
468
|
+
|
|
469
|
+
# Try simpler pattern without engine name
|
|
470
|
+
# "1. 11/8 11/5 eq:+0.589"
|
|
471
|
+
pattern = re.compile(
|
|
472
|
+
r'^\s*(\d+)\.\s+(.*?)\s+eq:\s*([+-]?\d+\.\d+)',
|
|
473
|
+
re.MULTILINE | re.IGNORECASE
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
for match in pattern.finditer(text):
|
|
477
|
+
rank = int(match.group(1))
|
|
478
|
+
notation = match.group(2).strip()
|
|
479
|
+
equity = float(match.group(3))
|
|
480
|
+
|
|
481
|
+
notation = XGTextParser._clean_move_notation(notation)
|
|
482
|
+
|
|
483
|
+
moves.append(Move(
|
|
484
|
+
notation=notation,
|
|
485
|
+
equity=equity,
|
|
486
|
+
error=0.0,
|
|
487
|
+
rank=rank
|
|
488
|
+
))
|
|
489
|
+
|
|
490
|
+
return moves
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def _parse_cube_decision(text: str) -> List[Move]:
|
|
494
|
+
"""
|
|
495
|
+
Parse cube decision analysis from text.
|
|
496
|
+
|
|
497
|
+
Format:
|
|
498
|
+
Cubeful Equities:
|
|
499
|
+
No redouble: +0.172
|
|
500
|
+
Redouble/Take: -0.361 (-0.533)
|
|
501
|
+
Redouble/Pass: +1.000 (+0.828)
|
|
502
|
+
|
|
503
|
+
Best Cube action: No redouble / Take
|
|
504
|
+
OR: Too good to redouble / Pass
|
|
505
|
+
|
|
506
|
+
Generates all 5 cube options:
|
|
507
|
+
- No double/redouble
|
|
508
|
+
- Double/Take (Redouble/Take)
|
|
509
|
+
- Double/Pass (Redouble/Pass)
|
|
510
|
+
- Too good/Take
|
|
511
|
+
- Too good/Pass
|
|
512
|
+
"""
|
|
513
|
+
moves = []
|
|
514
|
+
|
|
515
|
+
# Look for "Cubeful Equities:" section
|
|
516
|
+
if 'Cubeful Equities:' not in text:
|
|
517
|
+
return moves
|
|
518
|
+
|
|
519
|
+
# Parse the 3 equity values from "Cubeful Equities:" section
|
|
520
|
+
# Pattern to match cube decision lines:
|
|
521
|
+
# " No redouble: +0.172"
|
|
522
|
+
# " Redouble/Take: -0.361 (-0.533)"
|
|
523
|
+
# " Redouble/Pass: +1.000 (+0.828)"
|
|
524
|
+
pattern = re.compile(
|
|
525
|
+
r'^\s*(No (?:redouble|double)|(?:Re)?[Dd]ouble/(?:Take|Pass|Drop)):\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
|
|
526
|
+
re.MULTILINE | re.IGNORECASE
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Store parsed equities and XG errors in order they appear
|
|
530
|
+
# This preserves XG's original order (No double, Double/Take, Double/Pass)
|
|
531
|
+
xg_moves_data = [] # List of (normalized_notation, equity, xg_error, xg_order)
|
|
532
|
+
for i, match in enumerate(pattern.finditer(text), 1):
|
|
533
|
+
notation = match.group(1).strip()
|
|
534
|
+
equity = float(match.group(2))
|
|
535
|
+
error_str = match.group(3)
|
|
536
|
+
|
|
537
|
+
# Parse XG's error (in parentheses) - preserve the sign (+ or -)
|
|
538
|
+
xg_error = float(error_str) if error_str else 0.0
|
|
539
|
+
|
|
540
|
+
# Normalize notation
|
|
541
|
+
normalized = XGTextParser._clean_move_notation(notation)
|
|
542
|
+
xg_moves_data.append((normalized, equity, xg_error, i))
|
|
543
|
+
|
|
544
|
+
if not xg_moves_data:
|
|
545
|
+
return moves
|
|
546
|
+
|
|
547
|
+
# Build equity map for easy lookup
|
|
548
|
+
equity_map = {data[0]: data[1] for data in xg_moves_data}
|
|
549
|
+
|
|
550
|
+
# Parse "Best Cube action:" to determine which is actually best
|
|
551
|
+
best_action_match = re.search(
|
|
552
|
+
r'Best Cube action:\s*(.+?)(?:\n|$)',
|
|
553
|
+
text,
|
|
554
|
+
re.IGNORECASE
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
best_action_text = None
|
|
558
|
+
if best_action_match:
|
|
559
|
+
best_action_text = best_action_match.group(1).strip()
|
|
560
|
+
|
|
561
|
+
# Determine if we're using "double" or "redouble" terminology
|
|
562
|
+
# Check if any parsed notation contains "redouble"
|
|
563
|
+
use_redouble = any('redouble' in match.group(1).lower()
|
|
564
|
+
for match in pattern.finditer(text))
|
|
565
|
+
|
|
566
|
+
# Generate all 5 cube options with appropriate terminology
|
|
567
|
+
double_term = "Redouble" if use_redouble else "Double"
|
|
568
|
+
|
|
569
|
+
# Define all 5 possible cube options
|
|
570
|
+
# All options should show opponent's recommended response
|
|
571
|
+
all_options = [
|
|
572
|
+
f"No {double_term}/Take",
|
|
573
|
+
f"{double_term}/Take",
|
|
574
|
+
f"{double_term}/Pass",
|
|
575
|
+
f"Too good/Take",
|
|
576
|
+
f"Too good/Pass"
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
# Assign equities and determine best move
|
|
580
|
+
# The equities we have from XG are: No double, Double/Take, Double/Pass
|
|
581
|
+
# We need to infer equities for "Too good" options
|
|
582
|
+
|
|
583
|
+
no_double_eq = equity_map.get("No Double", None)
|
|
584
|
+
double_take_eq = equity_map.get("Double/Take", None)
|
|
585
|
+
double_pass_eq = equity_map.get("Double/Pass", None)
|
|
586
|
+
|
|
587
|
+
# Build option list with equities
|
|
588
|
+
option_equities = {}
|
|
589
|
+
if no_double_eq is not None:
|
|
590
|
+
# "No Double/Take" means we don't double and opponent would take if we did
|
|
591
|
+
option_equities[f"No {double_term}/Take"] = no_double_eq
|
|
592
|
+
if double_take_eq is not None:
|
|
593
|
+
option_equities[f"{double_term}/Take"] = double_take_eq
|
|
594
|
+
if double_pass_eq is not None:
|
|
595
|
+
option_equities[f"{double_term}/Pass"] = double_pass_eq
|
|
596
|
+
|
|
597
|
+
# For "Too good" options, use the same equity as the corresponding action
|
|
598
|
+
# Too good/Take means we're too good to double, so opponent should drop
|
|
599
|
+
# This has the same practical equity as Double/Pass
|
|
600
|
+
if double_pass_eq is not None:
|
|
601
|
+
option_equities["Too good/Take"] = double_pass_eq
|
|
602
|
+
option_equities["Too good/Pass"] = double_pass_eq
|
|
603
|
+
|
|
604
|
+
# Determine which is the best option based on "Best Cube action:" text
|
|
605
|
+
# Format can be:
|
|
606
|
+
# "No redouble / Take" means "No redouble/Take" (we don't double, opponent would take)
|
|
607
|
+
# "Redouble / Take" means "Redouble/Take" (we double, opponent takes)
|
|
608
|
+
# "Too good to redouble / Pass" means "Too good/Pass"
|
|
609
|
+
best_notation = None
|
|
610
|
+
if best_action_text:
|
|
611
|
+
text_lower = best_action_text.lower()
|
|
612
|
+
if 'too good' in text_lower:
|
|
613
|
+
# "Too good to redouble / Take" or "Too good to redouble / Pass"
|
|
614
|
+
# The part after the slash is what opponent would do
|
|
615
|
+
if 'take' in text_lower:
|
|
616
|
+
best_notation = "Too good/Take"
|
|
617
|
+
elif 'pass' in text_lower or 'drop' in text_lower:
|
|
618
|
+
best_notation = "Too good/Pass"
|
|
619
|
+
elif ('no double' in text_lower or 'no redouble' in text_lower):
|
|
620
|
+
# "No redouble / Take" means we don't double, opponent would take
|
|
621
|
+
best_notation = f"No {double_term}/Take"
|
|
622
|
+
elif ('double' in text_lower or 'redouble' in text_lower):
|
|
623
|
+
# This is tricky: "Redouble / Take" vs "No redouble / Take"
|
|
624
|
+
# We already handled "No redouble" above, so this must be actual double
|
|
625
|
+
if 'take' in text_lower:
|
|
626
|
+
best_notation = f"{double_term}/Take"
|
|
627
|
+
elif 'pass' in text_lower or 'drop' in text_lower:
|
|
628
|
+
best_notation = f"{double_term}/Pass"
|
|
629
|
+
|
|
630
|
+
# Build a lookup for XG move data
|
|
631
|
+
xg_data_map = {data[0]: data for data in xg_moves_data}
|
|
632
|
+
|
|
633
|
+
# Create Move objects for all 5 options
|
|
634
|
+
for i, option in enumerate(all_options):
|
|
635
|
+
equity = option_equities.get(option, 0.0)
|
|
636
|
+
# Mark "Too good" options as synthetic (not from XG's analysis)
|
|
637
|
+
is_from_xg = not option.startswith("Too good")
|
|
638
|
+
|
|
639
|
+
# Get XG's error, order, and original notation for this move if it's from XG
|
|
640
|
+
xg_error_val = None
|
|
641
|
+
xg_order = None
|
|
642
|
+
xg_notation_val = None
|
|
643
|
+
if is_from_xg:
|
|
644
|
+
# Look up the original notation (without /Take suffix for No Double)
|
|
645
|
+
base_notation = option.replace(f"No {double_term}/Take", "No Double")
|
|
646
|
+
base_notation = base_notation.replace(f"{double_term}/Take", "Double/Take")
|
|
647
|
+
base_notation = base_notation.replace(f"{double_term}/Pass", "Double/Pass")
|
|
648
|
+
|
|
649
|
+
if base_notation in xg_data_map:
|
|
650
|
+
_, _, xg_error_val, xg_order = xg_data_map[base_notation]
|
|
651
|
+
# Store the XG notation with proper terminology
|
|
652
|
+
if base_notation == "No Double":
|
|
653
|
+
xg_notation_val = f"No {double_term.lower()}"
|
|
654
|
+
else:
|
|
655
|
+
xg_notation_val = base_notation.replace("Double", double_term)
|
|
656
|
+
|
|
657
|
+
moves.append(Move(
|
|
658
|
+
notation=option,
|
|
659
|
+
equity=equity,
|
|
660
|
+
error=0.0, # Will calculate below (error relative to best)
|
|
661
|
+
rank=0, # Will assign ranks below
|
|
662
|
+
xg_rank=xg_order, # Order in XG's Cubeful Equities section
|
|
663
|
+
xg_error=xg_error_val, # Error as shown by XG
|
|
664
|
+
xg_notation=xg_notation_val, # Original XG notation for analysis table
|
|
665
|
+
from_xg_analysis=is_from_xg
|
|
666
|
+
))
|
|
667
|
+
|
|
668
|
+
# Sort by equity (highest first) to determine ranking
|
|
669
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
670
|
+
|
|
671
|
+
# Assign ranks: best move gets rank 1, rest get 2-5 based on equity
|
|
672
|
+
if best_notation:
|
|
673
|
+
# Best move was identified from "Best Cube action:" line
|
|
674
|
+
rank_counter = 1
|
|
675
|
+
for move in moves:
|
|
676
|
+
if move.notation == best_notation:
|
|
677
|
+
move.rank = 1
|
|
678
|
+
else:
|
|
679
|
+
# Assign ranks 2-5 based on equity order, skipping the best
|
|
680
|
+
if rank_counter == 1:
|
|
681
|
+
rank_counter = 2
|
|
682
|
+
move.rank = rank_counter
|
|
683
|
+
rank_counter += 1
|
|
684
|
+
else:
|
|
685
|
+
# Best wasn't identified, rank purely by equity
|
|
686
|
+
for i, move in enumerate(moves):
|
|
687
|
+
move.rank = i + 1
|
|
688
|
+
|
|
689
|
+
# Calculate errors relative to best move (for our internal use)
|
|
690
|
+
if moves:
|
|
691
|
+
best_move = next((m for m in moves if m.rank == 1), moves[0])
|
|
692
|
+
best_equity = best_move.equity
|
|
693
|
+
|
|
694
|
+
for move in moves:
|
|
695
|
+
if move.rank != 1:
|
|
696
|
+
move.error = abs(best_equity - move.equity)
|
|
697
|
+
|
|
698
|
+
# Sort by rank for output
|
|
699
|
+
moves.sort(key=lambda m: m.rank)
|
|
700
|
+
|
|
701
|
+
return moves
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def _clean_move_notation(notation: str) -> str:
|
|
705
|
+
"""Clean up move notation."""
|
|
706
|
+
# Remove engine names like "XG Roller+", "Roller++", "3-ply", etc.
|
|
707
|
+
# These appear at the start of the notation
|
|
708
|
+
notation = re.sub(r'^(XG\s+)?(?:Roller\+*|rollout|\d+-ply)\s+', '', notation, flags=re.IGNORECASE)
|
|
709
|
+
|
|
710
|
+
# Remove extra whitespace
|
|
711
|
+
notation = re.sub(r'\s+', ' ', notation)
|
|
712
|
+
notation = notation.strip()
|
|
713
|
+
|
|
714
|
+
# Handle cube actions
|
|
715
|
+
notation_lower = notation.lower()
|
|
716
|
+
if 'double' in notation_lower and 'take' in notation_lower:
|
|
717
|
+
return "Double/Take"
|
|
718
|
+
elif 'double' in notation_lower and 'drop' in notation_lower:
|
|
719
|
+
return "Double/Drop"
|
|
720
|
+
elif 'double' in notation_lower and 'pass' in notation_lower:
|
|
721
|
+
return "Double/Pass"
|
|
722
|
+
elif 'no double' in notation_lower or 'no redouble' in notation_lower:
|
|
723
|
+
return "No Double"
|
|
724
|
+
elif 'take' in notation_lower:
|
|
725
|
+
return "Take"
|
|
726
|
+
elif 'drop' in notation_lower or 'pass' in notation_lower:
|
|
727
|
+
return "Drop"
|
|
728
|
+
|
|
729
|
+
return notation
|