draftomen 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
@@ -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)