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
draftomen/session.py ADDED
@@ -0,0 +1,3668 @@
1
+ """Define immutable state and explicit commands for live Draftomen sessions.
2
+ Frontend adapters consume this contract without importing presentation frameworks.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections import Counter
8
+ from collections.abc import Callable, Iterable
9
+ from dataclasses import dataclass, field, replace
10
+ from datetime import UTC, datetime
11
+ from enum import StrEnum
12
+ from os import PathLike
13
+ from pathlib import Path
14
+ from threading import RLock
15
+ from typing import Protocol, TypeAlias
16
+
17
+ from draftomen.audit import DraftAuditStore
18
+ from draftomen.backtest import (
19
+ BacktestReport as DomainBacktestReport,
20
+ generate_backtest_report,
21
+ load_persisted_backtest_state,
22
+ )
23
+ from draftomen.carddb import CardDatabase, CardInfo
24
+ from draftomen.cardimages import CardImageError, CardImageService
25
+ from draftomen.config import POLL_INTERVAL_SECONDS, SPLASH
26
+ from draftomen.deckbuilder import (
27
+ BuildPool,
28
+ DeckBuilderError,
29
+ ManaBase,
30
+ PairSelection,
31
+ SpellSelection,
32
+ build_deck_from_pool,
33
+ )
34
+ from draftomen.events import (
35
+ EXPECTED_PICKS_PER_PACK,
36
+ AccountEvent,
37
+ DraftCompletedEvent,
38
+ DraftEvent,
39
+ DraftLogParser,
40
+ DraftStartedEvent,
41
+ PackOfferedEvent,
42
+ PickMadeEvent,
43
+ QuickDraftDetectedEvent,
44
+ )
45
+ from draftomen.logfollow import LogFollower, is_log_readable
46
+ from draftomen.pool import (
47
+ AccountProfile,
48
+ DraftPoolError,
49
+ DraftPoolStore,
50
+ DraftState,
51
+ list_account_profiles,
52
+ list_draft_states,
53
+ )
54
+ from draftomen.pickengine import (
55
+ PickEngine,
56
+ ScoredCard,
57
+ ScoredPack,
58
+ recommendation_confidence_summary,
59
+ recommendation_explanation,
60
+ )
61
+ from draftomen.ranking import (
62
+ DEFAULT_RANKING_MODE,
63
+ RANKING_MODES,
64
+ RankingMode,
65
+ rank_scored_cards,
66
+ validate_ranking_mode,
67
+ )
68
+ from draftomen.seventeen import (
69
+ DownloadProgressCallback,
70
+ SeventeenLandsData,
71
+ SeventeenLandsDownloadProgress,
72
+ )
73
+
74
+ PathInput: TypeAlias = str | PathLike[str]
75
+ SnapshotPublisher: TypeAlias = Callable[["LiveSessionSnapshot"], None]
76
+ EventPublisher: TypeAlias = Callable[["LiveSessionEvent"], None]
77
+ CardDatabaseLoader: TypeAlias = Callable[[], CardDatabase]
78
+ RatingsLoader: TypeAlias = Callable[[str], SeventeenLandsData]
79
+ RatingsLoaderFactory: TypeAlias = Callable[[CardDatabase], RatingsLoader]
80
+
81
+
82
+ class RatingsProgressLoader(Protocol):
83
+ def __call__(
84
+ self,
85
+ set_code: str,
86
+ progress_callback: DownloadProgressCallback,
87
+ *,
88
+ refresh: bool,
89
+ ) -> SeventeenLandsData:
90
+ """Load ratings while reporting request progress.
91
+ The callback receives download progress updates during loading.
92
+ """
93
+ ...
94
+
95
+
96
+ RatingsProgressLoaderFactory: TypeAlias = Callable[
97
+ [CardDatabase],
98
+ RatingsProgressLoader,
99
+ ]
100
+ RatingsCacheChecker: TypeAlias = Callable[[str], bool]
101
+
102
+ LOG_SETUP_GUIDANCE = (
103
+ "No draft or readable Player.log was detected. Enable Detailed Logs "
104
+ "(Plugin Support) in Arena's Account settings (usually Settings → Account; "
105
+ "the exact path may vary by platform or Arena version). Restart Arena if "
106
+ "required, then return to Draft Omen and try again while Arena is running."
107
+ )
108
+
109
+ WAITING_FOR_DRAFT_MESSAGE = "Waiting for a Quick Draft."
110
+ RECENT_PICK_LIMIT = 24
111
+
112
+
113
+ class ApplicationPhase(StrEnum):
114
+ """Identify the user-visible phase of the live application.
115
+ Detailed failures remain separate so phases stay stable across frontends.
116
+ """
117
+
118
+ STARTING = "starting"
119
+ WAITING_FOR_DRAFT = "waiting_for_draft"
120
+ DRAFTING = "drafting"
121
+ DRAFT_COMPLETE = "draft_complete"
122
+ STOPPED = "stopped"
123
+
124
+
125
+ class OperationKind(StrEnum):
126
+ """Identify long-running work reported through session progress.
127
+ Frontends decide how each operation is scheduled and presented.
128
+ """
129
+
130
+ CARD_DATA = "card_data"
131
+ RATINGS = "ratings"
132
+ BUILD = "build"
133
+ BACKTEST = "backtest"
134
+
135
+
136
+ class DataLoadPhase(StrEnum):
137
+ """Identify reusable data readiness without presentation-specific state.
138
+ Idle resources are configured but have not started loading yet.
139
+ """
140
+
141
+ UNAVAILABLE = "unavailable"
142
+ IDLE = "idle"
143
+ MISSING = "missing"
144
+ LOADING = "loading"
145
+ READY = "ready"
146
+ FAILED = "failed"
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class ApplicationStatus:
151
+ """Describe the current application phase and concise status message.
152
+ This state contains no frontend-specific formatting or widget state.
153
+ """
154
+
155
+ phase: ApplicationPhase = ApplicationPhase.STARTING
156
+ message: str = "Starting Draft Omen."
157
+ setup_guidance: bool = False
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class AccountIdentity:
162
+ """Identify one known Arena account using stable and display values.
163
+ The account id remains authoritative when the screen name is unavailable.
164
+ """
165
+
166
+ account_id: str
167
+ screen_name: str | None
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class DraftIdentity:
172
+ """Identify the active account-scoped draft and current pick position.
173
+ Optional coordinates represent pre-draft and recovered draft states.
174
+ """
175
+
176
+ account_id: str
177
+ draft_id: str
178
+ event_name: str
179
+ set_code: str
180
+ course_id: str | None
181
+ pack_number: int | None
182
+ pick_number: int | None
183
+ completed: bool
184
+
185
+
186
+ @dataclass(frozen=True, slots=True)
187
+ class CardView:
188
+ """Provide framework-neutral card facts shared by visual surfaces.
189
+ Cached image paths keep retrieval and filesystem work in Python services.
190
+ """
191
+
192
+ grp_id: int
193
+ name: str
194
+ colors: tuple[str, ...]
195
+ rarity: str
196
+ types: tuple[str, ...]
197
+ mana_cost: str | None
198
+ mana_value: float | None
199
+ image_path: str | None
200
+
201
+
202
+ @dataclass(frozen=True, slots=True)
203
+ class Recommendation:
204
+ """Describe one ranked card recommendation and its scoring evidence.
205
+ Primitive values let every frontend choose its own presentation.
206
+ """
207
+
208
+ rank: int
209
+ card: CardView
210
+ score: int
211
+ win_rate: float | None
212
+ average_last_seen_at: float | None
213
+ source_label: str
214
+ color_fit: str
215
+ no_data: bool
216
+ letter_grade: str | None = None
217
+ explanation: str | None = None
218
+
219
+
220
+ @dataclass(frozen=True, slots=True)
221
+ class RecommendationState:
222
+ """Hold the ordered recommendation rows for the current offered pack.
223
+ Selection is state published by Python rather than QML mutation.
224
+ """
225
+
226
+ ranking_mode: RankingMode = "score"
227
+ supported_ranking_modes: tuple[RankingMode, ...] = RANKING_MODES
228
+ splash_enabled: bool = SPLASH.enabled_by_default
229
+ cards: tuple[Recommendation, ...] = ()
230
+ selected_grp_id: int | None = None
231
+ source_summary: str | None = None
232
+ confidence_summary: str | None = None
233
+
234
+
235
+ @dataclass(frozen=True, slots=True)
236
+ class CardDataState:
237
+ """Describe shared card metadata readiness for scoring operations.
238
+ Frontends schedule configured loading work and render this primitive state.
239
+ """
240
+
241
+ phase: DataLoadPhase = DataLoadPhase.UNAVAILABLE
242
+ message: str = "Card metadata is not configured."
243
+ last_successful_update: str | None = None
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class RatingsState:
248
+ """Describe ratings readiness for the active set and offered pack.
249
+ Rated counts are populated after the current pack has been scored.
250
+ """
251
+
252
+ set_code: str | None = None
253
+ phase: DataLoadPhase = DataLoadPhase.UNAVAILABLE
254
+ message: str = "17Lands ratings are not configured."
255
+ rated_cards: int | None = None
256
+ total_cards: int | None = None
257
+ last_successful_update: str | None = None
258
+
259
+
260
+ @dataclass(frozen=True, slots=True)
261
+ class CardImageState:
262
+ """Describe focused-card or recent-pick image availability for adapters.
263
+ Fetching stays in Python while frontends render the atomically published path.
264
+ """
265
+
266
+ grp_id: int | None = None
267
+ image_path: str | None = None
268
+ phase: DataLoadPhase = DataLoadPhase.UNAVAILABLE
269
+ message: str = "No selected card image is available."
270
+
271
+
272
+ @dataclass(frozen=True, slots=True)
273
+ class CardImageRequest:
274
+ """Identify one focused, recent-pick, or recommendation image fetch.
275
+ The session validates completion before publishing its result.
276
+ """
277
+
278
+ generation: int
279
+ grp_id: int
280
+ image_uri: str | None
281
+
282
+
283
+ @dataclass(frozen=True, slots=True)
284
+ class CardImageFetchResult:
285
+ """Carry an image path and the URI resolved by the fetch worker."""
286
+
287
+ image_path: Path
288
+ image_uri: str
289
+
290
+
291
+ @dataclass(frozen=True, slots=True)
292
+ class RecentPick:
293
+ """Describe one chronological picked card and its gallery image state."""
294
+
295
+ card: CardView
296
+ image: CardImageState
297
+
298
+
299
+ @dataclass(frozen=True, slots=True)
300
+ class PoolCard:
301
+ """Describe one distinct card and quantity in the drafted pool.
302
+ Stable ordering allows frontends to render deterministic summaries.
303
+ """
304
+
305
+ card: CardView
306
+ quantity: int
307
+
308
+
309
+ @dataclass(frozen=True, slots=True)
310
+ class PoolState:
311
+ """Describe the drafted pool and its current color commitment.
312
+ Aggregate values avoid forcing presentation code to redo domain work.
313
+ """
314
+
315
+ cards: tuple[PoolCard, ...] = ()
316
+ recent_picks: tuple[RecentPick, ...] = ()
317
+ total_cards: int = 0
318
+ inferred_pair: str | None = None
319
+ commitment: float = 0.0
320
+ color_distribution: tuple[tuple[str, int], ...] = ()
321
+ mana_curve: tuple[int, ...] = ()
322
+ average_mana_value: float | None = None
323
+ target_cards: int = 42
324
+
325
+
326
+ @dataclass(frozen=True, slots=True)
327
+ class ProgressState:
328
+ """Report determinate or indeterminate progress for one operation.
329
+ Missing counts represent work whose total is not yet known.
330
+ """
331
+
332
+ operation: OperationKind
333
+ message: str
334
+ completed: int | None = None
335
+ total: int | None = None
336
+
337
+
338
+ @dataclass(frozen=True, slots=True)
339
+ class SessionError:
340
+ """Describe one stable application error and its recovery capability.
341
+ Error identifiers support explicit dismiss and retry commands.
342
+ """
343
+
344
+ error_id: str
345
+ code: str
346
+ message: str
347
+ recoverable: bool
348
+ operation: OperationKind | None = None
349
+
350
+
351
+ @dataclass(frozen=True, slots=True)
352
+ class BuildPairOption:
353
+ """Describe one scored color-pair option for a deck build.
354
+ The selected pair and automatic pair remain independently visible.
355
+ """
356
+
357
+ pair: str
358
+ score: float
359
+ selected: bool
360
+ automatic: bool
361
+ playable_count: int | None = None
362
+ playable_score_sum: float | None = None
363
+ pair_win_rate: float | None = None
364
+
365
+
366
+ @dataclass(frozen=True, slots=True)
367
+ class BuildCard:
368
+ """Describe an ordered spell or bench card in a build result.
369
+ Quantities preserve duplicate picks without repeating presentation rows.
370
+ """
371
+
372
+ card: CardView
373
+ quantity: int
374
+ score: int | None = None
375
+ win_rate: float | None = None
376
+ average_last_seen_at: float | None = None
377
+ letter_grade: str | None = None
378
+ source_label: str | None = None
379
+ color_fit: str | None = None
380
+ no_data: bool = False
381
+
382
+
383
+ @dataclass(frozen=True, slots=True)
384
+ class BuildLand:
385
+ """Describe one basic or drafted nonbasic land in a build result.
386
+ Optional card data distinguishes generated basics from drafted lands.
387
+ """
388
+
389
+ name: str
390
+ quantity: int
391
+ source_colors: tuple[str, ...]
392
+ card: CardView | None = None
393
+
394
+
395
+ @dataclass(frozen=True, slots=True)
396
+ class BuildResult:
397
+ """Provide structured pair, spell, land, bench, and warning output.
398
+ Frontends render this data without parsing terminal-formatted build text.
399
+ """
400
+
401
+ selected_pair: str
402
+ pair_options: tuple[BuildPairOption, ...]
403
+ spells: tuple[BuildCard, ...]
404
+ lands: tuple[BuildLand, ...]
405
+ bench: tuple[BuildCard, ...]
406
+ deck_size: int
407
+ average_mana_value: float | None = None
408
+ pair_override: str | None = None
409
+ warnings: tuple[str, ...] = ()
410
+ spell_count: int | None = None
411
+ land_count: int | None = None
412
+ creature_count: int | None = None
413
+ instant_count: int | None = None
414
+ domain_pool: BuildPool | None = None
415
+ domain_selection: PairSelection | None = None
416
+ domain_spell_selection: SpellSelection | None = None
417
+ domain_mana_base: ManaBase | None = None
418
+
419
+
420
+ @dataclass(frozen=True, slots=True)
421
+ class BacktestPickResult:
422
+ """Describe the recommendation comparison for one persisted draft pick.
423
+ Missing cards and match state preserve skipped-history outcomes.
424
+ """
425
+
426
+ pack_number: int
427
+ pick_number: int
428
+ recommended: CardView | None
429
+ actual: CardView | None
430
+ match: bool | None
431
+ skipped_reason: str | None
432
+ data_source: str | None
433
+ pool_size: int | None = None
434
+ offered_count: int | None = None
435
+ recommended_score: int | None = None
436
+ recommended_win_rate: float | None = None
437
+
438
+
439
+ @dataclass(frozen=True, slots=True)
440
+ class BacktestResult:
441
+ """Provide structured backtest rows and their aggregate outcome counts.
442
+ Data sources remain available for attribution and fallback disclosure.
443
+ """
444
+
445
+ ranking_mode: RankingMode
446
+ rows: tuple[BacktestPickResult, ...]
447
+ match_count: int
448
+ compared_count: int
449
+ skipped_count: int
450
+ data_sources: tuple[str, ...]
451
+ account_id: str | None = None
452
+ account_screen_name: str | None = None
453
+ draft_id: str | None = None
454
+ set_code: str | None = None
455
+ event_name: str | None = None
456
+ completed: bool | None = None
457
+ chosen_pick_count: int | None = None
458
+
459
+
460
+ @dataclass(frozen=True, slots=True)
461
+ class LiveSessionSnapshot:
462
+ """Publish all UI-neutral state needed by Draftomen frontends.
463
+ Replacing snapshots keeps presentation adapters out of domain mutation.
464
+ """
465
+
466
+ status: ApplicationStatus = field(default_factory=ApplicationStatus)
467
+ accounts: tuple[AccountIdentity, ...] = ()
468
+ active_account: AccountIdentity | None = None
469
+ draft: DraftIdentity | None = None
470
+ card_data: CardDataState = field(default_factory=CardDataState)
471
+ ratings: RatingsState = field(default_factory=RatingsState)
472
+ recommendations: RecommendationState = field(default_factory=RecommendationState)
473
+ pool: PoolState = field(default_factory=PoolState)
474
+ card_image: CardImageState = field(default_factory=CardImageState)
475
+ current_pack_event: PackOfferedEvent | None = None
476
+ current_scored_pack: ScoredPack | None = None
477
+ progress: ProgressState | None = None
478
+ errors: tuple[SessionError, ...] = ()
479
+ build: BuildResult | None = None
480
+ backtest: BacktestResult | None = None
481
+
482
+
483
+ @dataclass(frozen=True, slots=True)
484
+ class LiveSessionEvent:
485
+ """Publish one consumed domain event with its resulting session state.
486
+ Event adapters can preserve ordering without duplicating session orchestration.
487
+ """
488
+
489
+ event: DraftEvent
490
+ snapshot: LiveSessionSnapshot
491
+ scored_pack: ScoredPack | None = None
492
+
493
+
494
+ @dataclass(frozen=True, slots=True)
495
+ class ChooseAccount:
496
+ """Request that the session make one known account active.
497
+ The session remains responsible for resolving its persisted draft.
498
+ """
499
+
500
+ account_id: str
501
+
502
+
503
+ @dataclass(frozen=True, slots=True)
504
+ class ChooseRecommendation:
505
+ """Request that one recommendation become the selected card.
506
+ Selection changes are published in the next immutable snapshot.
507
+ """
508
+
509
+ grp_id: int
510
+
511
+
512
+ @dataclass(frozen=True, slots=True)
513
+ class FocusBuildCard:
514
+ """Request that one current build spell or bench card receive image focus."""
515
+
516
+ grp_id: int
517
+
518
+
519
+ @dataclass(frozen=True, slots=True)
520
+ class ChangeRanking:
521
+ """Request a supported recommendation and backtest ranking mode.
522
+ The application layer validates and applies the functional choice.
523
+ """
524
+
525
+ ranking_mode: RankingMode
526
+
527
+
528
+ @dataclass(frozen=True, slots=True)
529
+ class ChangeSplashPreference:
530
+ """Request whether supported splash recommendations are considered.
531
+ The application layer owns rescoring and persistence consequences.
532
+ """
533
+
534
+ enabled: bool
535
+
536
+
537
+ @dataclass(frozen=True, slots=True)
538
+ class RequestRatingsDownload:
539
+ """Request ratings retrieval for one active draft set.
540
+ Progress and recoverable failures return through session state.
541
+ """
542
+
543
+ set_code: str
544
+
545
+
546
+ @dataclass(frozen=True, slots=True)
547
+ class RequestBuild:
548
+ """Request a deck build with an optional explicit pair override.
549
+ Structured build output returns through the immutable snapshot.
550
+ """
551
+
552
+ pair_override: str | None = None
553
+ allow_splash: bool = True
554
+
555
+
556
+ @dataclass(frozen=True, slots=True)
557
+ class RequestBacktest:
558
+ """Request a persisted-draft backtest using explicit identity filters.
559
+ Missing filters allow the application to select the active draft.
560
+ """
561
+
562
+ account_id: str | None = None
563
+ draft_id: str | None = None
564
+
565
+
566
+ @dataclass(frozen=True, slots=True)
567
+ class DismissError:
568
+ """Request removal of one recoverable or acknowledged error.
569
+ The error identifier avoids direct mutation of an errors collection.
570
+ """
571
+
572
+ error_id: str
573
+
574
+
575
+ @dataclass(frozen=True, slots=True)
576
+ class RetryError:
577
+ """Request retry of the operation associated with one error.
578
+ The application decides whether the current state permits a retry.
579
+ """
580
+
581
+ error_id: str
582
+
583
+
584
+ LiveSessionCommand: TypeAlias = (
585
+ ChooseAccount
586
+ | ChooseRecommendation
587
+ | FocusBuildCard
588
+ | ChangeRanking
589
+ | ChangeSplashPreference
590
+ | RequestRatingsDownload
591
+ | RequestBuild
592
+ | RequestBacktest
593
+ | DismissError
594
+ | RetryError
595
+ )
596
+
597
+
598
+ class LiveSession:
599
+ """Coordinate live Arena ingestion and persisted draft lifecycle state.
600
+ Frontends schedule polling and receive immutable snapshots through one contract.
601
+ """
602
+
603
+ def __init__(
604
+ self,
605
+ *,
606
+ log_path: PathInput,
607
+ card_database: CardDatabase | None = None,
608
+ card_database_loader: CardDatabaseLoader | None = None,
609
+ app_dir: PathInput | None = None,
610
+ poll_interval: float = POLL_INTERVAL_SECONDS,
611
+ previous_log_path: PathInput | None = None,
612
+ snapshot_publisher: SnapshotPublisher | None = None,
613
+ event_publisher: EventPublisher | None = None,
614
+ ranking_mode: RankingMode = DEFAULT_RANKING_MODE,
615
+ ratings_loader: RatingsLoader | None = None,
616
+ ratings_loader_factory: RatingsLoaderFactory | None = None,
617
+ ratings_progress_loader: RatingsProgressLoader | None = None,
618
+ ratings_progress_loader_factory: RatingsProgressLoaderFactory | None = None,
619
+ ratings_cache_checker: RatingsCacheChecker | None = None,
620
+ lazy_pair_card_ratings: bool = False,
621
+ card_image_service: CardImageService | None = None,
622
+ splash_enabled: bool = SPLASH.enabled_by_default,
623
+ ) -> None:
624
+ if card_database is not None and card_database_loader is not None:
625
+ raise ValueError(
626
+ "card_database and card_database_loader are mutually exclusive."
627
+ )
628
+ configured_ratings_loaders = sum(
629
+ loader is not None
630
+ for loader in (
631
+ ratings_loader,
632
+ ratings_loader_factory,
633
+ ratings_progress_loader,
634
+ ratings_progress_loader_factory,
635
+ )
636
+ )
637
+ if configured_ratings_loaders > 1:
638
+ raise ValueError("Configure exactly one ratings loader or loader factory.")
639
+
640
+ self.log_path = Path(log_path).expanduser().resolve(strict=False)
641
+ initial_log_readable = is_log_readable(path=self.log_path)
642
+ self.follower = LogFollower(
643
+ log_path=self.log_path,
644
+ app_dir=app_dir,
645
+ poll_interval=poll_interval,
646
+ previous_log_path=previous_log_path,
647
+ )
648
+ self.parser = DraftLogParser()
649
+ self.store = DraftPoolStore(app_dir=app_dir)
650
+ self.audit_store = DraftAuditStore(app_dir=self.store.root.parent)
651
+ self._state_lock = RLock()
652
+ self._snapshot_publisher = snapshot_publisher
653
+ self._event_publisher = event_publisher
654
+ self._ranking_mode = validate_ranking_mode(ranking_mode=ranking_mode)
655
+ self._splash_enabled = splash_enabled
656
+ self._card_database = card_database
657
+ self._card_database_loader = card_database_loader
658
+ self._ratings_loader = ratings_loader
659
+ self._ratings_loader_factory = ratings_loader_factory
660
+ self._ratings_progress_loader = ratings_progress_loader
661
+ self._ratings_progress_loader_factory = ratings_progress_loader_factory
662
+ self._ratings_cache_checker = ratings_cache_checker
663
+ self._lazy_pair_card_ratings = lazy_pair_card_ratings
664
+ self._ratings_data_by_set: dict[str, SeventeenLandsData | None] = {}
665
+ self._ratings_state_by_set: dict[str, RatingsState] = {}
666
+ self._ratings_progress_by_set: dict[str, ProgressState] = {}
667
+ self._ratings_errors_by_set: dict[str, SessionError] = {}
668
+ self._loading_rating_sets: set[str] = set()
669
+ self._active_set_code_value: str | None = None
670
+ self._current_pack_event: PackOfferedEvent | None = None
671
+ self._current_scored_pack: ScoredPack | None = None
672
+ self._transient_pool_grp_ids: tuple[int, ...] = ()
673
+ self._last_build_request: RequestBuild | None = None
674
+ self._card_image_request: CardImageRequest | None = None
675
+ self._last_backtest_request: RequestBacktest | None = None
676
+ self._build_request_generation = 0
677
+ self._backtest_request_generation = 0
678
+ self._login_generation = self.parser.login_generation
679
+ self._log_account_id: str | None = None
680
+ self._states_by_key: dict[tuple[str, str], DraftState] = {}
681
+ self._screen_names_by_account_id: dict[str, str] = {}
682
+ self._card_image_service = card_image_service
683
+ self._card_image_paths_by_uri: dict[str, Path] = {}
684
+ self._card_image_uris_by_grp_id: dict[int, str] = {}
685
+ self._card_image_paths_by_grp_id: dict[int, Path] = {}
686
+ self._card_image_generation = 0
687
+ self._recommendation_image_generation = 0
688
+ self._recommendation_image_pack: tuple[
689
+ PackOfferedEvent | None,
690
+ str | None,
691
+ str | None,
692
+ ] | None = None
693
+ self._recommendation_image_requests: list[CardImageRequest] = []
694
+ self._recommendation_image_failures: set[tuple[int, str | None]] = set()
695
+ self._recent_pick_image_generation = 0
696
+ self._recent_pick_image_requests: list[CardImageRequest] = []
697
+ self._recent_pick_image_states: dict[int, CardImageState] = {}
698
+ self._recent_pick_pool_grp_ids: tuple[int, ...] | None = None
699
+ self._configure_ratings_loader_for_card_database()
700
+ if card_database is not None:
701
+ card_data = CardDataState(
702
+ phase=DataLoadPhase.READY,
703
+ message="Card metadata is ready.",
704
+ last_successful_update=_last_successful_update(
705
+ database=card_database
706
+ ),
707
+ )
708
+ elif card_database_loader is not None:
709
+ card_data = CardDataState(
710
+ phase=DataLoadPhase.IDLE,
711
+ message="Card metadata is ready to load.",
712
+ )
713
+ else:
714
+ card_data = CardDataState()
715
+ ratings_state = self._initial_ratings_state()
716
+ self._snapshot = LiveSessionSnapshot(
717
+ status=_waiting_for_draft_status(setup_guidance=not initial_log_readable),
718
+ accounts=self._known_accounts(),
719
+ card_data=card_data,
720
+ ratings=ratings_state,
721
+ recommendations=RecommendationState(
722
+ ranking_mode=self._ranking_mode,
723
+ splash_enabled=splash_enabled,
724
+ ),
725
+ )
726
+
727
+ @property
728
+ def snapshot(self) -> LiveSessionSnapshot:
729
+ """Return the latest immutable application snapshot.
730
+ Reading state has no parsing, persistence, or publication side effects.
731
+ """
732
+
733
+ return self._snapshot
734
+
735
+ @property
736
+ def card_database(self) -> CardDatabase | None:
737
+ """Return the card database currently owned by the live session.
738
+ Frontends may use it for presentation-only metadata lookups.
739
+ """
740
+
741
+ return self._card_database
742
+
743
+ @property
744
+ def current_pack_event(self) -> PackOfferedEvent | None:
745
+ """Return the active immutable pack event, when one is available.
746
+ Presentation adapters may use its coordinates and offered card ids.
747
+ """
748
+
749
+ return self.snapshot.current_pack_event
750
+
751
+ @property
752
+ def current_scored_pack(self) -> ScoredPack | None:
753
+ """Return the active immutable scored pack, when one is available.
754
+ Presentation adapters may retain their existing domain renderers.
755
+ """
756
+
757
+ return self.snapshot.current_scored_pack
758
+
759
+ def selected_card_image_request(self) -> CardImageRequest | None:
760
+ """Return the current selected-image fetch for an active frontend.
761
+ Calling this method has no network or publication side effects.
762
+ """
763
+
764
+ return self._card_image_request
765
+
766
+ def recent_pick_image_request(self) -> CardImageRequest | None:
767
+ """Return the next queued recent-pick image fetch, if any."""
768
+
769
+ return (
770
+ self._recent_pick_image_requests[0]
771
+ if self._recent_pick_image_requests
772
+ else None
773
+ )
774
+
775
+ def recommendation_image_request(self) -> CardImageRequest | None:
776
+ """Return the next queued current-pack recommendation image fetch."""
777
+
778
+ return (
779
+ self._recommendation_image_requests[0]
780
+ if self._recommendation_image_requests
781
+ else None
782
+ )
783
+
784
+ def _resolved_image_uri_for_request(
785
+ self,
786
+ *,
787
+ request: CardImageRequest,
788
+ ) -> str | None:
789
+ """Return a request URI without performing metadata or network work."""
790
+
791
+ return request.image_uri or self._card_image_uris_by_grp_id.get(request.grp_id)
792
+
793
+ def _remember_card_image(
794
+ self,
795
+ *,
796
+ grp_id: int,
797
+ image_uri: str | None,
798
+ image_path: Path,
799
+ ) -> None:
800
+ """Retain a successful image by card and by resolved URI."""
801
+
802
+ self._card_image_paths_by_grp_id[grp_id] = image_path
803
+ if image_uri is not None:
804
+ self._card_image_uris_by_grp_id[grp_id] = image_uri
805
+ self._card_image_paths_by_uri[image_uri] = image_path
806
+
807
+
808
+ def recommendation_image_requests(self) -> tuple[CardImageRequest, ...]:
809
+ """Return a stable view of all queued recommendation image fetches."""
810
+
811
+ return tuple(self._recommendation_image_requests)
812
+
813
+ def _retire_recommendation_requests_for_image(
814
+ self,
815
+ *,
816
+ grp_id: int,
817
+ image_uri: str | None,
818
+ ) -> None:
819
+ """Drop recommendation work satisfied by a focused image fetch."""
820
+
821
+ self._recommendation_image_requests = [
822
+ request
823
+ for request in self._recommendation_image_requests
824
+ if request.grp_id != grp_id
825
+ and (image_uri is None or request.image_uri != image_uri)
826
+ ]
827
+
828
+ def complete_recommendation_image_request(
829
+ self,
830
+ *,
831
+ request: CardImageRequest,
832
+ image_path: Path,
833
+ image_uri: str | None = None,
834
+ ) -> None:
835
+ """Publish one recommendation image when its pack generation is current."""
836
+
837
+ with self._state_lock:
838
+ if (
839
+ request.generation != self._recommendation_image_generation
840
+ or request not in self._recommendation_image_requests
841
+ ):
842
+ return
843
+ self._recommendation_image_requests.remove(request)
844
+ resolved_image_uri = image_uri or self._resolved_image_uri_for_request(
845
+ request=request
846
+ )
847
+ self._remember_card_image(
848
+ grp_id=request.grp_id,
849
+ image_uri=resolved_image_uri,
850
+ image_path=image_path,
851
+ )
852
+ recommendations = replace(
853
+ self.snapshot.recommendations,
854
+ cards=tuple(
855
+ replace(
856
+ recommendation,
857
+ card=replace(
858
+ recommendation.card,
859
+ image_path=str(image_path),
860
+ ),
861
+ )
862
+ if recommendation.card.grp_id == request.grp_id
863
+ else recommendation
864
+ for recommendation in self.snapshot.recommendations.cards
865
+ ),
866
+ )
867
+ self._publish(
868
+ snapshot=replace(
869
+ self.snapshot,
870
+ recommendations=recommendations,
871
+ )
872
+ )
873
+
874
+ def fail_recommendation_image_request(
875
+ self,
876
+ *,
877
+ request: CardImageRequest,
878
+ error_message: str,
879
+ ) -> None:
880
+ """Retire one failed recommendation image without stopping the queue."""
881
+
882
+ del error_message
883
+ with self._state_lock:
884
+ if (
885
+ request.generation != self._recommendation_image_generation
886
+ or request not in self._recommendation_image_requests
887
+ ):
888
+ return
889
+ self._recommendation_image_failures.add(
890
+ (request.grp_id, request.image_uri)
891
+ )
892
+ self._recommendation_image_requests = [
893
+ queued
894
+ for queued in self._recommendation_image_requests
895
+ if queued != request
896
+ ]
897
+
898
+ def complete_recent_pick_image_request(
899
+ self,
900
+ *,
901
+ request: CardImageRequest,
902
+ image_path: Path,
903
+ image_uri: str | None = None,
904
+ ) -> None:
905
+ """Publish a successful gallery fetch when its pool generation is current."""
906
+
907
+ with self._state_lock:
908
+ if (
909
+ request.generation != self._recent_pick_image_generation
910
+ or request not in self._recent_pick_image_requests
911
+ or request.grp_id not in self._recent_pick_image_states
912
+ ):
913
+ return
914
+ self._recent_pick_image_requests.remove(request)
915
+ resolved_image_uri = image_uri or self._resolved_image_uri_for_request(
916
+ request=request
917
+ )
918
+ self._remember_card_image(
919
+ grp_id=request.grp_id,
920
+ image_uri=resolved_image_uri,
921
+ image_path=image_path,
922
+ )
923
+ self._recent_pick_image_states[request.grp_id] = CardImageState(
924
+ grp_id=request.grp_id,
925
+ image_path=str(image_path),
926
+ phase=DataLoadPhase.READY,
927
+ message="Card image ready.",
928
+ )
929
+ self._publish_recent_pick_projection()
930
+
931
+ def fail_recent_pick_image_request(
932
+ self,
933
+ *,
934
+ request: CardImageRequest,
935
+ error_message: str,
936
+ ) -> None:
937
+ """Publish a failed gallery fetch when its pool generation is current."""
938
+
939
+ with self._state_lock:
940
+ if (
941
+ request.generation != self._recent_pick_image_generation
942
+ or request not in self._recent_pick_image_requests
943
+ or request.grp_id not in self._recent_pick_image_states
944
+ ):
945
+ return
946
+ self._recent_pick_image_requests.remove(request)
947
+ self._recent_pick_image_states[request.grp_id] = CardImageState(
948
+ grp_id=request.grp_id,
949
+ phase=DataLoadPhase.FAILED,
950
+ message=f"Card image unavailable: {error_message}",
951
+ )
952
+ self._publish_recent_pick_projection()
953
+
954
+
955
+ def fetch_card_image(self, *, request: CardImageRequest) -> CardImageFetchResult:
956
+ """Resolve and fetch one image in the caller-owned image worker."""
957
+
958
+ card_image_service = self._card_image_service
959
+ if card_image_service is None:
960
+ raise ValueError("Card images are not configured.")
961
+
962
+ image_uri = self._resolved_image_uri_for_request(request=request)
963
+ if image_uri is None:
964
+ database = self._card_database
965
+ if database is None:
966
+ raise CardImageError("Card metadata is not configured.")
967
+ card = database.lookup(grp_id=request.grp_id)
968
+ image_uri = card_image_service.resolve_focused_image_uri(
969
+ card=card,
970
+ card_database=database,
971
+ )
972
+ if image_uri is None:
973
+ raise CardImageError(f"No card image is available for {card.name}.")
974
+ return CardImageFetchResult(
975
+ image_path=card_image_service.fetch(image_uri=image_uri),
976
+ image_uri=image_uri,
977
+ )
978
+
979
+ def complete_card_image_request(
980
+ self,
981
+ *,
982
+ request: CardImageRequest,
983
+ image_path: Path,
984
+ image_uri: str | None = None,
985
+ ) -> None:
986
+ """Publish a successful adapter-owned image fetch when it is still current."""
987
+
988
+ with self._state_lock:
989
+ if request != self._card_image_request:
990
+ return
991
+ self._card_image_request = None
992
+ resolved_image_uri = image_uri or self._resolved_image_uri_for_request(
993
+ request=request
994
+ )
995
+ self._retire_recommendation_requests_for_image(
996
+ grp_id=request.grp_id,
997
+ image_uri=resolved_image_uri,
998
+ )
999
+ self._remember_card_image(
1000
+ grp_id=request.grp_id,
1001
+ image_uri=resolved_image_uri,
1002
+ image_path=image_path,
1003
+ )
1004
+ self._publish_selected_card_image(
1005
+ grp_id=request.grp_id,
1006
+ image_path=image_path,
1007
+ )
1008
+
1009
+ def fail_card_image_request(
1010
+ self,
1011
+ *,
1012
+ request: CardImageRequest,
1013
+ error_message: str,
1014
+ ) -> None:
1015
+ """Publish an adapter-owned image failure when it is still current."""
1016
+ with self._state_lock:
1017
+ if request != self._card_image_request:
1018
+ return
1019
+ self._card_image_request = None
1020
+ self._retire_recommendation_requests_for_image(
1021
+ grp_id=request.grp_id,
1022
+ image_uri=self._resolved_image_uri_for_request(request=request),
1023
+ )
1024
+ self._publish(
1025
+ snapshot=replace(
1026
+ self.snapshot,
1027
+ card_image=CardImageState(
1028
+ grp_id=request.grp_id,
1029
+ phase=DataLoadPhase.FAILED,
1030
+ message=f"Card image unavailable: {error_message}",
1031
+ ),
1032
+ )
1033
+ )
1034
+
1035
+ def _retire_card_image(self) -> CardImageState:
1036
+ """Invalidate the active fetch and return the neutral image state.
1037
+ Callers publish the returned state while holding the session state lock.
1038
+ """
1039
+
1040
+ self._card_image_generation += 1
1041
+ self._card_image_request = None
1042
+ return CardImageState()
1043
+
1044
+ def ratings_data(self, *, set_code: str) -> SeventeenLandsData | None:
1045
+ """Return loaded ratings for presentation-adjacent legacy services.
1046
+ The live session remains the only owner of ratings loading and caching.
1047
+ """
1048
+
1049
+ return self._ratings_data_by_set.get(set_code.upper())
1050
+
1051
+ def known_accounts(self) -> tuple[AccountIdentity, ...]:
1052
+ """Return known accounts from current profiles and persisted drafts.
1053
+ Adapters can refresh choices added after session construction.
1054
+ """
1055
+
1056
+ return self._known_accounts()
1057
+
1058
+ def poll_once(self) -> LiveSessionSnapshot:
1059
+ """Process one follower polling cycle and return the latest snapshot.
1060
+ Frontends decide whether this call runs on a worker or event-loop callback.
1061
+ """
1062
+
1063
+ snapshot = self._refresh_log_setup_status()
1064
+ if snapshot.status.setup_guidance:
1065
+ return snapshot
1066
+
1067
+ lines = self.follower.poll()
1068
+ self.process_lines(lines=lines)
1069
+ return self._refresh_log_setup_status()
1070
+
1071
+ def _refresh_log_setup_status(self) -> LiveSessionSnapshot:
1072
+ """Refresh neutral waiting guidance without overriding draft activity.
1073
+ Draft-specific statuses retain their normal event-driven state even if a
1074
+ later poll cannot open the log.
1075
+ """
1076
+
1077
+ log_readable = is_log_readable(path=self.log_path)
1078
+ with self._state_lock:
1079
+ snapshot = self.snapshot
1080
+ if not _log_setup_refresh_is_eligible(snapshot=snapshot):
1081
+ return snapshot
1082
+
1083
+ next_status = _waiting_for_draft_status(
1084
+ setup_guidance=not log_readable,
1085
+ )
1086
+ if snapshot.status == next_status:
1087
+ return snapshot
1088
+
1089
+ self._publish(snapshot=replace(snapshot, status=next_status))
1090
+ return self.snapshot
1091
+
1092
+ def scan_startup_files(
1093
+ self,
1094
+ *,
1095
+ include_previous: bool = True,
1096
+ include_pre_draft_detection: bool = True,
1097
+ ) -> LiveSessionSnapshot:
1098
+ """Process startup recovery logs in follower-defined chronological order.
1099
+ The follower advances its offset so later polling does not replay current lines.
1100
+ """
1101
+
1102
+ self.process_lines(
1103
+ lines=self.follower.scan_startup_files(
1104
+ include_previous=include_previous,
1105
+ ),
1106
+ include_pre_draft_detection=include_pre_draft_detection,
1107
+ )
1108
+ return self._refresh_log_setup_status()
1109
+
1110
+ def process_lines(
1111
+ self,
1112
+ *,
1113
+ lines: Iterable[str],
1114
+ include_pre_draft_detection: bool = True,
1115
+ ) -> LiveSessionSnapshot:
1116
+ """Consume complete Arena log lines and publish each resulting state change.
1117
+ Parser and account context remain incremental across repeated batches.
1118
+ """
1119
+
1120
+ for line in lines:
1121
+ events = tuple(self.parser.parse_lines(lines=(line,)))
1122
+ self._discard_previous_login_account_context()
1123
+ for parsed_event in events:
1124
+ if (
1125
+ isinstance(parsed_event, QuickDraftDetectedEvent)
1126
+ and not include_pre_draft_detection
1127
+ ):
1128
+ continue
1129
+
1130
+ event = self._event_with_log_account(event=parsed_event)
1131
+ state = self._consume_store_event(event=event)
1132
+ if state is not None:
1133
+ self._remember_state(state=state)
1134
+ self._log_account_id = state.account_id
1135
+ if _event_is_missing_account(event=event):
1136
+ event = replace(event, account_id=state.account_id)
1137
+ self._consume_event(event=event, state=state)
1138
+ self._publish_event(event=event)
1139
+ self._persist_pending_login_name_for_observed_course()
1140
+ return self.snapshot
1141
+
1142
+ def dispatch(self, *, command: LiveSessionCommand) -> LiveSessionSnapshot:
1143
+ """Apply one explicit frontend intention and publish the resulting snapshot.
1144
+ Blocking service work runs synchronously so frontend adapters own scheduling.
1145
+ """
1146
+
1147
+ if isinstance(command, ChooseAccount):
1148
+ self._choose_account(account_id=command.account_id)
1149
+ return self.snapshot
1150
+
1151
+ if isinstance(command, ChangeRanking):
1152
+ self._change_ranking(ranking_mode=command.ranking_mode)
1153
+ return self.snapshot
1154
+
1155
+ if isinstance(command, ChooseRecommendation):
1156
+ self._choose_recommendation(grp_id=command.grp_id)
1157
+ return self.snapshot
1158
+
1159
+ if isinstance(command, FocusBuildCard):
1160
+ self._focus_build_card(grp_id=command.grp_id)
1161
+ return self.snapshot
1162
+
1163
+ if isinstance(command, ChangeSplashPreference):
1164
+ self._change_splash_preference(enabled=command.enabled)
1165
+ return self.snapshot
1166
+
1167
+ if isinstance(command, RequestRatingsDownload):
1168
+ self._request_ratings_download(set_code=command.set_code)
1169
+ return self.snapshot
1170
+
1171
+ if isinstance(command, RequestBuild):
1172
+ self._request_build(command=command)
1173
+ return self.snapshot
1174
+
1175
+ if isinstance(command, RequestBacktest):
1176
+ self._request_backtest(command=command)
1177
+ return self.snapshot
1178
+
1179
+ if isinstance(command, DismissError):
1180
+ self._dismiss_error(error_id=command.error_id)
1181
+ return self.snapshot
1182
+
1183
+ if isinstance(command, RetryError):
1184
+ self._retry_error(error_id=command.error_id)
1185
+ return self.snapshot
1186
+
1187
+ raise ValueError(f"Live session command is not implemented yet: {command!r}.")
1188
+
1189
+ def _request_build(self, *, command: RequestBuild) -> None:
1190
+ with self._state_lock:
1191
+ self._last_build_request = command
1192
+ self._build_request_generation += 1
1193
+ generation = self._build_request_generation
1194
+ account = self.snapshot.active_account
1195
+ draft = self.snapshot.draft
1196
+ pool = self.snapshot.pool
1197
+ ratings = self.snapshot.ratings
1198
+ self._publish(
1199
+ snapshot=replace(
1200
+ self.snapshot,
1201
+ progress=ProgressState(
1202
+ operation=OperationKind.BUILD,
1203
+ message="Building deck",
1204
+ ),
1205
+ errors=self._without_operation_error(
1206
+ operation=OperationKind.BUILD,
1207
+ ),
1208
+ )
1209
+ )
1210
+ try:
1211
+ build = self._build_result(command=command)
1212
+ except Exception as error:
1213
+ session_error = SessionError(
1214
+ error_id="build",
1215
+ code="build_failed",
1216
+ message=f"Deck build failed: {error}",
1217
+ recoverable=True,
1218
+ operation=OperationKind.BUILD,
1219
+ )
1220
+ with self._state_lock:
1221
+ if not self._build_request_is_current(
1222
+ generation=generation,
1223
+ account=account,
1224
+ draft=draft,
1225
+ pool=pool,
1226
+ ratings=ratings,
1227
+ ):
1228
+ return
1229
+ self._publish(
1230
+ snapshot=replace(
1231
+ self.snapshot,
1232
+ progress=self._progress_after_operation(
1233
+ operation=OperationKind.BUILD,
1234
+ ),
1235
+ errors=self._with_error(error=session_error),
1236
+ build=None,
1237
+ )
1238
+ )
1239
+ return
1240
+
1241
+ with self._state_lock:
1242
+ if not self._build_request_is_current(
1243
+ generation=generation,
1244
+ account=account,
1245
+ draft=draft,
1246
+ pool=pool,
1247
+ ratings=ratings,
1248
+ ):
1249
+ return
1250
+ self._publish(
1251
+ snapshot=replace(
1252
+ self.snapshot,
1253
+ progress=self._progress_after_operation(
1254
+ operation=OperationKind.BUILD,
1255
+ ),
1256
+ errors=self._without_operation_error(
1257
+ operation=OperationKind.BUILD,
1258
+ ),
1259
+ build=build,
1260
+ )
1261
+ )
1262
+
1263
+ def _build_request_is_current(
1264
+ self,
1265
+ *,
1266
+ generation: int,
1267
+ account: AccountIdentity | None,
1268
+ draft: DraftIdentity | None,
1269
+ pool: PoolState,
1270
+ ratings: RatingsState,
1271
+ ) -> bool:
1272
+ return (
1273
+ generation == self._build_request_generation
1274
+ and account == self.snapshot.active_account
1275
+ and draft == self.snapshot.draft
1276
+ and pool == self.snapshot.pool
1277
+ and ratings == self.snapshot.ratings
1278
+ )
1279
+
1280
+ def _build_result(self, *, command: RequestBuild) -> BuildResult:
1281
+ state = self._active_draft_state()
1282
+ if state is None:
1283
+ set_code = self._active_set_code()
1284
+ pool_grp_ids = self._transient_pool_grp_ids
1285
+ account = self.snapshot.active_account
1286
+ account_id = None if account is None else account.account_id
1287
+ draft_id = None
1288
+ else:
1289
+ set_code = state.set_code
1290
+ pool_grp_ids = state.pool_grp_ids
1291
+ account_id = state.account_id
1292
+ draft_id = state.draft_id
1293
+
1294
+ if set_code is None or (state is None and not pool_grp_ids):
1295
+ raise DeckBuilderError("Deck build unavailable: no picked cards yet.")
1296
+ if self._card_database is None:
1297
+ raise DeckBuilderError("Deck build unavailable: card metadata is not ready.")
1298
+
1299
+ pool = BuildPool(
1300
+ set_code=set_code,
1301
+ pool_grp_ids=pool_grp_ids,
1302
+ source_label="live draft",
1303
+ account_id=account_id,
1304
+ draft_id=draft_id,
1305
+ )
1306
+ selection, build_sheet = build_deck_from_pool(
1307
+ pool=pool,
1308
+ card_database=self._card_database,
1309
+ ratings_data=self._ratings_data_for_scoring(set_code=set_code),
1310
+ forced_pair=command.pair_override,
1311
+ allow_splash=command.allow_splash,
1312
+ )
1313
+ spell_selection = build_sheet.spell_selection
1314
+ mana_base = build_sheet.mana_base
1315
+ average_mana_value = (
1316
+ None
1317
+ if any(spell.card.mana_value is None for spell in spell_selection.spells)
1318
+ else mana_base.average_mana_value
1319
+ )
1320
+ return BuildResult(
1321
+ selected_pair=selection.chosen.pair,
1322
+ pair_options=tuple(
1323
+ BuildPairOption(
1324
+ pair=score.pair,
1325
+ score=score.blended_score,
1326
+ selected=score.pair == selection.chosen.pair,
1327
+ automatic=score.pair == selection.automatic.pair,
1328
+ playable_count=score.playable_count,
1329
+ playable_score_sum=score.playable_score_sum,
1330
+ pair_win_rate=score.pair_win_rate,
1331
+ )
1332
+ for score in selection.ranked_scores
1333
+ ),
1334
+ spells=_build_cards(
1335
+ cards=tuple(
1336
+ sorted(
1337
+ spell_selection.spells,
1338
+ key=_build_spell_curve_sort_key,
1339
+ )
1340
+ ),
1341
+ ),
1342
+ lands=(
1343
+ tuple(
1344
+ BuildLand(
1345
+ name=land.card.name,
1346
+ quantity=1,
1347
+ source_colors=land.source_colors,
1348
+ card=_card_view(card=land.card),
1349
+ )
1350
+ for land in mana_base.nonbasic_lands
1351
+ )
1352
+ + tuple(
1353
+ BuildLand(
1354
+ name=land.name,
1355
+ quantity=land.count,
1356
+ source_colors=(land.color,),
1357
+ )
1358
+ for land in mana_base.basic_lands
1359
+ )
1360
+ ),
1361
+ bench=_build_cards(cards=spell_selection.bench),
1362
+ deck_size=mana_base.total_cards,
1363
+ average_mana_value=average_mana_value,
1364
+ pair_override=selection.forced_pair,
1365
+ spell_count=spell_selection.counts.total,
1366
+ land_count=mana_base.total_cards - spell_selection.counts.total,
1367
+ creature_count=spell_selection.counts.creatures,
1368
+ instant_count=spell_selection.counts.instants,
1369
+ warnings=(
1370
+ tuple(
1371
+ f"Applied spell-selection relaxation: {relaxation}"
1372
+ for relaxation in spell_selection.applied_relaxations
1373
+ )
1374
+ + mana_base.caveats
1375
+ ),
1376
+ domain_pool=pool,
1377
+ domain_selection=selection,
1378
+ domain_spell_selection=spell_selection,
1379
+ domain_mana_base=mana_base,
1380
+ )
1381
+
1382
+ def _request_backtest(self, *, command: RequestBacktest) -> None:
1383
+ with self._state_lock:
1384
+ self._last_backtest_request = command
1385
+ self._backtest_request_generation += 1
1386
+ generation = self._backtest_request_generation
1387
+ account = self.snapshot.active_account
1388
+ draft = self.snapshot.draft
1389
+ ranking_mode = self._ranking_mode
1390
+ splash_enabled = self._splash_enabled
1391
+ self._publish(
1392
+ snapshot=replace(
1393
+ self.snapshot,
1394
+ progress=ProgressState(
1395
+ operation=OperationKind.BACKTEST,
1396
+ message="Running backtest",
1397
+ ),
1398
+ errors=self._without_operation_error(
1399
+ operation=OperationKind.BACKTEST,
1400
+ ),
1401
+ )
1402
+ )
1403
+ try:
1404
+ backtest = self._backtest_result(command=command)
1405
+ except Exception as error:
1406
+ session_error = SessionError(
1407
+ error_id="backtest",
1408
+ code="backtest_failed",
1409
+ message=f"Backtest failed: {error}",
1410
+ recoverable=True,
1411
+ operation=OperationKind.BACKTEST,
1412
+ )
1413
+ with self._state_lock:
1414
+ if not self._backtest_request_is_current(
1415
+ generation=generation,
1416
+ account=account,
1417
+ draft=draft,
1418
+ ranking_mode=ranking_mode,
1419
+ splash_enabled=splash_enabled,
1420
+ ):
1421
+ return
1422
+ self._publish(
1423
+ snapshot=replace(
1424
+ self.snapshot,
1425
+ progress=self._progress_after_operation(
1426
+ operation=OperationKind.BACKTEST,
1427
+ ),
1428
+ errors=self._with_error(error=session_error),
1429
+ backtest=None,
1430
+ )
1431
+ )
1432
+ return
1433
+
1434
+ with self._state_lock:
1435
+ if not self._backtest_request_is_current(
1436
+ generation=generation,
1437
+ account=account,
1438
+ draft=draft,
1439
+ ranking_mode=ranking_mode,
1440
+ splash_enabled=splash_enabled,
1441
+ ):
1442
+ return
1443
+ self._publish(
1444
+ snapshot=replace(
1445
+ self.snapshot,
1446
+ progress=self._progress_after_operation(
1447
+ operation=OperationKind.BACKTEST,
1448
+ ),
1449
+ errors=self._without_operation_error(
1450
+ operation=OperationKind.BACKTEST,
1451
+ ),
1452
+ backtest=backtest,
1453
+ )
1454
+ )
1455
+
1456
+ def _backtest_request_is_current(
1457
+ self,
1458
+ *,
1459
+ generation: int,
1460
+ account: AccountIdentity | None,
1461
+ draft: DraftIdentity | None,
1462
+ ranking_mode: RankingMode,
1463
+ splash_enabled: bool,
1464
+ ) -> bool:
1465
+ return (
1466
+ generation == self._backtest_request_generation
1467
+ and account == self.snapshot.active_account
1468
+ and draft == self.snapshot.draft
1469
+ and ranking_mode == self._ranking_mode
1470
+ and splash_enabled == self._splash_enabled
1471
+ )
1472
+
1473
+ def _progress_after_operation(
1474
+ self,
1475
+ *,
1476
+ operation: OperationKind,
1477
+ ) -> ProgressState | None:
1478
+ progress = self.snapshot.progress
1479
+ if progress is not None and progress.operation != operation:
1480
+ return progress
1481
+
1482
+ return None
1483
+
1484
+ def _retire_derived_operations(self) -> tuple[SessionError, ...]:
1485
+ self._build_request_generation += 1
1486
+ self._backtest_request_generation += 1
1487
+ self._last_build_request = None
1488
+ self._last_backtest_request = None
1489
+ return tuple(
1490
+ error
1491
+ for error in self.snapshot.errors
1492
+ if error.operation not in {OperationKind.BUILD, OperationKind.BACKTEST}
1493
+ )
1494
+
1495
+ def _backtest_result(self, *, command: RequestBacktest) -> BacktestResult:
1496
+ if self._card_database is None:
1497
+ raise ValueError("Card metadata is not ready.")
1498
+
1499
+ account_id, draft_id = self._backtest_identity(command=command)
1500
+ state = load_persisted_backtest_state(
1501
+ app_dir=self.store.app_dir,
1502
+ account_id=account_id,
1503
+ draft_id=draft_id,
1504
+ )
1505
+ report = generate_backtest_report(
1506
+ state=state,
1507
+ card_database=self._card_database,
1508
+ ratings_data=self._ratings_data_for_scoring(set_code=state.set_code),
1509
+ ranking_mode=self._ranking_mode,
1510
+ splash_enabled=self._splash_enabled,
1511
+ )
1512
+ return _backtest_result(report=report)
1513
+
1514
+ def _backtest_identity(
1515
+ self,
1516
+ *,
1517
+ command: RequestBacktest,
1518
+ ) -> tuple[str | None, str | None]:
1519
+ active_draft = self.snapshot.draft
1520
+ active_account = self.snapshot.active_account
1521
+ account_id = command.account_id
1522
+ if account_id is None and active_account is not None:
1523
+ account_id = active_account.account_id
1524
+
1525
+ draft_id = command.draft_id
1526
+ if (
1527
+ draft_id is None
1528
+ and active_draft is not None
1529
+ and active_draft.account_id == account_id
1530
+ ):
1531
+ draft_id = active_draft.draft_id
1532
+
1533
+ return account_id, draft_id
1534
+
1535
+ def load_card_data(self) -> LiveSessionSnapshot:
1536
+ """Load configured card metadata and publish readiness or failure state.
1537
+ Frontends call this synchronous operation from their chosen worker context.
1538
+ """
1539
+
1540
+ if self._card_database is not None:
1541
+ return self.snapshot
1542
+ if self._card_database_loader is None:
1543
+ raise ValueError("No card metadata loader is configured.")
1544
+
1545
+ self._publish(
1546
+ snapshot=replace(
1547
+ self.snapshot,
1548
+ card_data=replace(
1549
+ self.snapshot.card_data,
1550
+ phase=DataLoadPhase.LOADING,
1551
+ message="Loading card metadata.",
1552
+ ),
1553
+ progress=ProgressState(
1554
+ operation=OperationKind.CARD_DATA,
1555
+ message="Loading card metadata",
1556
+ ),
1557
+ errors=self._without_operation_error(
1558
+ operation=OperationKind.CARD_DATA,
1559
+ ),
1560
+ )
1561
+ )
1562
+ try:
1563
+ database = self._card_database_loader()
1564
+ except Exception as error:
1565
+ self._finish_card_data_load(database=None, error_message=str(error))
1566
+ else:
1567
+ self._finish_card_data_load(database=database, error_message=None)
1568
+
1569
+ return self.snapshot
1570
+
1571
+ def stop(self) -> LiveSessionSnapshot:
1572
+ """Publish the terminal stopped state without owning process shutdown.
1573
+ Frontends remain responsible for timers, workers, and event-loop teardown.
1574
+ """
1575
+
1576
+ with self._state_lock:
1577
+ self._card_image_generation += 1
1578
+ self._card_image_request = None
1579
+ self._retire_recommendation_images()
1580
+ self._retire_recent_pick_images()
1581
+ card_image = self.snapshot.card_image
1582
+ self._publish(
1583
+ snapshot=replace(
1584
+ self.snapshot,
1585
+ status=ApplicationStatus(
1586
+ phase=ApplicationPhase.STOPPED,
1587
+ message="Draft Omen stopped.",
1588
+ ),
1589
+ card_image=(
1590
+ CardImageState(
1591
+ grp_id=card_image.grp_id,
1592
+ message="Card image loading stopped.",
1593
+ )
1594
+ if card_image.phase == DataLoadPhase.LOADING
1595
+ else card_image
1596
+ ),
1597
+ )
1598
+ )
1599
+ return self.snapshot
1600
+
1601
+ def _initial_ratings_state(self) -> RatingsState:
1602
+ if self._ratings_configured():
1603
+ return RatingsState(
1604
+ phase=DataLoadPhase.IDLE,
1605
+ message="17Lands ratings are ready to load.",
1606
+ )
1607
+
1608
+ return RatingsState()
1609
+
1610
+ def _ratings_configured(self) -> bool:
1611
+ return any(
1612
+ loader is not None
1613
+ for loader in (
1614
+ self._ratings_loader,
1615
+ self._ratings_loader_factory,
1616
+ self._ratings_progress_loader,
1617
+ self._ratings_progress_loader_factory,
1618
+ )
1619
+ )
1620
+
1621
+ def _configure_ratings_loader_for_card_database(self) -> None:
1622
+ database = self._card_database
1623
+ if database is None:
1624
+ return
1625
+
1626
+ if self._ratings_loader_factory is not None:
1627
+ self._ratings_loader = self._ratings_loader_factory(database)
1628
+ if self._ratings_progress_loader_factory is not None:
1629
+ self._ratings_progress_loader = self._ratings_progress_loader_factory(
1630
+ database
1631
+ )
1632
+
1633
+ def _finish_card_data_load(
1634
+ self,
1635
+ *,
1636
+ database: CardDatabase | None,
1637
+ error_message: str | None,
1638
+ ) -> None:
1639
+ if database is None:
1640
+ detail = error_message or "no card metadata was returned"
1641
+ session_error = SessionError(
1642
+ error_id="card-data",
1643
+ code="card_data_unavailable",
1644
+ message=f"Card metadata failed to load: {detail}.",
1645
+ recoverable=True,
1646
+ operation=OperationKind.CARD_DATA,
1647
+ )
1648
+ self._publish(
1649
+ snapshot=replace(
1650
+ self.snapshot,
1651
+ card_data=replace(
1652
+ self.snapshot.card_data,
1653
+ phase=DataLoadPhase.FAILED,
1654
+ message=session_error.message,
1655
+ ),
1656
+ progress=None,
1657
+ errors=self._with_error(error=session_error),
1658
+ )
1659
+ )
1660
+ return
1661
+
1662
+ self._card_database = database
1663
+ self._configure_ratings_loader_for_card_database()
1664
+ self._publish(
1665
+ snapshot=replace(
1666
+ self.snapshot,
1667
+ card_data=CardDataState(
1668
+ phase=DataLoadPhase.READY,
1669
+ message="Card metadata is ready.",
1670
+ last_successful_update=_last_successful_update(
1671
+ database=database
1672
+ ),
1673
+ ),
1674
+ progress=None,
1675
+ errors=self._without_error_id(error_id="card-data"),
1676
+ pool=self._pool_state_from_active_draft(),
1677
+ )
1678
+ )
1679
+ set_code = self._active_set_code()
1680
+ if set_code is not None:
1681
+ self._ensure_ratings_loaded(set_code=set_code)
1682
+ self._score_current_pack()
1683
+
1684
+ def _ensure_ratings_loaded(self, *, set_code: str) -> None:
1685
+ normalized_set_code = set_code.upper()
1686
+ existing = self._ratings_state_by_set.get(normalized_set_code)
1687
+ waiting_for_card_data = (
1688
+ existing is not None
1689
+ and existing.phase == DataLoadPhase.IDLE
1690
+ and self._card_database is None
1691
+ )
1692
+ if existing is not None and (
1693
+ existing.phase != DataLoadPhase.IDLE or waiting_for_card_data
1694
+ ):
1695
+ self._publish_active_ratings_state(state=existing)
1696
+ return
1697
+
1698
+ if not self._ratings_configured():
1699
+ state = RatingsState(
1700
+ set_code=normalized_set_code,
1701
+ phase=DataLoadPhase.UNAVAILABLE,
1702
+ message=(
1703
+ f"17Lands ratings are unavailable for {normalized_set_code}; "
1704
+ "neutral-prior scores are active."
1705
+ ),
1706
+ )
1707
+ self._ratings_state_by_set[normalized_set_code] = state
1708
+ self._publish_active_ratings_state(state=state)
1709
+ return
1710
+
1711
+ if (
1712
+ self._card_database is None
1713
+ and (
1714
+ self._ratings_loader_factory is not None
1715
+ or self._ratings_progress_loader_factory is not None
1716
+ )
1717
+ ):
1718
+ state = RatingsState(
1719
+ set_code=normalized_set_code,
1720
+ phase=DataLoadPhase.IDLE,
1721
+ message=(
1722
+ f"17Lands ratings for {normalized_set_code} are waiting for "
1723
+ "card metadata."
1724
+ ),
1725
+ )
1726
+ self._ratings_state_by_set[normalized_set_code] = state
1727
+ self._publish_active_ratings_state(state=state)
1728
+ return
1729
+
1730
+ if self._ratings_cache_checker is not None:
1731
+ try:
1732
+ cached = self._ratings_cache_checker(normalized_set_code)
1733
+ except Exception as error:
1734
+ self._finish_ratings_load(
1735
+ set_code=normalized_set_code,
1736
+ ratings_data=None,
1737
+ error_message=str(error),
1738
+ )
1739
+ return
1740
+
1741
+ if not cached:
1742
+ state = RatingsState(
1743
+ set_code=normalized_set_code,
1744
+ phase=DataLoadPhase.MISSING,
1745
+ message=(
1746
+ f"No local 17Lands data for {normalized_set_code}; "
1747
+ "neutral-prior scores are active."
1748
+ ),
1749
+ )
1750
+ self._ratings_data_by_set[normalized_set_code] = None
1751
+ self._ratings_state_by_set[normalized_set_code] = state
1752
+ self._publish_active_ratings_state(state=state)
1753
+ return
1754
+
1755
+ self._load_ratings(set_code=normalized_set_code, refresh=False)
1756
+
1757
+ def _request_ratings_download(self, *, set_code: str) -> None:
1758
+ normalized_set_code = set_code.upper()
1759
+ active_set_code = self._active_set_code()
1760
+ if active_set_code is None:
1761
+ raise ValueError("Ratings downloads require an active draft set.")
1762
+ if normalized_set_code != active_set_code:
1763
+ raise ValueError(
1764
+ f"Ratings download set {normalized_set_code!r} does not match "
1765
+ f"active set {active_set_code!r}."
1766
+ )
1767
+
1768
+ self._load_ratings(set_code=normalized_set_code, refresh=True)
1769
+
1770
+ def _load_ratings(self, *, set_code: str, refresh: bool) -> None:
1771
+ if self._ratings_loader is None and self._ratings_progress_loader is None:
1772
+ previous_state = self._ratings_state_by_set.get(set_code)
1773
+ state = RatingsState(
1774
+ set_code=set_code,
1775
+ phase=DataLoadPhase.UNAVAILABLE,
1776
+ message=f"No 17Lands ratings loader is available for {set_code}.",
1777
+ last_successful_update=(
1778
+ None
1779
+ if previous_state is None
1780
+ else previous_state.last_successful_update
1781
+ ),
1782
+ )
1783
+ self._ratings_state_by_set[set_code] = state
1784
+ self._publish_active_ratings_state(state=state)
1785
+ return
1786
+
1787
+ if not self._begin_ratings_load(set_code=set_code):
1788
+ return
1789
+
1790
+ ratings_data = None
1791
+ error_message = None
1792
+ try:
1793
+ if self._ratings_progress_loader is not None:
1794
+ ratings_data = self._ratings_progress_loader(
1795
+ set_code,
1796
+ lambda progress: self._update_ratings_progress(
1797
+ set_code=set_code,
1798
+ progress=progress,
1799
+ ),
1800
+ refresh=refresh,
1801
+ )
1802
+ elif self._ratings_loader is not None:
1803
+ ratings_data = self._ratings_loader(set_code)
1804
+ except Exception as error:
1805
+ error_message = str(error)
1806
+
1807
+ self._finish_ratings_load(
1808
+ set_code=set_code,
1809
+ ratings_data=ratings_data,
1810
+ error_message=error_message,
1811
+ )
1812
+
1813
+ def _begin_ratings_load(self, *, set_code: str) -> bool:
1814
+ with self._state_lock:
1815
+ if set_code in self._loading_rating_sets:
1816
+ return False
1817
+
1818
+ previous_state = self._ratings_state_by_set.get(set_code)
1819
+ loading_state = RatingsState(
1820
+ set_code=set_code,
1821
+ phase=DataLoadPhase.LOADING,
1822
+ message=f"Checking 17Lands data for {set_code}.",
1823
+ last_successful_update=(
1824
+ None
1825
+ if previous_state is None
1826
+ else previous_state.last_successful_update
1827
+ ),
1828
+ )
1829
+ loading_progress = ProgressState(
1830
+ operation=OperationKind.RATINGS,
1831
+ message=f"Checking 17Lands data for {set_code}",
1832
+ completed=0,
1833
+ )
1834
+
1835
+ self._loading_rating_sets.add(set_code)
1836
+ self._ratings_state_by_set[set_code] = loading_state
1837
+ self._ratings_progress_by_set[set_code] = loading_progress
1838
+ self._ratings_errors_by_set.pop(set_code, None)
1839
+ if self._active_set_code_value == set_code:
1840
+ self._publish(
1841
+ snapshot=replace(
1842
+ self.snapshot,
1843
+ ratings=loading_state,
1844
+ progress=loading_progress,
1845
+ errors=self._without_operation_error(
1846
+ operation=OperationKind.RATINGS,
1847
+ ),
1848
+ )
1849
+ )
1850
+ self._score_current_pack()
1851
+
1852
+ return True
1853
+
1854
+ def _update_ratings_progress(
1855
+ self,
1856
+ *,
1857
+ set_code: str,
1858
+ progress: SeventeenLandsDownloadProgress,
1859
+ ) -> None:
1860
+ with self._state_lock:
1861
+ state = self._ratings_state_by_set.get(set_code)
1862
+ if state is None or state.phase != DataLoadPhase.LOADING:
1863
+ return
1864
+
1865
+ next_state = replace(state, message=progress.message)
1866
+ next_progress = ProgressState(
1867
+ operation=OperationKind.RATINGS,
1868
+ message=progress.message,
1869
+ completed=progress.completed_requests,
1870
+ total=progress.total_requests,
1871
+ )
1872
+ self._ratings_state_by_set[set_code] = next_state
1873
+ self._ratings_progress_by_set[set_code] = next_progress
1874
+ if self._active_set_code_value != set_code:
1875
+ return
1876
+
1877
+ self._publish(
1878
+ snapshot=replace(
1879
+ self.snapshot,
1880
+ ratings=next_state,
1881
+ progress=next_progress,
1882
+ )
1883
+ )
1884
+
1885
+ def _finish_ratings_load(
1886
+ self,
1887
+ *,
1888
+ set_code: str,
1889
+ ratings_data: SeventeenLandsData | None,
1890
+ error_message: str | None,
1891
+ ) -> None:
1892
+ with self._state_lock:
1893
+ self._finish_ratings_load_locked(
1894
+ set_code=set_code,
1895
+ ratings_data=ratings_data,
1896
+ error_message=error_message,
1897
+ )
1898
+
1899
+ def _finish_ratings_load_locked(
1900
+ self,
1901
+ *,
1902
+ set_code: str,
1903
+ ratings_data: SeventeenLandsData | None,
1904
+ error_message: str | None,
1905
+ ) -> None:
1906
+ self._loading_rating_sets.discard(set_code)
1907
+ previous_state = self._ratings_state_by_set.get(set_code)
1908
+ if ratings_data is None:
1909
+ detail = error_message or "no ratings were returned"
1910
+ session_error = SessionError(
1911
+ error_id=self._ratings_error_id(set_code=set_code),
1912
+ code="ratings_unavailable",
1913
+ message=f"17Lands ratings failed for {set_code}: {detail}.",
1914
+ recoverable=True,
1915
+ operation=OperationKind.RATINGS,
1916
+ )
1917
+ state = RatingsState(
1918
+ set_code=set_code,
1919
+ phase=DataLoadPhase.FAILED,
1920
+ message=(
1921
+ f"{session_error.message} Neutral-prior scores remain active."
1922
+ ),
1923
+ last_successful_update=(
1924
+ None
1925
+ if previous_state is None
1926
+ else previous_state.last_successful_update
1927
+ ),
1928
+ )
1929
+ self._ratings_data_by_set[set_code] = None
1930
+ self._ratings_state_by_set[set_code] = state
1931
+ self._ratings_progress_by_set.pop(set_code, None)
1932
+ self._ratings_errors_by_set[set_code] = session_error
1933
+ if self._active_set_code_value != set_code:
1934
+ return
1935
+
1936
+ failed_snapshot = replace(
1937
+ self.snapshot,
1938
+ ratings=state,
1939
+ progress=None,
1940
+ errors=self._with_error(error=session_error),
1941
+ )
1942
+ if not self._score_current_pack_locked(snapshot=failed_snapshot):
1943
+ self._publish(snapshot=failed_snapshot)
1944
+ return
1945
+
1946
+ self._ratings_data_by_set[set_code] = ratings_data
1947
+ state = RatingsState(
1948
+ set_code=set_code,
1949
+ phase=DataLoadPhase.READY,
1950
+ message=f"17Lands ratings are ready for {set_code}.",
1951
+ last_successful_update=_ratings_last_successful_update(
1952
+ ratings_data=ratings_data
1953
+ ),
1954
+ )
1955
+ self._ratings_state_by_set[set_code] = state
1956
+ self._ratings_progress_by_set.pop(set_code, None)
1957
+ self._ratings_errors_by_set.pop(set_code, None)
1958
+ if self._active_set_code_value != set_code:
1959
+ return
1960
+
1961
+ ready_snapshot = replace(
1962
+ self.snapshot,
1963
+ ratings=state,
1964
+ progress=None,
1965
+ errors=self._without_error_id(
1966
+ error_id=self._ratings_error_id(set_code=set_code),
1967
+ ),
1968
+ )
1969
+ if not self._score_current_pack_locked(snapshot=ready_snapshot):
1970
+ self._publish(snapshot=ready_snapshot)
1971
+
1972
+ def _publish_active_ratings_state(self, *, state: RatingsState) -> None:
1973
+ with self._state_lock:
1974
+ set_code = state.set_code
1975
+ if set_code != self._active_set_code_value:
1976
+ return
1977
+ if (
1978
+ set_code is not None
1979
+ and self._ratings_state_by_set.get(set_code) != state
1980
+ ):
1981
+ return
1982
+
1983
+ progress = (
1984
+ None
1985
+ if set_code is None
1986
+ else self._ratings_progress_by_set.get(set_code)
1987
+ )
1988
+ errors = self._without_operation_error(
1989
+ operation=OperationKind.RATINGS
1990
+ )
1991
+ ratings_error = (
1992
+ None
1993
+ if set_code is None
1994
+ else self._ratings_errors_by_set.get(set_code)
1995
+ )
1996
+ if ratings_error is not None:
1997
+ errors += (ratings_error,)
1998
+ self._publish(
1999
+ snapshot=replace(
2000
+ self.snapshot,
2001
+ ratings=state,
2002
+ progress=progress,
2003
+ errors=errors,
2004
+ )
2005
+ )
2006
+
2007
+ def _card_image_focuses_current_build(
2008
+ self,
2009
+ *,
2010
+ card_image: CardImageState,
2011
+ build: BuildResult | None,
2012
+ ) -> bool:
2013
+ """Return whether the shared image still belongs to the published build."""
2014
+
2015
+ grp_id = card_image.grp_id
2016
+ if grp_id is None or build is None:
2017
+ return False
2018
+ return any(entry.card.grp_id == grp_id for entry in build.spells) or any(
2019
+ entry.card.grp_id == grp_id for entry in build.bench
2020
+ )
2021
+
2022
+ def _score_current_pack(self) -> None:
2023
+ with self._state_lock:
2024
+ self._score_current_pack_locked()
2025
+
2026
+ def _score_current_pack_locked(
2027
+ self,
2028
+ *,
2029
+ snapshot: LiveSessionSnapshot | None = None,
2030
+ ) -> bool:
2031
+ event = self._current_pack_event
2032
+ database = self._card_database
2033
+ if (
2034
+ event is None
2035
+ or database is None
2036
+ or event.set_code.upper() != self._active_set_code_value
2037
+ ):
2038
+ return False
2039
+
2040
+ ratings_data = self._ratings_data_for_scoring(set_code=event.set_code)
2041
+ engine = PickEngine(
2042
+ ratings_data=ratings_data,
2043
+ splash_enabled=self._splash_enabled,
2044
+ )
2045
+ scored_pack = engine.score_pack(
2046
+ offered_grp_ids=event.offered_grp_ids,
2047
+ card_database=database,
2048
+ pool_grp_ids=event.pool_grp_ids,
2049
+ pick_index=_draft_pick_index(event=event),
2050
+ )
2051
+ self._current_scored_pack = scored_pack
2052
+ recommendations = self._recommendation_state(scored_pack=scored_pack)
2053
+ ratings = self._ratings_state_after_scoring(
2054
+ set_code=event.set_code,
2055
+ recommendations=recommendations,
2056
+ )
2057
+ state = self._active_draft_state()
2058
+ if state is not None:
2059
+ self.audit_store.record_decision(
2060
+ state=state,
2061
+ event=event,
2062
+ scored_pack=scored_pack,
2063
+ config=engine.config,
2064
+ ratings_data=ratings_data,
2065
+ )
2066
+ previous_snapshot = self.snapshot if snapshot is None else snapshot
2067
+ build_image_focus = self._card_image_focuses_current_build(
2068
+ card_image=previous_snapshot.card_image,
2069
+ build=previous_snapshot.build,
2070
+ )
2071
+ card_image = (
2072
+ previous_snapshot.card_image
2073
+ if build_image_focus
2074
+ or previous_snapshot.card_image.grp_id == recommendations.selected_grp_id
2075
+ else self._retire_card_image()
2076
+ )
2077
+ self._prepare_recommendation_image_requests(
2078
+ pack=event,
2079
+ recommendations=recommendations,
2080
+ )
2081
+ self._publish(
2082
+ snapshot=replace(
2083
+ previous_snapshot,
2084
+ ratings=ratings,
2085
+ recommendations=recommendations,
2086
+ pool=self._pool_state(
2087
+ pool_grp_ids=event.pool_grp_ids,
2088
+ scored_pack=scored_pack,
2089
+ ),
2090
+ card_image=card_image,
2091
+ )
2092
+ )
2093
+ if not build_image_focus:
2094
+ self._start_focused_card_image_load(
2095
+ grp_id=recommendations.selected_grp_id,
2096
+ )
2097
+ return True
2098
+
2099
+ def _recommendation_state(self, *, scored_pack: ScoredPack) -> RecommendationState:
2100
+ ranked_cards = rank_scored_cards(
2101
+ cards=scored_pack.cards,
2102
+ ranking_mode=self._ranking_mode,
2103
+ )
2104
+ selected_grp_id = self.snapshot.recommendations.selected_grp_id
2105
+ available_grp_ids = {card.card.grp_id for card in ranked_cards}
2106
+ if selected_grp_id not in available_grp_ids:
2107
+ selected_grp_id = ranked_cards[0].card.grp_id if ranked_cards else None
2108
+ return RecommendationState(
2109
+ ranking_mode=self._ranking_mode,
2110
+ splash_enabled=self._splash_enabled,
2111
+ cards=tuple(
2112
+ self._recommendation(
2113
+ rank=rank,
2114
+ scored_card=scored_card,
2115
+ inferred_pair=scored_pack.commitment.inferred_pair,
2116
+ )
2117
+ for rank, scored_card in enumerate(ranked_cards, start=1)
2118
+ ),
2119
+ selected_grp_id=selected_grp_id,
2120
+ source_summary=scored_pack.source_summary,
2121
+ confidence_summary=recommendation_confidence_summary(
2122
+ cards=ranked_cards,
2123
+ ranking_mode=self._ranking_mode,
2124
+ phase=scored_pack.commitment.phase,
2125
+ ),
2126
+ )
2127
+
2128
+ def _recommendation(
2129
+ self,
2130
+ *,
2131
+ rank: int,
2132
+ scored_card: ScoredCard,
2133
+ inferred_pair: str | None = None,
2134
+ ) -> Recommendation:
2135
+ return Recommendation(
2136
+ rank=rank,
2137
+ card=self._card_view(card=scored_card.card),
2138
+ score=scored_card.score,
2139
+ win_rate=scored_card.rating.gih_win_rate,
2140
+ average_last_seen_at=scored_card.rating.average_last_seen_at,
2141
+ source_label=scored_card.source_label,
2142
+ color_fit=scored_card.color_fit,
2143
+ no_data=scored_card.no_data,
2144
+ letter_grade=scored_card.rating.letter_grade,
2145
+ explanation=recommendation_explanation(
2146
+ scored_card=scored_card,
2147
+ inferred_pair=inferred_pair,
2148
+ ),
2149
+ )
2150
+
2151
+ def _card_image_uri(self, *, card: CardInfo) -> str | None:
2152
+ """Resolve a card image URI without doing metadata network work."""
2153
+
2154
+ remembered_uri = self._card_image_uris_by_grp_id.get(card.grp_id)
2155
+ if remembered_uri is not None:
2156
+ return remembered_uri
2157
+ card_image_service = self._card_image_service
2158
+ database = self._card_database
2159
+ if card_image_service is None or database is None:
2160
+ return None
2161
+ return card_image_service.resolve_image_uri(
2162
+ card=card,
2163
+ card_database=database,
2164
+ )
2165
+
2166
+ def _card_view(self, *, card: CardInfo) -> CardView:
2167
+ """Project a card with any image path already resolved by this session.
2168
+ Cache lookups consult local memory and the filesystem but never network.
2169
+ """
2170
+
2171
+ image_uri = self._card_image_uri(card=card)
2172
+ image_path = (
2173
+ self._card_image_paths_by_grp_id.get(card.grp_id)
2174
+ if image_uri is None
2175
+ else self._cached_card_image_path(image_uri=image_uri)
2176
+ )
2177
+ return _card_view(
2178
+ card=card,
2179
+ image_path=None if image_path is None else str(image_path),
2180
+ )
2181
+
2182
+ def _recommendation_image_pack_key(
2183
+ self,
2184
+ *,
2185
+ pack: PackOfferedEvent | None,
2186
+ snapshot: LiveSessionSnapshot | None = None,
2187
+ ) -> tuple[PackOfferedEvent | None, str | None, str | None]:
2188
+ current_snapshot = self.snapshot if snapshot is None else snapshot
2189
+ active_account = current_snapshot.active_account
2190
+ draft = current_snapshot.draft
2191
+ return (
2192
+ pack,
2193
+ None if active_account is None else active_account.account_id,
2194
+ None if draft is None else draft.draft_id,
2195
+ )
2196
+
2197
+ def _retire_recommendation_images(self) -> None:
2198
+ """Invalidate all current-pack recommendation image work."""
2199
+
2200
+ self._recommendation_image_generation += 1
2201
+ self._recommendation_image_pack = None
2202
+ self._recommendation_image_requests.clear()
2203
+ self._recommendation_image_failures.clear()
2204
+
2205
+ def _cached_card_image_path(self, *, image_uri: str) -> Path | None:
2206
+ """Return a known or on-disk cached image without network access."""
2207
+
2208
+ cached_path = self._card_image_paths_by_uri.get(image_uri)
2209
+ if cached_path is not None:
2210
+ return cached_path
2211
+
2212
+ card_image_service = self._card_image_service
2213
+ if card_image_service is None:
2214
+ return None
2215
+ cached_path_method = getattr(card_image_service, "cached_path", None)
2216
+ if not callable(cached_path_method):
2217
+ return None
2218
+ cached_path = cached_path_method(image_uri=image_uri)
2219
+ if not isinstance(cached_path, Path):
2220
+ cached_path = Path(cached_path)
2221
+ if not cached_path.is_file():
2222
+ return None
2223
+ self._card_image_paths_by_uri[image_uri] = cached_path
2224
+ return cached_path
2225
+
2226
+ def _prepare_recommendation_image_requests(
2227
+ self,
2228
+ *,
2229
+ pack: PackOfferedEvent,
2230
+ recommendations: RecommendationState,
2231
+ ) -> None:
2232
+ """Queue uncached recommendation thumbnails for one current pack."""
2233
+
2234
+ pack_key = self._recommendation_image_pack_key(pack=pack)
2235
+ if pack_key != self._recommendation_image_pack:
2236
+ self._recommendation_image_generation += 1
2237
+ self._recommendation_image_pack = pack_key
2238
+ self._recommendation_image_requests.clear()
2239
+ self._recommendation_image_failures.clear()
2240
+
2241
+ database = self._card_database
2242
+ if self._card_image_service is None or database is None:
2243
+ return
2244
+
2245
+ queued = {
2246
+ (request.grp_id, request.image_uri)
2247
+ for request in self._recommendation_image_requests
2248
+ }
2249
+ for recommendation in recommendations.cards:
2250
+ if recommendation.card.image_path is not None:
2251
+ continue
2252
+ card = database.lookup(grp_id=recommendation.card.grp_id)
2253
+ image_uri = self._card_image_uri(card=card)
2254
+ if image_uri is None and card.unknown:
2255
+ continue
2256
+ if image_uri is not None and self._cached_card_image_path(
2257
+ image_uri=image_uri
2258
+ ) is not None:
2259
+ continue
2260
+ request_key = (recommendation.card.grp_id, image_uri)
2261
+ if (
2262
+ request_key in self._recommendation_image_failures
2263
+ or request_key in queued
2264
+ ):
2265
+ continue
2266
+ self._recommendation_image_requests.append(
2267
+ CardImageRequest(
2268
+ generation=self._recommendation_image_generation,
2269
+ grp_id=recommendation.card.grp_id,
2270
+ image_uri=image_uri,
2271
+ )
2272
+ )
2273
+ queued.add(request_key)
2274
+
2275
+ def _retire_recent_pick_images(self) -> None:
2276
+ """Invalidate all gallery work independently from focused-card work."""
2277
+
2278
+ self._recent_pick_image_generation += 1
2279
+ self._recent_pick_image_requests.clear()
2280
+ self._recent_pick_image_states.clear()
2281
+ self._recent_pick_pool_grp_ids = None
2282
+
2283
+ def _prepare_recent_pick_images(
2284
+ self,
2285
+ *,
2286
+ pool_grp_ids: tuple[int, ...],
2287
+ ) -> tuple[RecentPick, ...]:
2288
+ database = self._card_database
2289
+ if database is None:
2290
+ return ()
2291
+
2292
+ recent_grp_ids = tuple(reversed(pool_grp_ids[-RECENT_PICK_LIMIT:]))
2293
+ if recent_grp_ids != self._recent_pick_pool_grp_ids:
2294
+ previous_states = self._recent_pick_image_states
2295
+ self._recent_pick_image_generation += 1
2296
+ self._recent_pick_image_requests.clear()
2297
+ active_grp_ids = set(recent_grp_ids)
2298
+ self._recent_pick_image_states = {
2299
+ grp_id: state
2300
+ for grp_id, state in previous_states.items()
2301
+ if grp_id in active_grp_ids
2302
+ }
2303
+ self._recent_pick_pool_grp_ids = recent_grp_ids
2304
+
2305
+ recent_picks: list[RecentPick] = []
2306
+ queued_grp_ids = {
2307
+ request.grp_id for request in self._recent_pick_image_requests
2308
+ }
2309
+ for grp_id in dict.fromkeys(recent_grp_ids):
2310
+ card = database.lookup(grp_id=grp_id)
2311
+ image_uri = self._card_image_uri(card=card)
2312
+ cached_path = (
2313
+ None
2314
+ if image_uri is None
2315
+ else self._cached_card_image_path(image_uri=image_uri)
2316
+ )
2317
+ card_view = _card_view(
2318
+ card=card,
2319
+ image_path=None if cached_path is None else str(cached_path),
2320
+ )
2321
+ image = self._recent_pick_image_states.get(grp_id)
2322
+ if image is None:
2323
+ if cached_path is not None:
2324
+ image = CardImageState(
2325
+ grp_id=grp_id,
2326
+ image_path=str(cached_path),
2327
+ phase=DataLoadPhase.READY,
2328
+ message="Card image ready.",
2329
+ )
2330
+ elif image_uri is None:
2331
+ image = CardImageState(
2332
+ grp_id=grp_id,
2333
+ phase=DataLoadPhase.UNAVAILABLE,
2334
+ message=f"No image is available for {card.name}.",
2335
+ )
2336
+ else:
2337
+ image = CardImageState(
2338
+ grp_id=grp_id,
2339
+ phase=DataLoadPhase.LOADING,
2340
+ message=f"Loading image for {card.name}.",
2341
+ )
2342
+ self._recent_pick_image_states[grp_id] = image
2343
+
2344
+ if (
2345
+ image.phase == DataLoadPhase.LOADING
2346
+ and image_uri is not None
2347
+ and grp_id not in queued_grp_ids
2348
+ ):
2349
+ self._recent_pick_image_requests.append(
2350
+ CardImageRequest(
2351
+ generation=self._recent_pick_image_generation,
2352
+ grp_id=grp_id,
2353
+ image_uri=image_uri,
2354
+ )
2355
+ )
2356
+ queued_grp_ids.add(grp_id)
2357
+
2358
+ recent_picks.append(
2359
+ RecentPick(
2360
+ card=replace(card_view, image_path=image.image_path),
2361
+ image=image,
2362
+ )
2363
+ )
2364
+
2365
+ return tuple(
2366
+ pick
2367
+ for grp_id in recent_grp_ids
2368
+ for pick in recent_picks
2369
+ if pick.card.grp_id == grp_id
2370
+ )
2371
+
2372
+ def _publish_recent_pick_projection(self) -> None:
2373
+ """Publish only gallery entries after an adapter fetch completes."""
2374
+
2375
+ pool = self.snapshot.pool
2376
+ recent_picks = tuple(
2377
+ RecentPick(
2378
+ card=(
2379
+ pick.card
2380
+ if (image := self._recent_pick_image_states.get(pick.card.grp_id))
2381
+ is None
2382
+ or image.image_path is None
2383
+ else replace(pick.card, image_path=image.image_path)
2384
+ ),
2385
+ image=(
2386
+ pick.image
2387
+ if (image := self._recent_pick_image_states.get(pick.card.grp_id))
2388
+ is None
2389
+ else image
2390
+ ),
2391
+ )
2392
+ for pick in pool.recent_picks
2393
+ )
2394
+ self._publish(
2395
+ snapshot=replace(
2396
+ self.snapshot,
2397
+ pool=replace(pool, recent_picks=recent_picks),
2398
+ )
2399
+ )
2400
+
2401
+ def _start_focused_card_image_load(
2402
+ self,
2403
+ *,
2404
+ grp_id: int | None,
2405
+ ) -> None:
2406
+ """Publish one focused-card image request for the adapter worker.
2407
+ Metadata resolution and fetching happen only in that worker.
2408
+ """
2409
+
2410
+ if self.snapshot.status.phase == ApplicationPhase.STOPPED:
2411
+ return
2412
+
2413
+ card_image = self.snapshot.card_image
2414
+ if (
2415
+ card_image.grp_id == grp_id
2416
+ and card_image.phase in {DataLoadPhase.LOADING, DataLoadPhase.READY}
2417
+ ):
2418
+ return
2419
+
2420
+ self._retire_card_image()
2421
+ card_image_service = self._card_image_service
2422
+ database = self._card_database
2423
+ if card_image_service is None or grp_id is None or database is None:
2424
+ self._publish(
2425
+ snapshot=replace(
2426
+ self.snapshot,
2427
+ card_image=CardImageState(
2428
+ grp_id=grp_id,
2429
+ message="No selected card image is available.",
2430
+ ),
2431
+ )
2432
+ )
2433
+ return
2434
+
2435
+ card = database.lookup(grp_id=grp_id)
2436
+ self._card_image_generation += 1
2437
+ generation = self._card_image_generation
2438
+ self._publish(
2439
+ snapshot=replace(
2440
+ self.snapshot,
2441
+ card_image=CardImageState(
2442
+ grp_id=grp_id,
2443
+ phase=DataLoadPhase.LOADING,
2444
+ message=f"Loading image for {card.name}.",
2445
+ ),
2446
+ )
2447
+ )
2448
+ image_uri = self._card_image_uri(card=card)
2449
+ if image_uri is not None:
2450
+ cached_path = self._cached_card_image_path(image_uri=image_uri)
2451
+ if cached_path is not None:
2452
+ with self._state_lock:
2453
+ self._retire_recommendation_requests_for_image(
2454
+ grp_id=grp_id,
2455
+ image_uri=image_uri,
2456
+ )
2457
+ self._publish_selected_card_image(
2458
+ grp_id=grp_id,
2459
+ image_path=cached_path,
2460
+ )
2461
+ return
2462
+
2463
+ self._card_image_request = CardImageRequest(
2464
+ generation=generation,
2465
+ grp_id=grp_id,
2466
+ image_uri=image_uri,
2467
+ )
2468
+
2469
+ def _publish_selected_card_image(self, *, grp_id: int, image_path: Path) -> None:
2470
+ """Publish the resolved focused-card path as a plain snapshot value."""
2471
+
2472
+ recommendations = replace(
2473
+ self.snapshot.recommendations,
2474
+ cards=tuple(
2475
+ replace(
2476
+ recommendation,
2477
+ card=replace(recommendation.card, image_path=str(image_path)),
2478
+ )
2479
+ if recommendation.card.grp_id == grp_id
2480
+ else recommendation
2481
+ for recommendation in self.snapshot.recommendations.cards
2482
+ ),
2483
+ )
2484
+ self._publish(
2485
+ snapshot=replace(
2486
+ self.snapshot,
2487
+ recommendations=recommendations,
2488
+ card_image=CardImageState(
2489
+ grp_id=grp_id,
2490
+ image_path=str(image_path),
2491
+ phase=DataLoadPhase.READY,
2492
+ message="Card image ready.",
2493
+ ),
2494
+ )
2495
+ )
2496
+
2497
+ def _ratings_state_after_scoring(
2498
+ self,
2499
+ *,
2500
+ set_code: str,
2501
+ recommendations: RecommendationState,
2502
+ ) -> RatingsState:
2503
+ normalized_set_code = set_code.upper()
2504
+ state = self._ratings_state_by_set.get(
2505
+ normalized_set_code,
2506
+ RatingsState(set_code=normalized_set_code),
2507
+ )
2508
+ rated_cards = sum(not card.no_data for card in recommendations.cards)
2509
+ next_state = replace(
2510
+ state,
2511
+ rated_cards=rated_cards,
2512
+ total_cards=len(recommendations.cards),
2513
+ )
2514
+ self._ratings_state_by_set[normalized_set_code] = next_state
2515
+ return next_state
2516
+
2517
+ def _ratings_data_for_scoring(self, *, set_code: str) -> SeventeenLandsData | None:
2518
+ ratings_data = self._ratings_data_by_set.get(set_code.upper())
2519
+ if ratings_data is None:
2520
+ return None
2521
+ if self._lazy_pair_card_ratings:
2522
+ return ratings_data
2523
+
2524
+ return replace(ratings_data, pair_card_ratings_loader=None)
2525
+
2526
+ def _change_ranking(self, *, ranking_mode: str) -> None:
2527
+ with self._state_lock:
2528
+ self._ranking_mode = validate_ranking_mode(ranking_mode=ranking_mode)
2529
+ self._backtest_request_generation += 1
2530
+ self._last_backtest_request = None
2531
+ if self._current_scored_pack is None:
2532
+ recommendations = replace(
2533
+ self.snapshot.recommendations,
2534
+ ranking_mode=self._ranking_mode,
2535
+ )
2536
+ else:
2537
+ recommendations = self._recommendation_state(
2538
+ scored_pack=self._current_scored_pack,
2539
+ )
2540
+ self._publish(
2541
+ snapshot=replace(
2542
+ self.snapshot,
2543
+ recommendations=recommendations,
2544
+ progress=self._progress_after_operation(
2545
+ operation=OperationKind.BACKTEST,
2546
+ ),
2547
+ errors=self._without_operation_error(
2548
+ operation=OperationKind.BACKTEST,
2549
+ ),
2550
+ backtest=None,
2551
+ )
2552
+ )
2553
+
2554
+ def _choose_recommendation(self, *, grp_id: int) -> None:
2555
+ available_grp_ids = {
2556
+ recommendation.card.grp_id
2557
+ for recommendation in self.snapshot.recommendations.cards
2558
+ }
2559
+ if grp_id not in available_grp_ids:
2560
+ raise ValueError(f"Card {grp_id} is not in the current recommendations.")
2561
+
2562
+ self._publish(
2563
+ snapshot=replace(
2564
+ self.snapshot,
2565
+ recommendations=replace(
2566
+ self.snapshot.recommendations,
2567
+ selected_grp_id=grp_id,
2568
+ ),
2569
+ card_image=self._retire_card_image(),
2570
+ )
2571
+ )
2572
+ self._start_focused_card_image_load(grp_id=grp_id)
2573
+
2574
+ def _focus_build_card(self, *, grp_id: int) -> None:
2575
+ build = self.snapshot.build
2576
+ available_grp_ids = (
2577
+ set()
2578
+ if build is None
2579
+ else {
2580
+ entry.card.grp_id
2581
+ for entry in (*build.spells, *build.bench)
2582
+ }
2583
+ )
2584
+ if grp_id not in available_grp_ids:
2585
+ raise ValueError(f"Card {grp_id} is not in the current build.")
2586
+ card_image = self.snapshot.card_image
2587
+ if (
2588
+ card_image.grp_id == grp_id
2589
+ and card_image.phase in {DataLoadPhase.LOADING, DataLoadPhase.READY}
2590
+ ):
2591
+ return
2592
+ if card_image.grp_id == grp_id:
2593
+ self._retire_card_image()
2594
+ self._start_focused_card_image_load(grp_id=grp_id)
2595
+
2596
+ def _change_splash_preference(self, *, enabled: bool) -> None:
2597
+ with self._state_lock:
2598
+ if enabled == self._splash_enabled:
2599
+ return
2600
+
2601
+ self._splash_enabled = enabled
2602
+ self._build_request_generation += 1
2603
+ self._backtest_request_generation += 1
2604
+ self._last_build_request = None
2605
+ self._last_backtest_request = None
2606
+ errors = tuple(
2607
+ error
2608
+ for error in self.snapshot.errors
2609
+ if error.operation
2610
+ not in {OperationKind.BUILD, OperationKind.BACKTEST}
2611
+ )
2612
+ progress = self.snapshot.progress
2613
+ if progress is not None and progress.operation in {
2614
+ OperationKind.BUILD,
2615
+ OperationKind.BACKTEST,
2616
+ }:
2617
+ progress = None
2618
+ if self._current_pack_event is None:
2619
+ self._publish(
2620
+ snapshot=replace(
2621
+ self.snapshot,
2622
+ recommendations=replace(
2623
+ self.snapshot.recommendations,
2624
+ splash_enabled=enabled,
2625
+ ),
2626
+ progress=progress,
2627
+ errors=errors,
2628
+ build=None,
2629
+ backtest=None,
2630
+ )
2631
+ )
2632
+ return
2633
+
2634
+ self._publish(
2635
+ snapshot=replace(
2636
+ self.snapshot,
2637
+ progress=progress,
2638
+ errors=errors,
2639
+ build=None,
2640
+ backtest=None,
2641
+ )
2642
+ )
2643
+ self._score_current_pack_locked()
2644
+
2645
+ def _dismiss_error(self, *, error_id: str) -> None:
2646
+ if not any(error.error_id == error_id for error in self.snapshot.errors):
2647
+ raise ValueError(f"Unknown session error {error_id!r}.")
2648
+
2649
+ self._publish(
2650
+ snapshot=replace(
2651
+ self.snapshot,
2652
+ errors=self._without_error_id(error_id=error_id),
2653
+ )
2654
+ )
2655
+
2656
+ def _retry_error(self, *, error_id: str) -> None:
2657
+ error = next(
2658
+ (
2659
+ candidate
2660
+ for candidate in self.snapshot.errors
2661
+ if candidate.error_id == error_id
2662
+ ),
2663
+ None,
2664
+ )
2665
+ if error is None:
2666
+ raise ValueError(f"Unknown session error {error_id!r}.")
2667
+ if not error.recoverable:
2668
+ raise ValueError(f"Session error {error_id!r} is not recoverable.")
2669
+
2670
+ if error.operation == OperationKind.CARD_DATA:
2671
+ self.load_card_data()
2672
+ return
2673
+ if error.operation == OperationKind.RATINGS:
2674
+ prefix = "ratings:"
2675
+ if not error_id.startswith(prefix):
2676
+ raise ValueError(f"Ratings error {error_id!r} has no set code.")
2677
+ self._request_ratings_download(set_code=error_id.removeprefix(prefix))
2678
+ return
2679
+ if error.operation == OperationKind.BUILD:
2680
+ if self._last_build_request is None:
2681
+ raise ValueError(f"Build error {error_id!r} has no saved request.")
2682
+ self._request_build(command=self._last_build_request)
2683
+ return
2684
+ if error.operation == OperationKind.BACKTEST:
2685
+ if self._last_backtest_request is None:
2686
+ raise ValueError(f"Backtest error {error_id!r} has no saved request.")
2687
+ self._request_backtest(command=self._last_backtest_request)
2688
+ return
2689
+
2690
+ raise ValueError(f"Session error {error_id!r} has no retry operation.")
2691
+
2692
+ def _with_error(self, *, error: SessionError) -> tuple[SessionError, ...]:
2693
+ return self._without_error_id(error_id=error.error_id) + (error,)
2694
+
2695
+ def _without_error_id(self, *, error_id: str) -> tuple[SessionError, ...]:
2696
+ return tuple(
2697
+ error for error in self.snapshot.errors if error.error_id != error_id
2698
+ )
2699
+
2700
+ def _without_operation_error(
2701
+ self,
2702
+ *,
2703
+ operation: OperationKind,
2704
+ ) -> tuple[SessionError, ...]:
2705
+ return tuple(
2706
+ error for error in self.snapshot.errors if error.operation != operation
2707
+ )
2708
+
2709
+ def _ratings_error_id(self, *, set_code: str) -> str:
2710
+ return f"ratings:{set_code.upper()}"
2711
+
2712
+ def _active_set_code(self) -> str | None:
2713
+ with self._state_lock:
2714
+ return self._active_set_code_value
2715
+
2716
+ def _set_active_set_code(self, *, set_code: str | None) -> None:
2717
+ with self._state_lock:
2718
+ self._active_set_code_value = (
2719
+ None if set_code is None else set_code.upper()
2720
+ )
2721
+
2722
+ def _active_draft_state(self) -> DraftState | None:
2723
+ draft = self.snapshot.draft
2724
+ if draft is None:
2725
+ return None
2726
+
2727
+ return self._states_by_key.get((draft.account_id, draft.draft_id))
2728
+
2729
+ def _pool_state_from_active_draft(self) -> PoolState:
2730
+ state = self._active_draft_state()
2731
+ if state is None:
2732
+ return PoolState()
2733
+
2734
+ return self._pool_state(pool_grp_ids=state.pool_grp_ids)
2735
+
2736
+ @staticmethod
2737
+ def _pool_aggregates(
2738
+ *,
2739
+ cards: tuple[PoolCard, ...],
2740
+ ) -> tuple[tuple[tuple[str, int], ...], tuple[int, ...], float | None]:
2741
+ """Return authoritative color counts, spell curve, and average value.
2742
+ Lands and unknown mana values do not contribute to spell aggregates.
2743
+ """
2744
+
2745
+ color_counts = {color: 0 for color in ("W", "U", "B", "R", "G", "C")}
2746
+ mana_curve = [0] * 7
2747
+ mana_value_total = 0.0
2748
+ mana_value_quantity = 0
2749
+ for pool_card in cards:
2750
+ quantity = pool_card.quantity
2751
+ colors = pool_card.card.colors or ("C",)
2752
+ for color in colors:
2753
+ color_counts[color if color in color_counts else "C"] += quantity
2754
+ mana_value = pool_card.card.mana_value
2755
+ if any("Land" in type_line for type_line in pool_card.card.types):
2756
+ continue
2757
+ if mana_value is None:
2758
+ continue
2759
+ bucket = min(6, max(0, int(mana_value)))
2760
+ mana_curve[bucket] += quantity
2761
+ mana_value_total += mana_value * quantity
2762
+ mana_value_quantity += quantity
2763
+ return (
2764
+ tuple(color_counts.items()),
2765
+ tuple(mana_curve),
2766
+ (
2767
+ mana_value_total / mana_value_quantity
2768
+ if mana_value_quantity > 0
2769
+ else None
2770
+ ),
2771
+ )
2772
+ def _pool_state(
2773
+ self,
2774
+ *,
2775
+ pool_grp_ids: tuple[int, ...],
2776
+ scored_pack: ScoredPack | None = None,
2777
+ ) -> PoolState:
2778
+ database = self._card_database
2779
+ if database is None:
2780
+ return PoolState(total_cards=len(pool_grp_ids), target_cards=42)
2781
+
2782
+ counts = Counter(pool_grp_ids)
2783
+ cards = tuple(
2784
+ PoolCard(
2785
+ card=self._card_view(card=database.lookup(grp_id=grp_id)),
2786
+ quantity=counts[grp_id],
2787
+ )
2788
+ for grp_id in dict.fromkeys(pool_grp_ids)
2789
+ )
2790
+ recent_picks = self._prepare_recent_pick_images(
2791
+ pool_grp_ids=pool_grp_ids,
2792
+ )
2793
+ color_distribution, mana_curve, average_mana_value = self._pool_aggregates(
2794
+ cards=cards
2795
+ )
2796
+ return PoolState(
2797
+ cards=cards,
2798
+ recent_picks=recent_picks,
2799
+ total_cards=len(pool_grp_ids),
2800
+ target_cards=42,
2801
+ inferred_pair=(
2802
+ None
2803
+ if scored_pack is None
2804
+ else scored_pack.commitment.inferred_pair
2805
+ ),
2806
+ commitment=(
2807
+ 0.0 if scored_pack is None else scored_pack.commitment.level
2808
+ ),
2809
+ color_distribution=color_distribution,
2810
+ mana_curve=mana_curve,
2811
+ average_mana_value=average_mana_value,
2812
+ )
2813
+
2814
+ def _select_recovered_state(self, *, state: DraftState) -> None:
2815
+ self._current_pack_event = _pending_pack_event(state=state)
2816
+ self._current_scored_pack = None
2817
+ self._select_state(state=state, recovered=True)
2818
+ self._ensure_ratings_loaded(set_code=state.set_code)
2819
+ self._score_current_pack()
2820
+
2821
+ def _discard_previous_login_account_context(self) -> None:
2822
+ if self.parser.login_generation == self._login_generation:
2823
+ return
2824
+
2825
+ self._login_generation = self.parser.login_generation
2826
+ self._log_account_id = None
2827
+ self.store.clear_active_account()
2828
+ self._restore_pending_login_account_context()
2829
+
2830
+ def _restore_pending_login_account_context(self) -> None:
2831
+ profile = self._pending_login_account_profile()
2832
+ if profile is None:
2833
+ return
2834
+
2835
+ self._log_account_id = profile.account_id
2836
+ self.store.set_active_account(
2837
+ account_id=profile.account_id,
2838
+ screen_name=profile.screen_name,
2839
+ )
2840
+ active_account = self.snapshot.active_account
2841
+ if active_account is not None and active_account.account_id != profile.account_id:
2842
+ return
2843
+
2844
+ if self.snapshot.draft is not None or self.snapshot.pool.total_cards:
2845
+ return
2846
+
2847
+ state = self._latest_state_for_account(
2848
+ account_id=profile.account_id,
2849
+ require_pool=True,
2850
+ )
2851
+ if state is None:
2852
+ self._select_account_without_draft(account_id=profile.account_id)
2853
+ else:
2854
+ self._select_recovered_state(state=state)
2855
+
2856
+ def _consume_store_event(self, *, event: DraftEvent) -> DraftState | None:
2857
+ try:
2858
+ return self.store.consume(event=event)
2859
+ except DraftPoolError as error:
2860
+ if _is_missing_account_error(event=event, error=error):
2861
+ return None
2862
+
2863
+ raise
2864
+
2865
+ def _event_with_log_account(self, *, event: DraftEvent) -> DraftEvent:
2866
+ if self._log_account_id is None or not _event_is_missing_account(event=event):
2867
+ return event
2868
+
2869
+ return replace(event, account_id=self._log_account_id)
2870
+
2871
+ def _consume_event(self, *, event: DraftEvent, state: DraftState | None) -> None:
2872
+ if isinstance(event, AccountEvent):
2873
+ self._consume_account_event(event=event)
2874
+ return
2875
+
2876
+ if isinstance(event, QuickDraftDetectedEvent):
2877
+ self._consume_detected_event(event=event)
2878
+ return
2879
+
2880
+ if state is None:
2881
+ self._consume_accountless_event(event=event)
2882
+ return
2883
+
2884
+ if isinstance(event, DraftStartedEvent):
2885
+ self._current_pack_event = None
2886
+ self._current_scored_pack = None
2887
+ self.audit_store.record_draft_started(state=state)
2888
+ self._select_state(
2889
+ state=state,
2890
+ recovered=False,
2891
+ event=event,
2892
+ message=f"Draft started for {event.set_code}.",
2893
+ )
2894
+ self._ensure_ratings_loaded(set_code=event.set_code)
2895
+ return
2896
+
2897
+ if isinstance(event, PackOfferedEvent):
2898
+ self._current_pack_event = event
2899
+ self._current_scored_pack = None
2900
+ self._select_state(
2901
+ state=state,
2902
+ recovered=False,
2903
+ event=event,
2904
+ message=(
2905
+ f"Pack {event.pack_number + 1}, pick {event.pick_number + 1}."
2906
+ ),
2907
+ )
2908
+ self._ensure_ratings_loaded(set_code=event.set_code)
2909
+ self._score_current_pack()
2910
+ return
2911
+
2912
+ if isinstance(event, PickMadeEvent):
2913
+ self.audit_store.record_choice(
2914
+ state=state,
2915
+ event=event,
2916
+ ranking_mode=self._ranking_mode,
2917
+ )
2918
+ self._select_state(
2919
+ state=state,
2920
+ recovered=False,
2921
+ event=event,
2922
+ message=(
2923
+ f"Pack {event.pack_number + 1}, pick "
2924
+ f"{event.pick_number + 1} recorded."
2925
+ ),
2926
+ )
2927
+ return
2928
+
2929
+ if isinstance(event, DraftCompletedEvent):
2930
+ self._current_pack_event = None
2931
+ self._current_scored_pack = None
2932
+ self.audit_store.record_draft_completed(state=state, event=event)
2933
+ self._select_state(
2934
+ state=state,
2935
+ recovered=False,
2936
+ event=event,
2937
+ message="Draft complete.",
2938
+ )
2939
+ self._ensure_ratings_loaded(set_code=event.set_code)
2940
+
2941
+ def _consume_accountless_event(self, *, event: DraftEvent) -> None:
2942
+ if isinstance(event, DraftStartedEvent):
2943
+ self._current_pack_event = None
2944
+ self._current_scored_pack = None
2945
+ self._transient_pool_grp_ids = ()
2946
+ self._set_active_set_code(set_code=event.set_code)
2947
+ self._publish_accountless_state(
2948
+ event=event,
2949
+ phase=ApplicationPhase.WAITING_FOR_DRAFT,
2950
+ message="Draft detected; waiting for an Arena account ID.",
2951
+ keep_recommendations=False,
2952
+ )
2953
+ self._ensure_ratings_loaded(set_code=event.set_code)
2954
+ return
2955
+
2956
+ if isinstance(event, PackOfferedEvent):
2957
+ self._current_pack_event = event
2958
+ self._current_scored_pack = None
2959
+ self._transient_pool_grp_ids = event.pool_grp_ids
2960
+ self._set_active_set_code(set_code=event.set_code)
2961
+ self._publish_accountless_state(
2962
+ event=event,
2963
+ phase=ApplicationPhase.DRAFTING,
2964
+ message=(
2965
+ f"Pack {event.pack_number + 1}, pick "
2966
+ f"{event.pick_number + 1}; waiting for an account ID."
2967
+ ),
2968
+ keep_recommendations=False,
2969
+ )
2970
+ self._ensure_ratings_loaded(set_code=event.set_code)
2971
+ self._score_current_pack()
2972
+ return
2973
+
2974
+ if isinstance(event, PickMadeEvent):
2975
+ self._transient_pool_grp_ids += (event.chosen_grp_id,)
2976
+ self._publish_accountless_state(
2977
+ event=event,
2978
+ phase=ApplicationPhase.DRAFTING,
2979
+ message=(
2980
+ f"Pack {event.pack_number + 1}, pick "
2981
+ f"{event.pick_number + 1} recorded without an account ID."
2982
+ ),
2983
+ keep_recommendations=True,
2984
+ )
2985
+ return
2986
+
2987
+ if isinstance(event, DraftCompletedEvent):
2988
+ self._current_pack_event = None
2989
+ self._current_scored_pack = None
2990
+ self._transient_pool_grp_ids = event.picked_grp_ids
2991
+ self._set_active_set_code(set_code=event.set_code)
2992
+ self._publish_accountless_state(
2993
+ event=event,
2994
+ phase=ApplicationPhase.DRAFT_COMPLETE,
2995
+ message="Draft complete without an account ID.",
2996
+ keep_recommendations=False,
2997
+ )
2998
+ self._ensure_ratings_loaded(set_code=event.set_code)
2999
+
3000
+ def _publish_accountless_state(
3001
+ self,
3002
+ *,
3003
+ event: DraftEvent,
3004
+ phase: ApplicationPhase,
3005
+ message: str,
3006
+ keep_recommendations: bool,
3007
+ ) -> None:
3008
+ set_code = event.set_code.upper()
3009
+ ratings = self._ratings_state_by_set.get(
3010
+ set_code,
3011
+ replace(self._initial_ratings_state(), set_code=set_code),
3012
+ )
3013
+ with self._state_lock:
3014
+ errors = self._retire_derived_operations()
3015
+ if not keep_recommendations:
3016
+ self._retire_recent_pick_images()
3017
+ card_image = (
3018
+ self.snapshot.card_image
3019
+ if keep_recommendations
3020
+ else self._retire_card_image()
3021
+ )
3022
+ self._publish(
3023
+ snapshot=replace(
3024
+ self.snapshot,
3025
+ status=ApplicationStatus(phase=phase, message=message),
3026
+ accounts=self._known_accounts(),
3027
+ active_account=None,
3028
+ draft=None,
3029
+ ratings=ratings,
3030
+ recommendations=(
3031
+ self.snapshot.recommendations
3032
+ if keep_recommendations
3033
+ else RecommendationState(
3034
+ ranking_mode=self._ranking_mode,
3035
+ splash_enabled=self._splash_enabled,
3036
+ )
3037
+ ),
3038
+ card_image=card_image,
3039
+ pool=self._pool_state(
3040
+ pool_grp_ids=self._transient_pool_grp_ids,
3041
+ ),
3042
+ progress=None,
3043
+ errors=errors,
3044
+ build=None,
3045
+ backtest=None,
3046
+ )
3047
+ )
3048
+
3049
+ def _consume_account_event(self, *, event: AccountEvent) -> None:
3050
+ self._log_account_id = event.client_id
3051
+ if event.screen_name is not None:
3052
+ self._screen_names_by_account_id[event.client_id] = event.screen_name
3053
+
3054
+ active_account = self.snapshot.active_account
3055
+ if (
3056
+ active_account is not None
3057
+ and active_account.account_id != event.client_id
3058
+ and (self.snapshot.draft is not None or self.snapshot.pool.total_cards)
3059
+ ):
3060
+ self._refresh_accounts()
3061
+ return
3062
+
3063
+ state = self._latest_state_for_account(
3064
+ account_id=event.client_id,
3065
+ require_pool=True,
3066
+ )
3067
+ if state is None:
3068
+ self._select_account_without_draft(account_id=event.client_id)
3069
+ else:
3070
+ self._select_recovered_state(state=state)
3071
+
3072
+ def _consume_detected_event(self, *, event: QuickDraftDetectedEvent) -> None:
3073
+ self._current_pack_event = None
3074
+ self._current_scored_pack = None
3075
+ self._transient_pool_grp_ids = ()
3076
+ self._set_active_set_code(set_code=event.set_code)
3077
+ account_id = event.account_id or self._log_account_id
3078
+ active_account = self._identity_for(account_id=account_id)
3079
+ with self._state_lock:
3080
+ errors = self._retire_derived_operations()
3081
+ self._retire_recent_pick_images()
3082
+ card_image = self._retire_card_image()
3083
+ self._publish(
3084
+ snapshot=replace(
3085
+ self.snapshot,
3086
+ status=ApplicationStatus(
3087
+ phase=ApplicationPhase.WAITING_FOR_DRAFT,
3088
+ message=f"Preparing Quick Draft data for {event.set_code}.",
3089
+ ),
3090
+ accounts=self._known_accounts(),
3091
+ active_account=active_account,
3092
+ draft=None,
3093
+ recommendations=RecommendationState(
3094
+ ranking_mode=self._ranking_mode,
3095
+ splash_enabled=self._splash_enabled,
3096
+ ),
3097
+ card_image=card_image,
3098
+ pool=PoolState(),
3099
+ progress=None,
3100
+ errors=errors,
3101
+ build=None,
3102
+ backtest=None,
3103
+ )
3104
+ )
3105
+ self._ensure_ratings_loaded(set_code=event.set_code)
3106
+
3107
+ def _choose_account(self, *, account_id: str) -> None:
3108
+ known_account_ids = {account.account_id for account in self._known_accounts()}
3109
+ if account_id not in known_account_ids:
3110
+ raise ValueError(f"Unknown Arena account {account_id!r}.")
3111
+
3112
+ state = self._latest_state_for_account(
3113
+ account_id=account_id,
3114
+ require_pool=False,
3115
+ )
3116
+ if state is None:
3117
+ self._select_account_without_draft(account_id=account_id)
3118
+ else:
3119
+ self._select_recovered_state(state=state)
3120
+
3121
+ def _select_account_without_draft(self, *, account_id: str) -> None:
3122
+ identity = self._identity_for(account_id=account_id)
3123
+ if identity is None:
3124
+ raise ValueError(f"Unknown Arena account {account_id!r}.")
3125
+
3126
+ self.store.set_active_account(
3127
+ account_id=account_id,
3128
+ screen_name=identity.screen_name,
3129
+ )
3130
+ self._current_pack_event = None
3131
+ self._current_scored_pack = None
3132
+ self._transient_pool_grp_ids = ()
3133
+ with self._state_lock:
3134
+ self._retire_recent_pick_images()
3135
+ errors = self._retire_derived_operations()
3136
+ card_image = self._retire_card_image()
3137
+ self._publish(
3138
+ snapshot=replace(
3139
+ self.snapshot,
3140
+ status=ApplicationStatus(
3141
+ phase=ApplicationPhase.WAITING_FOR_DRAFT,
3142
+ message="Waiting for a Quick Draft.",
3143
+ ),
3144
+ accounts=self._known_accounts(),
3145
+ active_account=identity,
3146
+ draft=None,
3147
+ ratings=self._initial_ratings_state(),
3148
+ recommendations=RecommendationState(
3149
+ ranking_mode=self._ranking_mode,
3150
+ splash_enabled=self._splash_enabled,
3151
+ ),
3152
+ card_image=card_image,
3153
+ pool=PoolState(),
3154
+ progress=None,
3155
+ errors=errors,
3156
+ build=None,
3157
+ backtest=None,
3158
+ )
3159
+ )
3160
+
3161
+ def _select_state(
3162
+ self,
3163
+ *,
3164
+ state: DraftState,
3165
+ recovered: bool,
3166
+ event: DraftEvent | None = None,
3167
+ message: str | None = None,
3168
+ ) -> None:
3169
+ self._remember_state(state=state)
3170
+ self._transient_pool_grp_ids = state.pool_grp_ids
3171
+ self._set_active_set_code(set_code=state.set_code)
3172
+ self.store.set_active_account(
3173
+ account_id=state.account_id,
3174
+ screen_name=state.account_screen_name,
3175
+ )
3176
+ pack_number, pick_number = _draft_coordinates(state=state, event=event)
3177
+ if message is None:
3178
+ if state.completed:
3179
+ message = "Recovered completed draft."
3180
+ elif recovered:
3181
+ message = "Recovered draft in progress."
3182
+ else:
3183
+ message = "Draft in progress."
3184
+ phase = (
3185
+ ApplicationPhase.DRAFT_COMPLETE
3186
+ if state.completed
3187
+ else ApplicationPhase.DRAFTING
3188
+ )
3189
+ with self._state_lock:
3190
+ errors = self._retire_derived_operations()
3191
+ previous_draft = self.snapshot.draft
3192
+ switches_context = (
3193
+ self.snapshot.active_account is None
3194
+ or self.snapshot.active_account.account_id != state.account_id
3195
+ or previous_draft is None
3196
+ or previous_draft.account_id != state.account_id
3197
+ or previous_draft.draft_id != state.draft_id
3198
+ )
3199
+ if switches_context:
3200
+ self._retire_recent_pick_images()
3201
+ card_image = (
3202
+ self._retire_card_image()
3203
+ if switches_context or not isinstance(event, PickMadeEvent)
3204
+ else self.snapshot.card_image
3205
+ )
3206
+ self._publish(
3207
+ snapshot=replace(
3208
+ self.snapshot,
3209
+ status=ApplicationStatus(phase=phase, message=message),
3210
+ accounts=self._known_accounts(),
3211
+ active_account=AccountIdentity(
3212
+ account_id=state.account_id,
3213
+ screen_name=self._screen_name_for_state(state=state),
3214
+ ),
3215
+ draft=DraftIdentity(
3216
+ account_id=state.account_id,
3217
+ draft_id=state.draft_id,
3218
+ event_name=state.event_name,
3219
+ set_code=state.set_code,
3220
+ course_id=state.course_id,
3221
+ pack_number=pack_number,
3222
+ pick_number=pick_number,
3223
+ completed=state.completed,
3224
+ ),
3225
+ recommendations=(
3226
+ self.snapshot.recommendations
3227
+ if isinstance(event, PickMadeEvent)
3228
+ else RecommendationState(
3229
+ ranking_mode=self._ranking_mode,
3230
+ splash_enabled=self._splash_enabled,
3231
+ )
3232
+ ),
3233
+ card_image=card_image,
3234
+ pool=self._pool_state(
3235
+ pool_grp_ids=state.pool_grp_ids,
3236
+ ),
3237
+ progress=None,
3238
+ errors=errors,
3239
+ build=None,
3240
+ backtest=None,
3241
+ )
3242
+ )
3243
+
3244
+ def _remember_state(self, *, state: DraftState) -> None:
3245
+ self._states_by_key[(state.account_id, state.draft_id)] = state
3246
+ if state.account_screen_name is not None:
3247
+ self._screen_names_by_account_id[state.account_id] = (
3248
+ state.account_screen_name
3249
+ )
3250
+
3251
+ def _known_accounts(self) -> tuple[AccountIdentity, ...]:
3252
+ screen_names: dict[str, str | None] = dict(
3253
+ self._screen_names_by_account_id
3254
+ )
3255
+ for state in self._recovered_states():
3256
+ if state.account_screen_name is not None:
3257
+ screen_names.setdefault(state.account_id, state.account_screen_name)
3258
+ else:
3259
+ screen_names.setdefault(state.account_id, None)
3260
+ for profile in list_account_profiles(app_dir=self.store.app_dir):
3261
+ screen_names[profile.account_id] = profile.screen_name
3262
+
3263
+ return tuple(
3264
+ sorted(
3265
+ (
3266
+ AccountIdentity(
3267
+ account_id=account_id,
3268
+ screen_name=screen_name,
3269
+ )
3270
+ for account_id, screen_name in screen_names.items()
3271
+ ),
3272
+ key=lambda account: (
3273
+ (account.screen_name or account.account_id).casefold(),
3274
+ account.account_id,
3275
+ ),
3276
+ )
3277
+ )
3278
+
3279
+ def _identity_for(self, *, account_id: str | None) -> AccountIdentity | None:
3280
+ if account_id is None:
3281
+ return None
3282
+
3283
+ return next(
3284
+ (
3285
+ account
3286
+ for account in self._known_accounts()
3287
+ if account.account_id == account_id
3288
+ ),
3289
+ AccountIdentity(account_id=account_id, screen_name=None),
3290
+ )
3291
+
3292
+ def _refresh_accounts(self) -> None:
3293
+ active_account_id = (
3294
+ None
3295
+ if self.snapshot.active_account is None
3296
+ else self.snapshot.active_account.account_id
3297
+ )
3298
+ self._publish(
3299
+ snapshot=replace(
3300
+ self.snapshot,
3301
+ accounts=self._known_accounts(),
3302
+ active_account=self._identity_for(account_id=active_account_id),
3303
+ )
3304
+ )
3305
+
3306
+ def _pending_login_account_profile(self) -> AccountProfile | None:
3307
+ pending_screen_name = self.parser.pending_login_screen_name
3308
+ if pending_screen_name is None:
3309
+ return None
3310
+
3311
+ pending_key = _account_screen_name_match_key(
3312
+ screen_name=pending_screen_name,
3313
+ )
3314
+ matches = tuple(
3315
+ profile
3316
+ for profile in list_account_profiles(app_dir=self.store.app_dir)
3317
+ if _account_screen_name_match_key(screen_name=profile.screen_name)
3318
+ == pending_key
3319
+ )
3320
+ if len(matches) != 1:
3321
+ return None
3322
+
3323
+ return matches[0]
3324
+
3325
+ def _recovered_states(self) -> tuple[DraftState, ...]:
3326
+ states = dict(self._states_by_key)
3327
+ states.update(
3328
+ {
3329
+ (state.account_id, state.draft_id): state
3330
+ for state in list_draft_states(app_dir=self.store.app_dir)
3331
+ }
3332
+ )
3333
+ return tuple(states.values())
3334
+
3335
+ def _latest_state_for_account(
3336
+ self,
3337
+ *,
3338
+ account_id: str,
3339
+ require_pool: bool,
3340
+ ) -> DraftState | None:
3341
+ states = tuple(
3342
+ state
3343
+ for state in self._recovered_states()
3344
+ if state.account_id == account_id
3345
+ and (not require_pool or bool(state.pool_grp_ids))
3346
+ )
3347
+ if not states:
3348
+ return None
3349
+
3350
+ return max(states, key=_latest_draft_state_sort_key)
3351
+
3352
+ def _persist_pending_login_name_for_observed_course(self) -> None:
3353
+ screen_name = self.parser.pending_login_screen_name
3354
+ course_ids = self.parser.observed_quick_draft_course_ids
3355
+ if screen_name is None or not course_ids:
3356
+ return
3357
+
3358
+ matching_account_ids = {
3359
+ state.account_id
3360
+ for state in self._recovered_states()
3361
+ if state.course_id in course_ids or state.draft_id in course_ids
3362
+ }
3363
+ if len(matching_account_ids) != 1:
3364
+ return
3365
+
3366
+ account_id = matching_account_ids.pop()
3367
+ identity = self._identity_for(account_id=account_id)
3368
+ if identity is not None and identity.screen_name is not None:
3369
+ return
3370
+
3371
+ self.store.set_active_account(
3372
+ account_id=account_id,
3373
+ screen_name=screen_name,
3374
+ )
3375
+ self._screen_names_by_account_id[account_id] = screen_name
3376
+ self._refresh_accounts()
3377
+
3378
+ def _screen_name_for_state(self, *, state: DraftState) -> str | None:
3379
+ identity = self._identity_for(account_id=state.account_id)
3380
+ if identity is not None and identity.screen_name is not None:
3381
+ return identity.screen_name
3382
+
3383
+ return state.account_screen_name
3384
+
3385
+ def _publish(self, snapshot: LiveSessionSnapshot) -> None:
3386
+ with self._state_lock:
3387
+ snapshot = replace(
3388
+ snapshot,
3389
+ current_pack_event=self._current_pack_event,
3390
+ current_scored_pack=self._current_scored_pack,
3391
+ )
3392
+ pack_key = self._recommendation_image_pack_key(
3393
+ pack=self._current_pack_event,
3394
+ snapshot=snapshot,
3395
+ )
3396
+ if (
3397
+ self._recommendation_image_pack is not None
3398
+ and pack_key != self._recommendation_image_pack
3399
+ ):
3400
+ self._retire_recommendation_images()
3401
+ if snapshot == self._snapshot:
3402
+ return
3403
+
3404
+ self._snapshot = snapshot
3405
+ if self._snapshot_publisher is not None:
3406
+ self._snapshot_publisher(snapshot)
3407
+
3408
+ def _publish_event(self, *, event: DraftEvent) -> None:
3409
+ if self._event_publisher is None:
3410
+ return
3411
+
3412
+ scored_pack = (
3413
+ self._current_scored_pack
3414
+ if isinstance(event, PackOfferedEvent)
3415
+ else None
3416
+ )
3417
+ self._event_publisher(
3418
+ LiveSessionEvent(
3419
+ event=event,
3420
+ snapshot=self.snapshot,
3421
+ scored_pack=scored_pack,
3422
+ )
3423
+ )
3424
+
3425
+
3426
+ def _ratings_last_successful_update(
3427
+ *, ratings_data: SeventeenLandsData
3428
+ ) -> str | None:
3429
+ """Return primary ratings freshness as a QML-safe UTC ISO-8601 string.
3430
+ Invalid or naive 17Lands cache metadata is treated as never updated.
3431
+ """
3432
+
3433
+ fetched_at = getattr(
3434
+ getattr(ratings_data, "primary", None),
3435
+ "fetched_at",
3436
+ None,
3437
+ )
3438
+ if (
3439
+ not isinstance(fetched_at, datetime)
3440
+ or fetched_at.tzinfo is None
3441
+ or fetched_at.utcoffset() is None
3442
+ ):
3443
+ return None
3444
+
3445
+ try:
3446
+ return fetched_at.astimezone(UTC).isoformat()
3447
+ except (OverflowError, OSError, TypeError, ValueError):
3448
+ return None
3449
+
3450
+
3451
+ def _last_successful_update(*, database: CardDatabase) -> str | None:
3452
+ """Return a database refresh time as a QML-safe UTC ISO-8601 string.
3453
+ Unknown or naive cache metadata is treated as never updated.
3454
+ """
3455
+
3456
+ generated_at = database.generated_at
3457
+ if (
3458
+ not isinstance(generated_at, datetime)
3459
+ or generated_at.tzinfo is None
3460
+ or generated_at.utcoffset() is None
3461
+ ):
3462
+ return None
3463
+
3464
+ return generated_at.astimezone(UTC).isoformat()
3465
+
3466
+
3467
+ def _waiting_for_draft_status(*, setup_guidance: bool) -> ApplicationStatus:
3468
+ return ApplicationStatus(
3469
+ phase=ApplicationPhase.WAITING_FOR_DRAFT,
3470
+ message=LOG_SETUP_GUIDANCE if setup_guidance else WAITING_FOR_DRAFT_MESSAGE,
3471
+ setup_guidance=setup_guidance,
3472
+ )
3473
+
3474
+
3475
+ def _log_setup_refresh_is_eligible(
3476
+ *,
3477
+ snapshot: LiveSessionSnapshot,
3478
+ ) -> bool:
3479
+ status = snapshot.status
3480
+ return (
3481
+ snapshot.draft is None
3482
+ and status.phase == ApplicationPhase.WAITING_FOR_DRAFT
3483
+ and (
3484
+ status.setup_guidance
3485
+ or status.message == WAITING_FOR_DRAFT_MESSAGE
3486
+ )
3487
+ )
3488
+
3489
+
3490
+ def _draft_coordinates(
3491
+ *,
3492
+ state: DraftState,
3493
+ event: DraftEvent | None,
3494
+ ) -> tuple[int | None, int | None]:
3495
+ if isinstance(event, (PackOfferedEvent, PickMadeEvent, DraftCompletedEvent)):
3496
+ return (event.pack_number, event.pick_number)
3497
+
3498
+ if not state.picks:
3499
+ return (None, None)
3500
+
3501
+ pick = max(state.picks, key=lambda candidate: candidate.coordinate)
3502
+ return pick.coordinate
3503
+
3504
+
3505
+ def _pending_pack_event(*, state: DraftState) -> PackOfferedEvent | None:
3506
+ if state.completed:
3507
+ return None
3508
+
3509
+ pending_picks = tuple(
3510
+ pick
3511
+ for pick in state.picks
3512
+ if pick.offered_grp_ids and pick.chosen_grp_id is None
3513
+ )
3514
+ if not pending_picks:
3515
+ return None
3516
+
3517
+ pending_pick = max(pending_picks, key=lambda pick: pick.coordinate)
3518
+ offered_grp_ids = pending_pick.offered_grp_ids
3519
+ if offered_grp_ids is None:
3520
+ return None
3521
+
3522
+ pool_grp_ids = (
3523
+ state.pool_grp_ids
3524
+ if pending_pick.pool_before_pick is None
3525
+ else pending_pick.pool_before_pick
3526
+ )
3527
+ return PackOfferedEvent(
3528
+ event_name=state.event_name,
3529
+ set_code=state.set_code,
3530
+ pack_number=pending_pick.pack_number,
3531
+ pick_number=pending_pick.pick_number,
3532
+ offered_grp_ids=offered_grp_ids,
3533
+ pool_grp_ids=pool_grp_ids,
3534
+ account_id=state.account_id,
3535
+ )
3536
+
3537
+
3538
+ def _draft_pick_index(*, event: PackOfferedEvent) -> int:
3539
+ return (event.pack_number * EXPECTED_PICKS_PER_PACK) + event.pick_number + 1
3540
+
3541
+
3542
+ def _card_view(*, card: CardInfo, image_path: str | None = None) -> CardView:
3543
+ return CardView(
3544
+ grp_id=card.grp_id,
3545
+ name=card.name,
3546
+ colors=card.colors,
3547
+ rarity=card.rarity,
3548
+ types=card.types,
3549
+ mana_cost=card.mana_cost,
3550
+ mana_value=card.mana_value,
3551
+ image_path=image_path,
3552
+ )
3553
+
3554
+
3555
+ def _build_cards(*, cards: tuple[ScoredCard, ...]) -> tuple[BuildCard, ...]:
3556
+ grouped: dict[tuple[str, str], BuildCard] = {}
3557
+ for scored_card in cards:
3558
+ card = scored_card.card
3559
+ key = (
3560
+ ("unknown", str(card.grp_id))
3561
+ if card.unknown
3562
+ else ("name", " ".join(card.name.casefold().split()))
3563
+ )
3564
+ existing = grouped.get(key)
3565
+ if existing is None:
3566
+ grouped[key] = BuildCard(
3567
+ card=_card_view(card=card),
3568
+ quantity=1,
3569
+ score=scored_card.score,
3570
+ win_rate=scored_card.rating.gih_win_rate,
3571
+ average_last_seen_at=scored_card.rating.average_last_seen_at,
3572
+ letter_grade=scored_card.rating.letter_grade,
3573
+ source_label=scored_card.source_label,
3574
+ color_fit=scored_card.color_fit,
3575
+ no_data=scored_card.no_data,
3576
+ )
3577
+ continue
3578
+
3579
+ grouped[key] = replace(existing, quantity=existing.quantity + 1)
3580
+
3581
+ return tuple(grouped.values())
3582
+
3583
+
3584
+ def _build_spell_curve_sort_key(card: ScoredCard) -> tuple[float, int, str, int]:
3585
+ mana_value = 99.0 if card.card.mana_value is None else card.card.mana_value
3586
+ return (mana_value, -card.score, card.card.name, card.original_index)
3587
+
3588
+
3589
+ def _backtest_result(*, report: DomainBacktestReport) -> BacktestResult:
3590
+ state = report.state
3591
+ return BacktestResult(
3592
+ ranking_mode=report.ranking_mode,
3593
+ rows=tuple(
3594
+ BacktestPickResult(
3595
+ pack_number=row.pack_number,
3596
+ pick_number=row.pick_number,
3597
+ recommended=(
3598
+ None
3599
+ if row.recommended is None
3600
+ else _card_view(card=row.recommended.card)
3601
+ ),
3602
+ actual=(
3603
+ None if row.actual is None else _card_view(card=row.actual)
3604
+ ),
3605
+ match=row.match,
3606
+ skipped_reason=row.skipped_reason,
3607
+ data_source=row.data_source,
3608
+ pool_size=row.pool_size,
3609
+ offered_count=row.offered_count,
3610
+ recommended_score=(
3611
+ None if row.recommended is None else row.recommended.score
3612
+ ),
3613
+ recommended_win_rate=(
3614
+ None
3615
+ if row.recommended is None
3616
+ else row.recommended.rating.gih_win_rate
3617
+ ),
3618
+ )
3619
+ for row in report.rows
3620
+ ),
3621
+ match_count=report.match_count,
3622
+ compared_count=len(report.compared_rows),
3623
+ skipped_count=len(report.skipped_rows),
3624
+ data_sources=report.data_sources,
3625
+ account_id=state.account_id,
3626
+ account_screen_name=state.account_screen_name,
3627
+ draft_id=state.draft_id,
3628
+ set_code=state.set_code,
3629
+ event_name=state.event_name,
3630
+ completed=state.completed,
3631
+ chosen_pick_count=state.chosen_pick_count,
3632
+ )
3633
+
3634
+
3635
+ def _latest_draft_state_sort_key(state: DraftState) -> tuple[str, str, str]:
3636
+ return (state.updated_at, state.account_id, state.draft_id)
3637
+
3638
+
3639
+ def _is_missing_account_error(*, event: DraftEvent, error: DraftPoolError) -> bool:
3640
+ return (
3641
+ str(error) == "Draft event is missing an MTGA account id."
3642
+ and _event_is_missing_account(event=event)
3643
+ )
3644
+
3645
+
3646
+ def _event_is_missing_account(*, event: DraftEvent) -> bool:
3647
+ return (
3648
+ isinstance(
3649
+ event,
3650
+ (
3651
+ QuickDraftDetectedEvent,
3652
+ DraftStartedEvent,
3653
+ PackOfferedEvent,
3654
+ PickMadeEvent,
3655
+ DraftCompletedEvent,
3656
+ ),
3657
+ )
3658
+ and event.account_id is None
3659
+ )
3660
+
3661
+
3662
+ def _account_screen_name_match_key(*, screen_name: str) -> str:
3663
+ normalized = screen_name.strip()
3664
+ name, separator, discriminator = normalized.rpartition("#")
3665
+ if separator and name and discriminator.isdigit():
3666
+ normalized = name
3667
+
3668
+ return normalized.casefold()