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,468 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GNU Backgammon text output parser.
|
|
3
|
+
|
|
4
|
+
Parses analysis output from gnubg-cli.exe into Decision objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from ankigammon.models import Decision, DecisionType, Move, Player, Position
|
|
11
|
+
from ankigammon.utils.xgid import parse_xgid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GNUBGParser:
|
|
15
|
+
"""Parse GNU Backgammon analysis output."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def parse_analysis(
|
|
19
|
+
gnubg_output: str,
|
|
20
|
+
xgid: str,
|
|
21
|
+
decision_type: DecisionType
|
|
22
|
+
) -> Decision:
|
|
23
|
+
"""
|
|
24
|
+
Parse gnubg output into Decision object.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
gnubg_output: Raw text output from gnubg-cli.exe
|
|
28
|
+
xgid: Original XGID for position reconstruction
|
|
29
|
+
decision_type: CHECKER_PLAY or CUBE_ACTION
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Decision object with populated candidate_moves
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If parsing fails
|
|
36
|
+
"""
|
|
37
|
+
# Parse XGID to get position and metadata
|
|
38
|
+
position, metadata = parse_xgid(xgid)
|
|
39
|
+
|
|
40
|
+
# Parse moves based on decision type
|
|
41
|
+
if decision_type == DecisionType.CHECKER_PLAY:
|
|
42
|
+
moves = GNUBGParser._parse_checker_play(gnubg_output)
|
|
43
|
+
else:
|
|
44
|
+
moves = GNUBGParser._parse_cube_decision(gnubg_output)
|
|
45
|
+
|
|
46
|
+
if not moves:
|
|
47
|
+
raise ValueError(f"No moves found in gnubg output for {decision_type.value}")
|
|
48
|
+
|
|
49
|
+
# Extract winning chances from metadata or output
|
|
50
|
+
winning_chances = GNUBGParser._parse_winning_chances(gnubg_output)
|
|
51
|
+
|
|
52
|
+
# Build Decision object
|
|
53
|
+
decision = Decision(
|
|
54
|
+
position=position,
|
|
55
|
+
on_roll=metadata.get('on_roll', Player.O),
|
|
56
|
+
decision_type=decision_type,
|
|
57
|
+
candidate_moves=moves,
|
|
58
|
+
dice=metadata.get('dice'),
|
|
59
|
+
xgid=xgid,
|
|
60
|
+
score_x=metadata.get('score_x', 0),
|
|
61
|
+
score_o=metadata.get('score_o', 0),
|
|
62
|
+
match_length=metadata.get('match_length', 0),
|
|
63
|
+
cube_value=metadata.get('cube_value', 1),
|
|
64
|
+
cube_owner=metadata.get('cube_owner'),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Add winning chances to decision if found
|
|
68
|
+
if winning_chances:
|
|
69
|
+
decision.player_win_pct = winning_chances.get('player_win_pct')
|
|
70
|
+
decision.player_gammon_pct = winning_chances.get('player_gammon_pct')
|
|
71
|
+
decision.player_backgammon_pct = winning_chances.get('player_backgammon_pct')
|
|
72
|
+
decision.opponent_win_pct = winning_chances.get('opponent_win_pct')
|
|
73
|
+
decision.opponent_gammon_pct = winning_chances.get('opponent_gammon_pct')
|
|
74
|
+
decision.opponent_backgammon_pct = winning_chances.get('opponent_backgammon_pct')
|
|
75
|
+
|
|
76
|
+
return decision
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _parse_checker_play(text: str) -> List[Move]:
|
|
80
|
+
"""
|
|
81
|
+
Parse checker play analysis from gnubg output.
|
|
82
|
+
|
|
83
|
+
Expected format:
|
|
84
|
+
1. Cubeful 4-ply 21/16 21/15 Eq.: -0.411
|
|
85
|
+
0.266 0.021 0.001 - 0.734 0.048 0.001
|
|
86
|
+
4-ply cubeful prune [4ply]
|
|
87
|
+
2. Cubeful 4-ply 9/4 9/3 Eq.: -0.437 ( -0.025)
|
|
88
|
+
0.249 0.004 0.000 - 0.751 0.021 0.000
|
|
89
|
+
4-ply cubeful prune [4ply]
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
text: gnubg output text
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of Move objects sorted by rank
|
|
96
|
+
"""
|
|
97
|
+
moves = []
|
|
98
|
+
lines = text.split('\n')
|
|
99
|
+
|
|
100
|
+
# Pattern for gnubg move lines
|
|
101
|
+
# Matches: " 1. Cubeful 4-ply 21/16 21/15 Eq.: -0.411"
|
|
102
|
+
# " 2. Cubeful 4-ply 9/4 9/3 Eq.: -0.437 ( -0.025)"
|
|
103
|
+
move_pattern = re.compile(
|
|
104
|
+
r'^\s*(\d+)\.\s+(?:Cubeful\s+\d+-ply\s+)?(.*?)\s+Eq\.?:\s*([+-]?\d+\.\d+)(?:\s*\(\s*([+-]?\d+\.\d+)\))?',
|
|
105
|
+
re.IGNORECASE
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Pattern for probability line
|
|
109
|
+
# Matches: " 0.266 0.021 0.001 - 0.734 0.048 0.001"
|
|
110
|
+
prob_pattern = re.compile(
|
|
111
|
+
r'^\s*(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)\s*-\s*(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for i, line in enumerate(lines):
|
|
115
|
+
match = move_pattern.match(line)
|
|
116
|
+
if match:
|
|
117
|
+
rank = int(match.group(1))
|
|
118
|
+
notation = match.group(2).strip()
|
|
119
|
+
equity = float(match.group(3))
|
|
120
|
+
error_str = match.group(4)
|
|
121
|
+
|
|
122
|
+
error = float(error_str) if error_str else 0.0
|
|
123
|
+
abs_error = abs(error)
|
|
124
|
+
|
|
125
|
+
# Look for probability line on next line
|
|
126
|
+
player_win = None
|
|
127
|
+
player_gammon = None
|
|
128
|
+
player_backgammon = None
|
|
129
|
+
opponent_win = None
|
|
130
|
+
opponent_gammon = None
|
|
131
|
+
opponent_backgammon = None
|
|
132
|
+
|
|
133
|
+
if i + 1 < len(lines):
|
|
134
|
+
prob_match = prob_pattern.match(lines[i + 1])
|
|
135
|
+
if prob_match:
|
|
136
|
+
player_win = float(prob_match.group(1)) * 100
|
|
137
|
+
player_gammon = float(prob_match.group(2)) * 100
|
|
138
|
+
player_backgammon = float(prob_match.group(3)) * 100
|
|
139
|
+
opponent_win = float(prob_match.group(4)) * 100
|
|
140
|
+
opponent_gammon = float(prob_match.group(5)) * 100
|
|
141
|
+
opponent_backgammon = float(prob_match.group(6)) * 100
|
|
142
|
+
|
|
143
|
+
moves.append(Move(
|
|
144
|
+
notation=notation,
|
|
145
|
+
equity=equity,
|
|
146
|
+
rank=rank,
|
|
147
|
+
error=abs_error,
|
|
148
|
+
xg_error=error,
|
|
149
|
+
xg_notation=notation,
|
|
150
|
+
xg_rank=rank,
|
|
151
|
+
from_xg_analysis=True,
|
|
152
|
+
player_win_pct=player_win,
|
|
153
|
+
player_gammon_pct=player_gammon,
|
|
154
|
+
player_backgammon_pct=player_backgammon,
|
|
155
|
+
opponent_win_pct=opponent_win,
|
|
156
|
+
opponent_gammon_pct=opponent_gammon,
|
|
157
|
+
opponent_backgammon_pct=opponent_backgammon
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
# If no moves found, try alternative pattern
|
|
161
|
+
if not moves:
|
|
162
|
+
# Try simpler pattern without rank numbers
|
|
163
|
+
alt_pattern = re.compile(
|
|
164
|
+
r'^\s*([0-9/\s*bar]+?)\s+Eq:\s*([+-]?\d+\.\d+)',
|
|
165
|
+
re.MULTILINE
|
|
166
|
+
)
|
|
167
|
+
for i, match in enumerate(alt_pattern.finditer(text), 1):
|
|
168
|
+
notation = match.group(1).strip()
|
|
169
|
+
equity = float(match.group(2))
|
|
170
|
+
|
|
171
|
+
moves.append(Move(
|
|
172
|
+
notation=notation,
|
|
173
|
+
equity=equity,
|
|
174
|
+
rank=i,
|
|
175
|
+
error=0.0,
|
|
176
|
+
from_xg_analysis=True
|
|
177
|
+
))
|
|
178
|
+
|
|
179
|
+
# Sort by equity (highest first) and recalculate errors
|
|
180
|
+
if moves:
|
|
181
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
182
|
+
best_equity = moves[0].equity
|
|
183
|
+
|
|
184
|
+
for i, move in enumerate(moves, 1):
|
|
185
|
+
move.rank = i
|
|
186
|
+
move.error = abs(best_equity - move.equity)
|
|
187
|
+
|
|
188
|
+
return moves
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _parse_cube_decision(text: str) -> List[Move]:
|
|
192
|
+
"""
|
|
193
|
+
Parse cube decision analysis from gnubg output.
|
|
194
|
+
|
|
195
|
+
Expected format:
|
|
196
|
+
Cubeful equities:
|
|
197
|
+
1. No double +0.172
|
|
198
|
+
2. Double, take -0.361 (-0.533)
|
|
199
|
+
3. Double, pass +1.000 (+0.828)
|
|
200
|
+
|
|
201
|
+
Proper cube action: No double
|
|
202
|
+
|
|
203
|
+
Note: Score matrix generation always uses initial double (cube=1), so
|
|
204
|
+
"You cannot double" should not occur. This handler is for edge cases
|
|
205
|
+
and non-matrix analysis. Returns move that displays as "—" in matrix.
|
|
206
|
+
|
|
207
|
+
Generates all 5 cube options (like XG parser):
|
|
208
|
+
- No double/Take
|
|
209
|
+
- Double/Take
|
|
210
|
+
- Double/Pass
|
|
211
|
+
- Too good/Take (synthetic)
|
|
212
|
+
- Too good/Pass (synthetic)
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
text: gnubg output text
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
List of Move objects with all 5 cube options
|
|
219
|
+
"""
|
|
220
|
+
moves = []
|
|
221
|
+
|
|
222
|
+
# Handle "You cannot double" (restricted doubling at certain match scores)
|
|
223
|
+
if 'You cannot double' in text or 'you cannot double' in text:
|
|
224
|
+
moves.append(Move(
|
|
225
|
+
notation="No Double/Take",
|
|
226
|
+
equity=0.0, # No equity info available
|
|
227
|
+
error=0.0,
|
|
228
|
+
rank=1,
|
|
229
|
+
xg_error=0.0,
|
|
230
|
+
xg_notation="No Double/Take",
|
|
231
|
+
xg_rank=1,
|
|
232
|
+
from_xg_analysis=True
|
|
233
|
+
))
|
|
234
|
+
return moves
|
|
235
|
+
|
|
236
|
+
# Look for "Cubeful equities:" section
|
|
237
|
+
if 'Cubeful equities' not in text and 'cubeful equities' not in text:
|
|
238
|
+
return moves
|
|
239
|
+
|
|
240
|
+
# Parse the 3 equity values from gnubg
|
|
241
|
+
# Pattern to match cube decision lines:
|
|
242
|
+
# "1. No double +0.172"
|
|
243
|
+
# "2. Double, take -0.361 (-0.533)"
|
|
244
|
+
# "3. Double, pass +1.000 (+0.828)"
|
|
245
|
+
pattern = re.compile(
|
|
246
|
+
r'^\s*\d+\.\s*(No (?:re)?double|(?:Re)?[Dd]ouble,?\s*(?:take|pass|drop))\s*([+-]?\d+\.\d+)(?:\s*\(([+-]\d+\.\d+)\))?',
|
|
247
|
+
re.MULTILINE | re.IGNORECASE
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Store parsed equities in order they appear
|
|
251
|
+
gnubg_moves_data = [] # List of (normalized_notation, equity, gnubg_error)
|
|
252
|
+
for match in pattern.finditer(text):
|
|
253
|
+
notation = match.group(1).strip()
|
|
254
|
+
equity = float(match.group(2))
|
|
255
|
+
error_str = match.group(3)
|
|
256
|
+
|
|
257
|
+
gnubg_error = float(error_str) if error_str else 0.0
|
|
258
|
+
|
|
259
|
+
normalized = notation.replace(', ', '/').replace(',', '/')
|
|
260
|
+
normalized = GNUBGParser._normalize_cube_notation(normalized)
|
|
261
|
+
|
|
262
|
+
gnubg_moves_data.append((normalized, equity, gnubg_error))
|
|
263
|
+
|
|
264
|
+
if not gnubg_moves_data:
|
|
265
|
+
return moves
|
|
266
|
+
|
|
267
|
+
# Build equity map for easy lookup
|
|
268
|
+
equity_map = {data[0]: data[1] for data in gnubg_moves_data}
|
|
269
|
+
|
|
270
|
+
# Parse "Proper cube action:" to determine best move
|
|
271
|
+
best_action_match = re.search(
|
|
272
|
+
r'Proper cube action:\s*(.+?)(?:\n|$)',
|
|
273
|
+
text,
|
|
274
|
+
re.IGNORECASE
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
best_action_text = None
|
|
278
|
+
if best_action_match:
|
|
279
|
+
best_action_text = best_action_match.group(1).strip()
|
|
280
|
+
|
|
281
|
+
# Determine if using "double" or "redouble" terminology
|
|
282
|
+
use_redouble = any('redouble' in data[0].lower() for data in gnubg_moves_data)
|
|
283
|
+
double_term = "Redouble" if use_redouble else "Double"
|
|
284
|
+
|
|
285
|
+
# Generate all 5 cube options with appropriate terminology
|
|
286
|
+
all_options = [
|
|
287
|
+
f"No {double_term}/Take",
|
|
288
|
+
f"{double_term}/Take",
|
|
289
|
+
f"{double_term}/Pass",
|
|
290
|
+
f"Too good/Take",
|
|
291
|
+
f"Too good/Pass"
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
# Assign equities
|
|
295
|
+
no_double_eq = equity_map.get("No Double", None)
|
|
296
|
+
double_take_eq = equity_map.get("Double/Take", None)
|
|
297
|
+
double_pass_eq = equity_map.get("Double/Pass", None)
|
|
298
|
+
|
|
299
|
+
option_equities = {}
|
|
300
|
+
if no_double_eq is not None:
|
|
301
|
+
option_equities[f"No {double_term}/Take"] = no_double_eq
|
|
302
|
+
if double_take_eq is not None:
|
|
303
|
+
option_equities[f"{double_term}/Take"] = double_take_eq
|
|
304
|
+
if double_pass_eq is not None:
|
|
305
|
+
option_equities[f"{double_term}/Pass"] = double_pass_eq
|
|
306
|
+
|
|
307
|
+
# Assign equities for synthetic "Too good" options
|
|
308
|
+
if double_pass_eq is not None:
|
|
309
|
+
option_equities["Too good/Take"] = double_pass_eq
|
|
310
|
+
option_equities["Too good/Pass"] = double_pass_eq
|
|
311
|
+
|
|
312
|
+
# Determine best notation from "Proper cube action:" text
|
|
313
|
+
best_notation = GNUBGParser._parse_best_cube_action(best_action_text, double_term)
|
|
314
|
+
|
|
315
|
+
# Create Move objects for all 5 options
|
|
316
|
+
for option in all_options:
|
|
317
|
+
equity = option_equities.get(option, 0.0)
|
|
318
|
+
is_from_gnubg = not option.startswith("Too good")
|
|
319
|
+
|
|
320
|
+
moves.append(Move(
|
|
321
|
+
notation=option,
|
|
322
|
+
equity=equity,
|
|
323
|
+
error=0.0, # Will calculate below
|
|
324
|
+
rank=0, # Will assign below
|
|
325
|
+
xg_error=None,
|
|
326
|
+
xg_notation=option if is_from_gnubg else None,
|
|
327
|
+
xg_rank=None,
|
|
328
|
+
from_xg_analysis=is_from_gnubg
|
|
329
|
+
))
|
|
330
|
+
|
|
331
|
+
# Sort by equity (highest first) to determine ranking
|
|
332
|
+
moves.sort(key=lambda m: m.equity, reverse=True)
|
|
333
|
+
|
|
334
|
+
# Assign ranks
|
|
335
|
+
if best_notation:
|
|
336
|
+
rank_counter = 1
|
|
337
|
+
for move in moves:
|
|
338
|
+
if move.notation == best_notation:
|
|
339
|
+
move.rank = 1
|
|
340
|
+
else:
|
|
341
|
+
if rank_counter == 1:
|
|
342
|
+
rank_counter = 2
|
|
343
|
+
move.rank = rank_counter
|
|
344
|
+
rank_counter += 1
|
|
345
|
+
else:
|
|
346
|
+
# Best wasn't identified, rank purely by equity
|
|
347
|
+
for i, move in enumerate(moves, 1):
|
|
348
|
+
move.rank = i
|
|
349
|
+
|
|
350
|
+
# Calculate errors relative to best move
|
|
351
|
+
if moves:
|
|
352
|
+
best_move = next((m for m in moves if m.rank == 1), moves[0])
|
|
353
|
+
for move in moves:
|
|
354
|
+
move.error = abs(best_move.equity - move.equity)
|
|
355
|
+
|
|
356
|
+
return moves
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _normalize_cube_notation(notation: str) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Normalize cube notation to standard format.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
notation: Raw notation (e.g., "Double, take", "No redouble")
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Normalized notation (e.g., "Double/Take", "No Double")
|
|
368
|
+
"""
|
|
369
|
+
# Standardize case
|
|
370
|
+
parts = notation.split('/')
|
|
371
|
+
result_parts = []
|
|
372
|
+
|
|
373
|
+
for part in parts:
|
|
374
|
+
part = part.strip().lower()
|
|
375
|
+
|
|
376
|
+
# Normalize terms
|
|
377
|
+
if 'no' in part and ('double' in part or 'redouble' in part):
|
|
378
|
+
result_parts.append("No Double")
|
|
379
|
+
elif 'double' in part or 'redouble' in part:
|
|
380
|
+
result_parts.append("Double")
|
|
381
|
+
elif 'take' in part:
|
|
382
|
+
result_parts.append("Take")
|
|
383
|
+
elif 'pass' in part or 'drop' in part:
|
|
384
|
+
result_parts.append("Pass")
|
|
385
|
+
elif 'too good' in part:
|
|
386
|
+
result_parts.append("Too good")
|
|
387
|
+
else:
|
|
388
|
+
result_parts.append(part.capitalize())
|
|
389
|
+
|
|
390
|
+
return '/'.join(result_parts)
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _parse_best_cube_action(best_text: Optional[str], double_term: str) -> Optional[str]:
|
|
394
|
+
"""
|
|
395
|
+
Parse "Proper cube action:" text to determine best move notation.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
best_text: Text from "Proper cube action:" line
|
|
399
|
+
double_term: "Double" or "Redouble"
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Standardized notation matching all_options format
|
|
403
|
+
"""
|
|
404
|
+
if not best_text:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
text_lower = best_text.lower()
|
|
408
|
+
|
|
409
|
+
if 'too good' in text_lower:
|
|
410
|
+
if 'take' in text_lower:
|
|
411
|
+
return "Too good/Take"
|
|
412
|
+
elif 'pass' in text_lower or 'drop' in text_lower:
|
|
413
|
+
return "Too good/Pass"
|
|
414
|
+
elif 'no double' in text_lower or 'no redouble' in text_lower:
|
|
415
|
+
return f"No {double_term}/Take"
|
|
416
|
+
elif 'double' in text_lower or 'redouble' in text_lower:
|
|
417
|
+
if 'take' in text_lower:
|
|
418
|
+
return f"{double_term}/Take"
|
|
419
|
+
elif 'pass' in text_lower or 'drop' in text_lower:
|
|
420
|
+
return f"{double_term}/Pass"
|
|
421
|
+
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
@staticmethod
|
|
425
|
+
def _parse_winning_chances(text: str) -> dict:
|
|
426
|
+
"""
|
|
427
|
+
Extract W/G/B percentages from gnubg output.
|
|
428
|
+
|
|
429
|
+
Looks for patterns like:
|
|
430
|
+
Cubeless equity: +0.172
|
|
431
|
+
Win: 52.3% G: 14.2% B: 0.8%
|
|
432
|
+
|
|
433
|
+
or:
|
|
434
|
+
0.523 0.142 0.008 - 0.477 0.124 0.006
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
text: gnubg output text
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Dictionary with winning chance percentages (or empty dict)
|
|
441
|
+
"""
|
|
442
|
+
chances = {}
|
|
443
|
+
|
|
444
|
+
# Try pattern 1: "Win: 52.3% G: 14.2% B: 0.8%"
|
|
445
|
+
win_pattern = re.search(
|
|
446
|
+
r'Win:\s*(\d+\.?\d*)%.*?G:\s*(\d+\.?\d*)%.*?B:\s*(\d+\.?\d*)%',
|
|
447
|
+
text,
|
|
448
|
+
re.IGNORECASE
|
|
449
|
+
)
|
|
450
|
+
if win_pattern:
|
|
451
|
+
chances['player_win_pct'] = float(win_pattern.group(1))
|
|
452
|
+
chances['player_gammon_pct'] = float(win_pattern.group(2))
|
|
453
|
+
chances['player_backgammon_pct'] = float(win_pattern.group(3))
|
|
454
|
+
|
|
455
|
+
# Try pattern 2: Decimal probabilities "0.523 0.142 0.008 - 0.477 0.124 0.006"
|
|
456
|
+
prob_pattern = re.search(
|
|
457
|
+
r'(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)\s*-\s*(\d\.\d+)\s+(\d\.\d+)\s+(\d\.\d+)',
|
|
458
|
+
text
|
|
459
|
+
)
|
|
460
|
+
if prob_pattern:
|
|
461
|
+
chances['player_win_pct'] = float(prob_pattern.group(1)) * 100
|
|
462
|
+
chances['player_gammon_pct'] = float(prob_pattern.group(2)) * 100
|
|
463
|
+
chances['player_backgammon_pct'] = float(prob_pattern.group(3)) * 100
|
|
464
|
+
chances['opponent_win_pct'] = float(prob_pattern.group(4)) * 100
|
|
465
|
+
chances['opponent_gammon_pct'] = float(prob_pattern.group(5)) * 100
|
|
466
|
+
chances['opponent_backgammon_pct'] = float(prob_pattern.group(6)) * 100
|
|
467
|
+
|
|
468
|
+
return chances
|