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/deckbuilder.py
ADDED
|
@@ -0,0 +1,2723 @@
|
|
|
1
|
+
"""Deck-builder pair selection, spells, mana base, and text output.
|
|
2
|
+
Cached 17Lands structure targets override consensus defaults when present.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from os import PathLike
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, TypeAlias
|
|
15
|
+
|
|
16
|
+
from draftomen.carddb import CardDatabase, CardInfo
|
|
17
|
+
from draftomen.config import COLOR_PAIRS, DECK_BUILDER, SPLASH, DeckBuilderConfig
|
|
18
|
+
from draftomen.pickengine import PickEngine, ScoredCard
|
|
19
|
+
from draftomen.pool import DraftState, list_draft_states
|
|
20
|
+
from draftomen.seventeen import (
|
|
21
|
+
SEVENTEEN_LANDS_ATTRIBUTION,
|
|
22
|
+
SeventeenLandsData,
|
|
23
|
+
StructuralTargets,
|
|
24
|
+
)
|
|
25
|
+
from draftomen.splash import (
|
|
26
|
+
SplashState,
|
|
27
|
+
card_is_castable_in_pair,
|
|
28
|
+
grade_at_least,
|
|
29
|
+
infer_splash_state,
|
|
30
|
+
splash_requirement,
|
|
31
|
+
)
|
|
32
|
+
from draftomen.setinfo import format_set_label
|
|
33
|
+
|
|
34
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
35
|
+
CardQuantityKey: TypeAlias = tuple[str, str]
|
|
36
|
+
SPELL_TYPE_MARKERS = (
|
|
37
|
+
"Creature",
|
|
38
|
+
"Artifact",
|
|
39
|
+
"Enchantment",
|
|
40
|
+
"Planeswalker",
|
|
41
|
+
"Battle",
|
|
42
|
+
"Instant",
|
|
43
|
+
"Sorcery",
|
|
44
|
+
)
|
|
45
|
+
BASIC_LANDS_BY_COLOR = {
|
|
46
|
+
"W": "Plains",
|
|
47
|
+
"U": "Island",
|
|
48
|
+
"B": "Swamp",
|
|
49
|
+
"R": "Mountain",
|
|
50
|
+
"G": "Forest",
|
|
51
|
+
}
|
|
52
|
+
MANA_SYMBOL_PATTERN = re.compile(r"\{([^}]+)\}")
|
|
53
|
+
CURVE_BUCKET_LABELS = ("0", "1", "2", "3", "4", "5", "6+")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DeckBuilderError(RuntimeError):
|
|
57
|
+
"""Raised when deck-builder input is missing or inconsistent.
|
|
58
|
+
CLI callers surface this as a concise build diagnostic.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class BuildPool:
|
|
64
|
+
"""A pool selected from a file or persisted draft state.
|
|
65
|
+
The set code drives the offline 17Lands cache lookup.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
set_code: str
|
|
69
|
+
pool_grp_ids: tuple[int, ...]
|
|
70
|
+
source_label: str
|
|
71
|
+
account_id: str | None = None
|
|
72
|
+
draft_id: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class PairScore:
|
|
77
|
+
"""Computed score for one two-color pair.
|
|
78
|
+
Card quality and aggregate 17Lands pair win rate stay visible separately.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
pair: str
|
|
82
|
+
playable_count: int
|
|
83
|
+
playable_score_sum: float
|
|
84
|
+
average_playable_score: float
|
|
85
|
+
pair_win_rate: float | None
|
|
86
|
+
pair_win_rate_score: float
|
|
87
|
+
blended_score: float
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class PairSelection:
|
|
92
|
+
"""Chosen pair plus sorted pair-score context.
|
|
93
|
+
Forced selections keep the automatic best pair available for display.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
chosen: PairScore
|
|
97
|
+
runner_up: PairScore
|
|
98
|
+
automatic: PairScore
|
|
99
|
+
ranked_scores: tuple[PairScore, ...]
|
|
100
|
+
forced_pair: str | None
|
|
101
|
+
pool_size: int
|
|
102
|
+
target_spell_count: int
|
|
103
|
+
attribution: str = SEVENTEEN_LANDS_ATTRIBUTION
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def score_gap(self) -> float:
|
|
107
|
+
"""Return chosen score minus runner-up score.
|
|
108
|
+
Forced selections can produce a negative gap by design.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
return self.chosen.blended_score - self.runner_up.blended_score
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class SpellCounts:
|
|
116
|
+
"""Structural counts for a selected spell set.
|
|
117
|
+
Derived properties keep constraint checks readable and deterministic.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
total: int
|
|
121
|
+
creatures: int
|
|
122
|
+
two_drops: int
|
|
123
|
+
expensive: int
|
|
124
|
+
splashes: int = 0
|
|
125
|
+
instants: int = 0
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def noncreatures(self) -> int:
|
|
129
|
+
"""Return selected cards that are not creatures.
|
|
130
|
+
This is used when enforcing the creature ceiling.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
return self.total - self.creatures
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def non_expensive(self) -> int:
|
|
137
|
+
"""Return selected cards below the expensive-spell threshold.
|
|
138
|
+
This is used when enforcing the high-mana-value soft cap.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
return self.total - self.expensive
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, slots=True)
|
|
145
|
+
class SpellConstraints:
|
|
146
|
+
"""Effective constraints for a spell-selection attempt.
|
|
147
|
+
Values may be relaxed when the pool cannot satisfy configured defaults.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
spell_count: int
|
|
151
|
+
creature_floor: int
|
|
152
|
+
creature_ceiling: int
|
|
153
|
+
minimum_two_drops: int
|
|
154
|
+
maximum_expensive_spells: int
|
|
155
|
+
maximum_splash_spells: int = 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True)
|
|
159
|
+
class SpellSelection:
|
|
160
|
+
"""Selected spells plus bench and constraint metadata.
|
|
161
|
+
The requested count remains visible when a tiny fixture cannot make 23.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
pair: str
|
|
165
|
+
spells: tuple[ScoredCard, ...]
|
|
166
|
+
bench: tuple[ScoredCard, ...]
|
|
167
|
+
eligible_count: int
|
|
168
|
+
requested_spell_count: int
|
|
169
|
+
constraints: SpellConstraints
|
|
170
|
+
counts: SpellCounts
|
|
171
|
+
applied_relaxations: tuple[str, ...]
|
|
172
|
+
allow_splash_requested: bool
|
|
173
|
+
splash_color: str | None = None
|
|
174
|
+
splash_fixing_sources: int = 0
|
|
175
|
+
splash_planned_basic_sources: int = 0
|
|
176
|
+
structure_targets: StructuralTargets | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, slots=True)
|
|
180
|
+
class LandCard:
|
|
181
|
+
"""One drafted nonbasic land selected for the mana base.
|
|
182
|
+
Source colors are separated from card colors because lands are colorless.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
card: CardInfo
|
|
186
|
+
original_index: int
|
|
187
|
+
source_colors: tuple[str, ...]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class BasicLandCount:
|
|
192
|
+
"""Count of one basic land name in the recommended mana base.
|
|
193
|
+
Colors stay explicit so source accounting is deterministic.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
color: str
|
|
197
|
+
name: str
|
|
198
|
+
count: int
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class ManaBase:
|
|
203
|
+
"""Selected lands and source accounting for the final build sheet.
|
|
204
|
+
Basics are split by colored pips after drafted in-pair nonbasics.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
pair: str
|
|
208
|
+
land_count: int
|
|
209
|
+
spell_count: int
|
|
210
|
+
deck_size: int
|
|
211
|
+
nonbasic_lands: tuple[LandCard, ...]
|
|
212
|
+
basic_lands: tuple[BasicLandCount, ...]
|
|
213
|
+
pip_counts: tuple[tuple[str, int], ...]
|
|
214
|
+
double_pip_counts: tuple[tuple[str, int], ...]
|
|
215
|
+
source_counts: tuple[tuple[str, int], ...]
|
|
216
|
+
average_mana_value: float
|
|
217
|
+
reason: str
|
|
218
|
+
caveats: tuple[str, ...]
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def total_cards(self) -> int:
|
|
222
|
+
"""Return the spell-plus-land total for the proposed deck.
|
|
223
|
+
Build-sheet formatting uses this to prove the deck is exactly 40.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
return self.spell_count + self.land_count
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True, slots=True)
|
|
230
|
+
class BuildSheet:
|
|
231
|
+
"""Final selected spells plus mana base.
|
|
232
|
+
The pair-selection context is formatted separately before this sheet.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
spell_selection: SpellSelection
|
|
236
|
+
mana_base: ManaBase
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True, slots=True)
|
|
240
|
+
class _ConstraintPlan:
|
|
241
|
+
"""One point in the documented stage-2 relaxation order.
|
|
242
|
+
Later plans disable progressively more structural constraints.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
enforce_expensive_cap: bool
|
|
246
|
+
enforce_two_drop_minimum: bool
|
|
247
|
+
enforce_creature_ceiling: bool
|
|
248
|
+
enforce_creature_floor: bool
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def load_pool_file(*, path: PathInput, set_code: str | None = None) -> BuildPool:
|
|
253
|
+
"""Load a fixture pool JSON file for offline building.
|
|
254
|
+
Draftomen state JSON and compact pool objects are both supported.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
pool_path = Path(path)
|
|
258
|
+
try:
|
|
259
|
+
payload = json.loads(pool_path.read_text(encoding="utf-8"))
|
|
260
|
+
except FileNotFoundError as error:
|
|
261
|
+
raise DeckBuilderError(f"Pool file does not exist at {pool_path}.") from error
|
|
262
|
+
except json.JSONDecodeError as error:
|
|
263
|
+
raise DeckBuilderError(f"Malformed pool file {pool_path}: {error}.") from error
|
|
264
|
+
|
|
265
|
+
pool = _pool_from_payload(payload=payload, source_label=str(pool_path))
|
|
266
|
+
resolved_set_code = _resolve_set_code(
|
|
267
|
+
explicit_set_code=set_code,
|
|
268
|
+
payload_set_code=pool.set_code,
|
|
269
|
+
source_label=str(pool_path),
|
|
270
|
+
)
|
|
271
|
+
return BuildPool(
|
|
272
|
+
set_code=resolved_set_code,
|
|
273
|
+
pool_grp_ids=pool.pool_grp_ids,
|
|
274
|
+
source_label=str(pool_path),
|
|
275
|
+
account_id=pool.account_id,
|
|
276
|
+
draft_id=pool.draft_id,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def load_persisted_pool(
|
|
282
|
+
*,
|
|
283
|
+
app_dir: PathInput | None = None,
|
|
284
|
+
account_id: str | None = None,
|
|
285
|
+
draft_id: str | None = None,
|
|
286
|
+
) -> BuildPool:
|
|
287
|
+
"""Load the requested persisted pool, defaulting to the latest one.
|
|
288
|
+
Account and draft filters disambiguate local multi-account state.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
matches = _matching_states(
|
|
292
|
+
states=list_draft_states(app_dir=app_dir),
|
|
293
|
+
account_id=account_id,
|
|
294
|
+
draft_id=draft_id,
|
|
295
|
+
)
|
|
296
|
+
if not matches:
|
|
297
|
+
raise DeckBuilderError(
|
|
298
|
+
_missing_persisted_pool_message(account_id=account_id, draft_id=draft_id)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if draft_id is not None and len(matches) > 1:
|
|
302
|
+
raise DeckBuilderError(
|
|
303
|
+
f"Multiple persisted pools use draft id {draft_id!r}; pass --account."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
state = _latest_state(states=matches)
|
|
307
|
+
return BuildPool(
|
|
308
|
+
set_code=state.set_code,
|
|
309
|
+
pool_grp_ids=state.pool_grp_ids,
|
|
310
|
+
source_label=f"persisted {state.account_id}/{state.draft_id}",
|
|
311
|
+
account_id=state.account_id,
|
|
312
|
+
draft_id=state.draft_id,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def select_color_pair(
|
|
318
|
+
*,
|
|
319
|
+
pool_grp_ids: tuple[int, ...],
|
|
320
|
+
card_database: CardDatabase,
|
|
321
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
322
|
+
forced_pair: str | None = None,
|
|
323
|
+
config: DeckBuilderConfig = DECK_BUILDER,
|
|
324
|
+
) -> PairSelection:
|
|
325
|
+
"""Score all two-color pairs and choose the best or forced pair.
|
|
326
|
+
The pool is already drafted; early draft picks remain rating-first elsewhere.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
_validate_deck_builder_config(config=config)
|
|
330
|
+
_validate_blending_weights(config=config)
|
|
331
|
+
_validate_metadata_coverage(
|
|
332
|
+
pool_grp_ids=pool_grp_ids,
|
|
333
|
+
card_database=card_database,
|
|
334
|
+
config=config,
|
|
335
|
+
)
|
|
336
|
+
resolved_forced_pair = _optional_pair(value=forced_pair)
|
|
337
|
+
scored_pool = PickEngine(ratings_data=ratings_data).score_pack(
|
|
338
|
+
offered_grp_ids=pool_grp_ids,
|
|
339
|
+
card_database=card_database,
|
|
340
|
+
pool_grp_ids=(),
|
|
341
|
+
pick_index=1,
|
|
342
|
+
)
|
|
343
|
+
scored_cards = _limit_cards_to_pool_quantities(
|
|
344
|
+
cards=scored_pool.cards,
|
|
345
|
+
available_quantities=_pool_card_quantities(
|
|
346
|
+
pool_grp_ids=pool_grp_ids,
|
|
347
|
+
card_database=card_database,
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
scores = tuple(
|
|
351
|
+
_score_pair(
|
|
352
|
+
pair=pair,
|
|
353
|
+
scored_cards=scored_cards,
|
|
354
|
+
ratings_data=ratings_data,
|
|
355
|
+
config=config,
|
|
356
|
+
)
|
|
357
|
+
for pair in COLOR_PAIRS
|
|
358
|
+
)
|
|
359
|
+
_validate_playable_pair_scores(
|
|
360
|
+
scores=scores,
|
|
361
|
+
pool_grp_ids=pool_grp_ids,
|
|
362
|
+
card_database=card_database,
|
|
363
|
+
config=config,
|
|
364
|
+
)
|
|
365
|
+
ranked_scores = tuple(sorted(scores, key=_pair_score_sort_key))
|
|
366
|
+
automatic = ranked_scores[0]
|
|
367
|
+
chosen = (
|
|
368
|
+
_score_for_pair(scores=scores, pair=resolved_forced_pair)
|
|
369
|
+
if resolved_forced_pair is not None
|
|
370
|
+
else automatic
|
|
371
|
+
)
|
|
372
|
+
runner_up = next(score for score in ranked_scores if score.pair != chosen.pair)
|
|
373
|
+
return PairSelection(
|
|
374
|
+
chosen=chosen,
|
|
375
|
+
runner_up=runner_up,
|
|
376
|
+
automatic=automatic,
|
|
377
|
+
ranked_scores=ranked_scores,
|
|
378
|
+
forced_pair=resolved_forced_pair,
|
|
379
|
+
pool_size=len(pool_grp_ids),
|
|
380
|
+
target_spell_count=config.target_spell_count,
|
|
381
|
+
attribution=(
|
|
382
|
+
ratings_data.attribution
|
|
383
|
+
if ratings_data is not None
|
|
384
|
+
else SEVENTEEN_LANDS_ATTRIBUTION
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def select_deck_spells(
|
|
391
|
+
*,
|
|
392
|
+
pool_grp_ids: tuple[int, ...],
|
|
393
|
+
card_database: CardDatabase,
|
|
394
|
+
pair: str,
|
|
395
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
396
|
+
allow_splash: bool = SPLASH.enabled_by_default,
|
|
397
|
+
config: DeckBuilderConfig = DECK_BUILDER,
|
|
398
|
+
) -> SpellSelection:
|
|
399
|
+
"""Select deck spells for a chosen pair under structural constraints.
|
|
400
|
+
Cached pair targets and explicit splash eligibility are applied here.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
_validate_deck_builder_config(config=config)
|
|
404
|
+
_validate_metadata_coverage(
|
|
405
|
+
pool_grp_ids=pool_grp_ids,
|
|
406
|
+
card_database=card_database,
|
|
407
|
+
config=config,
|
|
408
|
+
)
|
|
409
|
+
resolved_pair = _optional_pair(value=pair)
|
|
410
|
+
if resolved_pair is None:
|
|
411
|
+
raise DeckBuilderError("A color pair is required before selecting spells.")
|
|
412
|
+
|
|
413
|
+
structure_targets = _structure_targets_for_pair(
|
|
414
|
+
ratings_data=ratings_data,
|
|
415
|
+
pair=resolved_pair,
|
|
416
|
+
)
|
|
417
|
+
effective_config = _config_with_structure_targets(
|
|
418
|
+
config=config,
|
|
419
|
+
structure_targets=structure_targets,
|
|
420
|
+
)
|
|
421
|
+
_validate_deck_builder_config(config=effective_config)
|
|
422
|
+
splash_state = infer_splash_state(
|
|
423
|
+
pool_grp_ids=pool_grp_ids,
|
|
424
|
+
card_database=card_database,
|
|
425
|
+
ratings_data=ratings_data,
|
|
426
|
+
base_pair=resolved_pair,
|
|
427
|
+
enabled=allow_splash,
|
|
428
|
+
)
|
|
429
|
+
splash_color = splash_state.active_color
|
|
430
|
+
splash_fixing_sources = (
|
|
431
|
+
0
|
|
432
|
+
if splash_color is None
|
|
433
|
+
else splash_state.fixing_for(color=splash_color)
|
|
434
|
+
)
|
|
435
|
+
splash_limit = _supported_splash_card_limit(
|
|
436
|
+
state=splash_state,
|
|
437
|
+
config=effective_config,
|
|
438
|
+
)
|
|
439
|
+
scored_pool = PickEngine(ratings_data=ratings_data).score_pack(
|
|
440
|
+
offered_grp_ids=pool_grp_ids,
|
|
441
|
+
card_database=card_database,
|
|
442
|
+
pool_grp_ids=(),
|
|
443
|
+
pick_index=1,
|
|
444
|
+
)
|
|
445
|
+
available_quantities = _pool_card_quantities(
|
|
446
|
+
pool_grp_ids=pool_grp_ids,
|
|
447
|
+
card_database=card_database,
|
|
448
|
+
)
|
|
449
|
+
candidates = _limit_cards_to_pool_quantities(
|
|
450
|
+
cards=tuple(
|
|
451
|
+
card
|
|
452
|
+
for card in scored_pool.cards
|
|
453
|
+
if _is_eligible_spell_for_pair(
|
|
454
|
+
card=card,
|
|
455
|
+
pair=resolved_pair,
|
|
456
|
+
allow_splash=allow_splash,
|
|
457
|
+
splash_color=splash_color,
|
|
458
|
+
splash_limit=splash_limit,
|
|
459
|
+
config=effective_config,
|
|
460
|
+
)
|
|
461
|
+
),
|
|
462
|
+
available_quantities=available_quantities,
|
|
463
|
+
)
|
|
464
|
+
_validate_spell_candidates(
|
|
465
|
+
candidates=candidates,
|
|
466
|
+
pool_grp_ids=pool_grp_ids,
|
|
467
|
+
card_database=card_database,
|
|
468
|
+
pair=resolved_pair,
|
|
469
|
+
config=effective_config,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
for plan in _constraint_plans():
|
|
473
|
+
constraints = _constraints_for_plan(
|
|
474
|
+
candidates=candidates,
|
|
475
|
+
pair=resolved_pair,
|
|
476
|
+
splash_limit=splash_limit,
|
|
477
|
+
plan=plan,
|
|
478
|
+
config=effective_config,
|
|
479
|
+
)
|
|
480
|
+
selected = _select_with_constraints(
|
|
481
|
+
candidates=candidates,
|
|
482
|
+
available_quantities=available_quantities,
|
|
483
|
+
pair=resolved_pair,
|
|
484
|
+
constraints=constraints,
|
|
485
|
+
config=effective_config,
|
|
486
|
+
)
|
|
487
|
+
if selected is None:
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
counts = _spell_counts(
|
|
491
|
+
cards=selected,
|
|
492
|
+
pair=resolved_pair,
|
|
493
|
+
config=effective_config,
|
|
494
|
+
)
|
|
495
|
+
return SpellSelection(
|
|
496
|
+
pair=resolved_pair,
|
|
497
|
+
spells=selected,
|
|
498
|
+
bench=_bench_cards(
|
|
499
|
+
candidates=candidates,
|
|
500
|
+
selected=selected,
|
|
501
|
+
config=effective_config,
|
|
502
|
+
),
|
|
503
|
+
eligible_count=len(candidates),
|
|
504
|
+
requested_spell_count=effective_config.target_spell_count,
|
|
505
|
+
constraints=constraints,
|
|
506
|
+
counts=counts,
|
|
507
|
+
applied_relaxations=_applied_relaxations(
|
|
508
|
+
plan=plan,
|
|
509
|
+
constraints=constraints,
|
|
510
|
+
config=effective_config,
|
|
511
|
+
),
|
|
512
|
+
allow_splash_requested=allow_splash,
|
|
513
|
+
splash_color=splash_color,
|
|
514
|
+
splash_fixing_sources=splash_fixing_sources,
|
|
515
|
+
splash_planned_basic_sources=(
|
|
516
|
+
SPLASH.planned_basic_sources
|
|
517
|
+
if splash_color is not None and splash_limit > 0
|
|
518
|
+
else 0
|
|
519
|
+
),
|
|
520
|
+
structure_targets=structure_targets,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
raise DeckBuilderError("Could not select deck spells with the configured constraints.")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def select_build_sheet(
|
|
528
|
+
*,
|
|
529
|
+
pool_grp_ids: tuple[int, ...],
|
|
530
|
+
card_database: CardDatabase,
|
|
531
|
+
pair: str,
|
|
532
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
533
|
+
allow_splash: bool = SPLASH.enabled_by_default,
|
|
534
|
+
config: DeckBuilderConfig = DECK_BUILDER,
|
|
535
|
+
) -> BuildSheet:
|
|
536
|
+
"""Select spells and lands for an exactly sized Limited deck.
|
|
537
|
+
The spell count is reselected when 16- or 18-land curve rules apply.
|
|
538
|
+
"""
|
|
539
|
+
|
|
540
|
+
_validate_deck_builder_config(config=config)
|
|
541
|
+
resolved_pair = _optional_pair(value=pair)
|
|
542
|
+
if resolved_pair is None:
|
|
543
|
+
raise DeckBuilderError("A color pair is required before selecting a build sheet.")
|
|
544
|
+
|
|
545
|
+
structure_targets = _structure_targets_for_pair(
|
|
546
|
+
ratings_data=ratings_data,
|
|
547
|
+
pair=resolved_pair,
|
|
548
|
+
)
|
|
549
|
+
effective_config = _config_with_structure_targets(
|
|
550
|
+
config=config,
|
|
551
|
+
structure_targets=structure_targets,
|
|
552
|
+
)
|
|
553
|
+
_validate_deck_builder_config(config=effective_config)
|
|
554
|
+
spell_selection = select_deck_spells(
|
|
555
|
+
pool_grp_ids=pool_grp_ids,
|
|
556
|
+
card_database=card_database,
|
|
557
|
+
pair=resolved_pair,
|
|
558
|
+
ratings_data=ratings_data,
|
|
559
|
+
allow_splash=allow_splash,
|
|
560
|
+
config=effective_config,
|
|
561
|
+
)
|
|
562
|
+
seen_targets = {spell_selection.counts.total}
|
|
563
|
+
for _ in range(effective_config.land_count_iteration_limit):
|
|
564
|
+
land_count = _curve_land_count(
|
|
565
|
+
selection=spell_selection,
|
|
566
|
+
config=effective_config,
|
|
567
|
+
)[0]
|
|
568
|
+
desired_spell_count = max(0, effective_config.deck_size - land_count)
|
|
569
|
+
if desired_spell_count == spell_selection.counts.total:
|
|
570
|
+
break
|
|
571
|
+
|
|
572
|
+
if desired_spell_count in seen_targets:
|
|
573
|
+
break
|
|
574
|
+
|
|
575
|
+
seen_targets.add(desired_spell_count)
|
|
576
|
+
spell_selection = select_deck_spells(
|
|
577
|
+
pool_grp_ids=pool_grp_ids,
|
|
578
|
+
card_database=card_database,
|
|
579
|
+
pair=resolved_pair,
|
|
580
|
+
ratings_data=ratings_data,
|
|
581
|
+
allow_splash=allow_splash,
|
|
582
|
+
config=replace(effective_config, target_spell_count=desired_spell_count),
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
mana_base = select_mana_base(
|
|
586
|
+
pool_grp_ids=pool_grp_ids,
|
|
587
|
+
card_database=card_database,
|
|
588
|
+
pair=resolved_pair,
|
|
589
|
+
spell_selection=spell_selection,
|
|
590
|
+
config=effective_config,
|
|
591
|
+
)
|
|
592
|
+
if mana_base.total_cards != effective_config.deck_size:
|
|
593
|
+
mana_base = select_mana_base(
|
|
594
|
+
pool_grp_ids=pool_grp_ids,
|
|
595
|
+
card_database=card_database,
|
|
596
|
+
pair=resolved_pair,
|
|
597
|
+
spell_selection=spell_selection,
|
|
598
|
+
land_count=max(0, effective_config.deck_size - spell_selection.counts.total),
|
|
599
|
+
reason="deck-size fill after spell-count relaxation",
|
|
600
|
+
config=effective_config,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
return BuildSheet(spell_selection=spell_selection, mana_base=mana_base)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def build_deck_from_pool(
|
|
607
|
+
*,
|
|
608
|
+
pool: BuildPool,
|
|
609
|
+
card_database: CardDatabase,
|
|
610
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
611
|
+
forced_pair: str | None = None,
|
|
612
|
+
allow_splash: bool = SPLASH.enabled_by_default,
|
|
613
|
+
config: DeckBuilderConfig = DECK_BUILDER,
|
|
614
|
+
) -> tuple[PairSelection, BuildSheet]:
|
|
615
|
+
"""Run pair selection, spell selection, and mana-base selection.
|
|
616
|
+
CLI, replay, and watch share this helper for identical build sheets.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
selection = select_color_pair(
|
|
620
|
+
pool_grp_ids=pool.pool_grp_ids,
|
|
621
|
+
card_database=card_database,
|
|
622
|
+
ratings_data=ratings_data,
|
|
623
|
+
forced_pair=forced_pair,
|
|
624
|
+
config=config,
|
|
625
|
+
)
|
|
626
|
+
build_sheet = select_build_sheet(
|
|
627
|
+
pool_grp_ids=pool.pool_grp_ids,
|
|
628
|
+
card_database=card_database,
|
|
629
|
+
pair=selection.chosen.pair,
|
|
630
|
+
ratings_data=ratings_data,
|
|
631
|
+
allow_splash=allow_splash,
|
|
632
|
+
config=config,
|
|
633
|
+
)
|
|
634
|
+
return selection, build_sheet
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def select_mana_base(
|
|
638
|
+
*,
|
|
639
|
+
pool_grp_ids: tuple[int, ...],
|
|
640
|
+
card_database: CardDatabase,
|
|
641
|
+
pair: str,
|
|
642
|
+
spell_selection: SpellSelection,
|
|
643
|
+
land_count: int | None = None,
|
|
644
|
+
reason: str | None = None,
|
|
645
|
+
config: DeckBuilderConfig = DECK_BUILDER,
|
|
646
|
+
) -> ManaBase:
|
|
647
|
+
"""Select drafted nonbasic lands and split basics by colored pips.
|
|
648
|
+
Per-main-color source floors are enforced when the land slots allow it.
|
|
649
|
+
"""
|
|
650
|
+
|
|
651
|
+
_validate_deck_builder_config(config=config)
|
|
652
|
+
resolved_pair = _optional_pair(value=pair)
|
|
653
|
+
if resolved_pair is None:
|
|
654
|
+
raise DeckBuilderError("A color pair is required before selecting lands.")
|
|
655
|
+
|
|
656
|
+
curve_land_count, curve_reason = _curve_land_count(
|
|
657
|
+
selection=spell_selection,
|
|
658
|
+
config=config,
|
|
659
|
+
)
|
|
660
|
+
resolved_land_count = curve_land_count if land_count is None else land_count
|
|
661
|
+
if resolved_land_count < 0:
|
|
662
|
+
raise DeckBuilderError("Mana base land count must be non-negative.")
|
|
663
|
+
|
|
664
|
+
effective_reason = curve_reason if reason is None else reason
|
|
665
|
+
nonbasic_lands = _selected_nonbasic_lands(
|
|
666
|
+
pool_grp_ids=pool_grp_ids,
|
|
667
|
+
card_database=card_database,
|
|
668
|
+
pair=resolved_pair,
|
|
669
|
+
land_count=resolved_land_count,
|
|
670
|
+
splash_colors=_selected_splash_colors(
|
|
671
|
+
cards=spell_selection.spells,
|
|
672
|
+
pair=resolved_pair,
|
|
673
|
+
),
|
|
674
|
+
)
|
|
675
|
+
splash_colors = _selected_splash_colors(
|
|
676
|
+
cards=spell_selection.spells,
|
|
677
|
+
pair=resolved_pair,
|
|
678
|
+
)
|
|
679
|
+
mana_colors = resolved_pair + "".join(splash_colors)
|
|
680
|
+
splash_basics = _splash_basic_land_counts(
|
|
681
|
+
splash_colors=splash_colors,
|
|
682
|
+
selected_spells=spell_selection.spells,
|
|
683
|
+
pair=resolved_pair,
|
|
684
|
+
nonbasic_lands=nonbasic_lands,
|
|
685
|
+
)
|
|
686
|
+
basic_slots = max(
|
|
687
|
+
0,
|
|
688
|
+
resolved_land_count - len(nonbasic_lands) - sum(
|
|
689
|
+
basic.count for basic in splash_basics
|
|
690
|
+
),
|
|
691
|
+
)
|
|
692
|
+
pip_counts, double_pip_counts = _spell_pip_counts(
|
|
693
|
+
cards=spell_selection.spells,
|
|
694
|
+
pair=mana_colors,
|
|
695
|
+
)
|
|
696
|
+
source_counts = _source_counts(
|
|
697
|
+
pair=mana_colors,
|
|
698
|
+
nonbasic_lands=nonbasic_lands,
|
|
699
|
+
)
|
|
700
|
+
base_basic_counts = _basic_land_counts(
|
|
701
|
+
pair=resolved_pair,
|
|
702
|
+
slots=basic_slots,
|
|
703
|
+
pip_counts=pip_counts,
|
|
704
|
+
double_pip_counts=double_pip_counts,
|
|
705
|
+
source_counts=source_counts,
|
|
706
|
+
config=config,
|
|
707
|
+
)
|
|
708
|
+
basic_counts = base_basic_counts + splash_basics
|
|
709
|
+
final_source_counts = _source_counts_with_basics(
|
|
710
|
+
pair=mana_colors,
|
|
711
|
+
nonbasic_lands=nonbasic_lands,
|
|
712
|
+
basic_lands=basic_counts,
|
|
713
|
+
)
|
|
714
|
+
return ManaBase(
|
|
715
|
+
pair=resolved_pair,
|
|
716
|
+
land_count=resolved_land_count,
|
|
717
|
+
spell_count=spell_selection.counts.total,
|
|
718
|
+
deck_size=config.deck_size,
|
|
719
|
+
nonbasic_lands=nonbasic_lands,
|
|
720
|
+
basic_lands=basic_counts,
|
|
721
|
+
pip_counts=_ordered_color_items(values=pip_counts, pair=mana_colors),
|
|
722
|
+
double_pip_counts=_ordered_color_items(values=double_pip_counts, pair=mana_colors),
|
|
723
|
+
source_counts=_ordered_color_items(values=final_source_counts, pair=mana_colors),
|
|
724
|
+
average_mana_value=_average_mana_value(cards=spell_selection.spells),
|
|
725
|
+
reason=effective_reason,
|
|
726
|
+
caveats=_mana_base_caveats(
|
|
727
|
+
land_count=resolved_land_count,
|
|
728
|
+
nonbasic_lands=nonbasic_lands,
|
|
729
|
+
config=config,
|
|
730
|
+
),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def format_build_result(
|
|
735
|
+
*,
|
|
736
|
+
pool: BuildPool,
|
|
737
|
+
selection: PairSelection,
|
|
738
|
+
spell_selection: SpellSelection | None = None,
|
|
739
|
+
mana_base: ManaBase | None = None,
|
|
740
|
+
config: DeckBuilderConfig = DECK_BUILDER,
|
|
741
|
+
) -> str:
|
|
742
|
+
"""Format deterministic plain-text deck-builder output.
|
|
743
|
+
Complete builds start with the suggested deck before diagnostics.
|
|
744
|
+
"""
|
|
745
|
+
|
|
746
|
+
if spell_selection is not None and mana_base is not None:
|
|
747
|
+
lines = _format_player_build_result(
|
|
748
|
+
pool=pool,
|
|
749
|
+
selection=selection,
|
|
750
|
+
spell_selection=spell_selection,
|
|
751
|
+
mana_base=mana_base,
|
|
752
|
+
config=config,
|
|
753
|
+
)
|
|
754
|
+
else:
|
|
755
|
+
lines = _format_pair_selection_result(
|
|
756
|
+
pool=pool,
|
|
757
|
+
selection=selection,
|
|
758
|
+
config=config,
|
|
759
|
+
)
|
|
760
|
+
if spell_selection is not None:
|
|
761
|
+
lines.extend(_format_spell_selection(selection=spell_selection, config=config))
|
|
762
|
+
|
|
763
|
+
lines.append(selection.attribution)
|
|
764
|
+
return "\n".join(lines) + "\n"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _format_player_build_result(
|
|
768
|
+
*,
|
|
769
|
+
pool: BuildPool,
|
|
770
|
+
selection: PairSelection,
|
|
771
|
+
spell_selection: SpellSelection,
|
|
772
|
+
mana_base: ManaBase,
|
|
773
|
+
config: DeckBuilderConfig,
|
|
774
|
+
) -> list[str]:
|
|
775
|
+
chosen_label = "forced" if selection.forced_pair is not None else "automatic"
|
|
776
|
+
lines = [
|
|
777
|
+
"Suggested deck",
|
|
778
|
+
f"Set: {format_set_label(set_code=pool.set_code)}",
|
|
779
|
+
f"Pool: {pool.source_label}",
|
|
780
|
+
f"Pool size: {selection.pool_size} cards",
|
|
781
|
+
]
|
|
782
|
+
_append_optional_pool_context(lines=lines, pool=pool)
|
|
783
|
+
lines.append(
|
|
784
|
+
f"Color pair: {selection.chosen.pair} "
|
|
785
|
+
f"({chosen_label}; 17Lands WR {_format_win_rate(score=selection.chosen)})"
|
|
786
|
+
)
|
|
787
|
+
lines.extend(
|
|
788
|
+
_format_player_build_sheet(
|
|
789
|
+
spell_selection=spell_selection,
|
|
790
|
+
mana_base=mana_base,
|
|
791
|
+
config=config,
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
lines.extend(
|
|
795
|
+
_format_pair_analysis(
|
|
796
|
+
selection=selection,
|
|
797
|
+
config=config,
|
|
798
|
+
)
|
|
799
|
+
)
|
|
800
|
+
return lines
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _format_pair_selection_result(
|
|
804
|
+
*,
|
|
805
|
+
pool: BuildPool,
|
|
806
|
+
selection: PairSelection,
|
|
807
|
+
config: DeckBuilderConfig,
|
|
808
|
+
) -> list[str]:
|
|
809
|
+
lines = [
|
|
810
|
+
"Deck builder pair selection",
|
|
811
|
+
f"Pool: {pool.source_label}",
|
|
812
|
+
f"Set: {format_set_label(set_code=pool.set_code)}",
|
|
813
|
+
f"Pool size: {selection.pool_size} cards",
|
|
814
|
+
]
|
|
815
|
+
_append_optional_pool_context(lines=lines, pool=pool)
|
|
816
|
+
lines.extend(_format_pair_analysis(selection=selection, config=config))
|
|
817
|
+
return lines
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _append_optional_pool_context(*, lines: list[str], pool: BuildPool) -> None:
|
|
821
|
+
if pool.account_id is not None:
|
|
822
|
+
lines.append(f"Account: {pool.account_id}")
|
|
823
|
+
|
|
824
|
+
if pool.draft_id is not None:
|
|
825
|
+
lines.append(f"Draft: {pool.draft_id}")
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _format_pair_analysis(
|
|
829
|
+
*,
|
|
830
|
+
selection: PairSelection,
|
|
831
|
+
config: DeckBuilderConfig,
|
|
832
|
+
) -> list[str]:
|
|
833
|
+
chosen_label = "forced" if selection.forced_pair is not None else "automatic"
|
|
834
|
+
lines = [
|
|
835
|
+
"",
|
|
836
|
+
"Color-pair reasoning:",
|
|
837
|
+
(
|
|
838
|
+
"Diagnostic: compares playable-card quality with 17Lands color-pair "
|
|
839
|
+
"context; it is not another decklist."
|
|
840
|
+
),
|
|
841
|
+
f"Chosen pair: {selection.chosen.pair} ({chosen_label}, strength "
|
|
842
|
+
f"{_format_score(selection.chosen.blended_score, config=config)})",
|
|
843
|
+
f"Runner-up: {selection.runner_up.pair} (strength "
|
|
844
|
+
f"{_format_score(selection.runner_up.blended_score, config=config)})",
|
|
845
|
+
f"Strength gap: {_format_score(selection.score_gap, config=config)}",
|
|
846
|
+
]
|
|
847
|
+
if selection.forced_pair is not None:
|
|
848
|
+
lines.append(
|
|
849
|
+
f"Best automatic pair: {selection.automatic.pair} (strength "
|
|
850
|
+
f"{_format_score(selection.automatic.blended_score, config=config)})"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
lines.append("Pair strengths:")
|
|
854
|
+
for score in selection.ranked_scores:
|
|
855
|
+
lines.append(
|
|
856
|
+
_format_pair_score(
|
|
857
|
+
score=score,
|
|
858
|
+
target_spell_count=selection.target_spell_count,
|
|
859
|
+
config=config,
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
return lines
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _pool_from_payload(*, payload: Any, source_label: str) -> BuildPool:
|
|
868
|
+
if isinstance(payload, dict):
|
|
869
|
+
pool_value = payload.get("pool_grp_ids", payload.get("pool", payload.get("cards")))
|
|
870
|
+
return BuildPool(
|
|
871
|
+
set_code=_optional_string(payload.get("set_code")) or "",
|
|
872
|
+
pool_grp_ids=_int_tuple(value=pool_value, field_name="pool_grp_ids"),
|
|
873
|
+
source_label=source_label,
|
|
874
|
+
account_id=_optional_string(payload.get("account_id")),
|
|
875
|
+
draft_id=_optional_string(payload.get("draft_id")),
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
if isinstance(payload, list):
|
|
879
|
+
return BuildPool(
|
|
880
|
+
set_code="",
|
|
881
|
+
pool_grp_ids=_int_tuple(value=payload, field_name="pool"),
|
|
882
|
+
source_label=source_label,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
raise DeckBuilderError("Pool file must contain a JSON object or list of grpIds.")
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _resolve_set_code(
|
|
890
|
+
*,
|
|
891
|
+
explicit_set_code: str | None,
|
|
892
|
+
payload_set_code: str,
|
|
893
|
+
source_label: str,
|
|
894
|
+
) -> str:
|
|
895
|
+
set_code = explicit_set_code or payload_set_code
|
|
896
|
+
if set_code == "":
|
|
897
|
+
raise DeckBuilderError(
|
|
898
|
+
f"Pool file {source_label} must include set_code or be used with --set-code."
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
return set_code.upper()
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _matching_states(
|
|
906
|
+
*,
|
|
907
|
+
states: tuple[DraftState, ...],
|
|
908
|
+
account_id: str | None,
|
|
909
|
+
draft_id: str | None,
|
|
910
|
+
) -> tuple[DraftState, ...]:
|
|
911
|
+
return tuple(
|
|
912
|
+
state
|
|
913
|
+
for state in states
|
|
914
|
+
if (account_id is None or state.account_id == account_id)
|
|
915
|
+
and (draft_id is None or state.draft_id == draft_id)
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _latest_state(*, states: tuple[DraftState, ...]) -> DraftState:
|
|
921
|
+
return max(
|
|
922
|
+
states,
|
|
923
|
+
key=lambda state: (state.updated_at, state.account_id, state.draft_id),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _missing_persisted_pool_message(
|
|
929
|
+
*,
|
|
930
|
+
account_id: str | None,
|
|
931
|
+
draft_id: str | None,
|
|
932
|
+
) -> str:
|
|
933
|
+
if account_id is not None and draft_id is not None:
|
|
934
|
+
return f"No persisted pool found for account {account_id!r} and draft {draft_id!r}."
|
|
935
|
+
|
|
936
|
+
if account_id is not None:
|
|
937
|
+
return f"No persisted pool found for account {account_id!r}."
|
|
938
|
+
|
|
939
|
+
if draft_id is not None:
|
|
940
|
+
return f"No persisted pool found for draft {draft_id!r}."
|
|
941
|
+
|
|
942
|
+
return "No persisted pools found. Pass --pool or replay/watch a draft first."
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _validate_metadata_coverage(
|
|
946
|
+
*,
|
|
947
|
+
pool_grp_ids: tuple[int, ...],
|
|
948
|
+
card_database: CardDatabase,
|
|
949
|
+
config: DeckBuilderConfig,
|
|
950
|
+
) -> None:
|
|
951
|
+
if not pool_grp_ids:
|
|
952
|
+
raise DeckBuilderError("Deck build unavailable: pool is empty.")
|
|
953
|
+
|
|
954
|
+
unresolved_grp_ids = _unresolved_pool_grp_ids(
|
|
955
|
+
pool_grp_ids=pool_grp_ids,
|
|
956
|
+
card_database=card_database,
|
|
957
|
+
)
|
|
958
|
+
if not unresolved_grp_ids:
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
unresolved_ratio = len(unresolved_grp_ids) / len(pool_grp_ids)
|
|
962
|
+
if unresolved_ratio > config.maximum_unresolved_metadata_ratio:
|
|
963
|
+
raise DeckBuilderError(
|
|
964
|
+
_metadata_missing_message(
|
|
965
|
+
pool_size=len(pool_grp_ids),
|
|
966
|
+
unresolved_grp_ids=unresolved_grp_ids,
|
|
967
|
+
detail=(
|
|
968
|
+
"Too much of the pool is unresolved for reliable "
|
|
969
|
+
"playable-card detection."
|
|
970
|
+
),
|
|
971
|
+
)
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _validate_playable_pair_scores(
|
|
976
|
+
*,
|
|
977
|
+
scores: tuple[PairScore, ...],
|
|
978
|
+
pool_grp_ids: tuple[int, ...],
|
|
979
|
+
card_database: CardDatabase,
|
|
980
|
+
config: DeckBuilderConfig,
|
|
981
|
+
) -> None:
|
|
982
|
+
best_playable_count = max((score.playable_count for score in scores), default=0)
|
|
983
|
+
if best_playable_count <= 0:
|
|
984
|
+
unresolved_grp_ids = _unresolved_pool_grp_ids(
|
|
985
|
+
pool_grp_ids=pool_grp_ids,
|
|
986
|
+
card_database=card_database,
|
|
987
|
+
)
|
|
988
|
+
if unresolved_grp_ids:
|
|
989
|
+
raise DeckBuilderError(
|
|
990
|
+
_metadata_missing_message(
|
|
991
|
+
pool_size=len(pool_grp_ids),
|
|
992
|
+
unresolved_grp_ids=unresolved_grp_ids,
|
|
993
|
+
detail="No playable spells could be identified from the known cards.",
|
|
994
|
+
)
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
raise DeckBuilderError(
|
|
998
|
+
"Deck build unavailable: no playable spells were detected in the pool, "
|
|
999
|
+
"so no automatic color pair can be trusted."
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
_validate_playable_count_with_metadata(
|
|
1003
|
+
playable_count=best_playable_count,
|
|
1004
|
+
pool_grp_ids=pool_grp_ids,
|
|
1005
|
+
card_database=card_database,
|
|
1006
|
+
target_spell_count=config.target_spell_count,
|
|
1007
|
+
label="playable spells",
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _validate_spell_candidates(
|
|
1012
|
+
*,
|
|
1013
|
+
candidates: tuple[ScoredCard, ...],
|
|
1014
|
+
pool_grp_ids: tuple[int, ...],
|
|
1015
|
+
card_database: CardDatabase,
|
|
1016
|
+
pair: str,
|
|
1017
|
+
config: DeckBuilderConfig,
|
|
1018
|
+
) -> None:
|
|
1019
|
+
if not candidates:
|
|
1020
|
+
unresolved_grp_ids = _unresolved_pool_grp_ids(
|
|
1021
|
+
pool_grp_ids=pool_grp_ids,
|
|
1022
|
+
card_database=card_database,
|
|
1023
|
+
)
|
|
1024
|
+
if unresolved_grp_ids:
|
|
1025
|
+
raise DeckBuilderError(
|
|
1026
|
+
_metadata_missing_message(
|
|
1027
|
+
pool_size=len(pool_grp_ids),
|
|
1028
|
+
unresolved_grp_ids=unresolved_grp_ids,
|
|
1029
|
+
detail=(
|
|
1030
|
+
f"No playable {pair} spells could be identified "
|
|
1031
|
+
"from the known cards."
|
|
1032
|
+
),
|
|
1033
|
+
)
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
raise DeckBuilderError(
|
|
1037
|
+
f"Deck build unavailable: no playable spells were detected for pair {pair}."
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
_validate_playable_count_with_metadata(
|
|
1041
|
+
playable_count=len(candidates),
|
|
1042
|
+
pool_grp_ids=pool_grp_ids,
|
|
1043
|
+
card_database=card_database,
|
|
1044
|
+
target_spell_count=config.target_spell_count,
|
|
1045
|
+
label=f"playable {pair} spells",
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _validate_playable_count_with_metadata(
|
|
1050
|
+
*,
|
|
1051
|
+
playable_count: int,
|
|
1052
|
+
pool_grp_ids: tuple[int, ...],
|
|
1053
|
+
card_database: CardDatabase,
|
|
1054
|
+
target_spell_count: int,
|
|
1055
|
+
label: str,
|
|
1056
|
+
) -> None:
|
|
1057
|
+
unresolved_grp_ids = _unresolved_pool_grp_ids(
|
|
1058
|
+
pool_grp_ids=pool_grp_ids,
|
|
1059
|
+
card_database=card_database,
|
|
1060
|
+
)
|
|
1061
|
+
if not unresolved_grp_ids:
|
|
1062
|
+
return
|
|
1063
|
+
|
|
1064
|
+
required_count = min(target_spell_count, len(pool_grp_ids))
|
|
1065
|
+
if playable_count >= required_count:
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
raise DeckBuilderError(
|
|
1069
|
+
_metadata_missing_message(
|
|
1070
|
+
pool_size=len(pool_grp_ids),
|
|
1071
|
+
unresolved_grp_ids=unresolved_grp_ids,
|
|
1072
|
+
detail=(
|
|
1073
|
+
f"Only {playable_count} {label} could be identified, below the "
|
|
1074
|
+
f"{required_count}-card target for this pool."
|
|
1075
|
+
),
|
|
1076
|
+
)
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _unresolved_pool_grp_ids(
|
|
1081
|
+
*,
|
|
1082
|
+
pool_grp_ids: tuple[int, ...],
|
|
1083
|
+
card_database: CardDatabase,
|
|
1084
|
+
) -> tuple[int, ...]:
|
|
1085
|
+
return tuple(
|
|
1086
|
+
grp_id
|
|
1087
|
+
for grp_id in pool_grp_ids
|
|
1088
|
+
if card_database.lookup(grp_id=grp_id).unknown
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _metadata_missing_message(
|
|
1093
|
+
*,
|
|
1094
|
+
pool_size: int,
|
|
1095
|
+
unresolved_grp_ids: tuple[int, ...],
|
|
1096
|
+
detail: str,
|
|
1097
|
+
) -> str:
|
|
1098
|
+
unresolved_count = len(unresolved_grp_ids)
|
|
1099
|
+
unresolved_percent = (unresolved_count / pool_size) * 100.0
|
|
1100
|
+
return (
|
|
1101
|
+
"Card metadata is missing for "
|
|
1102
|
+
f"{unresolved_count}/{pool_size} picked cards "
|
|
1103
|
+
f"({unresolved_percent:.0f}%). "
|
|
1104
|
+
f"{detail} "
|
|
1105
|
+
"The build cannot be trusted, so no deck was produced. "
|
|
1106
|
+
"Run `draftomen-tui refresh-data` or pass `--bulk-file` with current card data, "
|
|
1107
|
+
"then build again. "
|
|
1108
|
+
f"Unresolved grpIds: {_format_grp_id_preview(grp_ids=unresolved_grp_ids)}."
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _format_grp_id_preview(*, grp_ids: tuple[int, ...]) -> str:
|
|
1113
|
+
unique_grp_ids = tuple(dict.fromkeys(grp_ids))
|
|
1114
|
+
preview = unique_grp_ids[:5]
|
|
1115
|
+
suffix = ""
|
|
1116
|
+
if len(unique_grp_ids) > len(preview):
|
|
1117
|
+
suffix = f", +{len(unique_grp_ids) - len(preview)} more"
|
|
1118
|
+
|
|
1119
|
+
return ", ".join(str(grp_id) for grp_id in preview) + suffix
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _score_pair(
|
|
1123
|
+
*,
|
|
1124
|
+
pair: str,
|
|
1125
|
+
scored_cards: tuple[ScoredCard, ...],
|
|
1126
|
+
ratings_data: SeventeenLandsData | None,
|
|
1127
|
+
config: DeckBuilderConfig,
|
|
1128
|
+
) -> PairScore:
|
|
1129
|
+
playable_cards = tuple(
|
|
1130
|
+
card for card in scored_cards if _is_base_eligible_spell_for_pair(card=card, pair=pair)
|
|
1131
|
+
)
|
|
1132
|
+
top_cards = playable_cards[: config.target_spell_count]
|
|
1133
|
+
playable_score_sum = sum(card.raw_score for card in top_cards)
|
|
1134
|
+
average_playable_score = playable_score_sum / config.target_spell_count
|
|
1135
|
+
pair_win_rate = _pair_win_rate(pair=pair, ratings_data=ratings_data)
|
|
1136
|
+
pair_win_rate_score = _pair_win_rate_score(pair_win_rate=pair_win_rate, config=config)
|
|
1137
|
+
blended_score = _blended_score(
|
|
1138
|
+
average_playable_score=average_playable_score,
|
|
1139
|
+
pair_win_rate_score=pair_win_rate_score,
|
|
1140
|
+
config=config,
|
|
1141
|
+
)
|
|
1142
|
+
return PairScore(
|
|
1143
|
+
pair=pair,
|
|
1144
|
+
playable_count=len(playable_cards),
|
|
1145
|
+
playable_score_sum=playable_score_sum,
|
|
1146
|
+
average_playable_score=average_playable_score,
|
|
1147
|
+
pair_win_rate=pair_win_rate,
|
|
1148
|
+
pair_win_rate_score=pair_win_rate_score,
|
|
1149
|
+
blended_score=blended_score,
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _is_base_eligible_spell_for_pair(*, card: ScoredCard, pair: str) -> bool:
|
|
1155
|
+
return _is_spell_card(card=card.card) and _is_playable_in_pair(card=card, pair=pair)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _is_eligible_spell_for_pair(
|
|
1160
|
+
*,
|
|
1161
|
+
card: ScoredCard,
|
|
1162
|
+
pair: str,
|
|
1163
|
+
allow_splash: bool,
|
|
1164
|
+
splash_color: str | None,
|
|
1165
|
+
splash_limit: int,
|
|
1166
|
+
config: DeckBuilderConfig,
|
|
1167
|
+
) -> bool:
|
|
1168
|
+
if _is_base_eligible_spell_for_pair(card=card, pair=pair):
|
|
1169
|
+
return True
|
|
1170
|
+
|
|
1171
|
+
if not allow_splash or splash_limit <= 0 or splash_color is None:
|
|
1172
|
+
return False
|
|
1173
|
+
|
|
1174
|
+
if not _is_spell_card(card=card.card):
|
|
1175
|
+
return False
|
|
1176
|
+
|
|
1177
|
+
if card.raw_score < config.splash_elite_score_minimum:
|
|
1178
|
+
return False
|
|
1179
|
+
|
|
1180
|
+
candidate_color, off_color_pips = splash_requirement(
|
|
1181
|
+
card=card.card,
|
|
1182
|
+
base_pair=pair,
|
|
1183
|
+
)
|
|
1184
|
+
if candidate_color != splash_color:
|
|
1185
|
+
return False
|
|
1186
|
+
|
|
1187
|
+
if off_color_pips > SPLASH.maximum_off_color_pips:
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
return grade_at_least(
|
|
1191
|
+
grade=card.rating.letter_grade,
|
|
1192
|
+
minimum=SPLASH.supported_minimum_grade,
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _is_playable_in_pair(*, card: ScoredCard, pair: str) -> bool:
|
|
1198
|
+
return _card_is_playable_in_pair(card=card.card, pair=pair)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _card_is_playable_in_pair(*, card: CardInfo, pair: str) -> bool:
|
|
1203
|
+
return card_is_castable_in_pair(card=card, base_pair=pair)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _is_spell_card(*, card: CardInfo) -> bool:
|
|
1208
|
+
for type_line in card.types:
|
|
1209
|
+
faces = tuple(part.strip() for part in type_line.split("//"))
|
|
1210
|
+
if any(_type_face_is_spell(face=face) for face in faces):
|
|
1211
|
+
return True
|
|
1212
|
+
|
|
1213
|
+
return False
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _type_face_is_spell(*, face: str) -> bool:
|
|
1218
|
+
if "Land" in face:
|
|
1219
|
+
return False
|
|
1220
|
+
|
|
1221
|
+
return any(marker in face for marker in SPELL_TYPE_MARKERS)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _card_has_type(*, card: CardInfo, marker: str) -> bool:
|
|
1226
|
+
return any(
|
|
1227
|
+
marker in face
|
|
1228
|
+
for type_line in card.types
|
|
1229
|
+
for face in type_line.split("//")
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _is_creature_card(*, card: CardInfo) -> bool:
|
|
1234
|
+
return _card_has_type(card=card, marker="Creature")
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _is_instant_card(*, card: CardInfo) -> bool:
|
|
1238
|
+
return _card_has_type(card=card, marker="Instant")
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _is_two_drop(*, card: ScoredCard, config: DeckBuilderConfig) -> bool:
|
|
1243
|
+
return card.card.mana_value == config.two_drop_mana_value
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def _is_expensive_spell(*, card: ScoredCard, config: DeckBuilderConfig) -> bool:
|
|
1248
|
+
mana_value = card.card.mana_value
|
|
1249
|
+
return mana_value is not None and mana_value >= config.expensive_spell_mana_value
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _structure_targets_for_pair(
|
|
1254
|
+
*,
|
|
1255
|
+
ratings_data: SeventeenLandsData | None,
|
|
1256
|
+
pair: str,
|
|
1257
|
+
) -> StructuralTargets | None:
|
|
1258
|
+
if ratings_data is None:
|
|
1259
|
+
return None
|
|
1260
|
+
|
|
1261
|
+
return ratings_data.structure_targets_for(pair=pair)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _config_with_structure_targets(
|
|
1266
|
+
*,
|
|
1267
|
+
config: DeckBuilderConfig,
|
|
1268
|
+
structure_targets: StructuralTargets | None,
|
|
1269
|
+
) -> DeckBuilderConfig:
|
|
1270
|
+
if structure_targets is None:
|
|
1271
|
+
return config
|
|
1272
|
+
|
|
1273
|
+
land_count = _clamp_int(
|
|
1274
|
+
value=_round_half_up(structure_targets.average_land_count),
|
|
1275
|
+
lower=0,
|
|
1276
|
+
upper=config.deck_size,
|
|
1277
|
+
)
|
|
1278
|
+
creature_center = _round_half_up(structure_targets.average_creature_count)
|
|
1279
|
+
creature_floor = _clamp_int(
|
|
1280
|
+
value=math.floor(structure_targets.average_creature_count),
|
|
1281
|
+
lower=0,
|
|
1282
|
+
upper=config.target_spell_count,
|
|
1283
|
+
)
|
|
1284
|
+
creature_ceiling = _clamp_int(
|
|
1285
|
+
value=max(creature_center, math.ceil(structure_targets.average_creature_count)),
|
|
1286
|
+
lower=creature_floor,
|
|
1287
|
+
upper=config.target_spell_count,
|
|
1288
|
+
)
|
|
1289
|
+
target_spell_count = config.target_spell_count
|
|
1290
|
+
if config.target_spell_count == DECK_BUILDER.target_spell_count:
|
|
1291
|
+
target_spell_count = config.deck_size - land_count
|
|
1292
|
+
|
|
1293
|
+
return replace(
|
|
1294
|
+
config,
|
|
1295
|
+
target_spell_count=target_spell_count,
|
|
1296
|
+
default_land_count=land_count,
|
|
1297
|
+
creature_floor=creature_floor,
|
|
1298
|
+
creature_ceiling=creature_ceiling,
|
|
1299
|
+
minimum_two_drops=_clamp_int(
|
|
1300
|
+
value=_round_half_up(structure_targets.average_two_drop_count),
|
|
1301
|
+
lower=0,
|
|
1302
|
+
upper=target_spell_count,
|
|
1303
|
+
),
|
|
1304
|
+
maximum_expensive_spells=_clamp_int(
|
|
1305
|
+
value=math.ceil(structure_targets.average_expensive_spell_count),
|
|
1306
|
+
lower=0,
|
|
1307
|
+
upper=target_spell_count,
|
|
1308
|
+
),
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _round_half_up(value: float) -> int:
|
|
1314
|
+
return int(math.floor(value + 0.5))
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _clamp_int(*, value: int, lower: int, upper: int) -> int:
|
|
1319
|
+
return min(max(value, lower), upper)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _card_splash_colors(*, card: CardInfo, pair: str) -> tuple[str, ...]:
|
|
1324
|
+
return tuple(color for color in card.colors if color not in pair)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _is_splash_card(*, card: CardInfo, pair: str) -> bool:
|
|
1329
|
+
return bool(_card_splash_colors(card=card, pair=pair))
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _selected_splash_colors(
|
|
1334
|
+
*,
|
|
1335
|
+
cards: tuple[ScoredCard, ...],
|
|
1336
|
+
pair: str,
|
|
1337
|
+
) -> tuple[str, ...]:
|
|
1338
|
+
splash_colors = {
|
|
1339
|
+
color
|
|
1340
|
+
for scored_card in cards
|
|
1341
|
+
for color in _card_splash_colors(card=scored_card.card, pair=pair)
|
|
1342
|
+
}
|
|
1343
|
+
return tuple(color for color in BASIC_LANDS_BY_COLOR if color in splash_colors)
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _supported_splash_card_limit(
|
|
1347
|
+
*,
|
|
1348
|
+
state: SplashState,
|
|
1349
|
+
config: DeckBuilderConfig,
|
|
1350
|
+
) -> int:
|
|
1351
|
+
color = state.active_color
|
|
1352
|
+
if not state.enabled or color is None:
|
|
1353
|
+
return 0
|
|
1354
|
+
|
|
1355
|
+
available_sources = (
|
|
1356
|
+
state.fixing_for(color=color) + SPLASH.planned_basic_sources
|
|
1357
|
+
)
|
|
1358
|
+
if available_sources >= SPLASH.multiple_card_sources:
|
|
1359
|
+
return min(config.splash_max_cards, SPLASH.maximum_cards)
|
|
1360
|
+
|
|
1361
|
+
if available_sources >= SPLASH.single_card_sources:
|
|
1362
|
+
return min(config.splash_max_cards, 1)
|
|
1363
|
+
|
|
1364
|
+
return 0
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _constraints_for_plan(
|
|
1368
|
+
*,
|
|
1369
|
+
candidates: tuple[ScoredCard, ...],
|
|
1370
|
+
pair: str,
|
|
1371
|
+
splash_limit: int,
|
|
1372
|
+
plan: _ConstraintPlan,
|
|
1373
|
+
config: DeckBuilderConfig,
|
|
1374
|
+
) -> SpellConstraints:
|
|
1375
|
+
requested_target = min(config.target_spell_count, len(candidates))
|
|
1376
|
+
pool_counts = _spell_counts(cards=candidates, pair=pair, config=config)
|
|
1377
|
+
resolved_splash_limit = min(splash_limit, requested_target)
|
|
1378
|
+
non_splash_count = pool_counts.total - pool_counts.splashes
|
|
1379
|
+
target = min(requested_target, non_splash_count + resolved_splash_limit)
|
|
1380
|
+
|
|
1381
|
+
creature_floor = (
|
|
1382
|
+
min(config.creature_floor, pool_counts.creatures, target)
|
|
1383
|
+
if plan.enforce_creature_floor
|
|
1384
|
+
else 0
|
|
1385
|
+
)
|
|
1386
|
+
if plan.enforce_creature_ceiling:
|
|
1387
|
+
requested_ceiling = min(config.creature_ceiling, target)
|
|
1388
|
+
needed_creatures = max(0, target - pool_counts.noncreatures)
|
|
1389
|
+
creature_ceiling = min(
|
|
1390
|
+
target,
|
|
1391
|
+
max(requested_ceiling, needed_creatures, creature_floor),
|
|
1392
|
+
)
|
|
1393
|
+
else:
|
|
1394
|
+
creature_ceiling = target
|
|
1395
|
+
|
|
1396
|
+
minimum_two_drops = (
|
|
1397
|
+
min(config.minimum_two_drops, pool_counts.two_drops, target)
|
|
1398
|
+
if plan.enforce_two_drop_minimum
|
|
1399
|
+
else 0
|
|
1400
|
+
)
|
|
1401
|
+
if plan.enforce_expensive_cap:
|
|
1402
|
+
requested_cap = min(config.maximum_expensive_spells, target)
|
|
1403
|
+
needed_expensive = max(0, target - pool_counts.non_expensive)
|
|
1404
|
+
maximum_expensive_spells = min(target, max(requested_cap, needed_expensive))
|
|
1405
|
+
else:
|
|
1406
|
+
maximum_expensive_spells = target
|
|
1407
|
+
|
|
1408
|
+
return SpellConstraints(
|
|
1409
|
+
spell_count=target,
|
|
1410
|
+
creature_floor=creature_floor,
|
|
1411
|
+
creature_ceiling=creature_ceiling,
|
|
1412
|
+
minimum_two_drops=minimum_two_drops,
|
|
1413
|
+
maximum_expensive_spells=maximum_expensive_spells,
|
|
1414
|
+
maximum_splash_spells=min(resolved_splash_limit, target),
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _constraint_plans() -> tuple[_ConstraintPlan, ...]:
|
|
1420
|
+
return (
|
|
1421
|
+
_ConstraintPlan(
|
|
1422
|
+
enforce_expensive_cap=True,
|
|
1423
|
+
enforce_two_drop_minimum=True,
|
|
1424
|
+
enforce_creature_ceiling=True,
|
|
1425
|
+
enforce_creature_floor=True,
|
|
1426
|
+
),
|
|
1427
|
+
_ConstraintPlan(
|
|
1428
|
+
enforce_expensive_cap=False,
|
|
1429
|
+
enforce_two_drop_minimum=True,
|
|
1430
|
+
enforce_creature_ceiling=True,
|
|
1431
|
+
enforce_creature_floor=True,
|
|
1432
|
+
),
|
|
1433
|
+
_ConstraintPlan(
|
|
1434
|
+
enforce_expensive_cap=False,
|
|
1435
|
+
enforce_two_drop_minimum=False,
|
|
1436
|
+
enforce_creature_ceiling=True,
|
|
1437
|
+
enforce_creature_floor=True,
|
|
1438
|
+
),
|
|
1439
|
+
_ConstraintPlan(
|
|
1440
|
+
enforce_expensive_cap=False,
|
|
1441
|
+
enforce_two_drop_minimum=False,
|
|
1442
|
+
enforce_creature_ceiling=False,
|
|
1443
|
+
enforce_creature_floor=True,
|
|
1444
|
+
),
|
|
1445
|
+
_ConstraintPlan(
|
|
1446
|
+
enforce_expensive_cap=False,
|
|
1447
|
+
enforce_two_drop_minimum=False,
|
|
1448
|
+
enforce_creature_ceiling=False,
|
|
1449
|
+
enforce_creature_floor=False,
|
|
1450
|
+
),
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _select_with_constraints(
|
|
1456
|
+
*,
|
|
1457
|
+
candidates: tuple[ScoredCard, ...],
|
|
1458
|
+
available_quantities: Counter[CardQuantityKey],
|
|
1459
|
+
pair: str,
|
|
1460
|
+
constraints: SpellConstraints,
|
|
1461
|
+
config: DeckBuilderConfig,
|
|
1462
|
+
) -> tuple[ScoredCard, ...] | None:
|
|
1463
|
+
selected: list[ScoredCard] = []
|
|
1464
|
+
remaining = list(candidates)
|
|
1465
|
+
while len(selected) < constraints.spell_count and remaining:
|
|
1466
|
+
counts = _spell_counts(cards=tuple(selected), pair=pair, config=config)
|
|
1467
|
+
floor_unmet = counts.creatures < constraints.creature_floor
|
|
1468
|
+
ordered_indices = sorted(
|
|
1469
|
+
range(len(remaining)),
|
|
1470
|
+
key=lambda index: _candidate_selection_sort_key(
|
|
1471
|
+
card=remaining[index],
|
|
1472
|
+
floor_unmet=floor_unmet,
|
|
1473
|
+
config=config,
|
|
1474
|
+
),
|
|
1475
|
+
)
|
|
1476
|
+
picked_index = _first_feasible_index(
|
|
1477
|
+
ordered_indices=tuple(ordered_indices),
|
|
1478
|
+
selected=tuple(selected),
|
|
1479
|
+
remaining=tuple(remaining),
|
|
1480
|
+
available_quantities=available_quantities,
|
|
1481
|
+
constraints=constraints,
|
|
1482
|
+
pair=pair,
|
|
1483
|
+
config=config,
|
|
1484
|
+
)
|
|
1485
|
+
if picked_index is None:
|
|
1486
|
+
return None
|
|
1487
|
+
|
|
1488
|
+
selected.append(remaining.pop(picked_index))
|
|
1489
|
+
|
|
1490
|
+
result = tuple(selected)
|
|
1491
|
+
if _counts_satisfy_constraints(
|
|
1492
|
+
counts=_spell_counts(cards=result, pair=pair, config=config),
|
|
1493
|
+
constraints=constraints,
|
|
1494
|
+
) and not _exceeds_available_card_quantities(
|
|
1495
|
+
cards=result,
|
|
1496
|
+
available_quantities=available_quantities,
|
|
1497
|
+
):
|
|
1498
|
+
return result
|
|
1499
|
+
|
|
1500
|
+
return None
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _first_feasible_index(
|
|
1505
|
+
*,
|
|
1506
|
+
ordered_indices: tuple[int, ...],
|
|
1507
|
+
selected: tuple[ScoredCard, ...],
|
|
1508
|
+
remaining: tuple[ScoredCard, ...],
|
|
1509
|
+
available_quantities: Counter[CardQuantityKey],
|
|
1510
|
+
constraints: SpellConstraints,
|
|
1511
|
+
pair: str,
|
|
1512
|
+
config: DeckBuilderConfig,
|
|
1513
|
+
) -> int | None:
|
|
1514
|
+
for index in ordered_indices:
|
|
1515
|
+
remaining_after = tuple(
|
|
1516
|
+
card for item_index, card in enumerate(remaining) if item_index != index
|
|
1517
|
+
)
|
|
1518
|
+
if _can_add_spell(
|
|
1519
|
+
candidate=remaining[index],
|
|
1520
|
+
selected=selected,
|
|
1521
|
+
remaining_after=remaining_after,
|
|
1522
|
+
available_quantities=available_quantities,
|
|
1523
|
+
constraints=constraints,
|
|
1524
|
+
pair=pair,
|
|
1525
|
+
config=config,
|
|
1526
|
+
):
|
|
1527
|
+
return index
|
|
1528
|
+
|
|
1529
|
+
return None
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _can_add_spell(
|
|
1534
|
+
*,
|
|
1535
|
+
candidate: ScoredCard,
|
|
1536
|
+
selected: tuple[ScoredCard, ...],
|
|
1537
|
+
remaining_after: tuple[ScoredCard, ...],
|
|
1538
|
+
available_quantities: Counter[CardQuantityKey],
|
|
1539
|
+
constraints: SpellConstraints,
|
|
1540
|
+
pair: str,
|
|
1541
|
+
config: DeckBuilderConfig,
|
|
1542
|
+
) -> bool:
|
|
1543
|
+
next_selected = (*selected, candidate)
|
|
1544
|
+
counts = _spell_counts(cards=next_selected, pair=pair, config=config)
|
|
1545
|
+
if counts.total > constraints.spell_count:
|
|
1546
|
+
return False
|
|
1547
|
+
|
|
1548
|
+
if counts.creatures > constraints.creature_ceiling:
|
|
1549
|
+
return False
|
|
1550
|
+
|
|
1551
|
+
if counts.expensive > constraints.maximum_expensive_spells:
|
|
1552
|
+
return False
|
|
1553
|
+
|
|
1554
|
+
if counts.splashes > constraints.maximum_splash_spells:
|
|
1555
|
+
return False
|
|
1556
|
+
|
|
1557
|
+
if _exceeds_available_card_quantities(
|
|
1558
|
+
cards=next_selected,
|
|
1559
|
+
available_quantities=available_quantities,
|
|
1560
|
+
):
|
|
1561
|
+
return False
|
|
1562
|
+
|
|
1563
|
+
return _can_complete_selection(
|
|
1564
|
+
counts=counts,
|
|
1565
|
+
remaining=remaining_after,
|
|
1566
|
+
constraints=constraints,
|
|
1567
|
+
pair=pair,
|
|
1568
|
+
config=config,
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def _can_complete_selection(
|
|
1574
|
+
*,
|
|
1575
|
+
counts: SpellCounts,
|
|
1576
|
+
remaining: tuple[ScoredCard, ...],
|
|
1577
|
+
constraints: SpellConstraints,
|
|
1578
|
+
pair: str,
|
|
1579
|
+
config: DeckBuilderConfig,
|
|
1580
|
+
) -> bool:
|
|
1581
|
+
slots_remaining = constraints.spell_count - counts.total
|
|
1582
|
+
if slots_remaining == 0:
|
|
1583
|
+
return _counts_satisfy_constraints(counts=counts, constraints=constraints)
|
|
1584
|
+
|
|
1585
|
+
if len(remaining) < slots_remaining:
|
|
1586
|
+
return False
|
|
1587
|
+
|
|
1588
|
+
states = {(0, 0, 0, 0, 0)}
|
|
1589
|
+
creature_room = constraints.creature_ceiling - counts.creatures
|
|
1590
|
+
expensive_room = constraints.maximum_expensive_spells - counts.expensive
|
|
1591
|
+
splash_room = constraints.maximum_splash_spells - counts.splashes
|
|
1592
|
+
for card in remaining:
|
|
1593
|
+
creature = 1 if _is_creature_card(card=card.card) else 0
|
|
1594
|
+
two_drop = 1 if _is_two_drop(card=card, config=config) else 0
|
|
1595
|
+
expensive = 1 if _is_expensive_spell(card=card, config=config) else 0
|
|
1596
|
+
splash = 1 if _is_splash_card(card=card.card, pair=pair) else 0
|
|
1597
|
+
next_states = set(states)
|
|
1598
|
+
for selected_count, creatures, two_drops, expensive_spells, splashes in states:
|
|
1599
|
+
if selected_count >= slots_remaining:
|
|
1600
|
+
continue
|
|
1601
|
+
|
|
1602
|
+
next_creatures = creatures + creature
|
|
1603
|
+
next_expensive = expensive_spells + expensive
|
|
1604
|
+
next_splashes = splashes + splash
|
|
1605
|
+
if next_creatures > creature_room or next_expensive > expensive_room:
|
|
1606
|
+
continue
|
|
1607
|
+
|
|
1608
|
+
if next_splashes > splash_room:
|
|
1609
|
+
continue
|
|
1610
|
+
|
|
1611
|
+
next_states.add(
|
|
1612
|
+
(
|
|
1613
|
+
selected_count + 1,
|
|
1614
|
+
next_creatures,
|
|
1615
|
+
min(constraints.minimum_two_drops, two_drops + two_drop),
|
|
1616
|
+
next_expensive,
|
|
1617
|
+
next_splashes,
|
|
1618
|
+
)
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
states = next_states
|
|
1622
|
+
|
|
1623
|
+
for selected_count, creatures, two_drops, expensive_spells, splashes in states:
|
|
1624
|
+
if selected_count != slots_remaining:
|
|
1625
|
+
continue
|
|
1626
|
+
|
|
1627
|
+
final_counts = SpellCounts(
|
|
1628
|
+
total=constraints.spell_count,
|
|
1629
|
+
creatures=counts.creatures + creatures,
|
|
1630
|
+
two_drops=counts.two_drops + two_drops,
|
|
1631
|
+
expensive=counts.expensive + expensive_spells,
|
|
1632
|
+
splashes=counts.splashes + splashes,
|
|
1633
|
+
)
|
|
1634
|
+
if _counts_satisfy_constraints(counts=final_counts, constraints=constraints):
|
|
1635
|
+
return True
|
|
1636
|
+
|
|
1637
|
+
return False
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _candidate_selection_sort_key(
|
|
1642
|
+
*,
|
|
1643
|
+
card: ScoredCard,
|
|
1644
|
+
floor_unmet: bool,
|
|
1645
|
+
config: DeckBuilderConfig,
|
|
1646
|
+
) -> tuple[float, int, float, float, int]:
|
|
1647
|
+
effective_score = card.raw_score
|
|
1648
|
+
creature_preference = 0
|
|
1649
|
+
if floor_unmet:
|
|
1650
|
+
creature = _is_creature_card(card=card.card)
|
|
1651
|
+
if creature:
|
|
1652
|
+
effective_score += config.near_tie_creature_preference_points
|
|
1653
|
+
creature_preference = 0 if creature else 1
|
|
1654
|
+
|
|
1655
|
+
return (
|
|
1656
|
+
-effective_score,
|
|
1657
|
+
creature_preference,
|
|
1658
|
+
-card.raw_score,
|
|
1659
|
+
-card.base_rating,
|
|
1660
|
+
card.original_index,
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def _pool_card_quantities(
|
|
1666
|
+
*,
|
|
1667
|
+
pool_grp_ids: tuple[int, ...],
|
|
1668
|
+
card_database: CardDatabase,
|
|
1669
|
+
) -> Counter[CardQuantityKey]:
|
|
1670
|
+
return Counter(
|
|
1671
|
+
_card_quantity_key(card=card_database.lookup(grp_id=grp_id))
|
|
1672
|
+
for grp_id in pool_grp_ids
|
|
1673
|
+
)
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _limit_cards_to_pool_quantities(
|
|
1678
|
+
*,
|
|
1679
|
+
cards: tuple[ScoredCard, ...],
|
|
1680
|
+
available_quantities: Counter[CardQuantityKey],
|
|
1681
|
+
) -> tuple[ScoredCard, ...]:
|
|
1682
|
+
used_quantities: Counter[CardQuantityKey] = Counter()
|
|
1683
|
+
limited_cards: list[ScoredCard] = []
|
|
1684
|
+
for card in cards:
|
|
1685
|
+
quantity_key = _card_quantity_key(card=card.card)
|
|
1686
|
+
if used_quantities[quantity_key] >= available_quantities[quantity_key]:
|
|
1687
|
+
continue
|
|
1688
|
+
|
|
1689
|
+
used_quantities[quantity_key] += 1
|
|
1690
|
+
limited_cards.append(card)
|
|
1691
|
+
|
|
1692
|
+
return tuple(limited_cards)
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _exceeds_available_card_quantities(
|
|
1697
|
+
*,
|
|
1698
|
+
cards: tuple[ScoredCard, ...],
|
|
1699
|
+
available_quantities: Counter[CardQuantityKey],
|
|
1700
|
+
) -> bool:
|
|
1701
|
+
selected_quantities: Counter[CardQuantityKey] = Counter(
|
|
1702
|
+
_card_quantity_key(card=card.card) for card in cards
|
|
1703
|
+
)
|
|
1704
|
+
return any(
|
|
1705
|
+
count > available_quantities[quantity_key]
|
|
1706
|
+
for quantity_key, count in selected_quantities.items()
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _card_quantity_key(*, card: CardInfo) -> CardQuantityKey:
|
|
1712
|
+
if card.unknown:
|
|
1713
|
+
return ("unknown", str(card.grp_id))
|
|
1714
|
+
|
|
1715
|
+
return ("name", " ".join(card.name.casefold().split()))
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _bench_cards(
|
|
1720
|
+
*,
|
|
1721
|
+
candidates: tuple[ScoredCard, ...],
|
|
1722
|
+
selected: tuple[ScoredCard, ...],
|
|
1723
|
+
config: DeckBuilderConfig,
|
|
1724
|
+
) -> tuple[ScoredCard, ...]:
|
|
1725
|
+
if config.bench_card_count <= 0:
|
|
1726
|
+
return ()
|
|
1727
|
+
|
|
1728
|
+
selected_indices = {card.original_index for card in selected}
|
|
1729
|
+
unselected = tuple(
|
|
1730
|
+
card for card in candidates if card.original_index not in selected_indices
|
|
1731
|
+
)
|
|
1732
|
+
return tuple(sorted(unselected, key=_bench_sort_key)[: config.bench_card_count])
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _bench_sort_key(card: ScoredCard) -> tuple[int, float, float, int]:
|
|
1737
|
+
return (-card.score, -card.raw_score, -card.base_rating, card.original_index)
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _spell_counts(
|
|
1742
|
+
*,
|
|
1743
|
+
cards: tuple[ScoredCard, ...],
|
|
1744
|
+
config: DeckBuilderConfig,
|
|
1745
|
+
pair: str | None = None,
|
|
1746
|
+
) -> SpellCounts:
|
|
1747
|
+
return SpellCounts(
|
|
1748
|
+
total=len(cards),
|
|
1749
|
+
creatures=sum(1 for card in cards if _is_creature_card(card=card.card)),
|
|
1750
|
+
two_drops=sum(1 for card in cards if _is_two_drop(card=card, config=config)),
|
|
1751
|
+
expensive=sum(
|
|
1752
|
+
1 for card in cards if _is_expensive_spell(card=card, config=config)
|
|
1753
|
+
),
|
|
1754
|
+
splashes=(
|
|
1755
|
+
0
|
|
1756
|
+
if pair is None
|
|
1757
|
+
else sum(1 for card in cards if _is_splash_card(card=card.card, pair=pair))
|
|
1758
|
+
),
|
|
1759
|
+
instants=sum(1 for card in cards if _is_instant_card(card=card.card)),
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _counts_satisfy_constraints(
|
|
1765
|
+
*,
|
|
1766
|
+
counts: SpellCounts,
|
|
1767
|
+
constraints: SpellConstraints,
|
|
1768
|
+
) -> bool:
|
|
1769
|
+
return (
|
|
1770
|
+
counts.total == constraints.spell_count
|
|
1771
|
+
and counts.creatures >= constraints.creature_floor
|
|
1772
|
+
and counts.creatures <= constraints.creature_ceiling
|
|
1773
|
+
and counts.two_drops >= constraints.minimum_two_drops
|
|
1774
|
+
and counts.expensive <= constraints.maximum_expensive_spells
|
|
1775
|
+
and counts.splashes <= constraints.maximum_splash_spells
|
|
1776
|
+
)
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def _applied_relaxations(
|
|
1781
|
+
*,
|
|
1782
|
+
plan: _ConstraintPlan,
|
|
1783
|
+
constraints: SpellConstraints,
|
|
1784
|
+
config: DeckBuilderConfig,
|
|
1785
|
+
) -> tuple[str, ...]:
|
|
1786
|
+
relaxations: list[str] = []
|
|
1787
|
+
default_expensive_cap = min(config.maximum_expensive_spells, constraints.spell_count)
|
|
1788
|
+
if (
|
|
1789
|
+
not plan.enforce_expensive_cap
|
|
1790
|
+
or constraints.maximum_expensive_spells > default_expensive_cap
|
|
1791
|
+
):
|
|
1792
|
+
relaxations.append(config.relaxation_order[0])
|
|
1793
|
+
|
|
1794
|
+
default_two_drops = min(config.minimum_two_drops, constraints.spell_count)
|
|
1795
|
+
if (
|
|
1796
|
+
not plan.enforce_two_drop_minimum
|
|
1797
|
+
or constraints.minimum_two_drops < default_two_drops
|
|
1798
|
+
):
|
|
1799
|
+
relaxations.append(config.relaxation_order[1])
|
|
1800
|
+
|
|
1801
|
+
default_creature_ceiling = min(config.creature_ceiling, constraints.spell_count)
|
|
1802
|
+
if (
|
|
1803
|
+
not plan.enforce_creature_ceiling
|
|
1804
|
+
or constraints.creature_ceiling > default_creature_ceiling
|
|
1805
|
+
):
|
|
1806
|
+
relaxations.append(config.relaxation_order[2])
|
|
1807
|
+
|
|
1808
|
+
default_creature_floor = min(config.creature_floor, constraints.spell_count)
|
|
1809
|
+
if (
|
|
1810
|
+
not plan.enforce_creature_floor
|
|
1811
|
+
or constraints.creature_floor < default_creature_floor
|
|
1812
|
+
):
|
|
1813
|
+
relaxations.append(config.relaxation_order[3])
|
|
1814
|
+
|
|
1815
|
+
if constraints.spell_count < config.target_spell_count:
|
|
1816
|
+
relaxations.append(config.relaxation_order[4])
|
|
1817
|
+
|
|
1818
|
+
return tuple(dict.fromkeys(relaxations))
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
def _curve_land_count(
|
|
1822
|
+
*,
|
|
1823
|
+
selection: SpellSelection,
|
|
1824
|
+
config: DeckBuilderConfig,
|
|
1825
|
+
) -> tuple[int, str]:
|
|
1826
|
+
average_mana_value = _average_mana_value(cards=selection.spells)
|
|
1827
|
+
if (
|
|
1828
|
+
average_mana_value >= config.top_heavy_average_mana_value_min
|
|
1829
|
+
or selection.counts.expensive > config.maximum_expensive_spells
|
|
1830
|
+
):
|
|
1831
|
+
return (
|
|
1832
|
+
config.top_heavy_land_count,
|
|
1833
|
+
f"top-heavy curve: avg MV {average_mana_value:.2f}",
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
if (
|
|
1837
|
+
average_mana_value <= config.aggressive_average_mana_value_max
|
|
1838
|
+
and selection.counts.two_drops >= config.minimum_two_drops
|
|
1839
|
+
):
|
|
1840
|
+
return (
|
|
1841
|
+
config.aggressive_land_count,
|
|
1842
|
+
"aggressive curve: "
|
|
1843
|
+
f"avg MV {average_mana_value:.2f}, "
|
|
1844
|
+
f"{selection.counts.two_drops} two-drops",
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
return (
|
|
1848
|
+
config.default_land_count,
|
|
1849
|
+
f"default curve: avg MV {average_mana_value:.2f}",
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def _selected_nonbasic_lands(
|
|
1854
|
+
*,
|
|
1855
|
+
pool_grp_ids: tuple[int, ...],
|
|
1856
|
+
card_database: CardDatabase,
|
|
1857
|
+
pair: str,
|
|
1858
|
+
land_count: int,
|
|
1859
|
+
splash_colors: tuple[str, ...],
|
|
1860
|
+
) -> tuple[LandCard, ...]:
|
|
1861
|
+
lands: list[LandCard] = []
|
|
1862
|
+
for index, grp_id in enumerate(pool_grp_ids):
|
|
1863
|
+
card = card_database.lookup(grp_id=grp_id)
|
|
1864
|
+
if not _is_selected_nonbasic_land(
|
|
1865
|
+
card=card,
|
|
1866
|
+
pair=pair,
|
|
1867
|
+
splash_colors=splash_colors,
|
|
1868
|
+
):
|
|
1869
|
+
continue
|
|
1870
|
+
|
|
1871
|
+
lands.append(
|
|
1872
|
+
LandCard(
|
|
1873
|
+
card=card,
|
|
1874
|
+
original_index=index,
|
|
1875
|
+
source_colors=_land_source_colors(card=card),
|
|
1876
|
+
)
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
return tuple(sorted(lands, key=lambda land: _nonbasic_land_sort_key(
|
|
1880
|
+
land=land,
|
|
1881
|
+
pair=pair,
|
|
1882
|
+
splash_colors=splash_colors,
|
|
1883
|
+
))[:land_count])
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def _is_selected_nonbasic_land(
|
|
1887
|
+
*,
|
|
1888
|
+
card: CardInfo,
|
|
1889
|
+
pair: str,
|
|
1890
|
+
splash_colors: tuple[str, ...],
|
|
1891
|
+
) -> bool:
|
|
1892
|
+
if not _is_land_card(card=card) or _is_basic_land_card(card=card):
|
|
1893
|
+
return False
|
|
1894
|
+
|
|
1895
|
+
source_colors = _land_source_colors(card=card)
|
|
1896
|
+
if not source_colors:
|
|
1897
|
+
return False
|
|
1898
|
+
|
|
1899
|
+
if all(color in pair for color in source_colors):
|
|
1900
|
+
return True
|
|
1901
|
+
|
|
1902
|
+
return bool(splash_colors) and any(color in splash_colors for color in source_colors)
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def _nonbasic_land_sort_key(
|
|
1906
|
+
*,
|
|
1907
|
+
land: LandCard,
|
|
1908
|
+
pair: str,
|
|
1909
|
+
splash_colors: tuple[str, ...],
|
|
1910
|
+
) -> tuple[int, int]:
|
|
1911
|
+
fixes_splash = any(color in splash_colors for color in land.source_colors)
|
|
1912
|
+
supports_pair = any(color in pair for color in land.source_colors)
|
|
1913
|
+
if fixes_splash and supports_pair:
|
|
1914
|
+
priority = 0
|
|
1915
|
+
elif supports_pair:
|
|
1916
|
+
priority = 1
|
|
1917
|
+
else:
|
|
1918
|
+
priority = 2
|
|
1919
|
+
|
|
1920
|
+
return (priority, land.original_index)
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def _is_in_pair_nonbasic_land(*, card: CardInfo, pair: str) -> bool:
|
|
1924
|
+
return _is_selected_nonbasic_land(card=card, pair=pair, splash_colors=())
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
def _is_land_card(*, card: CardInfo) -> bool:
|
|
1928
|
+
return any("Land" in type_line for type_line in card.types)
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def _is_basic_land_card(*, card: CardInfo) -> bool:
|
|
1932
|
+
if card.name in set(BASIC_LANDS_BY_COLOR.values()):
|
|
1933
|
+
return True
|
|
1934
|
+
|
|
1935
|
+
return any(
|
|
1936
|
+
"Basic" in type_line and "Land" in type_line
|
|
1937
|
+
for type_line in card.types
|
|
1938
|
+
)
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
def _land_source_colors(*, card: CardInfo) -> tuple[str, ...]:
|
|
1942
|
+
source_colors = card.produced_mana or card.colors
|
|
1943
|
+
source_set = set(source_colors)
|
|
1944
|
+
return tuple(color for color in BASIC_LANDS_BY_COLOR if color in source_set)
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def _splash_basic_land_counts(
|
|
1948
|
+
*,
|
|
1949
|
+
splash_colors: tuple[str, ...],
|
|
1950
|
+
selected_spells: tuple[ScoredCard, ...],
|
|
1951
|
+
pair: str,
|
|
1952
|
+
nonbasic_lands: tuple[LandCard, ...],
|
|
1953
|
+
) -> tuple[BasicLandCount, ...]:
|
|
1954
|
+
if not splash_colors or SPLASH.planned_basic_sources <= 0:
|
|
1955
|
+
return ()
|
|
1956
|
+
|
|
1957
|
+
splash_color = splash_colors[0]
|
|
1958
|
+
splash_card_count = sum(
|
|
1959
|
+
_is_splash_card(card=scored_card.card, pair=pair)
|
|
1960
|
+
for scored_card in selected_spells
|
|
1961
|
+
)
|
|
1962
|
+
required_sources = (
|
|
1963
|
+
SPLASH.single_card_sources
|
|
1964
|
+
if splash_card_count <= 1
|
|
1965
|
+
else SPLASH.multiple_card_sources
|
|
1966
|
+
)
|
|
1967
|
+
drafted_sources = sum(
|
|
1968
|
+
splash_color in land.source_colors for land in nonbasic_lands
|
|
1969
|
+
)
|
|
1970
|
+
basic_count = min(
|
|
1971
|
+
SPLASH.planned_basic_sources,
|
|
1972
|
+
max(0, required_sources - drafted_sources),
|
|
1973
|
+
)
|
|
1974
|
+
if basic_count <= 0:
|
|
1975
|
+
return ()
|
|
1976
|
+
|
|
1977
|
+
return (
|
|
1978
|
+
BasicLandCount(
|
|
1979
|
+
color=splash_color,
|
|
1980
|
+
name=BASIC_LANDS_BY_COLOR[splash_color],
|
|
1981
|
+
count=basic_count,
|
|
1982
|
+
),
|
|
1983
|
+
)
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _spell_pip_counts(
|
|
1987
|
+
*,
|
|
1988
|
+
cards: tuple[ScoredCard, ...],
|
|
1989
|
+
pair: str,
|
|
1990
|
+
) -> tuple[dict[str, int], dict[str, int]]:
|
|
1991
|
+
pip_counts = {color: 0 for color in pair}
|
|
1992
|
+
double_pip_counts = {color: 0 for color in pair}
|
|
1993
|
+
for scored_card in cards:
|
|
1994
|
+
card_counts = _card_pip_counts(card=scored_card.card, pair=pair)
|
|
1995
|
+
for color in pair:
|
|
1996
|
+
pips = card_counts[color]
|
|
1997
|
+
pip_counts[color] += pips
|
|
1998
|
+
double_pip_counts[color] += max(0, pips - 1)
|
|
1999
|
+
|
|
2000
|
+
return pip_counts, double_pip_counts
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _card_pip_counts(*, card: CardInfo, pair: str) -> dict[str, int]:
|
|
2004
|
+
counts = {color: 0 for color in pair}
|
|
2005
|
+
if card.mana_cost:
|
|
2006
|
+
for symbol in MANA_SYMBOL_PATTERN.findall(card.mana_cost):
|
|
2007
|
+
for color in pair:
|
|
2008
|
+
if color in symbol:
|
|
2009
|
+
counts[color] += 1
|
|
2010
|
+
|
|
2011
|
+
return counts
|
|
2012
|
+
|
|
2013
|
+
for color in card.colors:
|
|
2014
|
+
if color in counts:
|
|
2015
|
+
counts[color] += 1
|
|
2016
|
+
|
|
2017
|
+
return counts
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
def _source_counts(
|
|
2021
|
+
*,
|
|
2022
|
+
pair: str,
|
|
2023
|
+
nonbasic_lands: tuple[LandCard, ...],
|
|
2024
|
+
) -> dict[str, int]:
|
|
2025
|
+
counts = {color: 0 for color in pair}
|
|
2026
|
+
for land in nonbasic_lands:
|
|
2027
|
+
for color in land.source_colors:
|
|
2028
|
+
if color in counts:
|
|
2029
|
+
counts[color] += 1
|
|
2030
|
+
|
|
2031
|
+
return counts
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def _source_counts_with_basics(
|
|
2035
|
+
*,
|
|
2036
|
+
pair: str,
|
|
2037
|
+
nonbasic_lands: tuple[LandCard, ...],
|
|
2038
|
+
basic_lands: tuple[BasicLandCount, ...],
|
|
2039
|
+
) -> dict[str, int]:
|
|
2040
|
+
counts = _source_counts(pair=pair, nonbasic_lands=nonbasic_lands)
|
|
2041
|
+
for basic in basic_lands:
|
|
2042
|
+
counts[basic.color] += basic.count
|
|
2043
|
+
|
|
2044
|
+
return counts
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _basic_land_counts(
|
|
2048
|
+
*,
|
|
2049
|
+
pair: str,
|
|
2050
|
+
slots: int,
|
|
2051
|
+
pip_counts: dict[str, int],
|
|
2052
|
+
double_pip_counts: dict[str, int],
|
|
2053
|
+
source_counts: dict[str, int],
|
|
2054
|
+
config: DeckBuilderConfig,
|
|
2055
|
+
) -> tuple[BasicLandCount, ...]:
|
|
2056
|
+
if slots <= 0:
|
|
2057
|
+
return ()
|
|
2058
|
+
|
|
2059
|
+
colors = tuple(pair)
|
|
2060
|
+
desired_counts = _desired_basic_counts(
|
|
2061
|
+
colors=colors,
|
|
2062
|
+
slots=slots,
|
|
2063
|
+
pip_counts=pip_counts,
|
|
2064
|
+
)
|
|
2065
|
+
double_heavy_color = max(
|
|
2066
|
+
colors,
|
|
2067
|
+
key=lambda color: (double_pip_counts[color], pip_counts[color], -colors.index(color)),
|
|
2068
|
+
)
|
|
2069
|
+
pip_heavy_color = max(
|
|
2070
|
+
colors,
|
|
2071
|
+
key=lambda color: (pip_counts[color], -colors.index(color)),
|
|
2072
|
+
)
|
|
2073
|
+
best_counts = min(
|
|
2074
|
+
_two_color_basic_splits(colors=colors, slots=slots),
|
|
2075
|
+
key=lambda counts: _basic_split_sort_key(
|
|
2076
|
+
counts=counts,
|
|
2077
|
+
colors=colors,
|
|
2078
|
+
desired_counts=desired_counts,
|
|
2079
|
+
source_counts=source_counts,
|
|
2080
|
+
source_floor=config.main_color_source_floor,
|
|
2081
|
+
double_heavy_color=double_heavy_color,
|
|
2082
|
+
pip_heavy_color=pip_heavy_color,
|
|
2083
|
+
),
|
|
2084
|
+
)
|
|
2085
|
+
return tuple(
|
|
2086
|
+
BasicLandCount(
|
|
2087
|
+
color=color,
|
|
2088
|
+
name=BASIC_LANDS_BY_COLOR[color],
|
|
2089
|
+
count=best_counts[color],
|
|
2090
|
+
)
|
|
2091
|
+
for color in colors
|
|
2092
|
+
if best_counts[color] > 0
|
|
2093
|
+
)
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
def _desired_basic_counts(
|
|
2097
|
+
*,
|
|
2098
|
+
colors: tuple[str, ...],
|
|
2099
|
+
slots: int,
|
|
2100
|
+
pip_counts: dict[str, int],
|
|
2101
|
+
) -> dict[str, float]:
|
|
2102
|
+
total_pips = sum(pip_counts[color] for color in colors)
|
|
2103
|
+
if total_pips <= 0:
|
|
2104
|
+
return {color: slots / len(colors) for color in colors}
|
|
2105
|
+
|
|
2106
|
+
return {
|
|
2107
|
+
color: slots * (pip_counts[color] / total_pips)
|
|
2108
|
+
for color in colors
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def _two_color_basic_splits(
|
|
2113
|
+
*,
|
|
2114
|
+
colors: tuple[str, ...],
|
|
2115
|
+
slots: int,
|
|
2116
|
+
) -> tuple[dict[str, int], ...]:
|
|
2117
|
+
first, second = colors
|
|
2118
|
+
return tuple(
|
|
2119
|
+
{first: first_count, second: slots - first_count}
|
|
2120
|
+
for first_count in range(slots + 1)
|
|
2121
|
+
)
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _basic_split_sort_key(
|
|
2125
|
+
*,
|
|
2126
|
+
counts: dict[str, int],
|
|
2127
|
+
colors: tuple[str, ...],
|
|
2128
|
+
desired_counts: dict[str, float],
|
|
2129
|
+
source_counts: dict[str, int],
|
|
2130
|
+
source_floor: int,
|
|
2131
|
+
double_heavy_color: str,
|
|
2132
|
+
pip_heavy_color: str,
|
|
2133
|
+
) -> tuple[float, float, int, int, int]:
|
|
2134
|
+
shortage = sum(
|
|
2135
|
+
max(0, source_floor - (source_counts[color] + counts[color]))
|
|
2136
|
+
for color in colors
|
|
2137
|
+
)
|
|
2138
|
+
proportion_error = sum(
|
|
2139
|
+
(counts[color] - desired_counts[color]) ** 2
|
|
2140
|
+
for color in colors
|
|
2141
|
+
)
|
|
2142
|
+
return (
|
|
2143
|
+
shortage,
|
|
2144
|
+
proportion_error,
|
|
2145
|
+
-counts[double_heavy_color],
|
|
2146
|
+
-counts[pip_heavy_color],
|
|
2147
|
+
counts[colors[0]],
|
|
2148
|
+
)
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
def _ordered_color_items(*, values: dict[str, int], pair: str) -> tuple[tuple[str, int], ...]:
|
|
2152
|
+
return tuple((color, values[color]) for color in pair)
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
def _average_mana_value(*, cards: tuple[ScoredCard, ...]) -> float:
|
|
2156
|
+
if not cards:
|
|
2157
|
+
return 0.0
|
|
2158
|
+
|
|
2159
|
+
return sum(card.card.mana_value or 0.0 for card in cards) / len(cards)
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _mana_base_caveats(
|
|
2163
|
+
*,
|
|
2164
|
+
land_count: int,
|
|
2165
|
+
nonbasic_lands: tuple[LandCard, ...],
|
|
2166
|
+
config: DeckBuilderConfig,
|
|
2167
|
+
) -> tuple[str, ...]:
|
|
2168
|
+
if land_count == config.aggressive_land_count and nonbasic_lands:
|
|
2169
|
+
return (
|
|
2170
|
+
"16-land caveat: prefer basics over slow taplands when curve pressure matters.",
|
|
2171
|
+
)
|
|
2172
|
+
|
|
2173
|
+
return ()
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
def _format_player_build_sheet(
|
|
2178
|
+
*,
|
|
2179
|
+
spell_selection: SpellSelection,
|
|
2180
|
+
mana_base: ManaBase,
|
|
2181
|
+
config: DeckBuilderConfig,
|
|
2182
|
+
) -> list[str]:
|
|
2183
|
+
counts = spell_selection.counts
|
|
2184
|
+
lines = [
|
|
2185
|
+
"",
|
|
2186
|
+
"Deck summary:",
|
|
2187
|
+
"Deck size: "
|
|
2188
|
+
f"{mana_base.total_cards} cards "
|
|
2189
|
+
f"({counts.total} spells + {mana_base.land_count} lands)",
|
|
2190
|
+
f"Average mana value: {mana_base.average_mana_value:.2f}",
|
|
2191
|
+
_format_mana_curve_summary(cards=spell_selection.spells),
|
|
2192
|
+
(
|
|
2193
|
+
f"Creatures: {counts.creatures}; "
|
|
2194
|
+
f"Non-creatures: {counts.noncreatures}; Lands: {mana_base.land_count}"
|
|
2195
|
+
),
|
|
2196
|
+
f"Land count: {mana_base.land_count} ({mana_base.reason})",
|
|
2197
|
+
"Mana pips: " f"{_format_color_counts(mana_base.pip_counts)}",
|
|
2198
|
+
"Sources: "
|
|
2199
|
+
f"{_format_color_counts(mana_base.source_counts)} "
|
|
2200
|
+
f"(floor {config.main_color_source_floor})",
|
|
2201
|
+
]
|
|
2202
|
+
similarity_line = _format_similarity_line(
|
|
2203
|
+
spell_selection=spell_selection,
|
|
2204
|
+
mana_base=mana_base,
|
|
2205
|
+
)
|
|
2206
|
+
if similarity_line is not None:
|
|
2207
|
+
lines.append(similarity_line)
|
|
2208
|
+
|
|
2209
|
+
lines.extend(_format_selected_spell_cards(selection=spell_selection))
|
|
2210
|
+
lines.extend(_format_land_section(mana_base=mana_base))
|
|
2211
|
+
lines.extend(
|
|
2212
|
+
_format_structure_checks(
|
|
2213
|
+
selection=spell_selection,
|
|
2214
|
+
config=config,
|
|
2215
|
+
)
|
|
2216
|
+
)
|
|
2217
|
+
lines.extend(_format_bench_section(selection=spell_selection, config=config))
|
|
2218
|
+
return lines
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
def _format_mana_curve_summary(*, cards: tuple[ScoredCard, ...]) -> str:
|
|
2222
|
+
counts = [0 for _ in CURVE_BUCKET_LABELS]
|
|
2223
|
+
unknown_count = 0
|
|
2224
|
+
for card in cards:
|
|
2225
|
+
mana_value = card.card.mana_value
|
|
2226
|
+
if mana_value is None:
|
|
2227
|
+
unknown_count += 1
|
|
2228
|
+
continue
|
|
2229
|
+
|
|
2230
|
+
counts[_curve_bucket(mana_value=mana_value)] += 1
|
|
2231
|
+
|
|
2232
|
+
parts = [
|
|
2233
|
+
f"{label}: {counts[index]}"
|
|
2234
|
+
for index, label in enumerate(CURVE_BUCKET_LABELS)
|
|
2235
|
+
]
|
|
2236
|
+
if unknown_count > 0:
|
|
2237
|
+
parts.append(f"?: {unknown_count}")
|
|
2238
|
+
|
|
2239
|
+
return "Mana curve: " + " | ".join(parts)
|
|
2240
|
+
|
|
2241
|
+
|
|
2242
|
+
def _curve_bucket(*, mana_value: float) -> int:
|
|
2243
|
+
rounded = max(0, int(mana_value))
|
|
2244
|
+
return min(rounded, len(CURVE_BUCKET_LABELS) - 1)
|
|
2245
|
+
|
|
2246
|
+
|
|
2247
|
+
def _format_similarity_line(
|
|
2248
|
+
*,
|
|
2249
|
+
spell_selection: SpellSelection,
|
|
2250
|
+
mana_base: ManaBase,
|
|
2251
|
+
) -> str | None:
|
|
2252
|
+
targets = spell_selection.structure_targets
|
|
2253
|
+
if targets is None:
|
|
2254
|
+
return None
|
|
2255
|
+
|
|
2256
|
+
return (
|
|
2257
|
+
"Similarity: "
|
|
2258
|
+
f"17Lands trophy {targets.pair} decks in {targets.set_code} "
|
|
2259
|
+
f"(n={targets.sample_size}): avg "
|
|
2260
|
+
f"{targets.average_creature_count:.1f} creatures / "
|
|
2261
|
+
f"{targets.average_land_count:.1f} lands; your build: "
|
|
2262
|
+
f"{spell_selection.counts.creatures} / {mana_base.land_count}."
|
|
2263
|
+
)
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
def _format_land_section(*, mana_base: ManaBase) -> list[str]:
|
|
2268
|
+
lines = ["Lands:", "Nonbasic lands:"]
|
|
2269
|
+
if mana_base.nonbasic_lands:
|
|
2270
|
+
lines.extend(_format_nonbasic_land(land=land) for land in mana_base.nonbasic_lands)
|
|
2271
|
+
else:
|
|
2272
|
+
lines.append("- none")
|
|
2273
|
+
|
|
2274
|
+
lines.append("Basics:")
|
|
2275
|
+
if mana_base.basic_lands:
|
|
2276
|
+
lines.extend(_format_basic_land(basic=basic) for basic in mana_base.basic_lands)
|
|
2277
|
+
else:
|
|
2278
|
+
lines.append("- none")
|
|
2279
|
+
|
|
2280
|
+
if mana_base.caveats:
|
|
2281
|
+
lines.append("Mana notes:")
|
|
2282
|
+
lines.extend(f"- {caveat}" for caveat in mana_base.caveats)
|
|
2283
|
+
|
|
2284
|
+
return lines
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def _format_nonbasic_land(*, land: LandCard) -> str:
|
|
2288
|
+
return (
|
|
2289
|
+
f"- 1 {land.card.name} "
|
|
2290
|
+
f"({_format_colors(land.source_colors)} source; grpId {land.card.grp_id})"
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
def _format_basic_land(*, basic: BasicLandCount) -> str:
|
|
2295
|
+
return f"- {basic.count} {basic.name}"
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
def _format_color_counts(counts: tuple[tuple[str, int], ...]) -> str:
|
|
2299
|
+
if not counts:
|
|
2300
|
+
return "none"
|
|
2301
|
+
|
|
2302
|
+
return ", ".join(f"{color} {count}" for color, count in counts)
|
|
2303
|
+
|
|
2304
|
+
|
|
2305
|
+
def _format_selected_spell_cards(*, selection: SpellSelection) -> list[str]:
|
|
2306
|
+
lines = ["", "Selected spells:", "Creatures:"]
|
|
2307
|
+
lines.extend(
|
|
2308
|
+
_format_spell_card(card=card)
|
|
2309
|
+
for card in _sorted_spell_cards(cards=selection.spells, creatures=True)
|
|
2310
|
+
)
|
|
2311
|
+
lines.append("Non-creatures:")
|
|
2312
|
+
lines.extend(
|
|
2313
|
+
_format_spell_card(card=card)
|
|
2314
|
+
for card in _sorted_spell_cards(cards=selection.spells, creatures=False)
|
|
2315
|
+
)
|
|
2316
|
+
return lines
|
|
2317
|
+
|
|
2318
|
+
|
|
2319
|
+
def _format_structure_checks(
|
|
2320
|
+
*,
|
|
2321
|
+
selection: SpellSelection,
|
|
2322
|
+
config: DeckBuilderConfig,
|
|
2323
|
+
) -> list[str]:
|
|
2324
|
+
counts = selection.counts
|
|
2325
|
+
constraints = selection.constraints
|
|
2326
|
+
return [
|
|
2327
|
+
"",
|
|
2328
|
+
"Structure checks:",
|
|
2329
|
+
f"Eligible spells for {selection.pair}: {selection.eligible_count}",
|
|
2330
|
+
f"Selected spells: {counts.total}/{selection.requested_spell_count}",
|
|
2331
|
+
f"Creature count: {counts.creatures} "
|
|
2332
|
+
f"(target {constraints.creature_floor}-{constraints.creature_ceiling})",
|
|
2333
|
+
f"Two-drops MV {config.two_drop_mana_value:g}: {counts.two_drops} "
|
|
2334
|
+
f"(minimum {constraints.minimum_two_drops})",
|
|
2335
|
+
f"Expensive spells MV >= {config.expensive_spell_mana_value:g}: "
|
|
2336
|
+
f"{counts.expensive} (soft cap {constraints.maximum_expensive_spells})",
|
|
2337
|
+
_format_splash_note(selection=selection),
|
|
2338
|
+
f"Relaxation order: {' -> '.join(config.relaxation_order)}",
|
|
2339
|
+
f"Applied relaxations: {_format_relaxations(selection.applied_relaxations)}",
|
|
2340
|
+
]
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def _format_spell_selection(
|
|
2344
|
+
*,
|
|
2345
|
+
selection: SpellSelection,
|
|
2346
|
+
config: DeckBuilderConfig,
|
|
2347
|
+
include_bench: bool = True,
|
|
2348
|
+
) -> list[str]:
|
|
2349
|
+
counts = selection.counts
|
|
2350
|
+
constraints = selection.constraints
|
|
2351
|
+
lines = [
|
|
2352
|
+
"",
|
|
2353
|
+
"Spell selection:",
|
|
2354
|
+
f"Eligible spells for {selection.pair}: {selection.eligible_count}",
|
|
2355
|
+
f"Selected spells: {counts.total}/{selection.requested_spell_count}",
|
|
2356
|
+
f"Creature count: {counts.creatures} "
|
|
2357
|
+
f"(target {constraints.creature_floor}-{constraints.creature_ceiling})",
|
|
2358
|
+
f"Two-drops MV {config.two_drop_mana_value:g}: {counts.two_drops} "
|
|
2359
|
+
f"(minimum {constraints.minimum_two_drops})",
|
|
2360
|
+
f"Expensive spells MV >= {config.expensive_spell_mana_value:g}: "
|
|
2361
|
+
f"{counts.expensive} (soft cap {constraints.maximum_expensive_spells})",
|
|
2362
|
+
_format_splash_note(selection=selection),
|
|
2363
|
+
f"Relaxation order: {' -> '.join(config.relaxation_order)}",
|
|
2364
|
+
f"Applied relaxations: {_format_relaxations(selection.applied_relaxations)}",
|
|
2365
|
+
"Creatures:",
|
|
2366
|
+
]
|
|
2367
|
+
lines.extend(
|
|
2368
|
+
_format_spell_card(card=card)
|
|
2369
|
+
for card in _sorted_spell_cards(cards=selection.spells, creatures=True)
|
|
2370
|
+
)
|
|
2371
|
+
lines.append("Non-creatures:")
|
|
2372
|
+
lines.extend(
|
|
2373
|
+
_format_spell_card(card=card)
|
|
2374
|
+
for card in _sorted_spell_cards(cards=selection.spells, creatures=False)
|
|
2375
|
+
)
|
|
2376
|
+
if include_bench:
|
|
2377
|
+
lines.extend(_format_bench_section(selection=selection, config=config))
|
|
2378
|
+
|
|
2379
|
+
return lines
|
|
2380
|
+
|
|
2381
|
+
|
|
2382
|
+
def _format_bench_section(
|
|
2383
|
+
*,
|
|
2384
|
+
selection: SpellSelection,
|
|
2385
|
+
config: DeckBuilderConfig,
|
|
2386
|
+
) -> list[str]:
|
|
2387
|
+
lines = ["Bench:"]
|
|
2388
|
+
if selection.bench:
|
|
2389
|
+
lines.extend(
|
|
2390
|
+
_format_bench_card(card=card, selection=selection, config=config)
|
|
2391
|
+
for card in selection.bench
|
|
2392
|
+
)
|
|
2393
|
+
else:
|
|
2394
|
+
lines.append("- none")
|
|
2395
|
+
|
|
2396
|
+
return lines
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
|
|
2400
|
+
def _format_splash_note(*, selection: SpellSelection) -> str:
|
|
2401
|
+
if not selection.allow_splash_requested:
|
|
2402
|
+
return "Splash: disabled (--no-splash; off-pair cards excluded)"
|
|
2403
|
+
|
|
2404
|
+
if selection.splash_color is None:
|
|
2405
|
+
return (
|
|
2406
|
+
"Splash: enabled; no eligible A- or better single-pip "
|
|
2407
|
+
"third-color card in the pool"
|
|
2408
|
+
)
|
|
2409
|
+
|
|
2410
|
+
available_sources = (
|
|
2411
|
+
selection.splash_fixing_sources + SPLASH.planned_basic_sources
|
|
2412
|
+
)
|
|
2413
|
+
required_sources = (
|
|
2414
|
+
SPLASH.single_card_sources
|
|
2415
|
+
if selection.constraints.maximum_splash_spells <= 1
|
|
2416
|
+
else SPLASH.multiple_card_sources
|
|
2417
|
+
)
|
|
2418
|
+
if selection.constraints.maximum_splash_spells <= 0:
|
|
2419
|
+
return (
|
|
2420
|
+
f"Splash: {selection.splash_color} unsupported "
|
|
2421
|
+
f"({selection.splash_fixing_sources} drafted fixing + "
|
|
2422
|
+
f"{SPLASH.planned_basic_sources} planned basic = "
|
|
2423
|
+
f"{available_sources}/{SPLASH.single_card_sources} sources; "
|
|
2424
|
+
"off-pair cards excluded)"
|
|
2425
|
+
)
|
|
2426
|
+
|
|
2427
|
+
return (
|
|
2428
|
+
f"Splash: {selection.splash_color} enabled "
|
|
2429
|
+
f"({selection.splash_fixing_sources} drafted fixing + "
|
|
2430
|
+
f"{selection.splash_planned_basic_sources} planned basic = "
|
|
2431
|
+
f"{available_sources}/{required_sources} sources; "
|
|
2432
|
+
f"selected {selection.counts.splashes}/"
|
|
2433
|
+
f"{selection.constraints.maximum_splash_spells} A- or better single-pip cards)"
|
|
2434
|
+
)
|
|
2435
|
+
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
def _format_relaxations(relaxations: tuple[str, ...]) -> str:
|
|
2439
|
+
if not relaxations:
|
|
2440
|
+
return "none"
|
|
2441
|
+
|
|
2442
|
+
return ", ".join(relaxations)
|
|
2443
|
+
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
def _sorted_spell_cards(
|
|
2447
|
+
*,
|
|
2448
|
+
cards: tuple[ScoredCard, ...],
|
|
2449
|
+
creatures: bool,
|
|
2450
|
+
) -> tuple[ScoredCard, ...]:
|
|
2451
|
+
matching = tuple(
|
|
2452
|
+
card for card in cards if _is_creature_card(card=card.card) == creatures
|
|
2453
|
+
)
|
|
2454
|
+
return tuple(sorted(matching, key=_curve_sort_key))
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
def _curve_sort_key(card: ScoredCard) -> tuple[float, str, int]:
|
|
2459
|
+
mana_value = 99.0 if card.card.mana_value is None else card.card.mana_value
|
|
2460
|
+
return (mana_value, card.card.name, card.original_index)
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def _format_spell_card(*, card: ScoredCard) -> str:
|
|
2465
|
+
return (
|
|
2466
|
+
f"- MV {_format_mana_value(card.card.mana_value)} | "
|
|
2467
|
+
f"score {card.score} | {card.card.name} ({_format_colors(card.card.colors)})"
|
|
2468
|
+
)
|
|
2469
|
+
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
def _format_bench_card(
|
|
2473
|
+
*,
|
|
2474
|
+
card: ScoredCard,
|
|
2475
|
+
selection: SpellSelection,
|
|
2476
|
+
config: DeckBuilderConfig,
|
|
2477
|
+
) -> str:
|
|
2478
|
+
reason = _bench_reason(card=card, selection=selection, config=config)
|
|
2479
|
+
return f"{_format_spell_card(card=card)} ({reason})"
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
|
|
2483
|
+
def _bench_reason(
|
|
2484
|
+
*,
|
|
2485
|
+
card: ScoredCard,
|
|
2486
|
+
selection: SpellSelection,
|
|
2487
|
+
config: DeckBuilderConfig,
|
|
2488
|
+
) -> str:
|
|
2489
|
+
if (
|
|
2490
|
+
_is_expensive_spell(card=card, config=config)
|
|
2491
|
+
and selection.counts.expensive >= selection.constraints.maximum_expensive_spells
|
|
2492
|
+
):
|
|
2493
|
+
return "cut: expensive-spell cap"
|
|
2494
|
+
|
|
2495
|
+
if (
|
|
2496
|
+
_is_creature_card(card=card.card)
|
|
2497
|
+
and selection.counts.creatures >= selection.constraints.creature_ceiling
|
|
2498
|
+
):
|
|
2499
|
+
return "cut: creature ceiling"
|
|
2500
|
+
|
|
2501
|
+
if (
|
|
2502
|
+
_is_splash_card(card=card.card, pair=selection.pair)
|
|
2503
|
+
and selection.counts.splashes >= selection.constraints.maximum_splash_spells
|
|
2504
|
+
):
|
|
2505
|
+
return "cut: splash cap"
|
|
2506
|
+
|
|
2507
|
+
return "cut: lower score"
|
|
2508
|
+
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def _format_mana_value(mana_value: float | None) -> str:
|
|
2512
|
+
if mana_value is None:
|
|
2513
|
+
return "?"
|
|
2514
|
+
|
|
2515
|
+
return f"{mana_value:g}"
|
|
2516
|
+
|
|
2517
|
+
|
|
2518
|
+
|
|
2519
|
+
def _format_colors(colors: tuple[str, ...]) -> str:
|
|
2520
|
+
if not colors:
|
|
2521
|
+
return "C"
|
|
2522
|
+
|
|
2523
|
+
return "".join(colors)
|
|
2524
|
+
|
|
2525
|
+
|
|
2526
|
+
|
|
2527
|
+
def _pair_win_rate(*, pair: str, ratings_data: SeventeenLandsData | None) -> float | None:
|
|
2528
|
+
if ratings_data is None:
|
|
2529
|
+
return None
|
|
2530
|
+
|
|
2531
|
+
pair_record = ratings_data.pair_win_rates.get(pair)
|
|
2532
|
+
if pair_record is None:
|
|
2533
|
+
return None
|
|
2534
|
+
|
|
2535
|
+
return pair_record.win_rate
|
|
2536
|
+
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def _pair_win_rate_score(
|
|
2540
|
+
*,
|
|
2541
|
+
pair_win_rate: float | None,
|
|
2542
|
+
config: DeckBuilderConfig,
|
|
2543
|
+
) -> float:
|
|
2544
|
+
win_rate = config.neutral_pair_win_rate if pair_win_rate is None else pair_win_rate
|
|
2545
|
+
return _clamp(value=win_rate * 100.0, lower=0.0, upper=100.0)
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
|
|
2549
|
+
def _blended_score(
|
|
2550
|
+
*,
|
|
2551
|
+
average_playable_score: float,
|
|
2552
|
+
pair_win_rate_score: float,
|
|
2553
|
+
config: DeckBuilderConfig,
|
|
2554
|
+
) -> float:
|
|
2555
|
+
weight_total = config.pair_score_card_weight + config.pair_score_win_rate_weight
|
|
2556
|
+
return (
|
|
2557
|
+
(average_playable_score * config.pair_score_card_weight)
|
|
2558
|
+
+ (pair_win_rate_score * config.pair_score_win_rate_weight)
|
|
2559
|
+
) / weight_total
|
|
2560
|
+
|
|
2561
|
+
|
|
2562
|
+
|
|
2563
|
+
def _validate_deck_builder_config(*, config: DeckBuilderConfig) -> None:
|
|
2564
|
+
if config.deck_size <= 0:
|
|
2565
|
+
raise DeckBuilderError("Deck-builder deck size must be greater than zero.")
|
|
2566
|
+
|
|
2567
|
+
if config.target_spell_count <= 0:
|
|
2568
|
+
raise DeckBuilderError("Deck-builder target spell count must be greater than zero.")
|
|
2569
|
+
|
|
2570
|
+
if min(
|
|
2571
|
+
config.default_land_count,
|
|
2572
|
+
config.aggressive_land_count,
|
|
2573
|
+
config.top_heavy_land_count,
|
|
2574
|
+
) < 0:
|
|
2575
|
+
raise DeckBuilderError("Deck-builder land counts must be non-negative.")
|
|
2576
|
+
|
|
2577
|
+
if config.land_count_iteration_limit <= 0:
|
|
2578
|
+
raise DeckBuilderError("Deck-builder land-count iterations must be positive.")
|
|
2579
|
+
|
|
2580
|
+
if not 0.0 <= config.maximum_unresolved_metadata_ratio <= 1.0:
|
|
2581
|
+
raise DeckBuilderError(
|
|
2582
|
+
"Deck-builder unresolved metadata ratio must be between zero and one."
|
|
2583
|
+
)
|
|
2584
|
+
|
|
2585
|
+
if config.main_color_source_floor < 0:
|
|
2586
|
+
raise DeckBuilderError("Deck-builder source floor must be non-negative.")
|
|
2587
|
+
|
|
2588
|
+
if config.creature_floor < 0 or config.creature_ceiling < 0:
|
|
2589
|
+
raise DeckBuilderError("Deck-builder creature constraints must be non-negative.")
|
|
2590
|
+
|
|
2591
|
+
if config.creature_floor > config.creature_ceiling:
|
|
2592
|
+
raise DeckBuilderError("Deck-builder creature floor cannot exceed its ceiling.")
|
|
2593
|
+
|
|
2594
|
+
if config.minimum_two_drops < 0 or config.maximum_expensive_spells < 0:
|
|
2595
|
+
raise DeckBuilderError("Deck-builder curve constraints must be non-negative.")
|
|
2596
|
+
|
|
2597
|
+
if config.near_tie_creature_preference_points < 0:
|
|
2598
|
+
raise DeckBuilderError("Deck-builder near-tie preference must be non-negative.")
|
|
2599
|
+
|
|
2600
|
+
if config.splash_max_cards < 0:
|
|
2601
|
+
raise DeckBuilderError("Deck-builder splash maximum must be non-negative.")
|
|
2602
|
+
|
|
2603
|
+
if config.splash_elite_score_minimum < 0:
|
|
2604
|
+
raise DeckBuilderError("Deck-builder splash score threshold must be non-negative.")
|
|
2605
|
+
|
|
2606
|
+
if config.structure_maindeck_rate_threshold < 0:
|
|
2607
|
+
raise DeckBuilderError("Deck-builder structure threshold must be non-negative.")
|
|
2608
|
+
|
|
2609
|
+
if config.structure_min_land_count > config.structure_max_land_count:
|
|
2610
|
+
raise DeckBuilderError("Deck-builder structure land range is invalid.")
|
|
2611
|
+
|
|
2612
|
+
if len(config.relaxation_order) < 5:
|
|
2613
|
+
raise DeckBuilderError("Deck-builder relaxation order must describe all stages.")
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
|
|
2617
|
+
def _validate_blending_weights(*, config: DeckBuilderConfig) -> None:
|
|
2618
|
+
if config.pair_score_card_weight < 0 or config.pair_score_win_rate_weight < 0:
|
|
2619
|
+
raise DeckBuilderError("Deck-builder blending weights must be non-negative.")
|
|
2620
|
+
|
|
2621
|
+
if config.pair_score_card_weight + config.pair_score_win_rate_weight <= 0:
|
|
2622
|
+
raise DeckBuilderError("At least one deck-builder blending weight must be positive.")
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
def _optional_pair(value: str | None) -> str | None:
|
|
2627
|
+
if value is None:
|
|
2628
|
+
return None
|
|
2629
|
+
|
|
2630
|
+
if value not in COLOR_PAIRS:
|
|
2631
|
+
raise DeckBuilderError(
|
|
2632
|
+
f"Invalid color pair {value!r}; expected one of {', '.join(COLOR_PAIRS)}."
|
|
2633
|
+
)
|
|
2634
|
+
|
|
2635
|
+
return value
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
|
|
2639
|
+
def _score_for_pair(*, scores: tuple[PairScore, ...], pair: str | None) -> PairScore:
|
|
2640
|
+
if pair is None:
|
|
2641
|
+
raise DeckBuilderError("Forced pair is missing.")
|
|
2642
|
+
|
|
2643
|
+
for score in scores:
|
|
2644
|
+
if score.pair == pair:
|
|
2645
|
+
return score
|
|
2646
|
+
|
|
2647
|
+
raise DeckBuilderError(f"No score was computed for pair {pair}.")
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
|
|
2651
|
+
def _pair_score_sort_key(score: PairScore) -> tuple[float, float, float, int]:
|
|
2652
|
+
return (
|
|
2653
|
+
-score.blended_score,
|
|
2654
|
+
-score.playable_score_sum,
|
|
2655
|
+
-score.pair_win_rate_score,
|
|
2656
|
+
COLOR_PAIRS.index(score.pair),
|
|
2657
|
+
)
|
|
2658
|
+
|
|
2659
|
+
|
|
2660
|
+
|
|
2661
|
+
def _format_pair_score(
|
|
2662
|
+
*,
|
|
2663
|
+
score: PairScore,
|
|
2664
|
+
target_spell_count: int,
|
|
2665
|
+
config: DeckBuilderConfig,
|
|
2666
|
+
) -> str:
|
|
2667
|
+
win_rate_label = _format_win_rate(score=score)
|
|
2668
|
+
return (
|
|
2669
|
+
f"- {score.pair}: strength {_format_score(score.blended_score, config=config)}; "
|
|
2670
|
+
f"top {target_spell_count} playable quality sum "
|
|
2671
|
+
f"{_format_score(score.playable_score_sum, config=config)}; "
|
|
2672
|
+
f"17Lands WR {win_rate_label}; playables {score.playable_count}"
|
|
2673
|
+
)
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
|
|
2677
|
+
def _format_win_rate(*, score: PairScore) -> str:
|
|
2678
|
+
if score.pair_win_rate is None:
|
|
2679
|
+
return f"neutral {score.pair_win_rate_score:.1f}%"
|
|
2680
|
+
|
|
2681
|
+
return f"{score.pair_win_rate * 100.0:.1f}%"
|
|
2682
|
+
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
def _format_score(value: float, *, config: DeckBuilderConfig) -> str:
|
|
2686
|
+
places = max(0, config.pair_score_decimal_places)
|
|
2687
|
+
return f"{value:.{places}f}"
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def _int_tuple(*, value: Any, field_name: str) -> tuple[int, ...]:
|
|
2692
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, list):
|
|
2693
|
+
raise DeckBuilderError(f"Missing or invalid {field_name}; expected integer list.")
|
|
2694
|
+
|
|
2695
|
+
result: list[int] = []
|
|
2696
|
+
for item in value:
|
|
2697
|
+
if isinstance(item, bool):
|
|
2698
|
+
raise DeckBuilderError(f"Missing or invalid {field_name}; expected integers.")
|
|
2699
|
+
|
|
2700
|
+
try:
|
|
2701
|
+
result.append(int(item))
|
|
2702
|
+
except (TypeError, ValueError) as error:
|
|
2703
|
+
raise DeckBuilderError(
|
|
2704
|
+
f"Missing or invalid {field_name}; expected integers."
|
|
2705
|
+
) from error
|
|
2706
|
+
|
|
2707
|
+
return tuple(result)
|
|
2708
|
+
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
def _optional_string(value: Any) -> str | None:
|
|
2712
|
+
if value is None:
|
|
2713
|
+
return None
|
|
2714
|
+
|
|
2715
|
+
if not isinstance(value, str):
|
|
2716
|
+
raise DeckBuilderError("Pool metadata values must be strings.")
|
|
2717
|
+
|
|
2718
|
+
return value
|
|
2719
|
+
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
def _clamp(*, value: float, lower: float, upper: float) -> float:
|
|
2723
|
+
return min(max(value, lower), upper)
|