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/seventeen.py ADDED
@@ -0,0 +1,2766 @@
1
+ """17Lands ratings fetch, cache, and fallback handling.
2
+ Keep network access isolated so CI can exercise recorded responses only.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import csv
8
+ import gzip
9
+ import io
10
+ import json
11
+ import math
12
+ import shutil
13
+ import tarfile
14
+ import tempfile
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from collections.abc import Callable, Iterable, Mapping
19
+ from dataclasses import dataclass, field
20
+ from datetime import UTC, datetime, timedelta
21
+ from os import PathLike
22
+ from pathlib import Path
23
+ from statistics import median
24
+ from typing import Any, TypeAlias
25
+
26
+ from draftomen import __version__
27
+ from draftomen.carddb import (
28
+ CardDatabase,
29
+ CardDatabaseError,
30
+ CardInfo,
31
+ CardMetadataSeed,
32
+ augment_card_database_with_mtgjson_set,
33
+ save_card_database,
34
+ )
35
+ from draftomen.config import (
36
+ COLOR_PAIRS,
37
+ DECK_BUILDER,
38
+ PICK_ENGINE,
39
+ RATINGS_CACHE_TTL_HOURS,
40
+ DeckBuilderConfig,
41
+ )
42
+ from draftomen.paths import app_data_dir
43
+
44
+ PathInput: TypeAlias = str | PathLike[str]
45
+ Clock: TypeAlias = Callable[[], datetime]
46
+ FetchJson: TypeAlias = Callable[[str, int], Any]
47
+
48
+ SEVENTEEN_LANDS_ATTRIBUTION = "Card data from 17Lands (17lands.com)"
49
+ SEVENTEEN_LANDS_BASE_URL = "https://www.17lands.com"
50
+ CARD_RATINGS_ENDPOINT = f"{SEVENTEEN_LANDS_BASE_URL}/api/card_data"
51
+ COLOR_RATINGS_ENDPOINT = f"{SEVENTEEN_LANDS_BASE_URL}/color_ratings/data"
52
+ PUBLIC_DRAFT_DATA_URL_TEMPLATE = (
53
+ "https://17lands-public.s3.amazonaws.com/analysis_data/draft_data/"
54
+ "draft_data_public.{set_code}.{event_format}.csv.gz"
55
+ )
56
+ SEVENTEEN_LANDS_USER_AGENT = (
57
+ f"draftomen/{__version__} "
58
+ "(+https://github.com/andreagrandi/draftomen)"
59
+ )
60
+ QUICK_DRAFT_FORMAT = "QuickDraft"
61
+ PREMIER_DRAFT_FORMAT = "PremierDraft"
62
+ FORMAT_RATING_SOURCE = "format"
63
+ NEUTRAL_PRIOR_SOURCE = "neutral-prior"
64
+ CACHE_SCHEMA_VERSION = 2
65
+ STRUCTURE_CACHE_SCHEMA_VERSION = 1
66
+ SEVENTEEN_CACHE_DIRECTORY_NAME = "17lands"
67
+ STRUCTURE_TARGET_SOURCE = "17lands-public-draft-data"
68
+ CURVE_BUCKETS = ("0-1", "2", "3", "4", "5", "6+")
69
+ HTTP_TIMEOUT_SECONDS = 60
70
+ ALL_TIME_PERIOD = "ALL_TIME"
71
+ GRADE_STEP_STANDARD_DEVIATIONS = 0.33
72
+ GRADE_LABELS = (
73
+ "F",
74
+ "D-",
75
+ "D",
76
+ "D+",
77
+ "C-",
78
+ "C",
79
+ "C+",
80
+ "B-",
81
+ "B",
82
+ "B+",
83
+ "A-",
84
+ "A",
85
+ "A+",
86
+ )
87
+ GRADE_CENTER_INDEX = GRADE_LABELS.index("C")
88
+ RELIABILITY_PREMIER_FACTOR = 0.65
89
+ RELIABILITY_SAMPLE_DEPTH_CAP = 20_000
90
+ RELIABILITY_LOW_MAXIMUM = 24
91
+ RELIABILITY_MEDIUM_MINIMUM = 50
92
+ RELIABILITY_HIGH_MINIMUM = 70
93
+ RELIABILITY_VERY_HIGH_MINIMUM = 85
94
+
95
+
96
+ class SeventeenLandsError(RuntimeError):
97
+ """Base error for 17Lands load, refresh, and parse failures.
98
+ Callers can surface this as a concise CLI diagnostic.
99
+ """
100
+
101
+
102
+ class SeventeenLandsCacheMissingError(SeventeenLandsError):
103
+ """Raised when a 17Lands cache has not been built yet.
104
+ The auto-refresh path normally handles this before callers see it.
105
+ """
106
+
107
+
108
+ class SeventeenLandsCacheOutdatedError(SeventeenLandsError):
109
+ """Raised when a legacy cache predates the current 17Lands data API.
110
+ Live loaders replace it automatically instead of serving incomplete data.
111
+ """
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class SeventeenLandsDownloadProgress:
116
+ """Describe completed network requests during a ratings download.
117
+ TUI callers can render determinate progress without owning fetch details.
118
+ """
119
+
120
+ completed_requests: int
121
+ total_requests: int
122
+ message: str
123
+
124
+
125
+ DownloadProgressCallback: TypeAlias = Callable[[SeventeenLandsDownloadProgress], None]
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class RatingSampleCounts:
130
+ """Sample counts reported by 17Lands for one card row.
131
+ These counts let callers distinguish strong signals from thin data.
132
+ """
133
+
134
+ seen: int
135
+ picked: int
136
+ games_played: int
137
+ opening_hand: int
138
+ games_in_hand: int
139
+
140
+ def to_json(self) -> dict[str, int]:
141
+ """Convert sample counts to Draftomen's cache shape.
142
+ The field names stay close to the 17Lands metric names.
143
+ """
144
+
145
+ return {
146
+ "seen": self.seen,
147
+ "picked": self.picked,
148
+ "games_played": self.games_played,
149
+ "opening_hand": self.opening_hand,
150
+ "games_in_hand": self.games_in_hand,
151
+ }
152
+
153
+ @classmethod
154
+ def from_json(cls, data: Mapping[str, Any]) -> RatingSampleCounts:
155
+ """Load sample counts from Draftomen's cache shape.
156
+ Parsing is strict so corrupted caches fail loudly.
157
+ """
158
+
159
+ return cls(
160
+ seen=_required_int(data.get("seen"), field_name="sample_counts.seen"),
161
+ picked=_required_int(data.get("picked"), field_name="sample_counts.picked"),
162
+ games_played=_required_int(
163
+ data.get("games_played"),
164
+ field_name="sample_counts.games_played",
165
+ ),
166
+ opening_hand=_required_int(
167
+ data.get("opening_hand"),
168
+ field_name="sample_counts.opening_hand",
169
+ ),
170
+ games_in_hand=_required_int(
171
+ data.get("games_in_hand"),
172
+ field_name="sample_counts.games_in_hand",
173
+ ),
174
+ )
175
+
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class SeventeenCardStats:
179
+ """One normalized card-rating row from 17Lands.
180
+ Win rates are stored as fractions, matching the upstream JSON endpoint.
181
+ """
182
+
183
+ grp_id: int
184
+ name: str
185
+ color: str
186
+ rarity: str
187
+ average_last_seen_at: float | None
188
+ gih_win_rate: float | None
189
+ opening_hand_win_rate: float | None
190
+ drawn_improvement_win_rate: float | None
191
+ sample_counts: RatingSampleCounts
192
+
193
+ def to_json(self) -> dict[str, object]:
194
+ """Convert this rating to Draftomen's cache shape.
195
+ Cards are keyed by grpId at the container level.
196
+ """
197
+
198
+ return {
199
+ "grp_id": self.grp_id,
200
+ "name": self.name,
201
+ "color": self.color,
202
+ "rarity": self.rarity,
203
+ "average_last_seen_at": self.average_last_seen_at,
204
+ "gih_win_rate": self.gih_win_rate,
205
+ "opening_hand_win_rate": self.opening_hand_win_rate,
206
+ "drawn_improvement_win_rate": self.drawn_improvement_win_rate,
207
+ "sample_counts": self.sample_counts.to_json(),
208
+ }
209
+
210
+ @classmethod
211
+ def from_json(cls, data: Mapping[str, Any]) -> SeventeenCardStats:
212
+ """Load one card rating from Draftomen's cache shape.
213
+ Schema mismatches fail before a partial rating table is used.
214
+ """
215
+
216
+ sample_counts_value = data.get("sample_counts")
217
+ if not isinstance(sample_counts_value, dict):
218
+ raise SeventeenLandsError(
219
+ "17Lands card rating is missing sample_counts object."
220
+ )
221
+
222
+ return cls(
223
+ grp_id=_required_int(data.get("grp_id"), field_name="card.grp_id"),
224
+ name=_required_str(data.get("name"), field_name="card.name"),
225
+ color=_required_str(data.get("color"), field_name="card.color"),
226
+ rarity=_required_str(data.get("rarity"), field_name="card.rarity"),
227
+ average_last_seen_at=_optional_float(
228
+ data.get("average_last_seen_at"),
229
+ field_name="card.average_last_seen_at",
230
+ ),
231
+ gih_win_rate=_optional_float(
232
+ data.get("gih_win_rate"),
233
+ field_name="card.gih_win_rate",
234
+ ),
235
+ opening_hand_win_rate=_optional_float(
236
+ data.get("opening_hand_win_rate"),
237
+ field_name="card.opening_hand_win_rate",
238
+ ),
239
+ drawn_improvement_win_rate=_optional_float(
240
+ data.get("drawn_improvement_win_rate"),
241
+ field_name="card.drawn_improvement_win_rate",
242
+ ),
243
+ sample_counts=RatingSampleCounts.from_json(data=sample_counts_value),
244
+ )
245
+
246
+
247
+ @dataclass(frozen=True, slots=True)
248
+ class ColorPairWinRate:
249
+ """Aggregate game record for one two-color pair.
250
+ The win_rate field is None when 17Lands reports zero games.
251
+ """
252
+
253
+ pair: str
254
+ wins: int
255
+ games: int
256
+ win_rate: float | None
257
+
258
+ def to_json(self) -> dict[str, object]:
259
+ """Convert this pair record to Draftomen's cache shape.
260
+ Pair records are keyed by pair at the container level.
261
+ """
262
+
263
+ return {
264
+ "pair": self.pair,
265
+ "wins": self.wins,
266
+ "games": self.games,
267
+ "win_rate": self.win_rate,
268
+ }
269
+
270
+ @classmethod
271
+ def from_json(cls, data: Mapping[str, Any]) -> ColorPairWinRate:
272
+ """Load one pair record from Draftomen's cache shape.
273
+ Pair codes are validated against the configured color-pair list.
274
+ """
275
+
276
+ pair = _required_pair(data.get("pair"), field_name="pair.pair")
277
+ wins = _required_int(data.get("wins"), field_name="pair.wins")
278
+ games = _required_int(data.get("games"), field_name="pair.games")
279
+ win_rate = _optional_float(data.get("win_rate"), field_name="pair.win_rate")
280
+ _ensure_non_negative(value=wins, field_name="pair.wins")
281
+ _ensure_non_negative(value=games, field_name="pair.games")
282
+ return cls(pair=pair, wins=wins, games=games, win_rate=win_rate)
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class StructuralTargets:
287
+ """Empirical deck-structure targets for one set and color pair.
288
+ They are derived from 17Lands public draft dumps, not scraped pages.
289
+ """
290
+
291
+ set_code: str
292
+ event_format: str
293
+ pair: str
294
+ sample_size: int
295
+ average_creature_count: float
296
+ average_land_count: float
297
+ average_spell_count: float
298
+ average_two_drop_count: float
299
+ average_expensive_spell_count: float
300
+ average_curve: tuple[tuple[str, float], ...]
301
+ source: str
302
+ source_url: str | None
303
+ computed_at: datetime
304
+
305
+ def to_json(self) -> dict[str, object]:
306
+ """Convert structural targets to Draftomen's cache shape.
307
+ Curve buckets are sorted in the configured display order.
308
+ """
309
+
310
+ return {
311
+ "set_code": self.set_code,
312
+ "event_format": self.event_format,
313
+ "pair": self.pair,
314
+ "sample_size": self.sample_size,
315
+ "average_creature_count": self.average_creature_count,
316
+ "average_land_count": self.average_land_count,
317
+ "average_spell_count": self.average_spell_count,
318
+ "average_two_drop_count": self.average_two_drop_count,
319
+ "average_expensive_spell_count": self.average_expensive_spell_count,
320
+ "average_curve": dict(self.average_curve),
321
+ "source": self.source,
322
+ "source_url": self.source_url,
323
+ "computed_at": self.computed_at.astimezone(UTC).isoformat(),
324
+ }
325
+
326
+ @classmethod
327
+ def from_json(cls, data: Mapping[str, Any]) -> StructuralTargets:
328
+ """Load structural targets from the cache shape.
329
+ Parsing is strict so bad target caches never affect deck building.
330
+ """
331
+
332
+ curve_value = data.get("average_curve")
333
+ if not isinstance(curve_value, dict):
334
+ raise SeventeenLandsError("17Lands structure target is missing average_curve.")
335
+
336
+ pair = _required_pair(data.get("pair"), field_name="structure.pair")
337
+ average_curve = tuple(
338
+ (
339
+ bucket,
340
+ _required_float(
341
+ curve_value.get(bucket),
342
+ field_name=f"structure.average_curve.{bucket}",
343
+ ),
344
+ )
345
+ for bucket in CURVE_BUCKETS
346
+ )
347
+ sample_size = _required_int(
348
+ data.get("sample_size"),
349
+ field_name="structure.sample_size",
350
+ )
351
+ _ensure_non_negative(value=sample_size, field_name="structure.sample_size")
352
+ return cls(
353
+ set_code=_required_str(data.get("set_code"), field_name="structure.set_code"),
354
+ event_format=_required_str(
355
+ data.get("event_format"),
356
+ field_name="structure.event_format",
357
+ ),
358
+ pair=pair,
359
+ sample_size=sample_size,
360
+ average_creature_count=_required_float(
361
+ data.get("average_creature_count"),
362
+ field_name="structure.average_creature_count",
363
+ ),
364
+ average_land_count=_required_float(
365
+ data.get("average_land_count"),
366
+ field_name="structure.average_land_count",
367
+ ),
368
+ average_spell_count=_required_float(
369
+ data.get("average_spell_count"),
370
+ field_name="structure.average_spell_count",
371
+ ),
372
+ average_two_drop_count=_required_float(
373
+ data.get("average_two_drop_count"),
374
+ field_name="structure.average_two_drop_count",
375
+ ),
376
+ average_expensive_spell_count=_required_float(
377
+ data.get("average_expensive_spell_count"),
378
+ field_name="structure.average_expensive_spell_count",
379
+ ),
380
+ average_curve=average_curve,
381
+ source=_required_str(data.get("source"), field_name="structure.source"),
382
+ source_url=_optional_str(
383
+ data.get("source_url"),
384
+ field_name="structure.source_url",
385
+ ),
386
+ computed_at=_required_datetime(
387
+ data.get("computed_at"),
388
+ field_name="structure.computed_at",
389
+ ),
390
+ )
391
+
392
+
393
+ @dataclass(frozen=True, slots=True)
394
+ class SeventeenLandsStructureTargets:
395
+ """Cached empirical structural targets for one set and format.
396
+ The target table is keyed by canonical two-color pair.
397
+ """
398
+
399
+ set_code: str
400
+ event_format: str
401
+ computed_at: datetime
402
+ source: str
403
+ source_url: str | None
404
+ total_decks: int
405
+ targets: dict[str, StructuralTargets]
406
+
407
+ def to_json(self) -> dict[str, object]:
408
+ """Convert this structure-target cache to stable JSON.
409
+ Pair keys stay sorted for inspectable cache files.
410
+ """
411
+
412
+ return {
413
+ "schema_version": STRUCTURE_CACHE_SCHEMA_VERSION,
414
+ "source": self.source,
415
+ "source_url": self.source_url,
416
+ "set_code": self.set_code,
417
+ "event_format": self.event_format,
418
+ "computed_at": self.computed_at.astimezone(UTC).isoformat(),
419
+ "total_decks": self.total_decks,
420
+ "targets": {
421
+ pair: target.to_json()
422
+ for pair, target in sorted(self.targets.items())
423
+ },
424
+ }
425
+
426
+ @classmethod
427
+ def from_json(cls, data: Mapping[str, Any]) -> SeventeenLandsStructureTargets:
428
+ """Load one cached structure-target table.
429
+ Pair keys and embedded pair values must agree.
430
+ """
431
+
432
+ schema_version = _required_int(
433
+ data.get("schema_version"),
434
+ field_name="schema_version",
435
+ )
436
+ if schema_version != STRUCTURE_CACHE_SCHEMA_VERSION:
437
+ raise SeventeenLandsError(
438
+ "Unsupported 17Lands structure cache schema "
439
+ f"{schema_version}; expected {STRUCTURE_CACHE_SCHEMA_VERSION}."
440
+ )
441
+
442
+ targets_value = data.get("targets")
443
+ if not isinstance(targets_value, dict):
444
+ raise SeventeenLandsError("17Lands structure cache is missing targets object.")
445
+
446
+ targets: dict[str, StructuralTargets] = {}
447
+ for key, value in targets_value.items():
448
+ pair = _required_pair(key, field_name="structure targets key")
449
+ if not isinstance(value, dict):
450
+ raise SeventeenLandsError(
451
+ f"17Lands structure target {key!r} is not an object."
452
+ )
453
+
454
+ target = StructuralTargets.from_json(data=value)
455
+ if target.pair != pair:
456
+ raise SeventeenLandsError(
457
+ f"17Lands structure key {pair} does not match entry pair "
458
+ f"{target.pair}."
459
+ )
460
+
461
+ targets[pair] = target
462
+
463
+ total_decks = _required_int(data.get("total_decks"), field_name="total_decks")
464
+ _ensure_non_negative(value=total_decks, field_name="total_decks")
465
+ return cls(
466
+ set_code=_required_str(data.get("set_code"), field_name="set_code"),
467
+ event_format=_required_str(data.get("event_format"), field_name="event_format"),
468
+ computed_at=_required_datetime(data.get("computed_at"), field_name="computed_at"),
469
+ source=_required_str(data.get("source"), field_name="source"),
470
+ source_url=_optional_str(data.get("source_url"), field_name="source_url"),
471
+ total_decks=total_decks,
472
+ targets=targets,
473
+ )
474
+
475
+
476
+ @dataclass(frozen=True, slots=True)
477
+ class _DeckStructureMetrics:
478
+ """Per-deck structure facts before pair-level averaging.
479
+ Keeping this private avoids exposing unfinished analysis details.
480
+ """
481
+
482
+ pair: str
483
+ creature_count: int
484
+ land_count: int
485
+ spell_count: int
486
+ two_drop_count: int
487
+ expensive_spell_count: int
488
+ curve: dict[str, int]
489
+
490
+
491
+ @dataclass(frozen=True, slots=True)
492
+ class SeventeenLandsFormatData:
493
+ """Cached 17Lands data for one set and event format.
494
+ It contains both card ratings and two-color pair win rates.
495
+ """
496
+
497
+ set_code: str
498
+ event_format: str
499
+ fetched_at: datetime
500
+ card_ratings: dict[int, SeventeenCardStats]
501
+ pair_win_rates: dict[str, ColorPairWinRate]
502
+
503
+ @property
504
+ def attribution(self) -> str:
505
+ """Return the required 17Lands attribution string.
506
+ UI and build-sheet callers can display this verbatim.
507
+ """
508
+
509
+ return SEVENTEEN_LANDS_ATTRIBUTION
510
+
511
+ def letter_grade_for(self, *, grp_id: int) -> str | None:
512
+ """Return the 17Lands-style GIH grade for one card.
513
+ Grades use the selected format's GIH distribution.
514
+ """
515
+
516
+ stats = self.card_ratings.get(grp_id)
517
+ if stats is None:
518
+ return None
519
+
520
+ return _letter_grade_for_metric(
521
+ value=stats.gih_win_rate,
522
+ distribution=_gih_win_rate_distribution(stats=self.card_ratings.values()),
523
+ )
524
+
525
+ def to_json(self) -> dict[str, object]:
526
+ """Convert this dataset to Draftomen's cache shape.
527
+ Keys are sorted so cache files remain stable and inspectable.
528
+ """
529
+
530
+ return {
531
+ "schema_version": CACHE_SCHEMA_VERSION,
532
+ "source": "17lands",
533
+ "set_code": self.set_code,
534
+ "event_format": self.event_format,
535
+ "fetched_at": self.fetched_at.astimezone(UTC).isoformat(),
536
+ "card_ratings": {
537
+ str(grp_id): card.to_json()
538
+ for grp_id, card in sorted(self.card_ratings.items())
539
+ },
540
+ "pair_win_rates": {
541
+ pair: win_rate.to_json()
542
+ for pair, win_rate in sorted(self.pair_win_rates.items())
543
+ },
544
+ }
545
+
546
+ @classmethod
547
+ def from_json(cls, data: Mapping[str, Any]) -> SeventeenLandsFormatData:
548
+ """Load a cached 17Lands dataset from Draftomen's JSON shape.
549
+ Schema mismatches and corrupted entries fail loudly.
550
+ """
551
+
552
+ schema_version = _required_int(
553
+ data.get("schema_version"),
554
+ field_name="schema_version",
555
+ )
556
+ if schema_version < CACHE_SCHEMA_VERSION:
557
+ raise SeventeenLandsCacheOutdatedError(
558
+ "Outdated 17Lands cache schema "
559
+ f"{schema_version}; current schema is {CACHE_SCHEMA_VERSION}."
560
+ )
561
+
562
+ if schema_version != CACHE_SCHEMA_VERSION:
563
+ raise SeventeenLandsError(
564
+ "Unsupported 17Lands cache schema "
565
+ f"{schema_version}; expected {CACHE_SCHEMA_VERSION}."
566
+ )
567
+
568
+ card_ratings_value = data.get("card_ratings")
569
+ if not isinstance(card_ratings_value, dict):
570
+ raise SeventeenLandsError("17Lands cache is missing card_ratings object.")
571
+
572
+ pair_win_rates_value = data.get("pair_win_rates")
573
+ if not isinstance(pair_win_rates_value, dict):
574
+ raise SeventeenLandsError("17Lands cache is missing pair_win_rates object.")
575
+
576
+ card_ratings: dict[int, SeventeenCardStats] = {}
577
+ for key, value in card_ratings_value.items():
578
+ grp_id = _required_int(key, field_name="card_ratings key")
579
+ if not isinstance(value, dict):
580
+ raise SeventeenLandsError(
581
+ f"17Lands card rating {key!r} is not an object."
582
+ )
583
+
584
+ card = SeventeenCardStats.from_json(data=value)
585
+ if card.grp_id != grp_id:
586
+ raise SeventeenLandsError(
587
+ f"17Lands card key {grp_id} does not match entry "
588
+ f"grp_id {card.grp_id}."
589
+ )
590
+
591
+ card_ratings[grp_id] = card
592
+
593
+ pair_win_rates: dict[str, ColorPairWinRate] = {}
594
+ for key, value in pair_win_rates_value.items():
595
+ pair = _required_pair(key, field_name="pair_win_rates key")
596
+ if not isinstance(value, dict):
597
+ raise SeventeenLandsError(
598
+ f"17Lands pair win rate {key!r} is not an object."
599
+ )
600
+
601
+ win_rate = ColorPairWinRate.from_json(data=value)
602
+ if win_rate.pair != pair:
603
+ raise SeventeenLandsError(
604
+ f"17Lands pair key {pair} does not match entry pair "
605
+ f"{win_rate.pair}."
606
+ )
607
+
608
+ pair_win_rates[pair] = win_rate
609
+
610
+ return cls(
611
+ set_code=_required_str(data.get("set_code"), field_name="set_code"),
612
+ event_format=_required_str(
613
+ data.get("event_format"),
614
+ field_name="event_format",
615
+ ),
616
+ fetched_at=_required_datetime(data.get("fetched_at"), field_name="fetched_at"),
617
+ card_ratings=card_ratings,
618
+ pair_win_rates=pair_win_rates,
619
+ )
620
+
621
+
622
+ @dataclass(frozen=True, slots=True)
623
+ class RatingSourceMetadata:
624
+ """Explain where a resolved card rating came from.
625
+ Fallback metadata is explicit so UI callers can flag Premier or neutral data.
626
+ """
627
+
628
+ requested_format: str
629
+ source: str
630
+ source_format: str | None
631
+ fallback_reason: str | None
632
+
633
+
634
+ @dataclass(frozen=True, slots=True)
635
+ class ResolvedCardRating:
636
+ """A card rating after applying the fallback chain.
637
+ Neutral-prior rows keep any useful ALSA-like context from cached data.
638
+ """
639
+
640
+ grp_id: int
641
+ name: str
642
+ color: str | None
643
+ rarity: str | None
644
+ average_last_seen_at: float | None
645
+ gih_win_rate: float | None
646
+ opening_hand_win_rate: float | None
647
+ drawn_improvement_win_rate: float | None
648
+ sample_counts: RatingSampleCounts
649
+ letter_grade: str | None
650
+ neutral_prior_score: float | None
651
+ metadata: RatingSourceMetadata
652
+
653
+ @property
654
+ def neutral_prior(self) -> bool:
655
+ """Return whether this rating is the neutral-prior fallback.
656
+ Callers can use this to downplay unavailable 17Lands signals.
657
+ """
658
+
659
+ return self.metadata.source == NEUTRAL_PRIOR_SOURCE
660
+
661
+
662
+ @dataclass(frozen=True, slots=True)
663
+ class SetDataReliability:
664
+ """One aggregate 17Lands reliability value for a set before drafting.
665
+ It is presentation metadata and is never an input to card scoring.
666
+ """
667
+
668
+ set_code: str
669
+ score: int
670
+ tier: str
671
+
672
+
673
+ @dataclass(frozen=True, slots=True)
674
+ class SeventeenLandsData:
675
+ """High-level 17Lands view with Quick→Premier→neutral fallback.
676
+ Pair win rates come from the requested primary event format.
677
+ """
678
+
679
+ set_code: str
680
+ requested_format: str
681
+ primary: SeventeenLandsFormatData
682
+ fallback: SeventeenLandsFormatData | None
683
+ structure_targets: dict[str, StructuralTargets] = field(default_factory=dict)
684
+ pair_card_ratings: dict[str, SeventeenLandsFormatData] = field(default_factory=dict)
685
+ pair_card_ratings_loader: Callable[[str], SeventeenLandsFormatData | None] | None = field(
686
+ default=None,
687
+ repr=False,
688
+ compare=False,
689
+ )
690
+ missing_pair_card_ratings: set[str] = field(
691
+ default_factory=set,
692
+ repr=False,
693
+ compare=False,
694
+ )
695
+ thin_sample_minimum: int = PICK_ENGINE.thin_sample_minimum
696
+ attribution: str = SEVENTEEN_LANDS_ATTRIBUTION
697
+
698
+ @property
699
+ def pair_win_rates(self) -> dict[str, ColorPairWinRate]:
700
+ """Return pair win rates for the requested primary format.
701
+ The returned dictionary is the cached primary pair table.
702
+ """
703
+
704
+ return self.primary.pair_win_rates
705
+
706
+ @property
707
+ def set_reliability(self) -> SetDataReliability:
708
+ """Return one aggregate reliability value for this set's data.
709
+ The calculation is independent from rating resolution and ranking.
710
+ """
711
+
712
+ return _set_data_reliability(data=self)
713
+
714
+ @property
715
+ def ratings(self) -> dict[int, ResolvedCardRating]:
716
+ """Return resolved ratings for cards seen in cached format data.
717
+ Arbitrary absent grpIds can still be resolved through rating_for.
718
+ """
719
+
720
+ grp_ids = set(self.primary.card_ratings)
721
+ if self.fallback is not None:
722
+ grp_ids.update(self.fallback.card_ratings)
723
+
724
+ return {
725
+ grp_id: self.rating_for(grp_id=grp_id)
726
+ for grp_id in sorted(grp_ids)
727
+ }
728
+
729
+ def rating_for(self, *, grp_id: int) -> ResolvedCardRating:
730
+ """Resolve one grpId through Quick, Premier, then neutral prior.
731
+ Missing and thin primary data are flagged in the returned metadata.
732
+ """
733
+
734
+ primary_stats = self.primary.card_ratings.get(grp_id)
735
+ if _has_strong_gih_signal(
736
+ stats=primary_stats,
737
+ thin_sample_minimum=self.thin_sample_minimum,
738
+ ):
739
+ return _resolved_from_stats(
740
+ stats=primary_stats,
741
+ format_data=self.primary,
742
+ requested_format=self.requested_format,
743
+ source_format=self.primary.event_format,
744
+ fallback_reason=None,
745
+ )
746
+
747
+ fallback_reason = "primary-missing" if primary_stats is None else "primary-thin"
748
+ fallback_stats = None
749
+ if self.fallback is not None:
750
+ fallback_stats = self.fallback.card_ratings.get(grp_id)
751
+
752
+ if _has_strong_gih_signal(
753
+ stats=fallback_stats,
754
+ thin_sample_minimum=self.thin_sample_minimum,
755
+ ):
756
+ return _resolved_from_stats(
757
+ stats=fallback_stats,
758
+ format_data=self.fallback,
759
+ requested_format=self.requested_format,
760
+ source_format=self.fallback.event_format if self.fallback is not None else None,
761
+ fallback_reason=fallback_reason,
762
+ )
763
+
764
+ neutral_stats = primary_stats or fallback_stats
765
+ if neutral_stats is None:
766
+ neutral_reason = "fallback-missing"
767
+ elif fallback_stats is None and primary_stats is not None:
768
+ neutral_reason = "fallback-missing"
769
+ else:
770
+ neutral_reason = "fallback-thin"
771
+
772
+ return _neutral_rating(
773
+ grp_id=grp_id,
774
+ stats=neutral_stats,
775
+ requested_format=self.requested_format,
776
+ fallback_reason=neutral_reason,
777
+ )
778
+
779
+ def structure_targets_for(self, *, pair: str) -> StructuralTargets | None:
780
+ """Return empirical build targets for a pair when cached.
781
+ Deck building falls back to consensus defaults when this is None.
782
+ """
783
+
784
+ return self.structure_targets.get(pair)
785
+
786
+ def pair_rating_for(self, *, grp_id: int, pair: str) -> ResolvedCardRating:
787
+ """Resolve a card through locked-pair data when it is strong enough.
788
+ Missing or thin pair rows fall back to all-decks resolution.
789
+ """
790
+
791
+ pair_data = self._pair_card_data(pair=pair)
792
+ pair_stats = None if pair_data is None else pair_data.card_ratings.get(grp_id)
793
+ if _has_strong_gih_signal(
794
+ stats=pair_stats,
795
+ thin_sample_minimum=self.thin_sample_minimum,
796
+ ):
797
+ return _resolved_from_stats(
798
+ stats=pair_stats,
799
+ format_data=pair_data,
800
+ requested_format=self.requested_format,
801
+ source_format=pair_data.event_format if pair_data is not None else None,
802
+ fallback_reason=None,
803
+ )
804
+
805
+ return self.rating_for(grp_id=grp_id)
806
+
807
+ def _pair_card_data(self, *, pair: str) -> SeventeenLandsFormatData | None:
808
+ pair_data = self.pair_card_ratings.get(pair)
809
+ if pair_data is not None:
810
+ return pair_data
811
+
812
+ if pair in self.missing_pair_card_ratings or self.pair_card_ratings_loader is None:
813
+ return None
814
+
815
+ try:
816
+ pair_data = self.pair_card_ratings_loader(pair)
817
+ except SeventeenLandsError:
818
+ self.missing_pair_card_ratings.add(pair)
819
+ return None
820
+
821
+ if pair_data is None:
822
+ self.missing_pair_card_ratings.add(pair)
823
+ return None
824
+
825
+ self.pair_card_ratings[pair] = pair_data
826
+ return pair_data
827
+
828
+
829
+ def metadata_augmenting_ratings_loader(
830
+ *,
831
+ database: CardDatabase,
832
+ load_ratings: Callable[[str], SeventeenLandsData],
833
+ app_dir: PathInput | None = None,
834
+ persist_database: bool = True,
835
+ ) -> Callable[[str], SeventeenLandsData]:
836
+ """Wrap ratings loading with the shared current-set metadata recovery.
837
+ Terminal and desktop frontends must use this same database lifecycle.
838
+ """
839
+
840
+ def load_and_augment(set_code: str) -> SeventeenLandsData:
841
+ ratings_data = load_ratings(set_code)
842
+ augment_card_database_from_ratings(
843
+ database=database,
844
+ set_code=set_code,
845
+ ratings_data=ratings_data,
846
+ app_dir=app_dir,
847
+ persist_database=persist_database,
848
+ )
849
+ return ratings_data
850
+
851
+ return load_and_augment
852
+
853
+
854
+ def metadata_augmenting_ratings_progress_loader(
855
+ *,
856
+ database: CardDatabase,
857
+ load_ratings: Callable[
858
+ [str, DownloadProgressCallback, bool],
859
+ SeventeenLandsData,
860
+ ],
861
+ app_dir: PathInput | None = None,
862
+ persist_database: bool = True,
863
+ ) -> Callable[[str, DownloadProgressCallback, bool], SeventeenLandsData]:
864
+ """Wrap progress-aware ratings loading with current-set metadata recovery.
865
+ The returned loader preserves the frontend-neutral progress contract.
866
+ """
867
+
868
+ def load_and_augment(
869
+ set_code: str,
870
+ progress_callback: DownloadProgressCallback,
871
+ *,
872
+ refresh: bool,
873
+ ) -> SeventeenLandsData:
874
+ ratings_data = load_ratings(
875
+ set_code,
876
+ progress_callback,
877
+ refresh=refresh,
878
+ )
879
+ augment_card_database_from_ratings(
880
+ database=database,
881
+ set_code=set_code,
882
+ ratings_data=ratings_data,
883
+ app_dir=app_dir,
884
+ persist_database=persist_database,
885
+ )
886
+ return ratings_data
887
+
888
+ return load_and_augment
889
+
890
+
891
+ def augment_card_database_from_ratings(
892
+ *,
893
+ database: CardDatabase,
894
+ set_code: str,
895
+ ratings_data: SeventeenLandsData,
896
+ app_dir: PathInput | None = None,
897
+ persist_database: bool = True,
898
+ ) -> None:
899
+ """Recover current Arena grpIds from ratings and MTGJSON metadata.
900
+ Successful recovery updates the shared in-memory database and cache.
901
+ """
902
+
903
+ seeds = _metadata_seeds_from_ratings(ratings=ratings_data.ratings.values())
904
+ if not seeds:
905
+ return
906
+
907
+ if not database.unresolved_grp_ids(
908
+ grp_ids=tuple(seed.grp_id for seed in seeds),
909
+ ):
910
+ return
911
+
912
+ try:
913
+ augmented = augment_card_database_with_mtgjson_set(
914
+ database,
915
+ set_code=set_code,
916
+ seeds=seeds,
917
+ )
918
+ except CardDatabaseError:
919
+ return
920
+
921
+ database.cards.clear()
922
+ database.cards.update(augmented.cards)
923
+ if not persist_database:
924
+ return
925
+
926
+ try:
927
+ save_card_database(database, app_dir=app_dir)
928
+ except OSError:
929
+ return
930
+
931
+
932
+ def _metadata_seeds_from_ratings(
933
+ *,
934
+ ratings: Iterable[ResolvedCardRating],
935
+ ) -> tuple[CardMetadataSeed, ...]:
936
+ seeds: dict[int, CardMetadataSeed] = {}
937
+ for rating in ratings:
938
+ if rating.name.startswith("Unknown card "):
939
+ continue
940
+
941
+ seeds[rating.grp_id] = CardMetadataSeed(
942
+ grp_id=rating.grp_id,
943
+ name=rating.name,
944
+ colors=_rating_colors(color=rating.color),
945
+ rarity=rating.rarity or "unknown",
946
+ )
947
+
948
+ return tuple(seeds.values())
949
+
950
+
951
+ def _rating_colors(*, color: str | None) -> tuple[str, ...]:
952
+ if color is None:
953
+ return ()
954
+
955
+ return tuple(symbol for symbol in "WUBRG" if symbol in color)
956
+
957
+
958
+ def seventeen_lands_cache_path(
959
+ *,
960
+ set_code: str,
961
+ event_format: str,
962
+ app_dir: PathInput | None = None,
963
+ ) -> Path:
964
+ """Return the on-disk cache path for one set and format.
965
+ The parent directory is created only when a refresh writes the cache.
966
+ """
967
+
968
+ root = Path(app_data_dir() if app_dir is None else app_dir)
969
+ filename = f"{_path_segment(set_code.upper())}-{_path_segment(event_format)}.json"
970
+ return root / SEVENTEEN_CACHE_DIRECTORY_NAME / filename
971
+
972
+
973
+ def has_cached_17lands_data(
974
+ *,
975
+ set_code: str,
976
+ event_format: str = QUICK_DRAFT_FORMAT,
977
+ app_dir: PathInput | None = None,
978
+ ) -> bool:
979
+ """Return whether primary ratings exist locally for a set and format.
980
+ Callers can request consent before the first network download.
981
+ """
982
+
983
+ return seventeen_lands_cache_path(
984
+ set_code=set_code,
985
+ event_format=event_format,
986
+ app_dir=app_dir,
987
+ ).is_file()
988
+
989
+
990
+ def seventeen_lands_pair_card_cache_path(
991
+ *,
992
+ set_code: str,
993
+ event_format: str,
994
+ pair: str,
995
+ app_dir: PathInput | None = None,
996
+ ) -> Path:
997
+ """Return the cache path for pair-filtered card ratings.
998
+ Pair ratings are separate so all-decks caches stay small and stable.
999
+ """
1000
+
1001
+ root = Path(app_data_dir() if app_dir is None else app_dir)
1002
+ filename = (
1003
+ f"{_path_segment(set_code.upper())}-"
1004
+ f"{_path_segment(event_format)}-"
1005
+ f"{_required_pair(pair, field_name='pair')}-cards.json"
1006
+ )
1007
+ return root / SEVENTEEN_CACHE_DIRECTORY_NAME / filename
1008
+
1009
+
1010
+ def seventeen_lands_structure_targets_cache_path(
1011
+ *,
1012
+ set_code: str,
1013
+ event_format: str,
1014
+ app_dir: PathInput | None = None,
1015
+ ) -> Path:
1016
+ """Return the cache path for empirical structure targets.
1017
+ Targets are loaded opportunistically by the deck builder when present.
1018
+ """
1019
+
1020
+ root = Path(app_data_dir() if app_dir is None else app_dir)
1021
+ filename = (
1022
+ f"{_path_segment(set_code.upper())}-"
1023
+ f"{_path_segment(event_format)}-structure-targets.json"
1024
+ )
1025
+ return root / SEVENTEEN_CACHE_DIRECTORY_NAME / filename
1026
+
1027
+
1028
+ def public_draft_data_url(*, set_code: str, event_format: str) -> str:
1029
+ """Return the preferred 17Lands public draft dump URL.
1030
+ Public dumps are CC BY and preferred by the usage guidelines.
1031
+ """
1032
+
1033
+ return PUBLIC_DRAFT_DATA_URL_TEMPLATE.format(
1034
+ set_code=set_code.upper(),
1035
+ event_format=event_format,
1036
+ )
1037
+
1038
+
1039
+ def iter_17lands_draft_data_rows(*, path: PathInput) -> Iterable[Mapping[str, str]]:
1040
+ """Iterate rows from a public 17Lands draft-data dump.
1041
+ CSV, CSV gzip, and tar archives use the same reader path.
1042
+ """
1043
+
1044
+ return _iter_draft_data_rows(path=path)
1045
+
1046
+
1047
+ def card_ratings_url(
1048
+ *,
1049
+ set_code: str,
1050
+ event_format: str,
1051
+ colors: str | None = None,
1052
+ ) -> str:
1053
+ """Build the 17Lands card-ratings endpoint URL.
1054
+ ALL_TIME preserves historical samples when an old set returns to draft.
1055
+ """
1056
+
1057
+ params = {
1058
+ "expansion": set_code.upper(),
1059
+ "event_type": event_format,
1060
+ "time_period": ALL_TIME_PERIOD,
1061
+ }
1062
+ if colors is not None:
1063
+ params["colors"] = colors
1064
+
1065
+ return _url_with_query(endpoint=CARD_RATINGS_ENDPOINT, params=params)
1066
+
1067
+
1068
+ def color_ratings_url(
1069
+ *,
1070
+ set_code: str,
1071
+ event_format: str,
1072
+ ) -> str:
1073
+ """Build the 17Lands color-ratings endpoint URL.
1074
+ ALL_TIME keeps exact pair rows useful across repeated draft windows.
1075
+ """
1076
+
1077
+ return _url_with_query(
1078
+ endpoint=COLOR_RATINGS_ENDPOINT,
1079
+ params={
1080
+ "expansion": set_code.upper(),
1081
+ "event_type": event_format,
1082
+ "time_period": ALL_TIME_PERIOD,
1083
+ "combine_splash": "false",
1084
+ },
1085
+ )
1086
+
1087
+
1088
+ def load_or_refresh_17lands_data(
1089
+ *,
1090
+ set_code: str,
1091
+ event_format: str = QUICK_DRAFT_FORMAT,
1092
+ app_dir: PathInput | None = None,
1093
+ refresh: bool = False,
1094
+ clock: Clock | None = None,
1095
+ fetch_json: FetchJson | None = None,
1096
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1097
+ cache_ttl: timedelta | None = None,
1098
+ thin_sample_minimum: int = PICK_ENGINE.thin_sample_minimum,
1099
+ premier_fallback_enabled: bool = PICK_ENGINE.premier_fallback_enabled,
1100
+ progress_callback: DownloadProgressCallback | None = None,
1101
+ ) -> SeventeenLandsData:
1102
+ """Load 17Lands data with the configured fallback chain.
1103
+ Premier fallback failures degrade to neutral-prior ratings.
1104
+ """
1105
+
1106
+ total_requests = (
1107
+ 4
1108
+ if premier_fallback_enabled and event_format != PREMIER_DRAFT_FORMAT
1109
+ else 2
1110
+ )
1111
+ _report_download_progress(
1112
+ callback=progress_callback,
1113
+ completed_requests=0,
1114
+ total_requests=total_requests,
1115
+ message=f"Checking local {event_format} ratings",
1116
+ )
1117
+ primary = load_or_refresh_17lands_format_data(
1118
+ set_code=set_code,
1119
+ event_format=event_format,
1120
+ app_dir=app_dir,
1121
+ refresh=refresh,
1122
+ clock=clock,
1123
+ fetch_json=fetch_json,
1124
+ timeout_seconds=timeout_seconds,
1125
+ cache_ttl=cache_ttl,
1126
+ progress_callback=progress_callback,
1127
+ progress_offset=0,
1128
+ progress_total=total_requests,
1129
+ )
1130
+ fallback = None
1131
+ if premier_fallback_enabled and event_format != PREMIER_DRAFT_FORMAT:
1132
+ try:
1133
+ fallback = load_or_refresh_17lands_format_data(
1134
+ set_code=set_code,
1135
+ event_format=PREMIER_DRAFT_FORMAT,
1136
+ app_dir=app_dir,
1137
+ refresh=refresh,
1138
+ clock=clock,
1139
+ fetch_json=fetch_json,
1140
+ timeout_seconds=timeout_seconds,
1141
+ cache_ttl=cache_ttl,
1142
+ progress_callback=progress_callback,
1143
+ progress_offset=2,
1144
+ progress_total=total_requests,
1145
+ )
1146
+ except SeventeenLandsError:
1147
+ fallback = None
1148
+
1149
+ structure_targets = _load_optional_structure_targets(
1150
+ set_code=set_code,
1151
+ event_format=event_format,
1152
+ app_dir=app_dir,
1153
+ )
1154
+ return SeventeenLandsData(
1155
+ set_code=set_code.upper(),
1156
+ requested_format=event_format,
1157
+ primary=primary,
1158
+ fallback=fallback,
1159
+ structure_targets=(
1160
+ {} if structure_targets is None else structure_targets.targets
1161
+ ),
1162
+ pair_card_ratings_loader=lambda pair: load_or_refresh_17lands_pair_card_data(
1163
+ set_code=set_code,
1164
+ event_format=event_format,
1165
+ pair=pair,
1166
+ app_dir=app_dir,
1167
+ refresh=refresh,
1168
+ clock=clock,
1169
+ fetch_json=fetch_json,
1170
+ timeout_seconds=timeout_seconds,
1171
+ cache_ttl=cache_ttl,
1172
+ ),
1173
+ thin_sample_minimum=thin_sample_minimum,
1174
+ )
1175
+
1176
+
1177
+ def load_cached_17lands_data(
1178
+ *,
1179
+ set_code: str,
1180
+ event_format: str = QUICK_DRAFT_FORMAT,
1181
+ app_dir: PathInput | None = None,
1182
+ thin_sample_minimum: int = PICK_ENGINE.thin_sample_minimum,
1183
+ premier_fallback_enabled: bool = PICK_ENGINE.premier_fallback_enabled,
1184
+ ) -> SeventeenLandsData:
1185
+ """Load cached 17Lands data without network access.
1186
+ Missing primary data becomes an empty neutral-prior dataset.
1187
+ """
1188
+
1189
+ primary = _load_cached_or_empty_format_data(
1190
+ set_code=set_code,
1191
+ event_format=event_format,
1192
+ app_dir=app_dir,
1193
+ )
1194
+ fallback = None
1195
+ if premier_fallback_enabled and event_format != PREMIER_DRAFT_FORMAT:
1196
+ fallback = _load_optional_cached_format_data(
1197
+ set_code=set_code,
1198
+ event_format=PREMIER_DRAFT_FORMAT,
1199
+ app_dir=app_dir,
1200
+ )
1201
+
1202
+ structure_targets = _load_optional_structure_targets(
1203
+ set_code=set_code,
1204
+ event_format=event_format,
1205
+ app_dir=app_dir,
1206
+ )
1207
+ return SeventeenLandsData(
1208
+ set_code=set_code.upper(),
1209
+ requested_format=event_format,
1210
+ primary=primary,
1211
+ fallback=fallback,
1212
+ structure_targets=(
1213
+ {} if structure_targets is None else structure_targets.targets
1214
+ ),
1215
+ pair_card_ratings_loader=lambda pair: _load_optional_pair_card_data(
1216
+ set_code=set_code,
1217
+ event_format=event_format,
1218
+ pair=pair,
1219
+ app_dir=app_dir,
1220
+ ),
1221
+ thin_sample_minimum=thin_sample_minimum,
1222
+ )
1223
+
1224
+
1225
+ def load_or_refresh_17lands_pair_card_data(
1226
+ *,
1227
+ set_code: str,
1228
+ event_format: str = QUICK_DRAFT_FORMAT,
1229
+ pair: str,
1230
+ app_dir: PathInput | None = None,
1231
+ refresh: bool = False,
1232
+ clock: Clock | None = None,
1233
+ fetch_json: FetchJson | None = None,
1234
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1235
+ cache_ttl: timedelta | None = None,
1236
+ ) -> SeventeenLandsFormatData:
1237
+ """Load pair-filtered card ratings, refreshing only when needed.
1238
+ These ratings are fetched lazily once scoring locks onto a pair.
1239
+ """
1240
+
1241
+ now = _now(clock=clock)
1242
+ ttl = _cache_ttl(cache_ttl=cache_ttl)
1243
+ cached = _load_optional_pair_card_data(
1244
+ set_code=set_code,
1245
+ event_format=event_format,
1246
+ pair=pair,
1247
+ app_dir=app_dir,
1248
+ )
1249
+ if cached is not None and not refresh and not _is_stale(
1250
+ fetched_at=cached.fetched_at,
1251
+ now=now,
1252
+ cache_ttl=ttl,
1253
+ ):
1254
+ return cached
1255
+
1256
+ try:
1257
+ return refresh_17lands_pair_card_data(
1258
+ set_code=set_code,
1259
+ event_format=event_format,
1260
+ pair=pair,
1261
+ app_dir=app_dir,
1262
+ fetched_at=now,
1263
+ fetch_json=fetch_json,
1264
+ timeout_seconds=timeout_seconds,
1265
+ )
1266
+ except SeventeenLandsError:
1267
+ if cached is not None and not refresh:
1268
+ return cached
1269
+
1270
+ raise
1271
+
1272
+
1273
+ def load_or_refresh_17lands_format_data(
1274
+ *,
1275
+ set_code: str,
1276
+ event_format: str,
1277
+ app_dir: PathInput | None = None,
1278
+ refresh: bool = False,
1279
+ clock: Clock | None = None,
1280
+ fetch_json: FetchJson | None = None,
1281
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1282
+ cache_ttl: timedelta | None = None,
1283
+ progress_callback: DownloadProgressCallback | None = None,
1284
+ progress_offset: int = 0,
1285
+ progress_total: int = 2,
1286
+ ) -> SeventeenLandsFormatData:
1287
+ """Load cached format data, refreshing only when stale.
1288
+ Stale but valid cache data is served when a refresh fails offline.
1289
+ """
1290
+
1291
+ now = _now(clock=clock)
1292
+ ttl = _cache_ttl(cache_ttl=cache_ttl)
1293
+ cached = _load_existing_format_data(
1294
+ set_code=set_code,
1295
+ event_format=event_format,
1296
+ app_dir=app_dir,
1297
+ )
1298
+ if cached is not None and not refresh and not _is_stale(
1299
+ fetched_at=cached.fetched_at,
1300
+ now=now,
1301
+ cache_ttl=ttl,
1302
+ ):
1303
+ _report_download_progress(
1304
+ callback=progress_callback,
1305
+ completed_requests=progress_offset + 2,
1306
+ total_requests=progress_total,
1307
+ message=f"Loaded cached {event_format} ratings",
1308
+ )
1309
+ return cached
1310
+
1311
+ try:
1312
+ return refresh_17lands_format_data(
1313
+ set_code=set_code,
1314
+ event_format=event_format,
1315
+ app_dir=app_dir,
1316
+ fetched_at=now,
1317
+ fetch_json=fetch_json,
1318
+ timeout_seconds=timeout_seconds,
1319
+ progress_callback=progress_callback,
1320
+ progress_offset=progress_offset,
1321
+ progress_total=progress_total,
1322
+ )
1323
+ except SeventeenLandsError:
1324
+ if cached is not None and not refresh:
1325
+ _report_download_progress(
1326
+ callback=progress_callback,
1327
+ completed_requests=progress_offset + 2,
1328
+ total_requests=progress_total,
1329
+ message=f"Using cached {event_format} ratings",
1330
+ )
1331
+ return cached
1332
+
1333
+ raise
1334
+
1335
+
1336
+ def load_17lands_format_data(
1337
+ *,
1338
+ set_code: str,
1339
+ event_format: str,
1340
+ app_dir: PathInput | None = None,
1341
+ cache_path: PathInput | None = None,
1342
+ ) -> SeventeenLandsFormatData:
1343
+ """Load one cached 17Lands set/format dataset without network calls.
1344
+ This is the fully offline path used after a successful refresh.
1345
+ """
1346
+
1347
+ path = _format_cache_path(
1348
+ set_code=set_code,
1349
+ event_format=event_format,
1350
+ app_dir=app_dir,
1351
+ cache_path=cache_path,
1352
+ )
1353
+ if not path.exists():
1354
+ raise SeventeenLandsCacheMissingError(
1355
+ f"17Lands cache does not exist at {path}. Refresh ratings first."
1356
+ )
1357
+
1358
+ try:
1359
+ data = json.loads(path.read_text(encoding="utf-8"))
1360
+ except json.JSONDecodeError as error:
1361
+ raise SeventeenLandsError(f"Malformed 17Lands cache {path}: {error}") from error
1362
+
1363
+ if not isinstance(data, dict):
1364
+ raise SeventeenLandsError(f"Malformed 17Lands cache {path}: expected object.")
1365
+
1366
+ dataset = SeventeenLandsFormatData.from_json(data=data)
1367
+ if dataset.set_code != set_code.upper():
1368
+ raise SeventeenLandsError(
1369
+ f"17Lands cache {path} is for set {dataset.set_code}, not {set_code.upper()}."
1370
+ )
1371
+
1372
+ if dataset.event_format != event_format:
1373
+ raise SeventeenLandsError(
1374
+ f"17Lands cache {path} is for format {dataset.event_format}, "
1375
+ f"not {event_format}."
1376
+ )
1377
+
1378
+ return dataset
1379
+
1380
+
1381
+ def load_17lands_structure_targets(
1382
+ *,
1383
+ set_code: str,
1384
+ event_format: str = QUICK_DRAFT_FORMAT,
1385
+ app_dir: PathInput | None = None,
1386
+ cache_path: PathInput | None = None,
1387
+ ) -> SeventeenLandsStructureTargets:
1388
+ """Load cached empirical structure targets without network access.
1389
+ Missing caches are intentionally optional for normal deck building.
1390
+ """
1391
+
1392
+ path = _structure_cache_path(
1393
+ set_code=set_code,
1394
+ event_format=event_format,
1395
+ app_dir=app_dir,
1396
+ cache_path=cache_path,
1397
+ )
1398
+ if not path.exists():
1399
+ raise SeventeenLandsCacheMissingError(
1400
+ f"17Lands structure cache does not exist at {path}."
1401
+ )
1402
+
1403
+ try:
1404
+ data = json.loads(path.read_text(encoding="utf-8"))
1405
+ except json.JSONDecodeError as error:
1406
+ raise SeventeenLandsError(
1407
+ f"Malformed 17Lands structure cache {path}: {error}"
1408
+ ) from error
1409
+
1410
+ if not isinstance(data, dict):
1411
+ raise SeventeenLandsError(
1412
+ f"Malformed 17Lands structure cache {path}: expected object."
1413
+ )
1414
+
1415
+ targets = SeventeenLandsStructureTargets.from_json(data=data)
1416
+ if targets.set_code != set_code.upper():
1417
+ raise SeventeenLandsError(
1418
+ f"17Lands structure cache {path} is for set {targets.set_code}, "
1419
+ f"not {set_code.upper()}."
1420
+ )
1421
+
1422
+ if targets.event_format != event_format:
1423
+ raise SeventeenLandsError(
1424
+ f"17Lands structure cache {path} is for format {targets.event_format}, "
1425
+ f"not {event_format}."
1426
+ )
1427
+
1428
+ return targets
1429
+
1430
+
1431
+ def save_17lands_structure_targets(
1432
+ targets: SeventeenLandsStructureTargets,
1433
+ *,
1434
+ app_dir: PathInput | None = None,
1435
+ cache_path: PathInput | None = None,
1436
+ ) -> Path:
1437
+ """Write empirical structure targets atomically.
1438
+ The file is small because it stores only per-pair aggregates.
1439
+ """
1440
+
1441
+ path = _structure_cache_path(
1442
+ set_code=targets.set_code,
1443
+ event_format=targets.event_format,
1444
+ app_dir=app_dir,
1445
+ cache_path=cache_path,
1446
+ )
1447
+ path.parent.mkdir(parents=True, exist_ok=True)
1448
+ payload = json.dumps(targets.to_json(), indent=2, sort_keys=True)
1449
+ with tempfile.NamedTemporaryFile(
1450
+ "w",
1451
+ delete=False,
1452
+ dir=path.parent,
1453
+ encoding="utf-8",
1454
+ ) as temporary_file:
1455
+ temporary_file.write(payload)
1456
+ temporary_file.write("\n")
1457
+ temporary_path = Path(temporary_file.name)
1458
+
1459
+ temporary_path.replace(path)
1460
+ return path
1461
+
1462
+
1463
+ def refresh_17lands_structure_targets(
1464
+ *,
1465
+ set_code: str,
1466
+ event_format: str = QUICK_DRAFT_FORMAT,
1467
+ card_database: CardDatabase,
1468
+ app_dir: PathInput | None = None,
1469
+ cache_path: PathInput | None = None,
1470
+ draft_data_file: PathInput | None = None,
1471
+ draft_data_url: str | None = None,
1472
+ computed_at: datetime | None = None,
1473
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1474
+ config: DeckBuilderConfig = DECK_BUILDER,
1475
+ ) -> SeventeenLandsStructureTargets:
1476
+ """Compute and cache structure targets from public draft data.
1477
+ Public dumps are the usage-guideline-compliant source for deck-level data.
1478
+ """
1479
+
1480
+ source_url = draft_data_url or public_draft_data_url(
1481
+ set_code=set_code,
1482
+ event_format=event_format,
1483
+ )
1484
+ if draft_data_file is None:
1485
+ with tempfile.TemporaryDirectory(prefix="draftomen-17lands-") as directory:
1486
+ temporary_path = Path(directory) / "draft-data.csv.gz"
1487
+ _download_public_draft_data(
1488
+ url=source_url,
1489
+ path=temporary_path,
1490
+ timeout_seconds=timeout_seconds,
1491
+ )
1492
+ targets = compute_17lands_structure_targets(
1493
+ set_code=set_code,
1494
+ event_format=event_format,
1495
+ card_database=card_database,
1496
+ draft_data_file=temporary_path,
1497
+ source_url=source_url,
1498
+ computed_at=computed_at,
1499
+ config=config,
1500
+ )
1501
+ else:
1502
+ targets = compute_17lands_structure_targets(
1503
+ set_code=set_code,
1504
+ event_format=event_format,
1505
+ card_database=card_database,
1506
+ draft_data_file=draft_data_file,
1507
+ source_url=source_url if draft_data_url is not None else None,
1508
+ computed_at=computed_at,
1509
+ config=config,
1510
+ )
1511
+
1512
+ save_17lands_structure_targets(
1513
+ targets,
1514
+ app_dir=app_dir,
1515
+ cache_path=cache_path,
1516
+ )
1517
+ return targets
1518
+
1519
+
1520
+ def compute_17lands_structure_targets(
1521
+ *,
1522
+ set_code: str,
1523
+ event_format: str = QUICK_DRAFT_FORMAT,
1524
+ card_database: CardDatabase,
1525
+ draft_data_file: PathInput,
1526
+ source_url: str | None = None,
1527
+ computed_at: datetime | None = None,
1528
+ config: DeckBuilderConfig = DECK_BUILDER,
1529
+ ) -> SeventeenLandsStructureTargets:
1530
+ """Compute per-pair targets from a 17Lands public draft dump.
1531
+ Only trophy drafts are included, grouped by draft id.
1532
+ """
1533
+
1534
+ timestamp = datetime.now(tz=UTC) if computed_at is None else computed_at.astimezone(UTC)
1535
+ decks = _trophy_decks_from_draft_data(
1536
+ path=draft_data_file,
1537
+ set_code=set_code,
1538
+ event_format=event_format,
1539
+ card_database=card_database,
1540
+ config=config,
1541
+ )
1542
+ metrics_by_pair: dict[str, list[_DeckStructureMetrics]] = {
1543
+ pair: [] for pair in COLOR_PAIRS
1544
+ }
1545
+ for cards in decks.values():
1546
+ metrics = _deck_structure_metrics(cards=tuple(cards), config=config)
1547
+ if metrics is not None:
1548
+ metrics_by_pair[metrics.pair].append(metrics)
1549
+
1550
+ targets = {
1551
+ pair: _structural_targets_from_metrics(
1552
+ set_code=set_code.upper(),
1553
+ event_format=event_format,
1554
+ pair=pair,
1555
+ metrics=tuple(metrics),
1556
+ source_url=source_url,
1557
+ computed_at=timestamp,
1558
+ )
1559
+ for pair, metrics in metrics_by_pair.items()
1560
+ if metrics
1561
+ }
1562
+ return SeventeenLandsStructureTargets(
1563
+ set_code=set_code.upper(),
1564
+ event_format=event_format,
1565
+ computed_at=timestamp,
1566
+ source=STRUCTURE_TARGET_SOURCE,
1567
+ source_url=source_url,
1568
+ total_decks=sum(len(metrics) for metrics in metrics_by_pair.values()),
1569
+ targets=targets,
1570
+ )
1571
+
1572
+
1573
+ def build_17lands_structure_targets_from_draft_rows(
1574
+ *,
1575
+ set_code: str,
1576
+ event_format: str = QUICK_DRAFT_FORMAT,
1577
+ card_database: CardDatabase,
1578
+ rows: Iterable[Mapping[str, str]],
1579
+ source_url: str | None = None,
1580
+ computed_at: datetime | None = None,
1581
+ config: DeckBuilderConfig = DECK_BUILDER,
1582
+ ) -> SeventeenLandsStructureTargets:
1583
+ """Build structure targets from already-loaded draft rows.
1584
+ Tests use this to exercise parsing without network or compressed files.
1585
+ """
1586
+
1587
+ timestamp = datetime.now(tz=UTC) if computed_at is None else computed_at.astimezone(UTC)
1588
+ decks = _trophy_decks_from_rows(
1589
+ rows=rows,
1590
+ set_code=set_code,
1591
+ event_format=event_format,
1592
+ card_database=card_database,
1593
+ config=config,
1594
+ )
1595
+ metrics_by_pair: dict[str, list[_DeckStructureMetrics]] = {
1596
+ pair: [] for pair in COLOR_PAIRS
1597
+ }
1598
+ for cards in decks.values():
1599
+ metrics = _deck_structure_metrics(cards=tuple(cards), config=config)
1600
+ if metrics is not None:
1601
+ metrics_by_pair[metrics.pair].append(metrics)
1602
+
1603
+ targets = {
1604
+ pair: _structural_targets_from_metrics(
1605
+ set_code=set_code.upper(),
1606
+ event_format=event_format,
1607
+ pair=pair,
1608
+ metrics=tuple(metrics),
1609
+ source_url=source_url,
1610
+ computed_at=timestamp,
1611
+ )
1612
+ for pair, metrics in metrics_by_pair.items()
1613
+ if metrics
1614
+ }
1615
+ return SeventeenLandsStructureTargets(
1616
+ set_code=set_code.upper(),
1617
+ event_format=event_format,
1618
+ computed_at=timestamp,
1619
+ source=STRUCTURE_TARGET_SOURCE,
1620
+ source_url=source_url,
1621
+ total_decks=sum(len(metrics) for metrics in metrics_by_pair.values()),
1622
+ targets=targets,
1623
+ )
1624
+
1625
+
1626
+ def save_17lands_format_data(
1627
+ dataset: SeventeenLandsFormatData,
1628
+ *,
1629
+ app_dir: PathInput | None = None,
1630
+ cache_path: PathInput | None = None,
1631
+ ) -> Path:
1632
+ """Write a 17Lands dataset cache atomically.
1633
+ The destination parent directory is created if needed.
1634
+ """
1635
+
1636
+ path = _format_cache_path(
1637
+ set_code=dataset.set_code,
1638
+ event_format=dataset.event_format,
1639
+ app_dir=app_dir,
1640
+ cache_path=cache_path,
1641
+ )
1642
+ path.parent.mkdir(parents=True, exist_ok=True)
1643
+ payload = json.dumps(dataset.to_json(), indent=2, sort_keys=True)
1644
+ with tempfile.NamedTemporaryFile(
1645
+ "w",
1646
+ delete=False,
1647
+ dir=path.parent,
1648
+ encoding="utf-8",
1649
+ ) as temporary_file:
1650
+ temporary_file.write(payload)
1651
+ temporary_file.write("\n")
1652
+ temporary_path = Path(temporary_file.name)
1653
+
1654
+ temporary_path.replace(path)
1655
+ return path
1656
+
1657
+
1658
+ def refresh_17lands_format_data(
1659
+ *,
1660
+ set_code: str,
1661
+ event_format: str,
1662
+ app_dir: PathInput | None = None,
1663
+ cache_path: PathInput | None = None,
1664
+ fetched_at: datetime | None = None,
1665
+ fetch_json: FetchJson | None = None,
1666
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1667
+ progress_callback: DownloadProgressCallback | None = None,
1668
+ progress_offset: int = 0,
1669
+ progress_total: int = 2,
1670
+ ) -> SeventeenLandsFormatData:
1671
+ """Fetch 17Lands endpoints and write the normalized cache.
1672
+ Tests pass a fetch_json callable so no live network is required.
1673
+ """
1674
+
1675
+ timestamp = datetime.now(tz=UTC) if fetched_at is None else fetched_at.astimezone(UTC)
1676
+ dataset = fetch_17lands_format_data(
1677
+ set_code=set_code,
1678
+ event_format=event_format,
1679
+ fetched_at=timestamp,
1680
+ fetch_json=fetch_json,
1681
+ timeout_seconds=timeout_seconds,
1682
+ progress_callback=progress_callback,
1683
+ progress_offset=progress_offset,
1684
+ progress_total=progress_total,
1685
+ )
1686
+ save_17lands_format_data(dataset, app_dir=app_dir, cache_path=cache_path)
1687
+ return dataset
1688
+
1689
+
1690
+ def refresh_17lands_pair_card_data(
1691
+ *,
1692
+ set_code: str,
1693
+ event_format: str,
1694
+ pair: str,
1695
+ app_dir: PathInput | None = None,
1696
+ fetched_at: datetime | None = None,
1697
+ fetch_json: FetchJson | None = None,
1698
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1699
+ ) -> SeventeenLandsFormatData:
1700
+ """Fetch pair-filtered card ratings and write their separate cache.
1701
+ Pair caches contain only card ratings; aggregate pair rates stay primary.
1702
+ """
1703
+
1704
+ timestamp = datetime.now(tz=UTC) if fetched_at is None else fetched_at.astimezone(UTC)
1705
+ dataset = fetch_17lands_pair_card_data(
1706
+ set_code=set_code,
1707
+ event_format=event_format,
1708
+ pair=pair,
1709
+ fetched_at=timestamp,
1710
+ fetch_json=fetch_json,
1711
+ timeout_seconds=timeout_seconds,
1712
+ )
1713
+ save_17lands_format_data(
1714
+ dataset,
1715
+ cache_path=seventeen_lands_pair_card_cache_path(
1716
+ set_code=set_code,
1717
+ event_format=event_format,
1718
+ pair=pair,
1719
+ app_dir=app_dir,
1720
+ ),
1721
+ )
1722
+ return dataset
1723
+
1724
+
1725
+ def fetch_17lands_format_data(
1726
+ *,
1727
+ set_code: str,
1728
+ event_format: str,
1729
+ fetched_at: datetime | None = None,
1730
+ fetch_json: FetchJson | None = None,
1731
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1732
+ progress_callback: DownloadProgressCallback | None = None,
1733
+ progress_offset: int = 0,
1734
+ progress_total: int = 2,
1735
+ ) -> SeventeenLandsFormatData:
1736
+ """Fetch and normalize one set/format from 17Lands.
1737
+ The fetch_json hook receives each URL and timeout in seconds.
1738
+ """
1739
+
1740
+ timestamp = datetime.now(tz=UTC) if fetched_at is None else fetched_at.astimezone(UTC)
1741
+ json_fetcher = _default_fetch_json if fetch_json is None else fetch_json
1742
+ card_payload = json_fetcher(
1743
+ card_ratings_url(
1744
+ set_code=set_code,
1745
+ event_format=event_format,
1746
+ ),
1747
+ timeout_seconds,
1748
+ )
1749
+ _report_download_progress(
1750
+ callback=progress_callback,
1751
+ completed_requests=progress_offset + 1,
1752
+ total_requests=progress_total,
1753
+ message=f"Downloaded all-time {event_format} card ratings",
1754
+ )
1755
+ color_payload = json_fetcher(
1756
+ color_ratings_url(
1757
+ set_code=set_code,
1758
+ event_format=event_format,
1759
+ ),
1760
+ timeout_seconds,
1761
+ )
1762
+ _report_download_progress(
1763
+ callback=progress_callback,
1764
+ completed_requests=progress_offset + 2,
1765
+ total_requests=progress_total,
1766
+ message=f"Downloaded all-time {event_format} color ratings",
1767
+ )
1768
+ return build_17lands_format_data(
1769
+ set_code=set_code,
1770
+ event_format=event_format,
1771
+ fetched_at=timestamp,
1772
+ card_ratings_payload=card_payload,
1773
+ color_ratings_payload=color_payload,
1774
+ )
1775
+
1776
+
1777
+ def fetch_17lands_pair_card_data(
1778
+ *,
1779
+ set_code: str,
1780
+ event_format: str,
1781
+ pair: str,
1782
+ fetched_at: datetime | None = None,
1783
+ fetch_json: FetchJson | None = None,
1784
+ timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
1785
+ ) -> SeventeenLandsFormatData:
1786
+ """Fetch and normalize one pair-filtered card-rating table.
1787
+ Only the card endpoint is needed because pair win rates live in primary data.
1788
+ """
1789
+
1790
+ timestamp = datetime.now(tz=UTC) if fetched_at is None else fetched_at.astimezone(UTC)
1791
+ json_fetcher = _default_fetch_json if fetch_json is None else fetch_json
1792
+ card_payload = json_fetcher(
1793
+ card_ratings_url(
1794
+ set_code=set_code,
1795
+ event_format=event_format,
1796
+ colors=_required_pair(pair, field_name="pair"),
1797
+ ),
1798
+ timeout_seconds,
1799
+ )
1800
+ return build_17lands_format_data(
1801
+ set_code=set_code,
1802
+ event_format=event_format,
1803
+ fetched_at=timestamp,
1804
+ card_ratings_payload=card_payload,
1805
+ color_ratings_payload=[],
1806
+ )
1807
+
1808
+
1809
+ def build_17lands_format_data(
1810
+ *,
1811
+ set_code: str,
1812
+ event_format: str,
1813
+ fetched_at: datetime,
1814
+ card_ratings_payload: Any,
1815
+ color_ratings_payload: Any,
1816
+ ) -> SeventeenLandsFormatData:
1817
+ """Normalize recorded 17Lands endpoint responses into cache data.
1818
+ This powers tests and the live refresh path with identical parsing.
1819
+ """
1820
+
1821
+ return SeventeenLandsFormatData(
1822
+ set_code=set_code.upper(),
1823
+ event_format=event_format,
1824
+ fetched_at=fetched_at.astimezone(UTC),
1825
+ card_ratings=_parse_card_ratings(payload=card_ratings_payload),
1826
+ pair_win_rates=_parse_pair_win_rates(payload=color_ratings_payload),
1827
+ )
1828
+
1829
+
1830
+ def _load_existing_format_data(
1831
+ *,
1832
+ set_code: str,
1833
+ event_format: str,
1834
+ app_dir: PathInput | None,
1835
+ ) -> SeventeenLandsFormatData | None:
1836
+ try:
1837
+ return load_17lands_format_data(
1838
+ set_code=set_code,
1839
+ event_format=event_format,
1840
+ app_dir=app_dir,
1841
+ )
1842
+ except (SeventeenLandsCacheMissingError, SeventeenLandsCacheOutdatedError):
1843
+ return None
1844
+
1845
+
1846
+ def _load_cached_or_empty_format_data(
1847
+ *,
1848
+ set_code: str,
1849
+ event_format: str,
1850
+ app_dir: PathInput | None,
1851
+ ) -> SeventeenLandsFormatData:
1852
+ cached = _load_optional_cached_format_data(
1853
+ set_code=set_code,
1854
+ event_format=event_format,
1855
+ app_dir=app_dir,
1856
+ )
1857
+ if cached is not None:
1858
+ return cached
1859
+
1860
+ return SeventeenLandsFormatData(
1861
+ set_code=set_code.upper(),
1862
+ event_format=event_format,
1863
+ fetched_at=datetime.now(tz=UTC),
1864
+ card_ratings={},
1865
+ pair_win_rates={},
1866
+ )
1867
+
1868
+
1869
+ def _load_optional_cached_format_data(
1870
+ *,
1871
+ set_code: str,
1872
+ event_format: str,
1873
+ app_dir: PathInput | None,
1874
+ ) -> SeventeenLandsFormatData | None:
1875
+ try:
1876
+ return load_17lands_format_data(
1877
+ set_code=set_code,
1878
+ event_format=event_format,
1879
+ app_dir=app_dir,
1880
+ )
1881
+ except (SeventeenLandsCacheMissingError, SeventeenLandsCacheOutdatedError):
1882
+ return None
1883
+
1884
+
1885
+ def _load_optional_pair_card_data(
1886
+ *,
1887
+ set_code: str,
1888
+ event_format: str,
1889
+ pair: str,
1890
+ app_dir: PathInput | None,
1891
+ ) -> SeventeenLandsFormatData | None:
1892
+ try:
1893
+ return load_17lands_format_data(
1894
+ set_code=set_code,
1895
+ event_format=event_format,
1896
+ cache_path=seventeen_lands_pair_card_cache_path(
1897
+ set_code=set_code,
1898
+ event_format=event_format,
1899
+ pair=pair,
1900
+ app_dir=app_dir,
1901
+ ),
1902
+ )
1903
+ except (SeventeenLandsCacheMissingError, SeventeenLandsCacheOutdatedError):
1904
+ return None
1905
+
1906
+
1907
+ def _load_optional_structure_targets(
1908
+ *,
1909
+ set_code: str,
1910
+ event_format: str,
1911
+ app_dir: PathInput | None,
1912
+ ) -> SeventeenLandsStructureTargets | None:
1913
+ try:
1914
+ return load_17lands_structure_targets(
1915
+ set_code=set_code,
1916
+ event_format=event_format,
1917
+ app_dir=app_dir,
1918
+ )
1919
+ except SeventeenLandsCacheMissingError:
1920
+ return None
1921
+
1922
+
1923
+ def _trophy_decks_from_draft_data(
1924
+ *,
1925
+ path: PathInput,
1926
+ set_code: str,
1927
+ event_format: str,
1928
+ card_database: CardDatabase,
1929
+ config: DeckBuilderConfig,
1930
+ ) -> dict[str, list[CardInfo]]:
1931
+ return _trophy_decks_from_rows(
1932
+ rows=_iter_draft_data_rows(path=path),
1933
+ set_code=set_code,
1934
+ event_format=event_format,
1935
+ card_database=card_database,
1936
+ config=config,
1937
+ )
1938
+
1939
+
1940
+ def _trophy_decks_from_rows(
1941
+ *,
1942
+ rows: Iterable[Mapping[str, str]],
1943
+ set_code: str,
1944
+ event_format: str,
1945
+ card_database: CardDatabase,
1946
+ config: DeckBuilderConfig,
1947
+ ) -> dict[str, list[CardInfo]]:
1948
+ name_index = _card_name_index(card_database=card_database)
1949
+ decks: dict[str, list[CardInfo]] = {}
1950
+ for row in rows:
1951
+ if not _draft_row_matches(
1952
+ row=row,
1953
+ set_code=set_code,
1954
+ event_format=event_format,
1955
+ ):
1956
+ continue
1957
+
1958
+ if _optional_int(
1959
+ row.get("event_match_wins"),
1960
+ field_name="event_match_wins",
1961
+ ) != _trophy_wins(event_format=event_format):
1962
+ continue
1963
+
1964
+ maindeck_rate = _optional_float(
1965
+ row.get("pick_maindeck_rate"),
1966
+ field_name="pick_maindeck_rate",
1967
+ )
1968
+ if maindeck_rate is None or maindeck_rate < config.structure_maindeck_rate_threshold:
1969
+ continue
1970
+
1971
+ draft_id = _required_str(row.get("draft_id"), field_name="draft_id")
1972
+ pick_name = _required_str(row.get("pick"), field_name="pick")
1973
+ card = name_index.get(_normalize_card_name(pick_name))
1974
+ if card is None:
1975
+ continue
1976
+
1977
+ decks.setdefault(draft_id, []).append(card)
1978
+
1979
+ return decks
1980
+
1981
+
1982
+ def _draft_row_matches(
1983
+ *,
1984
+ row: Mapping[str, str],
1985
+ set_code: str,
1986
+ event_format: str,
1987
+ ) -> bool:
1988
+ row_set = row.get("expansion")
1989
+ if row_set not in {None, "", set_code.upper()}:
1990
+ return False
1991
+
1992
+ row_format = row.get("event_type")
1993
+ return row_format in {None, "", event_format}
1994
+
1995
+
1996
+ def _iter_draft_data_rows(*, path: PathInput) -> Iterable[Mapping[str, str]]:
1997
+ draft_path = Path(path)
1998
+ if tarfile.is_tarfile(draft_path):
1999
+ with tarfile.open(draft_path, mode="r:*") as archive:
2000
+ member = _first_regular_tar_member(archive=archive)
2001
+ if member is None:
2002
+ raise SeventeenLandsError(
2003
+ f"17Lands draft data archive {draft_path} contains no files."
2004
+ )
2005
+
2006
+ extracted = archive.extractfile(member)
2007
+ if extracted is None:
2008
+ raise SeventeenLandsError(
2009
+ f"Could not read {member.name} from {draft_path}."
2010
+ )
2011
+
2012
+ with extracted:
2013
+ with io.TextIOWrapper(
2014
+ extracted,
2015
+ encoding="utf-8",
2016
+ newline="",
2017
+ ) as text_file:
2018
+ yield from csv.DictReader(text_file)
2019
+
2020
+ return
2021
+
2022
+ if draft_path.suffix == ".gz":
2023
+ with gzip.open(draft_path, mode="rt", encoding="utf-8", newline="") as csv_file:
2024
+ yield from csv.DictReader(csv_file)
2025
+ return
2026
+
2027
+ with draft_path.open(mode="rt", encoding="utf-8", newline="") as csv_file:
2028
+ yield from csv.DictReader(csv_file)
2029
+
2030
+
2031
+ def _first_regular_tar_member(*, archive: tarfile.TarFile) -> tarfile.TarInfo | None:
2032
+ for member in archive:
2033
+ if member.isfile():
2034
+ return member
2035
+
2036
+ return None
2037
+
2038
+
2039
+ def _download_public_draft_data(
2040
+ *,
2041
+ url: str,
2042
+ path: Path,
2043
+ timeout_seconds: int,
2044
+ ) -> None:
2045
+ request = urllib.request.Request(
2046
+ url,
2047
+ headers={
2048
+ "Accept": "application/gzip,application/octet-stream;q=0.9,*/*;q=0.8",
2049
+ "User-Agent": SEVENTEEN_LANDS_USER_AGENT,
2050
+ },
2051
+ )
2052
+ try:
2053
+ with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
2054
+ with path.open(mode="wb") as output_file:
2055
+ shutil.copyfileobj(response, output_file)
2056
+ except urllib.error.URLError as error:
2057
+ raise SeventeenLandsError(
2058
+ f"Failed to download 17Lands public draft data: {error}"
2059
+ ) from error
2060
+
2061
+
2062
+ def _card_name_index(*, card_database: CardDatabase) -> dict[str, CardInfo]:
2063
+ index: dict[str, CardInfo] = {}
2064
+ for card in card_database.cards.values():
2065
+ for name in _card_lookup_names(card=card):
2066
+ index.setdefault(_normalize_card_name(name), card)
2067
+
2068
+ return index
2069
+
2070
+
2071
+ def _card_lookup_names(*, card: CardInfo) -> tuple[str, ...]:
2072
+ names = [card.name]
2073
+ names.extend(part.strip() for part in card.name.split("//") if part.strip())
2074
+ return tuple(dict.fromkeys(names))
2075
+
2076
+
2077
+ def _normalize_card_name(name: str) -> str:
2078
+ return " ".join(name.casefold().split())
2079
+
2080
+
2081
+ def _deck_structure_metrics(
2082
+ *,
2083
+ cards: tuple[CardInfo, ...],
2084
+ config: DeckBuilderConfig,
2085
+ ) -> _DeckStructureMetrics | None:
2086
+ nonland_cards = tuple(card for card in cards if not _structure_is_land_card(card=card))
2087
+ land_count = config.deck_size - len(nonland_cards)
2088
+ if land_count < config.structure_min_land_count:
2089
+ return None
2090
+
2091
+ if land_count > config.structure_max_land_count:
2092
+ return None
2093
+
2094
+ pair = _structure_pair(cards=nonland_cards)
2095
+ if pair is None:
2096
+ return None
2097
+
2098
+ curve = {bucket: 0 for bucket in CURVE_BUCKETS}
2099
+ for card in nonland_cards:
2100
+ curve[_curve_bucket(card=card, config=config)] += 1
2101
+
2102
+ return _DeckStructureMetrics(
2103
+ pair=pair,
2104
+ creature_count=sum(
2105
+ 1 for card in nonland_cards if _structure_is_creature_card(card=card)
2106
+ ),
2107
+ land_count=land_count,
2108
+ spell_count=len(nonland_cards),
2109
+ two_drop_count=curve["2"],
2110
+ expensive_spell_count=curve["6+"],
2111
+ curve=curve,
2112
+ )
2113
+
2114
+
2115
+ def _structure_pair(*, cards: tuple[CardInfo, ...]) -> str | None:
2116
+ color_counts = _empty_structure_color_counts()
2117
+ for card in cards:
2118
+ for color in card.colors:
2119
+ if color in color_counts:
2120
+ color_counts[color] += 1
2121
+
2122
+ colors = tuple(
2123
+ color
2124
+ for color, count in sorted(
2125
+ color_counts.items(),
2126
+ key=lambda item: (-item[1], _color_order_index(color=item[0])),
2127
+ )
2128
+ if count > 0
2129
+ )
2130
+ if len(colors) < 2:
2131
+ return None
2132
+
2133
+ return _canonical_pair(colors=colors[:2])
2134
+
2135
+
2136
+ def _canonical_pair(*, colors: tuple[str, str]) -> str:
2137
+ color_set = set(colors)
2138
+ for pair in COLOR_PAIRS:
2139
+ if set(pair) == color_set:
2140
+ return pair
2141
+
2142
+ raise SeventeenLandsError(f"Could not canonicalize color pair {colors}.")
2143
+
2144
+
2145
+ def _empty_structure_color_counts() -> dict[str, int]:
2146
+ colors: dict[str, int] = {}
2147
+ for pair in COLOR_PAIRS:
2148
+ for color in pair:
2149
+ colors.setdefault(color, 0)
2150
+
2151
+ return colors
2152
+
2153
+
2154
+ def _color_order_index(*, color: str) -> int:
2155
+ colors = tuple(_empty_structure_color_counts())
2156
+ return colors.index(color)
2157
+
2158
+
2159
+ def _structure_is_land_card(*, card: CardInfo) -> bool:
2160
+ return any("Land" in type_line for type_line in card.types)
2161
+
2162
+
2163
+ def _structure_is_creature_card(*, card: CardInfo) -> bool:
2164
+ return any("Creature" in type_line for type_line in card.types)
2165
+
2166
+
2167
+ def _curve_bucket(*, card: CardInfo, config: DeckBuilderConfig) -> str:
2168
+ mana_value = card.mana_value or 0.0
2169
+ if mana_value < config.two_drop_mana_value:
2170
+ return "0-1"
2171
+
2172
+ if mana_value >= config.expensive_spell_mana_value:
2173
+ return "6+"
2174
+
2175
+ return f"{int(mana_value)}"
2176
+
2177
+
2178
+ def _trophy_wins(*, event_format: str) -> int:
2179
+ if event_format.startswith("Trad"):
2180
+ return 3
2181
+
2182
+ return 7
2183
+
2184
+
2185
+ def _structural_targets_from_metrics(
2186
+ *,
2187
+ set_code: str,
2188
+ event_format: str,
2189
+ pair: str,
2190
+ metrics: tuple[_DeckStructureMetrics, ...],
2191
+ source_url: str | None,
2192
+ computed_at: datetime,
2193
+ ) -> StructuralTargets:
2194
+ sample_size = len(metrics)
2195
+ if sample_size <= 0:
2196
+ raise SeventeenLandsError("Cannot build structure targets without decks.")
2197
+
2198
+ return StructuralTargets(
2199
+ set_code=set_code,
2200
+ event_format=event_format,
2201
+ pair=pair,
2202
+ sample_size=sample_size,
2203
+ average_creature_count=_average(
2204
+ values=tuple(metric.creature_count for metric in metrics)
2205
+ ),
2206
+ average_land_count=_average(values=tuple(metric.land_count for metric in metrics)),
2207
+ average_spell_count=_average(values=tuple(metric.spell_count for metric in metrics)),
2208
+ average_two_drop_count=_average(
2209
+ values=tuple(metric.two_drop_count for metric in metrics)
2210
+ ),
2211
+ average_expensive_spell_count=_average(
2212
+ values=tuple(metric.expensive_spell_count for metric in metrics)
2213
+ ),
2214
+ average_curve=tuple(
2215
+ (
2216
+ bucket,
2217
+ _average(values=tuple(metric.curve[bucket] for metric in metrics)),
2218
+ )
2219
+ for bucket in CURVE_BUCKETS
2220
+ ),
2221
+ source=STRUCTURE_TARGET_SOURCE,
2222
+ source_url=source_url,
2223
+ computed_at=computed_at,
2224
+ )
2225
+
2226
+
2227
+ def _average(*, values: tuple[int, ...]) -> float:
2228
+ if not values:
2229
+ return 0.0
2230
+
2231
+ return sum(values) / len(values)
2232
+
2233
+
2234
+ def _structure_cache_path(
2235
+ *,
2236
+ set_code: str,
2237
+ event_format: str,
2238
+ app_dir: PathInput | None,
2239
+ cache_path: PathInput | None,
2240
+ ) -> Path:
2241
+ if cache_path is not None:
2242
+ return Path(cache_path)
2243
+
2244
+ return seventeen_lands_structure_targets_cache_path(
2245
+ set_code=set_code,
2246
+ event_format=event_format,
2247
+ app_dir=app_dir,
2248
+ )
2249
+
2250
+
2251
+ def _parse_card_ratings(*, payload: Any) -> dict[int, SeventeenCardStats]:
2252
+ if isinstance(payload, dict):
2253
+ payload = payload.get("data")
2254
+
2255
+ if not isinstance(payload, list):
2256
+ raise SeventeenLandsError(
2257
+ "17Lands card ratings payload does not contain a data list."
2258
+ )
2259
+
2260
+ ratings: dict[int, SeventeenCardStats] = {}
2261
+ for index, item in enumerate(payload):
2262
+ if not isinstance(item, dict):
2263
+ raise SeventeenLandsError(
2264
+ f"17Lands card ratings item {index} is not an object."
2265
+ )
2266
+
2267
+ stats = _card_stats_from_endpoint_row(row=item, index=index)
2268
+ ratings[stats.grp_id] = stats
2269
+
2270
+ return ratings
2271
+
2272
+
2273
+ def _card_stats_from_endpoint_row(
2274
+ *,
2275
+ row: Mapping[str, Any],
2276
+ index: int,
2277
+ ) -> SeventeenCardStats:
2278
+ grp_id = _required_int(row.get("mtga_id"), field_name=f"card_ratings[{index}].mtga_id")
2279
+ return SeventeenCardStats(
2280
+ grp_id=grp_id,
2281
+ name=_required_str(row.get("name"), field_name=f"card {grp_id}.name"),
2282
+ color=_card_color(row.get("color")),
2283
+ rarity=_required_str(row.get("rarity"), field_name=f"card {grp_id}.rarity"),
2284
+ average_last_seen_at=_optional_float(
2285
+ row.get("avg_seen"),
2286
+ field_name=f"card {grp_id}.avg_seen",
2287
+ ),
2288
+ gih_win_rate=_optional_float(
2289
+ row.get("ever_drawn_win_rate"),
2290
+ field_name=f"card {grp_id}.ever_drawn_win_rate",
2291
+ ),
2292
+ opening_hand_win_rate=_optional_float(
2293
+ row.get("opening_hand_win_rate"),
2294
+ field_name=f"card {grp_id}.opening_hand_win_rate",
2295
+ ),
2296
+ drawn_improvement_win_rate=_optional_float(
2297
+ row.get("drawn_improvement_win_rate"),
2298
+ field_name=f"card {grp_id}.drawn_improvement_win_rate",
2299
+ ),
2300
+ sample_counts=RatingSampleCounts(
2301
+ seen=_optional_int(row.get("seen_count"), field_name=f"card {grp_id}.seen_count")
2302
+ or 0,
2303
+ picked=_optional_int(
2304
+ row.get("pick_count"),
2305
+ field_name=f"card {grp_id}.pick_count",
2306
+ )
2307
+ or 0,
2308
+ games_played=_optional_int(
2309
+ row.get("game_count"),
2310
+ field_name=f"card {grp_id}.game_count",
2311
+ )
2312
+ or 0,
2313
+ opening_hand=_optional_int(
2314
+ row.get("opening_hand_game_count"),
2315
+ field_name=f"card {grp_id}.opening_hand_game_count",
2316
+ )
2317
+ or 0,
2318
+ games_in_hand=_optional_int(
2319
+ row.get("ever_drawn_game_count"),
2320
+ field_name=f"card {grp_id}.ever_drawn_game_count",
2321
+ )
2322
+ or 0,
2323
+ ),
2324
+ )
2325
+
2326
+
2327
+ def _card_color(value: Any) -> str:
2328
+ if value is None or value == "":
2329
+ return "C"
2330
+
2331
+ return _required_str(value, field_name="card.color")
2332
+
2333
+
2334
+ def _parse_pair_win_rates(*, payload: Any) -> dict[str, ColorPairWinRate]:
2335
+ if not isinstance(payload, list):
2336
+ raise SeventeenLandsError("17Lands color ratings payload is not a list.")
2337
+
2338
+ pairs: dict[str, ColorPairWinRate] = {}
2339
+ for index, item in enumerate(payload):
2340
+ if not isinstance(item, dict):
2341
+ raise SeventeenLandsError(
2342
+ f"17Lands color ratings item {index} is not an object."
2343
+ )
2344
+
2345
+ pair = item.get("short_name")
2346
+ if pair not in COLOR_PAIRS:
2347
+ continue
2348
+
2349
+ wins = _required_int(item.get("wins"), field_name=f"color_ratings[{index}].wins")
2350
+ games = _required_int(item.get("games"), field_name=f"color_ratings[{index}].games")
2351
+ _ensure_non_negative(value=wins, field_name=f"color_ratings[{index}].wins")
2352
+ _ensure_non_negative(value=games, field_name=f"color_ratings[{index}].games")
2353
+ pairs[pair] = ColorPairWinRate(
2354
+ pair=pair,
2355
+ wins=wins,
2356
+ games=games,
2357
+ win_rate=(wins / games) if games else None,
2358
+ )
2359
+
2360
+ return pairs
2361
+
2362
+
2363
+ def _resolved_from_stats(
2364
+ *,
2365
+ stats: SeventeenCardStats | None,
2366
+ format_data: SeventeenLandsFormatData | None,
2367
+ requested_format: str,
2368
+ source_format: str | None,
2369
+ fallback_reason: str | None,
2370
+ ) -> ResolvedCardRating:
2371
+ if stats is None:
2372
+ raise SeventeenLandsError("Cannot resolve a rating from missing card stats.")
2373
+
2374
+ return ResolvedCardRating(
2375
+ grp_id=stats.grp_id,
2376
+ name=stats.name,
2377
+ color=stats.color,
2378
+ rarity=stats.rarity,
2379
+ average_last_seen_at=stats.average_last_seen_at,
2380
+ gih_win_rate=stats.gih_win_rate,
2381
+ opening_hand_win_rate=stats.opening_hand_win_rate,
2382
+ drawn_improvement_win_rate=stats.drawn_improvement_win_rate,
2383
+ sample_counts=stats.sample_counts,
2384
+ letter_grade=(
2385
+ None
2386
+ if format_data is None
2387
+ else format_data.letter_grade_for(grp_id=stats.grp_id)
2388
+ ),
2389
+ neutral_prior_score=None,
2390
+ metadata=RatingSourceMetadata(
2391
+ requested_format=requested_format,
2392
+ source=FORMAT_RATING_SOURCE,
2393
+ source_format=source_format,
2394
+ fallback_reason=fallback_reason,
2395
+ ),
2396
+ )
2397
+
2398
+
2399
+ def _neutral_rating(
2400
+ *,
2401
+ grp_id: int,
2402
+ stats: SeventeenCardStats | None,
2403
+ requested_format: str,
2404
+ fallback_reason: str,
2405
+ ) -> ResolvedCardRating:
2406
+ sample_counts = (
2407
+ stats.sample_counts
2408
+ if stats is not None
2409
+ else RatingSampleCounts(
2410
+ seen=0,
2411
+ picked=0,
2412
+ games_played=0,
2413
+ opening_hand=0,
2414
+ games_in_hand=0,
2415
+ )
2416
+ )
2417
+ return ResolvedCardRating(
2418
+ grp_id=grp_id,
2419
+ name=stats.name if stats is not None else f"Unknown card {grp_id}",
2420
+ color=stats.color if stats is not None else None,
2421
+ rarity=stats.rarity if stats is not None else None,
2422
+ average_last_seen_at=stats.average_last_seen_at if stats is not None else None,
2423
+ gih_win_rate=None,
2424
+ opening_hand_win_rate=stats.opening_hand_win_rate if stats is not None else None,
2425
+ drawn_improvement_win_rate=(
2426
+ stats.drawn_improvement_win_rate if stats is not None else None
2427
+ ),
2428
+ sample_counts=sample_counts,
2429
+ letter_grade=None,
2430
+ neutral_prior_score=PICK_ENGINE.neutral_prior_score,
2431
+ metadata=RatingSourceMetadata(
2432
+ requested_format=requested_format,
2433
+ source=NEUTRAL_PRIOR_SOURCE,
2434
+ source_format=None,
2435
+ fallback_reason=fallback_reason,
2436
+ ),
2437
+ )
2438
+
2439
+
2440
+ def _gih_win_rate_distribution(
2441
+ *,
2442
+ stats: Iterable[SeventeenCardStats],
2443
+ ) -> tuple[float, ...]:
2444
+ return tuple(
2445
+ card.gih_win_rate
2446
+ for card in stats
2447
+ if card.gih_win_rate is not None
2448
+ )
2449
+
2450
+
2451
+ def _letter_grade_for_metric(
2452
+ *,
2453
+ value: float | None,
2454
+ distribution: tuple[float, ...],
2455
+ ) -> str | None:
2456
+ if value is None or not distribution:
2457
+ return None
2458
+
2459
+ mean = sum(distribution) / len(distribution)
2460
+ variance = sum((metric - mean) ** 2 for metric in distribution) / len(distribution)
2461
+ standard_deviation = math.sqrt(variance)
2462
+ if standard_deviation <= 0:
2463
+ return "C"
2464
+
2465
+ grade_offset = _rounded_grade_offset(
2466
+ value=(value - mean) / standard_deviation / GRADE_STEP_STANDARD_DEVIATIONS
2467
+ )
2468
+ grade_index = _clamp_int(
2469
+ value=GRADE_CENTER_INDEX + grade_offset,
2470
+ lower=0,
2471
+ upper=len(GRADE_LABELS) - 1,
2472
+ )
2473
+ return GRADE_LABELS[grade_index]
2474
+
2475
+
2476
+ def _rounded_grade_offset(*, value: float) -> int:
2477
+ if value >= 0:
2478
+ return math.floor(value + 0.5)
2479
+
2480
+ return math.ceil(value - 0.5)
2481
+
2482
+
2483
+ def _clamp_int(*, value: int, lower: int, upper: int) -> int:
2484
+ return min(max(value, lower), upper)
2485
+
2486
+
2487
+ def _has_strong_gih_signal(
2488
+ *,
2489
+ stats: SeventeenCardStats | None,
2490
+ thin_sample_minimum: int,
2491
+ ) -> bool:
2492
+ if stats is None:
2493
+ return False
2494
+
2495
+ return (
2496
+ stats.gih_win_rate is not None
2497
+ and stats.sample_counts.games_in_hand >= thin_sample_minimum
2498
+ )
2499
+
2500
+
2501
+ def _set_data_reliability(*, data: SeventeenLandsData) -> SetDataReliability:
2502
+ card_ids = set(data.primary.card_ratings)
2503
+ if data.fallback is not None:
2504
+ card_ids.update(data.fallback.card_ratings)
2505
+
2506
+ if not card_ids:
2507
+ return SetDataReliability(
2508
+ set_code=data.set_code,
2509
+ score=0,
2510
+ tier=_reliability_tier(score=0),
2511
+ )
2512
+
2513
+ quick_samples: list[int] = []
2514
+ premier_samples: list[int] = []
2515
+ for grp_id in card_ids:
2516
+ quick_stats = data.primary.card_ratings.get(grp_id)
2517
+ if _has_strong_gih_signal(
2518
+ stats=quick_stats,
2519
+ thin_sample_minimum=data.thin_sample_minimum,
2520
+ ):
2521
+ if quick_stats is None:
2522
+ raise AssertionError("Strong Quick Draft stats cannot be missing.")
2523
+ quick_samples.append(quick_stats.sample_counts.games_in_hand)
2524
+ continue
2525
+
2526
+ premier_stats = (
2527
+ None
2528
+ if data.fallback is None
2529
+ else data.fallback.card_ratings.get(grp_id)
2530
+ )
2531
+ if _has_strong_gih_signal(
2532
+ stats=premier_stats,
2533
+ thin_sample_minimum=data.thin_sample_minimum,
2534
+ ):
2535
+ if premier_stats is None:
2536
+ raise AssertionError("Strong Premier Draft stats cannot be missing.")
2537
+ premier_samples.append(premier_stats.sample_counts.games_in_hand)
2538
+
2539
+ card_count = len(card_ids)
2540
+ quick_share = len(quick_samples) / card_count
2541
+ premier_share = len(premier_samples) / card_count
2542
+ coverage_quality = (
2543
+ quick_share + RELIABILITY_PREMIER_FACTOR * premier_share
2544
+ )
2545
+ depth_quality = (
2546
+ quick_share
2547
+ * _normalized_reliability_sample_depth(
2548
+ samples=quick_samples,
2549
+ minimum=data.thin_sample_minimum,
2550
+ )
2551
+ + RELIABILITY_PREMIER_FACTOR
2552
+ * premier_share
2553
+ * _normalized_reliability_sample_depth(
2554
+ samples=premier_samples,
2555
+ minimum=data.thin_sample_minimum,
2556
+ )
2557
+ )
2558
+ score = math.floor(
2559
+ 100.0 * (0.5 * coverage_quality + 0.5 * depth_quality) + 0.5
2560
+ )
2561
+ score = _clamp_int(value=score, lower=0, upper=100)
2562
+ return SetDataReliability(
2563
+ set_code=data.set_code,
2564
+ score=score,
2565
+ tier=_reliability_tier(score=score),
2566
+ )
2567
+
2568
+
2569
+ def _normalized_reliability_sample_depth(
2570
+ *,
2571
+ samples: list[int],
2572
+ minimum: int,
2573
+ ) -> float:
2574
+ if not samples:
2575
+ return 0.0
2576
+
2577
+ sample_median = float(median(samples))
2578
+ if sample_median <= minimum:
2579
+ return 0.0
2580
+
2581
+ if minimum >= RELIABILITY_SAMPLE_DEPTH_CAP:
2582
+ return 1.0
2583
+
2584
+ depth = math.log(sample_median / minimum) / math.log(
2585
+ RELIABILITY_SAMPLE_DEPTH_CAP / minimum
2586
+ )
2587
+ return min(max(depth, 0.0), 1.0)
2588
+
2589
+
2590
+ def _reliability_tier(*, score: int) -> str:
2591
+ if score >= RELIABILITY_VERY_HIGH_MINIMUM:
2592
+ return "Very high"
2593
+ if score >= RELIABILITY_HIGH_MINIMUM:
2594
+ return "High"
2595
+ if score >= RELIABILITY_MEDIUM_MINIMUM:
2596
+ return "Medium"
2597
+ if score > RELIABILITY_LOW_MAXIMUM:
2598
+ return "Low"
2599
+
2600
+ return "Very low"
2601
+
2602
+
2603
+ def _default_fetch_json(url: str, timeout_seconds: int) -> Any:
2604
+ request = urllib.request.Request(
2605
+ url,
2606
+ headers={
2607
+ "Accept": "application/json;q=0.9,*/*;q=0.8",
2608
+ "User-Agent": SEVENTEEN_LANDS_USER_AGENT,
2609
+ },
2610
+ )
2611
+ try:
2612
+ with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
2613
+ return json.loads(response.read().decode("utf-8"))
2614
+ except urllib.error.URLError as error:
2615
+ raise SeventeenLandsError(f"Failed to query 17Lands data: {error}") from error
2616
+ except json.JSONDecodeError as error:
2617
+ raise SeventeenLandsError(f"Malformed 17Lands JSON response: {error}") from error
2618
+
2619
+
2620
+ def _report_download_progress(
2621
+ *,
2622
+ callback: DownloadProgressCallback | None,
2623
+ completed_requests: int,
2624
+ total_requests: int,
2625
+ message: str,
2626
+ ) -> None:
2627
+ if callback is None:
2628
+ return
2629
+
2630
+ callback(
2631
+ SeventeenLandsDownloadProgress(
2632
+ completed_requests=completed_requests,
2633
+ total_requests=total_requests,
2634
+ message=message,
2635
+ )
2636
+ )
2637
+
2638
+
2639
+ def _url_with_query(*, endpoint: str, params: Mapping[str, str]) -> str:
2640
+ return f"{endpoint}?{urllib.parse.urlencode(params)}"
2641
+
2642
+
2643
+ def _format_cache_path(
2644
+ *,
2645
+ set_code: str,
2646
+ event_format: str,
2647
+ app_dir: PathInput | None,
2648
+ cache_path: PathInput | None,
2649
+ ) -> Path:
2650
+ if cache_path is not None:
2651
+ return Path(cache_path)
2652
+
2653
+ return seventeen_lands_cache_path(
2654
+ set_code=set_code,
2655
+ event_format=event_format,
2656
+ app_dir=app_dir,
2657
+ )
2658
+
2659
+
2660
+ def _is_stale(*, fetched_at: datetime, now: datetime, cache_ttl: timedelta) -> bool:
2661
+ return now.astimezone(UTC) - fetched_at.astimezone(UTC) >= cache_ttl
2662
+
2663
+
2664
+ def _cache_ttl(*, cache_ttl: timedelta | None) -> timedelta:
2665
+ if cache_ttl is not None:
2666
+ return cache_ttl
2667
+
2668
+ return timedelta(hours=RATINGS_CACHE_TTL_HOURS)
2669
+
2670
+
2671
+ def _now(*, clock: Clock | None) -> datetime:
2672
+ if clock is None:
2673
+ return datetime.now(tz=UTC)
2674
+
2675
+ return clock().astimezone(UTC)
2676
+
2677
+
2678
+ def _required_datetime(value: Any, *, field_name: str) -> datetime:
2679
+ text = _required_str(value, field_name=field_name)
2680
+ try:
2681
+ parsed = datetime.fromisoformat(text)
2682
+ except ValueError as error:
2683
+ raise SeventeenLandsError(
2684
+ f"Missing or invalid {field_name}; expected ISO datetime."
2685
+ ) from error
2686
+
2687
+ if parsed.tzinfo is None:
2688
+ return parsed.replace(tzinfo=UTC)
2689
+
2690
+ return parsed.astimezone(UTC)
2691
+
2692
+
2693
+ def _required_pair(value: Any, *, field_name: str) -> str:
2694
+ pair = _required_str(value, field_name=field_name)
2695
+ if pair not in COLOR_PAIRS:
2696
+ raise SeventeenLandsError(
2697
+ f"Missing or invalid {field_name}; expected a two-color pair."
2698
+ )
2699
+
2700
+ return pair
2701
+
2702
+
2703
+ def _path_segment(value: str) -> str:
2704
+ if value in {"", ".", ".."}:
2705
+ raise SeventeenLandsError("Invalid 17Lands cache path segment.")
2706
+
2707
+ return value.replace("/", "_").replace("\\", "_").replace(":", "_")
2708
+
2709
+
2710
+ def _ensure_non_negative(*, value: int, field_name: str) -> None:
2711
+ if value < 0:
2712
+ raise SeventeenLandsError(f"Missing or invalid {field_name}; expected >= 0.")
2713
+
2714
+
2715
+ def _required_str(value: Any, *, field_name: str) -> str:
2716
+ if not isinstance(value, str) or value == "":
2717
+ raise SeventeenLandsError(
2718
+ f"Missing or invalid {field_name}; expected non-empty string."
2719
+ )
2720
+
2721
+ return value
2722
+
2723
+
2724
+ def _optional_str(value: Any, *, field_name: str) -> str | None:
2725
+ if value is None:
2726
+ return None
2727
+
2728
+ return _required_str(value, field_name=field_name)
2729
+
2730
+
2731
+ def _required_int(value: Any, *, field_name: str) -> int:
2732
+ if isinstance(value, bool):
2733
+ raise SeventeenLandsError(f"Missing or invalid {field_name}; expected integer.")
2734
+
2735
+ try:
2736
+ return int(value)
2737
+ except (TypeError, ValueError) as error:
2738
+ raise SeventeenLandsError(
2739
+ f"Missing or invalid {field_name}; expected integer."
2740
+ ) from error
2741
+
2742
+
2743
+ def _optional_int(value: Any, *, field_name: str) -> int | None:
2744
+ if value is None:
2745
+ return None
2746
+
2747
+ return _required_int(value, field_name=field_name)
2748
+
2749
+
2750
+ def _required_float(value: Any, *, field_name: str) -> float:
2751
+ if isinstance(value, bool):
2752
+ raise SeventeenLandsError(f"Missing or invalid {field_name}; expected number.")
2753
+
2754
+ try:
2755
+ return float(value)
2756
+ except (TypeError, ValueError) as error:
2757
+ raise SeventeenLandsError(
2758
+ f"Missing or invalid {field_name}; expected number."
2759
+ ) from error
2760
+
2761
+
2762
+ def _optional_float(value: Any, *, field_name: str) -> float | None:
2763
+ if value is None:
2764
+ return None
2765
+
2766
+ return _required_float(value, field_name=field_name)