draftomen 0.3.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.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,745 @@
1
+ """Provide deterministic session state for the desktop QML mockup.
2
+ The provider exercises the production session contract without external services.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import replace
8
+ from typing import Literal, TypeAlias
9
+
10
+ from draftomen.session import (
11
+ AccountIdentity,
12
+ ApplicationPhase,
13
+ ApplicationStatus,
14
+ BacktestPickResult,
15
+ BacktestResult,
16
+ BuildCard,
17
+ BuildLand,
18
+ BuildPairOption,
19
+ BuildResult,
20
+ CardDataState,
21
+ CardImageState,
22
+ CardView,
23
+ ChangeRanking,
24
+ ChangeSplashPreference,
25
+ ChooseAccount,
26
+ ChooseRecommendation,
27
+ DataLoadPhase,
28
+ DismissError,
29
+ DraftIdentity,
30
+ FocusBuildCard,
31
+ LiveSessionCommand,
32
+ LiveSessionSnapshot,
33
+ OperationKind,
34
+ PoolCard,
35
+ PoolState,
36
+ ProgressState,
37
+ RatingsState,
38
+ Recommendation,
39
+ RecommendationState,
40
+ RecentPick,
41
+ RequestBacktest,
42
+ RequestBuild,
43
+ RequestRatingsDownload,
44
+ RetryError,
45
+ SessionError,
46
+ )
47
+
48
+ MockScenario: TypeAlias = Literal[
49
+ "loading",
50
+ "ready",
51
+ "empty",
52
+ "progress",
53
+ "warning",
54
+ "error",
55
+ "build_error",
56
+ "backtest_missing",
57
+ "backtest_error",
58
+ ]
59
+ MOCK_SCENARIOS: tuple[MockScenario, ...] = (
60
+ "loading",
61
+ "ready",
62
+ "empty",
63
+ "progress",
64
+ "warning",
65
+ "error",
66
+ "build_error",
67
+ "backtest_missing",
68
+ "backtest_error",
69
+ )
70
+
71
+
72
+ def _card(
73
+ *,
74
+ grp_id: int,
75
+ name: str,
76
+ colors: tuple[str, ...],
77
+ rarity: str,
78
+ types: tuple[str, ...],
79
+ mana_cost: str,
80
+ mana_value: float,
81
+ ) -> CardView:
82
+ return CardView(
83
+ grp_id=grp_id,
84
+ name=name,
85
+ colors=colors,
86
+ rarity=rarity,
87
+ types=types,
88
+ mana_cost=mana_cost,
89
+ mana_value=mana_value,
90
+ image_path=None,
91
+ )
92
+
93
+
94
+ CARDS = (
95
+ _card(
96
+ grp_id=91001,
97
+ name="Outcaster Trailblazer",
98
+ colors=("G",),
99
+ rarity="Rare",
100
+ types=("Creature", "Human", "Druid"),
101
+ mana_cost="2G",
102
+ mana_value=3.0,
103
+ ),
104
+ _card(
105
+ grp_id=91002,
106
+ name="Beastbond Outcaster",
107
+ colors=("G",),
108
+ rarity="Uncommon",
109
+ types=("Creature", "Human", "Druid"),
110
+ mana_cost="2G",
111
+ mana_value=3.0,
112
+ ),
113
+ _card(
114
+ grp_id=91003,
115
+ name="Freestrider Lookout",
116
+ colors=("G",),
117
+ rarity="Rare",
118
+ types=("Creature", "Human", "Rogue"),
119
+ mana_cost="2G",
120
+ mana_value=3.0,
121
+ ),
122
+ _card(
123
+ grp_id=91004,
124
+ name="Tumbleweed Rising",
125
+ colors=("G",),
126
+ rarity="Uncommon",
127
+ types=("Sorcery",),
128
+ mana_cost="1G",
129
+ mana_value=2.0,
130
+ ),
131
+ _card(
132
+ grp_id=91005,
133
+ name="Drover Grizzly",
134
+ colors=("G",),
135
+ rarity="Common",
136
+ types=("Creature", "Bear"),
137
+ mana_cost="2G",
138
+ mana_value=3.0,
139
+ ),
140
+ _card(
141
+ grp_id=91006,
142
+ name="Mourner's Surprise",
143
+ colors=("B",),
144
+ rarity="Common",
145
+ types=("Instant",),
146
+ mana_cost="1B",
147
+ mana_value=2.0,
148
+ ),
149
+ _card(
150
+ grp_id=91007,
151
+ name="Jailbreak Scheme",
152
+ colors=("U",),
153
+ rarity="Common",
154
+ types=("Sorcery",),
155
+ mana_cost="U",
156
+ mana_value=1.0,
157
+ ),
158
+ _card(
159
+ grp_id=91008,
160
+ name="Jagged Barrens",
161
+ colors=(),
162
+ rarity="Common",
163
+ types=("Land",),
164
+ mana_cost="",
165
+ mana_value=0.0,
166
+ ),
167
+ )
168
+
169
+
170
+ def _recommendations() -> tuple[Recommendation, ...]:
171
+ values = (
172
+ (
173
+ 99,
174
+ 0.636,
175
+ 1.30,
176
+ "Quick",
177
+ "On color",
178
+ "A-",
179
+ "Highest pool-aware score; the card strengthens the active colors.",
180
+ ),
181
+ (87, 0.616, 3.87, "Quick", "On color", "B+", "Strong rate and clean fit."),
182
+ (69, 0.584, 1.23, "Quick", "On color", "C+", "Efficient on-color option."),
183
+ (68, 0.582, 6.27, "Quick", "On color", "C+", "Playable curve support."),
184
+ (67, 0.579, 7.09, "Quick", "On color", "C+", "Solid creature depth."),
185
+ (65, 0.577, 6.33, "Quick", "Splash?", "C+", "Powerful if fixing improves."),
186
+ (62, 0.572, 5.37, "Quick", "Off color", "C", "Strong card outside the pool."),
187
+ (62, 0.571, 5.48, "Quick", "Any", "C", "Flexible mana source."),
188
+ )
189
+ return tuple(
190
+ Recommendation(
191
+ rank=index,
192
+ card=card,
193
+ score=score,
194
+ win_rate=win_rate,
195
+ average_last_seen_at=alsa,
196
+ source_label=source,
197
+ color_fit=color_fit,
198
+ no_data=False,
199
+ letter_grade=letter_grade,
200
+ explanation=explanation,
201
+ )
202
+ for index, (
203
+ card,
204
+ (score, win_rate, alsa, source, color_fit, letter_grade, explanation),
205
+ ) in enumerate(zip(CARDS, values, strict=True), start=1)
206
+ )
207
+
208
+
209
+ def _pool() -> PoolState:
210
+ chronological_cards = CARDS * 3
211
+ recent_picks = tuple(
212
+ RecentPick(
213
+ card=card,
214
+ image=CardImageState(
215
+ grp_id=card.grp_id,
216
+ phase=DataLoadPhase.UNAVAILABLE,
217
+ message=f"No image is available for {card.name}.",
218
+ ),
219
+ )
220
+ for card in reversed(chronological_cards)
221
+ )
222
+ pool_cards = tuple(PoolCard(card=card, quantity=3) for card in CARDS)
223
+ return PoolState(
224
+ cards=pool_cards,
225
+ recent_picks=recent_picks,
226
+ total_cards=24,
227
+ target_cards=42,
228
+ inferred_pair="White · Green",
229
+ commitment=0.64,
230
+ color_distribution=(("W", 0), ("U", 3), ("B", 3), ("R", 0), ("G", 15), ("C", 3)),
231
+ mana_curve=(0, 3, 6, 12, 0, 0, 0),
232
+ average_mana_value=17 / 7,
233
+ )
234
+
235
+
236
+ def _build() -> BuildResult:
237
+ spells = tuple(
238
+ BuildCard(
239
+ card=card,
240
+ quantity=quantity,
241
+ score=99 - (index * 5),
242
+ win_rate=0.636 - (index * 0.008),
243
+ average_last_seen_at=1.3 + index,
244
+ letter_grade=("A-", "B+", "B", "C+", "C", "C")[index],
245
+ source_label="Quick Draft",
246
+ color_fit="On color",
247
+ )
248
+ for index, (card, quantity) in enumerate(
249
+ zip(CARDS[:6], (1, 2, 1, 2, 3, 1), strict=True)
250
+ )
251
+ )
252
+ spell_quantity_total = sum(entry.quantity for entry in spells)
253
+ weighted_mana_total = sum(
254
+ entry.quantity * entry.card.mana_value
255
+ for entry in spells
256
+ if entry.card.mana_value is not None
257
+ )
258
+ average_mana_value = (
259
+ weighted_mana_total / spell_quantity_total
260
+ if all(entry.card.mana_value is not None for entry in spells)
261
+ else None
262
+ )
263
+ bench = (
264
+ BuildCard(
265
+ card=CARDS[6],
266
+ quantity=2,
267
+ score=62,
268
+ win_rate=0.572,
269
+ average_last_seen_at=5.37,
270
+ letter_grade="C",
271
+ source_label="Quick Draft",
272
+ color_fit="Off color",
273
+ ),
274
+ )
275
+ return BuildResult(
276
+ selected_pair="WG",
277
+ pair_options=(
278
+ BuildPairOption(
279
+ pair="WG",
280
+ score=82.4,
281
+ selected=True,
282
+ automatic=True,
283
+ playable_count=25,
284
+ playable_score_sum=1682.0,
285
+ pair_win_rate=0.574,
286
+ ),
287
+ BuildPairOption(
288
+ pair="BG",
289
+ score=74.1,
290
+ selected=False,
291
+ automatic=False,
292
+ playable_count=22,
293
+ playable_score_sum=1448.0,
294
+ pair_win_rate=0.558,
295
+ ),
296
+ BuildPairOption(
297
+ pair="GU",
298
+ score=70.8,
299
+ selected=False,
300
+ automatic=False,
301
+ playable_count=21,
302
+ playable_score_sum=1396.0,
303
+ pair_win_rate=0.551,
304
+ ),
305
+ ),
306
+ spells=spells,
307
+ lands=(
308
+ BuildLand(name="Plains", quantity=7, source_colors=("W",)),
309
+ BuildLand(name="Forest", quantity=10, source_colors=("G",)),
310
+ ),
311
+ bench=bench,
312
+ deck_size=40,
313
+ average_mana_value=average_mana_value,
314
+ warnings=("Two flexible slots use lower-confidence ratings.",),
315
+ spell_count=23,
316
+ land_count=17,
317
+ creature_count=7,
318
+ instant_count=1,
319
+ )
320
+
321
+
322
+ def _backtest() -> BacktestResult:
323
+ rows = (
324
+ BacktestPickResult(
325
+ pack_number=0,
326
+ pick_number=0,
327
+ recommended=CARDS[0],
328
+ actual=CARDS[0],
329
+ match=True,
330
+ skipped_reason=None,
331
+ data_source="Quick Draft",
332
+ pool_size=0,
333
+ offered_count=14,
334
+ recommended_score=99,
335
+ recommended_win_rate=0.636,
336
+ ),
337
+ BacktestPickResult(
338
+ pack_number=0,
339
+ pick_number=1,
340
+ recommended=CARDS[1],
341
+ actual=CARDS[3],
342
+ match=False,
343
+ skipped_reason=None,
344
+ data_source="Quick Draft",
345
+ pool_size=1,
346
+ offered_count=13,
347
+ recommended_score=87,
348
+ recommended_win_rate=0.616,
349
+ ),
350
+ BacktestPickResult(
351
+ pack_number=0,
352
+ pick_number=2,
353
+ recommended=None,
354
+ actual=CARDS[4],
355
+ match=None,
356
+ skipped_reason="Pack history was incomplete.",
357
+ data_source=None,
358
+ pool_size=2,
359
+ offered_count=None,
360
+ ),
361
+ )
362
+ return BacktestResult(
363
+ ranking_mode="score",
364
+ rows=rows,
365
+ match_count=1,
366
+ compared_count=2,
367
+ skipped_count=1,
368
+ data_sources=("Quick Draft",),
369
+ account_id="mock-account",
370
+ account_screen_name="MagoAnubiTest",
371
+ draft_id="mock-otj-draft",
372
+ set_code="OTJ",
373
+ event_name="Quick Draft",
374
+ completed=True,
375
+ chosen_pick_count=42,
376
+ )
377
+
378
+
379
+ def _ready_snapshot() -> LiveSessionSnapshot:
380
+ account = AccountIdentity(
381
+ account_id="mock-account",
382
+ screen_name="MagoAnubiTest",
383
+ )
384
+ return LiveSessionSnapshot(
385
+ status=ApplicationStatus(
386
+ phase=ApplicationPhase.DRAFTING,
387
+ message="Watching Arena · Waiting for your pick",
388
+ ),
389
+ accounts=(account,),
390
+ active_account=account,
391
+ draft=DraftIdentity(
392
+ account_id=account.account_id,
393
+ draft_id="mock-otj-draft",
394
+ event_name="Quick Draft",
395
+ set_code="OTJ",
396
+ course_id="QuickDraft_OTJ_2024",
397
+ pack_number=2,
398
+ pick_number=4,
399
+ completed=False,
400
+ ),
401
+ card_data=CardDataState(
402
+ phase=DataLoadPhase.READY,
403
+ message="Card metadata ready.",
404
+ last_successful_update="2026-08-23T12:00:00+00:00",
405
+ ),
406
+ ratings=RatingsState(
407
+ set_code="OTJ",
408
+ phase=DataLoadPhase.READY,
409
+ message="Quick Draft ratings ready.",
410
+ rated_cards=14,
411
+ total_cards=14,
412
+ last_successful_update="2026-08-23T12:00:00+00:00",
413
+ ),
414
+ recommendations=RecommendationState(
415
+ ranking_mode="score",
416
+ splash_enabled=True,
417
+ cards=_recommendations(),
418
+ selected_grp_id=CARDS[0].grp_id,
419
+ source_summary="Quick Draft · 17Lands",
420
+ confidence_summary=None,
421
+ ),
422
+ pool=_pool(),
423
+ build=_build(),
424
+ backtest=_backtest(),
425
+ )
426
+
427
+
428
+ def _snapshot_for_scenario(*, scenario: MockScenario) -> LiveSessionSnapshot:
429
+ ready = _ready_snapshot()
430
+ if scenario == "ready":
431
+ return ready
432
+ if scenario == "loading":
433
+ return replace(
434
+ ready,
435
+ status=ApplicationStatus(
436
+ phase=ApplicationPhase.STARTING,
437
+ message="Preparing deterministic card metadata.",
438
+ ),
439
+ card_data=CardDataState(
440
+ phase=DataLoadPhase.LOADING,
441
+ message="Loading card metadata.",
442
+ ),
443
+ ratings=RatingsState(
444
+ set_code="OTJ",
445
+ phase=DataLoadPhase.IDLE,
446
+ message="Ratings will load after metadata.",
447
+ ),
448
+ recommendations=RecommendationState(),
449
+ pool=PoolState(),
450
+ progress=ProgressState(
451
+ operation=OperationKind.CARD_DATA,
452
+ message="Loading card metadata",
453
+ ),
454
+ )
455
+ if scenario == "empty":
456
+ return LiveSessionSnapshot(
457
+ status=ApplicationStatus(
458
+ phase=ApplicationPhase.WAITING_FOR_DRAFT,
459
+ message="Waiting for a Quick Draft.",
460
+ ),
461
+ accounts=ready.accounts,
462
+ active_account=ready.active_account,
463
+ card_data=ready.card_data,
464
+ ratings=RatingsState(
465
+ phase=DataLoadPhase.IDLE,
466
+ message="Set will be detected when the draft starts.",
467
+ ),
468
+ )
469
+ if scenario == "progress":
470
+ return replace(
471
+ ready,
472
+ ratings=RatingsState(
473
+ set_code="OTJ",
474
+ phase=DataLoadPhase.LOADING,
475
+ message="Downloading OTJ ratings.",
476
+ rated_cards=340,
477
+ total_cards=1000,
478
+ last_successful_update="2026-08-23T12:00:00+00:00",
479
+ ),
480
+ progress=ProgressState(
481
+ operation=OperationKind.RATINGS,
482
+ message="Downloading OTJ ratings",
483
+ completed=340,
484
+ total=1000,
485
+ ),
486
+ )
487
+ if scenario == "warning":
488
+ return replace(
489
+ ready,
490
+ ratings=RatingsState(
491
+ set_code="OTJ",
492
+ phase=DataLoadPhase.MISSING,
493
+ message="OTJ ratings are missing; neutral-prior scores remain available.",
494
+ ),
495
+ build=replace(
496
+ ready.build,
497
+ warnings=(
498
+ "Ratings are incomplete; the suggested deck uses neutral priors.",
499
+ ),
500
+ )
501
+ if ready.build is not None
502
+ else None,
503
+ )
504
+ if scenario == "build_error":
505
+ return replace(
506
+ ready,
507
+ build=None,
508
+ errors=(
509
+ SessionError(
510
+ error_id="mock-build-error",
511
+ code="build_failed",
512
+ message="The suggested deck could not be built. Retry after ratings recover.",
513
+ recoverable=True,
514
+ operation=OperationKind.BUILD,
515
+ ),
516
+ ),
517
+ )
518
+ if scenario == "backtest_missing":
519
+ missing_row = replace(
520
+ _backtest().rows[-1],
521
+ recommended=None,
522
+ actual=CARDS[4],
523
+ match=None,
524
+ skipped_reason="Every offered-card history record is unavailable.",
525
+ )
526
+ return replace(
527
+ ready,
528
+ backtest=replace(
529
+ _backtest(),
530
+ rows=(missing_row,),
531
+ match_count=0,
532
+ compared_count=0,
533
+ skipped_count=1,
534
+ ),
535
+ )
536
+ if scenario == "backtest_error":
537
+ return replace(
538
+ ready,
539
+ backtest=None,
540
+ errors=(
541
+ SessionError(
542
+ error_id="mock-backtest-error",
543
+ code="backtest_failed",
544
+ message="The persisted pick history could not be compared.",
545
+ recoverable=True,
546
+ operation=OperationKind.BACKTEST,
547
+ ),
548
+ ),
549
+ )
550
+ return replace(
551
+ ready,
552
+ errors=(
553
+ SessionError(
554
+ error_id="mock-ratings-error",
555
+ code="ratings_download_failed",
556
+ message="Ratings download failed. Existing recommendations still work.",
557
+ recoverable=True,
558
+ operation=OperationKind.RATINGS,
559
+ ),
560
+ ),
561
+ )
562
+
563
+
564
+ class MockLiveSession:
565
+ """Publish representative snapshots through the live-session boundary.
566
+ Scenario selection is deterministic and never touches disk or network.
567
+ """
568
+
569
+ def __init__(self, *, scenario: MockScenario = "ready") -> None:
570
+ self._scenario = scenario
571
+ self._snapshot = _snapshot_for_scenario(scenario=scenario)
572
+
573
+ @property
574
+ def scenario(self) -> MockScenario:
575
+ return self._scenario
576
+
577
+ @property
578
+ def snapshot(self) -> LiveSessionSnapshot:
579
+ return self._snapshot
580
+
581
+ def select_scenario(self, *, scenario: MockScenario) -> LiveSessionSnapshot:
582
+ if scenario not in MOCK_SCENARIOS:
583
+ raise ValueError(f"Unsupported mock scenario: {scenario}")
584
+ self._scenario = scenario
585
+ self._snapshot = _snapshot_for_scenario(scenario=scenario)
586
+ return self._snapshot
587
+
588
+ def dispatch(self, *, command: LiveSessionCommand) -> LiveSessionSnapshot:
589
+ snapshot = self._snapshot
590
+ if isinstance(command, ChooseRecommendation):
591
+ known_ids = {
592
+ recommendation.card.grp_id
593
+ for recommendation in snapshot.recommendations.cards
594
+ }
595
+ if command.grp_id in known_ids:
596
+ snapshot = replace(
597
+ snapshot,
598
+ recommendations=replace(
599
+ snapshot.recommendations,
600
+ selected_grp_id=command.grp_id,
601
+ ),
602
+ card_image=CardImageState(
603
+ grp_id=command.grp_id,
604
+ message="Mock card images are unavailable.",
605
+ ),
606
+ )
607
+ elif isinstance(command, FocusBuildCard):
608
+ build = snapshot.build
609
+ known_ids = (
610
+ set()
611
+ if build is None
612
+ else {
613
+ entry.card.grp_id
614
+ for entry in (*build.spells, *build.bench)
615
+ }
616
+ )
617
+ if command.grp_id not in known_ids:
618
+ raise ValueError(f"Card {command.grp_id} is not in the current build.")
619
+ snapshot = replace(
620
+ snapshot,
621
+ card_image=CardImageState(
622
+ grp_id=command.grp_id,
623
+ message="Mock card images are unavailable.",
624
+ ),
625
+ )
626
+ elif isinstance(command, ChangeRanking):
627
+ snapshot = replace(
628
+ snapshot,
629
+ recommendations=self._change_ranking(
630
+ state=snapshot.recommendations,
631
+ ranking_mode=command.ranking_mode,
632
+ ),
633
+ )
634
+ elif isinstance(command, ChangeSplashPreference):
635
+ snapshot = replace(
636
+ snapshot,
637
+ recommendations=replace(
638
+ snapshot.recommendations,
639
+ splash_enabled=command.enabled,
640
+ ),
641
+ )
642
+ elif isinstance(command, ChooseAccount):
643
+ matching = next(
644
+ (
645
+ account
646
+ for account in snapshot.accounts
647
+ if account.account_id == command.account_id
648
+ ),
649
+ None,
650
+ )
651
+ if matching is not None:
652
+ snapshot = replace(snapshot, active_account=matching)
653
+ elif isinstance(command, RequestRatingsDownload):
654
+ snapshot = _snapshot_for_scenario(scenario="progress")
655
+ self._scenario = "progress"
656
+ elif isinstance(command, RequestBuild):
657
+ ready = _ready_snapshot()
658
+ build = ready.build
659
+ if build is not None and command.pair_override is not None:
660
+ build = replace(
661
+ build,
662
+ selected_pair=command.pair_override,
663
+ pair_options=tuple(
664
+ replace(
665
+ option,
666
+ selected=option.pair == command.pair_override,
667
+ )
668
+ for option in build.pair_options
669
+ ),
670
+ )
671
+ if build is not None:
672
+ build = replace(build, pair_override=command.pair_override)
673
+ snapshot = replace(
674
+ snapshot,
675
+ build=build,
676
+ progress=None,
677
+ errors=tuple(
678
+ error
679
+ for error in snapshot.errors
680
+ if error.operation != OperationKind.BUILD
681
+ ),
682
+ )
683
+ elif isinstance(command, RequestBacktest):
684
+ snapshot = replace(
685
+ snapshot,
686
+ backtest=_backtest(),
687
+ progress=None,
688
+ errors=tuple(
689
+ error
690
+ for error in snapshot.errors
691
+ if error.operation != OperationKind.BACKTEST
692
+ ),
693
+ )
694
+ elif isinstance(command, DismissError):
695
+ snapshot = replace(
696
+ snapshot,
697
+ errors=tuple(
698
+ error
699
+ for error in snapshot.errors
700
+ if error.error_id != command.error_id
701
+ ),
702
+ )
703
+ elif isinstance(command, RetryError):
704
+ if any(error.error_id == command.error_id for error in snapshot.errors):
705
+ snapshot = _ready_snapshot()
706
+ self._scenario = "ready"
707
+ self._snapshot = snapshot
708
+ return snapshot
709
+
710
+ @staticmethod
711
+ def _change_ranking(
712
+ *,
713
+ state: RecommendationState,
714
+ ranking_mode: str,
715
+ ) -> RecommendationState:
716
+ if ranking_mode not in state.supported_ranking_modes:
717
+ return state
718
+ key = {
719
+ "score": lambda recommendation: -recommendation.score,
720
+ "win_rate": lambda recommendation: -(
721
+ recommendation.win_rate
722
+ if recommendation.win_rate is not None
723
+ else float("-inf")
724
+ ),
725
+ "alsa": lambda recommendation: (
726
+ recommendation.average_last_seen_at
727
+ if recommendation.average_last_seen_at is not None
728
+ else float("inf")
729
+ ),
730
+ "mana_value": lambda recommendation: (
731
+ recommendation.card.mana_value
732
+ if recommendation.card.mana_value is not None
733
+ else float("inf")
734
+ ),
735
+ }[ranking_mode]
736
+ cards = tuple(
737
+ replace(recommendation, rank=index)
738
+ for index, recommendation in enumerate(
739
+ sorted(state.cards, key=key),
740
+ start=1,
741
+ )
742
+ )
743
+ return replace(state, ranking_mode=ranking_mode, cards=cards)
744
+
745
+