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,590 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GNU Backgammon command-line interface wrapper.
|
|
3
|
+
|
|
4
|
+
Provides functionality to analyze backgammon positions using gnubg-cli.exe.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import multiprocessing
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Tuple, List, Callable, Optional
|
|
15
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
16
|
+
|
|
17
|
+
from ankigammon.models import DecisionType
|
|
18
|
+
from ankigammon.utils.xgid import parse_xgid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GNUBGAnalyzer:
|
|
22
|
+
"""Wrapper for gnubg-cli.exe command-line interface."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, gnubg_path: str, analysis_ply: int = 3):
|
|
25
|
+
"""
|
|
26
|
+
Initialize GnuBG analyzer.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
gnubg_path: Path to gnubg-cli.exe executable
|
|
30
|
+
analysis_ply: Analysis depth in plies (default: 3)
|
|
31
|
+
"""
|
|
32
|
+
self.gnubg_path = gnubg_path
|
|
33
|
+
self.analysis_ply = analysis_ply
|
|
34
|
+
self._current_process = None
|
|
35
|
+
|
|
36
|
+
if not Path(gnubg_path).exists():
|
|
37
|
+
raise FileNotFoundError(f"GnuBG executable not found: {gnubg_path}")
|
|
38
|
+
|
|
39
|
+
def terminate(self):
|
|
40
|
+
"""Terminate any running GnuBG process."""
|
|
41
|
+
if self._current_process is not None:
|
|
42
|
+
try:
|
|
43
|
+
self._current_process.kill()
|
|
44
|
+
self._current_process = None
|
|
45
|
+
except:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def analyze_position(self, position_id: str) -> Tuple[str, DecisionType]:
|
|
49
|
+
"""
|
|
50
|
+
Analyze a position from XGID or GNUID.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
position_id: Position identifier (XGID or GNUID format)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Tuple of (gnubg_output_text, decision_type)
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If position_id format is invalid
|
|
60
|
+
subprocess.CalledProcessError: If gnubg execution fails
|
|
61
|
+
"""
|
|
62
|
+
if position_id is None:
|
|
63
|
+
raise ValueError("position_id cannot be None. Decision object must have xgid field populated.")
|
|
64
|
+
|
|
65
|
+
decision_type = self._determine_decision_type(position_id)
|
|
66
|
+
command_file = self._create_command_file(position_id, decision_type)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
output = self._run_gnubg(command_file)
|
|
70
|
+
return output, decision_type
|
|
71
|
+
finally:
|
|
72
|
+
try:
|
|
73
|
+
os.unlink(command_file)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def analyze_positions_parallel(
|
|
78
|
+
self,
|
|
79
|
+
position_ids: List[str],
|
|
80
|
+
max_workers: Optional[int] = None,
|
|
81
|
+
progress_callback: Optional[Callable[[int, int], None]] = None
|
|
82
|
+
) -> List[Tuple[str, DecisionType]]:
|
|
83
|
+
"""
|
|
84
|
+
Analyze multiple positions in parallel.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
position_ids: List of position identifiers (XGID or GNUID format)
|
|
88
|
+
max_workers: Maximum number of parallel workers (default: min(cpu_count, 8))
|
|
89
|
+
progress_callback: Optional callback for progress updates: callback(completed, total)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of tuples (gnubg_output_text, decision_type) in same order as position_ids
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If any position_id format is invalid
|
|
96
|
+
subprocess.CalledProcessError: If any gnubg execution fails
|
|
97
|
+
"""
|
|
98
|
+
if not position_ids:
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
if max_workers is None:
|
|
102
|
+
max_workers = min(multiprocessing.cpu_count(), 8)
|
|
103
|
+
|
|
104
|
+
if len(position_ids) <= 2:
|
|
105
|
+
results = []
|
|
106
|
+
for i, pos_id in enumerate(position_ids):
|
|
107
|
+
result = self.analyze_position(pos_id)
|
|
108
|
+
results.append(result)
|
|
109
|
+
if progress_callback:
|
|
110
|
+
progress_callback(i + 1, len(position_ids))
|
|
111
|
+
return results
|
|
112
|
+
|
|
113
|
+
args_list = [(self.gnubg_path, self.analysis_ply, pos_id) for pos_id in position_ids]
|
|
114
|
+
results = [None] * len(position_ids)
|
|
115
|
+
completed = 0
|
|
116
|
+
|
|
117
|
+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
118
|
+
future_to_idx = {
|
|
119
|
+
executor.submit(_analyze_position_worker, *args): idx
|
|
120
|
+
for idx, args in enumerate(args_list)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for future in as_completed(future_to_idx):
|
|
124
|
+
idx = future_to_idx[future]
|
|
125
|
+
try:
|
|
126
|
+
results[idx] = future.result()
|
|
127
|
+
completed += 1
|
|
128
|
+
if progress_callback:
|
|
129
|
+
progress_callback(completed, len(position_ids))
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise RuntimeError(f"Failed to analyze position {idx} ({position_ids[idx]}): {e}") from e
|
|
132
|
+
|
|
133
|
+
return results
|
|
134
|
+
|
|
135
|
+
def analyze_match_file(
|
|
136
|
+
self,
|
|
137
|
+
mat_file_path: str,
|
|
138
|
+
max_moves: int = 8,
|
|
139
|
+
progress_callback: Optional[Callable[[str], None]] = None
|
|
140
|
+
) -> List[str]:
|
|
141
|
+
"""
|
|
142
|
+
Analyze entire match file using gnubg and export to text files.
|
|
143
|
+
|
|
144
|
+
Supports both .mat (Jellyfish) and .sgf (Smart Game Format) files.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
mat_file_path: Path to match file (.mat or .sgf)
|
|
148
|
+
max_moves: Maximum number of candidate moves to show (default: 8)
|
|
149
|
+
progress_callback: Optional callback(status_message) for progress updates
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of paths to exported text files (one per game)
|
|
153
|
+
Caller is responsible for cleaning up these temp files after parsing.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
FileNotFoundError: If match file not found
|
|
157
|
+
subprocess.CalledProcessError: If gnubg execution fails
|
|
158
|
+
RuntimeError: If export files were not created
|
|
159
|
+
"""
|
|
160
|
+
mat_path = Path(mat_file_path)
|
|
161
|
+
if not mat_path.exists():
|
|
162
|
+
raise FileNotFoundError(f"Match file not found: {mat_file_path}")
|
|
163
|
+
|
|
164
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="gnubg_match_"))
|
|
165
|
+
output_base = temp_dir / "analyzed_match.txt"
|
|
166
|
+
|
|
167
|
+
if progress_callback:
|
|
168
|
+
progress_callback("Preparing analysis...")
|
|
169
|
+
|
|
170
|
+
mat_path_str = str(mat_path)
|
|
171
|
+
output_path_str = str(output_base)
|
|
172
|
+
|
|
173
|
+
if ' ' in mat_path_str:
|
|
174
|
+
mat_path_str = f'"{mat_path_str}"'
|
|
175
|
+
if ' ' in output_path_str:
|
|
176
|
+
output_path_str = f'"{output_path_str}"'
|
|
177
|
+
|
|
178
|
+
file_ext = mat_path.suffix.lower()
|
|
179
|
+
if file_ext == '.sgf':
|
|
180
|
+
import_cmd = f"load match {mat_path_str}"
|
|
181
|
+
else:
|
|
182
|
+
import_cmd = f"import mat {mat_path_str}"
|
|
183
|
+
|
|
184
|
+
commands = [
|
|
185
|
+
"set automatic game off",
|
|
186
|
+
"set automatic roll off",
|
|
187
|
+
f"set analysis chequerplay evaluation plies {self.analysis_ply}",
|
|
188
|
+
f"set analysis cubedecision evaluation plies {self.analysis_ply}",
|
|
189
|
+
f"set export moves number {max_moves}",
|
|
190
|
+
import_cmd,
|
|
191
|
+
"analyse match",
|
|
192
|
+
f"export match text {output_path_str}",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
command_file = self._create_command_file_from_list(commands)
|
|
196
|
+
|
|
197
|
+
import logging
|
|
198
|
+
logger = logging.getLogger(__name__)
|
|
199
|
+
with open(command_file, 'r') as f:
|
|
200
|
+
logger.info(f"GnuBG command file:\n{f.read()}")
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
if progress_callback:
|
|
204
|
+
progress_callback(f"Analyzing match with GnuBG ({self.analysis_ply}-ply)...")
|
|
205
|
+
|
|
206
|
+
kwargs = {
|
|
207
|
+
'stdout': subprocess.PIPE,
|
|
208
|
+
'stderr': subprocess.PIPE,
|
|
209
|
+
'text': True,
|
|
210
|
+
}
|
|
211
|
+
if sys.platform == 'win32':
|
|
212
|
+
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
|
|
213
|
+
|
|
214
|
+
self._current_process = subprocess.Popen(
|
|
215
|
+
[self.gnubg_path, "-t", "-c", command_file],
|
|
216
|
+
**kwargs
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
stdout, stderr = self._current_process.communicate(timeout=600)
|
|
221
|
+
returncode = self._current_process.returncode
|
|
222
|
+
except subprocess.TimeoutExpired:
|
|
223
|
+
self._current_process.kill()
|
|
224
|
+
stdout, stderr = self._current_process.communicate()
|
|
225
|
+
self._current_process = None
|
|
226
|
+
raise subprocess.CalledProcessError(
|
|
227
|
+
-1,
|
|
228
|
+
[self.gnubg_path, "-t", "-c", command_file],
|
|
229
|
+
output="Process timed out after 10 minutes",
|
|
230
|
+
stderr=""
|
|
231
|
+
)
|
|
232
|
+
finally:
|
|
233
|
+
self._current_process = None
|
|
234
|
+
|
|
235
|
+
import logging
|
|
236
|
+
logger = logging.getLogger(__name__)
|
|
237
|
+
if stdout:
|
|
238
|
+
logger.info(f"GnuBG stdout (first 1000 chars):\n{stdout[:1000]}")
|
|
239
|
+
if stderr:
|
|
240
|
+
logger.warning(f"GnuBG stderr:\n{stderr}")
|
|
241
|
+
|
|
242
|
+
if returncode != 0:
|
|
243
|
+
raise subprocess.CalledProcessError(
|
|
244
|
+
returncode,
|
|
245
|
+
[self.gnubg_path, "-t", "-c", command_file],
|
|
246
|
+
output=stdout,
|
|
247
|
+
stderr=stderr
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if progress_callback:
|
|
251
|
+
progress_callback("Finding exported files...")
|
|
252
|
+
|
|
253
|
+
temp_files = list(temp_dir.glob("*"))
|
|
254
|
+
logger.info(f"Files in temp dir {temp_dir}: {[f.name for f in temp_files]}")
|
|
255
|
+
|
|
256
|
+
exported_files = []
|
|
257
|
+
|
|
258
|
+
if output_base.exists():
|
|
259
|
+
exported_files.append(str(output_base))
|
|
260
|
+
|
|
261
|
+
game_num = 2
|
|
262
|
+
while True:
|
|
263
|
+
next_file = temp_dir / f"analyzed_match_{game_num:03d}.txt"
|
|
264
|
+
if next_file.exists():
|
|
265
|
+
exported_files.append(str(next_file))
|
|
266
|
+
game_num += 1
|
|
267
|
+
else:
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
if not exported_files:
|
|
271
|
+
error_msg = (
|
|
272
|
+
f"GnuBG did not create any export files.\n"
|
|
273
|
+
f"Expected files in: {temp_dir}\n"
|
|
274
|
+
f"Files found: {[f.name for f in temp_files]}\n\n"
|
|
275
|
+
)
|
|
276
|
+
if stdout:
|
|
277
|
+
error_msg += f"GnuBG output:\n{stdout[:500]}\n"
|
|
278
|
+
if stderr:
|
|
279
|
+
error_msg += f"GnuBG errors:\n{stderr[:500]}"
|
|
280
|
+
|
|
281
|
+
raise RuntimeError(error_msg)
|
|
282
|
+
|
|
283
|
+
if exported_files:
|
|
284
|
+
with open(exported_files[0], 'r', encoding='utf-8') as f:
|
|
285
|
+
content = f.read(5000)
|
|
286
|
+
has_analysis = bool(re.search(r'Rolled \d\d \([+-]?\d+\.\d+\):', content))
|
|
287
|
+
if not has_analysis:
|
|
288
|
+
logger.warning("GnuBG exported files but no analysis found")
|
|
289
|
+
logger.warning(f"Expected to find 'Rolled XX (±error):' pattern")
|
|
290
|
+
logger.warning(f"First file preview:\n{content[:800]}")
|
|
291
|
+
raise RuntimeError(
|
|
292
|
+
"GnuBG exported the match but did not include analysis.\n"
|
|
293
|
+
"The 'analyse match' command may have failed.\n\n"
|
|
294
|
+
f"Check logs for GnuBG output."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if progress_callback:
|
|
298
|
+
progress_callback(f"Analysis complete. {len(exported_files)} game(s) exported.")
|
|
299
|
+
|
|
300
|
+
if exported_files:
|
|
301
|
+
import shutil
|
|
302
|
+
debug_path = Path(__file__).parent.parent.parent / "debug_gnubg_output.txt"
|
|
303
|
+
shutil.copy2(exported_files[0], debug_path)
|
|
304
|
+
logger.info(f"Copied first export file to {debug_path}")
|
|
305
|
+
|
|
306
|
+
return exported_files
|
|
307
|
+
|
|
308
|
+
finally:
|
|
309
|
+
# Cleanup command file
|
|
310
|
+
try:
|
|
311
|
+
os.unlink(command_file)
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
def _create_command_file_from_list(self, commands: List[str]) -> str:
|
|
316
|
+
"""
|
|
317
|
+
Create temporary command file from list of commands.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
commands: List of gnubg commands
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Path to temporary command file
|
|
324
|
+
"""
|
|
325
|
+
fd, temp_path = tempfile.mkstemp(suffix=".txt", prefix="gnubg_commands_")
|
|
326
|
+
try:
|
|
327
|
+
with os.fdopen(fd, 'w') as f:
|
|
328
|
+
f.write('\n'.join(commands))
|
|
329
|
+
f.write('\n')
|
|
330
|
+
except:
|
|
331
|
+
os.close(fd)
|
|
332
|
+
raise
|
|
333
|
+
return temp_path
|
|
334
|
+
|
|
335
|
+
def _determine_decision_type(self, position_id: str) -> DecisionType:
|
|
336
|
+
"""
|
|
337
|
+
Determine the decision type from position ID.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
position_id: XGID or GNUID string
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
DecisionType.CHECKER_PLAY or DecisionType.CUBE_ACTION
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
ValueError: If position_id format is invalid
|
|
347
|
+
"""
|
|
348
|
+
if position_id.startswith("XGID=") or ":" in position_id:
|
|
349
|
+
try:
|
|
350
|
+
_, metadata = parse_xgid(position_id)
|
|
351
|
+
|
|
352
|
+
dice = metadata.get('dice', None)
|
|
353
|
+
if dice is None:
|
|
354
|
+
return metadata.get('decision_type', DecisionType.CUBE_ACTION)
|
|
355
|
+
else:
|
|
356
|
+
return DecisionType.CHECKER_PLAY
|
|
357
|
+
|
|
358
|
+
except (ValueError, KeyError) as e:
|
|
359
|
+
raise ValueError(f"Invalid XGID format: {e}")
|
|
360
|
+
else:
|
|
361
|
+
return DecisionType.CHECKER_PLAY
|
|
362
|
+
|
|
363
|
+
def _create_command_file(self, position_id: str, decision_type: DecisionType) -> str:
|
|
364
|
+
"""
|
|
365
|
+
Create a temporary command file for gnubg.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
position_id: XGID or GNUID string
|
|
369
|
+
decision_type: Type of decision to analyze
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Path to temporary command file
|
|
373
|
+
"""
|
|
374
|
+
if position_id.startswith("XGID="):
|
|
375
|
+
set_command = f"set xgid {position_id}"
|
|
376
|
+
elif ":" in position_id and not position_id.startswith("XGID="):
|
|
377
|
+
set_command = f"set xgid XGID={position_id}"
|
|
378
|
+
else:
|
|
379
|
+
set_command = f"set gnubgid {position_id}"
|
|
380
|
+
|
|
381
|
+
commands = [
|
|
382
|
+
"set automatic game off",
|
|
383
|
+
"set automatic roll off",
|
|
384
|
+
set_command,
|
|
385
|
+
f"set analysis chequerplay evaluation plies {self.analysis_ply}",
|
|
386
|
+
f"set analysis cubedecision evaluation plies {self.analysis_ply}",
|
|
387
|
+
"set output matchpc off",
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
if decision_type == DecisionType.CHECKER_PLAY:
|
|
391
|
+
commands.append("hint")
|
|
392
|
+
else:
|
|
393
|
+
commands.append("hint")
|
|
394
|
+
|
|
395
|
+
fd, temp_path = tempfile.mkstemp(suffix=".txt", prefix="gnubg_commands_")
|
|
396
|
+
try:
|
|
397
|
+
with os.fdopen(fd, 'w') as f:
|
|
398
|
+
f.write('\n'.join(commands))
|
|
399
|
+
f.write('\n')
|
|
400
|
+
except:
|
|
401
|
+
os.close(fd)
|
|
402
|
+
raise
|
|
403
|
+
|
|
404
|
+
return temp_path
|
|
405
|
+
|
|
406
|
+
def _run_gnubg(self, command_file: str) -> str:
|
|
407
|
+
"""
|
|
408
|
+
Execute gnubg-cli.exe with the command file.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
command_file: Path to command file
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Output text from gnubg
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
subprocess.CalledProcessError: If gnubg execution fails
|
|
418
|
+
"""
|
|
419
|
+
cmd = [self.gnubg_path, "-t", "-c", command_file]
|
|
420
|
+
|
|
421
|
+
kwargs = {
|
|
422
|
+
'capture_output': True,
|
|
423
|
+
'text': True,
|
|
424
|
+
'timeout': 120,
|
|
425
|
+
}
|
|
426
|
+
if sys.platform == 'win32':
|
|
427
|
+
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
|
|
428
|
+
|
|
429
|
+
result = subprocess.run(cmd, **kwargs)
|
|
430
|
+
|
|
431
|
+
if result.returncode != 0:
|
|
432
|
+
raise subprocess.CalledProcessError(
|
|
433
|
+
result.returncode,
|
|
434
|
+
cmd,
|
|
435
|
+
output=result.stdout,
|
|
436
|
+
stderr=result.stderr
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
output = result.stdout
|
|
440
|
+
if result.stderr:
|
|
441
|
+
output += "\n" + result.stderr
|
|
442
|
+
|
|
443
|
+
return output
|
|
444
|
+
|
|
445
|
+
def analyze_cube_at_score(
|
|
446
|
+
self,
|
|
447
|
+
position_id: str,
|
|
448
|
+
match_length: int,
|
|
449
|
+
player_away: int,
|
|
450
|
+
opponent_away: int
|
|
451
|
+
) -> dict:
|
|
452
|
+
"""
|
|
453
|
+
Analyze cube decision at a specific match score.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
position_id: XGID position string
|
|
457
|
+
match_length: Match length (e.g., 7 for 7-point match)
|
|
458
|
+
player_away: Points away from match for player on roll
|
|
459
|
+
opponent_away: Points away from match for opponent
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
Dictionary with cube analysis results:
|
|
463
|
+
- best_action: Best cube action (e.g., "D/T", "N/T", "D/P")
|
|
464
|
+
- equity_no_double: Equity for no double
|
|
465
|
+
- equity_double_take: Equity for double/take
|
|
466
|
+
- equity_double_pass: Equity for double/pass
|
|
467
|
+
- error_no_double: Error if don't double
|
|
468
|
+
- error_double: Error if double
|
|
469
|
+
- error_pass: Error if pass
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
ValueError: If position_id format is invalid or analysis fails
|
|
473
|
+
"""
|
|
474
|
+
from ankigammon.utils.xgid import parse_xgid, encode_xgid
|
|
475
|
+
|
|
476
|
+
position, metadata = parse_xgid(position_id)
|
|
477
|
+
|
|
478
|
+
score_on_roll = match_length - player_away
|
|
479
|
+
score_opponent = match_length - opponent_away
|
|
480
|
+
|
|
481
|
+
from ankigammon.models import Player
|
|
482
|
+
on_roll = metadata.get('on_roll')
|
|
483
|
+
|
|
484
|
+
if on_roll == Player.O:
|
|
485
|
+
score_o = score_on_roll
|
|
486
|
+
score_x = score_opponent
|
|
487
|
+
else:
|
|
488
|
+
score_x = score_on_roll
|
|
489
|
+
score_o = score_opponent
|
|
490
|
+
|
|
491
|
+
modified_xgid = encode_xgid(
|
|
492
|
+
position=position,
|
|
493
|
+
cube_value=metadata.get('cube_value', 1),
|
|
494
|
+
cube_owner=metadata.get('cube_owner'),
|
|
495
|
+
dice=None,
|
|
496
|
+
on_roll=on_roll,
|
|
497
|
+
score_x=score_x,
|
|
498
|
+
score_o=score_o,
|
|
499
|
+
match_length=match_length,
|
|
500
|
+
crawford_jacoby=metadata.get('crawford_jacoby', 0),
|
|
501
|
+
max_cube=metadata.get('max_cube', 256)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
output, decision_type = self.analyze_position(modified_xgid)
|
|
505
|
+
|
|
506
|
+
from ankigammon.parsers.gnubg_parser import GNUBGParser
|
|
507
|
+
moves = GNUBGParser._parse_cube_decision(output)
|
|
508
|
+
|
|
509
|
+
if not moves:
|
|
510
|
+
raise ValueError(f"Could not parse cube decision from GnuBG output")
|
|
511
|
+
|
|
512
|
+
equity_map = {m.notation: m.equity for m in moves}
|
|
513
|
+
|
|
514
|
+
best_move = next((m for m in moves if m.rank == 1), None)
|
|
515
|
+
if not best_move:
|
|
516
|
+
raise ValueError("Could not determine best cube action")
|
|
517
|
+
|
|
518
|
+
no_double_eq = equity_map.get("No Double/Take", None)
|
|
519
|
+
double_take_eq = equity_map.get("Double/Take", equity_map.get("Redouble/Take", None))
|
|
520
|
+
double_pass_eq = equity_map.get("Double/Pass", equity_map.get("Redouble/Pass", None))
|
|
521
|
+
|
|
522
|
+
best_action_simplified = self._simplify_cube_notation(best_move.notation)
|
|
523
|
+
|
|
524
|
+
best_equity = best_move.equity
|
|
525
|
+
error_no_double = None
|
|
526
|
+
error_double = None
|
|
527
|
+
error_pass = None
|
|
528
|
+
|
|
529
|
+
if no_double_eq is not None:
|
|
530
|
+
error_no_double = abs(best_equity - no_double_eq) if best_action_simplified != "N/T" else 0.0
|
|
531
|
+
if double_take_eq is not None:
|
|
532
|
+
error_double = abs(best_equity - double_take_eq) if best_action_simplified not in ["D/T", "TG/T"] else 0.0
|
|
533
|
+
if double_pass_eq is not None:
|
|
534
|
+
error_pass = abs(best_equity - double_pass_eq) if best_action_simplified != "D/P" else 0.0
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
'best_action': best_action_simplified,
|
|
538
|
+
'equity_no_double': no_double_eq,
|
|
539
|
+
'equity_double_take': double_take_eq,
|
|
540
|
+
'equity_double_pass': double_pass_eq,
|
|
541
|
+
'error_no_double': error_no_double,
|
|
542
|
+
'error_double': error_double,
|
|
543
|
+
'error_pass': error_pass
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _simplify_cube_notation(notation: str) -> str:
|
|
548
|
+
"""
|
|
549
|
+
Simplify cube notation for display in score matrix.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
notation: Full notation (e.g., "No Double/Take", "Double/Take")
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Simplified notation (e.g., "N/T", "D/T", "D/P", "TG/T", "TG/P")
|
|
556
|
+
"""
|
|
557
|
+
notation_lower = notation.lower()
|
|
558
|
+
|
|
559
|
+
if "too good" in notation_lower:
|
|
560
|
+
if "take" in notation_lower:
|
|
561
|
+
return "TG/T"
|
|
562
|
+
elif "pass" in notation_lower:
|
|
563
|
+
return "TG/P"
|
|
564
|
+
elif "no double" in notation_lower or "no redouble" in notation_lower:
|
|
565
|
+
return "N/T"
|
|
566
|
+
elif "double" in notation_lower or "redouble" in notation_lower:
|
|
567
|
+
if "take" in notation_lower:
|
|
568
|
+
return "D/T"
|
|
569
|
+
elif "pass" in notation_lower or "drop" in notation_lower:
|
|
570
|
+
return "D/P"
|
|
571
|
+
|
|
572
|
+
return notation
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _analyze_position_worker(gnubg_path: str, analysis_ply: int, position_id: str) -> Tuple[str, DecisionType]:
|
|
576
|
+
"""
|
|
577
|
+
Worker function for parallel position analysis.
|
|
578
|
+
|
|
579
|
+
This is a module-level function to support pickling for multiprocessing.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
gnubg_path: Path to gnubg-cli.exe executable
|
|
583
|
+
analysis_ply: Analysis depth in plies
|
|
584
|
+
position_id: Position identifier (XGID or GNUID format)
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Tuple of (gnubg_output_text, decision_type)
|
|
588
|
+
"""
|
|
589
|
+
analyzer = GNUBGAnalyzer(gnubg_path, analysis_ply)
|
|
590
|
+
return analyzer.analyze_position(position_id)
|