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.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. 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
+