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/replay.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Deterministic plain-text replay rendering.
|
|
2
|
+
Completion events append the same build sheet used by live plain watch.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from os import PathLike
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TypeAlias
|
|
13
|
+
|
|
14
|
+
from draftomen.carddb import CardDatabase, CardInfo
|
|
15
|
+
from draftomen.deckbuilder import BuildPool, build_deck_from_pool, format_build_result
|
|
16
|
+
from draftomen.events import (
|
|
17
|
+
EXPECTED_PICKS_PER_PACK,
|
|
18
|
+
AccountEvent,
|
|
19
|
+
DraftCompletedEvent,
|
|
20
|
+
DraftEvent,
|
|
21
|
+
DraftStartedEvent,
|
|
22
|
+
PackOfferedEvent,
|
|
23
|
+
PickMadeEvent,
|
|
24
|
+
parse_events,
|
|
25
|
+
)
|
|
26
|
+
from draftomen.pickengine import PickEngine, ScoredCard, ScoredPack
|
|
27
|
+
from draftomen.pool import DraftPoolStore
|
|
28
|
+
from draftomen.seventeen import SEVENTEEN_LANDS_ATTRIBUTION, SeventeenLandsData
|
|
29
|
+
|
|
30
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
31
|
+
RatingsLoader: TypeAlias = Callable[[str], SeventeenLandsData]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReplayError(RuntimeError):
|
|
35
|
+
"""Raised when a log cannot be replayed.
|
|
36
|
+
Callers should surface the message as a concise CLI diagnostic.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class _ReplayHeader:
|
|
42
|
+
"""Summary fields printed before replayed picks.
|
|
43
|
+
Missing fields are rendered explicitly so output stays deterministic.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
account_id: str | None
|
|
47
|
+
screen_name: str | None
|
|
48
|
+
event_name: str | None
|
|
49
|
+
set_code: str | None
|
|
50
|
+
draft_id: str | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def replay_log_file(
|
|
54
|
+
*,
|
|
55
|
+
logfile: PathInput,
|
|
56
|
+
card_database: CardDatabase,
|
|
57
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
58
|
+
ratings_loader: RatingsLoader | None = None,
|
|
59
|
+
splash_enabled: bool = True,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Replay one captured Player.log file into deterministic text.
|
|
62
|
+
Ratings are caller-supplied or loaded once from the parsed set code.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
path = Path(logfile)
|
|
66
|
+
try:
|
|
67
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
68
|
+
except OSError as error:
|
|
69
|
+
raise ReplayError(f"Could not read replay log {path}: {error}.") from error
|
|
70
|
+
|
|
71
|
+
events = tuple(parse_events(lines=lines))
|
|
72
|
+
return render_replay_events(
|
|
73
|
+
events=events,
|
|
74
|
+
card_database=card_database,
|
|
75
|
+
ratings_data=ratings_data,
|
|
76
|
+
ratings_loader=ratings_loader,
|
|
77
|
+
splash_enabled=splash_enabled,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_replay_events(
|
|
82
|
+
*,
|
|
83
|
+
events: Iterable[DraftEvent],
|
|
84
|
+
card_database: CardDatabase,
|
|
85
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
86
|
+
ratings_loader: RatingsLoader | None = None,
|
|
87
|
+
splash_enabled: bool = True,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Render parsed events to stable plain-text replay output.
|
|
90
|
+
Pool validation is run first so conflicting streams fail before printing.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
event_tuple = tuple(events)
|
|
94
|
+
if not event_tuple:
|
|
95
|
+
raise ReplayError("No Quick Draft events found in log file.")
|
|
96
|
+
|
|
97
|
+
_validate_events_with_pool(events=event_tuple)
|
|
98
|
+
|
|
99
|
+
header = _header_from_events(events=event_tuple)
|
|
100
|
+
loaded_ratings = _ratings_data_for_replay(
|
|
101
|
+
header=header,
|
|
102
|
+
ratings_data=ratings_data,
|
|
103
|
+
ratings_loader=ratings_loader,
|
|
104
|
+
)
|
|
105
|
+
pick_engine = PickEngine(
|
|
106
|
+
ratings_data=loaded_ratings,
|
|
107
|
+
splash_enabled=splash_enabled,
|
|
108
|
+
)
|
|
109
|
+
lines = _format_header(header=header)
|
|
110
|
+
lines.append("")
|
|
111
|
+
|
|
112
|
+
for event in event_tuple:
|
|
113
|
+
if isinstance(event, PackOfferedEvent):
|
|
114
|
+
lines.extend(
|
|
115
|
+
format_pack_offered_event(
|
|
116
|
+
event=event,
|
|
117
|
+
card_database=card_database,
|
|
118
|
+
pick_engine=pick_engine,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
elif isinstance(event, PickMadeEvent):
|
|
122
|
+
lines.extend(
|
|
123
|
+
format_pick_made_event(
|
|
124
|
+
event=event,
|
|
125
|
+
card_database=card_database,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
lines.append("")
|
|
129
|
+
elif isinstance(event, DraftCompletedEvent):
|
|
130
|
+
lines.extend(format_draft_completed_event(event=event))
|
|
131
|
+
lines.append("")
|
|
132
|
+
lines.extend(
|
|
133
|
+
_format_completed_build_sheet(
|
|
134
|
+
event=event,
|
|
135
|
+
header=header,
|
|
136
|
+
card_database=card_database,
|
|
137
|
+
ratings_data=loaded_ratings,
|
|
138
|
+
splash_enabled=splash_enabled,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _validate_events_with_pool(*, events: tuple[DraftEvent, ...]) -> None:
|
|
146
|
+
with tempfile.TemporaryDirectory(prefix="draftomen-replay-") as temporary_dir:
|
|
147
|
+
store = DraftPoolStore(app_dir=temporary_dir)
|
|
148
|
+
store.consume_all(events=events)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ratings_data_for_replay(
|
|
152
|
+
*,
|
|
153
|
+
header: _ReplayHeader,
|
|
154
|
+
ratings_data: SeventeenLandsData | None,
|
|
155
|
+
ratings_loader: RatingsLoader | None,
|
|
156
|
+
) -> SeventeenLandsData | None:
|
|
157
|
+
if ratings_data is not None or ratings_loader is None:
|
|
158
|
+
return ratings_data
|
|
159
|
+
|
|
160
|
+
if header.set_code is None:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
return ratings_loader(header.set_code)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _format_completed_build_sheet(
|
|
167
|
+
*,
|
|
168
|
+
event: DraftCompletedEvent,
|
|
169
|
+
header: _ReplayHeader,
|
|
170
|
+
card_database: CardDatabase,
|
|
171
|
+
ratings_data: SeventeenLandsData | None,
|
|
172
|
+
splash_enabled: bool,
|
|
173
|
+
) -> list[str]:
|
|
174
|
+
pool = BuildPool(
|
|
175
|
+
set_code=event.set_code,
|
|
176
|
+
pool_grp_ids=event.picked_grp_ids,
|
|
177
|
+
source_label=f"replay {header.draft_id or event.event_name}",
|
|
178
|
+
account_id=header.account_id,
|
|
179
|
+
draft_id=header.draft_id,
|
|
180
|
+
)
|
|
181
|
+
selection, build_sheet = build_deck_from_pool(
|
|
182
|
+
pool=pool,
|
|
183
|
+
card_database=card_database,
|
|
184
|
+
ratings_data=ratings_data,
|
|
185
|
+
allow_splash=splash_enabled,
|
|
186
|
+
)
|
|
187
|
+
return format_build_result(
|
|
188
|
+
pool=pool,
|
|
189
|
+
selection=selection,
|
|
190
|
+
spell_selection=build_sheet.spell_selection,
|
|
191
|
+
mana_base=build_sheet.mana_base,
|
|
192
|
+
).rstrip("\n").splitlines()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _header_from_events(*, events: tuple[DraftEvent, ...]) -> _ReplayHeader:
|
|
196
|
+
account_names: dict[str, str | None] = {}
|
|
197
|
+
active_account_id: str | None = None
|
|
198
|
+
event_name: str | None = None
|
|
199
|
+
set_code: str | None = None
|
|
200
|
+
draft_id: str | None = None
|
|
201
|
+
|
|
202
|
+
for event in events:
|
|
203
|
+
if isinstance(event, AccountEvent):
|
|
204
|
+
active_account_id = event.client_id
|
|
205
|
+
account_names[event.client_id] = event.screen_name
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
if isinstance(event, DraftStartedEvent):
|
|
209
|
+
active_account_id = event.account_id or active_account_id
|
|
210
|
+
event_name = event.event_name
|
|
211
|
+
set_code = event.set_code
|
|
212
|
+
draft_id = event.course_id
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
if isinstance(event, (PackOfferedEvent, PickMadeEvent, DraftCompletedEvent)):
|
|
216
|
+
active_account_id = event.account_id or active_account_id
|
|
217
|
+
event_name = event.event_name
|
|
218
|
+
set_code = event.set_code
|
|
219
|
+
draft_id = event.event_name
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
screen_name = None
|
|
223
|
+
if active_account_id is not None:
|
|
224
|
+
screen_name = account_names.get(active_account_id)
|
|
225
|
+
|
|
226
|
+
return _ReplayHeader(
|
|
227
|
+
account_id=active_account_id,
|
|
228
|
+
screen_name=screen_name,
|
|
229
|
+
event_name=event_name,
|
|
230
|
+
set_code=set_code,
|
|
231
|
+
draft_id=draft_id,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def format_pack_offered_event(
|
|
236
|
+
*,
|
|
237
|
+
event: PackOfferedEvent,
|
|
238
|
+
card_database: CardDatabase,
|
|
239
|
+
pick_engine: PickEngine | None = None,
|
|
240
|
+
scored_pack: ScoredPack | None = None,
|
|
241
|
+
) -> list[str]:
|
|
242
|
+
"""Format a pack offer with the same plain text replay uses.
|
|
243
|
+
Live watch mode calls this so pack rendering stays byte-compatible.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
return _format_pack(
|
|
247
|
+
event=event,
|
|
248
|
+
card_database=card_database,
|
|
249
|
+
pick_engine=pick_engine,
|
|
250
|
+
scored_pack=scored_pack,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def format_pick_made_event(
|
|
255
|
+
*,
|
|
256
|
+
event: PickMadeEvent,
|
|
257
|
+
card_database: CardDatabase,
|
|
258
|
+
) -> list[str]:
|
|
259
|
+
"""Format a chosen-card event with replay-compatible text.
|
|
260
|
+
The caller decides whether to add a separating blank line.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
return [
|
|
264
|
+
"Chosen card: "
|
|
265
|
+
f"{format_card_info(card_database.lookup(grp_id=event.chosen_grp_id))}"
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def format_draft_completed_event(*, event: DraftCompletedEvent) -> list[str]:
|
|
270
|
+
"""Format draft completion with replay-compatible text.
|
|
271
|
+
Completion type records whether Arena emitted an explicit status.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
completion_type = "inferred" if event.inferred else "explicit"
|
|
275
|
+
return [
|
|
276
|
+
"Draft complete: "
|
|
277
|
+
f"{len(event.picked_grp_ids)} cards ({completion_type} completion)"
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def format_card_info(card: CardInfo) -> str:
|
|
282
|
+
"""Format one card for plain CLI output.
|
|
283
|
+
Unknown cards are displayed explicitly instead of failing lookups.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
return _format_card(card)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _format_header(*, header: _ReplayHeader) -> list[str]:
|
|
290
|
+
return [
|
|
291
|
+
"Draft Omen replay",
|
|
292
|
+
f"Account: {_format_account(header=header)}",
|
|
293
|
+
f"Set: {header.set_code or 'unknown'}",
|
|
294
|
+
f"Event: {header.event_name or 'unknown'}",
|
|
295
|
+
f"Draft: {header.draft_id or 'unknown'}",
|
|
296
|
+
f"Attribution: {SEVENTEEN_LANDS_ATTRIBUTION}",
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _format_account(*, header: _ReplayHeader) -> str:
|
|
301
|
+
if header.account_id is None:
|
|
302
|
+
return "unknown"
|
|
303
|
+
|
|
304
|
+
if header.screen_name is None:
|
|
305
|
+
return header.account_id
|
|
306
|
+
|
|
307
|
+
return f"{header.screen_name} ({header.account_id})"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _format_pack(
|
|
311
|
+
*,
|
|
312
|
+
event: PackOfferedEvent,
|
|
313
|
+
card_database: CardDatabase,
|
|
314
|
+
pick_engine: PickEngine | None,
|
|
315
|
+
scored_pack: ScoredPack | None = None,
|
|
316
|
+
) -> list[str]:
|
|
317
|
+
if scored_pack is None:
|
|
318
|
+
engine = pick_engine if pick_engine is not None else PickEngine()
|
|
319
|
+
scored_pack = engine.score_pack(
|
|
320
|
+
offered_grp_ids=event.offered_grp_ids,
|
|
321
|
+
card_database=card_database,
|
|
322
|
+
pool_grp_ids=event.pool_grp_ids,
|
|
323
|
+
pick_index=_draft_pick_index(event=event),
|
|
324
|
+
)
|
|
325
|
+
lines = [
|
|
326
|
+
f"Pack {event.pack_number + 1} Pick {event.pick_number + 1}",
|
|
327
|
+
_format_pack_status(scored_pack=scored_pack),
|
|
328
|
+
f"Data source: {scored_pack.source_summary}",
|
|
329
|
+
]
|
|
330
|
+
unresolved_count = len(
|
|
331
|
+
card_database.unresolved_grp_ids(
|
|
332
|
+
grp_ids=(*event.offered_grp_ids, *event.pool_grp_ids),
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
if unresolved_count > 0:
|
|
336
|
+
lines.append(f"Warning: {unresolved_count} unresolved card metadata")
|
|
337
|
+
|
|
338
|
+
lines.append("Offered cards:")
|
|
339
|
+
lines.extend(_format_scored_cards(cards=scored_pack.cards))
|
|
340
|
+
if any(card.no_data for card in scored_pack.cards):
|
|
341
|
+
lines.append(" * Prior uses neutral prior adjusted by ALSA when available.")
|
|
342
|
+
|
|
343
|
+
return lines
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _draft_pick_index(*, event: PackOfferedEvent) -> int:
|
|
347
|
+
return (event.pack_number * EXPECTED_PICKS_PER_PACK) + event.pick_number + 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _format_pack_status(*, scored_pack: ScoredPack) -> str:
|
|
351
|
+
commitment = scored_pack.commitment
|
|
352
|
+
pair = commitment.inferred_pair if commitment.inferred_pair is not None else "open"
|
|
353
|
+
percent = int(round(commitment.level * 100))
|
|
354
|
+
status = (
|
|
355
|
+
"Status: "
|
|
356
|
+
f"inferred pair {pair}, "
|
|
357
|
+
f"commitment {percent}% ({commitment.phase}), "
|
|
358
|
+
f"pool {commitment.pool_size}"
|
|
359
|
+
)
|
|
360
|
+
splash_status = _format_splash_status(scored_pack=scored_pack)
|
|
361
|
+
if splash_status is None:
|
|
362
|
+
return status
|
|
363
|
+
|
|
364
|
+
return f"{status}, {splash_status}"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _format_splash_status(*, scored_pack: ScoredPack) -> str | None:
|
|
368
|
+
state = scored_pack.splash_state
|
|
369
|
+
if not state.enabled:
|
|
370
|
+
return "splash disabled"
|
|
371
|
+
if state.active_color is None:
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
fixing_sources = state.fixing_for(color=state.active_color)
|
|
375
|
+
return (
|
|
376
|
+
f"splash {state.active_color} "
|
|
377
|
+
f"{state.picked_card_count}/2 cards, "
|
|
378
|
+
f"{fixing_sources} drafted fixing"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _format_scored_cards(*, cards: tuple[ScoredCard, ...]) -> list[str]:
|
|
383
|
+
if not cards:
|
|
384
|
+
return []
|
|
385
|
+
|
|
386
|
+
card_width = max(len(_format_scored_card_name(card)) for card in cards)
|
|
387
|
+
fit_width = (
|
|
388
|
+
9
|
|
389
|
+
if any(card.color_fit.startswith("splash-") for card in cards)
|
|
390
|
+
else 5
|
|
391
|
+
)
|
|
392
|
+
lines = [
|
|
393
|
+
" # Score "
|
|
394
|
+
f"{'Card':<{card_width}} "
|
|
395
|
+
f"Colors {'Fit':<{fit_width}} GIH WR ALSA MV Source"
|
|
396
|
+
]
|
|
397
|
+
for rank, scored_card in enumerate(cards, start=1):
|
|
398
|
+
lines.append(
|
|
399
|
+
" "
|
|
400
|
+
f"{rank:02d} "
|
|
401
|
+
f"{scored_card.score:>5} "
|
|
402
|
+
f"{_format_scored_card_name(scored_card):<{card_width}} "
|
|
403
|
+
f"{_format_card_colors(scored_card.card):<9} "
|
|
404
|
+
f"{_format_color_fit(scored_card):<{fit_width}} "
|
|
405
|
+
f"{_format_win_rate(scored_card):>6} "
|
|
406
|
+
f"{_format_alsa(scored_card):>5} "
|
|
407
|
+
f"{_format_mana_value(scored_card.card):>4} "
|
|
408
|
+
f"{scored_card.source_label}"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return lines
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _format_scored_card_name(card: ScoredCard) -> str:
|
|
415
|
+
return f"{card.card.name} (grpId {card.card.grp_id})"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _format_win_rate(card: ScoredCard) -> str:
|
|
419
|
+
if card.rating.gih_win_rate is None:
|
|
420
|
+
return "—"
|
|
421
|
+
|
|
422
|
+
return f"{card.rating.gih_win_rate:.1%}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _format_alsa(card: ScoredCard) -> str:
|
|
426
|
+
if card.rating.average_last_seen_at is None:
|
|
427
|
+
return "—"
|
|
428
|
+
|
|
429
|
+
return f"{card.rating.average_last_seen_at:.2f}"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _format_color_fit(card: ScoredCard) -> str:
|
|
433
|
+
if card.color_fit == "on-color":
|
|
434
|
+
return "On"
|
|
435
|
+
|
|
436
|
+
if card.color_fit == "off-color":
|
|
437
|
+
return "Off!"
|
|
438
|
+
|
|
439
|
+
if card.color_fit == "colorless":
|
|
440
|
+
return "Any"
|
|
441
|
+
|
|
442
|
+
if card.color_fit == "unknown":
|
|
443
|
+
return "?"
|
|
444
|
+
|
|
445
|
+
if card.color_fit == "splash-ready":
|
|
446
|
+
return f"Splash {card.splash.splash_color}"
|
|
447
|
+
|
|
448
|
+
if card.color_fit == "splash-speculative":
|
|
449
|
+
return f"Splash?{card.splash.splash_color}"
|
|
450
|
+
|
|
451
|
+
if card.color_fit == "splash-fixer":
|
|
452
|
+
return f"Fix {card.splash.splash_color}"
|
|
453
|
+
|
|
454
|
+
return "Open"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _format_mana_value(card: CardInfo) -> str:
|
|
458
|
+
if card.mana_value is None:
|
|
459
|
+
return "—"
|
|
460
|
+
|
|
461
|
+
if card.mana_value.is_integer():
|
|
462
|
+
return str(int(card.mana_value))
|
|
463
|
+
|
|
464
|
+
return f"{card.mana_value:.1f}"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _format_card_colors(card: CardInfo) -> str:
|
|
468
|
+
if card.unknown:
|
|
469
|
+
return "Unknown"
|
|
470
|
+
|
|
471
|
+
return "".join(card.colors) if card.colors else "Colorless"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _format_card(card: CardInfo) -> str:
|
|
475
|
+
if card.unknown:
|
|
476
|
+
colors = "Unknown"
|
|
477
|
+
else:
|
|
478
|
+
colors = "".join(card.colors) if card.colors else "Colorless"
|
|
479
|
+
|
|
480
|
+
return f"{card.name} [{colors}] (grpId {card.grp_id})"
|