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/cli.py ADDED
@@ -0,0 +1,891 @@
1
+ """Command-line interface for Draftomen.
2
+ Define parser wiring and command handlers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from collections.abc import Callable, Sequence
10
+ from pathlib import Path
11
+
12
+ from draftomen import DISCLAIMER, __version__
13
+ from draftomen.audit import DraftAuditError
14
+ from draftomen.backtest import (
15
+ BacktestError,
16
+ format_backtest_report,
17
+ generate_backtest_report,
18
+ load_persisted_backtest_state,
19
+ )
20
+ from draftomen.benchmark import (
21
+ PickBenchmarkError,
22
+ format_pick_benchmark_report,
23
+ generate_pick_benchmark_report,
24
+ )
25
+ from draftomen.carddb import (
26
+ CardDatabase,
27
+ CardDatabaseError,
28
+ build_card_database_from_bulk_file,
29
+ card_database_cache_path,
30
+ load_card_database,
31
+ load_or_refresh_card_database,
32
+ refresh_card_database,
33
+ )
34
+ from draftomen.config import COLOR_PAIRS
35
+ from draftomen.deckbuilder import (
36
+ DeckBuilderError,
37
+ build_deck_from_pool,
38
+ format_build_result,
39
+ load_persisted_pool,
40
+ load_pool_file,
41
+ )
42
+ from draftomen.events import DraftLogParseError
43
+ from draftomen.logfollow import LogFollowError
44
+ from draftomen.paths import UnsupportedPlatformError, resolve_player_log_path
45
+ from draftomen.pool import DraftPoolError
46
+ from draftomen.ranking import DEFAULT_RANKING_MODE, RANKING_MODES
47
+ from draftomen.replay import ReplayError, replay_log_file
48
+ from draftomen.seventeen import (
49
+ PREMIER_DRAFT_FORMAT,
50
+ QUICK_DRAFT_FORMAT,
51
+ DownloadProgressCallback,
52
+ SeventeenLandsData,
53
+ augment_card_database_from_ratings,
54
+ SeventeenLandsError,
55
+ has_cached_17lands_data,
56
+ load_cached_17lands_data,
57
+ load_or_refresh_17lands_data,
58
+ metadata_augmenting_ratings_loader,
59
+ metadata_augmenting_ratings_progress_loader,
60
+ refresh_17lands_structure_targets,
61
+ seventeen_lands_structure_targets_cache_path,
62
+ )
63
+ from draftomen.tui import run_tui_watch
64
+ from draftomen.watch import run_plain_watch
65
+
66
+ CommandHandler = Callable[[argparse.Namespace], int]
67
+
68
+
69
+ def build_parser() -> argparse.ArgumentParser:
70
+ """Build the top-level argument parser.
71
+ Keep subcommand registration centralized for CLI tests.
72
+ """
73
+
74
+ parser = argparse.ArgumentParser(
75
+ prog="draftomen-tui",
76
+ description="Unofficial Quick Draft assistant for MTG Arena (TUI).",
77
+ )
78
+ parser.add_argument(
79
+ "--version",
80
+ action="store_true",
81
+ help="Print the Draft Omen version and required Fan Content disclaimer.",
82
+ )
83
+
84
+ subparsers = parser.add_subparsers(
85
+ dest="command",
86
+ metavar="COMMAND",
87
+ title="subcommands",
88
+ )
89
+
90
+ watch_parser = subparsers.add_parser(
91
+ name="watch",
92
+ help="Watch Player.log and show live draft recommendations.",
93
+ description="Live log watcher. Plain mode streams replay-compatible text.",
94
+ )
95
+ watch_parser.add_argument(
96
+ "--log-path",
97
+ type=Path,
98
+ default=None,
99
+ help="Override the default MTG Arena Player.log path.",
100
+ )
101
+ watch_parser.add_argument(
102
+ "--plain",
103
+ action="store_true",
104
+ help="Use plain-text output instead of the default TUI.",
105
+ )
106
+ watch_parser.add_argument(
107
+ "--mana-icons",
108
+ action="store_true",
109
+ help="Opt in to Mana font icons in the Textual TUI.",
110
+ )
111
+ splash_group = watch_parser.add_mutually_exclusive_group()
112
+ splash_group.add_argument(
113
+ "--splash",
114
+ dest="splash_enabled",
115
+ action="store_true",
116
+ help="Enable splash recommendations for this watch session.",
117
+ )
118
+ splash_group.add_argument(
119
+ "--no-splash",
120
+ dest="splash_enabled",
121
+ action="store_false",
122
+ help="Disable splash recommendations for this watch session.",
123
+ )
124
+ watch_parser.set_defaults(splash_enabled=None)
125
+ watch_parser.add_argument(
126
+ "--bulk-file",
127
+ type=Path,
128
+ default=None,
129
+ help=(
130
+ "Resolve card names from a local Scryfall JSONL(.gz) bulk file "
131
+ "instead of the cached card database."
132
+ ),
133
+ )
134
+ watch_parser.add_argument(
135
+ "--poll-interval",
136
+ type=float,
137
+ default=1.0,
138
+ help="Seconds between Player.log polls in watch mode.",
139
+ )
140
+ watch_parser.set_defaults(startup_scan=True)
141
+ watch_parser.add_argument(
142
+ "--startup-scan",
143
+ dest="startup_scan",
144
+ action="store_true",
145
+ help=argparse.SUPPRESS,
146
+ )
147
+ watch_parser.add_argument(
148
+ "--no-startup-scan",
149
+ dest="startup_scan",
150
+ action="store_false",
151
+ help="Skip startup recovery and only process new Player.log lines.",
152
+ )
153
+ watch_parser.add_argument(
154
+ "--once",
155
+ action="store_true",
156
+ help="Process one poll cycle and exit.",
157
+ )
158
+ watch_parser.add_argument(
159
+ "--app-dir",
160
+ type=Path,
161
+ default=None,
162
+ help=argparse.SUPPRESS,
163
+ )
164
+ watch_parser.set_defaults(handler=handle_watch)
165
+
166
+ replay_parser = subparsers.add_parser(
167
+ name="replay",
168
+ help="Replay a captured Player.log fixture in plain-text mode.",
169
+ description="Deterministic offline replay over a captured log file.",
170
+ )
171
+ replay_parser.add_argument(
172
+ "logfile",
173
+ type=Path,
174
+ help="Captured Player.log file to replay.",
175
+ )
176
+ replay_parser.add_argument(
177
+ "--bulk-file",
178
+ type=Path,
179
+ default=None,
180
+ help=(
181
+ "Resolve card names from a local Scryfall JSONL(.gz) bulk file "
182
+ "instead of the cached card database."
183
+ ),
184
+ )
185
+ replay_parser.add_argument(
186
+ "--app-dir",
187
+ type=Path,
188
+ default=None,
189
+ help=argparse.SUPPRESS,
190
+ )
191
+ replay_parser.add_argument(
192
+ "--no-splash",
193
+ dest="splash_enabled",
194
+ action="store_false",
195
+ help="Disable splash recommendations and splash deck building.",
196
+ )
197
+ replay_parser.set_defaults(splash_enabled=True)
198
+ replay_parser.set_defaults(handler=handle_replay)
199
+
200
+ build_parser_command = subparsers.add_parser(
201
+ name="build",
202
+ help="Build a deck from a persisted or exported draft pool.",
203
+ description="Select a color pair, spells, lands, and bench from a drafted pool.",
204
+ )
205
+ build_parser_command.add_argument(
206
+ "--pool",
207
+ type=Path,
208
+ default=None,
209
+ help="JSON pool file to build from instead of persisted state.",
210
+ )
211
+ build_parser_command.add_argument(
212
+ "--account",
213
+ default=None,
214
+ help="MTGA account identifier to disambiguate persisted pools.",
215
+ )
216
+ build_parser_command.add_argument(
217
+ "--draft-id",
218
+ default=None,
219
+ help="Draft identifier to disambiguate persisted pools.",
220
+ )
221
+ build_parser_command.add_argument(
222
+ "--pair",
223
+ choices=COLOR_PAIRS,
224
+ default=None,
225
+ help="Force a two-color pair when building.",
226
+ )
227
+ build_parser_command.add_argument(
228
+ "--allow-splash",
229
+ dest="allow_splash",
230
+ action="store_true",
231
+ help=argparse.SUPPRESS,
232
+ )
233
+ build_parser_command.add_argument(
234
+ "--no-splash",
235
+ dest="allow_splash",
236
+ action="store_false",
237
+ help="Build a strict two-color deck without third-color splash cards.",
238
+ )
239
+ build_parser_command.add_argument(
240
+ "--set-code",
241
+ default=None,
242
+ help="Set code for simple --pool files that do not include one.",
243
+ )
244
+ build_parser_command.add_argument(
245
+ "--bulk-file",
246
+ type=Path,
247
+ default=None,
248
+ help=(
249
+ "Resolve card names from a local Scryfall JSONL(.gz) bulk file "
250
+ "instead of the cached card database."
251
+ ),
252
+ )
253
+ build_parser_command.add_argument(
254
+ "--app-dir",
255
+ type=Path,
256
+ default=None,
257
+ help=argparse.SUPPRESS,
258
+ )
259
+ build_parser_command.set_defaults(allow_splash=True, handler=handle_build)
260
+
261
+ backtest_parser = subparsers.add_parser(
262
+ name="backtest",
263
+ help="Replay saved picks and compare recommendations to actual choices.",
264
+ description=(
265
+ "Dry-run a persisted draft with the current pick engine, using saved "
266
+ "offered card history and the pool before each pick."
267
+ ),
268
+ )
269
+ backtest_parser.add_argument(
270
+ "--account",
271
+ default=None,
272
+ help="MTGA account identifier to disambiguate persisted drafts.",
273
+ )
274
+ backtest_parser.add_argument(
275
+ "--draft-id",
276
+ default=None,
277
+ help="Draft identifier to disambiguate persisted drafts.",
278
+ )
279
+ backtest_parser.add_argument(
280
+ "--ranking",
281
+ choices=RANKING_MODES,
282
+ default=DEFAULT_RANKING_MODE,
283
+ help="Ranking used for recommendations (default: DO Score).",
284
+ )
285
+ backtest_parser.add_argument(
286
+ "--bulk-file",
287
+ type=Path,
288
+ default=None,
289
+ help=(
290
+ "Resolve card names from a local Scryfall JSONL(.gz) bulk file "
291
+ "instead of the cached card database."
292
+ ),
293
+ )
294
+ backtest_parser.add_argument(
295
+ "--app-dir",
296
+ type=Path,
297
+ default=None,
298
+ help=argparse.SUPPRESS,
299
+ )
300
+ backtest_parser.add_argument(
301
+ "--no-splash",
302
+ dest="splash_enabled",
303
+ action="store_false",
304
+ help="Evaluate saved picks with splash recommendations disabled.",
305
+ )
306
+ backtest_parser.set_defaults(splash_enabled=True)
307
+ backtest_parser.set_defaults(handler=handle_backtest)
308
+
309
+ benchmark_parser = subparsers.add_parser(
310
+ name="benchmark-picks",
311
+ help="Benchmark pick rankings against 17Lands public trophy drafts.",
312
+ description=(
313
+ "Offline benchmark for 17Lands WR vs DO Score using a local "
314
+ "17Lands public draft-data CSV(.gz) dump."
315
+ ),
316
+ )
317
+ benchmark_parser.add_argument(
318
+ "--set-code",
319
+ required=True,
320
+ help="Set code for the public draft data dump.",
321
+ )
322
+ benchmark_parser.add_argument(
323
+ "--format",
324
+ default=PREMIER_DRAFT_FORMAT,
325
+ help=f"17Lands event format (default: {PREMIER_DRAFT_FORMAT}).",
326
+ )
327
+ benchmark_parser.add_argument(
328
+ "--draft-data-file",
329
+ type=Path,
330
+ required=True,
331
+ help="Local 17Lands public draft-data CSV(.gz) or tar.gz file to analyze.",
332
+ )
333
+ benchmark_parser.add_argument(
334
+ "--max-drafts",
335
+ type=int,
336
+ default=None,
337
+ help="Optional cap on matching trophy drafts for quick smoke runs.",
338
+ )
339
+ benchmark_parser.set_defaults(trophy_only=True)
340
+ benchmark_parser.add_argument(
341
+ "--trophy-only",
342
+ dest="trophy_only",
343
+ action="store_true",
344
+ help=argparse.SUPPRESS,
345
+ )
346
+ benchmark_parser.add_argument(
347
+ "--include-non-trophy",
348
+ dest="trophy_only",
349
+ action="store_false",
350
+ help="Benchmark all matching drafts instead of only trophy drafts.",
351
+ )
352
+ benchmark_parser.add_argument(
353
+ "--refresh-ratings",
354
+ action="store_true",
355
+ help="Refresh 17Lands ratings before running instead of using cache only.",
356
+ )
357
+ benchmark_parser.add_argument(
358
+ "--bulk-file",
359
+ type=Path,
360
+ default=None,
361
+ help=(
362
+ "Resolve card names from a local Scryfall JSONL(.gz) bulk file "
363
+ "instead of the cached card database."
364
+ ),
365
+ )
366
+ benchmark_parser.add_argument(
367
+ "--app-dir",
368
+ type=Path,
369
+ default=None,
370
+ help=argparse.SUPPRESS,
371
+ )
372
+ benchmark_parser.set_defaults(handler=handle_benchmark_picks)
373
+
374
+ refresh_parser = subparsers.add_parser(
375
+ name="refresh-data",
376
+ help="Refresh cached Scryfall card metadata.",
377
+ description=(
378
+ "Refresh the local Scryfall grpId card metadata cache, overlaying "
379
+ "MTG Arena local data when available. The command fails if Scryfall "
380
+ "cannot produce a cacheable result."
381
+ ),
382
+ )
383
+ refresh_parser.add_argument(
384
+ "--bulk-file",
385
+ type=Path,
386
+ default=None,
387
+ help=(
388
+ "Build the card metadata cache from a local Scryfall JSONL(.gz) "
389
+ "file instead of downloading."
390
+ ),
391
+ )
392
+ refresh_parser.add_argument(
393
+ "--app-dir",
394
+ type=Path,
395
+ default=None,
396
+ help=argparse.SUPPRESS,
397
+ )
398
+ refresh_parser.set_defaults(handler=handle_refresh_data)
399
+
400
+ structure_parser = subparsers.add_parser(
401
+ name="refresh-structure-targets",
402
+ help="Compute cached 17Lands trophy-deck structure targets.",
403
+ description=(
404
+ "Compute per-pair deck-structure targets from a 17Lands public "
405
+ "draft-data dump."
406
+ ),
407
+ )
408
+ structure_parser.add_argument(
409
+ "--set-code",
410
+ required=True,
411
+ help="Set code for the public draft data dump.",
412
+ )
413
+ structure_parser.add_argument(
414
+ "--format",
415
+ default=QUICK_DRAFT_FORMAT,
416
+ help=f"17Lands event format (default: {QUICK_DRAFT_FORMAT}).",
417
+ )
418
+ structure_parser.add_argument(
419
+ "--draft-data-file",
420
+ type=Path,
421
+ default=None,
422
+ help="Local 17Lands public draft-data CSV(.gz) or tar.gz file to analyze.",
423
+ )
424
+ structure_parser.add_argument(
425
+ "--draft-data-url",
426
+ default=None,
427
+ help=argparse.SUPPRESS,
428
+ )
429
+ structure_parser.add_argument(
430
+ "--bulk-file",
431
+ type=Path,
432
+ default=None,
433
+ help=(
434
+ "Resolve card names from a local Scryfall JSONL(.gz) bulk file "
435
+ "instead of the cached card database."
436
+ ),
437
+ )
438
+ structure_parser.add_argument(
439
+ "--app-dir",
440
+ type=Path,
441
+ default=None,
442
+ help=argparse.SUPPRESS,
443
+ )
444
+ structure_parser.set_defaults(handler=handle_refresh_structure_targets)
445
+
446
+ return parser
447
+
448
+
449
+ def format_version() -> str:
450
+ """Format the TUI version banner.
451
+ Include the required Fan Content and 17Lands disclaimer block.
452
+ """
453
+
454
+ return f"draftomen-tui {__version__}\n\n{DISCLAIMER}"
455
+
456
+
457
+ def handle_watch(args: argparse.Namespace) -> int:
458
+ """Handle live Player.log watching.
459
+ Plain mode follows the log and renders replay-compatible pack output.
460
+ """
461
+
462
+ try:
463
+ log_path = resolve_player_log_path(log_path=args.log_path)
464
+ except UnsupportedPlatformError as error:
465
+ print(f"watch failed: {error}", file=sys.stderr)
466
+ return 2
467
+
468
+ if args.poll_interval <= 0:
469
+ print("watch failed: --poll-interval must be greater than zero.", file=sys.stderr)
470
+ return 2
471
+
472
+ try:
473
+ if args.plain:
474
+ database = _load_watch_card_database(args=args)
475
+ ratings_loader = metadata_augmenting_ratings_loader(
476
+ database=database,
477
+ load_ratings=lambda set_code: load_or_refresh_17lands_data(
478
+ set_code=set_code,
479
+ app_dir=args.app_dir,
480
+ ),
481
+ app_dir=args.app_dir,
482
+ persist_database=args.bulk_file is None,
483
+ )
484
+ return run_plain_watch(
485
+ log_path=log_path,
486
+ card_database=database,
487
+ app_dir=args.app_dir,
488
+ poll_interval=args.poll_interval,
489
+ once=args.once,
490
+ startup_scan=args.startup_scan,
491
+ ratings_loader=ratings_loader,
492
+ splash_enabled=(
493
+ True if args.splash_enabled is None else args.splash_enabled
494
+ ),
495
+ )
496
+
497
+ def load_ratings(
498
+ set_code: str,
499
+ progress_callback: DownloadProgressCallback,
500
+ *,
501
+ refresh: bool,
502
+ ) -> SeventeenLandsData:
503
+ return load_or_refresh_17lands_data(
504
+ set_code=set_code,
505
+ app_dir=args.app_dir,
506
+ refresh=refresh,
507
+ progress_callback=progress_callback,
508
+ )
509
+
510
+ return run_tui_watch(
511
+ log_path=log_path,
512
+ card_database_loader=lambda: _load_watch_card_database(args=args),
513
+ app_dir=args.app_dir,
514
+ poll_interval=args.poll_interval,
515
+ once=args.once,
516
+ startup_scan=args.startup_scan,
517
+ ratings_progress_loader_factory=lambda database: (
518
+ metadata_augmenting_ratings_progress_loader(
519
+ database=database,
520
+ load_ratings=load_ratings,
521
+ app_dir=args.app_dir,
522
+ persist_database=args.bulk_file is None,
523
+ )
524
+ ),
525
+ ratings_cache_checker=lambda set_code: has_cached_17lands_data(
526
+ set_code=set_code,
527
+ app_dir=args.app_dir,
528
+ ),
529
+ mana_icons_enabled=args.mana_icons,
530
+ splash_enabled=args.splash_enabled,
531
+ )
532
+ except KeyboardInterrupt:
533
+ return 130
534
+ except (
535
+ CardDatabaseError,
536
+ DeckBuilderError,
537
+ DraftAuditError,
538
+ DraftLogParseError,
539
+ DraftPoolError,
540
+ LogFollowError,
541
+ SeventeenLandsError,
542
+ ) as error:
543
+ print(f"watch failed: {error}", file=sys.stderr)
544
+ return 1
545
+
546
+
547
+ def _load_watch_card_database(*, args: argparse.Namespace) -> CardDatabase:
548
+ """Load watch card metadata, refreshing automatically when missing.
549
+ Users can still pass a local bulk file for deterministic tests.
550
+ """
551
+
552
+ if args.bulk_file is not None:
553
+ return build_card_database_from_bulk_file(path=args.bulk_file)
554
+
555
+ return load_or_refresh_card_database(app_dir=args.app_dir)
556
+
557
+
558
+ def handle_replay(args: argparse.Namespace) -> int:
559
+ """Handle deterministic offline replay.
560
+ Card metadata is loaded only from cache or an explicitly supplied bulk file.
561
+ """
562
+
563
+ try:
564
+ database = _load_replay_card_database(args=args)
565
+ output = replay_log_file(
566
+ logfile=args.logfile,
567
+ card_database=database,
568
+ ratings_loader=metadata_augmenting_ratings_loader(
569
+ database=database,
570
+ load_ratings=lambda set_code: load_cached_17lands_data(
571
+ set_code=set_code,
572
+ app_dir=args.app_dir,
573
+ ),
574
+ app_dir=args.app_dir,
575
+ persist_database=args.bulk_file is None,
576
+ ),
577
+ splash_enabled=args.splash_enabled,
578
+ )
579
+ except (
580
+ CardDatabaseError,
581
+ DeckBuilderError,
582
+ DraftLogParseError,
583
+ DraftPoolError,
584
+ ReplayError,
585
+ SeventeenLandsError,
586
+ ) as error:
587
+ print(f"replay failed: {error}", file=sys.stderr)
588
+ return 1
589
+
590
+ print(output, end="")
591
+ return 0
592
+
593
+
594
+ def _load_replay_card_database(*, args: argparse.Namespace) -> CardDatabase:
595
+ """Load replay card metadata without network access.
596
+ The vendored bulk path is useful for fixture and CI regression checks.
597
+ """
598
+
599
+ if args.bulk_file is not None:
600
+ return build_card_database_from_bulk_file(path=args.bulk_file)
601
+
602
+ return load_card_database(app_dir=args.app_dir)
603
+
604
+
605
+ def handle_build(args: argparse.Namespace) -> int:
606
+ """Handle deck-builder pair, spells, lands, and bench output.
607
+ The command is fully offline, using persisted pools and local caches.
608
+ """
609
+
610
+ try:
611
+ database = _load_build_card_database(args=args)
612
+ pool = (
613
+ load_pool_file(path=args.pool, set_code=args.set_code)
614
+ if args.pool is not None
615
+ else load_persisted_pool(
616
+ app_dir=args.app_dir,
617
+ account_id=args.account,
618
+ draft_id=args.draft_id,
619
+ )
620
+ )
621
+ ratings_data = load_cached_17lands_data(
622
+ set_code=pool.set_code,
623
+ app_dir=args.app_dir,
624
+ )
625
+ augment_card_database_from_ratings(
626
+ database=database,
627
+ set_code=pool.set_code,
628
+ ratings_data=ratings_data,
629
+ app_dir=args.app_dir,
630
+ persist_database=args.bulk_file is None,
631
+ )
632
+ selection, build_sheet = build_deck_from_pool(
633
+ pool=pool,
634
+ card_database=database,
635
+ ratings_data=ratings_data,
636
+ forced_pair=args.pair,
637
+ allow_splash=args.allow_splash,
638
+ )
639
+ except (CardDatabaseError, DeckBuilderError, DraftPoolError, SeventeenLandsError) as error:
640
+ print(f"build failed: {error}", file=sys.stderr)
641
+ return 1
642
+
643
+ print(
644
+ format_build_result(
645
+ pool=pool,
646
+ selection=selection,
647
+ spell_selection=build_sheet.spell_selection,
648
+ mana_base=build_sheet.mana_base,
649
+ ),
650
+ end="",
651
+ )
652
+ return 0
653
+
654
+
655
+ def _load_build_card_database(*, args: argparse.Namespace) -> CardDatabase:
656
+ """Load build card metadata, refreshing automatically when missing.
657
+ Build is user-facing, so it should not require a separate bootstrap command.
658
+ """
659
+
660
+ if args.bulk_file is not None:
661
+ return build_card_database_from_bulk_file(path=args.bulk_file)
662
+
663
+ return load_or_refresh_card_database(app_dir=args.app_dir)
664
+
665
+
666
+ def handle_backtest(args: argparse.Namespace) -> int:
667
+ """Handle persisted draft recommendation backtests.
668
+ Missing 17Lands cache falls back to neutral-prior pick scoring.
669
+ """
670
+
671
+ try:
672
+ state = load_persisted_backtest_state(
673
+ app_dir=args.app_dir,
674
+ account_id=args.account,
675
+ draft_id=args.draft_id,
676
+ )
677
+ database = _load_backtest_card_database(args=args)
678
+ ratings_data = _load_optional_backtest_ratings_data(
679
+ args=args,
680
+ database=database,
681
+ set_code=state.set_code,
682
+ )
683
+ report = generate_backtest_report(
684
+ state=state,
685
+ card_database=database,
686
+ ratings_data=ratings_data,
687
+ ranking_mode=args.ranking,
688
+ splash_enabled=args.splash_enabled,
689
+ )
690
+ except (BacktestError, CardDatabaseError, DraftPoolError) as error:
691
+ print(f"backtest failed: {error}", file=sys.stderr)
692
+ return 1
693
+
694
+ print(format_backtest_report(report), end="")
695
+ return 0
696
+
697
+
698
+ def _load_backtest_card_database(*, args: argparse.Namespace) -> CardDatabase:
699
+ """Load card metadata for backtest reports.
700
+ The command is user-facing, so cached metadata may refresh automatically.
701
+ """
702
+
703
+ if args.bulk_file is not None:
704
+ return build_card_database_from_bulk_file(path=args.bulk_file)
705
+
706
+ return load_or_refresh_card_database(app_dir=args.app_dir)
707
+
708
+
709
+ def _load_optional_backtest_ratings_data(
710
+ *,
711
+ args: argparse.Namespace,
712
+ database: CardDatabase,
713
+ set_code: str,
714
+ ) -> SeventeenLandsData | None:
715
+ """Load cached 17Lands ratings when available for backtests.
716
+ Backtests remain useful with neutral-prior scores if cache is absent.
717
+ """
718
+
719
+ try:
720
+ ratings_data = load_cached_17lands_data(
721
+ set_code=set_code,
722
+ app_dir=args.app_dir,
723
+ )
724
+ except SeventeenLandsError:
725
+ return None
726
+
727
+ augment_card_database_from_ratings(
728
+ database=database,
729
+ set_code=set_code,
730
+ ratings_data=ratings_data,
731
+ app_dir=args.app_dir,
732
+ persist_database=getattr(args, "bulk_file", None) is None,
733
+ )
734
+ return ratings_data
735
+
736
+
737
+ def handle_benchmark_picks(args: argparse.Namespace) -> int:
738
+ """Handle public-data recommendation calibration benchmarks.
739
+ The benchmark reads local draft data and cached ratings by default.
740
+ """
741
+
742
+ try:
743
+ _print_benchmark_progress("loading 17Lands ratings")
744
+ ratings_data = _load_benchmark_ratings_data(args=args)
745
+ _print_benchmark_progress("loading optional cached card metadata")
746
+ database = _load_benchmark_card_database(args=args)
747
+ _print_benchmark_progress(
748
+ "scoring public draft rows; full 17Lands dumps can take a few minutes"
749
+ )
750
+ report = generate_pick_benchmark_report(
751
+ set_code=args.set_code,
752
+ event_format=args.format,
753
+ draft_data_file=args.draft_data_file,
754
+ card_database=database,
755
+ ratings_data=ratings_data,
756
+ max_drafts=args.max_drafts,
757
+ trophy_only=args.trophy_only,
758
+ )
759
+ except (
760
+ CardDatabaseError,
761
+ OSError,
762
+ PickBenchmarkError,
763
+ SeventeenLandsError,
764
+ ) as error:
765
+ print(f"benchmark-picks failed: {error}", file=sys.stderr)
766
+ return 1
767
+
768
+ _print_benchmark_progress("done")
769
+ print(format_pick_benchmark_report(report), end="")
770
+ return 0
771
+
772
+
773
+ def _print_benchmark_progress(message: str) -> None:
774
+ print(f"benchmark-picks: {message}...", file=sys.stderr, flush=True)
775
+
776
+
777
+ def _load_benchmark_card_database(*, args: argparse.Namespace) -> CardDatabase:
778
+ """Load optional card metadata without starting large downloads.
779
+ 17Lands ratings provide the names/colors needed by the benchmark.
780
+ """
781
+
782
+ if args.bulk_file is not None:
783
+ return build_card_database_from_bulk_file(path=args.bulk_file)
784
+
785
+ try:
786
+ return load_card_database(app_dir=args.app_dir)
787
+ except CardDatabaseError:
788
+ return CardDatabase(cards={})
789
+
790
+
791
+ def _load_benchmark_ratings_data(*, args: argparse.Namespace) -> SeventeenLandsData:
792
+ """Load 17Lands ratings for the benchmarked set and format.
793
+ Cached data keeps the default path offline; a flag refreshes explicitly.
794
+ """
795
+
796
+ if args.refresh_ratings:
797
+ ratings_data = load_or_refresh_17lands_data(
798
+ set_code=args.set_code,
799
+ event_format=args.format,
800
+ app_dir=args.app_dir,
801
+ refresh=True,
802
+ )
803
+ else:
804
+ ratings_data = load_cached_17lands_data(
805
+ set_code=args.set_code,
806
+ event_format=args.format,
807
+ app_dir=args.app_dir,
808
+ )
809
+
810
+ if not ratings_data.ratings:
811
+ raise PickBenchmarkError(
812
+ "No cached 17Lands ratings found for this set/format; "
813
+ "rerun with --refresh-ratings."
814
+ )
815
+
816
+ return ratings_data
817
+
818
+
819
+ def handle_refresh_data(args: argparse.Namespace) -> int:
820
+ """Build the local Scryfall-backed grpId metadata cache."""
821
+
822
+ try:
823
+ database = refresh_card_database(
824
+ app_dir=args.app_dir,
825
+ bulk_file=args.bulk_file,
826
+ allow_arena_fallback=False,
827
+ )
828
+ except CardDatabaseError as error:
829
+ print(f"refresh-data failed: {error}", file=sys.stderr)
830
+ return 1
831
+
832
+ cache_path = card_database_cache_path(app_dir=args.app_dir)
833
+ print(f"refreshed {len(database)} card records at {cache_path}.")
834
+ return 0
835
+
836
+
837
+ def handle_refresh_structure_targets(args: argparse.Namespace) -> int:
838
+ """Handle empirical structure-target computation.
839
+ Uses 17Lands public draft data dumps rather than scraping trophy pages.
840
+ """
841
+
842
+ try:
843
+ database = _load_build_card_database(args=args)
844
+ targets = refresh_17lands_structure_targets(
845
+ set_code=args.set_code,
846
+ event_format=args.format,
847
+ card_database=database,
848
+ app_dir=args.app_dir,
849
+ draft_data_file=args.draft_data_file,
850
+ draft_data_url=args.draft_data_url,
851
+ )
852
+ except (CardDatabaseError, SeventeenLandsError) as error:
853
+ print(f"refresh-structure-targets failed: {error}", file=sys.stderr)
854
+ return 1
855
+
856
+ cache_path = seventeen_lands_structure_targets_cache_path(
857
+ set_code=targets.set_code,
858
+ event_format=targets.event_format,
859
+ app_dir=args.app_dir,
860
+ )
861
+ print(
862
+ "refreshed "
863
+ f"{len(targets.targets)} pair structure targets "
864
+ f"from {targets.total_decks} trophy decks at {cache_path}."
865
+ )
866
+ return 0
867
+
868
+
869
+ def main(argv: Sequence[str] | None = None) -> int:
870
+ """Run the Draftomen CLI.
871
+ Return a process-style exit code for tests and console-script use.
872
+ """
873
+
874
+ parser = build_parser()
875
+ raw_args = list(sys.argv[1:] if argv is None else argv)
876
+ args = parser.parse_args(args=raw_args)
877
+
878
+ if args.version:
879
+ print(format_version())
880
+ return 0
881
+
882
+ handler: CommandHandler | None = getattr(args, "handler", None)
883
+ if handler is None:
884
+ default_args = parser.parse_args(args=["watch"])
885
+ return default_args.handler(default_args)
886
+
887
+ return handler(args)
888
+
889
+
890
+ if __name__ == "__main__":
891
+ raise SystemExit(main())