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,268 @@
1
+ """
2
+ Smart input widget with auto-detection and visual feedback.
3
+ """
4
+
5
+ from PySide6.QtWidgets import (
6
+ QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
7
+ QLabel, QFrame, QPushButton
8
+ )
9
+ from PySide6.QtCore import Qt, Signal, QTimer
10
+ from PySide6.QtGui import QFont
11
+ import qtawesome as qta
12
+
13
+ from ankigammon.settings import Settings
14
+ from ankigammon.gui.format_detector import FormatDetector, DetectionResult, InputFormat
15
+
16
+
17
+ class SmartInputWidget(QWidget):
18
+ """
19
+ Input widget with intelligent format detection.
20
+
21
+ Signals:
22
+ format_detected(DetectionResult): Emitted when format is detected
23
+ """
24
+
25
+ format_detected = Signal(DetectionResult)
26
+
27
+ def __init__(self, settings: Settings, parent=None):
28
+ super().__init__(parent)
29
+ self.settings = settings
30
+ self.detector = FormatDetector(settings)
31
+ self.last_result = None
32
+
33
+ # Debounce timer for detection
34
+ self.detection_timer = QTimer()
35
+ self.detection_timer.setSingleShot(True)
36
+ self.detection_timer.timeout.connect(self._run_detection)
37
+
38
+ self._setup_ui()
39
+
40
+ def _setup_ui(self):
41
+ """Initialize the user interface."""
42
+ layout = QVBoxLayout(self)
43
+ layout.setContentsMargins(0, 0, 0, 0)
44
+ layout.setSpacing(12)
45
+
46
+ # Label
47
+ label = QLabel("Input Text:")
48
+ label.setStyleSheet("font-weight: 600; color: #cdd6f4;")
49
+ layout.addWidget(label)
50
+
51
+ # Text input area
52
+ self.text_area = QPlainTextEdit()
53
+ self.text_area.setPlaceholderText(
54
+ "Paste XG analysis or position IDs here...\n\n"
55
+ "Examples:\n"
56
+ "• Full XG analysis (Ctrl+C from eXtreme Gammon)\n"
57
+ "• XGID, OGID, or GNUID position IDs (one per line)\n"
58
+ "• Mixed formats supported - auto-detected"
59
+ )
60
+
61
+ # Use fixed-width font for better XGID readability
62
+ font = QFont("Consolas", 10)
63
+ if not font.exactMatch():
64
+ font = QFont("Courier New", 10)
65
+ self.text_area.setFont(font)
66
+
67
+ self.text_area.setLineWrapMode(QPlainTextEdit.NoWrap)
68
+ self.text_area.setTabChangesFocus(True)
69
+ self.text_area.setMinimumHeight(300)
70
+
71
+ # Dark theme styling
72
+ self.text_area.setStyleSheet("""
73
+ QPlainTextEdit {
74
+ background-color: #1e1e2e;
75
+ color: #cdd6f4;
76
+ border: 2px solid #313244;
77
+ border-radius: 8px;
78
+ padding: 12px;
79
+ selection-background-color: #585b70;
80
+ }
81
+ QPlainTextEdit:focus {
82
+ border-color: #89b4fa;
83
+ }
84
+ """)
85
+
86
+ self.text_area.textChanged.connect(self._on_text_changed)
87
+ layout.addWidget(self.text_area, stretch=1)
88
+
89
+ # Feedback container (outer wrapper with rounded corners)
90
+ self.feedback_container = QWidget()
91
+ self.feedback_container.setStyleSheet("""
92
+ QWidget {
93
+ background-color: #313244;
94
+ border-radius: 6px;
95
+ }
96
+ """)
97
+ self.feedback_container.setVisible(False)
98
+
99
+ container_layout = QHBoxLayout(self.feedback_container)
100
+ container_layout.setContentsMargins(0, 0, 0, 0)
101
+ container_layout.setSpacing(0)
102
+
103
+ # Left accent bar (separate widget - avoids Qt border-left + border-radius bug)
104
+ self.accent_bar = QWidget()
105
+ self.accent_bar.setFixedWidth(4)
106
+ self.accent_bar.setStyleSheet("background-color: #6c7086;")
107
+ container_layout.addWidget(self.accent_bar)
108
+
109
+ # Inner content panel
110
+ self.feedback_panel = QWidget()
111
+ self.feedback_panel.setStyleSheet("background-color: transparent;")
112
+ container_layout.addWidget(self.feedback_panel, stretch=1)
113
+
114
+ feedback_layout = QHBoxLayout(self.feedback_panel)
115
+ feedback_layout.setContentsMargins(12, 12, 12, 12)
116
+ feedback_layout.setSpacing(12) # Add spacing between icon and text
117
+
118
+ # Icon
119
+ self.feedback_icon = QLabel()
120
+ self.feedback_icon.setPixmap(qta.icon('fa6s.circle-info', color='#60a5fa').pixmap(20, 20))
121
+ self.feedback_icon.setMinimumSize(20, 20) # Minimum size instead of fixed
122
+ self.feedback_icon.setAlignment(Qt.AlignCenter)
123
+ self.feedback_icon.setScaledContents(False) # Prevent pixmap stretching/artifacts
124
+ feedback_layout.addWidget(self.feedback_icon, alignment=Qt.AlignTop)
125
+
126
+ # Text content
127
+ text_content = QVBoxLayout()
128
+ text_content.setSpacing(4)
129
+
130
+ self.feedback_title = QLabel()
131
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px;")
132
+ text_content.addWidget(self.feedback_title)
133
+
134
+ self.feedback_detail = QLabel()
135
+ self.feedback_detail.setStyleSheet("font-size: 12px; color: #a6adc8;")
136
+ self.feedback_detail.setWordWrap(True)
137
+ text_content.addWidget(self.feedback_detail)
138
+
139
+ feedback_layout.addLayout(text_content, stretch=1)
140
+
141
+ # Override button
142
+ self.override_btn = QPushButton("Override...")
143
+ self.override_btn.setVisible(False)
144
+ self.override_btn.setStyleSheet("""
145
+ QPushButton {
146
+ background-color: #45475a;
147
+ color: #cdd6f4;
148
+ border: none;
149
+ padding: 6px 12px;
150
+ border-radius: 4px;
151
+ font-size: 11px;
152
+ }
153
+ QPushButton:hover {
154
+ background-color: #585b70;
155
+ }
156
+ """)
157
+ self.override_btn.setCursor(Qt.PointingHandCursor)
158
+ feedback_layout.addWidget(self.override_btn, alignment=Qt.AlignTop)
159
+
160
+ layout.addWidget(self.feedback_container)
161
+
162
+ def _on_text_changed(self):
163
+ """Handle text change (debounced)."""
164
+ # Cancel previous timer, start new one
165
+ self.detection_timer.stop()
166
+ self.detection_timer.start(500) # 500ms debounce
167
+
168
+ def _run_detection(self):
169
+ """Run format detection (after debounce)."""
170
+ text = self.text_area.toPlainText()
171
+
172
+ if not text.strip():
173
+ self.feedback_container.setVisible(False)
174
+ self.last_result = None
175
+ return
176
+
177
+ result = self.detector.detect(text)
178
+ self.last_result = result
179
+ self._update_feedback_ui(result)
180
+ self.format_detected.emit(result)
181
+
182
+ def _set_feedback_icon(self, icon_name: str, color: str):
183
+ """Helper to properly set feedback icon."""
184
+ self.feedback_icon.clear() # Clear old pixmap first
185
+ self.feedback_icon.setPixmap(qta.icon(icon_name, color=color).pixmap(20, 20))
186
+
187
+ def _set_feedback_style(self, bg_color: str, accent_color: str):
188
+ """Helper to properly set feedback panel style (avoids Qt border-left + border-radius bug)."""
189
+ self.feedback_container.setStyleSheet(f"""
190
+ QWidget {{
191
+ background-color: {bg_color};
192
+ border-radius: 6px;
193
+ }}
194
+ """)
195
+ self.accent_bar.setStyleSheet(f"background-color: {accent_color};")
196
+
197
+ def _update_feedback_ui(self, result: DetectionResult):
198
+ """Update feedback panel with detection result."""
199
+ self.feedback_container.setVisible(True)
200
+
201
+ # Update icon and styling based on result
202
+ if result.format == InputFormat.POSITION_IDS:
203
+ if result.warnings:
204
+ # Warning state (GnuBG not configured)
205
+ self._set_feedback_icon('fa6s.triangle-exclamation', '#fab387')
206
+ self._set_feedback_style('#2e2416', '#f9e2af')
207
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #f9e2af;")
208
+ self.feedback_title.setText(f"{result.details}")
209
+ self.feedback_detail.setText(
210
+ result.warnings[0] + "\nConfigure GnuBG in Settings to analyze positions."
211
+ )
212
+ else:
213
+ # Success state
214
+ self._set_feedback_icon('fa6s.circle-check', '#a6e3a1')
215
+ self._set_feedback_style('#1e2d1f', '#a6e3a1')
216
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #a6e3a1;")
217
+ self.feedback_title.setText(f"{result.details}")
218
+
219
+ # Calculate estimated time
220
+ est_seconds = result.count * 5 # ~5 seconds per position
221
+ self.feedback_detail.setText(
222
+ f"Will analyze with GnuBG ({self.settings.gnubg_analysis_ply}-ply)\n"
223
+ f"Estimated time: ~{est_seconds} seconds"
224
+ )
225
+
226
+ elif result.format == InputFormat.FULL_ANALYSIS:
227
+ # Success state (blue)
228
+ self._set_feedback_icon('fa6s.circle-check', '#89b4fa')
229
+ self._set_feedback_style('#1e2633', '#89b4fa')
230
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #89b4fa;")
231
+ self.feedback_title.setText(f"{result.details}")
232
+
233
+ # Show preview of first position if available
234
+ preview_text = "Ready to add to export list"
235
+ if result.position_previews:
236
+ preview_text += f"\nFirst position: {result.position_previews[0]}"
237
+
238
+ if result.warnings:
239
+ preview_text += f"\n{result.warnings[0]}"
240
+
241
+ self.feedback_detail.setText(preview_text)
242
+
243
+ else:
244
+ # Unknown/error state
245
+ self._set_feedback_icon('fa6s.triangle-exclamation', '#fab387')
246
+ self._set_feedback_style('#2e2416', '#fab387')
247
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #fab387;")
248
+ self.feedback_title.setText(f"{result.details}")
249
+
250
+ warning_text = "Paste XGID/OGID/GNUID or full XG analysis text"
251
+ if result.warnings:
252
+ warning_text = "\n".join(result.warnings)
253
+
254
+ self.feedback_detail.setText(warning_text)
255
+
256
+ def get_text(self) -> str:
257
+ """Get current input text."""
258
+ return self.text_area.toPlainText()
259
+
260
+ def clear_text(self):
261
+ """Clear input text."""
262
+ self.text_area.clear()
263
+ self.feedback_container.setVisible(False)
264
+ self.last_result = None
265
+
266
+ def get_last_result(self) -> DetectionResult:
267
+ """Get last detection result."""
268
+ return self.last_result
ankigammon/models.py ADDED
@@ -0,0 +1,322 @@
1
+ """Data models for backgammon positions, moves, and decisions."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import List, Optional, Tuple
6
+
7
+
8
+ class Player(Enum):
9
+ """Player identifier."""
10
+ X = "X" # Top player
11
+ O = "O" # Bottom player
12
+
13
+
14
+ class CubeState(Enum):
15
+ """Doubling cube state."""
16
+ CENTERED = "centered"
17
+ X_OWNS = "x_owns"
18
+ O_OWNS = "o_owns"
19
+
20
+
21
+ class DecisionType(Enum):
22
+ """Type of decision."""
23
+ CHECKER_PLAY = "checker_play"
24
+ CUBE_ACTION = "cube_action"
25
+
26
+
27
+ @dataclass
28
+ class Position:
29
+ """
30
+ Represents a backgammon position.
31
+
32
+ Board representation:
33
+ - points[0] = bar for X (top player)
34
+ - points[1-24] = board points (point 24 is X's home, point 1 is O's home)
35
+ - points[25] = bar for O (bottom player)
36
+
37
+ Positive numbers = X checkers, negative numbers = O checkers
38
+ """
39
+ points: List[int] = field(default_factory=lambda: [0] * 26)
40
+ x_off: int = 0 # Checkers borne off by X
41
+ o_off: int = 0 # Checkers borne off by O
42
+
43
+ def __post_init__(self):
44
+ """Validate position."""
45
+ if len(self.points) != 26:
46
+ raise ValueError("Position must have exactly 26 points (0=X bar, 1-24=board, 25=O bar)")
47
+
48
+ @classmethod
49
+ def from_xgid(cls, xgid: str) -> 'Position':
50
+ """
51
+ Parse a position from XGID format.
52
+ XGID format: e.g., "XGID=-b----E-C---eE---c-e----B-:1:0:1:63:0:0:0:0:10"
53
+ """
54
+ # Import here to avoid circular dependency
55
+ from ankigammon.utils.xgid import parse_xgid
56
+ position, _ = parse_xgid(xgid)
57
+ return position
58
+
59
+ @classmethod
60
+ def from_ogid(cls, ogid: str) -> 'Position':
61
+ """
62
+ Parse a position from OGID format.
63
+ OGID format: e.g., "11jjjjjhhhccccc:ooddddd88866666:N0N::W:IW:0:0:1:0"
64
+ """
65
+ # Import here to avoid circular dependency
66
+ from ankigammon.utils.ogid import parse_ogid
67
+ position, _ = parse_ogid(ogid)
68
+ return position
69
+
70
+ @classmethod
71
+ def from_gnuid(cls, gnuid: str) -> 'Position':
72
+ """
73
+ Parse a position from GNUID format.
74
+ GNUID format: e.g., "4HPwATDgc/ABMA:8IhuACAACAAE"
75
+ """
76
+ # Import here to avoid circular dependency
77
+ from ankigammon.utils.gnuid import parse_gnuid
78
+ position, _ = parse_gnuid(gnuid)
79
+ return position
80
+
81
+ def to_xgid(
82
+ self,
83
+ cube_value: int = 1,
84
+ cube_owner: 'CubeState' = None,
85
+ dice: Optional[Tuple[int, int]] = None,
86
+ on_roll: 'Player' = None,
87
+ score_x: int = 0,
88
+ score_o: int = 0,
89
+ match_length: int = 0,
90
+ crawford_jacoby: int = 0,
91
+ ) -> str:
92
+ """Convert position to XGID format."""
93
+ # Import here to avoid circular dependency
94
+ from ankigammon.utils.xgid import encode_xgid
95
+ if cube_owner is None:
96
+ cube_owner = CubeState.CENTERED
97
+ if on_roll is None:
98
+ on_roll = Player.O
99
+ return encode_xgid(
100
+ self,
101
+ cube_value=cube_value,
102
+ cube_owner=cube_owner,
103
+ dice=dice,
104
+ on_roll=on_roll,
105
+ score_x=score_x,
106
+ score_o=score_o,
107
+ match_length=match_length,
108
+ crawford_jacoby=crawford_jacoby,
109
+ )
110
+
111
+ def to_ogid(
112
+ self,
113
+ cube_value: int = 1,
114
+ cube_owner: 'CubeState' = None,
115
+ cube_action: str = 'N',
116
+ dice: Optional[Tuple[int, int]] = None,
117
+ on_roll: 'Player' = None,
118
+ game_state: str = '',
119
+ score_x: int = 0,
120
+ score_o: int = 0,
121
+ match_length: Optional[int] = None,
122
+ match_modifier: str = '',
123
+ only_position: bool = False,
124
+ ) -> str:
125
+ """Convert position to OGID format."""
126
+ # Import here to avoid circular dependency
127
+ from ankigammon.utils.ogid import encode_ogid
128
+ if cube_owner is None:
129
+ cube_owner = CubeState.CENTERED
130
+ return encode_ogid(
131
+ self,
132
+ cube_value=cube_value,
133
+ cube_owner=cube_owner,
134
+ cube_action=cube_action,
135
+ dice=dice,
136
+ on_roll=on_roll,
137
+ game_state=game_state,
138
+ score_x=score_x,
139
+ score_o=score_o,
140
+ match_length=match_length,
141
+ match_modifier=match_modifier,
142
+ only_position=only_position,
143
+ )
144
+
145
+ def to_gnuid(
146
+ self,
147
+ cube_value: int = 1,
148
+ cube_owner: 'CubeState' = None,
149
+ dice: Optional[Tuple[int, int]] = None,
150
+ on_roll: 'Player' = None,
151
+ score_x: int = 0,
152
+ score_o: int = 0,
153
+ match_length: int = 0,
154
+ crawford: bool = False,
155
+ only_position: bool = False,
156
+ ) -> str:
157
+ """Convert position to GNUID format."""
158
+ # Import here to avoid circular dependency
159
+ from ankigammon.utils.gnuid import encode_gnuid
160
+ if cube_owner is None:
161
+ cube_owner = CubeState.CENTERED
162
+ if on_roll is None:
163
+ on_roll = Player.X
164
+ return encode_gnuid(
165
+ self,
166
+ cube_value=cube_value,
167
+ cube_owner=cube_owner,
168
+ dice=dice,
169
+ on_roll=on_roll,
170
+ score_x=score_x,
171
+ score_o=score_o,
172
+ match_length=match_length,
173
+ crawford=crawford,
174
+ only_position=only_position,
175
+ )
176
+
177
+ def copy(self) -> 'Position':
178
+ """Create a deep copy of the position."""
179
+ return Position(
180
+ points=self.points.copy(),
181
+ x_off=self.x_off,
182
+ o_off=self.o_off
183
+ )
184
+
185
+
186
+ @dataclass
187
+ class Move:
188
+ """
189
+ Represents a candidate move with its analysis.
190
+ """
191
+ notation: str # e.g., "13/9 6/5" or "double/take" (for MCQ and answer display)
192
+ equity: float # Equity of this move
193
+ error: float = 0.0 # Error compared to best move (0 for best move)
194
+ rank: int = 1 # Rank among all candidates (including synthetic, 1 = best)
195
+ xg_rank: Optional[int] = None # Order in XG's "Cubeful Equities:" section (1-3)
196
+ xg_error: Optional[float] = None # Error as shown by XG (relative to first option in Cubeful Equities)
197
+ xg_notation: Optional[str] = None # Original notation from XG (e.g., "No double" not "No double/Take")
198
+ resulting_position: Optional[Position] = None # Position after this move (if available)
199
+ from_xg_analysis: bool = True # True if from XG's analysis, False if synthetically generated
200
+ was_played: bool = False # True if this was the move actually played in the game
201
+ # Winning chances percentages
202
+ player_win_pct: Optional[float] = None # Player winning percentage (e.g., 52.68)
203
+ player_gammon_pct: Optional[float] = None # Player gammon percentage (e.g., 14.35)
204
+ player_backgammon_pct: Optional[float] = None # Player backgammon percentage (e.g., 0.69)
205
+ opponent_win_pct: Optional[float] = None # Opponent winning percentage (e.g., 47.32)
206
+ opponent_gammon_pct: Optional[float] = None # Opponent gammon percentage (e.g., 12.42)
207
+ opponent_backgammon_pct: Optional[float] = None # Opponent backgammon percentage (e.g., 0.55)
208
+
209
+ def __str__(self) -> str:
210
+ """Human-readable representation."""
211
+ if self.rank == 1:
212
+ return f"{self.notation} (Equity: {self.equity:.3f})"
213
+ else:
214
+ return f"{self.notation} (Equity: {self.equity:.3f}, Error: {self.error:.3f})"
215
+
216
+
217
+ @dataclass
218
+ class Decision:
219
+ """
220
+ Represents a single decision point from XG analysis.
221
+ """
222
+ # Position information
223
+ position: Position
224
+ position_image_path: Optional[str] = None # Path to board image (from HTML export)
225
+ xgid: Optional[str] = None
226
+
227
+ # Game context
228
+ on_roll: Player = Player.O
229
+ dice: Optional[Tuple[int, int]] = None # None for cube decisions
230
+ score_x: int = 0
231
+ score_o: int = 0
232
+ match_length: int = 0 # 0 for money games
233
+ crawford: bool = False # True if Crawford game (no doubling allowed)
234
+ cube_value: int = 1
235
+ cube_owner: CubeState = CubeState.CENTERED
236
+
237
+ # Decision analysis
238
+ decision_type: DecisionType = DecisionType.CHECKER_PLAY
239
+ candidate_moves: List[Move] = field(default_factory=list)
240
+
241
+ # Winning chances percentages (for cube decisions)
242
+ player_win_pct: Optional[float] = None
243
+ player_gammon_pct: Optional[float] = None
244
+ player_backgammon_pct: Optional[float] = None
245
+ opponent_win_pct: Optional[float] = None
246
+ opponent_gammon_pct: Optional[float] = None
247
+ opponent_backgammon_pct: Optional[float] = None
248
+
249
+ # Source metadata
250
+ source_file: Optional[str] = None
251
+ game_number: Optional[int] = None
252
+ move_number: Optional[int] = None
253
+
254
+ # User annotations
255
+ note: Optional[str] = None # User's note/comment/explanation for this position
256
+
257
+ def get_best_move(self) -> Optional[Move]:
258
+ """Get the best move (rank 1)."""
259
+ for move in self.candidate_moves:
260
+ if move.rank == 1:
261
+ return move
262
+ return self.candidate_moves[0] if self.candidate_moves else None
263
+
264
+ def get_short_display_text(self) -> str:
265
+ """Get short display text for list views."""
266
+ # Build score/game type string
267
+ if self.match_length > 0:
268
+ # Match play - show score and match info
269
+ score = f"{self.score_x}-{self.score_o} of {self.match_length}"
270
+ if self.crawford:
271
+ score += " Crawford"
272
+ else:
273
+ # Money game - just show "Money"
274
+ score = "Money"
275
+
276
+ if self.decision_type == DecisionType.CHECKER_PLAY:
277
+ dice_str = f"{self.dice[0]}{self.dice[1]}" if self.dice else "—"
278
+ return f"Checker | {dice_str} | {score}"
279
+ else:
280
+ # Cube decision - no dice to show
281
+ return f"Cube | {score}"
282
+
283
+ def get_metadata_text(self) -> str:
284
+ """Get formatted metadata for card display."""
285
+ dice_str = f"{self.dice[0]}{self.dice[1]}" if self.dice else "N/A"
286
+
287
+ # Show em dash when cube is centered, otherwise just show value
288
+ if self.cube_owner == CubeState.CENTERED:
289
+ cube_str = "—"
290
+ else:
291
+ cube_str = f"{self.cube_value}"
292
+
293
+ # Map Player enum to color names
294
+ # Player.X = TOP player (plays with white checkers from top)
295
+ # Player.O = BOTTOM player (plays with black checkers from bottom)
296
+ player_name = "White" if self.on_roll == Player.X else "Black"
297
+
298
+ # Build metadata string based on game type
299
+ if self.match_length > 0:
300
+ # Match play - show score and match info
301
+ match_str = f"{self.match_length}pt"
302
+ if self.crawford:
303
+ match_str += " (Crawford)"
304
+ return (
305
+ f"{player_name} | "
306
+ f"Dice: {dice_str} | "
307
+ f"Score: {self.score_x}-{self.score_o} | "
308
+ f"Cube: {cube_str} | "
309
+ f"Match: {match_str}"
310
+ )
311
+ else:
312
+ # Money game - don't show score, just "Money"
313
+ return (
314
+ f"{player_name} | "
315
+ f"Dice: {dice_str} | "
316
+ f"Cube: {cube_str} | "
317
+ f"Money"
318
+ )
319
+
320
+ def __str__(self) -> str:
321
+ """Human-readable representation."""
322
+ return f"Decision({self.decision_type.value}, {self.get_metadata_text()})"
@@ -0,0 +1,7 @@
1
+ """Parsers for XG file formats and GnuBG output."""
2
+
3
+ from ankigammon.parsers.xg_text_parser import XGTextParser
4
+ from ankigammon.parsers.gnubg_parser import GNUBGParser
5
+ from ankigammon.parsers.xg_binary_parser import XGBinaryParser
6
+
7
+ __all__ = ["XGTextParser", "GNUBGParser", "XGBinaryParser"]