draftomen 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- draftomen/__init__.py +17 -0
- draftomen/assets/draftomen.icns +0 -0
- draftomen/assets/draftomen.ico +0 -0
- draftomen/assets/draftomen_logo.png +0 -0
- draftomen/audit.py +606 -0
- draftomen/backtest.py +449 -0
- draftomen/benchmark.py +834 -0
- draftomen/carddb.py +1869 -0
- draftomen/cardimages.py +313 -0
- draftomen/cli.py +891 -0
- draftomen/config.py +133 -0
- draftomen/deckbuilder.py +2723 -0
- draftomen/events.py +772 -0
- draftomen/logfollow.py +451 -0
- draftomen/mock_session.py +745 -0
- draftomen/paths.py +120 -0
- draftomen/pickengine.py +1117 -0
- draftomen/pool.py +1259 -0
- draftomen/preferences.py +289 -0
- draftomen/qml/AboutDialog.qml +156 -0
- draftomen/qml/AppBar.qml +120 -0
- draftomen/qml/BacktestView.qml +316 -0
- draftomen/qml/BuildView.qml +1151 -0
- draftomen/qml/CardPreview.qml +434 -0
- draftomen/qml/DimensionalButton.qml +42 -0
- draftomen/qml/DimensionalComboBox.qml +182 -0
- draftomen/qml/DimensionalSurface.qml +118 -0
- draftomen/qml/DimensionalTabButton.qml +41 -0
- draftomen/qml/LiveDraftView.qml +468 -0
- draftomen/qml/Main.qml +175 -0
- draftomen/qml/NavigationRail.qml +226 -0
- draftomen/qml/PoolSummaryPanel.qml +322 -0
- draftomen/qml/PrivacyDialog.qml +96 -0
- draftomen/qml/RecentPickThumbnail.qml +83 -0
- draftomen/qml/RecentPicksGallery.qml +333 -0
- draftomen/qml/RecommendationRow.qml +404 -0
- draftomen/qml/SettingsSwitch.qml +141 -0
- draftomen/qml/SettingsView.qml +531 -0
- draftomen/qml/StateBanner.qml +189 -0
- draftomen/qml/StatusStrip.qml +84 -0
- draftomen/qml/Theme.qml +71 -0
- draftomen/qml/qmldir +23 -0
- draftomen/qt_adapter.py +884 -0
- draftomen/qt_gui.py +338 -0
- draftomen/qt_mock.py +63 -0
- draftomen/ranking.py +126 -0
- draftomen/replay.py +480 -0
- draftomen/session.py +3668 -0
- draftomen/setinfo.py +26 -0
- draftomen/seventeen.py +2766 -0
- draftomen/splash.py +617 -0
- draftomen/tui.py +4007 -0
- draftomen/watch.py +336 -0
- draftomen-0.3.0.dist-info/METADATA +156 -0
- draftomen-0.3.0.dist-info/RECORD +59 -0
- draftomen-0.3.0.dist-info/WHEEL +5 -0
- draftomen-0.3.0.dist-info/entry_points.txt +6 -0
- draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
- draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/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)
|