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.
- draftomen/__init__.py +17 -0
- draftomen/assets/draftomen.icns +0 -0
- draftomen/assets/draftomen.ico +0 -0
- draftomen/assets/draftomen_logo.png +0 -0
- draftomen/audit.py +606 -0
- draftomen/backtest.py +449 -0
- draftomen/benchmark.py +834 -0
- draftomen/carddb.py +1869 -0
- draftomen/cardimages.py +313 -0
- draftomen/cli.py +891 -0
- draftomen/config.py +133 -0
- draftomen/deckbuilder.py +2723 -0
- draftomen/events.py +772 -0
- draftomen/logfollow.py +451 -0
- draftomen/mock_session.py +745 -0
- draftomen/paths.py +120 -0
- draftomen/pickengine.py +1117 -0
- draftomen/pool.py +1259 -0
- draftomen/preferences.py +289 -0
- draftomen/qml/AboutDialog.qml +156 -0
- draftomen/qml/AppBar.qml +120 -0
- draftomen/qml/BacktestView.qml +316 -0
- draftomen/qml/BuildView.qml +1151 -0
- draftomen/qml/CardPreview.qml +434 -0
- draftomen/qml/DimensionalButton.qml +42 -0
- draftomen/qml/DimensionalComboBox.qml +182 -0
- draftomen/qml/DimensionalSurface.qml +118 -0
- draftomen/qml/DimensionalTabButton.qml +41 -0
- draftomen/qml/LiveDraftView.qml +468 -0
- draftomen/qml/Main.qml +175 -0
- draftomen/qml/NavigationRail.qml +226 -0
- draftomen/qml/PoolSummaryPanel.qml +322 -0
- draftomen/qml/PrivacyDialog.qml +96 -0
- draftomen/qml/RecentPickThumbnail.qml +83 -0
- draftomen/qml/RecentPicksGallery.qml +333 -0
- draftomen/qml/RecommendationRow.qml +404 -0
- draftomen/qml/SettingsSwitch.qml +141 -0
- draftomen/qml/SettingsView.qml +531 -0
- draftomen/qml/StateBanner.qml +189 -0
- draftomen/qml/StatusStrip.qml +84 -0
- draftomen/qml/Theme.qml +71 -0
- draftomen/qml/qmldir +23 -0
- draftomen/qt_adapter.py +884 -0
- draftomen/qt_gui.py +338 -0
- draftomen/qt_mock.py +63 -0
- draftomen/ranking.py +126 -0
- draftomen/replay.py +480 -0
- draftomen/session.py +3668 -0
- draftomen/setinfo.py +26 -0
- draftomen/seventeen.py +2766 -0
- draftomen/splash.py +617 -0
- draftomen/tui.py +4007 -0
- draftomen/watch.py +336 -0
- draftomen-0.3.0.dist-info/METADATA +156 -0
- draftomen-0.3.0.dist-info/RECORD +59 -0
- draftomen-0.3.0.dist-info/WHEEL +5 -0
- draftomen-0.3.0.dist-info/entry_points.txt +6 -0
- draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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()
|