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,431 @@
|
|
|
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 subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import multiprocessing
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Tuple, List, Callable, Optional
|
|
14
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
15
|
+
|
|
16
|
+
from ankigammon.models import DecisionType
|
|
17
|
+
from ankigammon.utils.xgid import parse_xgid
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GNUBGAnalyzer:
|
|
21
|
+
"""Wrapper for gnubg-cli.exe command-line interface."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, gnubg_path: str, analysis_ply: int = 3):
|
|
24
|
+
"""
|
|
25
|
+
Initialize GnuBG analyzer.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
gnubg_path: Path to gnubg-cli.exe executable
|
|
29
|
+
analysis_ply: Analysis depth in plies (default: 3)
|
|
30
|
+
"""
|
|
31
|
+
self.gnubg_path = gnubg_path
|
|
32
|
+
self.analysis_ply = analysis_ply
|
|
33
|
+
|
|
34
|
+
# Validate gnubg path
|
|
35
|
+
if not Path(gnubg_path).exists():
|
|
36
|
+
raise FileNotFoundError(f"GnuBG executable not found: {gnubg_path}")
|
|
37
|
+
|
|
38
|
+
def analyze_position(self, position_id: str) -> Tuple[str, DecisionType]:
|
|
39
|
+
"""
|
|
40
|
+
Analyze a position from XGID or GNUID.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
position_id: Position identifier (XGID or GNUID format)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Tuple of (gnubg_output_text, decision_type)
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If position_id format is invalid
|
|
50
|
+
subprocess.CalledProcessError: If gnubg execution fails
|
|
51
|
+
"""
|
|
52
|
+
# Validate position_id
|
|
53
|
+
if position_id is None:
|
|
54
|
+
raise ValueError("position_id cannot be None. Decision object must have xgid field populated.")
|
|
55
|
+
|
|
56
|
+
# Determine if it's XGID or GNUID and extract decision type
|
|
57
|
+
decision_type = self._determine_decision_type(position_id)
|
|
58
|
+
|
|
59
|
+
# Create command file
|
|
60
|
+
command_file = self._create_command_file(position_id, decision_type)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Execute gnubg
|
|
64
|
+
output = self._run_gnubg(command_file)
|
|
65
|
+
return output, decision_type
|
|
66
|
+
finally:
|
|
67
|
+
# Cleanup temp file
|
|
68
|
+
try:
|
|
69
|
+
os.unlink(command_file)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def analyze_positions_parallel(
|
|
74
|
+
self,
|
|
75
|
+
position_ids: List[str],
|
|
76
|
+
max_workers: Optional[int] = None,
|
|
77
|
+
progress_callback: Optional[Callable[[int, int], None]] = None
|
|
78
|
+
) -> List[Tuple[str, DecisionType]]:
|
|
79
|
+
"""
|
|
80
|
+
Analyze multiple positions in parallel.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
position_ids: List of position identifiers (XGID or GNUID format)
|
|
84
|
+
max_workers: Maximum number of parallel workers (default: min(cpu_count, 8))
|
|
85
|
+
progress_callback: Optional callback function(completed, total) for progress updates
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of tuples (gnubg_output_text, decision_type) in same order as position_ids
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If any position_id format is invalid
|
|
92
|
+
subprocess.CalledProcessError: If any gnubg execution fails
|
|
93
|
+
"""
|
|
94
|
+
if not position_ids:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
# Determine number of workers
|
|
98
|
+
if max_workers is None:
|
|
99
|
+
max_workers = min(multiprocessing.cpu_count(), 8)
|
|
100
|
+
|
|
101
|
+
# Use single-threaded for small batches (overhead not worth it)
|
|
102
|
+
if len(position_ids) <= 2:
|
|
103
|
+
results = []
|
|
104
|
+
for i, pos_id in enumerate(position_ids):
|
|
105
|
+
result = self.analyze_position(pos_id)
|
|
106
|
+
results.append(result)
|
|
107
|
+
if progress_callback:
|
|
108
|
+
progress_callback(i + 1, len(position_ids))
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
# Prepare arguments for parallel processing
|
|
112
|
+
args_list = [(self.gnubg_path, self.analysis_ply, pos_id) for pos_id in position_ids]
|
|
113
|
+
|
|
114
|
+
# Execute in parallel with progress tracking
|
|
115
|
+
results = [None] * len(position_ids) # Pre-allocate results list
|
|
116
|
+
completed = 0
|
|
117
|
+
|
|
118
|
+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
119
|
+
# Submit all tasks
|
|
120
|
+
future_to_idx = {
|
|
121
|
+
executor.submit(_analyze_position_worker, *args): idx
|
|
122
|
+
for idx, args in enumerate(args_list)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Collect results as they complete
|
|
126
|
+
for future in as_completed(future_to_idx):
|
|
127
|
+
idx = future_to_idx[future]
|
|
128
|
+
try:
|
|
129
|
+
results[idx] = future.result()
|
|
130
|
+
completed += 1
|
|
131
|
+
if progress_callback:
|
|
132
|
+
progress_callback(completed, len(position_ids))
|
|
133
|
+
except Exception as e:
|
|
134
|
+
# Re-raise with context about which position failed
|
|
135
|
+
raise RuntimeError(f"Failed to analyze position {idx} ({position_ids[idx]}): {e}") from e
|
|
136
|
+
|
|
137
|
+
return results
|
|
138
|
+
|
|
139
|
+
def _determine_decision_type(self, position_id: str) -> DecisionType:
|
|
140
|
+
"""
|
|
141
|
+
Determine the decision type from position ID.
|
|
142
|
+
|
|
143
|
+
For XGID: Parse dice field to determine if it's checker play or cube decision
|
|
144
|
+
For GNUID: Default to checker play (would need position parsing to determine)
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
position_id: XGID or GNUID string
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
DecisionType.CHECKER_PLAY or DecisionType.CUBE_ACTION
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If position_id format is invalid
|
|
154
|
+
"""
|
|
155
|
+
# Check if it's XGID format
|
|
156
|
+
if position_id.startswith("XGID=") or ":" in position_id:
|
|
157
|
+
try:
|
|
158
|
+
_, metadata = parse_xgid(position_id)
|
|
159
|
+
|
|
160
|
+
# Check dice field
|
|
161
|
+
dice = metadata.get('dice', None)
|
|
162
|
+
if dice is None:
|
|
163
|
+
# No dice rolled yet - could be cube decision
|
|
164
|
+
# Check if 'decision_type' was set by parse_xgid
|
|
165
|
+
return metadata.get('decision_type', DecisionType.CUBE_ACTION)
|
|
166
|
+
else:
|
|
167
|
+
# Dice rolled - checker play decision
|
|
168
|
+
return DecisionType.CHECKER_PLAY
|
|
169
|
+
|
|
170
|
+
except (ValueError, KeyError) as e:
|
|
171
|
+
raise ValueError(f"Invalid XGID format: {e}")
|
|
172
|
+
else:
|
|
173
|
+
# GNUID format - default to checker play
|
|
174
|
+
# (would need to parse GNUID to determine actual decision type)
|
|
175
|
+
return DecisionType.CHECKER_PLAY
|
|
176
|
+
|
|
177
|
+
def _create_command_file(self, position_id: str, decision_type: DecisionType) -> str:
|
|
178
|
+
"""
|
|
179
|
+
Create a temporary command file for gnubg.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
position_id: XGID or GNUID string
|
|
183
|
+
decision_type: Type of decision to analyze
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Path to temporary command file
|
|
187
|
+
"""
|
|
188
|
+
# Determine which set command to use
|
|
189
|
+
if position_id.startswith("XGID="):
|
|
190
|
+
set_command = f"set xgid {position_id}"
|
|
191
|
+
elif ":" in position_id and not position_id.startswith("XGID="):
|
|
192
|
+
# Likely XGID without prefix
|
|
193
|
+
set_command = f"set xgid XGID={position_id}"
|
|
194
|
+
else:
|
|
195
|
+
# GNUID format
|
|
196
|
+
set_command = f"set gnubgid {position_id}"
|
|
197
|
+
|
|
198
|
+
# Build command sequence
|
|
199
|
+
commands = [
|
|
200
|
+
"set automatic game off",
|
|
201
|
+
"set automatic roll off",
|
|
202
|
+
set_command,
|
|
203
|
+
f"set analysis chequerplay evaluation plies {self.analysis_ply}",
|
|
204
|
+
f"set analysis cubedecision evaluation plies {self.analysis_ply}",
|
|
205
|
+
"set output matchpc off", # Don't show match equity percentages
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
# Add analysis command based on decision type
|
|
209
|
+
if decision_type == DecisionType.CHECKER_PLAY:
|
|
210
|
+
commands.append("hint")
|
|
211
|
+
else:
|
|
212
|
+
# For cube decisions, hint will give cube advice
|
|
213
|
+
commands.append("hint")
|
|
214
|
+
|
|
215
|
+
# Create temp file
|
|
216
|
+
fd, temp_path = tempfile.mkstemp(suffix=".txt", prefix="gnubg_commands_")
|
|
217
|
+
try:
|
|
218
|
+
with os.fdopen(fd, 'w') as f:
|
|
219
|
+
f.write('\n'.join(commands))
|
|
220
|
+
f.write('\n')
|
|
221
|
+
except:
|
|
222
|
+
os.close(fd)
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
return temp_path
|
|
226
|
+
|
|
227
|
+
def _run_gnubg(self, command_file: str) -> str:
|
|
228
|
+
"""
|
|
229
|
+
Execute gnubg-cli.exe with the command file.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
command_file: Path to command file
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Output text from gnubg
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
subprocess.CalledProcessError: If gnubg execution fails
|
|
239
|
+
"""
|
|
240
|
+
# Build command
|
|
241
|
+
# -t: non-interactive mode
|
|
242
|
+
# -c: execute commands from file
|
|
243
|
+
cmd = [self.gnubg_path, "-t", "-c", command_file]
|
|
244
|
+
|
|
245
|
+
# Execute gnubg
|
|
246
|
+
# On Windows, prevent console window from appearing
|
|
247
|
+
kwargs = {
|
|
248
|
+
'capture_output': True,
|
|
249
|
+
'text': True,
|
|
250
|
+
'timeout': 120, # 120 second timeout
|
|
251
|
+
}
|
|
252
|
+
if sys.platform == 'win32':
|
|
253
|
+
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
|
|
254
|
+
|
|
255
|
+
result = subprocess.run(cmd, **kwargs)
|
|
256
|
+
|
|
257
|
+
# Check for errors
|
|
258
|
+
if result.returncode != 0:
|
|
259
|
+
raise subprocess.CalledProcessError(
|
|
260
|
+
result.returncode,
|
|
261
|
+
cmd,
|
|
262
|
+
output=result.stdout,
|
|
263
|
+
stderr=result.stderr
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Return combined stdout and stderr (gnubg may write to either)
|
|
267
|
+
output = result.stdout
|
|
268
|
+
if result.stderr:
|
|
269
|
+
output += "\n" + result.stderr
|
|
270
|
+
|
|
271
|
+
return output
|
|
272
|
+
|
|
273
|
+
def analyze_cube_at_score(
|
|
274
|
+
self,
|
|
275
|
+
position_id: str,
|
|
276
|
+
match_length: int,
|
|
277
|
+
player_away: int,
|
|
278
|
+
opponent_away: int
|
|
279
|
+
) -> dict:
|
|
280
|
+
"""
|
|
281
|
+
Analyze cube decision at a specific match score.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
position_id: XGID position string
|
|
285
|
+
match_length: Match length (e.g., 7 for 7-point match)
|
|
286
|
+
player_away: Points away from match for player on roll
|
|
287
|
+
opponent_away: Points away from match for opponent
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Dictionary with:
|
|
291
|
+
- best_action: Best cube action (e.g., "D/T", "N/T", "D/P")
|
|
292
|
+
- equity_no_double: Equity for no double
|
|
293
|
+
- equity_double_take: Equity for double/take
|
|
294
|
+
- equity_double_pass: Equity for double/pass
|
|
295
|
+
- error_no_double: Error if don't double (when D/T or D/P is best)
|
|
296
|
+
- error_double: Error if double (when N/T is best)
|
|
297
|
+
- error_pass: Error if pass (when D/T is best)
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
ValueError: If position_id format is invalid or analysis fails
|
|
301
|
+
"""
|
|
302
|
+
from ankigammon.utils.xgid import parse_xgid, encode_xgid
|
|
303
|
+
|
|
304
|
+
# Parse original XGID to get position and metadata
|
|
305
|
+
position, metadata = parse_xgid(position_id)
|
|
306
|
+
|
|
307
|
+
# Calculate actual scores from "away" values
|
|
308
|
+
# player_away=2 means player has (match_length - 2) points
|
|
309
|
+
score_on_roll = match_length - player_away
|
|
310
|
+
score_opponent = match_length - opponent_away
|
|
311
|
+
|
|
312
|
+
# Determine which player is on roll
|
|
313
|
+
from ankigammon.models import Player
|
|
314
|
+
on_roll = metadata.get('on_roll')
|
|
315
|
+
|
|
316
|
+
# Map scores to X and O based on who's on roll
|
|
317
|
+
if on_roll == Player.O:
|
|
318
|
+
score_o = score_on_roll
|
|
319
|
+
score_x = score_opponent
|
|
320
|
+
else:
|
|
321
|
+
score_x = score_on_roll
|
|
322
|
+
score_o = score_opponent
|
|
323
|
+
|
|
324
|
+
# Create new XGID with modified match score
|
|
325
|
+
modified_xgid = encode_xgid(
|
|
326
|
+
position=position,
|
|
327
|
+
cube_value=metadata.get('cube_value', 1),
|
|
328
|
+
cube_owner=metadata.get('cube_owner'),
|
|
329
|
+
dice=None, # Cube decision has no dice
|
|
330
|
+
on_roll=on_roll,
|
|
331
|
+
score_x=score_x,
|
|
332
|
+
score_o=score_o,
|
|
333
|
+
match_length=match_length,
|
|
334
|
+
crawford_jacoby=metadata.get('crawford_jacoby', 0),
|
|
335
|
+
max_cube=metadata.get('max_cube', 256)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Analyze the position
|
|
339
|
+
output, decision_type = self.analyze_position(modified_xgid)
|
|
340
|
+
|
|
341
|
+
# Parse cube decision
|
|
342
|
+
from ankigammon.parsers.gnubg_parser import GNUBGParser
|
|
343
|
+
moves = GNUBGParser._parse_cube_decision(output)
|
|
344
|
+
|
|
345
|
+
if not moves:
|
|
346
|
+
raise ValueError(f"Could not parse cube decision from GnuBG output")
|
|
347
|
+
|
|
348
|
+
# Build equity map
|
|
349
|
+
equity_map = {m.notation: m.equity for m in moves}
|
|
350
|
+
|
|
351
|
+
# Find best move
|
|
352
|
+
best_move = next((m for m in moves if m.rank == 1), None)
|
|
353
|
+
if not best_move:
|
|
354
|
+
raise ValueError("Could not determine best cube action")
|
|
355
|
+
|
|
356
|
+
# Get equities for the 3 main actions
|
|
357
|
+
no_double_eq = equity_map.get("No Double/Take", None)
|
|
358
|
+
double_take_eq = equity_map.get("Double/Take", equity_map.get("Redouble/Take", None))
|
|
359
|
+
double_pass_eq = equity_map.get("Double/Pass", equity_map.get("Redouble/Pass", None))
|
|
360
|
+
|
|
361
|
+
# Simplify best action notation for display
|
|
362
|
+
best_action_simplified = self._simplify_cube_notation(best_move.notation)
|
|
363
|
+
|
|
364
|
+
# Calculate errors for wrong decisions
|
|
365
|
+
best_equity = best_move.equity
|
|
366
|
+
error_no_double = None
|
|
367
|
+
error_double = None
|
|
368
|
+
error_pass = None
|
|
369
|
+
|
|
370
|
+
if no_double_eq is not None:
|
|
371
|
+
error_no_double = abs(best_equity - no_double_eq) if best_action_simplified != "N/T" else 0.0
|
|
372
|
+
if double_take_eq is not None:
|
|
373
|
+
error_double = abs(best_equity - double_take_eq) if best_action_simplified not in ["D/T", "TG/T"] else 0.0
|
|
374
|
+
if double_pass_eq is not None:
|
|
375
|
+
error_pass = abs(best_equity - double_pass_eq) if best_action_simplified != "D/P" else 0.0
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
'best_action': best_action_simplified,
|
|
379
|
+
'equity_no_double': no_double_eq,
|
|
380
|
+
'equity_double_take': double_take_eq,
|
|
381
|
+
'equity_double_pass': double_pass_eq,
|
|
382
|
+
'error_no_double': error_no_double,
|
|
383
|
+
'error_double': error_double,
|
|
384
|
+
'error_pass': error_pass
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _simplify_cube_notation(notation: str) -> str:
|
|
389
|
+
"""
|
|
390
|
+
Simplify cube notation for display in score matrix.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
notation: Full notation (e.g., "No Double/Take", "Double/Take")
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Simplified notation (e.g., "N/T", "D/T", "D/P", "TG/T", "TG/P")
|
|
397
|
+
"""
|
|
398
|
+
notation_lower = notation.lower()
|
|
399
|
+
|
|
400
|
+
if "too good" in notation_lower:
|
|
401
|
+
if "take" in notation_lower:
|
|
402
|
+
return "TG/T"
|
|
403
|
+
elif "pass" in notation_lower:
|
|
404
|
+
return "TG/P"
|
|
405
|
+
elif "no double" in notation_lower or "no redouble" in notation_lower:
|
|
406
|
+
return "N/T"
|
|
407
|
+
elif "double" in notation_lower or "redouble" in notation_lower:
|
|
408
|
+
if "take" in notation_lower:
|
|
409
|
+
return "D/T"
|
|
410
|
+
elif "pass" in notation_lower or "drop" in notation_lower:
|
|
411
|
+
return "D/P"
|
|
412
|
+
|
|
413
|
+
return notation
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _analyze_position_worker(gnubg_path: str, analysis_ply: int, position_id: str) -> Tuple[str, DecisionType]:
|
|
417
|
+
"""
|
|
418
|
+
Worker function for parallel position analysis.
|
|
419
|
+
|
|
420
|
+
This is a module-level function to support pickling for multiprocessing.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
gnubg_path: Path to gnubg-cli.exe executable
|
|
424
|
+
analysis_ply: Analysis depth in plies
|
|
425
|
+
position_id: Position identifier (XGID or GNUID format)
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Tuple of (gnubg_output_text, decision_type)
|
|
429
|
+
"""
|
|
430
|
+
analyzer = GNUBGAnalyzer(gnubg_path, analysis_ply)
|
|
431
|
+
return analyzer.analyze_position(position_id)
|