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,1307 @@
|
|
|
1
|
+
"""Generate Anki card content from XG decisions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import string
|
|
6
|
+
import html
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Dict, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ankigammon.models import Decision, Move, Player
|
|
11
|
+
from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
|
|
12
|
+
from ankigammon.renderer.animation_controller import AnimationController
|
|
13
|
+
from ankigammon.utils.move_parser import MoveParser
|
|
14
|
+
from ankigammon.settings import get_settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CardGenerator:
|
|
18
|
+
"""
|
|
19
|
+
Generates Anki card content from XG decisions.
|
|
20
|
+
|
|
21
|
+
Supports two variants:
|
|
22
|
+
1. Simple: Shows question only (no options)
|
|
23
|
+
2. Text MCQ: Shows move notation as text options
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
output_dir: Path,
|
|
29
|
+
show_options: bool = False,
|
|
30
|
+
interactive_moves: bool = False,
|
|
31
|
+
renderer: Optional[SVGBoardRenderer] = None,
|
|
32
|
+
animation_controller: Optional[AnimationController] = None,
|
|
33
|
+
progress_callback: Optional[callable] = None
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialize the card generator.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
output_dir: Directory for configuration (no media files needed with SVG)
|
|
40
|
+
show_options: If True, show interactive MCQ with clickable options
|
|
41
|
+
interactive_moves: If True, render positions for all moves (clickable analysis)
|
|
42
|
+
renderer: SVG board renderer instance (creates default if None)
|
|
43
|
+
animation_controller: Animation controller instance (creates default if None)
|
|
44
|
+
progress_callback: Optional callback(message: str) for progress updates
|
|
45
|
+
"""
|
|
46
|
+
self.output_dir = Path(output_dir)
|
|
47
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
self.show_options = show_options
|
|
50
|
+
self.interactive_moves = interactive_moves
|
|
51
|
+
self.renderer = renderer or SVGBoardRenderer()
|
|
52
|
+
self.animation_controller = animation_controller or AnimationController()
|
|
53
|
+
self.settings = get_settings()
|
|
54
|
+
self.progress_callback = progress_callback
|
|
55
|
+
|
|
56
|
+
def generate_card(self, decision: Decision, card_id: Optional[str] = None) -> Dict[str, any]:
|
|
57
|
+
"""
|
|
58
|
+
Generate an Anki card from a decision.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
decision: The decision to create a card for
|
|
62
|
+
card_id: Optional card ID (generated if not provided)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dictionary with card data:
|
|
66
|
+
{
|
|
67
|
+
'front': HTML for card front,
|
|
68
|
+
'back': HTML for card back,
|
|
69
|
+
'tags': List of tags
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
72
|
+
if card_id is None:
|
|
73
|
+
card_id = self._generate_id()
|
|
74
|
+
|
|
75
|
+
# Validate that we have candidate moves to generate a card
|
|
76
|
+
if not decision.candidate_moves:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Cannot generate card: decision has no candidate moves. "
|
|
79
|
+
"For XGID-only input, please use GnuBG analysis to populate moves."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Generate position SVG (before move)
|
|
83
|
+
position_svg = self._render_position_svg(decision)
|
|
84
|
+
|
|
85
|
+
# Prepare candidate moves
|
|
86
|
+
from ankigammon.models import DecisionType
|
|
87
|
+
|
|
88
|
+
if decision.decision_type == DecisionType.CUBE_ACTION:
|
|
89
|
+
candidates = decision.candidate_moves[:5]
|
|
90
|
+
else:
|
|
91
|
+
candidates = decision.candidate_moves[:5]
|
|
92
|
+
|
|
93
|
+
# Shuffle candidates for MCQ
|
|
94
|
+
shuffled_candidates, answer_index = self._shuffle_candidates(candidates)
|
|
95
|
+
|
|
96
|
+
# Generate card front
|
|
97
|
+
if self.show_options:
|
|
98
|
+
front_html = self._generate_interactive_mcq_front(
|
|
99
|
+
decision, position_svg, shuffled_candidates
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
front_html = self._generate_simple_front(
|
|
103
|
+
decision, position_svg
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Generate resulting position SVGs
|
|
107
|
+
move_result_svgs = {}
|
|
108
|
+
best_move = decision.get_best_move()
|
|
109
|
+
|
|
110
|
+
if not self.interactive_moves:
|
|
111
|
+
# Only render the best move's resulting position (no animation)
|
|
112
|
+
if best_move:
|
|
113
|
+
result_svg = self._render_resulting_position_svg(decision, best_move)
|
|
114
|
+
else:
|
|
115
|
+
result_svg = None
|
|
116
|
+
else:
|
|
117
|
+
# Interactive mode: generate result SVGs for all moves (for proper number display)
|
|
118
|
+
if self.progress_callback:
|
|
119
|
+
self.progress_callback(f"Rendering board positions...")
|
|
120
|
+
for candidate in candidates:
|
|
121
|
+
if candidate:
|
|
122
|
+
result_svg_for_move = self._render_resulting_position_svg(decision, candidate)
|
|
123
|
+
move_result_svgs[candidate.notation] = result_svg_for_move
|
|
124
|
+
result_svg = None
|
|
125
|
+
|
|
126
|
+
# Generate card back
|
|
127
|
+
if self.progress_callback:
|
|
128
|
+
self.progress_callback("Generating card content...")
|
|
129
|
+
back_html = self._generate_back(
|
|
130
|
+
decision, position_svg, result_svg, candidates, shuffled_candidates,
|
|
131
|
+
answer_index, self.show_options, move_result_svgs
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Generate tags
|
|
135
|
+
tags = self._generate_tags(decision)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
'front': front_html,
|
|
139
|
+
'back': back_html,
|
|
140
|
+
'tags': tags,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _get_metadata_html(self, decision: Decision) -> str:
|
|
144
|
+
"""
|
|
145
|
+
Get metadata HTML with colored player indicator.
|
|
146
|
+
|
|
147
|
+
Returns HTML with inline colored circle representing the checker color.
|
|
148
|
+
"""
|
|
149
|
+
base_metadata = decision.get_metadata_text()
|
|
150
|
+
|
|
151
|
+
# Get actual checker color from the renderer's color scheme
|
|
152
|
+
if decision.on_roll == Player.X:
|
|
153
|
+
checker_color = self.renderer.color_scheme.checker_x
|
|
154
|
+
else:
|
|
155
|
+
checker_color = self.renderer.color_scheme.checker_o
|
|
156
|
+
|
|
157
|
+
# Replace "White" or "Black" with colored circle
|
|
158
|
+
colored_circle = f'<span style="color: {checker_color}; font-size: 1.8em;">●</span>'
|
|
159
|
+
|
|
160
|
+
if decision.on_roll == Player.X:
|
|
161
|
+
metadata_html = base_metadata.replace("White", colored_circle)
|
|
162
|
+
else:
|
|
163
|
+
metadata_html = base_metadata.replace("Black", colored_circle)
|
|
164
|
+
|
|
165
|
+
return metadata_html
|
|
166
|
+
|
|
167
|
+
def _generate_simple_front(
|
|
168
|
+
self,
|
|
169
|
+
decision: Decision,
|
|
170
|
+
position_svg: str
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Generate HTML for simple front (no options)."""
|
|
173
|
+
metadata = self._get_metadata_html(decision)
|
|
174
|
+
|
|
175
|
+
# Determine question text based on decision type
|
|
176
|
+
from ankigammon.models import DecisionType
|
|
177
|
+
if decision.decision_type == DecisionType.CUBE_ACTION:
|
|
178
|
+
question_text = "What is the best cube action?"
|
|
179
|
+
else:
|
|
180
|
+
question_text = "What is the best move?"
|
|
181
|
+
|
|
182
|
+
html = f"""
|
|
183
|
+
<div class="card-front">
|
|
184
|
+
<div class="position-svg">
|
|
185
|
+
{position_svg}
|
|
186
|
+
</div>
|
|
187
|
+
<div class="metadata">{metadata}</div>
|
|
188
|
+
<div class="question">
|
|
189
|
+
<h3>{question_text}</h3>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
"""
|
|
193
|
+
return html
|
|
194
|
+
|
|
195
|
+
def _generate_interactive_mcq_front(
|
|
196
|
+
self,
|
|
197
|
+
decision: Decision,
|
|
198
|
+
position_svg: str,
|
|
199
|
+
candidates: List[Optional[Move]]
|
|
200
|
+
) -> str:
|
|
201
|
+
"""Generate interactive quiz MCQ front with clickable options."""
|
|
202
|
+
metadata = self._get_metadata_html(decision)
|
|
203
|
+
letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
|
|
204
|
+
|
|
205
|
+
# Determine question text based on decision type
|
|
206
|
+
from ankigammon.models import DecisionType
|
|
207
|
+
if decision.decision_type == DecisionType.CUBE_ACTION:
|
|
208
|
+
question_text = "What is the best cube action?"
|
|
209
|
+
else:
|
|
210
|
+
question_text = "What is the best move?"
|
|
211
|
+
|
|
212
|
+
# Build clickable options
|
|
213
|
+
options_html = []
|
|
214
|
+
for i, candidate in enumerate(candidates):
|
|
215
|
+
if candidate:
|
|
216
|
+
options_html.append(f"""
|
|
217
|
+
<div class='mcq-option' data-option-letter='{letters[i]}'>
|
|
218
|
+
<strong>{letters[i]}.</strong> {candidate.notation}
|
|
219
|
+
</div>
|
|
220
|
+
""")
|
|
221
|
+
|
|
222
|
+
html = f"""
|
|
223
|
+
<div class="card-front interactive-mcq-front">
|
|
224
|
+
<div class="position-svg">
|
|
225
|
+
{position_svg}
|
|
226
|
+
</div>
|
|
227
|
+
<div class="metadata">{metadata}</div>
|
|
228
|
+
<div class="question">
|
|
229
|
+
<h3>{question_text}</h3>
|
|
230
|
+
<div class="mcq-options">
|
|
231
|
+
{''.join(options_html)}
|
|
232
|
+
</div>
|
|
233
|
+
<p class="mcq-hint">Click an option to see if you're correct</p>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<script>
|
|
238
|
+
{self._generate_mcq_front_javascript()}
|
|
239
|
+
</script>
|
|
240
|
+
"""
|
|
241
|
+
return html
|
|
242
|
+
|
|
243
|
+
def _generate_mcq_front_javascript(self) -> str:
|
|
244
|
+
"""Generate JavaScript for interactive MCQ front side."""
|
|
245
|
+
return """
|
|
246
|
+
(function() {
|
|
247
|
+
const options = document.querySelectorAll('.mcq-option');
|
|
248
|
+
|
|
249
|
+
options.forEach(option => {
|
|
250
|
+
option.addEventListener('click', function() {
|
|
251
|
+
const selectedLetter = this.dataset.optionLetter;
|
|
252
|
+
|
|
253
|
+
// Store selection in sessionStorage
|
|
254
|
+
try {
|
|
255
|
+
sessionStorage.setItem('ankigammon-mcq-choice', selectedLetter);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
window.location.hash = 'choice-' + selectedLetter;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Visual feedback before flip
|
|
261
|
+
this.classList.add('selected-flash');
|
|
262
|
+
|
|
263
|
+
// Trigger Anki flip to back side
|
|
264
|
+
setTimeout(function() {
|
|
265
|
+
if (typeof pycmd !== 'undefined') {
|
|
266
|
+
pycmd('ans'); // Anki desktop
|
|
267
|
+
} else if (typeof AnkiDroidJS !== 'undefined') {
|
|
268
|
+
AnkiDroidJS.ankiShowAnswer(); // AnkiDroid
|
|
269
|
+
} else {
|
|
270
|
+
const event = new KeyboardEvent('keydown', { keyCode: 32 });
|
|
271
|
+
document.dispatchEvent(event);
|
|
272
|
+
}
|
|
273
|
+
}, 200);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
})();
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def _generate_mcq_back_javascript(self, correct_letter: str) -> str:
|
|
280
|
+
"""Generate JavaScript for interactive MCQ back side."""
|
|
281
|
+
return f"""
|
|
282
|
+
<script>
|
|
283
|
+
(function() {{
|
|
284
|
+
let selectedLetter = null;
|
|
285
|
+
|
|
286
|
+
try {{
|
|
287
|
+
selectedLetter = sessionStorage.getItem('ankigammon-mcq-choice');
|
|
288
|
+
sessionStorage.removeItem('ankigammon-mcq-choice');
|
|
289
|
+
}} catch (e) {{
|
|
290
|
+
const hash = window.location.hash;
|
|
291
|
+
if (hash.startsWith('#choice-')) {{
|
|
292
|
+
selectedLetter = hash.replace('#choice-', '');
|
|
293
|
+
window.location.hash = '';
|
|
294
|
+
}}
|
|
295
|
+
}}
|
|
296
|
+
|
|
297
|
+
const correctLetter = '{correct_letter}';
|
|
298
|
+
const feedbackContainer = document.getElementById('mcq-feedback');
|
|
299
|
+
const standardAnswer = document.getElementById('mcq-standard-answer');
|
|
300
|
+
|
|
301
|
+
let moveMap = {{}};
|
|
302
|
+
let errorMap = {{}};
|
|
303
|
+
if (standardAnswer && standardAnswer.dataset.moveMap) {{
|
|
304
|
+
try {{
|
|
305
|
+
moveMap = JSON.parse(standardAnswer.dataset.moveMap);
|
|
306
|
+
}} catch (e) {{}}
|
|
307
|
+
}}
|
|
308
|
+
if (standardAnswer && standardAnswer.dataset.errorMap) {{
|
|
309
|
+
try {{
|
|
310
|
+
errorMap = JSON.parse(standardAnswer.dataset.errorMap);
|
|
311
|
+
}} catch (e) {{}}
|
|
312
|
+
}}
|
|
313
|
+
|
|
314
|
+
if (selectedLetter) {{
|
|
315
|
+
feedbackContainer.style.display = 'block';
|
|
316
|
+
if (standardAnswer) standardAnswer.style.display = 'none';
|
|
317
|
+
|
|
318
|
+
const selectedMove = moveMap[selectedLetter] || '';
|
|
319
|
+
const correctMove = moveMap[correctLetter] || '';
|
|
320
|
+
const selectedError = errorMap[selectedLetter] || 0.0;
|
|
321
|
+
|
|
322
|
+
const CLOSE_THRESHOLD = 0.020;
|
|
323
|
+
|
|
324
|
+
if (selectedLetter === correctLetter) {{
|
|
325
|
+
feedbackContainer.innerHTML = `
|
|
326
|
+
<div class="mcq-feedback-correct">
|
|
327
|
+
<div class="feedback-icon">✓</div>
|
|
328
|
+
<div class="feedback-text">
|
|
329
|
+
<strong>${{selectedLetter}} is Correct!</strong>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
`;
|
|
333
|
+
}} else if (selectedError < CLOSE_THRESHOLD) {{
|
|
334
|
+
feedbackContainer.innerHTML = `
|
|
335
|
+
<div class="mcq-feedback-close">
|
|
336
|
+
<div class="feedback-icon">≈</div>
|
|
337
|
+
<div class="feedback-text">
|
|
338
|
+
<strong>${{selectedLetter}} is Close!</strong> (${{selectedMove}}) <span class="feedback-separator">•</span> <strong>Best: ${{correctLetter}}</strong> (${{correctMove}})
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
`;
|
|
342
|
+
}} else {{
|
|
343
|
+
feedbackContainer.innerHTML = `
|
|
344
|
+
<div class="mcq-feedback-incorrect">
|
|
345
|
+
<div class="feedback-icon">✗</div>
|
|
346
|
+
<div class="feedback-text">
|
|
347
|
+
<strong>${{selectedLetter}} is Incorrect</strong> (${{selectedMove}}) <span class="feedback-separator">•</span> <strong>Correct: ${{correctLetter}}</strong> (${{correctMove}})
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
`;
|
|
351
|
+
}}
|
|
352
|
+
|
|
353
|
+
const moveRows = document.querySelectorAll('.moves-table tbody tr');
|
|
354
|
+
moveRows.forEach(row => {{
|
|
355
|
+
const moveCell = row.cells[1];
|
|
356
|
+
if (moveCell) {{
|
|
357
|
+
const moveText = moveCell.textContent.trim();
|
|
358
|
+
if (moveText === selectedMove) {{
|
|
359
|
+
if (selectedLetter === correctLetter) {{
|
|
360
|
+
row.classList.add('user-correct');
|
|
361
|
+
}} else if (selectedError < CLOSE_THRESHOLD) {{
|
|
362
|
+
row.classList.add('user-close');
|
|
363
|
+
}} else {{
|
|
364
|
+
row.classList.add('user-incorrect');
|
|
365
|
+
}}
|
|
366
|
+
}}
|
|
367
|
+
}}
|
|
368
|
+
}});
|
|
369
|
+
}} else {{
|
|
370
|
+
feedbackContainer.style.display = 'none';
|
|
371
|
+
}}
|
|
372
|
+
}})();
|
|
373
|
+
</script>
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
def _generate_back(
|
|
377
|
+
self,
|
|
378
|
+
decision: Decision,
|
|
379
|
+
original_position_svg: str,
|
|
380
|
+
result_position_svg: str,
|
|
381
|
+
candidates: List[Optional[Move]],
|
|
382
|
+
shuffled_candidates: List[Optional[Move]],
|
|
383
|
+
answer_index: int,
|
|
384
|
+
show_options: bool,
|
|
385
|
+
move_result_svgs: Dict[str, str] = None
|
|
386
|
+
) -> str:
|
|
387
|
+
"""Generate HTML for card back."""
|
|
388
|
+
metadata = self._get_metadata_html(decision)
|
|
389
|
+
|
|
390
|
+
# Build move table
|
|
391
|
+
table_rows = []
|
|
392
|
+
letters = ['A', 'B', 'C', 'D', 'E']
|
|
393
|
+
|
|
394
|
+
sorted_candidates = sorted(
|
|
395
|
+
[m for m in candidates if m and m.from_xg_analysis],
|
|
396
|
+
key=lambda m: m.xg_rank if m.xg_rank is not None else 999
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
for i, move in enumerate(sorted_candidates):
|
|
400
|
+
rank_class = "best-move" if move.rank == 1 else ""
|
|
401
|
+
display_rank = move.xg_rank if move.xg_rank is not None else (i + 1)
|
|
402
|
+
display_error = move.xg_error if move.xg_error is not None else move.error
|
|
403
|
+
display_notation = move.xg_notation if move.xg_notation is not None else move.notation
|
|
404
|
+
|
|
405
|
+
error_str = f"{display_error:+.3f}" if display_error != 0 else "0.000"
|
|
406
|
+
|
|
407
|
+
# Prepare W/G/B data attributes
|
|
408
|
+
wgb_attrs = ""
|
|
409
|
+
if move.player_win_pct is not None:
|
|
410
|
+
wgb_attrs = (
|
|
411
|
+
f'data-player-win="{move.player_win_pct:.2f}" '
|
|
412
|
+
f'data-player-gammon="{move.player_gammon_pct:.2f}" '
|
|
413
|
+
f'data-player-backgammon="{move.player_backgammon_pct:.2f}" '
|
|
414
|
+
f'data-opponent-win="{move.opponent_win_pct:.2f}" '
|
|
415
|
+
f'data-opponent-gammon="{move.opponent_gammon_pct:.2f}" '
|
|
416
|
+
f'data-opponent-backgammon="{move.opponent_backgammon_pct:.2f}"'
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if self.interactive_moves:
|
|
420
|
+
row_class = f"{rank_class} move-row clickable-move-row"
|
|
421
|
+
row_attrs = f'data-move-notation="{move.notation}" {wgb_attrs}'
|
|
422
|
+
else:
|
|
423
|
+
row_class = f"{rank_class} clickable-move-row"
|
|
424
|
+
row_attrs = wgb_attrs
|
|
425
|
+
|
|
426
|
+
# Generate inline W/G/B display
|
|
427
|
+
wgb_inline_html = self._format_wgb_inline(move, decision)
|
|
428
|
+
|
|
429
|
+
table_rows.append(f"""
|
|
430
|
+
<tr class="{row_class}" {row_attrs}>
|
|
431
|
+
<td>{display_rank}</td>
|
|
432
|
+
<td>
|
|
433
|
+
<div class="move-notation">{display_notation}</div>{wgb_inline_html}
|
|
434
|
+
</td>
|
|
435
|
+
<td>{move.equity:.3f}</td>
|
|
436
|
+
<td>{error_str}</td>
|
|
437
|
+
</tr>""")
|
|
438
|
+
|
|
439
|
+
# Generate answer section
|
|
440
|
+
best_move = decision.get_best_move()
|
|
441
|
+
best_notation = best_move.notation if best_move else "Unknown"
|
|
442
|
+
|
|
443
|
+
if show_options:
|
|
444
|
+
correct_letter = letters[answer_index] if answer_index < len(letters) else "?"
|
|
445
|
+
|
|
446
|
+
import json
|
|
447
|
+
letter_to_move = {}
|
|
448
|
+
letter_to_error = {}
|
|
449
|
+
for i, move in enumerate(shuffled_candidates):
|
|
450
|
+
if move and i < len(letters):
|
|
451
|
+
letter_to_move[letters[i]] = move.notation
|
|
452
|
+
letter_to_error[letters[i]] = abs(move.error) if move.error is not None else 0.0
|
|
453
|
+
|
|
454
|
+
answer_html = f"""
|
|
455
|
+
<div class="mcq-feedback-container" id="mcq-feedback" style="display: none;">
|
|
456
|
+
</div>
|
|
457
|
+
<div class="answer" id="mcq-standard-answer" data-correct-answer="{correct_letter}" data-move-map='{json.dumps(letter_to_move)}' data-error-map='{json.dumps(letter_to_error)}'>
|
|
458
|
+
<h3>Correct Answer: <span class="answer-letter">{correct_letter}</span></h3>
|
|
459
|
+
<p class="best-move-notation">{best_notation}</p>
|
|
460
|
+
</div>
|
|
461
|
+
"""
|
|
462
|
+
else:
|
|
463
|
+
answer_html = f"""
|
|
464
|
+
<div class="answer">
|
|
465
|
+
<h3>Best Move:</h3>
|
|
466
|
+
<p class="best-move-notation">{best_notation}</p>
|
|
467
|
+
</div>
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
# Generate position viewer HTML
|
|
471
|
+
from ankigammon.models import DecisionType
|
|
472
|
+
is_cube_decision = decision.decision_type == DecisionType.CUBE_ACTION
|
|
473
|
+
|
|
474
|
+
if self.interactive_moves:
|
|
475
|
+
# Interactive mode: single board with animated checkers
|
|
476
|
+
position_viewer_html = f'''
|
|
477
|
+
<div class="position-viewer">
|
|
478
|
+
<div class="position-svg-animated" id="animated-board">
|
|
479
|
+
{original_position_svg}
|
|
480
|
+
</div>
|
|
481
|
+
</div>'''
|
|
482
|
+
# Change title and hide animation tip for cube decisions
|
|
483
|
+
if is_cube_decision:
|
|
484
|
+
analysis_title = '<h4>Cube Actions Analysis:</h4>'
|
|
485
|
+
else:
|
|
486
|
+
analysis_title = '<h4>Top Moves Analysis: <span class="click-hint">(click a move to see it animated)</span></h4>'
|
|
487
|
+
table_body_id = 'id="moves-tbody"'
|
|
488
|
+
else:
|
|
489
|
+
position_viewer_html = f'''
|
|
490
|
+
<div class="position-svg">
|
|
491
|
+
{result_position_svg or original_position_svg}
|
|
492
|
+
</div>'''
|
|
493
|
+
# Change title for cube decisions
|
|
494
|
+
if is_cube_decision:
|
|
495
|
+
analysis_title = '<h4>Cube Actions Analysis:</h4>'
|
|
496
|
+
else:
|
|
497
|
+
analysis_title = '<h4>Top Moves Analysis:</h4>'
|
|
498
|
+
table_body_id = ''
|
|
499
|
+
|
|
500
|
+
# Generate winning chances HTML for cube decisions
|
|
501
|
+
winning_chances_html = ''
|
|
502
|
+
if is_cube_decision and decision.player_win_pct is not None:
|
|
503
|
+
winning_chances_html = self._generate_winning_chances_html(decision)
|
|
504
|
+
|
|
505
|
+
# For cube decisions with W/G/B, create side-by-side layout
|
|
506
|
+
if is_cube_decision and winning_chances_html:
|
|
507
|
+
analysis_and_chances = f"""
|
|
508
|
+
<div class="analysis-container">
|
|
509
|
+
<div class="analysis-section">
|
|
510
|
+
{analysis_title}
|
|
511
|
+
<table class="moves-table">
|
|
512
|
+
<thead>
|
|
513
|
+
<tr>
|
|
514
|
+
<th>Rank</th>
|
|
515
|
+
<th>Move</th>
|
|
516
|
+
<th>Equity</th>
|
|
517
|
+
<th>Error</th>
|
|
518
|
+
</tr>
|
|
519
|
+
</thead>
|
|
520
|
+
<tbody {table_body_id}>
|
|
521
|
+
{''.join(table_rows)}
|
|
522
|
+
</tbody>
|
|
523
|
+
</table>
|
|
524
|
+
</div>
|
|
525
|
+
<div class="chances-section">
|
|
526
|
+
<h4>Winning Chances:</h4>
|
|
527
|
+
{winning_chances_html}
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
"""
|
|
531
|
+
else:
|
|
532
|
+
analysis_and_chances = f"""
|
|
533
|
+
<div class="analysis">
|
|
534
|
+
{analysis_title}
|
|
535
|
+
<table class="moves-table">
|
|
536
|
+
<thead>
|
|
537
|
+
<tr>
|
|
538
|
+
<th>Rank</th>
|
|
539
|
+
<th>Move</th>
|
|
540
|
+
<th>Equity</th>
|
|
541
|
+
<th>Error</th>
|
|
542
|
+
</tr>
|
|
543
|
+
</thead>
|
|
544
|
+
<tbody {table_body_id}>
|
|
545
|
+
{''.join(table_rows)}
|
|
546
|
+
</tbody>
|
|
547
|
+
</table>
|
|
548
|
+
</div>
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
# Generate score matrix for cube decisions if enabled
|
|
552
|
+
score_matrix_html = ''
|
|
553
|
+
if is_cube_decision and decision.match_length > 0 and self.settings.get('generate_score_matrix', False):
|
|
554
|
+
score_matrix_html = self._generate_score_matrix_html(decision)
|
|
555
|
+
if score_matrix_html:
|
|
556
|
+
score_matrix_html = f"\n{score_matrix_html}"
|
|
557
|
+
|
|
558
|
+
# Generate note HTML if note exists
|
|
559
|
+
note_html = self._generate_note_html(decision)
|
|
560
|
+
|
|
561
|
+
html = f"""
|
|
562
|
+
<div class="card-back">
|
|
563
|
+
{position_viewer_html}
|
|
564
|
+
<div class="metadata">{metadata}</div>
|
|
565
|
+
{answer_html}
|
|
566
|
+
{note_html}
|
|
567
|
+
{analysis_and_chances}{score_matrix_html}
|
|
568
|
+
{self._generate_source_info(decision)}
|
|
569
|
+
</div>
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
if show_options:
|
|
573
|
+
html += self._generate_mcq_back_javascript(correct_letter)
|
|
574
|
+
|
|
575
|
+
if self.interactive_moves:
|
|
576
|
+
# Generate animation scripts
|
|
577
|
+
animation_scripts = self._generate_checker_animation_scripts(decision, candidates, move_result_svgs or {})
|
|
578
|
+
html += animation_scripts
|
|
579
|
+
|
|
580
|
+
return html
|
|
581
|
+
|
|
582
|
+
def _generate_checker_animation_scripts(
|
|
583
|
+
self,
|
|
584
|
+
decision: Decision,
|
|
585
|
+
candidates: List[Optional[Move]],
|
|
586
|
+
move_result_svgs: Dict[str, str]
|
|
587
|
+
) -> str:
|
|
588
|
+
"""
|
|
589
|
+
Generate JavaScript for animating actual checker movements on the board.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
decision: The decision with the original position
|
|
593
|
+
candidates: List of candidate moves
|
|
594
|
+
move_result_svgs: Dictionary mapping move notation to result SVG
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
HTML script tags with animation code
|
|
598
|
+
"""
|
|
599
|
+
# Build move data with coordinates for each checker movement
|
|
600
|
+
move_data = {}
|
|
601
|
+
|
|
602
|
+
for candidate in candidates:
|
|
603
|
+
if not candidate:
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
# Parse move notation to get individual checker movements
|
|
607
|
+
from ankigammon.renderer.animation_helper import AnimationHelper
|
|
608
|
+
movements = AnimationHelper.parse_move_notation(candidate.notation, decision.on_roll)
|
|
609
|
+
|
|
610
|
+
if not movements:
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
# Calculate coordinates for each movement
|
|
614
|
+
# We need to track the position state as we animate
|
|
615
|
+
move_animations = []
|
|
616
|
+
|
|
617
|
+
# Create a copy of the position to track changes during the move
|
|
618
|
+
current_position = decision.position.copy()
|
|
619
|
+
|
|
620
|
+
for from_point, to_point in movements:
|
|
621
|
+
# Get starting coordinates (top of stack at from_point)
|
|
622
|
+
# Cap at max visible checkers (5 for points, 3 for bar)
|
|
623
|
+
from_count = abs(current_position.points[from_point]) if 0 <= from_point <= 25 else 0
|
|
624
|
+
from_max_visible = 3 if (from_point == 0 or from_point == 25) else 5
|
|
625
|
+
from_index = min(max(0, from_count - 1), from_max_visible - 1) # Top visible checker
|
|
626
|
+
start_x, start_y = self.animation_controller.get_point_coordinates(from_point, from_index)
|
|
627
|
+
|
|
628
|
+
# Get ending coordinates (where it lands at to_point - on top of existing checkers)
|
|
629
|
+
# Cap at max visible checkers to avoid landing above visible stack
|
|
630
|
+
if to_point >= 0 and to_point <= 25:
|
|
631
|
+
to_count = abs(current_position.points[to_point])
|
|
632
|
+
to_max_visible = 3 if (to_point == 0 or to_point == 25) else 5
|
|
633
|
+
to_index = min(to_count, to_max_visible - 1) # Land on last visible position
|
|
634
|
+
end_x, end_y = self.animation_controller.get_point_coordinates(to_point, to_index)
|
|
635
|
+
else:
|
|
636
|
+
# Bear-off or special case
|
|
637
|
+
end_x, end_y = self.animation_controller.get_point_coordinates(to_point, 0)
|
|
638
|
+
|
|
639
|
+
move_animations.append({
|
|
640
|
+
'from_point': from_point,
|
|
641
|
+
'to_point': to_point,
|
|
642
|
+
'start_x': start_x,
|
|
643
|
+
'start_y': start_y,
|
|
644
|
+
'end_x': end_x,
|
|
645
|
+
'end_y': end_y
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
# Update position for next movement calculation
|
|
649
|
+
if 0 <= from_point <= 25:
|
|
650
|
+
if current_position.points[from_point] > 0:
|
|
651
|
+
current_position.points[from_point] -= 1
|
|
652
|
+
elif current_position.points[from_point] < 0:
|
|
653
|
+
current_position.points[from_point] += 1
|
|
654
|
+
|
|
655
|
+
if 0 <= to_point <= 25:
|
|
656
|
+
if decision.on_roll == Player.X:
|
|
657
|
+
current_position.points[to_point] += 1
|
|
658
|
+
else:
|
|
659
|
+
current_position.points[to_point] -= 1
|
|
660
|
+
|
|
661
|
+
move_data[candidate.notation] = move_animations
|
|
662
|
+
|
|
663
|
+
move_data_json = json.dumps(move_data)
|
|
664
|
+
move_result_svgs_json = json.dumps(move_result_svgs)
|
|
665
|
+
|
|
666
|
+
# Get player on roll and checker colors
|
|
667
|
+
on_roll_player = 'X' if decision.on_roll == Player.X else 'O'
|
|
668
|
+
checker_x_color = self.renderer.color_scheme.checker_x
|
|
669
|
+
checker_o_color = self.renderer.color_scheme.checker_o
|
|
670
|
+
checker_border_color = self.renderer.color_scheme.checker_border
|
|
671
|
+
checker_radius = self.renderer.checker_radius
|
|
672
|
+
|
|
673
|
+
# Generate animation JavaScript
|
|
674
|
+
script = f"""
|
|
675
|
+
<script>
|
|
676
|
+
// Checker movement animation system
|
|
677
|
+
(function() {{
|
|
678
|
+
const ANIMATION_DURATION = 200; // milliseconds
|
|
679
|
+
const moveData = {move_data_json};
|
|
680
|
+
const moveResultSVGs = {move_result_svgs_json};
|
|
681
|
+
const onRollPlayer = '{on_roll_player}';
|
|
682
|
+
const checkerXColor = '{checker_x_color}';
|
|
683
|
+
const checkerOColor = '{checker_o_color}';
|
|
684
|
+
const checkerBorderColor = '{checker_border_color}';
|
|
685
|
+
const checkerRadius = {checker_radius};
|
|
686
|
+
let isAnimating = false;
|
|
687
|
+
let cancelCurrentAnimation = false;
|
|
688
|
+
let currentSelectedRow = null;
|
|
689
|
+
let originalBoardHTML = null;
|
|
690
|
+
|
|
691
|
+
// Store original board HTML for reset
|
|
692
|
+
function storeOriginalBoard() {{
|
|
693
|
+
const board = document.getElementById('animated-board');
|
|
694
|
+
if (board) {{
|
|
695
|
+
originalBoardHTML = board.innerHTML;
|
|
696
|
+
}}
|
|
697
|
+
}}
|
|
698
|
+
|
|
699
|
+
// Reset board to original state
|
|
700
|
+
function resetBoard() {{
|
|
701
|
+
if (originalBoardHTML) {{
|
|
702
|
+
const board = document.getElementById('animated-board');
|
|
703
|
+
if (board) {{
|
|
704
|
+
board.innerHTML = originalBoardHTML;
|
|
705
|
+
}}
|
|
706
|
+
}}
|
|
707
|
+
}}
|
|
708
|
+
|
|
709
|
+
// Helper to find checkers at a specific point
|
|
710
|
+
function getCheckersAtPoint(svg, pointNum) {{
|
|
711
|
+
return svg.querySelectorAll('.checker[data-point="' + pointNum + '"]');
|
|
712
|
+
}}
|
|
713
|
+
|
|
714
|
+
// Get the checker count text element at a point (if it exists)
|
|
715
|
+
function getCheckerCountText(svg, pointNum) {{
|
|
716
|
+
const checkers = getCheckersAtPoint(svg, pointNum);
|
|
717
|
+
if (checkers.length === 0) return null;
|
|
718
|
+
|
|
719
|
+
// Search for text element near any checker at this point
|
|
720
|
+
const allTexts = svg.querySelectorAll('text.checker-text');
|
|
721
|
+
for (const checker of checkers) {{
|
|
722
|
+
const checkerCx = parseFloat(checker.getAttribute('cx'));
|
|
723
|
+
const checkerCy = parseFloat(checker.getAttribute('cy'));
|
|
724
|
+
|
|
725
|
+
for (const text of allTexts) {{
|
|
726
|
+
const textX = parseFloat(text.getAttribute('x'));
|
|
727
|
+
const textY = parseFloat(text.getAttribute('y'));
|
|
728
|
+
|
|
729
|
+
if (5 > Math.abs(checkerCx - textX) && 5 > Math.abs(checkerCy - textY)) {{
|
|
730
|
+
return text;
|
|
731
|
+
}}
|
|
732
|
+
}}
|
|
733
|
+
}}
|
|
734
|
+
|
|
735
|
+
return null;
|
|
736
|
+
}}
|
|
737
|
+
|
|
738
|
+
// Update checker count display for a point (with count adjustment)
|
|
739
|
+
function updateCheckerCount(svg, pointNum, countAdjustment) {{
|
|
740
|
+
const checkers = getCheckersAtPoint(svg, pointNum);
|
|
741
|
+
const countText = getCheckerCountText(svg, pointNum);
|
|
742
|
+
|
|
743
|
+
// Determine actual current count
|
|
744
|
+
let currentCount;
|
|
745
|
+
if (countText) {{
|
|
746
|
+
currentCount = parseInt(countText.textContent);
|
|
747
|
+
}} else {{
|
|
748
|
+
currentCount = checkers.length;
|
|
749
|
+
}}
|
|
750
|
+
|
|
751
|
+
// Apply adjustment
|
|
752
|
+
const newCount = currentCount + countAdjustment;
|
|
753
|
+
|
|
754
|
+
// Determine threshold based on point type
|
|
755
|
+
const isBar = (pointNum === 0 || pointNum === 25);
|
|
756
|
+
const threshold = isBar ? 3 : 5;
|
|
757
|
+
|
|
758
|
+
if (threshold >= newCount) {{
|
|
759
|
+
// No count needed, remove if exists
|
|
760
|
+
if (countText) {{
|
|
761
|
+
countText.remove();
|
|
762
|
+
}}
|
|
763
|
+
return;
|
|
764
|
+
}}
|
|
765
|
+
|
|
766
|
+
// Need to show count - find the target checker (at threshold - 1 index)
|
|
767
|
+
const checkersArray = Array.from(checkers);
|
|
768
|
+
const targetChecker = checkersArray[threshold - 1];
|
|
769
|
+
|
|
770
|
+
if (!targetChecker) return;
|
|
771
|
+
|
|
772
|
+
const cx = parseFloat(targetChecker.getAttribute('cx'));
|
|
773
|
+
const cy = parseFloat(targetChecker.getAttribute('cy'));
|
|
774
|
+
|
|
775
|
+
// Get checker color to determine text color (inverse)
|
|
776
|
+
const isX = targetChecker.classList.contains('checker-x');
|
|
777
|
+
const textColor = isX ? '{self.renderer.color_scheme.checker_o}' : '{self.renderer.color_scheme.checker_x}';
|
|
778
|
+
const fontSize = {self.renderer.checker_radius} * 1.2;
|
|
779
|
+
|
|
780
|
+
if (countText) {{
|
|
781
|
+
// Update existing text
|
|
782
|
+
countText.textContent = newCount;
|
|
783
|
+
countText.setAttribute('x', cx);
|
|
784
|
+
countText.setAttribute('y', cy);
|
|
785
|
+
// Move to end of parent to ensure it's on top (SVG z-index)
|
|
786
|
+
const parent = countText.parentNode;
|
|
787
|
+
parent.removeChild(countText);
|
|
788
|
+
parent.appendChild(countText);
|
|
789
|
+
}} else {{
|
|
790
|
+
// Create new text element
|
|
791
|
+
const textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
792
|
+
textElement.setAttribute('class', 'checker-text');
|
|
793
|
+
textElement.setAttribute('x', cx);
|
|
794
|
+
textElement.setAttribute('y', cy);
|
|
795
|
+
textElement.setAttribute('font-size', fontSize);
|
|
796
|
+
textElement.setAttribute('fill', textColor);
|
|
797
|
+
textElement.textContent = newCount;
|
|
798
|
+
|
|
799
|
+
// Append to parent (end of DOM) to ensure it appears on top
|
|
800
|
+
targetChecker.parentNode.appendChild(textElement);
|
|
801
|
+
}}
|
|
802
|
+
}}
|
|
803
|
+
|
|
804
|
+
// Create SVG arrow element from start to end coordinates
|
|
805
|
+
function createArrow(svg, startX, startY, endX, endY) {{
|
|
806
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
807
|
+
g.setAttribute('class', 'move-arrow');
|
|
808
|
+
|
|
809
|
+
// Calculate arrow direction
|
|
810
|
+
const dx = endX - startX;
|
|
811
|
+
const dy = endY - startY;
|
|
812
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
813
|
+
const angle = Math.atan2(dy, dx);
|
|
814
|
+
|
|
815
|
+
// Arrow parameters
|
|
816
|
+
const arrowheadSize = 15;
|
|
817
|
+
const strokeWidth = 3;
|
|
818
|
+
const arrowAngle = Math.PI / 6; // 30 degrees
|
|
819
|
+
|
|
820
|
+
// Calculate arrowhead points
|
|
821
|
+
const arrowTipX = endX;
|
|
822
|
+
const arrowTipY = endY;
|
|
823
|
+
const arrowBase1X = endX - arrowheadSize * Math.cos(angle - arrowAngle);
|
|
824
|
+
const arrowBase1Y = endY - arrowheadSize * Math.sin(angle - arrowAngle);
|
|
825
|
+
const arrowBase2X = endX - arrowheadSize * Math.cos(angle + arrowAngle);
|
|
826
|
+
const arrowBase2Y = endY - arrowheadSize * Math.sin(angle + arrowAngle);
|
|
827
|
+
|
|
828
|
+
// Calculate where line should end (at the center of the arrowhead base)
|
|
829
|
+
const arrowBaseLength = arrowheadSize * Math.cos(arrowAngle);
|
|
830
|
+
const lineEndX = endX - arrowBaseLength * Math.cos(angle);
|
|
831
|
+
const lineEndY = endY - arrowBaseLength * Math.sin(angle);
|
|
832
|
+
|
|
833
|
+
// Create line
|
|
834
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
835
|
+
line.setAttribute('x1', startX);
|
|
836
|
+
line.setAttribute('y1', startY);
|
|
837
|
+
line.setAttribute('x2', lineEndX);
|
|
838
|
+
line.setAttribute('y2', lineEndY);
|
|
839
|
+
line.setAttribute('stroke', '#FF6B35');
|
|
840
|
+
line.setAttribute('stroke-width', strokeWidth);
|
|
841
|
+
line.setAttribute('stroke-linecap', 'round');
|
|
842
|
+
line.setAttribute('opacity', '0.8');
|
|
843
|
+
|
|
844
|
+
// Create arrowhead
|
|
845
|
+
const arrowhead = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
846
|
+
arrowhead.setAttribute('points',
|
|
847
|
+
`${{arrowTipX}},${{arrowTipY}} ${{arrowBase1X}},${{arrowBase1Y}} ${{arrowBase2X}},${{arrowBase2Y}}`);
|
|
848
|
+
arrowhead.setAttribute('fill', '#FF6B35');
|
|
849
|
+
arrowhead.setAttribute('opacity', '0.8');
|
|
850
|
+
|
|
851
|
+
g.appendChild(line);
|
|
852
|
+
g.appendChild(arrowhead);
|
|
853
|
+
|
|
854
|
+
return g;
|
|
855
|
+
}}
|
|
856
|
+
|
|
857
|
+
// Remove all arrows from SVG
|
|
858
|
+
function removeArrows(svg) {{
|
|
859
|
+
const arrows = svg.querySelectorAll('.move-arrow');
|
|
860
|
+
arrows.forEach(arrow => arrow.remove());
|
|
861
|
+
}}
|
|
862
|
+
|
|
863
|
+
// Remove all ghost checkers from SVG
|
|
864
|
+
function removeGhostCheckers(svg) {{
|
|
865
|
+
const ghosts = svg.querySelectorAll('.ghost-checker');
|
|
866
|
+
ghosts.forEach(ghost => ghost.remove());
|
|
867
|
+
}}
|
|
868
|
+
|
|
869
|
+
// Create a transparent checker at the original position
|
|
870
|
+
function createGhostChecker(svg, x, y) {{
|
|
871
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
872
|
+
g.setAttribute('class', 'ghost-checker');
|
|
873
|
+
|
|
874
|
+
// Determine checker color based on which player is moving
|
|
875
|
+
const checkerColor = onRollPlayer === 'X' ? checkerXColor : checkerOColor;
|
|
876
|
+
|
|
877
|
+
// Create the checker circle
|
|
878
|
+
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
879
|
+
circle.setAttribute('cx', x);
|
|
880
|
+
circle.setAttribute('cy', y);
|
|
881
|
+
circle.setAttribute('r', checkerRadius);
|
|
882
|
+
circle.setAttribute('fill', checkerColor);
|
|
883
|
+
circle.setAttribute('stroke', checkerBorderColor);
|
|
884
|
+
circle.setAttribute('stroke-width', '2');
|
|
885
|
+
circle.setAttribute('opacity', '0.3');
|
|
886
|
+
|
|
887
|
+
g.appendChild(circle);
|
|
888
|
+
return g;
|
|
889
|
+
}}
|
|
890
|
+
|
|
891
|
+
// Add arrows and ghost checkers for all movements in a move
|
|
892
|
+
function addMoveArrows(svg, animations) {{
|
|
893
|
+
// Remove any existing arrows and ghost checkers first
|
|
894
|
+
removeArrows(svg);
|
|
895
|
+
removeGhostCheckers(svg);
|
|
896
|
+
|
|
897
|
+
// Add arrow and ghost checker for each movement
|
|
898
|
+
animations.forEach(anim => {{
|
|
899
|
+
// Add ghost checker at start position
|
|
900
|
+
const ghost = createGhostChecker(svg, anim.start_x, anim.start_y);
|
|
901
|
+
svg.appendChild(ghost);
|
|
902
|
+
|
|
903
|
+
// Add arrow showing the move path
|
|
904
|
+
const arrow = createArrow(svg, anim.start_x, anim.start_y, anim.end_x, anim.end_y);
|
|
905
|
+
svg.appendChild(arrow);
|
|
906
|
+
}});
|
|
907
|
+
}}
|
|
908
|
+
|
|
909
|
+
// Animate a single checker from start to end coordinates
|
|
910
|
+
function animateChecker(checker, startX, startY, endX, endY, duration) {{
|
|
911
|
+
return new Promise((resolve) => {{
|
|
912
|
+
const startTime = performance.now();
|
|
913
|
+
|
|
914
|
+
function animate(currentTime) {{
|
|
915
|
+
// Check if animation was cancelled
|
|
916
|
+
if (cancelCurrentAnimation) {{
|
|
917
|
+
resolve('cancelled');
|
|
918
|
+
return;
|
|
919
|
+
}}
|
|
920
|
+
|
|
921
|
+
const elapsed = currentTime - startTime;
|
|
922
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
923
|
+
|
|
924
|
+
// Easing function (ease-in-out)
|
|
925
|
+
const eased = 0.5 > progress
|
|
926
|
+
? 2 * progress * progress
|
|
927
|
+
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
928
|
+
|
|
929
|
+
// Interpolate position
|
|
930
|
+
const currentX = startX + (endX - startX) * eased;
|
|
931
|
+
const currentY = startY + (endY - startY) * eased;
|
|
932
|
+
|
|
933
|
+
// Update checker position
|
|
934
|
+
checker.setAttribute('cx', currentX);
|
|
935
|
+
checker.setAttribute('cy', currentY);
|
|
936
|
+
|
|
937
|
+
if (1 > progress) {{
|
|
938
|
+
requestAnimationFrame(animate);
|
|
939
|
+
}} else {{
|
|
940
|
+
resolve('completed');
|
|
941
|
+
}}
|
|
942
|
+
}}
|
|
943
|
+
|
|
944
|
+
requestAnimationFrame(animate);
|
|
945
|
+
}});
|
|
946
|
+
}}
|
|
947
|
+
|
|
948
|
+
// Animate a move
|
|
949
|
+
async function animateMove(moveNotation) {{
|
|
950
|
+
// If already animating, cancel the current animation
|
|
951
|
+
if (isAnimating) {{
|
|
952
|
+
cancelCurrentAnimation = true;
|
|
953
|
+
// Wait a bit for the cancellation to take effect
|
|
954
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
955
|
+
}}
|
|
956
|
+
|
|
957
|
+
const animations = moveData[moveNotation];
|
|
958
|
+
if (!animations || animations.length === 0) return;
|
|
959
|
+
|
|
960
|
+
// Reset cancellation flag and set animating flag
|
|
961
|
+
cancelCurrentAnimation = false;
|
|
962
|
+
isAnimating = true;
|
|
963
|
+
|
|
964
|
+
// Reset board to original position before animating
|
|
965
|
+
resetBoard();
|
|
966
|
+
|
|
967
|
+
// Small delay to ensure DOM update
|
|
968
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
969
|
+
|
|
970
|
+
const board = document.getElementById('animated-board');
|
|
971
|
+
const svg = board.querySelector('svg');
|
|
972
|
+
|
|
973
|
+
if (!svg) {{
|
|
974
|
+
isAnimating = false;
|
|
975
|
+
return;
|
|
976
|
+
}}
|
|
977
|
+
|
|
978
|
+
// Animate each checker movement sequentially
|
|
979
|
+
let totalAnimationTime = 0;
|
|
980
|
+
for (const anim of animations) {{
|
|
981
|
+
// Check if we should cancel
|
|
982
|
+
if (cancelCurrentAnimation) {{
|
|
983
|
+
break;
|
|
984
|
+
}}
|
|
985
|
+
|
|
986
|
+
const checkers = getCheckersAtPoint(svg, anim.from_point);
|
|
987
|
+
|
|
988
|
+
if (checkers.length > 0) {{
|
|
989
|
+
// Animate the LAST checker (top of stack, at pointy end)
|
|
990
|
+
const checker = checkers[checkers.length - 1];
|
|
991
|
+
|
|
992
|
+
// Update data-point attribute to new position
|
|
993
|
+
checker.setAttribute('data-point', anim.to_point);
|
|
994
|
+
|
|
995
|
+
// Animate movement
|
|
996
|
+
const result = await animateChecker(
|
|
997
|
+
checker,
|
|
998
|
+
anim.start_x, anim.start_y,
|
|
999
|
+
anim.end_x, anim.end_y,
|
|
1000
|
+
ANIMATION_DURATION
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
totalAnimationTime += ANIMATION_DURATION;
|
|
1004
|
+
|
|
1005
|
+
// If cancelled, stop processing
|
|
1006
|
+
if (result === 'cancelled') {{
|
|
1007
|
+
break;
|
|
1008
|
+
}}
|
|
1009
|
+
}}
|
|
1010
|
+
}}
|
|
1011
|
+
|
|
1012
|
+
// Add delay proportional to animation time (100% extra buffer to ensure animations complete)
|
|
1013
|
+
const bufferDelay = Math.max(300, totalAnimationTime * 1.0);
|
|
1014
|
+
await new Promise(resolve => setTimeout(resolve, bufferDelay));
|
|
1015
|
+
|
|
1016
|
+
// After animation completes, replace with result SVG if available
|
|
1017
|
+
const resultSVG = moveResultSVGs[moveNotation];
|
|
1018
|
+
if (resultSVG && !cancelCurrentAnimation) {{
|
|
1019
|
+
const board = document.getElementById('animated-board');
|
|
1020
|
+
if (board) {{
|
|
1021
|
+
board.innerHTML = resultSVG;
|
|
1022
|
+
|
|
1023
|
+
// Add arrows to the final result SVG showing the move paths
|
|
1024
|
+
const finalSvg = board.querySelector('svg');
|
|
1025
|
+
if (finalSvg) {{
|
|
1026
|
+
addMoveArrows(finalSvg, animations);
|
|
1027
|
+
}}
|
|
1028
|
+
}}
|
|
1029
|
+
}}
|
|
1030
|
+
|
|
1031
|
+
isAnimating = false;
|
|
1032
|
+
}}
|
|
1033
|
+
|
|
1034
|
+
// Initialize when DOM is ready
|
|
1035
|
+
function initialize() {{
|
|
1036
|
+
// Store the original board state
|
|
1037
|
+
storeOriginalBoard();
|
|
1038
|
+
|
|
1039
|
+
// Set up click handlers for move rows
|
|
1040
|
+
const moveRows = document.querySelectorAll('.move-row');
|
|
1041
|
+
|
|
1042
|
+
// Initialize - highlight best move row
|
|
1043
|
+
const bestMoveRow = document.querySelector('.move-row.best-move');
|
|
1044
|
+
if (bestMoveRow) {{
|
|
1045
|
+
bestMoveRow.classList.add('selected');
|
|
1046
|
+
currentSelectedRow = bestMoveRow;
|
|
1047
|
+
|
|
1048
|
+
// Automatically trigger animation for best move
|
|
1049
|
+
const bestMoveNotation = bestMoveRow.dataset.moveNotation;
|
|
1050
|
+
if (bestMoveNotation) {{
|
|
1051
|
+
// Small delay to ensure DOM is fully ready
|
|
1052
|
+
setTimeout(() => {{
|
|
1053
|
+
animateMove(bestMoveNotation);
|
|
1054
|
+
}}, 100);
|
|
1055
|
+
}}
|
|
1056
|
+
}}
|
|
1057
|
+
|
|
1058
|
+
moveRows.forEach(row => {{
|
|
1059
|
+
row.addEventListener('click', function() {{
|
|
1060
|
+
const moveNotation = this.dataset.moveNotation;
|
|
1061
|
+
|
|
1062
|
+
if (!moveNotation) return;
|
|
1063
|
+
|
|
1064
|
+
// Update selection highlighting
|
|
1065
|
+
moveRows.forEach(r => r.classList.remove('selected'));
|
|
1066
|
+
this.classList.add('selected');
|
|
1067
|
+
currentSelectedRow = this;
|
|
1068
|
+
|
|
1069
|
+
// Trigger animation
|
|
1070
|
+
animateMove(moveNotation);
|
|
1071
|
+
}});
|
|
1072
|
+
}});
|
|
1073
|
+
}}
|
|
1074
|
+
|
|
1075
|
+
// Run initialization
|
|
1076
|
+
if (document.readyState === 'loading') {{
|
|
1077
|
+
document.addEventListener('DOMContentLoaded', initialize);
|
|
1078
|
+
}} else {{
|
|
1079
|
+
initialize();
|
|
1080
|
+
}}
|
|
1081
|
+
}})();
|
|
1082
|
+
</script>
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
return script
|
|
1086
|
+
|
|
1087
|
+
def _format_wgb_inline(self, move: Move, decision: Decision) -> str:
|
|
1088
|
+
"""
|
|
1089
|
+
Generate inline HTML for W/G/B percentages to display within move table cell.
|
|
1090
|
+
|
|
1091
|
+
Returns HTML with two lines showing player and opponent win/gammon/backgammon percentages.
|
|
1092
|
+
Returns empty string if move has no W/G/B data.
|
|
1093
|
+
"""
|
|
1094
|
+
if move.player_win_pct is None:
|
|
1095
|
+
return ""
|
|
1096
|
+
|
|
1097
|
+
# Get checker colors from the renderer's color scheme
|
|
1098
|
+
player_color = self.renderer.color_scheme.checker_x if decision.on_roll == Player.X else self.renderer.color_scheme.checker_o
|
|
1099
|
+
opponent_color = self.renderer.color_scheme.checker_o if decision.on_roll == Player.X else self.renderer.color_scheme.checker_x
|
|
1100
|
+
|
|
1101
|
+
wgb_html = f'''
|
|
1102
|
+
<div class="move-wgb-inline">
|
|
1103
|
+
<div class="wgb-line">
|
|
1104
|
+
<span style="color: {player_color};">●</span> P: <strong>{move.player_win_pct:.1f}%</strong> <span class="wgb-detail">(G:{move.player_gammon_pct:.1f}% B:{move.player_backgammon_pct:.1f}%)</span>
|
|
1105
|
+
</div>
|
|
1106
|
+
<div class="wgb-line">
|
|
1107
|
+
<span style="color: {opponent_color};">○</span> O: <strong>{move.opponent_win_pct:.1f}%</strong> <span class="wgb-detail">(G:{move.opponent_gammon_pct:.1f}% B:{move.opponent_backgammon_pct:.1f}%)</span>
|
|
1108
|
+
</div>
|
|
1109
|
+
</div>'''
|
|
1110
|
+
|
|
1111
|
+
return wgb_html
|
|
1112
|
+
|
|
1113
|
+
def _generate_winning_chances_html(self, decision: Decision) -> str:
|
|
1114
|
+
"""
|
|
1115
|
+
Generate HTML for winning chances display (W/G/B percentages).
|
|
1116
|
+
|
|
1117
|
+
Shows player and opponent winning chances with gammon and backgammon percentages.
|
|
1118
|
+
Note: Title is added separately in side-by-side layout.
|
|
1119
|
+
"""
|
|
1120
|
+
# Get checker colors from the renderer's color scheme
|
|
1121
|
+
player_color = self.renderer.color_scheme.checker_x if decision.on_roll == Player.X else self.renderer.color_scheme.checker_o
|
|
1122
|
+
opponent_color = self.renderer.color_scheme.checker_o if decision.on_roll == Player.X else self.renderer.color_scheme.checker_x
|
|
1123
|
+
|
|
1124
|
+
html = f''' <div class="winning-chances">
|
|
1125
|
+
<div class="chances-grid">
|
|
1126
|
+
<div class="chances-row">
|
|
1127
|
+
<span class="chances-label"><span style="color: {player_color}; font-size: 1.2em;">●</span> Player:</span>
|
|
1128
|
+
<span class="chances-values">
|
|
1129
|
+
<strong>{decision.player_win_pct:.2f}%</strong>
|
|
1130
|
+
<span class="chances-detail">(G: {decision.player_gammon_pct:.2f}% B: {decision.player_backgammon_pct:.2f}%)</span>
|
|
1131
|
+
</span>
|
|
1132
|
+
</div>
|
|
1133
|
+
<div class="chances-row">
|
|
1134
|
+
<span class="chances-label"><span style="color: {opponent_color}; font-size: 1.2em;">●</span> Opp.:</span>
|
|
1135
|
+
<span class="chances-values">
|
|
1136
|
+
<strong>{decision.opponent_win_pct:.2f}%</strong>
|
|
1137
|
+
<span class="chances-detail">(G: {decision.opponent_gammon_pct:.2f}% B: {decision.opponent_backgammon_pct:.2f}%)</span>
|
|
1138
|
+
</span>
|
|
1139
|
+
</div>
|
|
1140
|
+
</div>
|
|
1141
|
+
</div>
|
|
1142
|
+
'''
|
|
1143
|
+
return html
|
|
1144
|
+
|
|
1145
|
+
def _generate_score_matrix_html(self, decision: Decision) -> str:
|
|
1146
|
+
"""
|
|
1147
|
+
Generate score matrix HTML for cube decisions.
|
|
1148
|
+
|
|
1149
|
+
Args:
|
|
1150
|
+
decision: The cube decision
|
|
1151
|
+
|
|
1152
|
+
Returns:
|
|
1153
|
+
HTML string with score matrix, or empty string if generation fails
|
|
1154
|
+
"""
|
|
1155
|
+
# Only generate if GnuBG is available
|
|
1156
|
+
if not self.settings.is_gnubg_available():
|
|
1157
|
+
return ""
|
|
1158
|
+
|
|
1159
|
+
try:
|
|
1160
|
+
from ankigammon.analysis.score_matrix import generate_score_matrix, format_matrix_as_html
|
|
1161
|
+
|
|
1162
|
+
# Calculate current score (away from match)
|
|
1163
|
+
current_player_away = decision.match_length - (
|
|
1164
|
+
decision.score_o if decision.on_roll == Player.O else decision.score_x
|
|
1165
|
+
)
|
|
1166
|
+
current_opponent_away = decision.match_length - (
|
|
1167
|
+
decision.score_x if decision.on_roll == Player.O else decision.score_o
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
# Generate matrix with progress callback
|
|
1171
|
+
matrix = generate_score_matrix(
|
|
1172
|
+
xgid=decision.xgid,
|
|
1173
|
+
match_length=decision.match_length,
|
|
1174
|
+
gnubg_path=self.settings.gnubg_path,
|
|
1175
|
+
ply_level=self.settings.gnubg_analysis_ply,
|
|
1176
|
+
progress_callback=self.progress_callback
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
# Format as HTML
|
|
1180
|
+
matrix_html = format_matrix_as_html(
|
|
1181
|
+
matrix=matrix,
|
|
1182
|
+
current_player_away=current_player_away,
|
|
1183
|
+
current_opponent_away=current_opponent_away
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
return matrix_html
|
|
1187
|
+
|
|
1188
|
+
except Exception as e:
|
|
1189
|
+
# Silently fail if matrix generation fails
|
|
1190
|
+
# (e.g., GnuBG not available, analysis error)
|
|
1191
|
+
print(f"Warning: Failed to generate score matrix: {e}")
|
|
1192
|
+
return ""
|
|
1193
|
+
|
|
1194
|
+
def _generate_note_html(self, decision: Decision) -> str:
|
|
1195
|
+
"""Generate note HTML if a note exists."""
|
|
1196
|
+
if not decision.note:
|
|
1197
|
+
return ""
|
|
1198
|
+
|
|
1199
|
+
# Escape HTML characters and preserve line breaks
|
|
1200
|
+
escaped_note = html.escape(decision.note)
|
|
1201
|
+
|
|
1202
|
+
return f"""
|
|
1203
|
+
<div class="note-section">
|
|
1204
|
+
<h4>Note:</h4>
|
|
1205
|
+
<div class="note-content">{escaped_note}</div>
|
|
1206
|
+
</div>
|
|
1207
|
+
"""
|
|
1208
|
+
|
|
1209
|
+
def _generate_source_info(self, decision: Decision) -> str:
|
|
1210
|
+
"""Generate source information HTML."""
|
|
1211
|
+
parts = []
|
|
1212
|
+
if decision.xgid:
|
|
1213
|
+
parts.append(f"<code>{decision.xgid}</code>")
|
|
1214
|
+
if decision.source_file:
|
|
1215
|
+
parts.append(f"Source: {decision.source_file}")
|
|
1216
|
+
if decision.game_number:
|
|
1217
|
+
parts.append(f"Game #{decision.game_number}")
|
|
1218
|
+
if decision.move_number:
|
|
1219
|
+
parts.append(f"Move #{decision.move_number}")
|
|
1220
|
+
|
|
1221
|
+
if parts:
|
|
1222
|
+
return f"""
|
|
1223
|
+
<div class="source-info">
|
|
1224
|
+
<p>{'<br>'.join(parts)}</p>
|
|
1225
|
+
</div>
|
|
1226
|
+
"""
|
|
1227
|
+
return ""
|
|
1228
|
+
|
|
1229
|
+
def _generate_tags(self, decision: Decision) -> List[str]:
|
|
1230
|
+
"""Generate tags for the card."""
|
|
1231
|
+
tags = ["ankigammon", "backgammon"]
|
|
1232
|
+
|
|
1233
|
+
tags.append(decision.decision_type.value)
|
|
1234
|
+
|
|
1235
|
+
if decision.match_length > 0:
|
|
1236
|
+
tags.append(f"match_{decision.match_length}pt")
|
|
1237
|
+
else:
|
|
1238
|
+
tags.append("money_game")
|
|
1239
|
+
|
|
1240
|
+
if decision.cube_value > 1:
|
|
1241
|
+
tags.append(f"cube_{decision.cube_value}")
|
|
1242
|
+
|
|
1243
|
+
return tags
|
|
1244
|
+
|
|
1245
|
+
def _render_position_svg(self, decision: Decision) -> str:
|
|
1246
|
+
"""Render position as SVG markup."""
|
|
1247
|
+
return self.renderer.render_svg(
|
|
1248
|
+
position=decision.position,
|
|
1249
|
+
on_roll=decision.on_roll,
|
|
1250
|
+
dice=decision.dice,
|
|
1251
|
+
cube_value=decision.cube_value,
|
|
1252
|
+
cube_owner=decision.cube_owner,
|
|
1253
|
+
score_x=decision.score_x,
|
|
1254
|
+
score_o=decision.score_o,
|
|
1255
|
+
match_length=decision.match_length,
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
def _render_resulting_position_svg(self, decision: Decision, move: Move) -> str:
|
|
1259
|
+
"""Render the resulting position after a move as SVG markup."""
|
|
1260
|
+
if move.resulting_position:
|
|
1261
|
+
resulting_pos = move.resulting_position
|
|
1262
|
+
else:
|
|
1263
|
+
resulting_pos = MoveParser.apply_move(
|
|
1264
|
+
decision.position,
|
|
1265
|
+
move.notation,
|
|
1266
|
+
decision.on_roll
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
return self.renderer.render_svg(
|
|
1270
|
+
position=resulting_pos,
|
|
1271
|
+
on_roll=decision.on_roll,
|
|
1272
|
+
dice=decision.dice,
|
|
1273
|
+
dice_opacity=0.3,
|
|
1274
|
+
cube_value=decision.cube_value,
|
|
1275
|
+
cube_owner=decision.cube_owner,
|
|
1276
|
+
score_x=decision.score_x,
|
|
1277
|
+
score_o=decision.score_o,
|
|
1278
|
+
match_length=decision.match_length,
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
def _shuffle_candidates(
|
|
1282
|
+
self,
|
|
1283
|
+
candidates: List[Optional[Move]]
|
|
1284
|
+
) -> Tuple[List[Optional[Move]], int]:
|
|
1285
|
+
"""
|
|
1286
|
+
Shuffle candidates for MCQ and return answer index.
|
|
1287
|
+
|
|
1288
|
+
Returns:
|
|
1289
|
+
(shuffled_candidates, answer_index_of_best_move)
|
|
1290
|
+
"""
|
|
1291
|
+
best_idx = 0
|
|
1292
|
+
for i, candidate in enumerate(candidates):
|
|
1293
|
+
if candidate and candidate.rank == 1:
|
|
1294
|
+
best_idx = i
|
|
1295
|
+
break
|
|
1296
|
+
|
|
1297
|
+
indices = list(range(len(candidates)))
|
|
1298
|
+
random.shuffle(indices)
|
|
1299
|
+
|
|
1300
|
+
shuffled = [candidates[i] for i in indices]
|
|
1301
|
+
answer_idx = indices.index(best_idx)
|
|
1302
|
+
|
|
1303
|
+
return shuffled, answer_idx
|
|
1304
|
+
|
|
1305
|
+
def _generate_id(self) -> str:
|
|
1306
|
+
"""Generate a random ID for a card."""
|
|
1307
|
+
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))
|