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/tui.py
ADDED
|
@@ -0,0 +1,4007 @@
|
|
|
1
|
+
"""Textual live interface for Draftomen watch mode.
|
|
2
|
+
Render ranked packs and status updates without blocking fetches.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from os import PathLike
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from threading import Lock, get_ident
|
|
14
|
+
from typing import TypeAlias
|
|
15
|
+
|
|
16
|
+
from rich.align import Align
|
|
17
|
+
from rich.console import Group
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from textual import events, work
|
|
20
|
+
from textual.app import App, ComposeResult
|
|
21
|
+
from textual.binding import Binding
|
|
22
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
23
|
+
from textual.css.query import NoMatches
|
|
24
|
+
from textual.screen import ModalScreen
|
|
25
|
+
from textual.widgets import (
|
|
26
|
+
Button,
|
|
27
|
+
DataTable,
|
|
28
|
+
Footer,
|
|
29
|
+
Header,
|
|
30
|
+
ProgressBar,
|
|
31
|
+
Select,
|
|
32
|
+
Static,
|
|
33
|
+
Switch,
|
|
34
|
+
)
|
|
35
|
+
from textual.worker import get_current_worker
|
|
36
|
+
|
|
37
|
+
try: # pragma: no cover - import availability depends on optional terminal extras.
|
|
38
|
+
from textual_image.renderable.tgp import Image as TgpImage
|
|
39
|
+
except Exception: # pragma: no cover - graceful fallback when unavailable.
|
|
40
|
+
TgpImage = None
|
|
41
|
+
|
|
42
|
+
from draftomen.carddb import CardDatabase, CardInfo
|
|
43
|
+
from draftomen.cardimages import CardImageService, card_image_cache_dir
|
|
44
|
+
from draftomen.config import COLOR_PAIRS, POLL_INTERVAL_SECONDS
|
|
45
|
+
from draftomen.deckbuilder import BuildPool, ManaBase, PairSelection, SpellSelection
|
|
46
|
+
from draftomen.events import (
|
|
47
|
+
AccountEvent,
|
|
48
|
+
DraftCompletedEvent,
|
|
49
|
+
DraftStartedEvent,
|
|
50
|
+
PackOfferedEvent,
|
|
51
|
+
PickMadeEvent,
|
|
52
|
+
QuickDraftDetectedEvent,
|
|
53
|
+
)
|
|
54
|
+
from draftomen.pickengine import (
|
|
55
|
+
ScoredCard,
|
|
56
|
+
ScoredPack,
|
|
57
|
+
recommendation_confidence_summary,
|
|
58
|
+
)
|
|
59
|
+
from draftomen.preferences import (
|
|
60
|
+
TuiVisibilityPreferences,
|
|
61
|
+
load_tui_preferences,
|
|
62
|
+
save_tui_preferences,
|
|
63
|
+
)
|
|
64
|
+
from draftomen.ranking import (
|
|
65
|
+
DEFAULT_RANKING_MODE,
|
|
66
|
+
RANKING_LABELS,
|
|
67
|
+
RANKING_MODES,
|
|
68
|
+
rank_scored_cards,
|
|
69
|
+
ranking_label,
|
|
70
|
+
)
|
|
71
|
+
from draftomen.session import (
|
|
72
|
+
ApplicationPhase,
|
|
73
|
+
BacktestPickResult,
|
|
74
|
+
BacktestResult,
|
|
75
|
+
BuildResult,
|
|
76
|
+
CardView,
|
|
77
|
+
ChangeRanking,
|
|
78
|
+
ChangeSplashPreference,
|
|
79
|
+
ChooseAccount,
|
|
80
|
+
DataLoadPhase,
|
|
81
|
+
LiveSession,
|
|
82
|
+
LiveSessionCommand,
|
|
83
|
+
LiveSessionEvent,
|
|
84
|
+
LiveSessionSnapshot,
|
|
85
|
+
OperationKind,
|
|
86
|
+
PoolState,
|
|
87
|
+
RatingsProgressLoader,
|
|
88
|
+
RequestBacktest,
|
|
89
|
+
RequestBuild,
|
|
90
|
+
RequestRatingsDownload,
|
|
91
|
+
SessionError,
|
|
92
|
+
)
|
|
93
|
+
from draftomen.seventeen import (
|
|
94
|
+
SEVENTEEN_LANDS_ATTRIBUTION,
|
|
95
|
+
DownloadProgressCallback,
|
|
96
|
+
SeventeenLandsData,
|
|
97
|
+
)
|
|
98
|
+
from draftomen.setinfo import format_set_label
|
|
99
|
+
|
|
100
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
101
|
+
RatingsLoader: TypeAlias = Callable[[str], SeventeenLandsData]
|
|
102
|
+
CardDatabaseLoader: TypeAlias = Callable[[], CardDatabase]
|
|
103
|
+
RatingsLoaderFactory: TypeAlias = Callable[[CardDatabase], RatingsLoader]
|
|
104
|
+
RatingsProgressLoaderFactory: TypeAlias = Callable[
|
|
105
|
+
[CardDatabase],
|
|
106
|
+
RatingsProgressLoader,
|
|
107
|
+
]
|
|
108
|
+
RatingsCacheChecker: TypeAlias = Callable[[str], bool]
|
|
109
|
+
TuiCardQuantityKey: TypeAlias = tuple[str, str]
|
|
110
|
+
TuiCardQuantityGroup: TypeAlias = tuple[ScoredCard, int]
|
|
111
|
+
|
|
112
|
+
PRIMARY_COLUMN_KEYS = ("rank", "win_rate", "grade", "score", "card", "colors")
|
|
113
|
+
SECONDARY_COLUMN_KEYS = ("fit", "alsa", "mv", "source")
|
|
114
|
+
SORT_MODES = RANKING_MODES
|
|
115
|
+
BUILD_SPELL_SORT_MODES = ("curve", "score", "name")
|
|
116
|
+
SECONDARY_COLUMN_MIN_WIDTH = 88
|
|
117
|
+
SIDEBAR_MIN_WIDTH = 56
|
|
118
|
+
COLOR_ORDER = ("W", "U", "B", "R", "G")
|
|
119
|
+
COLOR_NAMES = {
|
|
120
|
+
"W": "White",
|
|
121
|
+
"U": "Blue",
|
|
122
|
+
"B": "Black",
|
|
123
|
+
"R": "Red",
|
|
124
|
+
"G": "Green",
|
|
125
|
+
}
|
|
126
|
+
BASIC_LAND_NAMES = {
|
|
127
|
+
"W": "Plains",
|
|
128
|
+
"U": "Island",
|
|
129
|
+
"B": "Swamp",
|
|
130
|
+
"R": "Mountain",
|
|
131
|
+
"G": "Forest",
|
|
132
|
+
}
|
|
133
|
+
COLORLESS_KEY = "C"
|
|
134
|
+
UNKNOWN_COLOR_KEY = "?"
|
|
135
|
+
CURVE_BUCKET_LABELS = ("0", "1", "2", "3", "4", "5", "6+")
|
|
136
|
+
SPARKLINE_GLYPHS = "▁▂▃▄▅▆▇█"
|
|
137
|
+
CARD_IMAGE_PREVIEW_ENV = "DRAFTOMEN_CARD_IMAGES"
|
|
138
|
+
MANA_ICON_GLYPHS = {
|
|
139
|
+
"W": "\ue600",
|
|
140
|
+
"U": "\ue601",
|
|
141
|
+
"B": "\ue602",
|
|
142
|
+
"R": "\ue603",
|
|
143
|
+
"G": "\ue604",
|
|
144
|
+
"C": "\ue904",
|
|
145
|
+
}
|
|
146
|
+
MANA_CARD_TYPE_GLYPHS = {
|
|
147
|
+
"Artifact": "\ue61e",
|
|
148
|
+
"Creature": "\ue61f",
|
|
149
|
+
"Enchantment": "\ue620",
|
|
150
|
+
"Instant": "\ue621",
|
|
151
|
+
"Land": "\ue622",
|
|
152
|
+
"Planeswalker": "\ue623",
|
|
153
|
+
"Sorcery": "\ue624",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
COLOR_STYLES = {
|
|
157
|
+
"W": "bold bright_white",
|
|
158
|
+
"U": "bold dodger_blue1",
|
|
159
|
+
"B": "bold grey50",
|
|
160
|
+
"R": "bold red3",
|
|
161
|
+
"G": "bold green3",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
COLUMN_LABELS = {
|
|
165
|
+
"rank": "#",
|
|
166
|
+
"win_rate": "17L WR",
|
|
167
|
+
"grade": "17L Grade",
|
|
168
|
+
"score": "DO",
|
|
169
|
+
"card": "Card",
|
|
170
|
+
"colors": "Colors",
|
|
171
|
+
"fit": "Fit",
|
|
172
|
+
"gih": "GIH WR",
|
|
173
|
+
"alsa": "ALSA",
|
|
174
|
+
"mv": "MV",
|
|
175
|
+
"source": "Source",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
COLUMN_WIDTHS = {
|
|
179
|
+
"rank": 3,
|
|
180
|
+
"win_rate": 8,
|
|
181
|
+
"grade": 9,
|
|
182
|
+
"score": 5,
|
|
183
|
+
"card": None,
|
|
184
|
+
"colors": 10,
|
|
185
|
+
"fit": 10,
|
|
186
|
+
"gih": 8,
|
|
187
|
+
"alsa": 7,
|
|
188
|
+
"mv": 5,
|
|
189
|
+
"source": 9,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
SORT_LABELS = RANKING_LABELS
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CardDetailsPanel(Static, can_focus=False):
|
|
196
|
+
"""Sidebar panel for the highlighted card.
|
|
197
|
+
Keep focus styling ready for future actions, but do not enter it with Tab yet.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_VISIBILITY_BOOLEAN_OPTIONS = (
|
|
202
|
+
(
|
|
203
|
+
"splash_enabled",
|
|
204
|
+
"Splash recommendations — consider one supported third color for "
|
|
205
|
+
"exceptionally strong cards.",
|
|
206
|
+
),
|
|
207
|
+
(
|
|
208
|
+
"secondary_columns",
|
|
209
|
+
"Secondary pack columns — show Fit, ALSA, mana value, and source on wide "
|
|
210
|
+
"screens.",
|
|
211
|
+
),
|
|
212
|
+
(
|
|
213
|
+
"build_details",
|
|
214
|
+
"Build details — show the picked pool, pair reasoning, checks, and bench "
|
|
215
|
+
"cuts.",
|
|
216
|
+
),
|
|
217
|
+
(
|
|
218
|
+
"pool_metadata",
|
|
219
|
+
"Pool metadata — show set, event, pool size, pairs, and metadata status.",
|
|
220
|
+
),
|
|
221
|
+
(
|
|
222
|
+
"pool_color_distribution",
|
|
223
|
+
"Pool color distribution — show the sidebar color bar.",
|
|
224
|
+
),
|
|
225
|
+
(
|
|
226
|
+
"pool_mana_curve",
|
|
227
|
+
"Mana curve — show pool and detailed-build mana curves.",
|
|
228
|
+
),
|
|
229
|
+
(
|
|
230
|
+
"account_identifier",
|
|
231
|
+
"Account identifier — show the active account and detailed-build account ID.",
|
|
232
|
+
),
|
|
233
|
+
(
|
|
234
|
+
"draft_identifier",
|
|
235
|
+
"Draft identifier — show draft IDs in pool and detailed-build metadata.",
|
|
236
|
+
),
|
|
237
|
+
(
|
|
238
|
+
"mana_pips_and_sources",
|
|
239
|
+
"Mana pips and sources — show detailed-build mana requirements and sources.",
|
|
240
|
+
),
|
|
241
|
+
(
|
|
242
|
+
"attribution",
|
|
243
|
+
"17Lands attribution — show the data-source attribution in the status bar.",
|
|
244
|
+
),
|
|
245
|
+
(
|
|
246
|
+
"focused_card_details",
|
|
247
|
+
"Focused card details — show statistics for the highlighted card in the "
|
|
248
|
+
"sidebar.",
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TuiVisibilityScreen(ModalScreen[TuiVisibilityPreferences | None]):
|
|
254
|
+
"""Modal editor for persisted optional TUI elements.
|
|
255
|
+
Saving returns the selected preferences to the calling application.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
CSS = """
|
|
259
|
+
TuiVisibilityScreen {
|
|
260
|
+
align: center middle;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#visibility-dialog {
|
|
264
|
+
width: 76;
|
|
265
|
+
height: 90%;
|
|
266
|
+
border: thick $accent;
|
|
267
|
+
background: $surface;
|
|
268
|
+
padding: 1 2;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#visibility-options {
|
|
272
|
+
height: 1fr;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.visibility-label {
|
|
276
|
+
height: auto;
|
|
277
|
+
margin-top: 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#visibility-actions {
|
|
281
|
+
height: auto;
|
|
282
|
+
margin-top: 1;
|
|
283
|
+
}
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
|
|
287
|
+
|
|
288
|
+
def __init__(self, *, preferences: TuiVisibilityPreferences) -> None:
|
|
289
|
+
super().__init__()
|
|
290
|
+
self.preferences = preferences
|
|
291
|
+
|
|
292
|
+
def compose(self) -> ComposeResult:
|
|
293
|
+
"""Compose descriptive controls for every persisted visibility preference.
|
|
294
|
+
The scrollable form fits short terminals without hiding any option.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
with Vertical(id="visibility-dialog"):
|
|
298
|
+
yield Static("TUI config", classes="visibility-label")
|
|
299
|
+
yield Static(
|
|
300
|
+
"Choose optional elements. Responsive terminal width can still hide "
|
|
301
|
+
"enabled sections.",
|
|
302
|
+
classes="visibility-label",
|
|
303
|
+
)
|
|
304
|
+
with VerticalScroll(id="visibility-options"):
|
|
305
|
+
for field_name, label in _VISIBILITY_BOOLEAN_OPTIONS:
|
|
306
|
+
yield Static(label, classes="visibility-label")
|
|
307
|
+
yield Switch(
|
|
308
|
+
value=getattr(self.preferences, field_name),
|
|
309
|
+
id=_visibility_control_id(field_name=field_name),
|
|
310
|
+
)
|
|
311
|
+
yield Static(
|
|
312
|
+
"Card image preview — Auto follows terminal detection; Show "
|
|
313
|
+
"requests previews; Hide prevents image loading.",
|
|
314
|
+
classes="visibility-label",
|
|
315
|
+
)
|
|
316
|
+
yield Select(
|
|
317
|
+
(
|
|
318
|
+
("Auto", "auto"),
|
|
319
|
+
("Show", "show"),
|
|
320
|
+
("Hide", "hide"),
|
|
321
|
+
),
|
|
322
|
+
value=self.preferences.card_image_preview,
|
|
323
|
+
allow_blank=False,
|
|
324
|
+
id=_visibility_control_id(field_name="card_image_preview"),
|
|
325
|
+
)
|
|
326
|
+
with Horizontal(id="visibility-actions"):
|
|
327
|
+
yield Button("Save", id="save-visibility", variant="primary")
|
|
328
|
+
yield Button("Cancel", id="cancel-visibility")
|
|
329
|
+
yield Button("Reset defaults", id="reset-visibility")
|
|
330
|
+
|
|
331
|
+
def action_cancel(self) -> None:
|
|
332
|
+
"""Dismiss the dialog without changing active or persisted preferences.
|
|
333
|
+
Escape follows the explicit Cancel action.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
self.dismiss(None)
|
|
337
|
+
|
|
338
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
339
|
+
"""Save, cancel, or reset the form according to its selected action.
|
|
340
|
+
Reset changes the form only until the user confirms Save.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
if event.button.id == "save-visibility":
|
|
344
|
+
self.dismiss(self._selected_preferences())
|
|
345
|
+
elif event.button.id == "cancel-visibility":
|
|
346
|
+
self.dismiss(None)
|
|
347
|
+
elif event.button.id == "reset-visibility":
|
|
348
|
+
self._restore_defaults()
|
|
349
|
+
|
|
350
|
+
def _selected_preferences(self) -> TuiVisibilityPreferences:
|
|
351
|
+
values = {
|
|
352
|
+
field_name: self.query_one(
|
|
353
|
+
f"#{_visibility_control_id(field_name=field_name)}",
|
|
354
|
+
Switch,
|
|
355
|
+
).value
|
|
356
|
+
for field_name, _ in _VISIBILITY_BOOLEAN_OPTIONS
|
|
357
|
+
}
|
|
358
|
+
card_image_preview = self.query_one(
|
|
359
|
+
f"#{_visibility_control_id(field_name='card_image_preview')}",
|
|
360
|
+
Select,
|
|
361
|
+
).value
|
|
362
|
+
return TuiVisibilityPreferences(
|
|
363
|
+
**values,
|
|
364
|
+
card_image_preview=str(card_image_preview),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _restore_defaults(self) -> None:
|
|
368
|
+
defaults = TuiVisibilityPreferences()
|
|
369
|
+
for field_name, _ in _VISIBILITY_BOOLEAN_OPTIONS:
|
|
370
|
+
self.query_one(
|
|
371
|
+
f"#{_visibility_control_id(field_name=field_name)}",
|
|
372
|
+
Switch,
|
|
373
|
+
).value = getattr(defaults, field_name)
|
|
374
|
+
self.query_one(
|
|
375
|
+
f"#{_visibility_control_id(field_name='card_image_preview')}",
|
|
376
|
+
Select,
|
|
377
|
+
).value = defaults.card_image_preview
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _visibility_control_id(*, field_name: str) -> str:
|
|
381
|
+
return f"visibility-{field_name.replace('_', '-')}"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class MissingRatingsScreen(ModalScreen[bool]):
|
|
385
|
+
"""Ask before the first 17Lands download for an uncached set.
|
|
386
|
+
The active draft continues using neutral priors until the user confirms.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
CSS = """
|
|
390
|
+
MissingRatingsScreen {
|
|
391
|
+
align: center middle;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#missing-ratings-dialog {
|
|
395
|
+
width: 68;
|
|
396
|
+
height: auto;
|
|
397
|
+
border: thick $warning;
|
|
398
|
+
background: $surface;
|
|
399
|
+
padding: 1 2;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#missing-ratings-title {
|
|
403
|
+
height: auto;
|
|
404
|
+
text-style: bold;
|
|
405
|
+
margin-bottom: 1;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#missing-ratings-message {
|
|
409
|
+
height: auto;
|
|
410
|
+
margin-bottom: 1;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#missing-ratings-actions {
|
|
414
|
+
height: auto;
|
|
415
|
+
}
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
BINDINGS = [Binding("escape", "cancel", "Not now", show=False)]
|
|
419
|
+
|
|
420
|
+
def __init__(self, *, set_code: str) -> None:
|
|
421
|
+
super().__init__()
|
|
422
|
+
self.set_code = set_code.upper()
|
|
423
|
+
|
|
424
|
+
def compose(self) -> ComposeResult:
|
|
425
|
+
"""Compose the missing-data warning and explicit download choice.
|
|
426
|
+
Downloaded Quick and Premier data will be cached for later drafts.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
with Vertical(id="missing-ratings-dialog"):
|
|
430
|
+
yield Static(
|
|
431
|
+
f"No local 17Lands data for {self.set_code}",
|
|
432
|
+
id="missing-ratings-title",
|
|
433
|
+
)
|
|
434
|
+
yield Static(
|
|
435
|
+
"Draft Omen is using neutral-prior scores. Download the all-time "
|
|
436
|
+
"Quick Draft and Premier fallback ratings now? Progress will be "
|
|
437
|
+
"shown, then the current pack will be rescored automatically.",
|
|
438
|
+
id="missing-ratings-message",
|
|
439
|
+
)
|
|
440
|
+
with Horizontal(id="missing-ratings-actions"):
|
|
441
|
+
yield Button(
|
|
442
|
+
"Download data",
|
|
443
|
+
id="download-ratings",
|
|
444
|
+
variant="primary",
|
|
445
|
+
)
|
|
446
|
+
yield Button("Not now", id="cancel-ratings-download")
|
|
447
|
+
|
|
448
|
+
def action_cancel(self) -> None:
|
|
449
|
+
"""Keep neutral-prior scores without starting a network request.
|
|
450
|
+
The data action remains available if the user changes their mind.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
self.dismiss(False)
|
|
454
|
+
|
|
455
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
456
|
+
"""Return whether the user explicitly approved the download.
|
|
457
|
+
The app owns the worker and progress state after this screen closes.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
self.dismiss(event.button.id == "download-ratings")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class DraftomenTuiApp(App[None]):
|
|
464
|
+
"""Textual app for live Quick Draft recommendations.
|
|
465
|
+
The app can tail a real log or accept fixture lines in tests.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
TITLE = "Draft Omen"
|
|
469
|
+
|
|
470
|
+
CSS = """
|
|
471
|
+
Screen {
|
|
472
|
+
layout: vertical;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#main {
|
|
476
|
+
height: 1fr;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#pack-panel {
|
|
480
|
+
width: 3fr;
|
|
481
|
+
min-width: 0;
|
|
482
|
+
padding: 0 1;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#sidebar {
|
|
486
|
+
width: 1fr;
|
|
487
|
+
min-width: 24;
|
|
488
|
+
padding: 0 1;
|
|
489
|
+
border-left: solid $accent;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#pack-title {
|
|
493
|
+
height: 1;
|
|
494
|
+
text-style: bold;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#pre-draft-readiness {
|
|
498
|
+
height: 1fr;
|
|
499
|
+
content-align: center middle;
|
|
500
|
+
text-align: center;
|
|
501
|
+
text-style: bold;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#pack-table,
|
|
505
|
+
#build-scroll,
|
|
506
|
+
#focused-card {
|
|
507
|
+
border: blank $surface;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
#pack-table:focus,
|
|
511
|
+
#build-scroll:focus,
|
|
512
|
+
#focused-card:focus {
|
|
513
|
+
border: solid $accent;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#pack-table {
|
|
517
|
+
height: 1fr;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#build-scroll {
|
|
521
|
+
height: 1fr;
|
|
522
|
+
overflow-y: auto;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#build-view {
|
|
526
|
+
height: auto;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#pool-summary,
|
|
530
|
+
#focused-card,
|
|
531
|
+
#card-image-preview {
|
|
532
|
+
height: auto;
|
|
533
|
+
margin-bottom: 1;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#card-image-preview {
|
|
537
|
+
content-align: center top;
|
|
538
|
+
margin-top: 1;
|
|
539
|
+
text-align: center;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#status-bar {
|
|
543
|
+
height: 1;
|
|
544
|
+
background: $accent;
|
|
545
|
+
color: $text;
|
|
546
|
+
padding: 0 1;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
#ratings-download-panel {
|
|
550
|
+
height: auto;
|
|
551
|
+
display: none;
|
|
552
|
+
background: $panel;
|
|
553
|
+
padding: 0 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#ratings-download-label {
|
|
557
|
+
height: 1;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#ratings-download-progress {
|
|
561
|
+
height: 1;
|
|
562
|
+
}
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
BINDINGS = [
|
|
566
|
+
Binding("q", "quit", "Quit", show=True),
|
|
567
|
+
Binding("c", "open_config", "Config", show=True),
|
|
568
|
+
Binding("s", "cycle_sort", "Rank", show=True),
|
|
569
|
+
Binding("b", "open_build_view", "Build", show=True),
|
|
570
|
+
Binding("t", "open_backtest_report", "Backtest", show=True),
|
|
571
|
+
Binding("a", "cycle_account", "Account", show=True),
|
|
572
|
+
Binding("p", "rebuild_with_pair_override", "Pair", show=True),
|
|
573
|
+
Binding("m", "toggle_mana_icons", "Mana", show=True),
|
|
574
|
+
Binding("d", "download_ratings", "Data", show=True),
|
|
575
|
+
Binding("up", "navigate_previous_card", "Previous", show=False, priority=True),
|
|
576
|
+
Binding("left", "navigate_previous_card", "Previous", show=False, priority=True),
|
|
577
|
+
Binding("k", "navigate_previous_card", "Previous", show=False, priority=True),
|
|
578
|
+
Binding("down", "navigate_next_card", "Next", show=False, priority=True),
|
|
579
|
+
Binding("right", "navigate_next_card", "Next", show=False, priority=True),
|
|
580
|
+
Binding("j", "navigate_next_card", "Next", show=False, priority=True),
|
|
581
|
+
Binding("pageup", "navigate_page_up", "Page", show=False, priority=True),
|
|
582
|
+
Binding("pagedown", "navigate_page_down", "Page", show=False, priority=True),
|
|
583
|
+
Binding("home", "navigate_home", "Home", show=False, priority=True),
|
|
584
|
+
Binding("end", "navigate_end", "End", show=False, priority=True),
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
def __init__(
|
|
588
|
+
self,
|
|
589
|
+
*,
|
|
590
|
+
log_path: PathInput,
|
|
591
|
+
card_database: CardDatabase | None = None,
|
|
592
|
+
card_database_loader: CardDatabaseLoader | None = None,
|
|
593
|
+
app_dir: PathInput | None = None,
|
|
594
|
+
poll_interval: float = POLL_INTERVAL_SECONDS,
|
|
595
|
+
previous_log_path: PathInput | None = None,
|
|
596
|
+
ratings_loader: RatingsLoader | None = None,
|
|
597
|
+
ratings_loader_factory: RatingsLoaderFactory | None = None,
|
|
598
|
+
ratings_progress_loader: RatingsProgressLoader | None = None,
|
|
599
|
+
ratings_progress_loader_factory: RatingsProgressLoaderFactory | None = None,
|
|
600
|
+
ratings_cache_checker: RatingsCacheChecker | None = None,
|
|
601
|
+
startup_scan: bool = False,
|
|
602
|
+
once: bool = False,
|
|
603
|
+
poll_enabled: bool = True,
|
|
604
|
+
image_preview_enabled: bool | None = None,
|
|
605
|
+
mana_icons_enabled: bool = False,
|
|
606
|
+
card_image_service: CardImageService | None = None,
|
|
607
|
+
visibility_preferences: TuiVisibilityPreferences | None = None,
|
|
608
|
+
splash_enabled: bool | None = None,
|
|
609
|
+
) -> None:
|
|
610
|
+
super().__init__()
|
|
611
|
+
if card_database is None and card_database_loader is None:
|
|
612
|
+
raise ValueError("card_database or card_database_loader is required.")
|
|
613
|
+
if card_database is not None and card_database_loader is not None:
|
|
614
|
+
raise ValueError("card_database and card_database_loader are mutually exclusive.")
|
|
615
|
+
configured_ratings_loaders = sum(
|
|
616
|
+
loader is not None
|
|
617
|
+
for loader in (
|
|
618
|
+
ratings_loader,
|
|
619
|
+
ratings_loader_factory,
|
|
620
|
+
ratings_progress_loader,
|
|
621
|
+
ratings_progress_loader_factory,
|
|
622
|
+
)
|
|
623
|
+
)
|
|
624
|
+
if configured_ratings_loaders > 1:
|
|
625
|
+
raise ValueError("Configure exactly one ratings loader or loader factory.")
|
|
626
|
+
self._ratings_operations_may_block = configured_ratings_loaders > 0
|
|
627
|
+
|
|
628
|
+
self.log_path = Path(log_path).expanduser().resolve(strict=False)
|
|
629
|
+
self._preferences_app_dir = app_dir
|
|
630
|
+
if visibility_preferences is None:
|
|
631
|
+
(
|
|
632
|
+
self.visibility_preferences,
|
|
633
|
+
self._preferences_load_warning,
|
|
634
|
+
) = load_tui_preferences(app_dir=app_dir)
|
|
635
|
+
else:
|
|
636
|
+
self.visibility_preferences = visibility_preferences
|
|
637
|
+
self._preferences_load_warning = None
|
|
638
|
+
if splash_enabled is not None:
|
|
639
|
+
self.visibility_preferences = replace(
|
|
640
|
+
self.visibility_preferences,
|
|
641
|
+
splash_enabled=splash_enabled,
|
|
642
|
+
)
|
|
643
|
+
self._preferences_save_warning: str | None = None
|
|
644
|
+
self._card_database_error: str | None = None
|
|
645
|
+
self._log_processing_started = False
|
|
646
|
+
self._ingestion_lock = Lock()
|
|
647
|
+
self._textual_thread_id: int | None = None
|
|
648
|
+
self.session = LiveSession(
|
|
649
|
+
log_path=self.log_path,
|
|
650
|
+
card_database=card_database,
|
|
651
|
+
card_database_loader=card_database_loader,
|
|
652
|
+
app_dir=app_dir,
|
|
653
|
+
poll_interval=poll_interval,
|
|
654
|
+
previous_log_path=previous_log_path,
|
|
655
|
+
snapshot_publisher=self._publish_session_snapshot,
|
|
656
|
+
event_publisher=self._publish_session_event,
|
|
657
|
+
ratings_loader=ratings_loader,
|
|
658
|
+
ratings_loader_factory=ratings_loader_factory,
|
|
659
|
+
ratings_progress_loader=ratings_progress_loader,
|
|
660
|
+
ratings_progress_loader_factory=ratings_progress_loader_factory,
|
|
661
|
+
ratings_cache_checker=ratings_cache_checker,
|
|
662
|
+
splash_enabled=self.visibility_preferences.splash_enabled,
|
|
663
|
+
)
|
|
664
|
+
self.startup_scan = startup_scan
|
|
665
|
+
self.once = once
|
|
666
|
+
self.poll_enabled = poll_enabled
|
|
667
|
+
self.poll_interval = poll_interval
|
|
668
|
+
self._card_image_service = card_image_service or CardImageService(
|
|
669
|
+
cache_dir=card_image_cache_dir(app_dir=app_dir),
|
|
670
|
+
)
|
|
671
|
+
self._card_image_preview_enabled = (
|
|
672
|
+
_card_image_preview_enabled(env=os.environ)
|
|
673
|
+
if image_preview_enabled is None
|
|
674
|
+
else image_preview_enabled
|
|
675
|
+
)
|
|
676
|
+
self.mana_icons_enabled = mana_icons_enabled
|
|
677
|
+
self._card_image_paths_by_uri: dict[str, Path] = {}
|
|
678
|
+
self._card_image_failures_by_uri: dict[str, str] = {}
|
|
679
|
+
self._loading_card_image_uris: set[str] = set()
|
|
680
|
+
self._card_image_uris_by_grp_id: dict[int, str] = {}
|
|
681
|
+
|
|
682
|
+
self.show_secondary_columns = self.visibility_preferences.secondary_columns
|
|
683
|
+
self.sort_mode = DEFAULT_RANKING_MODE
|
|
684
|
+
self._view_mode = "pack"
|
|
685
|
+
self._visible_column_keys: tuple[str, ...] = ()
|
|
686
|
+
self._active_account_id: str | None = None
|
|
687
|
+
self._active_account_label = "unknown"
|
|
688
|
+
self._event_name: str | None = None
|
|
689
|
+
self._set_code: str | None = None
|
|
690
|
+
self._draft_id: str | None = None
|
|
691
|
+
self._pick_label = "—"
|
|
692
|
+
self._pool_size = 0
|
|
693
|
+
self._pool_grp_ids: tuple[int, ...] = ()
|
|
694
|
+
self._pair_label = "open"
|
|
695
|
+
self._commitment_label = "0% open"
|
|
696
|
+
self._data_source = "unknown"
|
|
697
|
+
self._last_error: str | None = None
|
|
698
|
+
self._session_error: str | None = None
|
|
699
|
+
self._forced_pair: str | None = None
|
|
700
|
+
self._build_pair_label = "—"
|
|
701
|
+
self._build_text = "Build view: no picked cards yet."
|
|
702
|
+
self._build_error: str | None = None
|
|
703
|
+
self._build_action_status: str | None = None
|
|
704
|
+
self._pending_build_success_message: str | None = None
|
|
705
|
+
self._build_spell_sort_mode = "curve"
|
|
706
|
+
self._build_show_details = self.visibility_preferences.build_details
|
|
707
|
+
self._build_focus_cards: tuple[TuiCardQuantityGroup, ...] = ()
|
|
708
|
+
self._build_focused_card_index = 0
|
|
709
|
+
self._build_result: BuildResult | None = None
|
|
710
|
+
self._backtest_text = "Backtest view: complete a draft, then press t."
|
|
711
|
+
self._backtest_error: str | None = None
|
|
712
|
+
self._backtest_action_status: str | None = None
|
|
713
|
+
self._pending_backtest_success_message: str | None = None
|
|
714
|
+
self._open_backtest_when_ready = False
|
|
715
|
+
self._current_pack_event: PackOfferedEvent | None = None
|
|
716
|
+
self._current_pack: ScoredPack | None = None
|
|
717
|
+
self._rating_prompted_sets: set[str] = set()
|
|
718
|
+
self._rating_prompt_open_sets: set[str] = set()
|
|
719
|
+
self._rating_notices_by_set: dict[str, str] = {}
|
|
720
|
+
self._rating_download_requested_sets: set[str] = set()
|
|
721
|
+
|
|
722
|
+
@property
|
|
723
|
+
def card_database(self) -> CardDatabase:
|
|
724
|
+
"""Return loaded card metadata for scoring and rendering.
|
|
725
|
+
Startup code must wait for the loader worker before accessing it.
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
database = self.session.card_database
|
|
729
|
+
if database is None:
|
|
730
|
+
raise RuntimeError("Card metadata is not ready.")
|
|
731
|
+
|
|
732
|
+
return database
|
|
733
|
+
|
|
734
|
+
@property
|
|
735
|
+
def card_database_loading(self) -> bool:
|
|
736
|
+
"""Return whether a card metadata worker is currently running.
|
|
737
|
+
Tests use this to verify startup work stays outside the render loop.
|
|
738
|
+
"""
|
|
739
|
+
|
|
740
|
+
return self.session.snapshot.card_data.phase in {
|
|
741
|
+
DataLoadPhase.IDLE,
|
|
742
|
+
DataLoadPhase.LOADING,
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
@property
|
|
746
|
+
def visible_column_keys(self) -> tuple[str, ...]:
|
|
747
|
+
"""Return current pack-table columns for tests.
|
|
748
|
+
The value reflects width-based degradation and user toggles.
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
return self._visible_column_keys
|
|
752
|
+
|
|
753
|
+
@property
|
|
754
|
+
def loading_rating_sets(self) -> frozenset[str]:
|
|
755
|
+
"""Return set codes currently refreshing in a worker.
|
|
756
|
+
Tests use this to confirm slow loads stay off the render loop.
|
|
757
|
+
"""
|
|
758
|
+
|
|
759
|
+
ratings = self.session.snapshot.ratings
|
|
760
|
+
if ratings.phase != DataLoadPhase.LOADING or ratings.set_code is None:
|
|
761
|
+
return frozenset()
|
|
762
|
+
|
|
763
|
+
return frozenset((ratings.set_code,))
|
|
764
|
+
|
|
765
|
+
@property
|
|
766
|
+
def build_view_text(self) -> str:
|
|
767
|
+
"""Return the rendered build view text for pilot tests.
|
|
768
|
+
This mirrors the Static widget content after a build refresh.
|
|
769
|
+
"""
|
|
770
|
+
|
|
771
|
+
return self._build_text
|
|
772
|
+
|
|
773
|
+
@property
|
|
774
|
+
def backtest_view_text(self) -> str:
|
|
775
|
+
"""Return the rendered backtest report text for pilot tests.
|
|
776
|
+
This mirrors the Static widget content after a report refresh.
|
|
777
|
+
"""
|
|
778
|
+
|
|
779
|
+
return self._backtest_text
|
|
780
|
+
|
|
781
|
+
def compose(self) -> ComposeResult:
|
|
782
|
+
"""Compose the pack table, sidebar, status bar, and key footer.
|
|
783
|
+
Textual's Footer automatically renders the declared keybindings.
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
yield Header(show_clock=False)
|
|
787
|
+
with Horizontal(id="main"):
|
|
788
|
+
with Vertical(id="pack-panel"):
|
|
789
|
+
yield Static("Waiting for a Quick Draft pack…", id="pack-title")
|
|
790
|
+
yield Static(
|
|
791
|
+
"Quick Draft set not detected yet.",
|
|
792
|
+
id="pre-draft-readiness",
|
|
793
|
+
)
|
|
794
|
+
yield DataTable(id="pack-table")
|
|
795
|
+
with VerticalScroll(id="build-scroll", can_focus=True):
|
|
796
|
+
yield Static("Build view: no picked cards yet.", id="build-view")
|
|
797
|
+
with Vertical(id="sidebar"):
|
|
798
|
+
yield Static("Pool: no draft yet", id="pool-summary")
|
|
799
|
+
yield Static("", id="card-image-preview")
|
|
800
|
+
yield CardDetailsPanel("Focused card: none", id="focused-card")
|
|
801
|
+
with Vertical(id="ratings-download-panel"):
|
|
802
|
+
yield Static("", id="ratings-download-label")
|
|
803
|
+
yield ProgressBar(
|
|
804
|
+
total=4,
|
|
805
|
+
show_eta=False,
|
|
806
|
+
id="ratings-download-progress",
|
|
807
|
+
)
|
|
808
|
+
yield Static("", id="status-bar")
|
|
809
|
+
yield Footer()
|
|
810
|
+
|
|
811
|
+
def on_mount(self) -> None:
|
|
812
|
+
"""Render the shell before starting metadata and log workers.
|
|
813
|
+
Log processing begins only after card data is available for scoring.
|
|
814
|
+
"""
|
|
815
|
+
|
|
816
|
+
self._textual_thread_id = get_ident()
|
|
817
|
+
table = self.query_one("#pack-table", DataTable)
|
|
818
|
+
table.cursor_type = "row"
|
|
819
|
+
table.zebra_stripes = True
|
|
820
|
+
table.focus()
|
|
821
|
+
self._render_all()
|
|
822
|
+
|
|
823
|
+
if self.card_database_loading:
|
|
824
|
+
self._load_card_database_worker()
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
self._start_log_processing()
|
|
828
|
+
|
|
829
|
+
def _start_log_processing(self) -> None:
|
|
830
|
+
"""Start polling only once card metadata is safe to consume.
|
|
831
|
+
Deferring this preserves all events for normal scoring and recommendations.
|
|
832
|
+
"""
|
|
833
|
+
|
|
834
|
+
if not self.poll_enabled or self._log_processing_started:
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
self._log_processing_started = True
|
|
838
|
+
if self.startup_scan:
|
|
839
|
+
self._scan_startup_files_worker(exit_after=self.once)
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
self._start_polling()
|
|
843
|
+
|
|
844
|
+
def _start_polling(self) -> None:
|
|
845
|
+
"""Begin polling only after optional startup recovery has finished.
|
|
846
|
+
The first poll catches lines appended while startup recovery was running.
|
|
847
|
+
"""
|
|
848
|
+
|
|
849
|
+
self._poll_log_worker(exit_after=self.once)
|
|
850
|
+
if not self.once:
|
|
851
|
+
self.set_interval(self.poll_interval, self._poll_log_worker)
|
|
852
|
+
|
|
853
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
854
|
+
"""Rebuild the table when width crosses a degradation threshold.
|
|
855
|
+
Secondary columns are hidden first on narrow terminals.
|
|
856
|
+
"""
|
|
857
|
+
|
|
858
|
+
self._render_all()
|
|
859
|
+
|
|
860
|
+
def on_data_table_row_highlighted(
|
|
861
|
+
self,
|
|
862
|
+
event: DataTable.RowHighlighted,
|
|
863
|
+
) -> None:
|
|
864
|
+
"""Refresh card details when keyboard navigation changes rows.
|
|
865
|
+
Periodic log polls should not be needed before details update.
|
|
866
|
+
"""
|
|
867
|
+
|
|
868
|
+
if event.data_table.id == "pack-table":
|
|
869
|
+
self._render_focused_card_details()
|
|
870
|
+
|
|
871
|
+
def action_open_config(self) -> None:
|
|
872
|
+
"""Open the in-app editor for persisted optional TUI elements.
|
|
873
|
+
The callback applies only settings that the user explicitly saves.
|
|
874
|
+
"""
|
|
875
|
+
|
|
876
|
+
self.push_screen(
|
|
877
|
+
TuiVisibilityScreen(preferences=self.visibility_preferences),
|
|
878
|
+
self._apply_visibility_preferences,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
def _apply_visibility_preferences(
|
|
882
|
+
self,
|
|
883
|
+
preferences: TuiVisibilityPreferences | None,
|
|
884
|
+
) -> None:
|
|
885
|
+
if preferences is None:
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
splash_changed = (
|
|
889
|
+
self.visibility_preferences.splash_enabled != preferences.splash_enabled
|
|
890
|
+
)
|
|
891
|
+
had_build_result = self._build_result is not None
|
|
892
|
+
self.visibility_preferences = preferences
|
|
893
|
+
self.show_secondary_columns = preferences.secondary_columns
|
|
894
|
+
self._build_show_details = preferences.build_details
|
|
895
|
+
self._save_visibility_preferences()
|
|
896
|
+
self.session.dispatch(
|
|
897
|
+
command=ChangeSplashPreference(enabled=preferences.splash_enabled),
|
|
898
|
+
)
|
|
899
|
+
if had_build_result and splash_changed:
|
|
900
|
+
self._request_build_view(success_message=None)
|
|
901
|
+
elif self._build_result is not None:
|
|
902
|
+
self._refresh_build_text_from_result()
|
|
903
|
+
self._render_all()
|
|
904
|
+
|
|
905
|
+
def _save_visibility_preferences(self) -> None:
|
|
906
|
+
self._preferences_save_warning = save_tui_preferences(
|
|
907
|
+
preferences=self.visibility_preferences,
|
|
908
|
+
app_dir=self._preferences_app_dir,
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def action_toggle_mana_icons(self) -> None:
|
|
912
|
+
"""Toggle opt-in Mana font icon rendering.
|
|
913
|
+
Terminals need Andrew Gioia's Mana font installed before enabling it.
|
|
914
|
+
"""
|
|
915
|
+
|
|
916
|
+
self.mana_icons_enabled = not self.mana_icons_enabled
|
|
917
|
+
if self._view_mode == "build":
|
|
918
|
+
self._refresh_build_text_from_result()
|
|
919
|
+
|
|
920
|
+
self._render_all()
|
|
921
|
+
|
|
922
|
+
def action_download_ratings(self) -> None:
|
|
923
|
+
"""Offer or retry the ratings download for the active draft set.
|
|
924
|
+
Existing ready data is left untouched.
|
|
925
|
+
"""
|
|
926
|
+
|
|
927
|
+
set_code = self._set_code
|
|
928
|
+
if set_code is None:
|
|
929
|
+
self._last_error = "No active draft set is available for a data download."
|
|
930
|
+
self._render_all()
|
|
931
|
+
return
|
|
932
|
+
|
|
933
|
+
ratings = self.session.snapshot.ratings
|
|
934
|
+
if ratings.phase == DataLoadPhase.LOADING:
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
if ratings.phase == DataLoadPhase.READY:
|
|
938
|
+
self._rating_notices_by_set[set_code] = (
|
|
939
|
+
f"17Lands data is already ready for {set_code}."
|
|
940
|
+
)
|
|
941
|
+
self._render_all()
|
|
942
|
+
return
|
|
943
|
+
|
|
944
|
+
self._show_missing_ratings_prompt(set_code=set_code, force=True)
|
|
945
|
+
|
|
946
|
+
def action_cycle_sort(self) -> None:
|
|
947
|
+
"""Cycle pack ranking or build spell grouping.
|
|
948
|
+
Backtest reports are rebuilt with the selected recommendation ranking.
|
|
949
|
+
"""
|
|
950
|
+
|
|
951
|
+
if self._view_mode == "build":
|
|
952
|
+
index = BUILD_SPELL_SORT_MODES.index(self._build_spell_sort_mode)
|
|
953
|
+
self._build_spell_sort_mode = BUILD_SPELL_SORT_MODES[
|
|
954
|
+
(index + 1) % len(BUILD_SPELL_SORT_MODES)
|
|
955
|
+
]
|
|
956
|
+
self._refresh_build_text_from_result()
|
|
957
|
+
else:
|
|
958
|
+
index = SORT_MODES.index(self.sort_mode)
|
|
959
|
+
self.sort_mode = SORT_MODES[(index + 1) % len(SORT_MODES)]
|
|
960
|
+
self.session.dispatch(
|
|
961
|
+
command=ChangeRanking(ranking_mode=self.sort_mode),
|
|
962
|
+
)
|
|
963
|
+
if self._view_mode == "backtest":
|
|
964
|
+
self._request_backtest_view(
|
|
965
|
+
success_message=(
|
|
966
|
+
f"rebuilt {ranking_label(ranking_mode=self.sort_mode)} "
|
|
967
|
+
"recommendation comparison"
|
|
968
|
+
),
|
|
969
|
+
open_when_ready=False,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
self._render_all()
|
|
973
|
+
|
|
974
|
+
def action_open_build_view(self) -> None:
|
|
975
|
+
"""Request a build for the current pool and show the shared result."""
|
|
976
|
+
|
|
977
|
+
current_build = self.session.snapshot.build
|
|
978
|
+
if (
|
|
979
|
+
self._view_mode == "build"
|
|
980
|
+
and self._build_error is None
|
|
981
|
+
and current_build is not None
|
|
982
|
+
and current_build.pair_override == self._forced_pair
|
|
983
|
+
):
|
|
984
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_home(animate=False)
|
|
985
|
+
self._last_error = None
|
|
986
|
+
self._build_action_status = "no build needed — current pool already shown"
|
|
987
|
+
self._render_all()
|
|
988
|
+
return
|
|
989
|
+
|
|
990
|
+
self._request_build_view(success_message="rebuilt current pool")
|
|
991
|
+
self._render_all()
|
|
992
|
+
|
|
993
|
+
def action_open_backtest_report(self) -> None:
|
|
994
|
+
"""Request the shared persisted-pick recommendation comparison."""
|
|
995
|
+
|
|
996
|
+
self._build_action_status = None
|
|
997
|
+
self._request_backtest_view(
|
|
998
|
+
success_message=(
|
|
999
|
+
f"rebuilt {ranking_label(ranking_mode=self.sort_mode)} "
|
|
1000
|
+
"recommendation comparison"
|
|
1001
|
+
),
|
|
1002
|
+
open_when_ready=True,
|
|
1003
|
+
)
|
|
1004
|
+
self._render_all()
|
|
1005
|
+
|
|
1006
|
+
def action_navigate_previous_card(self) -> None:
|
|
1007
|
+
"""Move to the previous card in the active card list.
|
|
1008
|
+
Left, Up, and k share this action for predictable keyboard browsing.
|
|
1009
|
+
"""
|
|
1010
|
+
|
|
1011
|
+
if self._view_mode == "build":
|
|
1012
|
+
self._move_build_card_cursor(delta=-1)
|
|
1013
|
+
return
|
|
1014
|
+
|
|
1015
|
+
if self._view_mode == "backtest":
|
|
1016
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_page_up(animate=False)
|
|
1017
|
+
return
|
|
1018
|
+
|
|
1019
|
+
self._move_pack_cursor(delta=-1)
|
|
1020
|
+
|
|
1021
|
+
def action_navigate_next_card(self) -> None:
|
|
1022
|
+
"""Move to the next card in the active card list.
|
|
1023
|
+
Right, Down, and j share this action for predictable keyboard browsing.
|
|
1024
|
+
"""
|
|
1025
|
+
|
|
1026
|
+
if self._view_mode == "build":
|
|
1027
|
+
self._move_build_card_cursor(delta=1)
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
if self._view_mode == "backtest":
|
|
1031
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_page_down(animate=False)
|
|
1032
|
+
return
|
|
1033
|
+
|
|
1034
|
+
self._move_pack_cursor(delta=1)
|
|
1035
|
+
|
|
1036
|
+
def action_navigate_page_up(self) -> None:
|
|
1037
|
+
"""Page up in the current keyboard-navigation context.
|
|
1038
|
+
Pack view moves the card cursor; build view scrolls the deck sheet.
|
|
1039
|
+
"""
|
|
1040
|
+
|
|
1041
|
+
if self._view_mode in {"build", "backtest"}:
|
|
1042
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_page_up(animate=False)
|
|
1043
|
+
return
|
|
1044
|
+
|
|
1045
|
+
self._move_pack_cursor(delta=-self._pack_cursor_page_size())
|
|
1046
|
+
|
|
1047
|
+
def action_navigate_page_down(self) -> None:
|
|
1048
|
+
"""Page down in the current keyboard-navigation context.
|
|
1049
|
+
Pack view moves the card cursor; build view scrolls the deck sheet.
|
|
1050
|
+
"""
|
|
1051
|
+
|
|
1052
|
+
if self._view_mode in {"build", "backtest"}:
|
|
1053
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_page_down(animate=False)
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
self._move_pack_cursor(delta=self._pack_cursor_page_size())
|
|
1057
|
+
|
|
1058
|
+
def action_navigate_home(self) -> None:
|
|
1059
|
+
"""Jump to the first pack card or the top of the build view.
|
|
1060
|
+
This keeps Home useful in both major TUI modes.
|
|
1061
|
+
"""
|
|
1062
|
+
|
|
1063
|
+
if self._view_mode in {"build", "backtest"}:
|
|
1064
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_home(animate=False)
|
|
1065
|
+
return
|
|
1066
|
+
|
|
1067
|
+
self._move_pack_cursor_to(row=0)
|
|
1068
|
+
|
|
1069
|
+
def action_navigate_end(self) -> None:
|
|
1070
|
+
"""Jump to the last pack card or the bottom of the build view.
|
|
1071
|
+
This keeps End useful in both major TUI modes.
|
|
1072
|
+
"""
|
|
1073
|
+
|
|
1074
|
+
if self._view_mode in {"build", "backtest"}:
|
|
1075
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_end(animate=False)
|
|
1076
|
+
return
|
|
1077
|
+
|
|
1078
|
+
table = self.query_one("#pack-table", DataTable)
|
|
1079
|
+
self._move_pack_cursor_to(row=table.row_count - 1)
|
|
1080
|
+
|
|
1081
|
+
def action_cycle_account(self) -> None:
|
|
1082
|
+
"""Cycle known accounts and use the latest recovered draft when available.
|
|
1083
|
+
Multiple saved drafts for one account must not create duplicate stops.
|
|
1084
|
+
"""
|
|
1085
|
+
|
|
1086
|
+
accounts = self.session.known_accounts()
|
|
1087
|
+
if not accounts:
|
|
1088
|
+
self._record_error("no known accounts to switch to")
|
|
1089
|
+
return
|
|
1090
|
+
|
|
1091
|
+
ordered_account_ids = tuple(account.account_id for account in accounts)
|
|
1092
|
+
if self._active_account_id in ordered_account_ids:
|
|
1093
|
+
index = (ordered_account_ids.index(self._active_account_id) + 1) % len(
|
|
1094
|
+
ordered_account_ids
|
|
1095
|
+
)
|
|
1096
|
+
else:
|
|
1097
|
+
index = 0
|
|
1098
|
+
|
|
1099
|
+
account_id = ordered_account_ids[index]
|
|
1100
|
+
command = ChooseAccount(account_id=account_id)
|
|
1101
|
+
if self._ratings_operations_may_block:
|
|
1102
|
+
self._dispatch_session_command_worker(command)
|
|
1103
|
+
else:
|
|
1104
|
+
self.session.dispatch(command=command)
|
|
1105
|
+
|
|
1106
|
+
def action_rebuild_with_pair_override(self) -> None:
|
|
1107
|
+
"""Request a shared build after cycling the TUI pair override."""
|
|
1108
|
+
|
|
1109
|
+
self._forced_pair = self._next_forced_pair()
|
|
1110
|
+
self._request_build_view(
|
|
1111
|
+
success_message=f"rebuilt with forced pair {self._forced_pair}",
|
|
1112
|
+
)
|
|
1113
|
+
self._render_all()
|
|
1114
|
+
|
|
1115
|
+
def process_lines(
|
|
1116
|
+
self,
|
|
1117
|
+
*,
|
|
1118
|
+
lines: Iterable[str],
|
|
1119
|
+
include_pre_draft_detection: bool = True,
|
|
1120
|
+
) -> None:
|
|
1121
|
+
"""Process complete Player.log lines and refresh the TUI.
|
|
1122
|
+
Tests call this directly to simulate a live fixture stream.
|
|
1123
|
+
"""
|
|
1124
|
+
|
|
1125
|
+
if self.card_database_loading or self._card_database_error is not None:
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
line_batch = tuple(lines)
|
|
1129
|
+
if (
|
|
1130
|
+
self.is_running
|
|
1131
|
+
and line_batch
|
|
1132
|
+
and self._ratings_operations_may_block
|
|
1133
|
+
):
|
|
1134
|
+
self._process_lines_worker(
|
|
1135
|
+
lines=line_batch,
|
|
1136
|
+
include_pre_draft_detection=include_pre_draft_detection,
|
|
1137
|
+
)
|
|
1138
|
+
return
|
|
1139
|
+
|
|
1140
|
+
try:
|
|
1141
|
+
with self._ingestion_lock:
|
|
1142
|
+
self.session.process_lines(
|
|
1143
|
+
lines=line_batch,
|
|
1144
|
+
include_pre_draft_detection=include_pre_draft_detection,
|
|
1145
|
+
)
|
|
1146
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
1147
|
+
self._record_error(str(error))
|
|
1148
|
+
return
|
|
1149
|
+
|
|
1150
|
+
self._render_all()
|
|
1151
|
+
|
|
1152
|
+
@work(thread=True, exclusive=True, group="session-ingestion")
|
|
1153
|
+
def _process_lines_worker(
|
|
1154
|
+
self,
|
|
1155
|
+
*,
|
|
1156
|
+
lines: tuple[str, ...],
|
|
1157
|
+
include_pre_draft_detection: bool,
|
|
1158
|
+
) -> None:
|
|
1159
|
+
"""Run shared ingestion and ratings work away from Textual's thread.
|
|
1160
|
+
Session publications marshal immutable state back to the adapter.
|
|
1161
|
+
"""
|
|
1162
|
+
|
|
1163
|
+
worker = get_current_worker()
|
|
1164
|
+
try:
|
|
1165
|
+
with self._ingestion_lock:
|
|
1166
|
+
if worker.is_cancelled:
|
|
1167
|
+
return
|
|
1168
|
+
self.session.process_lines(
|
|
1169
|
+
lines=lines,
|
|
1170
|
+
include_pre_draft_detection=include_pre_draft_detection,
|
|
1171
|
+
)
|
|
1172
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
1173
|
+
self.call_from_thread(self._record_error, str(error))
|
|
1174
|
+
|
|
1175
|
+
@work(thread=True, exclusive=True, group="card-database")
|
|
1176
|
+
def _load_card_database_worker(self) -> None:
|
|
1177
|
+
"""Ask the shared session to load metadata outside the Textual thread.
|
|
1178
|
+
Published readiness and errors are marshalled back as immutable state.
|
|
1179
|
+
"""
|
|
1180
|
+
|
|
1181
|
+
worker = get_current_worker()
|
|
1182
|
+
if worker.is_cancelled:
|
|
1183
|
+
return
|
|
1184
|
+
|
|
1185
|
+
try:
|
|
1186
|
+
self.session.load_card_data()
|
|
1187
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
1188
|
+
self.call_from_thread(self._record_error, str(error))
|
|
1189
|
+
|
|
1190
|
+
@work(thread=True, exclusive=True, group="session-ingestion")
|
|
1191
|
+
def _poll_log_worker(self, *, exit_after: bool = False) -> None:
|
|
1192
|
+
"""Schedule one shared-session polling cycle in a Textual worker.
|
|
1193
|
+
Session publications marshal state changes onto the Textual thread.
|
|
1194
|
+
"""
|
|
1195
|
+
|
|
1196
|
+
worker = get_current_worker()
|
|
1197
|
+
try:
|
|
1198
|
+
with self._ingestion_lock:
|
|
1199
|
+
if worker.is_cancelled:
|
|
1200
|
+
return
|
|
1201
|
+
self.session.poll_once()
|
|
1202
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
1203
|
+
self.call_from_thread(self._record_error, str(error))
|
|
1204
|
+
if exit_after:
|
|
1205
|
+
self.call_from_thread(self.exit)
|
|
1206
|
+
return
|
|
1207
|
+
|
|
1208
|
+
if exit_after:
|
|
1209
|
+
self.call_from_thread(self.exit)
|
|
1210
|
+
|
|
1211
|
+
@work(thread=True, exclusive=True, group="session-ingestion")
|
|
1212
|
+
def _scan_startup_files_worker(self, *, exit_after: bool = False) -> None:
|
|
1213
|
+
"""Schedule shared startup recovery in a Textual worker.
|
|
1214
|
+
Historical pre-draft detection remains excluded from the live surface.
|
|
1215
|
+
"""
|
|
1216
|
+
|
|
1217
|
+
worker = get_current_worker()
|
|
1218
|
+
try:
|
|
1219
|
+
with self._ingestion_lock:
|
|
1220
|
+
if worker.is_cancelled:
|
|
1221
|
+
return
|
|
1222
|
+
self.session.scan_startup_files(
|
|
1223
|
+
include_previous=True,
|
|
1224
|
+
include_pre_draft_detection=False,
|
|
1225
|
+
)
|
|
1226
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
1227
|
+
self.call_from_thread(self._record_error, str(error))
|
|
1228
|
+
if exit_after:
|
|
1229
|
+
self.call_from_thread(self.exit)
|
|
1230
|
+
return
|
|
1231
|
+
|
|
1232
|
+
if exit_after:
|
|
1233
|
+
self.call_from_thread(self.exit)
|
|
1234
|
+
return
|
|
1235
|
+
|
|
1236
|
+
self.call_from_thread(self._start_polling)
|
|
1237
|
+
|
|
1238
|
+
@work(thread=True, group="session-commands")
|
|
1239
|
+
def _dispatch_session_command_worker(
|
|
1240
|
+
self,
|
|
1241
|
+
command: LiveSessionCommand,
|
|
1242
|
+
) -> None:
|
|
1243
|
+
"""Dispatch a potentially blocking shared command in a worker.
|
|
1244
|
+
Session progress and results return through immutable publications.
|
|
1245
|
+
"""
|
|
1246
|
+
|
|
1247
|
+
worker = get_current_worker()
|
|
1248
|
+
if worker.is_cancelled:
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
try:
|
|
1252
|
+
self.session.dispatch(command=command)
|
|
1253
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
1254
|
+
self.call_from_thread(self._record_error, str(error))
|
|
1255
|
+
|
|
1256
|
+
def _session_publication_is_allowed(self) -> bool:
|
|
1257
|
+
"""Allow presentation updates only while Textual is running."""
|
|
1258
|
+
return self.is_running
|
|
1259
|
+
|
|
1260
|
+
def _publish_session_snapshot(self, snapshot: LiveSessionSnapshot) -> None:
|
|
1261
|
+
"""Marshal one immutable shared snapshot onto Textual's thread.
|
|
1262
|
+
Pack projections travel with the snapshot so account state stays coherent.
|
|
1263
|
+
"""
|
|
1264
|
+
|
|
1265
|
+
if not self._session_publication_is_allowed():
|
|
1266
|
+
return
|
|
1267
|
+
if (
|
|
1268
|
+
self.is_running
|
|
1269
|
+
and self._textual_thread_id is not None
|
|
1270
|
+
and get_ident() != self._textual_thread_id
|
|
1271
|
+
):
|
|
1272
|
+
self.call_from_thread(
|
|
1273
|
+
self._apply_session_snapshot,
|
|
1274
|
+
snapshot,
|
|
1275
|
+
)
|
|
1276
|
+
return
|
|
1277
|
+
|
|
1278
|
+
self._apply_session_snapshot(snapshot)
|
|
1279
|
+
|
|
1280
|
+
def _publish_session_event(self, published: LiveSessionEvent) -> None:
|
|
1281
|
+
"""Marshal an ordered domain event onto Textual's presentation thread.
|
|
1282
|
+
Events drive view transitions without re-owning application state.
|
|
1283
|
+
"""
|
|
1284
|
+
|
|
1285
|
+
if not self._session_publication_is_allowed():
|
|
1286
|
+
return
|
|
1287
|
+
if (
|
|
1288
|
+
self.is_running
|
|
1289
|
+
and self._textual_thread_id is not None
|
|
1290
|
+
and get_ident() != self._textual_thread_id
|
|
1291
|
+
):
|
|
1292
|
+
self.call_from_thread(self._apply_session_event, published)
|
|
1293
|
+
return
|
|
1294
|
+
|
|
1295
|
+
self._apply_session_event(published)
|
|
1296
|
+
|
|
1297
|
+
def _apply_session_snapshot(
|
|
1298
|
+
self,
|
|
1299
|
+
snapshot: LiveSessionSnapshot,
|
|
1300
|
+
) -> None:
|
|
1301
|
+
if not self._session_publication_is_allowed():
|
|
1302
|
+
return
|
|
1303
|
+
previous_identity = (self._active_account_id, self._draft_id)
|
|
1304
|
+
account = snapshot.active_account
|
|
1305
|
+
draft = snapshot.draft
|
|
1306
|
+
self._active_account_id = None if account is None else account.account_id
|
|
1307
|
+
self._active_account_label = self._account_label(
|
|
1308
|
+
account_id=self._active_account_id,
|
|
1309
|
+
snapshot=snapshot,
|
|
1310
|
+
)
|
|
1311
|
+
self._event_name = None if draft is None else draft.event_name
|
|
1312
|
+
self._set_code = snapshot.ratings.set_code
|
|
1313
|
+
if self._set_code is None and draft is not None:
|
|
1314
|
+
self._set_code = draft.set_code
|
|
1315
|
+
self._draft_id = None if draft is None else draft.draft_id
|
|
1316
|
+
self._current_pack_event = snapshot.current_pack_event
|
|
1317
|
+
self._current_pack = snapshot.current_scored_pack
|
|
1318
|
+
if snapshot.status.phase == ApplicationPhase.DRAFT_COMPLETE:
|
|
1319
|
+
self._pick_label = "complete"
|
|
1320
|
+
elif snapshot.current_pack_event is not None:
|
|
1321
|
+
self._pick_label = (
|
|
1322
|
+
f"P{snapshot.current_pack_event.pack_number + 1}"
|
|
1323
|
+
f"P{snapshot.current_pack_event.pick_number + 1}"
|
|
1324
|
+
)
|
|
1325
|
+
elif draft is not None:
|
|
1326
|
+
self._pick_label = "recovered"
|
|
1327
|
+
elif snapshot.ratings.set_code is not None:
|
|
1328
|
+
self._pick_label = "preparing"
|
|
1329
|
+
else:
|
|
1330
|
+
self._pick_label = "—"
|
|
1331
|
+
self._pool_grp_ids = self._snapshot_pool_grp_ids(snapshot=snapshot)
|
|
1332
|
+
self._pool_size = snapshot.pool.total_cards
|
|
1333
|
+
self._pair_label = snapshot.pool.inferred_pair or "open"
|
|
1334
|
+
self._commitment_label = _commitment_label(
|
|
1335
|
+
commitment=snapshot.pool.commitment,
|
|
1336
|
+
)
|
|
1337
|
+
self.sort_mode = snapshot.recommendations.ranking_mode
|
|
1338
|
+
self._data_source = self._session_data_source(snapshot=snapshot)
|
|
1339
|
+
self._apply_card_data_state(snapshot=snapshot)
|
|
1340
|
+
self._apply_ratings_state(snapshot=snapshot)
|
|
1341
|
+
general_errors = tuple(
|
|
1342
|
+
error
|
|
1343
|
+
for error in snapshot.errors
|
|
1344
|
+
if error.operation not in {OperationKind.BUILD, OperationKind.BACKTEST}
|
|
1345
|
+
)
|
|
1346
|
+
self._session_error = (
|
|
1347
|
+
None if not general_errors else general_errors[-1].message
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
identity = (self._active_account_id, self._draft_id)
|
|
1351
|
+
if identity != previous_identity:
|
|
1352
|
+
self._adopt_changed_session_identity(snapshot=snapshot)
|
|
1353
|
+
self._apply_secondary_results(snapshot=snapshot)
|
|
1354
|
+
if snapshot.card_data.phase == DataLoadPhase.READY:
|
|
1355
|
+
self._start_log_processing()
|
|
1356
|
+
|
|
1357
|
+
self._render_all()
|
|
1358
|
+
|
|
1359
|
+
def _apply_session_event(self, published: LiveSessionEvent) -> None:
|
|
1360
|
+
if not self._session_publication_is_allowed():
|
|
1361
|
+
return
|
|
1362
|
+
event = published.event
|
|
1363
|
+
if isinstance(
|
|
1364
|
+
event,
|
|
1365
|
+
(
|
|
1366
|
+
QuickDraftDetectedEvent,
|
|
1367
|
+
DraftStartedEvent,
|
|
1368
|
+
PackOfferedEvent,
|
|
1369
|
+
PickMadeEvent,
|
|
1370
|
+
DraftCompletedEvent,
|
|
1371
|
+
),
|
|
1372
|
+
):
|
|
1373
|
+
self._event_name = event.event_name
|
|
1374
|
+
self._set_code = event.set_code
|
|
1375
|
+
|
|
1376
|
+
if isinstance(event, (QuickDraftDetectedEvent, DraftStartedEvent)):
|
|
1377
|
+
self._reset_secondary_view_state()
|
|
1378
|
+
self._view_mode = "pack"
|
|
1379
|
+
self._pick_label = (
|
|
1380
|
+
"preparing"
|
|
1381
|
+
if isinstance(event, QuickDraftDetectedEvent)
|
|
1382
|
+
else "waiting"
|
|
1383
|
+
)
|
|
1384
|
+
elif isinstance(event, PackOfferedEvent):
|
|
1385
|
+
self._view_mode = "pack"
|
|
1386
|
+
self._pick_label = f"P{event.pack_number + 1}P{event.pick_number + 1}"
|
|
1387
|
+
self._build_action_status = None
|
|
1388
|
+
self._backtest_action_status = None
|
|
1389
|
+
self._build_result = None
|
|
1390
|
+
self._backtest_error = None
|
|
1391
|
+
self._clear_build_render_state()
|
|
1392
|
+
elif isinstance(event, PickMadeEvent):
|
|
1393
|
+
self._pick_label = (
|
|
1394
|
+
f"P{event.pack_number + 1}P{event.pick_number + 1} picked"
|
|
1395
|
+
)
|
|
1396
|
+
self._build_action_status = None
|
|
1397
|
+
self._backtest_action_status = None
|
|
1398
|
+
self._build_result = None
|
|
1399
|
+
self._backtest_error = None
|
|
1400
|
+
self._clear_build_render_state()
|
|
1401
|
+
elif isinstance(event, DraftCompletedEvent):
|
|
1402
|
+
self._pick_label = "complete"
|
|
1403
|
+
self._build_action_status = None
|
|
1404
|
+
self._backtest_action_status = None
|
|
1405
|
+
self._request_build_view(success_message=None)
|
|
1406
|
+
|
|
1407
|
+
self._render_all()
|
|
1408
|
+
|
|
1409
|
+
def _apply_secondary_results(self, *, snapshot: LiveSessionSnapshot) -> None:
|
|
1410
|
+
progress = snapshot.progress
|
|
1411
|
+
build_in_progress = (
|
|
1412
|
+
progress is not None and progress.operation == OperationKind.BUILD
|
|
1413
|
+
)
|
|
1414
|
+
backtest_in_progress = (
|
|
1415
|
+
progress is not None and progress.operation == OperationKind.BACKTEST
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
if not build_in_progress:
|
|
1419
|
+
build_error = _operation_error(
|
|
1420
|
+
snapshot=snapshot,
|
|
1421
|
+
operation=OperationKind.BUILD,
|
|
1422
|
+
)
|
|
1423
|
+
if build_error is not None:
|
|
1424
|
+
message = build_error.message.removeprefix("Deck build failed: ")
|
|
1425
|
+
self._adopt_build_error(snapshot=snapshot, message=message)
|
|
1426
|
+
elif snapshot.build is not None:
|
|
1427
|
+
self._adopt_build_result(result=snapshot.build)
|
|
1428
|
+
|
|
1429
|
+
if not backtest_in_progress:
|
|
1430
|
+
backtest_error = _operation_error(
|
|
1431
|
+
snapshot=snapshot,
|
|
1432
|
+
operation=OperationKind.BACKTEST,
|
|
1433
|
+
)
|
|
1434
|
+
if backtest_error is not None:
|
|
1435
|
+
message = backtest_error.message.removeprefix("Backtest failed: ")
|
|
1436
|
+
self._backtest_error = message
|
|
1437
|
+
self._backtest_text = f"Backtest view unavailable: {message}"
|
|
1438
|
+
self._backtest_action_status = f"cannot backtest — {message}"
|
|
1439
|
+
self._pending_backtest_success_message = None
|
|
1440
|
+
self._open_backtest_when_ready = False
|
|
1441
|
+
elif snapshot.backtest is not None:
|
|
1442
|
+
self._adopt_backtest_result(result=snapshot.backtest)
|
|
1443
|
+
|
|
1444
|
+
def _apply_card_data_state(self, *, snapshot: LiveSessionSnapshot) -> None:
|
|
1445
|
+
if snapshot.card_data.phase != DataLoadPhase.FAILED:
|
|
1446
|
+
self._card_database_error = None
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
error = next(
|
|
1450
|
+
(
|
|
1451
|
+
candidate
|
|
1452
|
+
for candidate in snapshot.errors
|
|
1453
|
+
if candidate.operation == OperationKind.CARD_DATA
|
|
1454
|
+
),
|
|
1455
|
+
None,
|
|
1456
|
+
)
|
|
1457
|
+
detail = snapshot.card_data.message if error is None else error.message
|
|
1458
|
+
detail = detail.removeprefix("Card metadata failed to load: ").removesuffix(
|
|
1459
|
+
"."
|
|
1460
|
+
)
|
|
1461
|
+
self._card_database_error = (
|
|
1462
|
+
f"{detail}. Run `draftomen-tui refresh-data` after checking your "
|
|
1463
|
+
"network connection and local card-data cache."
|
|
1464
|
+
)
|
|
1465
|
+
|
|
1466
|
+
def _apply_ratings_state(self, *, snapshot: LiveSessionSnapshot) -> None:
|
|
1467
|
+
ratings = snapshot.ratings
|
|
1468
|
+
set_code = ratings.set_code
|
|
1469
|
+
if set_code is None:
|
|
1470
|
+
return
|
|
1471
|
+
|
|
1472
|
+
progress = snapshot.progress
|
|
1473
|
+
if (
|
|
1474
|
+
ratings.phase == DataLoadPhase.LOADING
|
|
1475
|
+
and progress is not None
|
|
1476
|
+
and progress.operation == OperationKind.RATINGS
|
|
1477
|
+
):
|
|
1478
|
+
completed = 0 if progress.completed is None else progress.completed
|
|
1479
|
+
total = "?" if progress.total is None else str(progress.total)
|
|
1480
|
+
self._rating_notices_by_set[set_code] = (
|
|
1481
|
+
f"{progress.message} for {set_code} ({completed}/{total})"
|
|
1482
|
+
)
|
|
1483
|
+
return
|
|
1484
|
+
|
|
1485
|
+
if ratings.phase == DataLoadPhase.MISSING:
|
|
1486
|
+
self._rating_notices_by_set[set_code] = (
|
|
1487
|
+
f"No local 17Lands data for {set_code}; neutral-prior scores are "
|
|
1488
|
+
"active. Choose Download data, or press d later."
|
|
1489
|
+
)
|
|
1490
|
+
self._show_missing_ratings_prompt(set_code=set_code)
|
|
1491
|
+
return
|
|
1492
|
+
|
|
1493
|
+
if ratings.phase == DataLoadPhase.FAILED:
|
|
1494
|
+
self._rating_notices_by_set[set_code] = (
|
|
1495
|
+
f"{ratings.message} Press d to retry."
|
|
1496
|
+
)
|
|
1497
|
+
return
|
|
1498
|
+
|
|
1499
|
+
if ratings.phase != DataLoadPhase.READY:
|
|
1500
|
+
return
|
|
1501
|
+
|
|
1502
|
+
if set_code in self._rating_download_requested_sets:
|
|
1503
|
+
self._rating_notices_by_set[set_code] = self._ratings_ready_notice(
|
|
1504
|
+
snapshot=snapshot,
|
|
1505
|
+
)
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1508
|
+
self._rating_notices_by_set.pop(set_code, None)
|
|
1509
|
+
|
|
1510
|
+
def _ratings_ready_notice(self, *, snapshot: LiveSessionSnapshot) -> str:
|
|
1511
|
+
ratings = snapshot.ratings
|
|
1512
|
+
set_code = ratings.set_code or "unknown set"
|
|
1513
|
+
total_cards = ratings.total_cards
|
|
1514
|
+
rated_cards = ratings.rated_cards
|
|
1515
|
+
if total_cards is None or rated_cards is None:
|
|
1516
|
+
return f"17Lands data ready for {set_code}; future scores will use it."
|
|
1517
|
+
if rated_cards == total_cards:
|
|
1518
|
+
return (
|
|
1519
|
+
f"17Lands data ready for {set_code}; scores recalculated. "
|
|
1520
|
+
f"All {total_cards} offered cards have usable ratings."
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
return (
|
|
1524
|
+
f"17Lands data ready for {set_code}; scores recalculated. "
|
|
1525
|
+
f"{rated_cards}/{total_cards} offered cards have usable ratings; "
|
|
1526
|
+
"neutral priors remain where 17Lands samples are unavailable or thin."
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
def _adopt_changed_session_identity(
|
|
1530
|
+
self,
|
|
1531
|
+
*,
|
|
1532
|
+
snapshot: LiveSessionSnapshot,
|
|
1533
|
+
) -> None:
|
|
1534
|
+
self._reset_secondary_view_state()
|
|
1535
|
+
if self._current_pack_event is not None:
|
|
1536
|
+
self._view_mode = "pack"
|
|
1537
|
+
return
|
|
1538
|
+
if snapshot.draft is None or snapshot.pool.total_cards == 0:
|
|
1539
|
+
self._view_mode = "pack"
|
|
1540
|
+
return
|
|
1541
|
+
|
|
1542
|
+
self._request_build_view(success_message=None)
|
|
1543
|
+
|
|
1544
|
+
def _reset_secondary_view_state(self) -> None:
|
|
1545
|
+
self._forced_pair = None
|
|
1546
|
+
self._build_pair_label = "—"
|
|
1547
|
+
self._build_text = "Build view: no picked cards yet."
|
|
1548
|
+
self._build_error = None
|
|
1549
|
+
self._build_action_status = None
|
|
1550
|
+
self._pending_build_success_message = None
|
|
1551
|
+
self._build_spell_sort_mode = "curve"
|
|
1552
|
+
self._build_show_details = self.visibility_preferences.build_details
|
|
1553
|
+
self._build_result = None
|
|
1554
|
+
self._backtest_text = "Backtest view: complete a draft, then press t."
|
|
1555
|
+
self._backtest_error = None
|
|
1556
|
+
self._backtest_action_status = None
|
|
1557
|
+
self._pending_backtest_success_message = None
|
|
1558
|
+
self._open_backtest_when_ready = False
|
|
1559
|
+
self._clear_build_render_state()
|
|
1560
|
+
|
|
1561
|
+
def _snapshot_pool_grp_ids(
|
|
1562
|
+
self,
|
|
1563
|
+
*,
|
|
1564
|
+
snapshot: LiveSessionSnapshot,
|
|
1565
|
+
) -> tuple[int, ...]:
|
|
1566
|
+
return tuple(
|
|
1567
|
+
pool_card.card.grp_id
|
|
1568
|
+
for pool_card in snapshot.pool.cards
|
|
1569
|
+
for _ in range(pool_card.quantity)
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
def _session_data_source(self, *, snapshot: LiveSessionSnapshot) -> str:
|
|
1573
|
+
source = snapshot.recommendations.source_summary
|
|
1574
|
+
if source is None:
|
|
1575
|
+
return "unknown"
|
|
1576
|
+
if snapshot.ratings.phase == DataLoadPhase.LOADING:
|
|
1577
|
+
return f"{source} (downloading ratings)"
|
|
1578
|
+
if snapshot.ratings.phase == DataLoadPhase.FAILED:
|
|
1579
|
+
return f"{source} (ratings unavailable)"
|
|
1580
|
+
if snapshot.ratings.phase == DataLoadPhase.MISSING:
|
|
1581
|
+
return f"{source} (no local 17Lands data)"
|
|
1582
|
+
if (
|
|
1583
|
+
snapshot.ratings.phase == DataLoadPhase.READY
|
|
1584
|
+
and snapshot.ratings.total_cards
|
|
1585
|
+
and snapshot.ratings.rated_cards == 0
|
|
1586
|
+
):
|
|
1587
|
+
return f"{source} (17Lands cached; samples unavailable or thin)"
|
|
1588
|
+
|
|
1589
|
+
return source
|
|
1590
|
+
|
|
1591
|
+
def _show_missing_ratings_prompt(
|
|
1592
|
+
self,
|
|
1593
|
+
*,
|
|
1594
|
+
set_code: str,
|
|
1595
|
+
force: bool = False,
|
|
1596
|
+
) -> None:
|
|
1597
|
+
if not self.is_running or set_code in self._rating_prompt_open_sets:
|
|
1598
|
+
return
|
|
1599
|
+
|
|
1600
|
+
if not force and set_code in self._rating_prompted_sets:
|
|
1601
|
+
return
|
|
1602
|
+
|
|
1603
|
+
self._rating_prompted_sets.add(set_code)
|
|
1604
|
+
self._rating_prompt_open_sets.add(set_code)
|
|
1605
|
+
self._render_all()
|
|
1606
|
+
self.push_screen(
|
|
1607
|
+
MissingRatingsScreen(set_code=set_code),
|
|
1608
|
+
lambda approved: self._handle_ratings_download_choice(
|
|
1609
|
+
set_code=set_code,
|
|
1610
|
+
approved=approved,
|
|
1611
|
+
),
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
def _handle_ratings_download_choice(
|
|
1615
|
+
self,
|
|
1616
|
+
*,
|
|
1617
|
+
set_code: str,
|
|
1618
|
+
approved: bool,
|
|
1619
|
+
) -> None:
|
|
1620
|
+
self._rating_prompt_open_sets.discard(set_code)
|
|
1621
|
+
if approved:
|
|
1622
|
+
self._start_ratings_load(set_code=set_code)
|
|
1623
|
+
return
|
|
1624
|
+
|
|
1625
|
+
self._rating_notices_by_set[set_code] = (
|
|
1626
|
+
f"No local 17Lands data for {set_code}; neutral-prior scores remain. "
|
|
1627
|
+
"Press d to download."
|
|
1628
|
+
)
|
|
1629
|
+
self._render_all()
|
|
1630
|
+
|
|
1631
|
+
def _start_ratings_load(self, *, set_code: str) -> None:
|
|
1632
|
+
if self.session.snapshot.ratings.phase == DataLoadPhase.LOADING:
|
|
1633
|
+
return
|
|
1634
|
+
|
|
1635
|
+
self._rating_download_requested_sets.add(set_code)
|
|
1636
|
+
self._rating_notices_by_set[set_code] = (
|
|
1637
|
+
f"Checking 17Lands data for {set_code}…"
|
|
1638
|
+
)
|
|
1639
|
+
self._render_all()
|
|
1640
|
+
self._dispatch_session_command_worker(
|
|
1641
|
+
RequestRatingsDownload(set_code=set_code),
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def _render_all(self) -> None:
|
|
1646
|
+
if not self.is_mounted:
|
|
1647
|
+
return
|
|
1648
|
+
try:
|
|
1649
|
+
self.query_one("#sidebar", Vertical)
|
|
1650
|
+
except NoMatches:
|
|
1651
|
+
return
|
|
1652
|
+
|
|
1653
|
+
self._update_responsive_visibility()
|
|
1654
|
+
self._render_pack_title()
|
|
1655
|
+
self._render_pre_draft_readiness()
|
|
1656
|
+
self._render_pack_table()
|
|
1657
|
+
self._render_sidebar()
|
|
1658
|
+
self._ensure_visible_focus()
|
|
1659
|
+
self._render_ratings_download_status()
|
|
1660
|
+
self._render_status_bar()
|
|
1661
|
+
|
|
1662
|
+
def _render_ratings_download_status(self) -> None:
|
|
1663
|
+
panel = self.query_one("#ratings-download-panel", Vertical)
|
|
1664
|
+
label = self.query_one("#ratings-download-label", Static)
|
|
1665
|
+
progress_bar = self.query_one("#ratings-download-progress", ProgressBar)
|
|
1666
|
+
set_code = self._set_code
|
|
1667
|
+
notice = (
|
|
1668
|
+
None
|
|
1669
|
+
if set_code is None
|
|
1670
|
+
else self._rating_notices_by_set.get(set_code)
|
|
1671
|
+
)
|
|
1672
|
+
panel.display = notice is not None
|
|
1673
|
+
if notice is None:
|
|
1674
|
+
return
|
|
1675
|
+
|
|
1676
|
+
label.update(notice)
|
|
1677
|
+
snapshot = self.session.snapshot
|
|
1678
|
+
downloading = (
|
|
1679
|
+
set_code is not None
|
|
1680
|
+
and snapshot.ratings.set_code == set_code
|
|
1681
|
+
and snapshot.ratings.phase == DataLoadPhase.LOADING
|
|
1682
|
+
)
|
|
1683
|
+
progress_bar.display = downloading
|
|
1684
|
+
if not downloading or set_code is None:
|
|
1685
|
+
return
|
|
1686
|
+
|
|
1687
|
+
progress = snapshot.progress
|
|
1688
|
+
if progress is None or progress.operation != OperationKind.RATINGS:
|
|
1689
|
+
return
|
|
1690
|
+
progress_bar.update(
|
|
1691
|
+
total=1 if progress.total is None else progress.total,
|
|
1692
|
+
progress=0 if progress.completed is None else progress.completed,
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
def _update_responsive_visibility(self) -> None:
|
|
1696
|
+
sidebar = self.query_one("#sidebar", Vertical)
|
|
1697
|
+
sidebar.display = (
|
|
1698
|
+
self.size.width >= SIDEBAR_MIN_WIDTH
|
|
1699
|
+
and self._sidebar_has_enabled_content()
|
|
1700
|
+
)
|
|
1701
|
+
|
|
1702
|
+
def _sidebar_has_enabled_content(self) -> bool:
|
|
1703
|
+
if self._view_mode not in {"build", "backtest"} and any(
|
|
1704
|
+
(
|
|
1705
|
+
self.visibility_preferences.pool_metadata,
|
|
1706
|
+
self.visibility_preferences.pool_color_distribution,
|
|
1707
|
+
self.visibility_preferences.pool_mana_curve,
|
|
1708
|
+
)
|
|
1709
|
+
):
|
|
1710
|
+
return True
|
|
1711
|
+
|
|
1712
|
+
return (
|
|
1713
|
+
self.visibility_preferences.focused_card_details
|
|
1714
|
+
or self._card_image_preview_is_enabled()
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
def _card_image_preview_is_enabled(self) -> bool:
|
|
1718
|
+
mode = self.visibility_preferences.card_image_preview
|
|
1719
|
+
if mode == "hide":
|
|
1720
|
+
return False
|
|
1721
|
+
if mode == "show":
|
|
1722
|
+
return TgpImage is not None
|
|
1723
|
+
|
|
1724
|
+
return self._card_image_preview_enabled
|
|
1725
|
+
|
|
1726
|
+
def _render_pack_title(self) -> None:
|
|
1727
|
+
title = self.query_one("#pack-title", Static)
|
|
1728
|
+
if self.card_database_loading:
|
|
1729
|
+
title.update("Loading card metadata…")
|
|
1730
|
+
return
|
|
1731
|
+
|
|
1732
|
+
if self._card_database_error is not None:
|
|
1733
|
+
title.update("Card metadata unavailable — check the status bar")
|
|
1734
|
+
return
|
|
1735
|
+
|
|
1736
|
+
if self._view_mode == "build":
|
|
1737
|
+
if self._build_error is not None and self._build_pair_label == "—":
|
|
1738
|
+
title.update("Build view unavailable — metadata or playable count issue")
|
|
1739
|
+
return
|
|
1740
|
+
|
|
1741
|
+
build_pair_label = _format_pair_label(
|
|
1742
|
+
pair=self._build_pair_label,
|
|
1743
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
1744
|
+
)
|
|
1745
|
+
override = "automatic"
|
|
1746
|
+
if self._forced_pair is not None:
|
|
1747
|
+
forced_pair = _format_pair_label(
|
|
1748
|
+
pair=self._forced_pair,
|
|
1749
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
1750
|
+
)
|
|
1751
|
+
override = f"forced {forced_pair}"
|
|
1752
|
+
|
|
1753
|
+
detail = "details" if self._build_show_details else "compact"
|
|
1754
|
+
title.update(
|
|
1755
|
+
f"Build view — pair {build_pair_label} ({override}); "
|
|
1756
|
+
f"spells {self._build_spell_sort_mode}; {detail}; "
|
|
1757
|
+
"scroll ↑/↓ PgUp/PgDn"
|
|
1758
|
+
)
|
|
1759
|
+
return
|
|
1760
|
+
|
|
1761
|
+
if self._view_mode == "backtest":
|
|
1762
|
+
if self._backtest_error is not None:
|
|
1763
|
+
title.update("Backtest view unavailable — saved draft state issue")
|
|
1764
|
+
else:
|
|
1765
|
+
title.update(
|
|
1766
|
+
"Backtest view — "
|
|
1767
|
+
f"{ranking_label(ranking_mode=self.sort_mode)} "
|
|
1768
|
+
"recommendations vs actual picks; scroll ↑/↓ PgUp/PgDn"
|
|
1769
|
+
)
|
|
1770
|
+
return
|
|
1771
|
+
|
|
1772
|
+
if self._current_pack_event is None:
|
|
1773
|
+
if self._pick_label == "complete":
|
|
1774
|
+
title.update("Draft complete")
|
|
1775
|
+
elif self._set_code is not None:
|
|
1776
|
+
title.update(
|
|
1777
|
+
"Quick Draft detected — "
|
|
1778
|
+
f"{format_set_label(set_code=self._set_code)}; "
|
|
1779
|
+
"waiting for the first pack…"
|
|
1780
|
+
)
|
|
1781
|
+
else:
|
|
1782
|
+
title.update("Waiting for a Quick Draft pack…")
|
|
1783
|
+
return
|
|
1784
|
+
|
|
1785
|
+
event = self._current_pack_event
|
|
1786
|
+
title.update(
|
|
1787
|
+
"Available cards — Pack "
|
|
1788
|
+
f"{event.pack_number + 1} "
|
|
1789
|
+
"Pick "
|
|
1790
|
+
f"{event.pick_number + 1} "
|
|
1791
|
+
f"— ranked by {SORT_LABELS[self.sort_mode]}"
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1794
|
+
def _render_pre_draft_readiness(self) -> None:
|
|
1795
|
+
readiness = self.query_one("#pre-draft-readiness", Static)
|
|
1796
|
+
readiness.display = self._show_pre_draft_readiness()
|
|
1797
|
+
if not readiness.display:
|
|
1798
|
+
return
|
|
1799
|
+
status = self.session.snapshot.status
|
|
1800
|
+
if status.setup_guidance:
|
|
1801
|
+
readiness.update(status.message)
|
|
1802
|
+
return
|
|
1803
|
+
|
|
1804
|
+
set_code = self._set_code
|
|
1805
|
+
if set_code is None:
|
|
1806
|
+
readiness.update(
|
|
1807
|
+
"Quick Draft set not detected yet.\n"
|
|
1808
|
+
"Waiting for Arena to report a Quick Draft entry."
|
|
1809
|
+
)
|
|
1810
|
+
return
|
|
1811
|
+
|
|
1812
|
+
set_label = format_set_label(set_code=set_code)
|
|
1813
|
+
prefix = f"17Lands reliability for {set_label} Quick Draft:"
|
|
1814
|
+
ratings = self.session.snapshot.ratings
|
|
1815
|
+
if ratings.phase == DataLoadPhase.LOADING:
|
|
1816
|
+
readiness.update(f"{prefix} Checking…")
|
|
1817
|
+
return
|
|
1818
|
+
|
|
1819
|
+
if ratings.phase == DataLoadPhase.FAILED:
|
|
1820
|
+
readiness.update(f"{prefix} Unavailable")
|
|
1821
|
+
return
|
|
1822
|
+
|
|
1823
|
+
ratings_data = self.session.ratings_data(set_code=set_code)
|
|
1824
|
+
if ratings_data is not None:
|
|
1825
|
+
reliability = ratings_data.set_reliability
|
|
1826
|
+
readiness.update(
|
|
1827
|
+
f"{prefix} {reliability.tier} — {reliability.score}/100"
|
|
1828
|
+
)
|
|
1829
|
+
return
|
|
1830
|
+
|
|
1831
|
+
if ratings.phase == DataLoadPhase.MISSING:
|
|
1832
|
+
readiness.update(f"{prefix} Not checked — press d to download data")
|
|
1833
|
+
return
|
|
1834
|
+
|
|
1835
|
+
readiness.update(f"{prefix} Not checked")
|
|
1836
|
+
|
|
1837
|
+
def _show_pre_draft_readiness(self) -> bool:
|
|
1838
|
+
return (
|
|
1839
|
+
self._view_mode == "pack"
|
|
1840
|
+
and self._current_pack_event is None
|
|
1841
|
+
and self._pick_label != "complete"
|
|
1842
|
+
)
|
|
1843
|
+
|
|
1844
|
+
def _render_pack_table(self) -> None:
|
|
1845
|
+
table = self.query_one("#pack-table", DataTable)
|
|
1846
|
+
build_scroll = self.query_one("#build-scroll", VerticalScroll)
|
|
1847
|
+
build_view = self.query_one("#build-view", Static)
|
|
1848
|
+
table.loading = self.card_database_loading
|
|
1849
|
+
table.display = (
|
|
1850
|
+
self._view_mode == "pack" and not self._show_pre_draft_readiness()
|
|
1851
|
+
)
|
|
1852
|
+
build_scroll.display = self._view_mode in {"build", "backtest"}
|
|
1853
|
+
build_view.update(self._active_text_view())
|
|
1854
|
+
if self._view_mode in {"build", "backtest"}:
|
|
1855
|
+
self._visible_column_keys = ()
|
|
1856
|
+
return
|
|
1857
|
+
|
|
1858
|
+
previous_row_key, previous_row_index = self._capture_table_cursor(table=table)
|
|
1859
|
+
table.clear(columns=True)
|
|
1860
|
+
column_keys = self._column_keys_for_width()
|
|
1861
|
+
self._visible_column_keys = column_keys
|
|
1862
|
+
for column_key in column_keys:
|
|
1863
|
+
table.add_column(
|
|
1864
|
+
COLUMN_LABELS[column_key],
|
|
1865
|
+
width=COLUMN_WIDTHS[column_key],
|
|
1866
|
+
key=column_key,
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
if self._current_pack is None:
|
|
1870
|
+
return
|
|
1871
|
+
|
|
1872
|
+
for rank, scored_card in enumerate(self._sorted_cards(), start=1):
|
|
1873
|
+
row = _row_cells(
|
|
1874
|
+
rank=rank,
|
|
1875
|
+
scored_card=scored_card,
|
|
1876
|
+
column_keys=column_keys,
|
|
1877
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
1878
|
+
)
|
|
1879
|
+
table.add_row(
|
|
1880
|
+
*row,
|
|
1881
|
+
key=f"{rank}-{scored_card.card.grp_id}-{scored_card.original_index}",
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
self._restore_table_cursor(
|
|
1885
|
+
table=table,
|
|
1886
|
+
row_key=previous_row_key,
|
|
1887
|
+
row_index=previous_row_index,
|
|
1888
|
+
)
|
|
1889
|
+
|
|
1890
|
+
def _capture_table_cursor(self, *, table: DataTable) -> tuple[str | None, int]:
|
|
1891
|
+
if table.row_count == 0:
|
|
1892
|
+
return None, 0
|
|
1893
|
+
|
|
1894
|
+
row_index = max(0, table.cursor_coordinate.row)
|
|
1895
|
+
try:
|
|
1896
|
+
cell_key = table.coordinate_to_cell_key(table.cursor_coordinate)
|
|
1897
|
+
except Exception: # pragma: no cover - defensive Textual boundary.
|
|
1898
|
+
return None, row_index
|
|
1899
|
+
|
|
1900
|
+
return str(cell_key.row_key.value), row_index
|
|
1901
|
+
|
|
1902
|
+
def _restore_table_cursor(
|
|
1903
|
+
self,
|
|
1904
|
+
*,
|
|
1905
|
+
table: DataTable,
|
|
1906
|
+
row_key: str | None,
|
|
1907
|
+
row_index: int,
|
|
1908
|
+
) -> None:
|
|
1909
|
+
if table.row_count == 0:
|
|
1910
|
+
return
|
|
1911
|
+
|
|
1912
|
+
target_row = min(max(row_index, 0), table.row_count - 1)
|
|
1913
|
+
if row_key is not None:
|
|
1914
|
+
try:
|
|
1915
|
+
target_row = table.get_row_index(row_key)
|
|
1916
|
+
except Exception: # pragma: no cover - defensive Textual boundary.
|
|
1917
|
+
pass
|
|
1918
|
+
|
|
1919
|
+
table.move_cursor(row=target_row, column=0, animate=False)
|
|
1920
|
+
|
|
1921
|
+
def _move_pack_cursor(self, *, delta: int) -> None:
|
|
1922
|
+
table = self.query_one("#pack-table", DataTable)
|
|
1923
|
+
self._move_pack_cursor_to(row=table.cursor_coordinate.row + delta)
|
|
1924
|
+
|
|
1925
|
+
def _move_pack_cursor_to(self, *, row: int) -> None:
|
|
1926
|
+
if self._view_mode != "pack":
|
|
1927
|
+
return
|
|
1928
|
+
|
|
1929
|
+
table = self.query_one("#pack-table", DataTable)
|
|
1930
|
+
if table.row_count == 0:
|
|
1931
|
+
return
|
|
1932
|
+
|
|
1933
|
+
target_row = min(max(row, 0), table.row_count - 1)
|
|
1934
|
+
table.focus()
|
|
1935
|
+
table.move_cursor(row=target_row, column=0, animate=False)
|
|
1936
|
+
self._render_focused_card_details()
|
|
1937
|
+
|
|
1938
|
+
def _move_build_card_cursor(self, *, delta: int) -> None:
|
|
1939
|
+
if not self._build_focus_cards:
|
|
1940
|
+
return
|
|
1941
|
+
|
|
1942
|
+
target_index = self._build_focused_card_index + delta
|
|
1943
|
+
self._build_focused_card_index = min(
|
|
1944
|
+
max(target_index, 0),
|
|
1945
|
+
len(self._build_focus_cards) - 1,
|
|
1946
|
+
)
|
|
1947
|
+
self._refresh_build_text_from_result()
|
|
1948
|
+
self.query_one("#build-scroll", VerticalScroll).focus()
|
|
1949
|
+
self.query_one("#build-view", Static).update(self._build_text)
|
|
1950
|
+
self._render_focused_card_details()
|
|
1951
|
+
|
|
1952
|
+
def _pack_cursor_page_size(self) -> int:
|
|
1953
|
+
table = self.query_one("#pack-table", DataTable)
|
|
1954
|
+
return max(1, table.size.height - 3)
|
|
1955
|
+
|
|
1956
|
+
def _render_sidebar(self) -> None:
|
|
1957
|
+
pool_summary = self.query_one("#pool-summary", Static)
|
|
1958
|
+
show_pool_summary = self._view_mode not in {"build", "backtest"} and any(
|
|
1959
|
+
(
|
|
1960
|
+
self.visibility_preferences.pool_metadata,
|
|
1961
|
+
self.visibility_preferences.pool_color_distribution,
|
|
1962
|
+
self.visibility_preferences.pool_mana_curve,
|
|
1963
|
+
)
|
|
1964
|
+
)
|
|
1965
|
+
pool_summary.display = show_pool_summary and self.query_one(
|
|
1966
|
+
"#sidebar",
|
|
1967
|
+
Vertical,
|
|
1968
|
+
).display
|
|
1969
|
+
if self.card_database_loading:
|
|
1970
|
+
if pool_summary.display:
|
|
1971
|
+
pool_summary.update("Card metadata\nLoading local cache and Scryfall data…")
|
|
1972
|
+
self._render_focused_card_details()
|
|
1973
|
+
return
|
|
1974
|
+
|
|
1975
|
+
if self._card_database_error is not None:
|
|
1976
|
+
if pool_summary.display:
|
|
1977
|
+
pool_summary.update(
|
|
1978
|
+
"Card metadata\nLoad failed — check the status bar for next steps."
|
|
1979
|
+
)
|
|
1980
|
+
self._render_focused_card_details()
|
|
1981
|
+
return
|
|
1982
|
+
|
|
1983
|
+
if pool_summary.display:
|
|
1984
|
+
lines = ["Pool summary"]
|
|
1985
|
+
if self.visibility_preferences.pool_metadata:
|
|
1986
|
+
event_text = self._event_name or "unknown event"
|
|
1987
|
+
override_label = self._build_override_label()
|
|
1988
|
+
inferred_pair = _format_pair_label(
|
|
1989
|
+
pair=self._pair_label,
|
|
1990
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
1991
|
+
)
|
|
1992
|
+
build_pair = _format_pair_label(
|
|
1993
|
+
pair=self._build_pair_label,
|
|
1994
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
1995
|
+
)
|
|
1996
|
+
override = _format_pair_label(
|
|
1997
|
+
pair=override_label,
|
|
1998
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
1999
|
+
)
|
|
2000
|
+
lines.extend(
|
|
2001
|
+
(
|
|
2002
|
+
f"Set: {format_set_label(set_code=self._set_code)}",
|
|
2003
|
+
f"Event: {event_text}",
|
|
2004
|
+
)
|
|
2005
|
+
)
|
|
2006
|
+
if self.visibility_preferences.draft_identifier:
|
|
2007
|
+
lines.append(f"Draft: {self._draft_id or 'unknown draft'}")
|
|
2008
|
+
lines.extend(
|
|
2009
|
+
(
|
|
2010
|
+
f"Pool size: {self._pool_size}",
|
|
2011
|
+
f"Inferred pair: {inferred_pair}",
|
|
2012
|
+
f"Build pair: {build_pair}",
|
|
2013
|
+
f"Override: {override}",
|
|
2014
|
+
f"Build action: {self._build_action_label()}",
|
|
2015
|
+
self._metadata_status_text(),
|
|
2016
|
+
)
|
|
2017
|
+
)
|
|
2018
|
+
if self.visibility_preferences.pool_color_distribution:
|
|
2019
|
+
lines.append(
|
|
2020
|
+
_pool_color_distribution_bar(
|
|
2021
|
+
pool_grp_ids=self._pool_grp_ids,
|
|
2022
|
+
card_database=self.card_database,
|
|
2023
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
2024
|
+
)
|
|
2025
|
+
)
|
|
2026
|
+
if self.visibility_preferences.pool_mana_curve:
|
|
2027
|
+
lines.append(
|
|
2028
|
+
_pool_curve_sparkline(
|
|
2029
|
+
pool_grp_ids=self._pool_grp_ids,
|
|
2030
|
+
card_database=self.card_database,
|
|
2031
|
+
)
|
|
2032
|
+
)
|
|
2033
|
+
pool_summary.update("\n".join(lines))
|
|
2034
|
+
|
|
2035
|
+
self._render_focused_card_details()
|
|
2036
|
+
|
|
2037
|
+
def _render_focused_card_details(self) -> None:
|
|
2038
|
+
try:
|
|
2039
|
+
focused_card = self.query_one("#focused-card", Static)
|
|
2040
|
+
except NoMatches:
|
|
2041
|
+
return
|
|
2042
|
+
|
|
2043
|
+
sidebar = self.query_one("#sidebar", Vertical)
|
|
2044
|
+
focused_card.display = (
|
|
2045
|
+
self.visibility_preferences.focused_card_details and sidebar.display
|
|
2046
|
+
)
|
|
2047
|
+
selected = self._focused_card_details()
|
|
2048
|
+
if selected is None:
|
|
2049
|
+
self._render_card_image_preview(card=None)
|
|
2050
|
+
if focused_card.display:
|
|
2051
|
+
focused_card.update(
|
|
2052
|
+
"Focused card details\n"
|
|
2053
|
+
"Use ↑/↓/←/→ in the card list to browse card details here."
|
|
2054
|
+
)
|
|
2055
|
+
return
|
|
2056
|
+
|
|
2057
|
+
section, rank, total_count, scored_card, quantity = selected
|
|
2058
|
+
card = scored_card.card
|
|
2059
|
+
self._render_card_image_preview(card=card)
|
|
2060
|
+
if not focused_card.display:
|
|
2061
|
+
return
|
|
2062
|
+
|
|
2063
|
+
type_line = _format_card_types(
|
|
2064
|
+
card=card,
|
|
2065
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
2066
|
+
)
|
|
2067
|
+
quantity_line = f"Quantity: {quantity}\n" if quantity > 1 else ""
|
|
2068
|
+
color_label = _format_card_colors(
|
|
2069
|
+
card=card,
|
|
2070
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
2071
|
+
long_colorless=True,
|
|
2072
|
+
)
|
|
2073
|
+
focused_card.update(
|
|
2074
|
+
"Focused card details\n"
|
|
2075
|
+
f"{section} {rank}/{total_count}\n"
|
|
2076
|
+
f"{_format_card_name(card=card)}\n"
|
|
2077
|
+
f"{quantity_line}"
|
|
2078
|
+
f"Colors: {color_label}\n"
|
|
2079
|
+
f"Mana value: {_format_mana_value(card=card)}\n"
|
|
2080
|
+
f"Type: {type_line}\n"
|
|
2081
|
+
f"17L WR: {_format_win_rate(scored_card=scored_card)}\n"
|
|
2082
|
+
f"17L Grade: {_format_letter_grade(scored_card=scored_card)}\n"
|
|
2083
|
+
f"DO Score: {scored_card.score}\n"
|
|
2084
|
+
f"Color fit: {_format_color_fit(scored_card=scored_card)}\n"
|
|
2085
|
+
f"{_format_splash_details(scored_card=scored_card)}"
|
|
2086
|
+
f"ALSA (avg last seen): {_format_alsa(scored_card=scored_card)}\n"
|
|
2087
|
+
f"Data source: {_format_tui_source_label(scored_card=scored_card)}"
|
|
2088
|
+
)
|
|
2089
|
+
|
|
2090
|
+
def _render_card_image_preview(self, *, card: CardInfo | None) -> None:
|
|
2091
|
+
image_panel = self.query_one("#card-image-preview", Static)
|
|
2092
|
+
if not self._card_image_preview_is_enabled():
|
|
2093
|
+
image_panel.display = False
|
|
2094
|
+
return
|
|
2095
|
+
|
|
2096
|
+
sidebar = self.query_one("#sidebar", Vertical)
|
|
2097
|
+
if not sidebar.display:
|
|
2098
|
+
image_panel.display = False
|
|
2099
|
+
return
|
|
2100
|
+
|
|
2101
|
+
if TgpImage is None:
|
|
2102
|
+
image_panel.display = True
|
|
2103
|
+
image_panel.update(
|
|
2104
|
+
"Image preview unavailable\n"
|
|
2105
|
+
"Install the textual-image package to render Kitty images."
|
|
2106
|
+
)
|
|
2107
|
+
return
|
|
2108
|
+
|
|
2109
|
+
if card is None:
|
|
2110
|
+
image_panel.display = False
|
|
2111
|
+
return
|
|
2112
|
+
|
|
2113
|
+
image_panel.display = True
|
|
2114
|
+
image_uri = self._card_image_uri_for_card(card=card)
|
|
2115
|
+
if image_uri is None:
|
|
2116
|
+
if card.unknown:
|
|
2117
|
+
image_panel.display = False
|
|
2118
|
+
return
|
|
2119
|
+
|
|
2120
|
+
image_panel.update(
|
|
2121
|
+
"Image preview unavailable\n"
|
|
2122
|
+
f"{_format_card_name(card=card)}\n"
|
|
2123
|
+
"Image URL is not in the local Scryfall cache. Run refresh-data."
|
|
2124
|
+
)
|
|
2125
|
+
return
|
|
2126
|
+
image_path = self._card_image_paths_by_uri.get(image_uri)
|
|
2127
|
+
if image_path is not None and image_path.exists():
|
|
2128
|
+
self._show_card_image(
|
|
2129
|
+
image_panel=image_panel,
|
|
2130
|
+
image_path=image_path,
|
|
2131
|
+
card=card,
|
|
2132
|
+
image_uri=image_uri,
|
|
2133
|
+
)
|
|
2134
|
+
return
|
|
2135
|
+
|
|
2136
|
+
cached_path = self._card_image_service.cached_path(
|
|
2137
|
+
image_uri=image_uri,
|
|
2138
|
+
)
|
|
2139
|
+
if cached_path.exists():
|
|
2140
|
+
self._card_image_paths_by_uri[image_uri] = cached_path
|
|
2141
|
+
self._show_card_image(
|
|
2142
|
+
image_panel=image_panel,
|
|
2143
|
+
image_path=cached_path,
|
|
2144
|
+
card=card,
|
|
2145
|
+
image_uri=image_uri,
|
|
2146
|
+
)
|
|
2147
|
+
return
|
|
2148
|
+
|
|
2149
|
+
failure = self._card_image_failures_by_uri.get(image_uri)
|
|
2150
|
+
if failure is not None:
|
|
2151
|
+
image_panel.update(
|
|
2152
|
+
"Image preview unavailable\n"
|
|
2153
|
+
f"{_format_card_name(card=card)}\n"
|
|
2154
|
+
f"{failure}"
|
|
2155
|
+
)
|
|
2156
|
+
return
|
|
2157
|
+
|
|
2158
|
+
image_panel.update(
|
|
2159
|
+
"Loading image preview…\n"
|
|
2160
|
+
f"{_format_card_name(card=card)}"
|
|
2161
|
+
)
|
|
2162
|
+
if image_uri not in self._loading_card_image_uris:
|
|
2163
|
+
self._loading_card_image_uris.add(image_uri)
|
|
2164
|
+
self._fetch_card_image_worker(image_uri)
|
|
2165
|
+
|
|
2166
|
+
def _card_image_uri_for_card(self, *, card: CardInfo) -> str | None:
|
|
2167
|
+
image_uri = self._card_image_uris_by_grp_id.get(card.grp_id)
|
|
2168
|
+
if image_uri is None:
|
|
2169
|
+
image_uri = self._card_image_service.resolve_image_uri(
|
|
2170
|
+
card=card,
|
|
2171
|
+
card_database=self.card_database,
|
|
2172
|
+
)
|
|
2173
|
+
if image_uri is not None:
|
|
2174
|
+
self._card_image_uris_by_grp_id[card.grp_id] = image_uri
|
|
2175
|
+
|
|
2176
|
+
return image_uri
|
|
2177
|
+
|
|
2178
|
+
def _show_card_image(
|
|
2179
|
+
self,
|
|
2180
|
+
*,
|
|
2181
|
+
image_panel: Static,
|
|
2182
|
+
image_path: Path,
|
|
2183
|
+
card: CardInfo,
|
|
2184
|
+
image_uri: str,
|
|
2185
|
+
) -> None:
|
|
2186
|
+
if TgpImage is None:
|
|
2187
|
+
return
|
|
2188
|
+
|
|
2189
|
+
width = max(20, min(30, image_panel.size.width or 28))
|
|
2190
|
+
try:
|
|
2191
|
+
preview = TgpImage(
|
|
2192
|
+
str(image_path),
|
|
2193
|
+
width=width,
|
|
2194
|
+
height="auto",
|
|
2195
|
+
)
|
|
2196
|
+
except Exception as error: # pragma: no cover - defensive renderer boundary.
|
|
2197
|
+
self._card_image_failures_by_uri[image_uri] = str(error)
|
|
2198
|
+
image_panel.update(
|
|
2199
|
+
"Image preview unavailable\n"
|
|
2200
|
+
f"{_format_card_name(card=card)}\n"
|
|
2201
|
+
f"{error}"
|
|
2202
|
+
)
|
|
2203
|
+
return
|
|
2204
|
+
|
|
2205
|
+
image_panel.update(
|
|
2206
|
+
Group(
|
|
2207
|
+
Align.center(Text(f"Preview: {_format_card_name(card=card)}")),
|
|
2208
|
+
"",
|
|
2209
|
+
Align.center(preview),
|
|
2210
|
+
)
|
|
2211
|
+
)
|
|
2212
|
+
|
|
2213
|
+
@work(thread=True, group="card-images")
|
|
2214
|
+
def _fetch_card_image_worker(self, image_uri: str) -> None:
|
|
2215
|
+
worker = get_current_worker()
|
|
2216
|
+
if worker.is_cancelled:
|
|
2217
|
+
return
|
|
2218
|
+
|
|
2219
|
+
image_path = None
|
|
2220
|
+
error_message = None
|
|
2221
|
+
try:
|
|
2222
|
+
image_path = self._card_image_service.fetch(image_uri=image_uri)
|
|
2223
|
+
except Exception as error: # pragma: no cover - defensive network boundary.
|
|
2224
|
+
error_message = str(error)
|
|
2225
|
+
|
|
2226
|
+
if worker.is_cancelled:
|
|
2227
|
+
return
|
|
2228
|
+
|
|
2229
|
+
self.call_from_thread(
|
|
2230
|
+
self._finish_card_image_load,
|
|
2231
|
+
image_uri,
|
|
2232
|
+
image_path,
|
|
2233
|
+
error_message,
|
|
2234
|
+
)
|
|
2235
|
+
|
|
2236
|
+
def _finish_card_image_load(
|
|
2237
|
+
self,
|
|
2238
|
+
image_uri: str,
|
|
2239
|
+
image_path: Path | None,
|
|
2240
|
+
error_message: str | None,
|
|
2241
|
+
) -> None:
|
|
2242
|
+
self._loading_card_image_uris.discard(image_uri)
|
|
2243
|
+
if image_path is None:
|
|
2244
|
+
self._card_image_failures_by_uri[image_uri] = error_message or "fetch failed"
|
|
2245
|
+
elif not image_path.exists():
|
|
2246
|
+
self._card_image_failures_by_uri[image_uri] = "fetch failed"
|
|
2247
|
+
else:
|
|
2248
|
+
self._card_image_paths_by_uri[image_uri] = image_path
|
|
2249
|
+
self._card_image_failures_by_uri.pop(image_uri, None)
|
|
2250
|
+
|
|
2251
|
+
self._render_focused_card_details()
|
|
2252
|
+
|
|
2253
|
+
def _focused_card_details(
|
|
2254
|
+
self,
|
|
2255
|
+
) -> tuple[str, int, int, ScoredCard, int] | None:
|
|
2256
|
+
if self._view_mode == "build":
|
|
2257
|
+
return self._focused_build_card()
|
|
2258
|
+
|
|
2259
|
+
selected = self._focused_pack_card()
|
|
2260
|
+
if selected is None:
|
|
2261
|
+
return None
|
|
2262
|
+
|
|
2263
|
+
rank, scored_card = selected
|
|
2264
|
+
return "Available card", rank, len(self._sorted_cards()), scored_card, 1
|
|
2265
|
+
|
|
2266
|
+
def _focused_build_card(self) -> tuple[str, int, int, ScoredCard, int] | None:
|
|
2267
|
+
if self._view_mode != "build" or not self._build_focus_cards:
|
|
2268
|
+
return None
|
|
2269
|
+
|
|
2270
|
+
self._build_focused_card_index = min(
|
|
2271
|
+
max(self._build_focused_card_index, 0),
|
|
2272
|
+
len(self._build_focus_cards) - 1,
|
|
2273
|
+
)
|
|
2274
|
+
card, quantity = self._build_focus_cards[self._build_focused_card_index]
|
|
2275
|
+
return (
|
|
2276
|
+
"Selected card",
|
|
2277
|
+
self._build_focused_card_index + 1,
|
|
2278
|
+
len(self._build_focus_cards),
|
|
2279
|
+
card,
|
|
2280
|
+
quantity,
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
def _focused_pack_card(self) -> tuple[int, ScoredCard] | None:
|
|
2284
|
+
if self._view_mode != "pack" or self._current_pack is None:
|
|
2285
|
+
return None
|
|
2286
|
+
|
|
2287
|
+
table = self.query_one("#pack-table", DataTable)
|
|
2288
|
+
row_index = table.cursor_coordinate.row
|
|
2289
|
+
cards = self._sorted_cards()
|
|
2290
|
+
if row_index < 0 or row_index >= len(cards):
|
|
2291
|
+
return None
|
|
2292
|
+
|
|
2293
|
+
return row_index + 1, cards[row_index]
|
|
2294
|
+
|
|
2295
|
+
def _ensure_visible_focus(self) -> None:
|
|
2296
|
+
focused_id = None if self.focused is None else self.focused.id
|
|
2297
|
+
if focused_id is None:
|
|
2298
|
+
self._focus_primary_card_section()
|
|
2299
|
+
return
|
|
2300
|
+
|
|
2301
|
+
if focused_id == "pack-table" and self._view_mode != "pack":
|
|
2302
|
+
self._focus_primary_card_section()
|
|
2303
|
+
return
|
|
2304
|
+
|
|
2305
|
+
if focused_id == "build-scroll" and self._view_mode not in {"build", "backtest"}:
|
|
2306
|
+
self._focus_primary_card_section()
|
|
2307
|
+
|
|
2308
|
+
def _focus_primary_card_section(self) -> None:
|
|
2309
|
+
if self._view_mode in {"build", "backtest"}:
|
|
2310
|
+
self.query_one("#build-scroll", VerticalScroll).focus()
|
|
2311
|
+
return
|
|
2312
|
+
|
|
2313
|
+
self.query_one("#pack-table", DataTable).focus()
|
|
2314
|
+
|
|
2315
|
+
def _build_override_label(self) -> str:
|
|
2316
|
+
if self._forced_pair is not None:
|
|
2317
|
+
return self._forced_pair
|
|
2318
|
+
|
|
2319
|
+
if self._build_error is not None and self._build_pair_label == "—":
|
|
2320
|
+
return "unavailable"
|
|
2321
|
+
|
|
2322
|
+
return "automatic"
|
|
2323
|
+
|
|
2324
|
+
def _build_action_label(self) -> str:
|
|
2325
|
+
if self._build_action_status is None:
|
|
2326
|
+
return "not requested"
|
|
2327
|
+
|
|
2328
|
+
return self._build_action_status
|
|
2329
|
+
|
|
2330
|
+
def _render_status_bar(self) -> None:
|
|
2331
|
+
status = self.query_one("#status-bar", Static)
|
|
2332
|
+
if self.card_database_loading:
|
|
2333
|
+
status.update("Loading card metadata… the watch UI remains available.")
|
|
2334
|
+
return
|
|
2335
|
+
|
|
2336
|
+
if self._card_database_error is not None:
|
|
2337
|
+
status.update(f"Card metadata failed to load: {self._card_database_error}")
|
|
2338
|
+
return
|
|
2339
|
+
|
|
2340
|
+
pair_label = self._pair_label
|
|
2341
|
+
if self._view_mode == "build" and self._build_pair_label != "—":
|
|
2342
|
+
pair_label = self._build_pair_label
|
|
2343
|
+
pair_label = _format_pair_label(
|
|
2344
|
+
pair=pair_label,
|
|
2345
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
2346
|
+
)
|
|
2347
|
+
|
|
2348
|
+
if self._view_mode == "build":
|
|
2349
|
+
sort_label = f"Build sort: {self._build_spell_sort_mode}"
|
|
2350
|
+
elif self._view_mode == "backtest":
|
|
2351
|
+
sort_label = f"Backtest ranking: {SORT_LABELS[self.sort_mode]}"
|
|
2352
|
+
else:
|
|
2353
|
+
sort_label = f"Ranking: {SORT_LABELS[self.sort_mode]}"
|
|
2354
|
+
|
|
2355
|
+
segments: list[str] = []
|
|
2356
|
+
if self.visibility_preferences.account_identifier:
|
|
2357
|
+
segments.append(f"Account: {self._active_account_label}")
|
|
2358
|
+
segments.extend(
|
|
2359
|
+
(
|
|
2360
|
+
f"View: {self._view_mode}",
|
|
2361
|
+
f"Pair: {pair_label} ({self._commitment_label})",
|
|
2362
|
+
f"Pick: {self._pick_label}",
|
|
2363
|
+
f"Pool: {self._pool_size}",
|
|
2364
|
+
f"Data: {self._data_source}",
|
|
2365
|
+
sort_label,
|
|
2366
|
+
)
|
|
2367
|
+
)
|
|
2368
|
+
confidence_label = self._recommendation_confidence_label()
|
|
2369
|
+
if confidence_label is not None:
|
|
2370
|
+
segments.append(f"Confidence: {confidence_label}")
|
|
2371
|
+
segments.append(self._splash_status_label())
|
|
2372
|
+
icon_label = "on" if self.mana_icons_enabled else "off"
|
|
2373
|
+
segments.append(f"Mana icons: {icon_label}")
|
|
2374
|
+
if self.visibility_preferences.attribution:
|
|
2375
|
+
segments.append(SEVENTEEN_LANDS_ATTRIBUTION)
|
|
2376
|
+
if self._forced_pair is not None:
|
|
2377
|
+
override = _format_pair_label(
|
|
2378
|
+
pair=self._forced_pair,
|
|
2379
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
2380
|
+
)
|
|
2381
|
+
segments.insert(0, f"Override: {override}")
|
|
2382
|
+
|
|
2383
|
+
unresolved_count = self._unresolved_metadata_count()
|
|
2384
|
+
if unresolved_count > 0:
|
|
2385
|
+
segments.insert(0, f"Warning: {unresolved_count} unresolved card metadata")
|
|
2386
|
+
if self._build_error is not None:
|
|
2387
|
+
segments.insert(0, f"Build: {self._build_error}")
|
|
2388
|
+
if self._build_action_status is not None:
|
|
2389
|
+
segments.insert(0, f"Build action: {self._build_action_status}")
|
|
2390
|
+
if self._backtest_action_status is not None:
|
|
2391
|
+
segments.insert(0, f"Backtest action: {self._backtest_action_status}")
|
|
2392
|
+
if self._backtest_error is not None and self._view_mode == "backtest":
|
|
2393
|
+
segments.insert(0, f"Backtest: {self._backtest_error}")
|
|
2394
|
+
if self._last_error is not None:
|
|
2395
|
+
segments.insert(0, f"Error: {self._last_error}")
|
|
2396
|
+
if self._session_error is not None:
|
|
2397
|
+
segments.insert(0, f"Error: {self._session_error}")
|
|
2398
|
+
if self._preferences_save_warning is not None:
|
|
2399
|
+
segments.insert(0, self._preferences_save_warning)
|
|
2400
|
+
if self._preferences_load_warning is not None:
|
|
2401
|
+
segments.insert(0, self._preferences_load_warning)
|
|
2402
|
+
|
|
2403
|
+
status.update(" | ".join(segments))
|
|
2404
|
+
|
|
2405
|
+
def _splash_status_label(self) -> str:
|
|
2406
|
+
enabled_label = "On" if self.visibility_preferences.splash_enabled else "Off"
|
|
2407
|
+
return f"Splash: {enabled_label}"
|
|
2408
|
+
|
|
2409
|
+
def _column_keys_for_width(self) -> tuple[str, ...]:
|
|
2410
|
+
show_secondary = (
|
|
2411
|
+
self.show_secondary_columns and self.size.width >= SECONDARY_COLUMN_MIN_WIDTH
|
|
2412
|
+
)
|
|
2413
|
+
if show_secondary:
|
|
2414
|
+
return PRIMARY_COLUMN_KEYS + SECONDARY_COLUMN_KEYS
|
|
2415
|
+
|
|
2416
|
+
return PRIMARY_COLUMN_KEYS
|
|
2417
|
+
|
|
2418
|
+
def _sorted_cards(self) -> tuple[ScoredCard, ...]:
|
|
2419
|
+
if self._current_pack is None:
|
|
2420
|
+
return ()
|
|
2421
|
+
|
|
2422
|
+
return rank_scored_cards(
|
|
2423
|
+
cards=self._current_pack.cards,
|
|
2424
|
+
ranking_mode=self.sort_mode,
|
|
2425
|
+
)
|
|
2426
|
+
|
|
2427
|
+
def _recommendation_confidence_label(self) -> str | None:
|
|
2428
|
+
if self._view_mode != "pack" or self._current_pack is None:
|
|
2429
|
+
return None
|
|
2430
|
+
|
|
2431
|
+
return recommendation_confidence_summary(
|
|
2432
|
+
cards=self._sorted_cards(),
|
|
2433
|
+
ranking_mode=self.sort_mode,
|
|
2434
|
+
phase=self._current_pack.commitment.phase,
|
|
2435
|
+
)
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
def _metadata_status_text(self) -> str:
|
|
2439
|
+
visible_count = len(self._visible_metadata_grp_ids())
|
|
2440
|
+
if visible_count == 0:
|
|
2441
|
+
return "Metadata: waiting"
|
|
2442
|
+
|
|
2443
|
+
unresolved_count = self._unresolved_metadata_count()
|
|
2444
|
+
if unresolved_count == 0:
|
|
2445
|
+
return "Metadata: complete"
|
|
2446
|
+
|
|
2447
|
+
return f"Metadata warning: {unresolved_count} unresolved card metadata"
|
|
2448
|
+
|
|
2449
|
+
def _unresolved_metadata_count(self) -> int:
|
|
2450
|
+
unresolved_grp_ids = self.card_database.unresolved_grp_ids(
|
|
2451
|
+
grp_ids=self._visible_metadata_grp_ids(),
|
|
2452
|
+
)
|
|
2453
|
+
return len(unresolved_grp_ids)
|
|
2454
|
+
|
|
2455
|
+
def _visible_metadata_grp_ids(self) -> tuple[int, ...]:
|
|
2456
|
+
grp_ids = list(self._pool_grp_ids)
|
|
2457
|
+
if self._current_pack_event is not None:
|
|
2458
|
+
grp_ids.extend(self._current_pack_event.offered_grp_ids)
|
|
2459
|
+
|
|
2460
|
+
return tuple(grp_ids)
|
|
2461
|
+
|
|
2462
|
+
def _active_text_view(self) -> str:
|
|
2463
|
+
if self._view_mode == "backtest":
|
|
2464
|
+
return self._backtest_text
|
|
2465
|
+
|
|
2466
|
+
return self._build_text
|
|
2467
|
+
|
|
2468
|
+
def _request_build_view(self, *, success_message: str | None) -> None:
|
|
2469
|
+
if self.session.snapshot.pool.total_cards == 0 or self._set_code is None:
|
|
2470
|
+
self._build_error = "no picked cards yet"
|
|
2471
|
+
self._build_text = "Build view: no picked cards yet."
|
|
2472
|
+
self._build_pair_label = "—"
|
|
2473
|
+
self._build_result = None
|
|
2474
|
+
self._clear_build_render_state()
|
|
2475
|
+
if success_message is not None:
|
|
2476
|
+
self._build_action_status = "cannot build — no picked cards yet"
|
|
2477
|
+
return
|
|
2478
|
+
|
|
2479
|
+
self._pending_build_success_message = success_message
|
|
2480
|
+
if success_message is not None:
|
|
2481
|
+
self._build_action_status = None
|
|
2482
|
+
self._build_error = None
|
|
2483
|
+
self._view_mode = "build"
|
|
2484
|
+
self._dispatch_session_command_worker(
|
|
2485
|
+
RequestBuild(
|
|
2486
|
+
pair_override=self._forced_pair,
|
|
2487
|
+
allow_splash=self.visibility_preferences.splash_enabled,
|
|
2488
|
+
)
|
|
2489
|
+
)
|
|
2490
|
+
|
|
2491
|
+
def _request_backtest_view(
|
|
2492
|
+
self,
|
|
2493
|
+
*,
|
|
2494
|
+
success_message: str,
|
|
2495
|
+
open_when_ready: bool,
|
|
2496
|
+
) -> None:
|
|
2497
|
+
self._pending_backtest_success_message = success_message
|
|
2498
|
+
self._backtest_action_status = None
|
|
2499
|
+
self._backtest_error = None
|
|
2500
|
+
self._open_backtest_when_ready = open_when_ready
|
|
2501
|
+
self._dispatch_session_command_worker(
|
|
2502
|
+
RequestBacktest(
|
|
2503
|
+
account_id=self._active_account_id,
|
|
2504
|
+
draft_id=self._draft_id,
|
|
2505
|
+
)
|
|
2506
|
+
)
|
|
2507
|
+
|
|
2508
|
+
def _adopt_build_result(self, *, result: BuildResult) -> None:
|
|
2509
|
+
if (
|
|
2510
|
+
result.domain_pool is None
|
|
2511
|
+
or result.domain_selection is None
|
|
2512
|
+
or result.domain_spell_selection is None
|
|
2513
|
+
or result.domain_mana_base is None
|
|
2514
|
+
):
|
|
2515
|
+
self._adopt_build_error(
|
|
2516
|
+
snapshot=self.session.snapshot,
|
|
2517
|
+
message="Shared build result is missing detailed build data.",
|
|
2518
|
+
)
|
|
2519
|
+
return
|
|
2520
|
+
|
|
2521
|
+
self._build_result = result
|
|
2522
|
+
self._build_error = None
|
|
2523
|
+
self._last_error = None
|
|
2524
|
+
self._build_pair_label = result.selected_pair
|
|
2525
|
+
if self._pending_build_success_message is not None:
|
|
2526
|
+
self._build_action_status = self._pending_build_success_message
|
|
2527
|
+
self._pending_build_success_message = None
|
|
2528
|
+
self._refresh_build_text_from_result()
|
|
2529
|
+
|
|
2530
|
+
def _adopt_build_error(
|
|
2531
|
+
self,
|
|
2532
|
+
*,
|
|
2533
|
+
snapshot: LiveSessionSnapshot,
|
|
2534
|
+
message: str,
|
|
2535
|
+
) -> None:
|
|
2536
|
+
self._build_error = message
|
|
2537
|
+
self._build_text = _format_tui_build_error(
|
|
2538
|
+
pool=snapshot.pool,
|
|
2539
|
+
error=message,
|
|
2540
|
+
width=self._build_text_width(),
|
|
2541
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
2542
|
+
)
|
|
2543
|
+
self._build_pair_label = "—"
|
|
2544
|
+
self._build_result = None
|
|
2545
|
+
self._clear_build_render_state()
|
|
2546
|
+
self._pending_build_success_message = None
|
|
2547
|
+
self._build_action_status = f"cannot build — {message}"
|
|
2548
|
+
|
|
2549
|
+
def _adopt_backtest_result(self, *, result: BacktestResult) -> None:
|
|
2550
|
+
self._backtest_error = None
|
|
2551
|
+
self._backtest_text = _format_tui_backtest_result(result=result).rstrip("\n")
|
|
2552
|
+
if self._pending_backtest_success_message is not None:
|
|
2553
|
+
self._backtest_action_status = self._pending_backtest_success_message
|
|
2554
|
+
self._pending_backtest_success_message = None
|
|
2555
|
+
if self._open_backtest_when_ready:
|
|
2556
|
+
self._view_mode = "backtest"
|
|
2557
|
+
self.query_one("#build-scroll", VerticalScroll).scroll_home(animate=False)
|
|
2558
|
+
self._last_error = None
|
|
2559
|
+
self._open_backtest_when_ready = False
|
|
2560
|
+
|
|
2561
|
+
def _refresh_build_text_from_result(self) -> None:
|
|
2562
|
+
result = self._build_result
|
|
2563
|
+
if (
|
|
2564
|
+
result is None
|
|
2565
|
+
or result.domain_pool is None
|
|
2566
|
+
or result.domain_selection is None
|
|
2567
|
+
or result.domain_spell_selection is None
|
|
2568
|
+
or result.domain_mana_base is None
|
|
2569
|
+
):
|
|
2570
|
+
return
|
|
2571
|
+
|
|
2572
|
+
spell_selection = result.domain_spell_selection
|
|
2573
|
+
self._build_focus_cards = _tui_selected_spell_groups(
|
|
2574
|
+
spell_selection=spell_selection,
|
|
2575
|
+
spell_sort_mode=self._build_spell_sort_mode,
|
|
2576
|
+
)
|
|
2577
|
+
if not self._build_focus_cards:
|
|
2578
|
+
self._build_focused_card_index = 0
|
|
2579
|
+
else:
|
|
2580
|
+
self._build_focused_card_index = min(
|
|
2581
|
+
max(self._build_focused_card_index, 0),
|
|
2582
|
+
len(self._build_focus_cards) - 1,
|
|
2583
|
+
)
|
|
2584
|
+
|
|
2585
|
+
self._build_text = _format_tui_build_result(
|
|
2586
|
+
pool=result.domain_pool,
|
|
2587
|
+
selection=result.domain_selection,
|
|
2588
|
+
spell_selection=spell_selection,
|
|
2589
|
+
mana_base=result.domain_mana_base,
|
|
2590
|
+
card_database=self.card_database,
|
|
2591
|
+
spell_sort_mode=self._build_spell_sort_mode,
|
|
2592
|
+
show_details=self._build_show_details,
|
|
2593
|
+
focused_card_index=self._build_focused_card_index,
|
|
2594
|
+
width=self._build_text_width(),
|
|
2595
|
+
visibility_preferences=self.visibility_preferences,
|
|
2596
|
+
mana_icons_enabled=self.mana_icons_enabled,
|
|
2597
|
+
)
|
|
2598
|
+
|
|
2599
|
+
def _clear_build_render_state(self) -> None:
|
|
2600
|
+
self._build_focus_cards = ()
|
|
2601
|
+
self._build_focused_card_index = 0
|
|
2602
|
+
|
|
2603
|
+
def _build_text_width(self) -> int:
|
|
2604
|
+
sidebar_width = max(0, self.query_one("#sidebar", Vertical).size.width)
|
|
2605
|
+
return max(60, self.size.width - sidebar_width - 4)
|
|
2606
|
+
|
|
2607
|
+
def _next_forced_pair(self) -> str:
|
|
2608
|
+
current_pair = self._forced_pair
|
|
2609
|
+
if current_pair is None and self._build_pair_label in COLOR_PAIRS:
|
|
2610
|
+
current_pair = self._build_pair_label
|
|
2611
|
+
|
|
2612
|
+
if current_pair not in COLOR_PAIRS:
|
|
2613
|
+
return COLOR_PAIRS[0]
|
|
2614
|
+
|
|
2615
|
+
index = COLOR_PAIRS.index(current_pair)
|
|
2616
|
+
return COLOR_PAIRS[(index + 1) % len(COLOR_PAIRS)]
|
|
2617
|
+
|
|
2618
|
+
def _account_label(
|
|
2619
|
+
self,
|
|
2620
|
+
*,
|
|
2621
|
+
account_id: str | None,
|
|
2622
|
+
snapshot: LiveSessionSnapshot | None = None,
|
|
2623
|
+
) -> str:
|
|
2624
|
+
client_id = account_id or self._active_account_id
|
|
2625
|
+
if client_id is None:
|
|
2626
|
+
return "unknown"
|
|
2627
|
+
|
|
2628
|
+
current = self.session.snapshot if snapshot is None else snapshot
|
|
2629
|
+
identity = next(
|
|
2630
|
+
(
|
|
2631
|
+
account
|
|
2632
|
+
for account in current.accounts
|
|
2633
|
+
if account.account_id == client_id
|
|
2634
|
+
),
|
|
2635
|
+
None,
|
|
2636
|
+
)
|
|
2637
|
+
if identity is None or identity.screen_name is None:
|
|
2638
|
+
return client_id
|
|
2639
|
+
|
|
2640
|
+
return identity.screen_name
|
|
2641
|
+
|
|
2642
|
+
def _record_error(self, message: str) -> None:
|
|
2643
|
+
self._last_error = message
|
|
2644
|
+
self._render_all()
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
def _card_image_preview_enabled(*, env: Mapping[str, str]) -> bool:
|
|
2648
|
+
if TgpImage is None:
|
|
2649
|
+
return False
|
|
2650
|
+
|
|
2651
|
+
override = env.get(CARD_IMAGE_PREVIEW_ENV)
|
|
2652
|
+
if override is not None:
|
|
2653
|
+
return override.strip().casefold() in {"1", "true", "yes", "on"}
|
|
2654
|
+
|
|
2655
|
+
term_program = env.get("TERM_PROGRAM", "").casefold()
|
|
2656
|
+
if term_program in {"ghostty", "kitty", "wezterm"}:
|
|
2657
|
+
return True
|
|
2658
|
+
|
|
2659
|
+
term = env.get("TERM", "").casefold()
|
|
2660
|
+
if "kitty" in term or "ghostty" in term:
|
|
2661
|
+
return True
|
|
2662
|
+
|
|
2663
|
+
return bool(
|
|
2664
|
+
env.get("KITTY_WINDOW_ID")
|
|
2665
|
+
or env.get("GHOSTTY_RESOURCES_DIR")
|
|
2666
|
+
or env.get("WEZTERM_EXECUTABLE")
|
|
2667
|
+
)
|
|
2668
|
+
|
|
2669
|
+
def _operation_error(
|
|
2670
|
+
*,
|
|
2671
|
+
snapshot: LiveSessionSnapshot,
|
|
2672
|
+
operation: OperationKind,
|
|
2673
|
+
) -> SessionError | None:
|
|
2674
|
+
return next(
|
|
2675
|
+
(
|
|
2676
|
+
error
|
|
2677
|
+
for error in reversed(snapshot.errors)
|
|
2678
|
+
if error.operation == operation
|
|
2679
|
+
),
|
|
2680
|
+
None,
|
|
2681
|
+
)
|
|
2682
|
+
|
|
2683
|
+
|
|
2684
|
+
def _format_tui_backtest_result(*, result: BacktestResult) -> str:
|
|
2685
|
+
account = result.account_id or "unknown"
|
|
2686
|
+
if result.account_screen_name is not None:
|
|
2687
|
+
account = f"{result.account_screen_name} ({account})"
|
|
2688
|
+
completed = (
|
|
2689
|
+
"unknown" if result.completed is None else _format_tui_yes_no(result.completed)
|
|
2690
|
+
)
|
|
2691
|
+
chosen_pick_count = (
|
|
2692
|
+
"unknown"
|
|
2693
|
+
if result.chosen_pick_count is None
|
|
2694
|
+
else str(result.chosen_pick_count)
|
|
2695
|
+
)
|
|
2696
|
+
data_sources = "none" if not result.data_sources else "; ".join(result.data_sources)
|
|
2697
|
+
lines = [
|
|
2698
|
+
"Draft Omen backtest",
|
|
2699
|
+
f"Account: {account}",
|
|
2700
|
+
f"Set: {result.set_code or 'unknown'}",
|
|
2701
|
+
f"Event: {result.event_name or 'unknown'}",
|
|
2702
|
+
f"Draft: {result.draft_id or 'unknown'}",
|
|
2703
|
+
f"Completed: {completed}",
|
|
2704
|
+
f"Ranking: {ranking_label(ranking_mode=result.ranking_mode)}",
|
|
2705
|
+
(
|
|
2706
|
+
f"Picks: {chosen_pick_count} chosen, "
|
|
2707
|
+
f"{result.compared_count} compared, {result.skipped_count} skipped"
|
|
2708
|
+
),
|
|
2709
|
+
f"Data sources: {data_sources}",
|
|
2710
|
+
"",
|
|
2711
|
+
]
|
|
2712
|
+
lines.extend(_format_tui_backtest_rows(result=result))
|
|
2713
|
+
lines.append("")
|
|
2714
|
+
lines.extend(_format_tui_backtest_summary(result=result))
|
|
2715
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
def _format_tui_backtest_rows(*, result: BacktestResult) -> list[str]:
|
|
2719
|
+
if not result.rows:
|
|
2720
|
+
return ["No saved picks were found for this draft."]
|
|
2721
|
+
|
|
2722
|
+
recommended_values = tuple(
|
|
2723
|
+
_format_tui_backtest_recommended(row=row) for row in result.rows
|
|
2724
|
+
)
|
|
2725
|
+
actual_values = tuple(_format_tui_backtest_actual(row=row) for row in result.rows)
|
|
2726
|
+
recommended_width = max(
|
|
2727
|
+
len("Recommended"),
|
|
2728
|
+
*(len(value) for value in recommended_values),
|
|
2729
|
+
)
|
|
2730
|
+
actual_width = max(len("Actual"), *(len(value) for value in actual_values))
|
|
2731
|
+
lines = [
|
|
2732
|
+
"Pack Pick Pool 17L WR DO "
|
|
2733
|
+
f"{'Recommended':<{recommended_width}} "
|
|
2734
|
+
f"{'Actual':<{actual_width}} "
|
|
2735
|
+
"Match"
|
|
2736
|
+
]
|
|
2737
|
+
for row, recommended, actual in zip(
|
|
2738
|
+
result.rows,
|
|
2739
|
+
recommended_values,
|
|
2740
|
+
actual_values,
|
|
2741
|
+
strict=True,
|
|
2742
|
+
):
|
|
2743
|
+
lines.append(
|
|
2744
|
+
f"{row.pack_number + 1:>4} "
|
|
2745
|
+
f"{row.pick_number + 1:>4} "
|
|
2746
|
+
f"{_format_tui_optional_int(row.pool_size):>4} "
|
|
2747
|
+
f"{_format_tui_backtest_win_rate(row=row):>6} "
|
|
2748
|
+
f"{_format_tui_backtest_score(row=row):>3} "
|
|
2749
|
+
f"{recommended:<{recommended_width}} "
|
|
2750
|
+
f"{actual:<{actual_width}} "
|
|
2751
|
+
f"{_format_tui_backtest_match(row=row)}"
|
|
2752
|
+
)
|
|
2753
|
+
|
|
2754
|
+
return lines
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def _format_tui_backtest_summary(*, result: BacktestResult) -> list[str]:
|
|
2758
|
+
if result.compared_count == 0:
|
|
2759
|
+
lines = [
|
|
2760
|
+
f"Summary: no comparable picks; {result.skipped_count} skipped."
|
|
2761
|
+
]
|
|
2762
|
+
else:
|
|
2763
|
+
match_rate = result.match_count / result.compared_count
|
|
2764
|
+
lines = [
|
|
2765
|
+
"Summary: "
|
|
2766
|
+
f"{result.match_count}/{result.compared_count} recommendations matched "
|
|
2767
|
+
f"actual picks ({match_rate:.1%})."
|
|
2768
|
+
]
|
|
2769
|
+
|
|
2770
|
+
if result.skipped_count:
|
|
2771
|
+
lines.append(
|
|
2772
|
+
"Skipped picks were not scored when saved offered-card or "
|
|
2773
|
+
"pool-before-pick history was missing."
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
return lines
|
|
2777
|
+
|
|
2778
|
+
|
|
2779
|
+
def _format_tui_backtest_recommended(*, row: BacktestPickResult) -> str:
|
|
2780
|
+
if row.recommended is not None:
|
|
2781
|
+
return _format_tui_card_view(card=row.recommended)
|
|
2782
|
+
|
|
2783
|
+
reason = row.skipped_reason or "not scored"
|
|
2784
|
+
return f"skipped: {reason}"
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def _format_tui_backtest_actual(*, row: BacktestPickResult) -> str:
|
|
2788
|
+
if row.actual is not None:
|
|
2789
|
+
return _format_tui_card_view(card=row.actual)
|
|
2790
|
+
|
|
2791
|
+
return row.skipped_reason or "missing actual selected card"
|
|
2792
|
+
|
|
2793
|
+
|
|
2794
|
+
def _format_tui_backtest_win_rate(*, row: BacktestPickResult) -> str:
|
|
2795
|
+
if row.recommended is None or row.recommended_win_rate is None:
|
|
2796
|
+
return "—"
|
|
2797
|
+
|
|
2798
|
+
return f"{row.recommended_win_rate:.1%}"
|
|
2799
|
+
|
|
2800
|
+
|
|
2801
|
+
def _format_tui_backtest_score(*, row: BacktestPickResult) -> str:
|
|
2802
|
+
if row.recommended is None or row.recommended_score is None:
|
|
2803
|
+
return "—"
|
|
2804
|
+
|
|
2805
|
+
return str(row.recommended_score)
|
|
2806
|
+
|
|
2807
|
+
|
|
2808
|
+
def _format_tui_backtest_match(*, row: BacktestPickResult) -> str:
|
|
2809
|
+
if row.match is None:
|
|
2810
|
+
return "skipped"
|
|
2811
|
+
|
|
2812
|
+
return _format_tui_yes_no(row.match)
|
|
2813
|
+
|
|
2814
|
+
|
|
2815
|
+
def _format_tui_card_view(*, card: CardView) -> str:
|
|
2816
|
+
colors = "Unknown" if _card_view_is_unknown(card=card) else (
|
|
2817
|
+
"".join(card.colors) if card.colors else "Colorless"
|
|
2818
|
+
)
|
|
2819
|
+
return f"{card.name} [{colors}] (grpId {card.grp_id})"
|
|
2820
|
+
|
|
2821
|
+
|
|
2822
|
+
def _format_tui_optional_int(value: int | None) -> str:
|
|
2823
|
+
return "—" if value is None else str(value)
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
def _format_tui_yes_no(value: bool) -> str:
|
|
2827
|
+
return "yes" if value else "no"
|
|
2828
|
+
|
|
2829
|
+
|
|
2830
|
+
def _card_info_from_view(*, card: CardView) -> CardInfo:
|
|
2831
|
+
return CardInfo(
|
|
2832
|
+
grp_id=card.grp_id,
|
|
2833
|
+
name=card.name,
|
|
2834
|
+
colors=card.colors,
|
|
2835
|
+
mana_value=card.mana_value,
|
|
2836
|
+
rarity=card.rarity,
|
|
2837
|
+
types=card.types,
|
|
2838
|
+
mana_cost=card.mana_cost,
|
|
2839
|
+
unknown=_card_view_is_unknown(card=card),
|
|
2840
|
+
)
|
|
2841
|
+
|
|
2842
|
+
|
|
2843
|
+
def _card_view_is_unknown(*, card: CardView) -> bool:
|
|
2844
|
+
return card.rarity == "unknown" and card.types == ("Unknown",)
|
|
2845
|
+
|
|
2846
|
+
|
|
2847
|
+
|
|
2848
|
+
_BUILD_COLUMN_MIN_WIDTH = 34
|
|
2849
|
+
_BUILD_COLUMN_MAX_WIDTH = 80
|
|
2850
|
+
|
|
2851
|
+
|
|
2852
|
+
def _format_tui_build_result(
|
|
2853
|
+
*,
|
|
2854
|
+
pool: BuildPool,
|
|
2855
|
+
selection: PairSelection,
|
|
2856
|
+
spell_selection: SpellSelection,
|
|
2857
|
+
mana_base: ManaBase,
|
|
2858
|
+
card_database: CardDatabase,
|
|
2859
|
+
spell_sort_mode: str,
|
|
2860
|
+
show_details: bool,
|
|
2861
|
+
focused_card_index: int,
|
|
2862
|
+
width: int,
|
|
2863
|
+
visibility_preferences: TuiVisibilityPreferences,
|
|
2864
|
+
mana_icons_enabled: bool = False,
|
|
2865
|
+
) -> str:
|
|
2866
|
+
chosen_label = "forced" if selection.forced_pair is not None else "automatic"
|
|
2867
|
+
color_pair = _format_pair_label(
|
|
2868
|
+
pair=selection.chosen.pair,
|
|
2869
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2870
|
+
)
|
|
2871
|
+
counts = spell_selection.counts
|
|
2872
|
+
lines = [
|
|
2873
|
+
"[bold]Suggested deck[/bold]",
|
|
2874
|
+
"",
|
|
2875
|
+
f"Set: {format_set_label(set_code=pool.set_code)}",
|
|
2876
|
+
f"Color pair: {color_pair} ({chosen_label})",
|
|
2877
|
+
(
|
|
2878
|
+
f"Deck: {mana_base.deck_size} cards — "
|
|
2879
|
+
f"{mana_base.spell_count} spells, {mana_base.land_count} lands"
|
|
2880
|
+
),
|
|
2881
|
+
f"Average mana value: {mana_base.average_mana_value:.2f}",
|
|
2882
|
+
(
|
|
2883
|
+
f"Creatures: {counts.creatures}; "
|
|
2884
|
+
f"Noncreatures: {counts.noncreatures}; Lands: {mana_base.land_count}"
|
|
2885
|
+
),
|
|
2886
|
+
(
|
|
2887
|
+
"Ratings: rows show 17Lands WR and 17Lands-style grade from "
|
|
2888
|
+
"each source format."
|
|
2889
|
+
),
|
|
2890
|
+
(
|
|
2891
|
+
"Keys: b checks build status; ↑/↓/←/→ or j/k browse cards; "
|
|
2892
|
+
"PgUp/PgDn page; s changes spell sort; c opens config; "
|
|
2893
|
+
"p changes pair; m toggles Mana icons"
|
|
2894
|
+
),
|
|
2895
|
+
"",
|
|
2896
|
+
]
|
|
2897
|
+
lines.extend(
|
|
2898
|
+
_format_tui_selected_spells(
|
|
2899
|
+
spell_selection=spell_selection,
|
|
2900
|
+
spell_sort_mode=spell_sort_mode,
|
|
2901
|
+
focused_card_index=focused_card_index,
|
|
2902
|
+
width=width,
|
|
2903
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2904
|
+
)
|
|
2905
|
+
)
|
|
2906
|
+
lines.append("")
|
|
2907
|
+
lines.extend(
|
|
2908
|
+
_format_tui_lands(
|
|
2909
|
+
mana_base=mana_base,
|
|
2910
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2911
|
+
)
|
|
2912
|
+
)
|
|
2913
|
+
if show_details:
|
|
2914
|
+
lines.append("")
|
|
2915
|
+
lines.extend(
|
|
2916
|
+
_format_tui_build_context(
|
|
2917
|
+
pool=pool,
|
|
2918
|
+
selection=selection,
|
|
2919
|
+
spell_selection=spell_selection,
|
|
2920
|
+
mana_base=mana_base,
|
|
2921
|
+
visibility_preferences=visibility_preferences,
|
|
2922
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2923
|
+
)
|
|
2924
|
+
)
|
|
2925
|
+
lines.append("")
|
|
2926
|
+
lines.extend(
|
|
2927
|
+
_format_tui_spell_counts(
|
|
2928
|
+
spell_selection=spell_selection,
|
|
2929
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2930
|
+
)
|
|
2931
|
+
)
|
|
2932
|
+
lines.append("")
|
|
2933
|
+
lines.extend(
|
|
2934
|
+
_format_tui_picked_pool(
|
|
2935
|
+
pool=pool,
|
|
2936
|
+
card_database=card_database,
|
|
2937
|
+
width=width,
|
|
2938
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2939
|
+
)
|
|
2940
|
+
)
|
|
2941
|
+
lines.append("")
|
|
2942
|
+
lines.extend(
|
|
2943
|
+
_format_tui_pair_scores(
|
|
2944
|
+
selection=selection,
|
|
2945
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2946
|
+
)
|
|
2947
|
+
)
|
|
2948
|
+
lines.append("")
|
|
2949
|
+
lines.extend(
|
|
2950
|
+
_format_tui_bench(
|
|
2951
|
+
selection=spell_selection,
|
|
2952
|
+
width=width,
|
|
2953
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2954
|
+
)
|
|
2955
|
+
)
|
|
2956
|
+
else:
|
|
2957
|
+
lines.append("")
|
|
2958
|
+
lines.append(
|
|
2959
|
+
"Details hidden: open config with c to show build context, picked pool, "
|
|
2960
|
+
"color-pair reasoning, structure checks, and bench cuts."
|
|
2961
|
+
)
|
|
2962
|
+
|
|
2963
|
+
return "\n".join(lines) + "\n"
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def _format_tui_build_context(
|
|
2967
|
+
*,
|
|
2968
|
+
pool: BuildPool,
|
|
2969
|
+
selection: PairSelection,
|
|
2970
|
+
spell_selection: SpellSelection,
|
|
2971
|
+
mana_base: ManaBase,
|
|
2972
|
+
visibility_preferences: TuiVisibilityPreferences,
|
|
2973
|
+
mana_icons_enabled: bool = False,
|
|
2974
|
+
) -> list[str]:
|
|
2975
|
+
lines = ["Build context"]
|
|
2976
|
+
if visibility_preferences.pool_mana_curve:
|
|
2977
|
+
lines.append(_format_tui_curve_summary(spells=spell_selection.spells))
|
|
2978
|
+
if visibility_preferences.pool_metadata:
|
|
2979
|
+
lines.extend(
|
|
2980
|
+
(
|
|
2981
|
+
f"Pool: {pool.source_label}",
|
|
2982
|
+
f"Pool size: {selection.pool_size} cards",
|
|
2983
|
+
)
|
|
2984
|
+
)
|
|
2985
|
+
if visibility_preferences.account_identifier and pool.account_id is not None:
|
|
2986
|
+
lines.append(f"Account: {pool.account_id}")
|
|
2987
|
+
if visibility_preferences.draft_identifier and pool.draft_id is not None:
|
|
2988
|
+
lines.append(f"Draft: {pool.draft_id}")
|
|
2989
|
+
if visibility_preferences.mana_pips_and_sources:
|
|
2990
|
+
mana_pips = _format_plain_color_counts(
|
|
2991
|
+
mana_base.pip_counts,
|
|
2992
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2993
|
+
)
|
|
2994
|
+
mana_sources = _format_plain_color_counts(
|
|
2995
|
+
mana_base.source_counts,
|
|
2996
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
2997
|
+
)
|
|
2998
|
+
lines.extend([
|
|
2999
|
+
f"Mana pips: {mana_pips}",
|
|
3000
|
+
f"Mana sources: {mana_sources}",
|
|
3001
|
+
])
|
|
3002
|
+
return lines
|
|
3003
|
+
|
|
3004
|
+
|
|
3005
|
+
def _format_tui_build_error(
|
|
3006
|
+
*,
|
|
3007
|
+
pool: PoolState,
|
|
3008
|
+
error: str,
|
|
3009
|
+
width: int,
|
|
3010
|
+
mana_icons_enabled: bool = False,
|
|
3011
|
+
) -> str:
|
|
3012
|
+
lines = [
|
|
3013
|
+
f"Build view unavailable: {error}",
|
|
3014
|
+
"",
|
|
3015
|
+
]
|
|
3016
|
+
lines.extend(
|
|
3017
|
+
_format_tui_snapshot_pool(
|
|
3018
|
+
pool=pool,
|
|
3019
|
+
width=width,
|
|
3020
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3021
|
+
)
|
|
3022
|
+
)
|
|
3023
|
+
return "\n".join(lines) + "\n"
|
|
3024
|
+
|
|
3025
|
+
|
|
3026
|
+
def _format_tui_snapshot_pool(
|
|
3027
|
+
*,
|
|
3028
|
+
pool: PoolState,
|
|
3029
|
+
width: int,
|
|
3030
|
+
mana_icons_enabled: bool = False,
|
|
3031
|
+
) -> list[str]:
|
|
3032
|
+
lines = [f"Picked pool ({pool.total_cards})"]
|
|
3033
|
+
if not pool.cards:
|
|
3034
|
+
return lines + ["- none"]
|
|
3035
|
+
|
|
3036
|
+
index = 0
|
|
3037
|
+
for pool_card in pool.cards:
|
|
3038
|
+
card = _card_info_from_view(card=pool_card.card)
|
|
3039
|
+
for _ in range(pool_card.quantity):
|
|
3040
|
+
index += 1
|
|
3041
|
+
lines.append(
|
|
3042
|
+
_clip(
|
|
3043
|
+
text=_format_tui_picked_card(
|
|
3044
|
+
index=index,
|
|
3045
|
+
card=card,
|
|
3046
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3047
|
+
),
|
|
3048
|
+
width=width,
|
|
3049
|
+
)
|
|
3050
|
+
)
|
|
3051
|
+
|
|
3052
|
+
return lines
|
|
3053
|
+
|
|
3054
|
+
|
|
3055
|
+
def _format_tui_picked_pool(
|
|
3056
|
+
*,
|
|
3057
|
+
pool: BuildPool,
|
|
3058
|
+
card_database: CardDatabase,
|
|
3059
|
+
width: int,
|
|
3060
|
+
mana_icons_enabled: bool = False,
|
|
3061
|
+
) -> list[str]:
|
|
3062
|
+
lines = [f"Picked pool ({len(pool.pool_grp_ids)})"]
|
|
3063
|
+
if not pool.pool_grp_ids:
|
|
3064
|
+
return lines + ["- none"]
|
|
3065
|
+
|
|
3066
|
+
for index, grp_id in enumerate(pool.pool_grp_ids, start=1):
|
|
3067
|
+
card = card_database.lookup(grp_id=grp_id)
|
|
3068
|
+
lines.append(
|
|
3069
|
+
_clip(
|
|
3070
|
+
text=_format_tui_picked_card(
|
|
3071
|
+
index=index,
|
|
3072
|
+
card=card,
|
|
3073
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3074
|
+
),
|
|
3075
|
+
width=width,
|
|
3076
|
+
)
|
|
3077
|
+
)
|
|
3078
|
+
|
|
3079
|
+
return lines
|
|
3080
|
+
|
|
3081
|
+
|
|
3082
|
+
def _format_tui_picked_card(
|
|
3083
|
+
*,
|
|
3084
|
+
index: int,
|
|
3085
|
+
card: CardInfo,
|
|
3086
|
+
mana_icons_enabled: bool = False,
|
|
3087
|
+
) -> str:
|
|
3088
|
+
marker = "[unresolved] " if card.unknown else ""
|
|
3089
|
+
color_label = _format_card_colors(
|
|
3090
|
+
card=card,
|
|
3091
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3092
|
+
long_colorless=True,
|
|
3093
|
+
)
|
|
3094
|
+
mana_value = _format_mana_value(card=card)
|
|
3095
|
+
card_name = _format_card_name(card=card)
|
|
3096
|
+
return f"{index:02d}. {marker}{card_name} | Colors {color_label} | MV {mana_value}"
|
|
3097
|
+
|
|
3098
|
+
|
|
3099
|
+
def _format_tui_curve_summary(*, spells: tuple[ScoredCard, ...]) -> str:
|
|
3100
|
+
counts = [0 for _ in CURVE_BUCKET_LABELS]
|
|
3101
|
+
unknown_count = 0
|
|
3102
|
+
for card in spells:
|
|
3103
|
+
mana_value = card.card.mana_value
|
|
3104
|
+
if mana_value is None:
|
|
3105
|
+
unknown_count += 1
|
|
3106
|
+
continue
|
|
3107
|
+
|
|
3108
|
+
counts[_curve_bucket(mana_value=mana_value)] += 1
|
|
3109
|
+
|
|
3110
|
+
parts = [
|
|
3111
|
+
f"{label}: {counts[index]}"
|
|
3112
|
+
for index, label in enumerate(CURVE_BUCKET_LABELS)
|
|
3113
|
+
]
|
|
3114
|
+
if unknown_count > 0:
|
|
3115
|
+
parts.append(f"?: {unknown_count}")
|
|
3116
|
+
|
|
3117
|
+
return "Mana curve: " + " | ".join(parts)
|
|
3118
|
+
|
|
3119
|
+
|
|
3120
|
+
def _format_tui_selected_spells(
|
|
3121
|
+
*,
|
|
3122
|
+
spell_selection: SpellSelection,
|
|
3123
|
+
spell_sort_mode: str,
|
|
3124
|
+
focused_card_index: int,
|
|
3125
|
+
width: int,
|
|
3126
|
+
mana_icons_enabled: bool = False,
|
|
3127
|
+
) -> list[str]:
|
|
3128
|
+
groups = _tui_selected_spell_groups(
|
|
3129
|
+
spell_selection=spell_selection,
|
|
3130
|
+
spell_sort_mode=spell_sort_mode,
|
|
3131
|
+
)
|
|
3132
|
+
if spell_sort_mode == "score":
|
|
3133
|
+
return _format_tui_spell_columns(
|
|
3134
|
+
title="Selected spells by score",
|
|
3135
|
+
groups=groups,
|
|
3136
|
+
total_count=len(spell_selection.spells),
|
|
3137
|
+
focused_card_index=focused_card_index,
|
|
3138
|
+
width=width,
|
|
3139
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3140
|
+
)
|
|
3141
|
+
|
|
3142
|
+
if spell_sort_mode == "name":
|
|
3143
|
+
return _format_tui_spell_columns(
|
|
3144
|
+
title="Selected spells by name",
|
|
3145
|
+
groups=groups,
|
|
3146
|
+
total_count=len(spell_selection.spells),
|
|
3147
|
+
focused_card_index=focused_card_index,
|
|
3148
|
+
width=width,
|
|
3149
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3150
|
+
)
|
|
3151
|
+
|
|
3152
|
+
return _format_tui_spell_curve(
|
|
3153
|
+
groups=groups,
|
|
3154
|
+
total_count=len(spell_selection.spells),
|
|
3155
|
+
focused_card_index=focused_card_index,
|
|
3156
|
+
width=width,
|
|
3157
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3158
|
+
)
|
|
3159
|
+
|
|
3160
|
+
|
|
3161
|
+
def _tui_selected_spell_groups(
|
|
3162
|
+
*,
|
|
3163
|
+
spell_selection: SpellSelection,
|
|
3164
|
+
spell_sort_mode: str,
|
|
3165
|
+
) -> tuple[TuiCardQuantityGroup, ...]:
|
|
3166
|
+
if spell_sort_mode == "score":
|
|
3167
|
+
return _group_tui_spell_cards(
|
|
3168
|
+
cards=tuple(sorted(
|
|
3169
|
+
spell_selection.spells,
|
|
3170
|
+
key=lambda card: (
|
|
3171
|
+
-card.score,
|
|
3172
|
+
_format_mana_value(card=card.card),
|
|
3173
|
+
card.card.name,
|
|
3174
|
+
),
|
|
3175
|
+
)),
|
|
3176
|
+
)
|
|
3177
|
+
|
|
3178
|
+
if spell_sort_mode == "name":
|
|
3179
|
+
return _group_tui_spell_cards(
|
|
3180
|
+
cards=tuple(sorted(
|
|
3181
|
+
spell_selection.spells,
|
|
3182
|
+
key=lambda card: card.card.name,
|
|
3183
|
+
)),
|
|
3184
|
+
)
|
|
3185
|
+
|
|
3186
|
+
return _group_tui_spell_cards(
|
|
3187
|
+
cards=tuple(sorted(spell_selection.spells, key=_tui_spell_curve_sort_key)),
|
|
3188
|
+
)
|
|
3189
|
+
|
|
3190
|
+
|
|
3191
|
+
def _format_tui_spell_curve(
|
|
3192
|
+
*,
|
|
3193
|
+
groups: tuple[TuiCardQuantityGroup, ...],
|
|
3194
|
+
total_count: int,
|
|
3195
|
+
focused_card_index: int,
|
|
3196
|
+
width: int,
|
|
3197
|
+
mana_icons_enabled: bool = False,
|
|
3198
|
+
) -> list[str]:
|
|
3199
|
+
groups_by_bucket: dict[str, list[tuple[int, TuiCardQuantityGroup]]] = {}
|
|
3200
|
+
for index, group in enumerate(groups):
|
|
3201
|
+
groups_by_bucket.setdefault(_mana_value_bucket(card=group[0]), []).append(
|
|
3202
|
+
(index, group),
|
|
3203
|
+
)
|
|
3204
|
+
|
|
3205
|
+
blocks: list[list[str]] = []
|
|
3206
|
+
for bucket in _ordered_mana_buckets(groups={
|
|
3207
|
+
key: [card for _, (card, _) in values]
|
|
3208
|
+
for key, values in groups_by_bucket.items()
|
|
3209
|
+
}):
|
|
3210
|
+
indexed_groups = groups_by_bucket[bucket]
|
|
3211
|
+
bucket_count = sum(quantity for _, (_, quantity) in indexed_groups)
|
|
3212
|
+
block = [f"MV {bucket} ({bucket_count})"]
|
|
3213
|
+
block.extend(
|
|
3214
|
+
_format_tui_spell_card(
|
|
3215
|
+
card=card,
|
|
3216
|
+
quantity=quantity,
|
|
3217
|
+
focused=index == focused_card_index,
|
|
3218
|
+
show_focus_marker=True,
|
|
3219
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3220
|
+
)
|
|
3221
|
+
for index, (card, quantity) in indexed_groups
|
|
3222
|
+
)
|
|
3223
|
+
blocks.append(block)
|
|
3224
|
+
|
|
3225
|
+
return [f"Selected spells by mana value ({total_count})"] + _columnize_blocks(
|
|
3226
|
+
blocks=blocks,
|
|
3227
|
+
width=width,
|
|
3228
|
+
)
|
|
3229
|
+
|
|
3230
|
+
|
|
3231
|
+
def _format_tui_spell_columns(
|
|
3232
|
+
*,
|
|
3233
|
+
title: str,
|
|
3234
|
+
groups: tuple[TuiCardQuantityGroup, ...],
|
|
3235
|
+
total_count: int,
|
|
3236
|
+
focused_card_index: int,
|
|
3237
|
+
width: int,
|
|
3238
|
+
mana_icons_enabled: bool = False,
|
|
3239
|
+
) -> list[str]:
|
|
3240
|
+
blocks = [
|
|
3241
|
+
[
|
|
3242
|
+
_format_tui_spell_card(
|
|
3243
|
+
card=card,
|
|
3244
|
+
quantity=quantity,
|
|
3245
|
+
focused=index == focused_card_index,
|
|
3246
|
+
show_focus_marker=True,
|
|
3247
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3248
|
+
)
|
|
3249
|
+
]
|
|
3250
|
+
for index, (card, quantity) in enumerate(groups)
|
|
3251
|
+
]
|
|
3252
|
+
return [f"{title} ({total_count})"] + _columnize_blocks(blocks=blocks, width=width)
|
|
3253
|
+
|
|
3254
|
+
|
|
3255
|
+
def _columnize_blocks(*, blocks: list[list[str]], width: int) -> list[str]:
|
|
3256
|
+
if not blocks:
|
|
3257
|
+
return ["- none"]
|
|
3258
|
+
|
|
3259
|
+
column_count = max(1, min(2, width // _BUILD_COLUMN_MIN_WIDTH))
|
|
3260
|
+
column_width = min(
|
|
3261
|
+
_BUILD_COLUMN_MAX_WIDTH,
|
|
3262
|
+
max(_BUILD_COLUMN_MIN_WIDTH, width // column_count),
|
|
3263
|
+
)
|
|
3264
|
+
columns: list[list[str]] = [[] for _ in range(column_count)]
|
|
3265
|
+
heights = [0 for _ in range(column_count)]
|
|
3266
|
+
for block in blocks:
|
|
3267
|
+
column_index = min(range(column_count), key=lambda index: heights[index])
|
|
3268
|
+
if columns[column_index]:
|
|
3269
|
+
columns[column_index].append("")
|
|
3270
|
+
heights[column_index] += 1
|
|
3271
|
+
|
|
3272
|
+
columns[column_index].extend(block)
|
|
3273
|
+
heights[column_index] += len(block)
|
|
3274
|
+
|
|
3275
|
+
max_height = max(len(column) for column in columns)
|
|
3276
|
+
lines: list[str] = []
|
|
3277
|
+
for row_index in range(max_height):
|
|
3278
|
+
parts = []
|
|
3279
|
+
for column in columns:
|
|
3280
|
+
text = column[row_index] if row_index < len(column) else ""
|
|
3281
|
+
parts.append(_clip(text=text, width=column_width - 2).ljust(column_width))
|
|
3282
|
+
|
|
3283
|
+
lines.append("".join(parts).rstrip())
|
|
3284
|
+
|
|
3285
|
+
return lines
|
|
3286
|
+
|
|
3287
|
+
|
|
3288
|
+
def _format_tui_lands(
|
|
3289
|
+
*,
|
|
3290
|
+
mana_base: ManaBase,
|
|
3291
|
+
mana_icons_enabled: bool = False,
|
|
3292
|
+
) -> list[str]:
|
|
3293
|
+
lines = [f"Lands: {mana_base.land_count} ({mana_base.reason})"]
|
|
3294
|
+
basics = ", ".join(
|
|
3295
|
+
f"{basic.count} {basic.name}" for basic in mana_base.basic_lands
|
|
3296
|
+
)
|
|
3297
|
+
if basics:
|
|
3298
|
+
lines.append(f"Basics: {basics}")
|
|
3299
|
+
else:
|
|
3300
|
+
lines.append("Basics: none")
|
|
3301
|
+
|
|
3302
|
+
if mana_base.nonbasic_lands:
|
|
3303
|
+
nonbasic_parts = []
|
|
3304
|
+
for land in mana_base.nonbasic_lands:
|
|
3305
|
+
source_label = _format_color_label(
|
|
3306
|
+
colors=land.source_colors,
|
|
3307
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3308
|
+
long_colorless=False,
|
|
3309
|
+
)
|
|
3310
|
+
nonbasic_parts.append(f"{land.card.name} ({source_label} source)")
|
|
3311
|
+
|
|
3312
|
+
lines.append(f"Nonbasics: {'; '.join(nonbasic_parts)}")
|
|
3313
|
+
else:
|
|
3314
|
+
lines.append("Nonbasics: none")
|
|
3315
|
+
|
|
3316
|
+
return lines
|
|
3317
|
+
|
|
3318
|
+
|
|
3319
|
+
def _format_tui_spell_counts(
|
|
3320
|
+
*,
|
|
3321
|
+
spell_selection: SpellSelection,
|
|
3322
|
+
mana_icons_enabled: bool = False,
|
|
3323
|
+
) -> list[str]:
|
|
3324
|
+
counts = spell_selection.counts
|
|
3325
|
+
constraints = spell_selection.constraints
|
|
3326
|
+
pair = _format_pair_label(
|
|
3327
|
+
pair=spell_selection.pair,
|
|
3328
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3329
|
+
)
|
|
3330
|
+
return [
|
|
3331
|
+
"Structure checks",
|
|
3332
|
+
f"Eligible spells for {pair}: {spell_selection.eligible_count}",
|
|
3333
|
+
f"Selected spells: {counts.total}/{spell_selection.requested_spell_count}",
|
|
3334
|
+
(
|
|
3335
|
+
f"Creatures: {counts.creatures} "
|
|
3336
|
+
f"(target {constraints.creature_floor}-{constraints.creature_ceiling})"
|
|
3337
|
+
),
|
|
3338
|
+
f"Two-drops: {counts.two_drops} (minimum {constraints.minimum_two_drops})",
|
|
3339
|
+
f"Expensive spells: {counts.expensive} (soft cap {constraints.maximum_expensive_spells})",
|
|
3340
|
+
f"Applied relaxations: {_format_tui_relaxations(spell_selection.applied_relaxations)}",
|
|
3341
|
+
]
|
|
3342
|
+
|
|
3343
|
+
|
|
3344
|
+
def _format_tui_pair_scores(
|
|
3345
|
+
*,
|
|
3346
|
+
selection: PairSelection,
|
|
3347
|
+
mana_icons_enabled: bool = False,
|
|
3348
|
+
) -> list[str]:
|
|
3349
|
+
lines = [
|
|
3350
|
+
"Color-pair reasoning",
|
|
3351
|
+
(
|
|
3352
|
+
"This diagnostic compares playable cards with 17Lands color-pair "
|
|
3353
|
+
"context; it is not another decklist."
|
|
3354
|
+
),
|
|
3355
|
+
]
|
|
3356
|
+
for score in selection.ranked_scores:
|
|
3357
|
+
pair = _format_pair_label(
|
|
3358
|
+
pair=score.pair,
|
|
3359
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3360
|
+
)
|
|
3361
|
+
lines.append(
|
|
3362
|
+
f"- {pair}: {score.playable_count} playable cards; "
|
|
3363
|
+
f"pair strength {score.blended_score:.2f}; "
|
|
3364
|
+
f"17Lands WR {_format_tui_win_rate(score.pair_win_rate)}; "
|
|
3365
|
+
f"top {selection.target_spell_count} sum {score.playable_score_sum:.2f}"
|
|
3366
|
+
)
|
|
3367
|
+
|
|
3368
|
+
return lines
|
|
3369
|
+
|
|
3370
|
+
|
|
3371
|
+
def _format_tui_bench(
|
|
3372
|
+
*,
|
|
3373
|
+
selection: SpellSelection,
|
|
3374
|
+
width: int,
|
|
3375
|
+
mana_icons_enabled: bool = False,
|
|
3376
|
+
) -> list[str]:
|
|
3377
|
+
lines = ["Bench"]
|
|
3378
|
+
if not selection.bench:
|
|
3379
|
+
return lines + ["- none"]
|
|
3380
|
+
|
|
3381
|
+
for card, quantity in _group_tui_spell_cards(cards=selection.bench):
|
|
3382
|
+
lines.append(
|
|
3383
|
+
_clip(
|
|
3384
|
+
text=_format_tui_spell_card(
|
|
3385
|
+
card=card,
|
|
3386
|
+
quantity=quantity,
|
|
3387
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3388
|
+
),
|
|
3389
|
+
width=width,
|
|
3390
|
+
)
|
|
3391
|
+
)
|
|
3392
|
+
|
|
3393
|
+
return lines
|
|
3394
|
+
|
|
3395
|
+
|
|
3396
|
+
def _format_tui_spell_card(
|
|
3397
|
+
*,
|
|
3398
|
+
card: ScoredCard,
|
|
3399
|
+
quantity: int = 1,
|
|
3400
|
+
focused: bool = False,
|
|
3401
|
+
show_focus_marker: bool = False,
|
|
3402
|
+
mana_icons_enabled: bool = False,
|
|
3403
|
+
) -> str:
|
|
3404
|
+
quantity_suffix = _format_tui_quantity_suffix(quantity=quantity)
|
|
3405
|
+
focus_marker = ""
|
|
3406
|
+
if show_focus_marker:
|
|
3407
|
+
focus_marker = "▶ " if focused else " "
|
|
3408
|
+
|
|
3409
|
+
color_label = _format_card_colors(
|
|
3410
|
+
card=card.card,
|
|
3411
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3412
|
+
long_colorless=False,
|
|
3413
|
+
)
|
|
3414
|
+
return (
|
|
3415
|
+
f"{focus_marker}"
|
|
3416
|
+
f"{_format_win_rate(scored_card=card):>6} "
|
|
3417
|
+
f"{_format_letter_grade(scored_card=card):>2} "
|
|
3418
|
+
f"{card.score:>2} "
|
|
3419
|
+
f"{card.card.name} ({color_label})"
|
|
3420
|
+
f"{quantity_suffix}"
|
|
3421
|
+
)
|
|
3422
|
+
|
|
3423
|
+
|
|
3424
|
+
def _group_tui_spell_cards(
|
|
3425
|
+
*,
|
|
3426
|
+
cards: Iterable[ScoredCard],
|
|
3427
|
+
) -> tuple[TuiCardQuantityGroup, ...]:
|
|
3428
|
+
grouped_cards: dict[TuiCardQuantityKey, TuiCardQuantityGroup] = {}
|
|
3429
|
+
for card in cards:
|
|
3430
|
+
quantity_key = _tui_card_quantity_key(card=card.card)
|
|
3431
|
+
existing_group = grouped_cards.get(quantity_key)
|
|
3432
|
+
if existing_group is None:
|
|
3433
|
+
grouped_cards[quantity_key] = (card, 1)
|
|
3434
|
+
continue
|
|
3435
|
+
|
|
3436
|
+
representative, quantity = existing_group
|
|
3437
|
+
grouped_cards[quantity_key] = (representative, quantity + 1)
|
|
3438
|
+
|
|
3439
|
+
return tuple(grouped_cards.values())
|
|
3440
|
+
|
|
3441
|
+
|
|
3442
|
+
def _tui_card_quantity_key(*, card: CardInfo) -> TuiCardQuantityKey:
|
|
3443
|
+
if card.unknown:
|
|
3444
|
+
return ("unknown", str(card.grp_id))
|
|
3445
|
+
|
|
3446
|
+
return ("name", " ".join(card.name.casefold().split()))
|
|
3447
|
+
|
|
3448
|
+
|
|
3449
|
+
def _format_tui_quantity_suffix(*, quantity: int) -> str:
|
|
3450
|
+
if quantity <= 1:
|
|
3451
|
+
return ""
|
|
3452
|
+
|
|
3453
|
+
return f" x{quantity}"
|
|
3454
|
+
|
|
3455
|
+
|
|
3456
|
+
def _tui_spell_curve_sort_key(card: ScoredCard) -> tuple[float, int, str, int]:
|
|
3457
|
+
mana_value = 99.0 if card.card.mana_value is None else card.card.mana_value
|
|
3458
|
+
return (mana_value, -card.score, card.card.name, card.original_index)
|
|
3459
|
+
|
|
3460
|
+
|
|
3461
|
+
def _mana_value_bucket(*, card: ScoredCard) -> str:
|
|
3462
|
+
mana_value = card.card.mana_value
|
|
3463
|
+
if mana_value is None:
|
|
3464
|
+
return "?"
|
|
3465
|
+
|
|
3466
|
+
if mana_value >= 6:
|
|
3467
|
+
return "6+"
|
|
3468
|
+
|
|
3469
|
+
return _format_mana_value(card=card.card)
|
|
3470
|
+
|
|
3471
|
+
|
|
3472
|
+
def _ordered_mana_buckets(*, groups: dict[str, list[ScoredCard]]) -> tuple[str, ...]:
|
|
3473
|
+
ordered = tuple(label for label in ("0", "1", "2", "3", "4", "5", "6+") if label in groups)
|
|
3474
|
+
if "?" in groups:
|
|
3475
|
+
return ordered + ("?",)
|
|
3476
|
+
|
|
3477
|
+
return ordered
|
|
3478
|
+
|
|
3479
|
+
|
|
3480
|
+
def _format_plain_color_counts(
|
|
3481
|
+
counts: tuple[tuple[str, int], ...],
|
|
3482
|
+
*,
|
|
3483
|
+
mana_icons_enabled: bool = False,
|
|
3484
|
+
) -> str:
|
|
3485
|
+
if not counts:
|
|
3486
|
+
return "none"
|
|
3487
|
+
|
|
3488
|
+
parts = []
|
|
3489
|
+
for color, count in counts:
|
|
3490
|
+
label = _format_color_count_label(
|
|
3491
|
+
color=color,
|
|
3492
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3493
|
+
)
|
|
3494
|
+
parts.append(f"{label} {count}")
|
|
3495
|
+
|
|
3496
|
+
return ", ".join(parts)
|
|
3497
|
+
|
|
3498
|
+
|
|
3499
|
+
def _format_plain_colors(colors: tuple[str, ...]) -> str:
|
|
3500
|
+
return _format_color_label(colors=colors, mana_icons_enabled=False)
|
|
3501
|
+
|
|
3502
|
+
|
|
3503
|
+
def _format_card_colors(
|
|
3504
|
+
*,
|
|
3505
|
+
card: CardInfo,
|
|
3506
|
+
mana_icons_enabled: bool = False,
|
|
3507
|
+
long_colorless: bool = True,
|
|
3508
|
+
) -> str:
|
|
3509
|
+
if card.unknown:
|
|
3510
|
+
return "Unknown"
|
|
3511
|
+
|
|
3512
|
+
return _format_color_label(
|
|
3513
|
+
colors=card.colors,
|
|
3514
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3515
|
+
long_colorless=long_colorless,
|
|
3516
|
+
)
|
|
3517
|
+
|
|
3518
|
+
|
|
3519
|
+
def _format_color_label(
|
|
3520
|
+
*,
|
|
3521
|
+
colors: tuple[str, ...],
|
|
3522
|
+
mana_icons_enabled: bool = False,
|
|
3523
|
+
long_colorless: bool = True,
|
|
3524
|
+
) -> str:
|
|
3525
|
+
if not colors:
|
|
3526
|
+
return _format_colorless_label(
|
|
3527
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3528
|
+
long_colorless=long_colorless,
|
|
3529
|
+
)
|
|
3530
|
+
|
|
3531
|
+
if not mana_icons_enabled:
|
|
3532
|
+
return "".join(colors)
|
|
3533
|
+
|
|
3534
|
+
return "".join(
|
|
3535
|
+
_format_mana_symbol(symbol=color, mana_icons_enabled=mana_icons_enabled)
|
|
3536
|
+
for color in colors
|
|
3537
|
+
)
|
|
3538
|
+
|
|
3539
|
+
|
|
3540
|
+
def _format_pair_label(*, pair: str, mana_icons_enabled: bool = False) -> str:
|
|
3541
|
+
if not mana_icons_enabled:
|
|
3542
|
+
return pair
|
|
3543
|
+
|
|
3544
|
+
if not pair or any(symbol not in MANA_ICON_GLYPHS for symbol in pair):
|
|
3545
|
+
return pair
|
|
3546
|
+
|
|
3547
|
+
return "".join(
|
|
3548
|
+
_format_mana_symbol(symbol=symbol, mana_icons_enabled=mana_icons_enabled)
|
|
3549
|
+
for symbol in pair
|
|
3550
|
+
)
|
|
3551
|
+
|
|
3552
|
+
|
|
3553
|
+
def _format_color_count_label(*, color: str, mana_icons_enabled: bool = False) -> str:
|
|
3554
|
+
if color == UNKNOWN_COLOR_KEY:
|
|
3555
|
+
return UNKNOWN_COLOR_KEY
|
|
3556
|
+
|
|
3557
|
+
if color == COLORLESS_KEY:
|
|
3558
|
+
return _format_colorless_label(
|
|
3559
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3560
|
+
long_colorless=False,
|
|
3561
|
+
)
|
|
3562
|
+
|
|
3563
|
+
return _format_pair_label(pair=color, mana_icons_enabled=mana_icons_enabled)
|
|
3564
|
+
|
|
3565
|
+
|
|
3566
|
+
def _format_colorless_label(
|
|
3567
|
+
*,
|
|
3568
|
+
mana_icons_enabled: bool,
|
|
3569
|
+
long_colorless: bool,
|
|
3570
|
+
) -> str:
|
|
3571
|
+
if not mana_icons_enabled:
|
|
3572
|
+
return "Colorless"
|
|
3573
|
+
|
|
3574
|
+
fallback = "Colorless" if long_colorless else COLORLESS_KEY
|
|
3575
|
+
return f"{MANA_ICON_GLYPHS[COLORLESS_KEY]} {fallback}"
|
|
3576
|
+
|
|
3577
|
+
|
|
3578
|
+
def _format_mana_symbol(*, symbol: str, mana_icons_enabled: bool) -> str:
|
|
3579
|
+
if not mana_icons_enabled:
|
|
3580
|
+
return symbol
|
|
3581
|
+
|
|
3582
|
+
return MANA_ICON_GLYPHS.get(symbol, symbol)
|
|
3583
|
+
|
|
3584
|
+
|
|
3585
|
+
def _format_card_types(
|
|
3586
|
+
*,
|
|
3587
|
+
card: CardInfo,
|
|
3588
|
+
mana_icons_enabled: bool = False,
|
|
3589
|
+
) -> str:
|
|
3590
|
+
type_line = " ".join(card.types) if card.types else "Unknown"
|
|
3591
|
+
if card.unknown or not mana_icons_enabled:
|
|
3592
|
+
return type_line
|
|
3593
|
+
|
|
3594
|
+
icons = [
|
|
3595
|
+
glyph
|
|
3596
|
+
for card_type, glyph in MANA_CARD_TYPE_GLYPHS.items()
|
|
3597
|
+
if any(card_type in type_part for type_part in card.types)
|
|
3598
|
+
]
|
|
3599
|
+
if not icons:
|
|
3600
|
+
return type_line
|
|
3601
|
+
|
|
3602
|
+
return f"{''.join(icons)} {type_line}"
|
|
3603
|
+
|
|
3604
|
+
|
|
3605
|
+
def _format_tui_relaxations(relaxations: tuple[str, ...]) -> str:
|
|
3606
|
+
return ", ".join(relaxations) if relaxations else "none"
|
|
3607
|
+
|
|
3608
|
+
|
|
3609
|
+
def _format_tui_win_rate(value: float | None) -> str:
|
|
3610
|
+
if value is None:
|
|
3611
|
+
return "unknown"
|
|
3612
|
+
|
|
3613
|
+
return f"{value:.1%}"
|
|
3614
|
+
|
|
3615
|
+
|
|
3616
|
+
def _clip(*, text: str, width: int) -> str:
|
|
3617
|
+
if len(text) <= width:
|
|
3618
|
+
return text
|
|
3619
|
+
|
|
3620
|
+
if width <= 1:
|
|
3621
|
+
return "…"
|
|
3622
|
+
|
|
3623
|
+
protected_suffix = _clip_protected_suffix(text=text)
|
|
3624
|
+
if protected_suffix and width > len(protected_suffix) + 1:
|
|
3625
|
+
prefix_width = width - len(protected_suffix) - 1
|
|
3626
|
+
return text[:prefix_width] + "…" + protected_suffix
|
|
3627
|
+
|
|
3628
|
+
return text[: width - 1] + "…"
|
|
3629
|
+
|
|
3630
|
+
|
|
3631
|
+
def _clip_protected_suffix(*, text: str) -> str:
|
|
3632
|
+
quantity_index = text.rfind(" x")
|
|
3633
|
+
if quantity_index == -1 or not text[quantity_index + 2 :].isdigit():
|
|
3634
|
+
return ""
|
|
3635
|
+
|
|
3636
|
+
color_close_index = quantity_index - 1
|
|
3637
|
+
if color_close_index < 0 or text[color_close_index] != ")":
|
|
3638
|
+
return ""
|
|
3639
|
+
|
|
3640
|
+
color_open_index = text.rfind("(", 0, color_close_index)
|
|
3641
|
+
if color_open_index == -1:
|
|
3642
|
+
return ""
|
|
3643
|
+
|
|
3644
|
+
if color_open_index > 0 and text[color_open_index - 1] == " ":
|
|
3645
|
+
return text[color_open_index - 1 :]
|
|
3646
|
+
|
|
3647
|
+
return text[color_open_index:]
|
|
3648
|
+
|
|
3649
|
+
|
|
3650
|
+
_ERROR_EXIT_CODE = 1
|
|
3651
|
+
|
|
3652
|
+
|
|
3653
|
+
def run_tui_watch(
|
|
3654
|
+
*,
|
|
3655
|
+
log_path: PathInput,
|
|
3656
|
+
card_database: CardDatabase | None = None,
|
|
3657
|
+
card_database_loader: CardDatabaseLoader | None = None,
|
|
3658
|
+
app_dir: PathInput | None = None,
|
|
3659
|
+
poll_interval: float = POLL_INTERVAL_SECONDS,
|
|
3660
|
+
once: bool = False,
|
|
3661
|
+
startup_scan: bool = False,
|
|
3662
|
+
ratings_loader: RatingsLoader | None = None,
|
|
3663
|
+
ratings_loader_factory: RatingsLoaderFactory | None = None,
|
|
3664
|
+
ratings_progress_loader: RatingsProgressLoader | None = None,
|
|
3665
|
+
ratings_progress_loader_factory: RatingsProgressLoaderFactory | None = None,
|
|
3666
|
+
ratings_cache_checker: RatingsCacheChecker | None = None,
|
|
3667
|
+
mana_icons_enabled: bool = False,
|
|
3668
|
+
splash_enabled: bool | None = None,
|
|
3669
|
+
) -> int:
|
|
3670
|
+
"""Run Textual watch mode and return a process-style exit code.
|
|
3671
|
+
Metadata may load in a worker after the initial shell has rendered.
|
|
3672
|
+
"""
|
|
3673
|
+
|
|
3674
|
+
app = DraftomenTuiApp(
|
|
3675
|
+
log_path=log_path,
|
|
3676
|
+
card_database=card_database,
|
|
3677
|
+
card_database_loader=card_database_loader,
|
|
3678
|
+
app_dir=app_dir,
|
|
3679
|
+
poll_interval=poll_interval,
|
|
3680
|
+
ratings_loader=ratings_loader,
|
|
3681
|
+
ratings_loader_factory=ratings_loader_factory,
|
|
3682
|
+
ratings_progress_loader=ratings_progress_loader,
|
|
3683
|
+
ratings_progress_loader_factory=ratings_progress_loader_factory,
|
|
3684
|
+
ratings_cache_checker=ratings_cache_checker,
|
|
3685
|
+
startup_scan=startup_scan,
|
|
3686
|
+
once=once,
|
|
3687
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3688
|
+
splash_enabled=splash_enabled,
|
|
3689
|
+
)
|
|
3690
|
+
try:
|
|
3691
|
+
app.run(headless=once)
|
|
3692
|
+
except KeyboardInterrupt:
|
|
3693
|
+
return 130
|
|
3694
|
+
except Exception:
|
|
3695
|
+
return _ERROR_EXIT_CODE
|
|
3696
|
+
|
|
3697
|
+
return 0
|
|
3698
|
+
|
|
3699
|
+
|
|
3700
|
+
|
|
3701
|
+
|
|
3702
|
+
def _row_cells(
|
|
3703
|
+
*,
|
|
3704
|
+
rank: int,
|
|
3705
|
+
scored_card: ScoredCard,
|
|
3706
|
+
column_keys: tuple[str, ...],
|
|
3707
|
+
mana_icons_enabled: bool = False,
|
|
3708
|
+
) -> tuple[object, ...]:
|
|
3709
|
+
values = {
|
|
3710
|
+
"rank": f"{rank:02d}",
|
|
3711
|
+
"win_rate": _format_win_rate(scored_card=scored_card),
|
|
3712
|
+
"grade": _format_letter_grade(scored_card=scored_card),
|
|
3713
|
+
"score": str(scored_card.score),
|
|
3714
|
+
"card": _format_pick_card_name(scored_card=scored_card),
|
|
3715
|
+
"colors": _styled_colors(
|
|
3716
|
+
card=scored_card.card,
|
|
3717
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3718
|
+
),
|
|
3719
|
+
"fit": _format_color_fit(scored_card=scored_card),
|
|
3720
|
+
"gih": _format_win_rate(scored_card=scored_card),
|
|
3721
|
+
"alsa": _format_alsa(scored_card=scored_card),
|
|
3722
|
+
"mv": _format_mana_value(card=scored_card.card),
|
|
3723
|
+
"source": scored_card.source_label,
|
|
3724
|
+
}
|
|
3725
|
+
return tuple(values[column_key] for column_key in column_keys)
|
|
3726
|
+
|
|
3727
|
+
|
|
3728
|
+
|
|
3729
|
+
|
|
3730
|
+
def _commitment_label(*, commitment: float) -> str:
|
|
3731
|
+
if commitment <= 0.0:
|
|
3732
|
+
phase = "open"
|
|
3733
|
+
elif commitment >= 1.0:
|
|
3734
|
+
phase = "locked"
|
|
3735
|
+
else:
|
|
3736
|
+
phase = "building"
|
|
3737
|
+
|
|
3738
|
+
return f"{int(round(commitment * 100))}% {phase}"
|
|
3739
|
+
|
|
3740
|
+
|
|
3741
|
+
def _format_card_name(*, card: CardInfo) -> str:
|
|
3742
|
+
if card.unknown:
|
|
3743
|
+
return f"{card.name} (grpId {card.grp_id})"
|
|
3744
|
+
|
|
3745
|
+
return card.name
|
|
3746
|
+
|
|
3747
|
+
|
|
3748
|
+
def _format_pick_card_name(*, scored_card: ScoredCard) -> str:
|
|
3749
|
+
card_name = _format_card_name(card=scored_card.card)
|
|
3750
|
+
splash_color = scored_card.splash.splash_color
|
|
3751
|
+
if splash_color is None:
|
|
3752
|
+
return card_name
|
|
3753
|
+
|
|
3754
|
+
color_name = COLOR_NAMES.get(splash_color, splash_color).upper()
|
|
3755
|
+
if scored_card.color_fit == "splash-ready":
|
|
3756
|
+
return f"SPLASH {color_name} — {card_name}"
|
|
3757
|
+
if scored_card.color_fit == "splash-speculative":
|
|
3758
|
+
return f"POSSIBLE SPLASH {color_name} — {card_name}"
|
|
3759
|
+
if scored_card.color_fit == "splash-fixer":
|
|
3760
|
+
return f"{color_name} SPLASH MANA — {card_name}"
|
|
3761
|
+
|
|
3762
|
+
return card_name
|
|
3763
|
+
|
|
3764
|
+
|
|
3765
|
+
def _styled_colors(*, card: CardInfo, mana_icons_enabled: bool = False) -> Text:
|
|
3766
|
+
if card.unknown:
|
|
3767
|
+
return Text("Unknown", style="bold yellow")
|
|
3768
|
+
|
|
3769
|
+
if not card.colors:
|
|
3770
|
+
colorless = _format_colorless_label(
|
|
3771
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3772
|
+
long_colorless=False,
|
|
3773
|
+
)
|
|
3774
|
+
return Text(colorless, style="grey50")
|
|
3775
|
+
|
|
3776
|
+
text = Text()
|
|
3777
|
+
for color in card.colors:
|
|
3778
|
+
symbol = _format_mana_symbol(
|
|
3779
|
+
symbol=color,
|
|
3780
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3781
|
+
)
|
|
3782
|
+
text.append(symbol, style=COLOR_STYLES.get(color, "bold"))
|
|
3783
|
+
return text
|
|
3784
|
+
|
|
3785
|
+
|
|
3786
|
+
def _format_color_fit(*, scored_card: ScoredCard) -> str:
|
|
3787
|
+
if scored_card.color_fit == "on-color":
|
|
3788
|
+
return "On"
|
|
3789
|
+
|
|
3790
|
+
if scored_card.color_fit == "off-color":
|
|
3791
|
+
return "Off!"
|
|
3792
|
+
|
|
3793
|
+
if scored_card.color_fit == "colorless":
|
|
3794
|
+
return "Any"
|
|
3795
|
+
|
|
3796
|
+
if scored_card.color_fit == "unknown":
|
|
3797
|
+
return "?"
|
|
3798
|
+
|
|
3799
|
+
if scored_card.color_fit == "splash-ready":
|
|
3800
|
+
return f"Splash {scored_card.splash.splash_color}"
|
|
3801
|
+
|
|
3802
|
+
if scored_card.color_fit == "splash-speculative":
|
|
3803
|
+
return f"Splash? {scored_card.splash.splash_color}"
|
|
3804
|
+
|
|
3805
|
+
if scored_card.color_fit == "splash-fixer":
|
|
3806
|
+
return f"Fix {scored_card.splash.splash_color}"
|
|
3807
|
+
|
|
3808
|
+
return "Open"
|
|
3809
|
+
|
|
3810
|
+
|
|
3811
|
+
def _format_splash_details(*, scored_card: ScoredCard) -> str:
|
|
3812
|
+
splash = scored_card.splash
|
|
3813
|
+
if splash.splash_color is None:
|
|
3814
|
+
return ""
|
|
3815
|
+
|
|
3816
|
+
color_name = COLOR_NAMES.get(splash.splash_color, splash.splash_color)
|
|
3817
|
+
classification = {
|
|
3818
|
+
"splash-ready": "Recommended — mana is supported",
|
|
3819
|
+
"splash-speculative": "Speculative — more fixing is needed",
|
|
3820
|
+
"splash-fixer": "Fixing pick for the active splash",
|
|
3821
|
+
"off-color": "Not recommended as a splash",
|
|
3822
|
+
}.get(splash.classification, splash.classification.replace("-", " ").title())
|
|
3823
|
+
source_line = ""
|
|
3824
|
+
if splash.required_sources > 0:
|
|
3825
|
+
source_parts = [f"{splash.fixing_sources} fixing lands"]
|
|
3826
|
+
if splash.planned_basic_sources:
|
|
3827
|
+
basic_name = BASIC_LAND_NAMES.get(
|
|
3828
|
+
splash.splash_color,
|
|
3829
|
+
"basic land",
|
|
3830
|
+
)
|
|
3831
|
+
source_parts.append(
|
|
3832
|
+
f"{splash.planned_basic_sources} planned {basic_name}"
|
|
3833
|
+
)
|
|
3834
|
+
source_line = (
|
|
3835
|
+
f"Mana support: {splash.available_sources} of "
|
|
3836
|
+
f"{splash.required_sources} sources "
|
|
3837
|
+
f"({', '.join(source_parts)})\n"
|
|
3838
|
+
)
|
|
3839
|
+
reason = "; ".join(splash.reasons)
|
|
3840
|
+
return (
|
|
3841
|
+
f"Splash recommendation: {color_name}\n"
|
|
3842
|
+
f"Splash assessment: {classification}\n"
|
|
3843
|
+
f"{source_line}"
|
|
3844
|
+
f"Reason: {reason}\n"
|
|
3845
|
+
)
|
|
3846
|
+
|
|
3847
|
+
|
|
3848
|
+
def _format_win_rate(*, scored_card: ScoredCard) -> str:
|
|
3849
|
+
if scored_card.rating.gih_win_rate is None:
|
|
3850
|
+
return "—"
|
|
3851
|
+
|
|
3852
|
+
return f"{scored_card.rating.gih_win_rate:.1%}"
|
|
3853
|
+
|
|
3854
|
+
|
|
3855
|
+
def _format_letter_grade(*, scored_card: ScoredCard) -> str:
|
|
3856
|
+
return scored_card.rating.letter_grade or "—"
|
|
3857
|
+
|
|
3858
|
+
|
|
3859
|
+
def _format_alsa(*, scored_card: ScoredCard) -> str:
|
|
3860
|
+
if scored_card.rating.average_last_seen_at is None:
|
|
3861
|
+
return "—"
|
|
3862
|
+
|
|
3863
|
+
return f"{scored_card.rating.average_last_seen_at:.2f}"
|
|
3864
|
+
|
|
3865
|
+
|
|
3866
|
+
def _format_tui_source_label(*, scored_card: ScoredCard) -> str:
|
|
3867
|
+
labels = {
|
|
3868
|
+
"Quick": "Quick Draft",
|
|
3869
|
+
"Premier": "Premier Draft fallback",
|
|
3870
|
+
"Prior": "neutral prior",
|
|
3871
|
+
}
|
|
3872
|
+
return labels.get(scored_card.source_label, scored_card.source_label)
|
|
3873
|
+
|
|
3874
|
+
|
|
3875
|
+
def _format_mana_value(*, card: CardInfo) -> str:
|
|
3876
|
+
if card.mana_value is None:
|
|
3877
|
+
return "—"
|
|
3878
|
+
|
|
3879
|
+
if card.mana_value.is_integer():
|
|
3880
|
+
return str(int(card.mana_value))
|
|
3881
|
+
|
|
3882
|
+
return f"{card.mana_value:.1f}"
|
|
3883
|
+
|
|
3884
|
+
|
|
3885
|
+
def _pool_color_distribution_bar(
|
|
3886
|
+
*,
|
|
3887
|
+
pool_grp_ids: tuple[int, ...],
|
|
3888
|
+
card_database: CardDatabase,
|
|
3889
|
+
mana_icons_enabled: bool = False,
|
|
3890
|
+
) -> str:
|
|
3891
|
+
if not pool_grp_ids:
|
|
3892
|
+
return "Colors: none"
|
|
3893
|
+
|
|
3894
|
+
counts = _pool_color_counts(
|
|
3895
|
+
pool_grp_ids=pool_grp_ids,
|
|
3896
|
+
card_database=card_database,
|
|
3897
|
+
)
|
|
3898
|
+
max_count = max(counts.values(), default=0)
|
|
3899
|
+
keys = COLOR_ORDER + (COLORLESS_KEY,)
|
|
3900
|
+
parts = []
|
|
3901
|
+
for color in keys:
|
|
3902
|
+
bar = _scaled_bar(count=counts[color], max_count=max_count)
|
|
3903
|
+
label = _format_color_count_label(
|
|
3904
|
+
color=color,
|
|
3905
|
+
mana_icons_enabled=mana_icons_enabled,
|
|
3906
|
+
)
|
|
3907
|
+
parts.append(f"{label} {bar} {counts[color]}")
|
|
3908
|
+
if counts[UNKNOWN_COLOR_KEY] > 0:
|
|
3909
|
+
parts.append(
|
|
3910
|
+
f"? {_scaled_bar(count=counts[UNKNOWN_COLOR_KEY], max_count=max_count)} "
|
|
3911
|
+
f"{counts[UNKNOWN_COLOR_KEY]}"
|
|
3912
|
+
)
|
|
3913
|
+
|
|
3914
|
+
return "Colors: " + " | ".join(parts)
|
|
3915
|
+
|
|
3916
|
+
|
|
3917
|
+
def _pool_color_counts(
|
|
3918
|
+
*,
|
|
3919
|
+
pool_grp_ids: tuple[int, ...],
|
|
3920
|
+
card_database: CardDatabase,
|
|
3921
|
+
) -> Counter[str]:
|
|
3922
|
+
counts: Counter[str] = Counter({color: 0 for color in COLOR_ORDER})
|
|
3923
|
+
counts[COLORLESS_KEY] = 0
|
|
3924
|
+
counts[UNKNOWN_COLOR_KEY] = 0
|
|
3925
|
+
for grp_id in pool_grp_ids:
|
|
3926
|
+
card = card_database.lookup(grp_id=grp_id)
|
|
3927
|
+
if card.unknown:
|
|
3928
|
+
counts[UNKNOWN_COLOR_KEY] += 1
|
|
3929
|
+
continue
|
|
3930
|
+
|
|
3931
|
+
if not card.colors:
|
|
3932
|
+
counts[COLORLESS_KEY] += 1
|
|
3933
|
+
continue
|
|
3934
|
+
|
|
3935
|
+
for color in card.colors:
|
|
3936
|
+
if color in COLOR_ORDER:
|
|
3937
|
+
counts[color] += 1
|
|
3938
|
+
else:
|
|
3939
|
+
counts[UNKNOWN_COLOR_KEY] += 1
|
|
3940
|
+
|
|
3941
|
+
return counts
|
|
3942
|
+
|
|
3943
|
+
|
|
3944
|
+
def _pool_curve_sparkline(
|
|
3945
|
+
*,
|
|
3946
|
+
pool_grp_ids: tuple[int, ...],
|
|
3947
|
+
card_database: CardDatabase,
|
|
3948
|
+
) -> str:
|
|
3949
|
+
if not pool_grp_ids:
|
|
3950
|
+
return "Curve: none"
|
|
3951
|
+
|
|
3952
|
+
counts = _mana_curve_counts(
|
|
3953
|
+
pool_grp_ids=pool_grp_ids,
|
|
3954
|
+
card_database=card_database,
|
|
3955
|
+
)
|
|
3956
|
+
max_count = max(counts, default=0)
|
|
3957
|
+
parts = []
|
|
3958
|
+
for index, label in enumerate(CURVE_BUCKET_LABELS):
|
|
3959
|
+
glyph = _sparkline_glyph(count=counts[index], max_count=max_count)
|
|
3960
|
+
parts.append(f"{label}{glyph}{counts[index]}")
|
|
3961
|
+
return "Curve: " + " ".join(parts)
|
|
3962
|
+
|
|
3963
|
+
|
|
3964
|
+
def _mana_curve_counts(
|
|
3965
|
+
*,
|
|
3966
|
+
pool_grp_ids: tuple[int, ...],
|
|
3967
|
+
card_database: CardDatabase,
|
|
3968
|
+
) -> list[int]:
|
|
3969
|
+
counts = [0 for _ in CURVE_BUCKET_LABELS]
|
|
3970
|
+
for grp_id in pool_grp_ids:
|
|
3971
|
+
card = card_database.lookup(grp_id=grp_id)
|
|
3972
|
+
if card.unknown or card.mana_value is None or _is_land_card(card=card):
|
|
3973
|
+
continue
|
|
3974
|
+
|
|
3975
|
+
bucket = _curve_bucket(mana_value=card.mana_value)
|
|
3976
|
+
counts[bucket] += 1
|
|
3977
|
+
|
|
3978
|
+
return counts
|
|
3979
|
+
|
|
3980
|
+
|
|
3981
|
+
def _curve_bucket(*, mana_value: float) -> int:
|
|
3982
|
+
rounded = max(0, int(mana_value))
|
|
3983
|
+
return min(rounded, len(CURVE_BUCKET_LABELS) - 1)
|
|
3984
|
+
|
|
3985
|
+
|
|
3986
|
+
def _is_land_card(*, card: CardInfo) -> bool:
|
|
3987
|
+
return any("Land" in type_line for type_line in card.types)
|
|
3988
|
+
|
|
3989
|
+
|
|
3990
|
+
def _is_creature_card(*, card: CardInfo) -> bool:
|
|
3991
|
+
return any("Creature" in type_line for type_line in card.types)
|
|
3992
|
+
|
|
3993
|
+
|
|
3994
|
+
def _scaled_bar(*, count: int, max_count: int, width: int = 5) -> str:
|
|
3995
|
+
if count <= 0 or max_count <= 0:
|
|
3996
|
+
return "·"
|
|
3997
|
+
|
|
3998
|
+
filled = max(1, round((count / max_count) * width))
|
|
3999
|
+
return "█" * filled
|
|
4000
|
+
|
|
4001
|
+
|
|
4002
|
+
def _sparkline_glyph(*, count: int, max_count: int) -> str:
|
|
4003
|
+
if count <= 0 or max_count <= 0:
|
|
4004
|
+
return "·"
|
|
4005
|
+
|
|
4006
|
+
index = max(0, round((count / max_count) * (len(SPARKLINE_GLYPHS) - 1)))
|
|
4007
|
+
return SPARKLINE_GLYPHS[index]
|