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.

Files changed (56) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +373 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +224 -0
  7. ankigammon/anki/apkg_exporter.py +123 -0
  8. ankigammon/anki/card_generator.py +1307 -0
  9. ankigammon/anki/card_styles.py +1034 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +209 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +597 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +163 -0
  15. ankigammon/gui/dialogs/input_dialog.py +776 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +384 -0
  18. ankigammon/gui/format_detector.py +292 -0
  19. ankigammon/gui/main_window.py +1071 -0
  20. ankigammon/gui/resources/icon.icns +0 -0
  21. ankigammon/gui/resources/icon.ico +0 -0
  22. ankigammon/gui/resources/icon.png +0 -0
  23. ankigammon/gui/resources/style.qss +394 -0
  24. ankigammon/gui/resources.py +26 -0
  25. ankigammon/gui/widgets/__init__.py +8 -0
  26. ankigammon/gui/widgets/position_list.py +193 -0
  27. ankigammon/gui/widgets/smart_input.py +268 -0
  28. ankigammon/models.py +322 -0
  29. ankigammon/parsers/__init__.py +7 -0
  30. ankigammon/parsers/gnubg_parser.py +454 -0
  31. ankigammon/parsers/xg_binary_parser.py +870 -0
  32. ankigammon/parsers/xg_text_parser.py +729 -0
  33. ankigammon/renderer/__init__.py +5 -0
  34. ankigammon/renderer/animation_controller.py +406 -0
  35. ankigammon/renderer/animation_helper.py +221 -0
  36. ankigammon/renderer/color_schemes.py +145 -0
  37. ankigammon/renderer/svg_board_renderer.py +824 -0
  38. ankigammon/settings.py +239 -0
  39. ankigammon/thirdparty/__init__.py +7 -0
  40. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  41. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  42. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  43. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  44. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  45. ankigammon/utils/__init__.py +13 -0
  46. ankigammon/utils/gnubg_analyzer.py +431 -0
  47. ankigammon/utils/gnuid.py +622 -0
  48. ankigammon/utils/move_parser.py +239 -0
  49. ankigammon/utils/ogid.py +335 -0
  50. ankigammon/utils/xgid.py +419 -0
  51. ankigammon-1.0.0.dist-info/METADATA +370 -0
  52. ankigammon-1.0.0.dist-info/RECORD +56 -0
  53. ankigammon-1.0.0.dist-info/WHEEL +5 -0
  54. ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
  55. ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
  56. 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)