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