feat(combos): add Combos & Synergies detection, chip-style UI with dual hover; JSON persistence and headless honoring; stage ordering; docs and tests; bump to v2.2.1

This commit is contained in:
mwisnowski 2025-09-01 16:55:24 -07:00
parent cc16c6f13a
commit 6c48fb3437
38 changed files with 2042 additions and 131 deletions

View file

@ -1,7 +1,9 @@
from .builder import DeckBuilder
from .builder_utils import *
from .builder_constants import *
__all__ = ['DeckBuilder']
__all__ = [
'DeckBuilder',
]
def __getattr__(name):
# Lazy-load DeckBuilder to avoid side effects during import of submodules
if name == 'DeckBuilder':
from .builder import DeckBuilder # type: ignore
return DeckBuilder
raise AttributeError(name)

View file

@ -1024,6 +1024,24 @@ class DeckBuilder(
return
except Exception:
pass
# Enforce color identity / card-pool legality: if the card is not present in the
# current dataframes snapshot (which is filtered by color identity), skip it.
# Allow the commander to bypass this check.
try:
if not is_commander:
df_src = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df
if df_src is not None and not df_src.empty and 'name' in df_src.columns:
if df_src[df_src['name'].astype(str).str.lower() == str(card_name).lower()].empty:
# Not in the legal pool (likely off-color or unavailable)
try:
self.output_func(f"Skipped illegal/off-pool card: {card_name}")
except Exception:
pass
return
except Exception:
# If any unexpected error occurs, fall through (do not block legitimate adds)
pass
if creature_types is None:
creature_types = []
if tags is None:
@ -1094,6 +1112,19 @@ class DeckBuilder(
tags = [p for p in parts if p]
except Exception:
pass
# Enrich missing type and mana_cost for accurate categorization
if (not card_type) or (not mana_cost):
try:
df_src = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df
if df_src is not None and not df_src.empty and 'name' in df_src.columns:
row_match2 = df_src[df_src['name'].astype(str).str.lower() == str(card_name).lower()]
if not row_match2.empty:
if not card_type:
card_type = str(row_match2.iloc[0].get('type', row_match2.iloc[0].get('type_line', '')) or '')
if not mana_cost:
mana_cost = str(row_match2.iloc[0].get('mana_cost', row_match2.iloc[0].get('manaCost', '')) or '')
except Exception:
pass
# Normalize & dedupe tags
norm_tags: list[str] = []
seen_tag = set()

View file

@ -0,0 +1,89 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Optional
from tagging.combo_schema import (
load_and_validate_combos,
load_and_validate_synergies,
CombosListModel,
SynergiesListModel,
)
def _canonicalize(name: str) -> str:
s = str(name or "").strip()
s = s.replace("\u2019", "'").replace("\u2018", "'")
s = s.replace("\u201C", '"').replace("\u201D", '"')
s = s.replace("\u2013", "-").replace("\u2014", "-")
s = " ".join(s.split())
return s
@dataclass(frozen=True)
class DetectedCombo:
a: str
b: str
cheap_early: bool
setup_dependent: bool
tags: Optional[List[str]] = None
@dataclass(frozen=True)
class DetectedSynergy:
a: str
b: str
tags: Optional[List[str]] = None
def _detect_combos_from_model(names_norm: set[str], combos: CombosListModel) -> List[DetectedCombo]:
out: List[DetectedCombo] = []
for p in combos.pairs:
a = _canonicalize(p.a).casefold()
b = _canonicalize(p.b).casefold()
if a in names_norm and b in names_norm:
out.append(
DetectedCombo(
a=p.a,
b=p.b,
cheap_early=bool(p.cheap_early),
setup_dependent=bool(p.setup_dependent),
tags=list(p.tags or []),
)
)
return out
def detect_combos(names: Iterable[str], combos_path: str | Path = "config/card_lists/combos.json") -> List[DetectedCombo]:
names_norm = set()
for n in names:
c = _canonicalize(n).casefold()
if not c:
continue
names_norm.add(c)
if not names_norm:
return []
combos = load_and_validate_combos(combos_path)
return _detect_combos_from_model(names_norm, combos)
def _detect_synergies_from_model(names_norm: set[str], syn: SynergiesListModel) -> List[DetectedSynergy]:
out: List[DetectedSynergy] = []
for p in syn.pairs:
a = _canonicalize(p.a).casefold()
b = _canonicalize(p.b).casefold()
if a in names_norm and b in names_norm:
out.append(DetectedSynergy(a=p.a, b=p.b, tags=list(p.tags or [])))
return out
def detect_synergies(names: Iterable[str], synergies_path: str | Path = "config/card_lists/synergies.json") -> List[DetectedSynergy]:
names_norm = {_canonicalize(n).casefold() for n in names if str(n).strip()}
if not names_norm:
return []
syn = load_and_validate_synergies(synergies_path)
return _detect_synergies_from_model(names_norm, syn)

View file

@ -704,6 +704,10 @@ class ReportingMixin:
"add_lands": True,
"add_creatures": True,
"add_non_creature_spells": True,
# Combos preferences (if set during build)
"prefer_combos": bool(getattr(self, 'prefer_combos', False)),
"combo_target_count": (int(getattr(self, 'combo_target_count', 0)) if getattr(self, 'prefer_combos', False) else None),
"combo_balance": (getattr(self, 'combo_balance', None) if getattr(self, 'prefer_combos', False) else None),
# chosen fetch land count (others intentionally omitted for variance)
"fetch_count": chosen_fetch,
# actual ideal counts used for this run