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/pickengine.py
ADDED
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
"""Card scoring and ranked pack recommendations.
|
|
2
|
+
Keep pick-quality math isolated from CLI and TUI rendering code.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from functools import cmp_to_key
|
|
10
|
+
|
|
11
|
+
from draftomen.carddb import CardDatabase, CardInfo
|
|
12
|
+
from draftomen.config import COLOR_PAIRS, PICK_ENGINE, SPLASH, PickEngineConfig
|
|
13
|
+
from draftomen.seventeen import (
|
|
14
|
+
FORMAT_RATING_SOURCE,
|
|
15
|
+
NEUTRAL_PRIOR_SOURCE,
|
|
16
|
+
PREMIER_DRAFT_FORMAT,
|
|
17
|
+
QUICK_DRAFT_FORMAT,
|
|
18
|
+
RatingSampleCounts,
|
|
19
|
+
RatingSourceMetadata,
|
|
20
|
+
ResolvedCardRating,
|
|
21
|
+
SeventeenLandsData,
|
|
22
|
+
)
|
|
23
|
+
from draftomen.splash import (
|
|
24
|
+
SplashAssessment,
|
|
25
|
+
SplashState,
|
|
26
|
+
assess_splash_card,
|
|
27
|
+
card_is_castable_in_pair,
|
|
28
|
+
infer_splash_state,
|
|
29
|
+
)
|
|
30
|
+
CLOSE_DO_SCORE_THRESHOLD = 3.0
|
|
31
|
+
CLOSE_WIN_RATE_THRESHOLD = 0.01
|
|
32
|
+
STANDARD_BASIC_LAND_NAMES = frozenset(
|
|
33
|
+
{"Plains", "Island", "Swamp", "Mountain", "Forest"}
|
|
34
|
+
)
|
|
35
|
+
STANDARD_BASIC_LAND_TYPE_LINES = frozenset(
|
|
36
|
+
f"Basic Land — {name}" for name in STANDARD_BASIC_LAND_NAMES
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def recommendation_confidence_summary(
|
|
41
|
+
*,
|
|
42
|
+
cards: tuple[ScoredCard, ...],
|
|
43
|
+
ranking_mode: str,
|
|
44
|
+
phase: str,
|
|
45
|
+
) -> str | None:
|
|
46
|
+
"""Summarize how decisive the current recommendation evidence is.
|
|
47
|
+
The input cards must already be ordered by ``ranking_mode``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
close_label = _close_pick_label(cards=cards, ranking_mode=ranking_mode)
|
|
51
|
+
if phase == "open":
|
|
52
|
+
if close_label is not None:
|
|
53
|
+
return f"early/open {close_label}; stay flexible"
|
|
54
|
+
|
|
55
|
+
return "early/open pick — stay flexible"
|
|
56
|
+
|
|
57
|
+
return close_label
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def recommendation_explanation(
|
|
61
|
+
*,
|
|
62
|
+
scored_card: ScoredCard,
|
|
63
|
+
inferred_pair: str | None,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Describe one recommendation using only scoring and pool evidence.
|
|
66
|
+
Wording describes supporting evidence rather than promising an outcome.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
if scored_card.freely_available_basic:
|
|
70
|
+
return (
|
|
71
|
+
f"{scored_card.card.name} is freely available during deck building, "
|
|
72
|
+
"so it receives 0 DO points and ranks after draftable cards."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
fit = scored_card.color_fit.replace("-", " ")
|
|
76
|
+
pool_context = (
|
|
77
|
+
f"the inferred {inferred_pair} pool"
|
|
78
|
+
if inferred_pair is not None
|
|
79
|
+
else "an open-color pool"
|
|
80
|
+
)
|
|
81
|
+
gih_win_rate = scored_card.rating.gih_win_rate
|
|
82
|
+
alsa = scored_card.rating.average_last_seen_at
|
|
83
|
+
if gih_win_rate is not None:
|
|
84
|
+
rating_evidence = (
|
|
85
|
+
f"{scored_card.source_label} GIH win rate "
|
|
86
|
+
f"{gih_win_rate:.1%}"
|
|
87
|
+
)
|
|
88
|
+
elif scored_card.no_data and alsa is not None:
|
|
89
|
+
rating_evidence = (
|
|
90
|
+
f"neutral-prior estimate adjusted by ALSA "
|
|
91
|
+
f"{alsa:.2f}"
|
|
92
|
+
)
|
|
93
|
+
elif scored_card.no_data:
|
|
94
|
+
rating_evidence = "neutral-prior estimate with no GIH data"
|
|
95
|
+
else:
|
|
96
|
+
rating_evidence = f"{scored_card.source_label} rating data"
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
f"{fit.capitalize()} fit supports a {scored_card.score} DO-point "
|
|
100
|
+
f"candidate for {pool_context}; {rating_evidence} informs the score."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _close_pick_label(
|
|
105
|
+
*,
|
|
106
|
+
cards: tuple[ScoredCard, ...],
|
|
107
|
+
ranking_mode: str,
|
|
108
|
+
) -> str | None:
|
|
109
|
+
draftable_cards = tuple(
|
|
110
|
+
card for card in cards if not card.freely_available_basic
|
|
111
|
+
)
|
|
112
|
+
if len(draftable_cards) < 2:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
top_card, second_card = draftable_cards[:2]
|
|
116
|
+
if ranking_mode == "score":
|
|
117
|
+
score_delta = max(0.0, top_card.raw_score - second_card.raw_score)
|
|
118
|
+
if score_delta <= CLOSE_DO_SCORE_THRESHOLD:
|
|
119
|
+
return f"close pick — top two within {score_delta:.1f} DO points"
|
|
120
|
+
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
if ranking_mode == "win_rate":
|
|
124
|
+
top_win_rate = top_card.rating.gih_win_rate
|
|
125
|
+
second_win_rate = second_card.rating.gih_win_rate
|
|
126
|
+
if top_win_rate is None or second_win_rate is None:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
win_rate_delta = max(0.0, top_win_rate - second_win_rate)
|
|
130
|
+
if win_rate_delta <= CLOSE_WIN_RATE_THRESHOLD:
|
|
131
|
+
return f"close pick — top two within {win_rate_delta * 100:.1f}pp WR"
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class ScoreNormalization:
|
|
138
|
+
"""Rating bounds used to normalize raw card ratings.
|
|
139
|
+
The neutral prior is centered at score 50 by construction.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
lower_rating: float
|
|
143
|
+
upper_rating: float
|
|
144
|
+
neutral_rating: float
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class ColorCommitment:
|
|
149
|
+
"""Color inference for the current pick.
|
|
150
|
+
The level is a 0.0-1.0 ramp from open to locked.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
pick_index: int
|
|
154
|
+
pool_size: int
|
|
155
|
+
color_weights: tuple[tuple[str, float], ...]
|
|
156
|
+
inferred_pair: str | None
|
|
157
|
+
level: float
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def locked(self) -> bool:
|
|
161
|
+
"""Return whether commitment is fully locked.
|
|
162
|
+
Locked picks may use pair-filtered ratings when available.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
return self.level >= 1.0
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def phase(self) -> str:
|
|
169
|
+
"""Return a compact label for status-line rendering.
|
|
170
|
+
This keeps CLI and TUI language consistent.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
if self.level <= 0.0:
|
|
174
|
+
return "open"
|
|
175
|
+
|
|
176
|
+
if self.locked:
|
|
177
|
+
return "locked"
|
|
178
|
+
|
|
179
|
+
return "building"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, slots=True)
|
|
183
|
+
class ScoredCard:
|
|
184
|
+
"""One offered card with its computed pick score.
|
|
185
|
+
Rows keep source metadata so renderers can mark fallbacks clearly.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
card: CardInfo
|
|
189
|
+
rating: ResolvedCardRating
|
|
190
|
+
original_index: int
|
|
191
|
+
base_rating: float
|
|
192
|
+
base_score: float
|
|
193
|
+
color_factor: float
|
|
194
|
+
adjusted_rating: float
|
|
195
|
+
raw_score: float
|
|
196
|
+
score: int
|
|
197
|
+
source_label: str
|
|
198
|
+
color_fit: str
|
|
199
|
+
pair_tiebreaker_pair: str | None
|
|
200
|
+
pair_tiebreaker_win_rate: float | None
|
|
201
|
+
pair_tiebreaker_weight: float | None
|
|
202
|
+
score_sort_index: int
|
|
203
|
+
splash: SplashAssessment
|
|
204
|
+
freely_available_basic: bool
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def no_data(self) -> bool:
|
|
208
|
+
"""Return whether this row used the neutral-prior fallback.
|
|
209
|
+
Renderers mark these cards because no strong GIH sample was available.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
return self.rating.neutral_prior and not self.freely_available_basic
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def prior_adjusted_by_alsa(self) -> bool:
|
|
216
|
+
"""Return whether ALSA moved the neutral prior up or down.
|
|
217
|
+
Missing ALSA leaves the neutral prior exactly centered.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
return self.no_data and self.rating.average_last_seen_at is not None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class ScoredPack:
|
|
225
|
+
"""A recommendation-ordered view of one offered pack.
|
|
226
|
+
Source summary describes the actual data used in this pack.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
cards: tuple[ScoredCard, ...]
|
|
230
|
+
normalization: ScoreNormalization
|
|
231
|
+
source_summary: str
|
|
232
|
+
commitment: ColorCommitment
|
|
233
|
+
splash_state: SplashState
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class PickEngine:
|
|
237
|
+
"""Score offered cards using 17Lands data and configured priors.
|
|
238
|
+
Pool color weights progressively bias scores toward an inferred pair.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
245
|
+
config: PickEngineConfig = PICK_ENGINE,
|
|
246
|
+
splash_enabled: bool = SPLASH.enabled_by_default,
|
|
247
|
+
) -> None:
|
|
248
|
+
self.ratings_data = ratings_data
|
|
249
|
+
self.config = config
|
|
250
|
+
self.splash_enabled = splash_enabled
|
|
251
|
+
self.normalization = _normalization_from_data(
|
|
252
|
+
ratings_data=ratings_data,
|
|
253
|
+
config=config,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def score_pack(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
offered_grp_ids: tuple[int, ...],
|
|
260
|
+
card_database: CardDatabase,
|
|
261
|
+
pool_grp_ids: tuple[int, ...] = (),
|
|
262
|
+
pick_index: int | None = None,
|
|
263
|
+
) -> ScoredPack:
|
|
264
|
+
"""Return offered cards in recommendation order.
|
|
265
|
+
Open close-pick groups can use pair win rates as a final tiebreaker.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
commitment = _color_commitment(
|
|
269
|
+
pool_grp_ids=pool_grp_ids,
|
|
270
|
+
pick_index=_pick_index(pool_grp_ids=pool_grp_ids, pick_index=pick_index),
|
|
271
|
+
card_database=card_database,
|
|
272
|
+
ratings_data=self.ratings_data,
|
|
273
|
+
config=self.config,
|
|
274
|
+
)
|
|
275
|
+
splash_state = infer_splash_state(
|
|
276
|
+
pool_grp_ids=pool_grp_ids,
|
|
277
|
+
card_database=card_database,
|
|
278
|
+
ratings_data=self.ratings_data,
|
|
279
|
+
base_pair=(
|
|
280
|
+
commitment.inferred_pair
|
|
281
|
+
if commitment.level > 0.0
|
|
282
|
+
else None
|
|
283
|
+
),
|
|
284
|
+
enabled=self.splash_enabled,
|
|
285
|
+
)
|
|
286
|
+
best_on_color_score = self._best_on_color_score(
|
|
287
|
+
offered_grp_ids=offered_grp_ids,
|
|
288
|
+
card_database=card_database,
|
|
289
|
+
commitment=commitment,
|
|
290
|
+
)
|
|
291
|
+
scored_cards = tuple(
|
|
292
|
+
self._score_card(
|
|
293
|
+
grp_id=grp_id,
|
|
294
|
+
original_index=index,
|
|
295
|
+
card_database=card_database,
|
|
296
|
+
ratings_data=self.ratings_data,
|
|
297
|
+
commitment=commitment,
|
|
298
|
+
splash_state=splash_state,
|
|
299
|
+
best_on_color_score=best_on_color_score,
|
|
300
|
+
)
|
|
301
|
+
for index, grp_id in enumerate(offered_grp_ids)
|
|
302
|
+
)
|
|
303
|
+
sorted_cards = _score_sorted_cards(
|
|
304
|
+
cards=scored_cards,
|
|
305
|
+
commitment=commitment,
|
|
306
|
+
ratings_data=self.ratings_data,
|
|
307
|
+
offered_count=len(offered_grp_ids),
|
|
308
|
+
config=self.config,
|
|
309
|
+
)
|
|
310
|
+
return ScoredPack(
|
|
311
|
+
cards=sorted_cards,
|
|
312
|
+
normalization=self.normalization,
|
|
313
|
+
source_summary=_source_summary(cards=sorted_cards),
|
|
314
|
+
commitment=commitment,
|
|
315
|
+
splash_state=splash_state,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _best_on_color_score(
|
|
319
|
+
self,
|
|
320
|
+
*,
|
|
321
|
+
offered_grp_ids: tuple[int, ...],
|
|
322
|
+
card_database: CardDatabase,
|
|
323
|
+
commitment: ColorCommitment,
|
|
324
|
+
) -> float | None:
|
|
325
|
+
pair = commitment.inferred_pair
|
|
326
|
+
if pair is None:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
scores: list[float] = []
|
|
330
|
+
for grp_id in offered_grp_ids:
|
|
331
|
+
card = card_database.lookup(grp_id=grp_id)
|
|
332
|
+
if card.unknown or _is_freely_available_basic_land(card=card):
|
|
333
|
+
continue
|
|
334
|
+
if not card_is_castable_in_pair(card=card, base_pair=pair):
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
rating = _rating_for(
|
|
338
|
+
ratings_data=self.ratings_data,
|
|
339
|
+
grp_id=grp_id,
|
|
340
|
+
config=self.config,
|
|
341
|
+
commitment=commitment,
|
|
342
|
+
)
|
|
343
|
+
scores.append(
|
|
344
|
+
_normalized_score(
|
|
345
|
+
adjusted_rating=_base_rating(rating=rating, config=self.config),
|
|
346
|
+
normalization=self.normalization,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return max(scores, default=None)
|
|
351
|
+
|
|
352
|
+
def _score_card(
|
|
353
|
+
self,
|
|
354
|
+
*,
|
|
355
|
+
grp_id: int,
|
|
356
|
+
original_index: int,
|
|
357
|
+
card_database: CardDatabase,
|
|
358
|
+
ratings_data: SeventeenLandsData | None,
|
|
359
|
+
commitment: ColorCommitment,
|
|
360
|
+
splash_state: SplashState,
|
|
361
|
+
best_on_color_score: float | None,
|
|
362
|
+
) -> ScoredCard:
|
|
363
|
+
card = card_database.lookup(grp_id=grp_id)
|
|
364
|
+
freely_available_basic = _is_freely_available_basic_land(card=card)
|
|
365
|
+
rating = _rating_for(
|
|
366
|
+
ratings_data=self.ratings_data,
|
|
367
|
+
grp_id=grp_id,
|
|
368
|
+
config=self.config,
|
|
369
|
+
commitment=commitment,
|
|
370
|
+
)
|
|
371
|
+
base_rating = (
|
|
372
|
+
self.normalization.lower_rating
|
|
373
|
+
if freely_available_basic
|
|
374
|
+
else _base_rating(rating=rating, config=self.config)
|
|
375
|
+
)
|
|
376
|
+
base_score = _normalized_score(
|
|
377
|
+
adjusted_rating=base_rating,
|
|
378
|
+
normalization=self.normalization,
|
|
379
|
+
)
|
|
380
|
+
global_rating = _rating_for(
|
|
381
|
+
ratings_data=self.ratings_data,
|
|
382
|
+
grp_id=grp_id,
|
|
383
|
+
config=self.config,
|
|
384
|
+
)
|
|
385
|
+
splash = assess_splash_card(
|
|
386
|
+
card=card,
|
|
387
|
+
grade=global_rating.letter_grade,
|
|
388
|
+
base_score=base_score,
|
|
389
|
+
best_on_color_score=best_on_color_score,
|
|
390
|
+
locked=commitment.locked,
|
|
391
|
+
state=splash_state,
|
|
392
|
+
)
|
|
393
|
+
color_fit = splash.classification
|
|
394
|
+
color_factor = _color_factor(
|
|
395
|
+
color_fit=color_fit,
|
|
396
|
+
commitment=commitment,
|
|
397
|
+
config=self.config,
|
|
398
|
+
)
|
|
399
|
+
raw_score = _clamp(
|
|
400
|
+
value=base_score * color_factor,
|
|
401
|
+
lower=0.0,
|
|
402
|
+
upper=100.0,
|
|
403
|
+
)
|
|
404
|
+
if freely_available_basic:
|
|
405
|
+
pair_tiebreaker_pair = None
|
|
406
|
+
pair_tiebreaker_win_rate = None
|
|
407
|
+
pair_tiebreaker_weight = None
|
|
408
|
+
else:
|
|
409
|
+
(
|
|
410
|
+
pair_tiebreaker_pair,
|
|
411
|
+
pair_tiebreaker_win_rate,
|
|
412
|
+
pair_tiebreaker_weight,
|
|
413
|
+
) = _pair_tiebreaker_for_card(
|
|
414
|
+
card=card,
|
|
415
|
+
base_rating=base_rating,
|
|
416
|
+
commitment=commitment,
|
|
417
|
+
ratings_data=ratings_data,
|
|
418
|
+
config=self.config,
|
|
419
|
+
)
|
|
420
|
+
return ScoredCard(
|
|
421
|
+
card=card,
|
|
422
|
+
rating=rating,
|
|
423
|
+
original_index=original_index,
|
|
424
|
+
base_rating=base_rating,
|
|
425
|
+
base_score=base_score,
|
|
426
|
+
color_factor=color_factor,
|
|
427
|
+
adjusted_rating=base_rating * color_factor,
|
|
428
|
+
raw_score=raw_score,
|
|
429
|
+
score=_integer_score(raw_score=raw_score),
|
|
430
|
+
source_label=(
|
|
431
|
+
"Basic"
|
|
432
|
+
if freely_available_basic
|
|
433
|
+
else _source_label(rating=rating)
|
|
434
|
+
),
|
|
435
|
+
color_fit=color_fit,
|
|
436
|
+
pair_tiebreaker_pair=pair_tiebreaker_pair,
|
|
437
|
+
pair_tiebreaker_win_rate=pair_tiebreaker_win_rate,
|
|
438
|
+
pair_tiebreaker_weight=pair_tiebreaker_weight,
|
|
439
|
+
score_sort_index=original_index,
|
|
440
|
+
splash=splash,
|
|
441
|
+
freely_available_basic=freely_available_basic,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def score_pack(
|
|
446
|
+
*,
|
|
447
|
+
offered_grp_ids: tuple[int, ...],
|
|
448
|
+
card_database: CardDatabase,
|
|
449
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
450
|
+
config: PickEngineConfig = PICK_ENGINE,
|
|
451
|
+
pool_grp_ids: tuple[int, ...] = (),
|
|
452
|
+
pick_index: int | None = None,
|
|
453
|
+
splash_enabled: bool = SPLASH.enabled_by_default,
|
|
454
|
+
) -> ScoredPack:
|
|
455
|
+
"""Convenience wrapper for callers that do not keep an engine instance.
|
|
456
|
+
The reusable PickEngine class avoids rebuilding normalization per pack.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
return PickEngine(
|
|
460
|
+
ratings_data=ratings_data,
|
|
461
|
+
config=config,
|
|
462
|
+
splash_enabled=splash_enabled,
|
|
463
|
+
).score_pack(
|
|
464
|
+
offered_grp_ids=offered_grp_ids,
|
|
465
|
+
card_database=card_database,
|
|
466
|
+
pool_grp_ids=pool_grp_ids,
|
|
467
|
+
pick_index=pick_index,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _pick_index(
|
|
472
|
+
*,
|
|
473
|
+
pool_grp_ids: tuple[int, ...],
|
|
474
|
+
pick_index: int | None,
|
|
475
|
+
) -> int:
|
|
476
|
+
if pick_index is not None:
|
|
477
|
+
return max(1, pick_index)
|
|
478
|
+
|
|
479
|
+
return len(pool_grp_ids) + 1
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _color_commitment(
|
|
483
|
+
*,
|
|
484
|
+
pool_grp_ids: tuple[int, ...],
|
|
485
|
+
pick_index: int,
|
|
486
|
+
card_database: CardDatabase,
|
|
487
|
+
ratings_data: SeventeenLandsData | None,
|
|
488
|
+
config: PickEngineConfig,
|
|
489
|
+
) -> ColorCommitment:
|
|
490
|
+
weights = _pool_color_weights(
|
|
491
|
+
pool_grp_ids=pool_grp_ids,
|
|
492
|
+
card_database=card_database,
|
|
493
|
+
ratings_data=ratings_data,
|
|
494
|
+
config=config,
|
|
495
|
+
)
|
|
496
|
+
inferred_pair = _inferred_pair(weights=weights, config=config)
|
|
497
|
+
level = _commitment_level(pick_index=pick_index, config=config)
|
|
498
|
+
if inferred_pair is None:
|
|
499
|
+
level = 0.0
|
|
500
|
+
|
|
501
|
+
return ColorCommitment(
|
|
502
|
+
pick_index=pick_index,
|
|
503
|
+
pool_size=len(pool_grp_ids),
|
|
504
|
+
color_weights=tuple((color, weights[color]) for color in weights),
|
|
505
|
+
inferred_pair=inferred_pair,
|
|
506
|
+
level=level,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _pool_color_weights(
|
|
511
|
+
*,
|
|
512
|
+
pool_grp_ids: tuple[int, ...],
|
|
513
|
+
card_database: CardDatabase,
|
|
514
|
+
ratings_data: SeventeenLandsData | None,
|
|
515
|
+
config: PickEngineConfig,
|
|
516
|
+
) -> dict[str, float]:
|
|
517
|
+
weights = _empty_color_weights()
|
|
518
|
+
for grp_id in pool_grp_ids:
|
|
519
|
+
card = card_database.lookup(grp_id=grp_id)
|
|
520
|
+
if card.unknown or not card.colors:
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
rating = _rating_for(
|
|
524
|
+
ratings_data=ratings_data,
|
|
525
|
+
grp_id=grp_id,
|
|
526
|
+
config=config,
|
|
527
|
+
)
|
|
528
|
+
base_rating = _base_rating(rating=rating, config=config)
|
|
529
|
+
weight = _pool_card_weight(base_rating=base_rating, config=config)
|
|
530
|
+
for color in card.colors:
|
|
531
|
+
if color in weights:
|
|
532
|
+
weights[color] += weight
|
|
533
|
+
|
|
534
|
+
return weights
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _empty_color_weights() -> dict[str, float]:
|
|
538
|
+
colors: dict[str, float] = {}
|
|
539
|
+
for pair in COLOR_PAIRS:
|
|
540
|
+
for color in pair:
|
|
541
|
+
colors.setdefault(color, 0.0)
|
|
542
|
+
|
|
543
|
+
return colors
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _pool_card_weight(*, base_rating: float, config: PickEngineConfig) -> float:
|
|
547
|
+
lower = min(config.pool_weight_minimum, config.pool_weight_maximum)
|
|
548
|
+
upper = max(config.pool_weight_minimum, config.pool_weight_maximum)
|
|
549
|
+
rating_delta = base_rating - config.neutral_prior_win_rate
|
|
550
|
+
weight = config.pool_weight_baseline + (rating_delta * config.pool_weight_rating_scale)
|
|
551
|
+
return _clamp(value=weight, lower=lower, upper=upper)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _inferred_pair(
|
|
555
|
+
*,
|
|
556
|
+
weights: dict[str, float],
|
|
557
|
+
config: PickEngineConfig,
|
|
558
|
+
) -> str | None:
|
|
559
|
+
positive_colors = tuple(
|
|
560
|
+
color for color, weight in weights.items() if weight > config.pool_weight_epsilon
|
|
561
|
+
)
|
|
562
|
+
if len(positive_colors) < config.minimum_pair_colors:
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
return max(COLOR_PAIRS, key=lambda pair: _pair_weight(pair=pair, weights=weights))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _pair_weight(*, pair: str, weights: dict[str, float]) -> float:
|
|
569
|
+
return sum(weights.get(color, 0.0) for color in pair)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _commitment_level(*, pick_index: int, config: PickEngineConfig) -> float:
|
|
573
|
+
if pick_index <= config.open_pick_count:
|
|
574
|
+
return 0.0
|
|
575
|
+
|
|
576
|
+
ramp_start = max(config.commitment_start_pick, config.open_pick_count + 1)
|
|
577
|
+
if pick_index < ramp_start:
|
|
578
|
+
return 0.0
|
|
579
|
+
|
|
580
|
+
if pick_index >= config.locked_pick_index:
|
|
581
|
+
return 1.0
|
|
582
|
+
|
|
583
|
+
ramp_span = config.locked_pick_index - ramp_start + 1
|
|
584
|
+
if ramp_span <= 0:
|
|
585
|
+
return 1.0
|
|
586
|
+
|
|
587
|
+
return _clamp(
|
|
588
|
+
value=(pick_index - ramp_start + 1) / ramp_span,
|
|
589
|
+
lower=0.0,
|
|
590
|
+
upper=1.0,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _color_factor(
|
|
595
|
+
*,
|
|
596
|
+
color_fit: str,
|
|
597
|
+
commitment: ColorCommitment,
|
|
598
|
+
config: PickEngineConfig,
|
|
599
|
+
) -> float:
|
|
600
|
+
if commitment.level <= 0.0 or color_fit in {"open", "colorless", "unknown"}:
|
|
601
|
+
return 1.0
|
|
602
|
+
|
|
603
|
+
if color_fit == "on-color":
|
|
604
|
+
factor = 1.0 + (commitment.level * (config.on_color_bonus_multiplier - 1.0))
|
|
605
|
+
return max(0.0, factor)
|
|
606
|
+
|
|
607
|
+
if color_fit == "off-color":
|
|
608
|
+
penalty = 1.0 - config.off_color_penalty_multiplier
|
|
609
|
+
factor = 1.0 - (commitment.level * penalty)
|
|
610
|
+
return max(0.0, factor)
|
|
611
|
+
|
|
612
|
+
if color_fit == "splash-ready":
|
|
613
|
+
penalty = 1.0 - SPLASH.ready_score_multiplier
|
|
614
|
+
factor = 1.0 - (commitment.level * penalty)
|
|
615
|
+
return max(0.0, factor)
|
|
616
|
+
|
|
617
|
+
if color_fit == "splash-speculative":
|
|
618
|
+
penalty = 1.0 - SPLASH.speculative_score_multiplier
|
|
619
|
+
factor = 1.0 - (commitment.level * penalty)
|
|
620
|
+
return max(0.0, factor)
|
|
621
|
+
|
|
622
|
+
if color_fit == "splash-fixer":
|
|
623
|
+
bonus = SPLASH.fixer_score_multiplier - 1.0
|
|
624
|
+
factor = 1.0 + (commitment.level * bonus)
|
|
625
|
+
return max(0.0, factor)
|
|
626
|
+
|
|
627
|
+
return 1.0
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _score_sorted_cards(
|
|
631
|
+
*,
|
|
632
|
+
cards: tuple[ScoredCard, ...],
|
|
633
|
+
commitment: ColorCommitment,
|
|
634
|
+
ratings_data: SeventeenLandsData | None,
|
|
635
|
+
offered_count: int,
|
|
636
|
+
config: PickEngineConfig,
|
|
637
|
+
) -> tuple[ScoredCard, ...]:
|
|
638
|
+
base_sorted = tuple(sorted(cards, key=_scored_card_base_sort_key))
|
|
639
|
+
if not _early_pair_tiebreaker_enabled(
|
|
640
|
+
commitment=commitment,
|
|
641
|
+
ratings_data=ratings_data,
|
|
642
|
+
offered_count=offered_count,
|
|
643
|
+
config=config,
|
|
644
|
+
):
|
|
645
|
+
return _with_score_sort_indexes(cards=base_sorted)
|
|
646
|
+
|
|
647
|
+
sorted_cards = _apply_early_pair_tiebreaker(cards=base_sorted, config=config)
|
|
648
|
+
return _with_score_sort_indexes(cards=sorted_cards)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _early_pair_tiebreaker_enabled(
|
|
652
|
+
*,
|
|
653
|
+
commitment: ColorCommitment,
|
|
654
|
+
ratings_data: SeventeenLandsData | None,
|
|
655
|
+
offered_count: int,
|
|
656
|
+
config: PickEngineConfig,
|
|
657
|
+
) -> bool:
|
|
658
|
+
if ratings_data is None or not ratings_data.pair_win_rates:
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
if commitment.level > 0.0 or commitment.pick_index > config.open_pick_count:
|
|
662
|
+
return False
|
|
663
|
+
|
|
664
|
+
if config.early_pair_tiebreaker_score_threshold <= 0.0:
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
if config.early_pair_tiebreaker_max_offered_cards <= 0:
|
|
668
|
+
return False
|
|
669
|
+
|
|
670
|
+
return offered_count <= config.early_pair_tiebreaker_max_offered_cards
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _apply_early_pair_tiebreaker(
|
|
674
|
+
*,
|
|
675
|
+
cards: tuple[ScoredCard, ...],
|
|
676
|
+
config: PickEngineConfig,
|
|
677
|
+
) -> tuple[ScoredCard, ...]:
|
|
678
|
+
sorted_cards: list[ScoredCard] = []
|
|
679
|
+
group: list[ScoredCard] = []
|
|
680
|
+
group_top_score = 0.0
|
|
681
|
+
for card in cards:
|
|
682
|
+
if not group:
|
|
683
|
+
group = [card]
|
|
684
|
+
group_top_score = card.raw_score
|
|
685
|
+
continue
|
|
686
|
+
|
|
687
|
+
score_delta = group_top_score - card.raw_score
|
|
688
|
+
if score_delta <= config.early_pair_tiebreaker_score_threshold:
|
|
689
|
+
group.append(card)
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
sorted_cards.extend(
|
|
693
|
+
_sort_early_pair_tiebreaker_group(group=group, config=config)
|
|
694
|
+
)
|
|
695
|
+
group = [card]
|
|
696
|
+
group_top_score = card.raw_score
|
|
697
|
+
|
|
698
|
+
if group:
|
|
699
|
+
sorted_cards.extend(
|
|
700
|
+
_sort_early_pair_tiebreaker_group(group=group, config=config)
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
return tuple(sorted_cards)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _sort_early_pair_tiebreaker_group(
|
|
707
|
+
*,
|
|
708
|
+
group: list[ScoredCard],
|
|
709
|
+
config: PickEngineConfig,
|
|
710
|
+
) -> tuple[ScoredCard, ...]:
|
|
711
|
+
return tuple(
|
|
712
|
+
sorted(
|
|
713
|
+
group,
|
|
714
|
+
key=cmp_to_key(
|
|
715
|
+
lambda left, right: _compare_early_pair_tiebreaker(
|
|
716
|
+
left=left,
|
|
717
|
+
right=right,
|
|
718
|
+
config=config,
|
|
719
|
+
),
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _compare_early_pair_tiebreaker(
|
|
726
|
+
*,
|
|
727
|
+
left: ScoredCard,
|
|
728
|
+
right: ScoredCard,
|
|
729
|
+
config: PickEngineConfig,
|
|
730
|
+
) -> int:
|
|
731
|
+
left_win_rate = left.pair_tiebreaker_win_rate
|
|
732
|
+
right_win_rate = right.pair_tiebreaker_win_rate
|
|
733
|
+
left_weight = left.pair_tiebreaker_weight
|
|
734
|
+
right_weight = right.pair_tiebreaker_weight
|
|
735
|
+
if (
|
|
736
|
+
left_win_rate is not None
|
|
737
|
+
and right_win_rate is not None
|
|
738
|
+
and left_weight is not None
|
|
739
|
+
and right_weight is not None
|
|
740
|
+
and abs(left.raw_score - right.raw_score)
|
|
741
|
+
<= config.early_pair_tiebreaker_score_threshold
|
|
742
|
+
and abs(left_weight - right_weight)
|
|
743
|
+
<= config.early_pair_tiebreaker_pair_weight_threshold
|
|
744
|
+
and left_win_rate != right_win_rate
|
|
745
|
+
):
|
|
746
|
+
return -1 if left_win_rate > right_win_rate else 1
|
|
747
|
+
|
|
748
|
+
return _compare_base_scored_cards(left=left, right=right)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _compare_base_scored_cards(*, left: ScoredCard, right: ScoredCard) -> int:
|
|
752
|
+
left_key = _scored_card_base_sort_key(left)
|
|
753
|
+
right_key = _scored_card_base_sort_key(right)
|
|
754
|
+
if left_key < right_key:
|
|
755
|
+
return -1
|
|
756
|
+
|
|
757
|
+
if left_key > right_key:
|
|
758
|
+
return 1
|
|
759
|
+
|
|
760
|
+
return 0
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _with_score_sort_indexes(*, cards: tuple[ScoredCard, ...]) -> tuple[ScoredCard, ...]:
|
|
764
|
+
return tuple(
|
|
765
|
+
replace(card, score_sort_index=index)
|
|
766
|
+
for index, card in enumerate(cards)
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _pair_tiebreaker_for_card(
|
|
771
|
+
*,
|
|
772
|
+
card: CardInfo,
|
|
773
|
+
base_rating: float,
|
|
774
|
+
commitment: ColorCommitment,
|
|
775
|
+
ratings_data: SeventeenLandsData | None,
|
|
776
|
+
config: PickEngineConfig,
|
|
777
|
+
) -> tuple[str | None, float | None, float | None]:
|
|
778
|
+
if ratings_data is None or not ratings_data.pair_win_rates:
|
|
779
|
+
return (None, None, None)
|
|
780
|
+
|
|
781
|
+
colors = _recognized_card_colors(card=card)
|
|
782
|
+
if not colors:
|
|
783
|
+
return (None, None, None)
|
|
784
|
+
|
|
785
|
+
compatible_pairs = _compatible_pairs_for_colors(colors=colors)
|
|
786
|
+
if not compatible_pairs:
|
|
787
|
+
return (None, None, None)
|
|
788
|
+
|
|
789
|
+
weights = _candidate_pair_weights(
|
|
790
|
+
colors=colors,
|
|
791
|
+
base_rating=base_rating,
|
|
792
|
+
commitment=commitment,
|
|
793
|
+
config=config,
|
|
794
|
+
)
|
|
795
|
+
pair = _best_tiebreaker_pair(
|
|
796
|
+
pairs=compatible_pairs,
|
|
797
|
+
weights=weights,
|
|
798
|
+
ratings_data=ratings_data,
|
|
799
|
+
config=config,
|
|
800
|
+
)
|
|
801
|
+
if pair is None:
|
|
802
|
+
return (None, None, None)
|
|
803
|
+
|
|
804
|
+
return (
|
|
805
|
+
pair,
|
|
806
|
+
_pair_win_rate(pair=pair, ratings_data=ratings_data),
|
|
807
|
+
_pair_weight(pair=pair, weights=weights),
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _recognized_card_colors(*, card: CardInfo) -> tuple[str, ...]:
|
|
812
|
+
color_order = tuple(_empty_color_weights())
|
|
813
|
+
colors: list[str] = []
|
|
814
|
+
for color in card.colors:
|
|
815
|
+
if color in color_order and color not in colors:
|
|
816
|
+
colors.append(color)
|
|
817
|
+
|
|
818
|
+
return tuple(colors)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _compatible_pairs_for_colors(*, colors: tuple[str, ...]) -> tuple[str, ...]:
|
|
822
|
+
color_set = set(colors)
|
|
823
|
+
return tuple(
|
|
824
|
+
pair for pair in COLOR_PAIRS if color_set.issubset(set(pair))
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _candidate_pair_weights(
|
|
829
|
+
*,
|
|
830
|
+
colors: tuple[str, ...],
|
|
831
|
+
base_rating: float,
|
|
832
|
+
commitment: ColorCommitment,
|
|
833
|
+
config: PickEngineConfig,
|
|
834
|
+
) -> dict[str, float]:
|
|
835
|
+
weights = dict(commitment.color_weights)
|
|
836
|
+
weight = _pool_card_weight(base_rating=base_rating, config=config)
|
|
837
|
+
for color in colors:
|
|
838
|
+
weights[color] = weights.get(color, 0.0) + weight
|
|
839
|
+
|
|
840
|
+
return weights
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _best_tiebreaker_pair(
|
|
844
|
+
*,
|
|
845
|
+
pairs: tuple[str, ...],
|
|
846
|
+
weights: dict[str, float],
|
|
847
|
+
ratings_data: SeventeenLandsData,
|
|
848
|
+
config: PickEngineConfig,
|
|
849
|
+
) -> str | None:
|
|
850
|
+
if not pairs:
|
|
851
|
+
return None
|
|
852
|
+
|
|
853
|
+
max_pair_weight = max(_pair_weight(pair=pair, weights=weights) for pair in pairs)
|
|
854
|
+
weight_threshold = max(0.0, config.early_pair_tiebreaker_pair_weight_threshold)
|
|
855
|
+
close_pairs = tuple(
|
|
856
|
+
pair
|
|
857
|
+
for pair in pairs
|
|
858
|
+
if max_pair_weight - _pair_weight(pair=pair, weights=weights) <= weight_threshold
|
|
859
|
+
)
|
|
860
|
+
return max(
|
|
861
|
+
close_pairs,
|
|
862
|
+
key=lambda pair: _tiebreaker_pair_sort_key(
|
|
863
|
+
pair=pair,
|
|
864
|
+
weights=weights,
|
|
865
|
+
ratings_data=ratings_data,
|
|
866
|
+
),
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _tiebreaker_pair_sort_key(
|
|
871
|
+
*,
|
|
872
|
+
pair: str,
|
|
873
|
+
weights: dict[str, float],
|
|
874
|
+
ratings_data: SeventeenLandsData,
|
|
875
|
+
) -> tuple[bool, float, float, int]:
|
|
876
|
+
win_rate = _pair_win_rate(pair=pair, ratings_data=ratings_data)
|
|
877
|
+
return (
|
|
878
|
+
win_rate is not None,
|
|
879
|
+
0.0 if win_rate is None else win_rate,
|
|
880
|
+
_pair_weight(pair=pair, weights=weights),
|
|
881
|
+
-COLOR_PAIRS.index(pair),
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _pair_win_rate(
|
|
886
|
+
*,
|
|
887
|
+
pair: str,
|
|
888
|
+
ratings_data: SeventeenLandsData | None,
|
|
889
|
+
) -> float | None:
|
|
890
|
+
if ratings_data is None:
|
|
891
|
+
return None
|
|
892
|
+
|
|
893
|
+
pair_record = ratings_data.pair_win_rates.get(pair)
|
|
894
|
+
if pair_record is None:
|
|
895
|
+
return None
|
|
896
|
+
|
|
897
|
+
return pair_record.win_rate
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _normalization_from_data(
|
|
901
|
+
*,
|
|
902
|
+
ratings_data: SeventeenLandsData | None,
|
|
903
|
+
config: PickEngineConfig,
|
|
904
|
+
) -> ScoreNormalization:
|
|
905
|
+
distribution = _strong_rating_distribution(ratings_data=ratings_data)
|
|
906
|
+
lower_percentile = _percentile(
|
|
907
|
+
values=distribution,
|
|
908
|
+
percentile=config.normalization_lower_percentile,
|
|
909
|
+
)
|
|
910
|
+
upper_percentile = _percentile(
|
|
911
|
+
values=distribution,
|
|
912
|
+
percentile=config.normalization_upper_percentile,
|
|
913
|
+
)
|
|
914
|
+
neutral = config.neutral_prior_win_rate
|
|
915
|
+
half_span = config.normalization_min_half_span
|
|
916
|
+
if lower_percentile is not None:
|
|
917
|
+
half_span = max(half_span, neutral - lower_percentile)
|
|
918
|
+
|
|
919
|
+
if upper_percentile is not None:
|
|
920
|
+
half_span = max(half_span, upper_percentile - neutral)
|
|
921
|
+
|
|
922
|
+
return ScoreNormalization(
|
|
923
|
+
lower_rating=neutral - half_span,
|
|
924
|
+
upper_rating=neutral + half_span,
|
|
925
|
+
neutral_rating=neutral,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _strong_rating_distribution(
|
|
930
|
+
*,
|
|
931
|
+
ratings_data: SeventeenLandsData | None,
|
|
932
|
+
) -> tuple[float, ...]:
|
|
933
|
+
if ratings_data is None:
|
|
934
|
+
return ()
|
|
935
|
+
|
|
936
|
+
return tuple(
|
|
937
|
+
rating.gih_win_rate
|
|
938
|
+
for rating in ratings_data.ratings.values()
|
|
939
|
+
if rating.gih_win_rate is not None
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _rating_for(
|
|
944
|
+
*,
|
|
945
|
+
ratings_data: SeventeenLandsData | None,
|
|
946
|
+
grp_id: int,
|
|
947
|
+
config: PickEngineConfig,
|
|
948
|
+
commitment: ColorCommitment | None = None,
|
|
949
|
+
) -> ResolvedCardRating:
|
|
950
|
+
if ratings_data is None:
|
|
951
|
+
return _neutral_rating(grp_id=grp_id, config=config)
|
|
952
|
+
|
|
953
|
+
if commitment is not None and commitment.locked and commitment.inferred_pair is not None:
|
|
954
|
+
return ratings_data.pair_rating_for(
|
|
955
|
+
grp_id=grp_id,
|
|
956
|
+
pair=commitment.inferred_pair,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
return ratings_data.rating_for(grp_id=grp_id)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _neutral_rating(*, grp_id: int, config: PickEngineConfig) -> ResolvedCardRating:
|
|
963
|
+
return ResolvedCardRating(
|
|
964
|
+
grp_id=grp_id,
|
|
965
|
+
name=f"Unknown card {grp_id}",
|
|
966
|
+
color=None,
|
|
967
|
+
rarity=None,
|
|
968
|
+
average_last_seen_at=None,
|
|
969
|
+
gih_win_rate=None,
|
|
970
|
+
opening_hand_win_rate=None,
|
|
971
|
+
drawn_improvement_win_rate=None,
|
|
972
|
+
sample_counts=RatingSampleCounts(
|
|
973
|
+
seen=0,
|
|
974
|
+
picked=0,
|
|
975
|
+
games_played=0,
|
|
976
|
+
opening_hand=0,
|
|
977
|
+
games_in_hand=0,
|
|
978
|
+
),
|
|
979
|
+
letter_grade=None,
|
|
980
|
+
neutral_prior_score=config.neutral_prior_score,
|
|
981
|
+
metadata=RatingSourceMetadata(
|
|
982
|
+
requested_format=QUICK_DRAFT_FORMAT,
|
|
983
|
+
source=NEUTRAL_PRIOR_SOURCE,
|
|
984
|
+
source_format=None,
|
|
985
|
+
fallback_reason="ratings-unavailable",
|
|
986
|
+
),
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _base_rating(*, rating: ResolvedCardRating, config: PickEngineConfig) -> float:
|
|
991
|
+
if rating.gih_win_rate is not None:
|
|
992
|
+
return rating.gih_win_rate
|
|
993
|
+
|
|
994
|
+
return config.neutral_prior_win_rate + _alsa_adjustment(
|
|
995
|
+
average_last_seen_at=rating.average_last_seen_at,
|
|
996
|
+
config=config,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _alsa_adjustment(
|
|
1001
|
+
*,
|
|
1002
|
+
average_last_seen_at: float | None,
|
|
1003
|
+
config: PickEngineConfig,
|
|
1004
|
+
) -> float:
|
|
1005
|
+
if average_last_seen_at is None:
|
|
1006
|
+
return 0.0
|
|
1007
|
+
|
|
1008
|
+
early = config.alsa_early_pick
|
|
1009
|
+
late = config.alsa_late_pick
|
|
1010
|
+
if late <= early:
|
|
1011
|
+
return 0.0
|
|
1012
|
+
|
|
1013
|
+
clamped = _clamp(value=average_last_seen_at, lower=early, upper=late)
|
|
1014
|
+
midpoint = (early + late) / 2.0
|
|
1015
|
+
half_span = (late - early) / 2.0
|
|
1016
|
+
return ((midpoint - clamped) / half_span) * config.alsa_adjustment_max
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _normalized_score(
|
|
1020
|
+
*,
|
|
1021
|
+
adjusted_rating: float,
|
|
1022
|
+
normalization: ScoreNormalization,
|
|
1023
|
+
) -> float:
|
|
1024
|
+
rating_span = normalization.upper_rating - normalization.lower_rating
|
|
1025
|
+
if rating_span <= 0:
|
|
1026
|
+
return 50.0
|
|
1027
|
+
|
|
1028
|
+
score = ((adjusted_rating - normalization.lower_rating) / rating_span) * 100.0
|
|
1029
|
+
return _clamp(value=score, lower=0.0, upper=100.0)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _integer_score(*, raw_score: float) -> int:
|
|
1033
|
+
return int(math.floor(_clamp(value=raw_score, lower=0.0, upper=100.0) + 0.5))
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _source_label(*, rating: ResolvedCardRating) -> str:
|
|
1037
|
+
if rating.metadata.source == NEUTRAL_PRIOR_SOURCE:
|
|
1038
|
+
return "Prior*"
|
|
1039
|
+
|
|
1040
|
+
if rating.metadata.source != FORMAT_RATING_SOURCE:
|
|
1041
|
+
return "Unknown"
|
|
1042
|
+
|
|
1043
|
+
if rating.metadata.source_format == QUICK_DRAFT_FORMAT:
|
|
1044
|
+
return "Quick"
|
|
1045
|
+
|
|
1046
|
+
if rating.metadata.source_format == PREMIER_DRAFT_FORMAT:
|
|
1047
|
+
return "Premier"
|
|
1048
|
+
|
|
1049
|
+
return rating.metadata.source_format or "Unknown"
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _is_freely_available_basic_land(*, card: CardInfo) -> bool:
|
|
1053
|
+
return card.name in STANDARD_BASIC_LAND_NAMES or any(
|
|
1054
|
+
type_line in STANDARD_BASIC_LAND_TYPE_LINES
|
|
1055
|
+
for type_line in card.types
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _source_summary(*, cards: tuple[ScoredCard, ...]) -> str:
|
|
1060
|
+
if not cards:
|
|
1061
|
+
return "none"
|
|
1062
|
+
|
|
1063
|
+
uses_quick = any(card.source_label == "Quick" for card in cards)
|
|
1064
|
+
uses_premier = any(card.source_label == "Premier" for card in cards)
|
|
1065
|
+
uses_prior = any(card.no_data for card in cards)
|
|
1066
|
+
uses_basic_policy = any(card.freely_available_basic for card in cards)
|
|
1067
|
+
parts: list[str] = []
|
|
1068
|
+
if uses_quick:
|
|
1069
|
+
parts.append("QuickDraft")
|
|
1070
|
+
|
|
1071
|
+
if uses_premier:
|
|
1072
|
+
parts.append("Premier fallback")
|
|
1073
|
+
|
|
1074
|
+
if uses_prior:
|
|
1075
|
+
parts.append("neutral prior")
|
|
1076
|
+
|
|
1077
|
+
if uses_basic_policy:
|
|
1078
|
+
parts.append("basic land policy")
|
|
1079
|
+
|
|
1080
|
+
return " + ".join(parts) if parts else "unknown"
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _scored_card_base_sort_key(
|
|
1084
|
+
card: ScoredCard,
|
|
1085
|
+
) -> tuple[bool, int, float, float, int]:
|
|
1086
|
+
return (
|
|
1087
|
+
card.freely_available_basic,
|
|
1088
|
+
-card.score,
|
|
1089
|
+
-card.raw_score,
|
|
1090
|
+
-card.base_rating,
|
|
1091
|
+
card.original_index,
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _percentile(*, values: tuple[float, ...], percentile: float) -> float | None:
|
|
1096
|
+
if not values:
|
|
1097
|
+
return None
|
|
1098
|
+
|
|
1099
|
+
ordered = sorted(values)
|
|
1100
|
+
if len(ordered) == 1:
|
|
1101
|
+
return ordered[0]
|
|
1102
|
+
|
|
1103
|
+
clamped_percentile = _clamp(value=percentile, lower=0.0, upper=100.0)
|
|
1104
|
+
rank = (len(ordered) - 1) * (clamped_percentile / 100.0)
|
|
1105
|
+
lower_index = math.floor(rank)
|
|
1106
|
+
upper_index = math.ceil(rank)
|
|
1107
|
+
if lower_index == upper_index:
|
|
1108
|
+
return ordered[lower_index]
|
|
1109
|
+
|
|
1110
|
+
lower_value = ordered[lower_index]
|
|
1111
|
+
upper_value = ordered[upper_index]
|
|
1112
|
+
fraction = rank - lower_index
|
|
1113
|
+
return lower_value + ((upper_value - lower_value) * fraction)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _clamp(*, value: float, lower: float, upper: float) -> float:
|
|
1117
|
+
return min(max(value, lower), upper)
|