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