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,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"]
|