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/qt_gui.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Launch the Draftomen PySide6 and QML desktop application.
|
|
2
|
+
Select the live application provider or deterministic mock provider explicitly.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from time import monotonic
|
|
12
|
+
from typing import Literal, cast
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QTimer, QUrl
|
|
15
|
+
from PySide6.QtGui import QFontDatabase, QGuiApplication
|
|
16
|
+
from PySide6.QtQml import QQmlApplicationEngine
|
|
17
|
+
from PySide6.QtQuickControls2 import QQuickStyle
|
|
18
|
+
|
|
19
|
+
from draftomen import __version__
|
|
20
|
+
from draftomen.carddb import (
|
|
21
|
+
CardDatabase,
|
|
22
|
+
build_card_database_from_bulk_file,
|
|
23
|
+
load_or_refresh_card_database,
|
|
24
|
+
)
|
|
25
|
+
from draftomen.cardimages import CardImageService, card_image_cache_dir
|
|
26
|
+
from draftomen.mock_session import MOCK_SCENARIOS, MockLiveSession, MockScenario
|
|
27
|
+
from draftomen.paths import resolve_player_log_path
|
|
28
|
+
from draftomen.qt_adapter import (
|
|
29
|
+
GuiPreferencesAdapter,
|
|
30
|
+
LiveSessionAdapter,
|
|
31
|
+
SessionAdapter,
|
|
32
|
+
SessionFactory,
|
|
33
|
+
)
|
|
34
|
+
from draftomen.qt_mock import MockSessionAdapter
|
|
35
|
+
from draftomen.session import LiveSession, SnapshotPublisher
|
|
36
|
+
from draftomen.seventeen import (
|
|
37
|
+
DownloadProgressCallback,
|
|
38
|
+
SeventeenLandsData,
|
|
39
|
+
has_cached_17lands_data,
|
|
40
|
+
load_or_refresh_17lands_data,
|
|
41
|
+
metadata_augmenting_ratings_progress_loader,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
SURFACES = ("live", "build", "backtest", "settings")
|
|
46
|
+
ProviderName = Literal["live", "mock"]
|
|
47
|
+
APPLICATION_NAME = "Draft Omen"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _configure_application_metadata(*, application: QGuiApplication) -> None:
|
|
51
|
+
"""Set the Qt application metadata.
|
|
52
|
+
Use the canonical package name and version.
|
|
53
|
+
"""
|
|
54
|
+
application.setApplicationName(APPLICATION_NAME)
|
|
55
|
+
application.setApplicationDisplayName(APPLICATION_NAME)
|
|
56
|
+
application.setApplicationVersion(__version__)
|
|
57
|
+
application.setOrganizationName(APPLICATION_NAME)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _qml_directory(*, executable_path: Path | None = None) -> Path:
|
|
61
|
+
"""Resolve QML beside the source package or compiled bundle executable."""
|
|
62
|
+
source_directory = Path(__file__).with_name("qml")
|
|
63
|
+
if source_directory.is_dir():
|
|
64
|
+
return source_directory
|
|
65
|
+
|
|
66
|
+
bundle_executable = (
|
|
67
|
+
Path(sys.argv[0]) if executable_path is None else executable_path
|
|
68
|
+
)
|
|
69
|
+
return bundle_executable.resolve().parent / "qml"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fixed_font_family() -> str:
|
|
73
|
+
if QGuiApplication.instance() is None:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"QGuiApplication must exist before resolving the fixed font."
|
|
76
|
+
)
|
|
77
|
+
return QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parser(*, forced_provider: ProviderName | None = None) -> argparse.ArgumentParser:
|
|
81
|
+
parser = argparse.ArgumentParser(
|
|
82
|
+
description="Launch the Draft Omen desktop application.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--provider",
|
|
86
|
+
choices=("live", "mock"),
|
|
87
|
+
default="live" if forced_provider is None else forced_provider,
|
|
88
|
+
help="Use production services or deterministic visual-development data.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--scenario",
|
|
92
|
+
choices=MOCK_SCENARIOS,
|
|
93
|
+
default="ready",
|
|
94
|
+
help="Initial representative state in mock mode.",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--surface",
|
|
98
|
+
choices=SURFACES,
|
|
99
|
+
default="live",
|
|
100
|
+
help="Initial application surface.",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument("--width", type=int, default=1440)
|
|
103
|
+
parser.add_argument("--height", type=int, default=900)
|
|
104
|
+
parser.add_argument("--log-path", type=Path, default=None)
|
|
105
|
+
parser.add_argument("--app-dir", type=Path, default=None, help=argparse.SUPPRESS)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--bulk-file",
|
|
108
|
+
type=Path,
|
|
109
|
+
default=None,
|
|
110
|
+
help="Use a local Scryfall JSONL card database in live mode.",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--poll-interval", type=float, default=1.0)
|
|
113
|
+
parser.set_defaults(startup_scan=True)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--no-startup-scan",
|
|
116
|
+
dest="startup_scan",
|
|
117
|
+
action="store_false",
|
|
118
|
+
help="Skip live startup log recovery.",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--smoke-test",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="Render the window and exit automatically.",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--smoke-test-until-complete",
|
|
127
|
+
action="store_true",
|
|
128
|
+
help=argparse.SUPPRESS,
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--screenshot",
|
|
132
|
+
type=Path,
|
|
133
|
+
help="Save the rendered window before exiting.",
|
|
134
|
+
)
|
|
135
|
+
return parser
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _live_session_factory(
|
|
139
|
+
*,
|
|
140
|
+
log_path: Path | None,
|
|
141
|
+
app_dir: Path | None,
|
|
142
|
+
bulk_file: Path | None,
|
|
143
|
+
poll_interval: float,
|
|
144
|
+
) -> SessionFactory:
|
|
145
|
+
def load_card_database() -> CardDatabase:
|
|
146
|
+
if bulk_file is not None:
|
|
147
|
+
return build_card_database_from_bulk_file(path=bulk_file)
|
|
148
|
+
return load_or_refresh_card_database(app_dir=app_dir)
|
|
149
|
+
|
|
150
|
+
def load_ratings(
|
|
151
|
+
set_code: str,
|
|
152
|
+
progress_callback: DownloadProgressCallback,
|
|
153
|
+
*,
|
|
154
|
+
refresh: bool,
|
|
155
|
+
) -> SeventeenLandsData:
|
|
156
|
+
return load_or_refresh_17lands_data(
|
|
157
|
+
set_code=set_code,
|
|
158
|
+
app_dir=app_dir,
|
|
159
|
+
refresh=refresh,
|
|
160
|
+
progress_callback=progress_callback,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def factory(publish: SnapshotPublisher) -> LiveSession:
|
|
164
|
+
return LiveSession(
|
|
165
|
+
log_path=resolve_player_log_path(log_path=log_path),
|
|
166
|
+
card_database_loader=load_card_database,
|
|
167
|
+
app_dir=app_dir,
|
|
168
|
+
poll_interval=poll_interval,
|
|
169
|
+
snapshot_publisher=publish,
|
|
170
|
+
ratings_progress_loader_factory=lambda database: (
|
|
171
|
+
metadata_augmenting_ratings_progress_loader(
|
|
172
|
+
database=database,
|
|
173
|
+
load_ratings=load_ratings,
|
|
174
|
+
app_dir=app_dir,
|
|
175
|
+
persist_database=bulk_file is None,
|
|
176
|
+
)
|
|
177
|
+
),
|
|
178
|
+
card_image_service=CardImageService(
|
|
179
|
+
cache_dir=card_image_cache_dir(app_dir=app_dir),
|
|
180
|
+
timeout_seconds=2.0,
|
|
181
|
+
max_attempts=1,
|
|
182
|
+
),
|
|
183
|
+
ratings_cache_checker=lambda set_code: has_cached_17lands_data(
|
|
184
|
+
set_code=set_code,
|
|
185
|
+
app_dir=app_dir,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return factory
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _build_provider(*, args: argparse.Namespace) -> SessionAdapter:
|
|
193
|
+
if args.provider == "mock":
|
|
194
|
+
return MockSessionAdapter(
|
|
195
|
+
session=MockLiveSession(
|
|
196
|
+
scenario=cast(MockScenario, args.scenario),
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
if args.poll_interval <= 0:
|
|
200
|
+
raise ValueError("--poll-interval must be greater than zero.")
|
|
201
|
+
return LiveSessionAdapter(
|
|
202
|
+
session_factory=_live_session_factory(
|
|
203
|
+
log_path=args.log_path,
|
|
204
|
+
app_dir=args.app_dir,
|
|
205
|
+
bulk_file=args.bulk_file,
|
|
206
|
+
poll_interval=args.poll_interval,
|
|
207
|
+
),
|
|
208
|
+
poll_interval_ms=max(1, round(args.poll_interval * 1000)),
|
|
209
|
+
startup_scan=args.startup_scan,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _finish_smoke_test(
|
|
214
|
+
*,
|
|
215
|
+
engine: QQmlApplicationEngine,
|
|
216
|
+
application: QGuiApplication,
|
|
217
|
+
screenshot: Path | None,
|
|
218
|
+
) -> None:
|
|
219
|
+
root_objects = engine.rootObjects()
|
|
220
|
+
if not root_objects:
|
|
221
|
+
application.exit(1)
|
|
222
|
+
return
|
|
223
|
+
if screenshot is not None:
|
|
224
|
+
screenshot.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
image = root_objects[0].grabWindow()
|
|
226
|
+
if image.isNull() or not image.save(str(screenshot)):
|
|
227
|
+
application.exit(1)
|
|
228
|
+
return
|
|
229
|
+
print(f"Saved GUI screenshot to {screenshot}")
|
|
230
|
+
application.quit()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _finish_smoke_test_when_draft_completes(
|
|
234
|
+
*,
|
|
235
|
+
engine: QQmlApplicationEngine,
|
|
236
|
+
application: QGuiApplication,
|
|
237
|
+
provider: SessionAdapter,
|
|
238
|
+
screenshot: Path | None,
|
|
239
|
+
timeout_seconds: float = 10.0,
|
|
240
|
+
) -> None:
|
|
241
|
+
deadline = monotonic() + timeout_seconds
|
|
242
|
+
timer = QTimer(application)
|
|
243
|
+
|
|
244
|
+
def stop_waiting() -> None:
|
|
245
|
+
timer.stop()
|
|
246
|
+
timer.timeout.disconnect(finish_when_ready)
|
|
247
|
+
timer.deleteLater()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def finish_when_ready() -> None:
|
|
251
|
+
status = provider.state.get("status", {})
|
|
252
|
+
if status.get("phase") == "draft_complete":
|
|
253
|
+
stop_waiting()
|
|
254
|
+
QTimer.singleShot(
|
|
255
|
+
0,
|
|
256
|
+
lambda: _finish_smoke_test(
|
|
257
|
+
engine=engine,
|
|
258
|
+
application=application,
|
|
259
|
+
screenshot=screenshot,
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
elif monotonic() >= deadline:
|
|
263
|
+
stop_waiting()
|
|
264
|
+
QTimer.singleShot(0, lambda: application.exit(1))
|
|
265
|
+
|
|
266
|
+
timer.setInterval(20)
|
|
267
|
+
timer.timeout.connect(finish_when_ready)
|
|
268
|
+
timer.start()
|
|
269
|
+
|
|
270
|
+
def run_gui(
|
|
271
|
+
*,
|
|
272
|
+
argv: Sequence[str] | None = None,
|
|
273
|
+
forced_provider: ProviderName | None = None,
|
|
274
|
+
) -> int:
|
|
275
|
+
args = _parser(forced_provider=forced_provider).parse_args(argv)
|
|
276
|
+
if forced_provider is not None:
|
|
277
|
+
args.provider = forced_provider
|
|
278
|
+
|
|
279
|
+
QQuickStyle.setStyle("Fusion")
|
|
280
|
+
application = QGuiApplication([sys.argv[0]])
|
|
281
|
+
_configure_application_metadata(application=application)
|
|
282
|
+
|
|
283
|
+
provider = _build_provider(args=args)
|
|
284
|
+
preferences = GuiPreferencesAdapter(app_dir=args.app_dir, parent=application)
|
|
285
|
+
engine = QQmlApplicationEngine()
|
|
286
|
+
qml_directory = _qml_directory()
|
|
287
|
+
engine.addImportPath(str(qml_directory))
|
|
288
|
+
context = engine.rootContext()
|
|
289
|
+
context.setContextProperty("sessionProvider", provider)
|
|
290
|
+
context.setContextProperty("guiPreferences", preferences)
|
|
291
|
+
context.setContextProperty("applicationTitle", APPLICATION_NAME)
|
|
292
|
+
context.setContextProperty("fixedFontFamily", _fixed_font_family())
|
|
293
|
+
context.setContextProperty("applicationVersion", __version__)
|
|
294
|
+
context.setContextProperty("initialSurface", args.surface)
|
|
295
|
+
context.setContextProperty("initialWindowWidth", args.width)
|
|
296
|
+
context.setContextProperty("initialWindowHeight", args.height)
|
|
297
|
+
engine.setInitialProperties({"provider": provider})
|
|
298
|
+
|
|
299
|
+
engine.load(QUrl.fromLocalFile(str(qml_directory / "Main.qml")))
|
|
300
|
+
if not engine.rootObjects():
|
|
301
|
+
return 1
|
|
302
|
+
|
|
303
|
+
if isinstance(provider, LiveSessionAdapter):
|
|
304
|
+
application.aboutToQuit.connect(provider.shutdown)
|
|
305
|
+
provider.start()
|
|
306
|
+
|
|
307
|
+
if args.smoke_test_until_complete:
|
|
308
|
+
_finish_smoke_test_when_draft_completes(
|
|
309
|
+
engine=engine,
|
|
310
|
+
application=application,
|
|
311
|
+
provider=provider,
|
|
312
|
+
screenshot=args.screenshot,
|
|
313
|
+
)
|
|
314
|
+
elif args.smoke_test or args.screenshot is not None:
|
|
315
|
+
QTimer.singleShot(
|
|
316
|
+
800,
|
|
317
|
+
lambda: _finish_smoke_test(
|
|
318
|
+
engine=engine,
|
|
319
|
+
application=application,
|
|
320
|
+
screenshot=args.screenshot,
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
exit_code = application.exec()
|
|
324
|
+
preferences.shutdown()
|
|
325
|
+
if isinstance(provider, LiveSessionAdapter):
|
|
326
|
+
provider.shutdown()
|
|
327
|
+
provider.wait_for_shutdown()
|
|
328
|
+
del engine
|
|
329
|
+
return exit_code
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main() -> int:
|
|
333
|
+
return run_gui()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
raise SystemExit(main())
|
|
338
|
+
|
draftomen/qt_mock.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Launch the deterministic PySide6 and QML desktop mockup.
|
|
2
|
+
The mock provider implements the same narrow contract as the live adapter.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import Property, QObject, Slot
|
|
10
|
+
|
|
11
|
+
from draftomen.mock_session import MOCK_SCENARIOS, MockLiveSession, MockScenario
|
|
12
|
+
from draftomen.qt_adapter import SessionAdapter
|
|
13
|
+
from draftomen.session import LiveSessionCommand
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MockSessionAdapter(SessionAdapter):
|
|
17
|
+
"""Translate deterministic snapshots and explicit commands for QML.
|
|
18
|
+
The mock owns representative data only, never production behavior.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
session: MockLiveSession,
|
|
25
|
+
parent: QObject | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._session = session
|
|
28
|
+
super().__init__(snapshot=session.snapshot, parent=parent)
|
|
29
|
+
|
|
30
|
+
@Property(bool, constant=True)
|
|
31
|
+
def mockMode(self) -> bool:
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
@Property(str, notify=SessionAdapter.scenarioChanged)
|
|
35
|
+
def scenario(self) -> str:
|
|
36
|
+
return self._session.scenario
|
|
37
|
+
|
|
38
|
+
@Property("QStringList", constant=True)
|
|
39
|
+
def scenarios(self) -> list[str]:
|
|
40
|
+
return list(MOCK_SCENARIOS)
|
|
41
|
+
|
|
42
|
+
@Slot(str)
|
|
43
|
+
def selectScenario(self, scenario: str) -> None:
|
|
44
|
+
if scenario not in MOCK_SCENARIOS:
|
|
45
|
+
return
|
|
46
|
+
self._session.select_scenario(scenario=cast(MockScenario, scenario))
|
|
47
|
+
self.scenarioChanged.emit()
|
|
48
|
+
self._publish(snapshot=self._session.snapshot)
|
|
49
|
+
|
|
50
|
+
def _dispatch(self, *, command: LiveSessionCommand) -> None:
|
|
51
|
+
self._session.dispatch(command=command)
|
|
52
|
+
self._publish(snapshot=self._session.snapshot)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> int:
|
|
56
|
+
from draftomen.qt_gui import run_gui
|
|
57
|
+
|
|
58
|
+
return run_gui(forced_provider="mock")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
raise SystemExit(main())
|
|
63
|
+
|
draftomen/ranking.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shared ranking modes for scored draft cards.
|
|
2
|
+
Keep UI sorting and backtest recommendations explicit and consistent.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
from draftomen.pickengine import ScoredCard
|
|
10
|
+
|
|
11
|
+
RankingMode: TypeAlias = Literal["win_rate", "score", "alsa", "mv"]
|
|
12
|
+
|
|
13
|
+
DEFAULT_RANKING_MODE: RankingMode = "score"
|
|
14
|
+
RANKING_MODES: tuple[RankingMode, ...] = ("score", "win_rate", "alsa", "mv")
|
|
15
|
+
RANKING_LABELS: dict[RankingMode, str] = {
|
|
16
|
+
"win_rate": "17L WR",
|
|
17
|
+
"score": "DO Score",
|
|
18
|
+
"alsa": "ALSA",
|
|
19
|
+
"mv": "MV",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_ranking_mode(*, ranking_mode: str) -> RankingMode:
|
|
24
|
+
"""Return a supported ranking mode or fail loudly.
|
|
25
|
+
Callers use this before producing user-visible recommendations.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
if ranking_mode in RANKING_MODES:
|
|
29
|
+
return ranking_mode # type: ignore[return-value]
|
|
30
|
+
|
|
31
|
+
allowed = ", ".join(RANKING_MODES)
|
|
32
|
+
raise ValueError(f"Unsupported ranking mode {ranking_mode!r}; expected one of {allowed}.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ranking_label(*, ranking_mode: str) -> str:
|
|
36
|
+
"""Return the user-facing label for one ranking mode.
|
|
37
|
+
Unknown values are validated so labels never silently lie.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
return RANKING_LABELS[validate_ranking_mode(ranking_mode=ranking_mode)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rank_scored_cards(
|
|
44
|
+
*,
|
|
45
|
+
cards: tuple[ScoredCard, ...],
|
|
46
|
+
ranking_mode: str,
|
|
47
|
+
) -> tuple[ScoredCard, ...]:
|
|
48
|
+
"""Return scored cards ordered by the selected ranking mode.
|
|
49
|
+
Ties remain deterministic and prefer stronger DO scores where useful.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
mode = validate_ranking_mode(ranking_mode=ranking_mode)
|
|
53
|
+
if mode == "win_rate":
|
|
54
|
+
return tuple(sorted(cards, key=_win_rate_sort_key))
|
|
55
|
+
|
|
56
|
+
if mode == "score":
|
|
57
|
+
return tuple(sorted(cards, key=_score_sort_key))
|
|
58
|
+
|
|
59
|
+
if mode == "alsa":
|
|
60
|
+
return tuple(sorted(cards, key=_alsa_sort_key))
|
|
61
|
+
|
|
62
|
+
return tuple(sorted(cards, key=_mana_value_sort_key))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _win_rate_sort_key(
|
|
66
|
+
card: ScoredCard,
|
|
67
|
+
) -> tuple[bool, bool, float, int, float, int]:
|
|
68
|
+
win_rate = card.rating.gih_win_rate
|
|
69
|
+
if win_rate is None:
|
|
70
|
+
return (
|
|
71
|
+
card.freely_available_basic,
|
|
72
|
+
True,
|
|
73
|
+
0.0,
|
|
74
|
+
-card.score,
|
|
75
|
+
-card.raw_score,
|
|
76
|
+
card.original_index,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
card.freely_available_basic,
|
|
81
|
+
False,
|
|
82
|
+
-win_rate,
|
|
83
|
+
-card.score,
|
|
84
|
+
-card.raw_score,
|
|
85
|
+
card.original_index,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _score_sort_key(
|
|
90
|
+
card: ScoredCard,
|
|
91
|
+
) -> tuple[bool, int, int, float, float, int]:
|
|
92
|
+
return (
|
|
93
|
+
card.freely_available_basic,
|
|
94
|
+
card.score_sort_index,
|
|
95
|
+
-card.score,
|
|
96
|
+
-card.raw_score,
|
|
97
|
+
-card.base_rating,
|
|
98
|
+
card.original_index,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _alsa_sort_key(card: ScoredCard) -> tuple[bool, float, int, float, int]:
|
|
103
|
+
alsa = card.rating.average_last_seen_at
|
|
104
|
+
sort_alsa = float("inf") if alsa is None else alsa
|
|
105
|
+
return (
|
|
106
|
+
card.freely_available_basic,
|
|
107
|
+
sort_alsa,
|
|
108
|
+
-card.score,
|
|
109
|
+
-card.raw_score,
|
|
110
|
+
card.original_index,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _mana_value_sort_key(
|
|
115
|
+
card: ScoredCard,
|
|
116
|
+
) -> tuple[bool, float, int, float, int]:
|
|
117
|
+
mana_value = card.card.mana_value
|
|
118
|
+
sort_mana_value = float("inf") if mana_value is None else mana_value
|
|
119
|
+
return (
|
|
120
|
+
card.freely_available_basic,
|
|
121
|
+
sort_mana_value,
|
|
122
|
+
-card.score,
|
|
123
|
+
-card.raw_score,
|
|
124
|
+
card.original_index,
|
|
125
|
+
)
|
|
126
|
+
|