mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-21 01:50:12 +01:00
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:
parent
cc16c6f13a
commit
6c48fb3437
38 changed files with 2042 additions and 131 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
89
code/deck_builder/combos.py
Normal file
89
code/deck_builder/combos.py
Normal 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)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
45
code/tagging/combo_schema.py
Normal file
45
code/tagging/combo_schema.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import json
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ComboPairModel(BaseModel):
|
||||
a: str
|
||||
b: str
|
||||
cheap_early: bool = False
|
||||
setup_dependent: bool = False
|
||||
tags: List[str] | None = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CombosListModel(BaseModel):
|
||||
list_version: str
|
||||
generated_at: Optional[str] = None
|
||||
pairs: List[ComboPairModel] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SynergyPairModel(BaseModel):
|
||||
a: str
|
||||
b: str
|
||||
tags: List[str] | None = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SynergiesListModel(BaseModel):
|
||||
list_version: str
|
||||
generated_at: Optional[str] = None
|
||||
pairs: List[SynergyPairModel] = Field(default_factory=list)
|
||||
|
||||
|
||||
def load_and_validate_combos(path: str | Path) -> CombosListModel:
|
||||
obj = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return CombosListModel.model_validate(obj)
|
||||
|
||||
|
||||
def load_and_validate_synergies(path: str | Path) -> SynergiesListModel:
|
||||
obj = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return SynergiesListModel.model_validate(obj)
|
||||
153
code/tagging/combo_tag_applier.py
Normal file
153
code/tagging/combo_tag_applier.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, DefaultDict
|
||||
from collections import defaultdict
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from settings import CSV_DIRECTORY, SETUP_COLORS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComboPair:
|
||||
a: str
|
||||
b: str
|
||||
cheap_early: bool = False
|
||||
setup_dependent: bool = False
|
||||
tags: List[str] | None = None
|
||||
|
||||
|
||||
def _load_pairs(path: Path) -> List[ComboPair]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
pairs = []
|
||||
for entry in data.get("pairs", []):
|
||||
pairs.append(
|
||||
ComboPair(
|
||||
a=entry["a"].strip(),
|
||||
b=entry["b"].strip(),
|
||||
cheap_early=bool(entry.get("cheap_early", False)),
|
||||
setup_dependent=bool(entry.get("setup_dependent", False)),
|
||||
tags=list(entry.get("tags", [])),
|
||||
)
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
def _canonicalize(name: str) -> str:
|
||||
# Canonicalize for matching: trim, unify punctuation/quotes, collapse spaces, casefold later
|
||||
if name is None:
|
||||
return ""
|
||||
s = str(name).strip()
|
||||
# Normalize common unicode punctuation variants
|
||||
s = s.replace("\u2019", "'") # curly apostrophe to straight
|
||||
s = s.replace("\u2018", "'")
|
||||
s = s.replace("\u201C", '"').replace("\u201D", '"')
|
||||
s = s.replace("\u2013", "-").replace("\u2014", "-") # en/em dash -> hyphen
|
||||
# Collapse multiple spaces
|
||||
s = " ".join(s.split())
|
||||
return s
|
||||
|
||||
|
||||
def _ensure_combo_cols(df: pd.DataFrame) -> None:
|
||||
if "comboTags" not in df.columns:
|
||||
df["comboTags"] = [[] for _ in range(len(df))]
|
||||
|
||||
|
||||
def _apply_partner_to_names(df: pd.DataFrame, target_names: Set[str], partner: str) -> None:
|
||||
if not target_names:
|
||||
return
|
||||
mask = df["name"].isin(target_names)
|
||||
if not mask.any():
|
||||
return
|
||||
current = df.loc[mask, "comboTags"]
|
||||
df.loc[mask, "comboTags"] = current.apply(
|
||||
lambda tags: sorted(list({*tags, partner})) if isinstance(tags, list) else [partner]
|
||||
)
|
||||
|
||||
|
||||
def _safe_list_parse(s: object) -> List[str]:
|
||||
if isinstance(s, list):
|
||||
return s
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
return []
|
||||
txt = s.strip()
|
||||
# Try JSON first
|
||||
try:
|
||||
v = json.loads(txt)
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to Python literal
|
||||
try:
|
||||
v = ast.literal_eval(txt)
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def apply_combo_tags(colors: List[str] | None = None, combos_path: str | Path = "config/card_lists/combos.json", csv_dir: str | Path | None = None) -> Dict[str, int]:
|
||||
"""Apply bidirectional comboTags to per-color CSVs based on combos.json.
|
||||
|
||||
Returns a dict of color->updated_row_count for quick reporting.
|
||||
"""
|
||||
colors = colors or list(SETUP_COLORS)
|
||||
combos_file = Path(combos_path)
|
||||
pairs = _load_pairs(combos_file)
|
||||
|
||||
updated_counts: Dict[str, int] = {}
|
||||
base_dir = Path(csv_dir) if csv_dir is not None else Path(CSV_DIRECTORY)
|
||||
for color in colors:
|
||||
csv_path = base_dir / f"{color}_cards.csv"
|
||||
if not csv_path.exists():
|
||||
continue
|
||||
df = pd.read_csv(csv_path, converters={
|
||||
"themeTags": _safe_list_parse,
|
||||
"creatureTypes": _safe_list_parse,
|
||||
"comboTags": _safe_list_parse,
|
||||
})
|
||||
|
||||
_ensure_combo_cols(df)
|
||||
before_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum()
|
||||
|
||||
# Build an index of canonicalized keys -> actual DF row names to update.
|
||||
name_index: DefaultDict[str, Set[str]] = defaultdict(set)
|
||||
for nm in df["name"].astype(str).tolist():
|
||||
canon = _canonicalize(nm)
|
||||
cf = canon.casefold()
|
||||
name_index[cf].add(nm)
|
||||
# If split/fused faces exist, map each face to the combined row name as well
|
||||
if " // " in canon:
|
||||
for part in canon.split(" // "):
|
||||
p = part.strip().casefold()
|
||||
if p:
|
||||
name_index[p].add(nm)
|
||||
|
||||
for p in pairs:
|
||||
a = _canonicalize(p.a)
|
||||
b = _canonicalize(p.b)
|
||||
a_key = a.casefold()
|
||||
b_key = b.casefold()
|
||||
# Apply A<->B bidirectionally to any matching DF rows
|
||||
_apply_partner_to_names(df, name_index.get(a_key, set()), b)
|
||||
_apply_partner_to_names(df, name_index.get(b_key, set()), a)
|
||||
|
||||
after_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum()
|
||||
if before_hash != after_hash:
|
||||
df.to_csv(csv_path, index=False)
|
||||
updated_counts[color] = int((df["comboTags"].apply(bool)).sum())
|
||||
|
||||
return updated_counts
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
counts = apply_combo_tags()
|
||||
print("Updated comboTags counts:")
|
||||
for k, v in counts.items():
|
||||
print(f" {k}: {v}")
|
||||
61
code/tests/test_combo_schema_validation.py
Normal file
61
code/tests/test_combo_schema_validation.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tagging.combo_schema import (
|
||||
load_and_validate_combos,
|
||||
load_and_validate_synergies,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_combos_schema_ok(tmp_path: Path):
|
||||
combos_dir = tmp_path / "config" / "card_lists"
|
||||
combos_dir.mkdir(parents=True)
|
||||
combos = {
|
||||
"list_version": "0.1.0",
|
||||
"generated_at": None,
|
||||
"pairs": [
|
||||
{"a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": True, "tags": ["wincon"]},
|
||||
{"a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts", "setup_dependent": False},
|
||||
],
|
||||
}
|
||||
path = combos_dir / "combos.json"
|
||||
path.write_text(json.dumps(combos), encoding="utf-8")
|
||||
model = load_and_validate_combos(str(path))
|
||||
assert len(model.pairs) == 2
|
||||
assert model.pairs[0].a == "Thassa's Oracle"
|
||||
|
||||
|
||||
def test_validate_synergies_schema_ok(tmp_path: Path):
|
||||
syn_dir = tmp_path / "config" / "card_lists"
|
||||
syn_dir.mkdir(parents=True)
|
||||
syn = {
|
||||
"list_version": "0.1.0",
|
||||
"generated_at": None,
|
||||
"pairs": [
|
||||
{"a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats"]},
|
||||
],
|
||||
}
|
||||
path = syn_dir / "synergies.json"
|
||||
path.write_text(json.dumps(syn), encoding="utf-8")
|
||||
model = load_and_validate_synergies(str(path))
|
||||
assert len(model.pairs) == 1
|
||||
assert model.pairs[0].b == "Phyrexian Altar"
|
||||
|
||||
|
||||
def test_validate_combos_schema_invalid(tmp_path: Path):
|
||||
combos_dir = tmp_path / "config" / "card_lists"
|
||||
combos_dir.mkdir(parents=True)
|
||||
invalid = {
|
||||
"list_version": "0.1.0",
|
||||
"pairs": [
|
||||
{"a": 123, "b": "Demonic Consultation"}, # a must be str
|
||||
],
|
||||
}
|
||||
path = combos_dir / "bad_combos.json"
|
||||
path.write_text(json.dumps(invalid), encoding="utf-8")
|
||||
with pytest.raises(Exception):
|
||||
load_and_validate_combos(str(path))
|
||||
109
code/tests/test_combo_tag_applier.py
Normal file
109
code/tests/test_combo_tag_applier.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from tagging.combo_tag_applier import apply_combo_tags
|
||||
|
||||
|
||||
def _write_csv(dirpath: Path, color: str, rows: list[dict]):
|
||||
df = pd.DataFrame(rows)
|
||||
df.to_csv(dirpath / f"{color}_cards.csv", index=False)
|
||||
|
||||
|
||||
def test_apply_combo_tags_bidirectional(tmp_path: Path):
|
||||
# Arrange: create a minimal CSV for blue with two combo cards
|
||||
csv_dir = tmp_path / "csv"
|
||||
csv_dir.mkdir(parents=True)
|
||||
rows = [
|
||||
{"name": "Thassa's Oracle", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
{"name": "Demonic Consultation", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
{"name": "Zealous Conscripts", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
]
|
||||
_write_csv(csv_dir, "blue", rows)
|
||||
|
||||
# And a combos.json in a temp location
|
||||
combos_dir = tmp_path / "config" / "card_lists"
|
||||
combos_dir.mkdir(parents=True)
|
||||
combos = {
|
||||
"list_version": "0.1.0",
|
||||
"generated_at": None,
|
||||
"pairs": [
|
||||
{"a": "Thassa's Oracle", "b": "Demonic Consultation"},
|
||||
{"a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts"},
|
||||
],
|
||||
}
|
||||
combos_path = combos_dir / "combos.json"
|
||||
combos_path.write_text(json.dumps(combos), encoding="utf-8")
|
||||
|
||||
# Act
|
||||
counts = apply_combo_tags(colors=["blue"], combos_path=str(combos_path), csv_dir=str(csv_dir))
|
||||
|
||||
# Assert
|
||||
assert counts.get("blue", 0) > 0
|
||||
df = pd.read_csv(csv_dir / "blue_cards.csv")
|
||||
# Oracle should list Consultation
|
||||
row_oracle = df[df["name"] == "Thassa's Oracle"].iloc[0]
|
||||
assert "Demonic Consultation" in row_oracle["comboTags"]
|
||||
# Consultation should list Oracle
|
||||
row_consult = df[df["name"] == "Demonic Consultation"].iloc[0]
|
||||
assert "Thassa's Oracle" in row_consult["comboTags"]
|
||||
# Zealous Conscripts is present but not its partner in this CSV; we still record the partner name
|
||||
row_conscripts = df[df["name"] == "Zealous Conscripts"].iloc[0]
|
||||
assert "Kiki-Jiki, Mirror Breaker" in row_conscripts.get("comboTags")
|
||||
|
||||
|
||||
def test_name_normalization_curly_apostrophes(tmp_path: Path):
|
||||
csv_dir = tmp_path / "csv"
|
||||
csv_dir.mkdir(parents=True)
|
||||
# Use curly apostrophe in CSV name, straight in combos
|
||||
rows = [
|
||||
{"name": "Thassa’s Oracle", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
{"name": "Demonic Consultation", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
]
|
||||
_write_csv(csv_dir, "blue", rows)
|
||||
|
||||
combos_dir = tmp_path / "config" / "card_lists"
|
||||
combos_dir.mkdir(parents=True)
|
||||
combos = {
|
||||
"list_version": "0.1.0",
|
||||
"generated_at": None,
|
||||
"pairs": [{"a": "Thassa's Oracle", "b": "Demonic Consultation"}],
|
||||
}
|
||||
combos_path = combos_dir / "combos.json"
|
||||
combos_path.write_text(json.dumps(combos), encoding="utf-8")
|
||||
|
||||
counts = apply_combo_tags(colors=["blue"], combos_path=str(combos_path), csv_dir=str(csv_dir))
|
||||
assert counts.get("blue", 0) >= 1
|
||||
df = pd.read_csv(csv_dir / "blue_cards.csv")
|
||||
row = df[df["name"] == "Thassa’s Oracle"].iloc[0]
|
||||
assert "Demonic Consultation" in row["comboTags"]
|
||||
|
||||
|
||||
def test_split_card_face_matching(tmp_path: Path):
|
||||
csv_dir = tmp_path / "csv"
|
||||
csv_dir.mkdir(parents=True)
|
||||
# Card stored as split name in CSV
|
||||
rows = [
|
||||
{"name": "Fire // Ice", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
{"name": "Isochron Scepter", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
]
|
||||
_write_csv(csv_dir, "izzet", rows)
|
||||
|
||||
combos_dir = tmp_path / "config" / "card_lists"
|
||||
combos_dir.mkdir(parents=True)
|
||||
combos = {
|
||||
"list_version": "0.1.0",
|
||||
"generated_at": None,
|
||||
"pairs": [{"a": "Ice", "b": "Isochron Scepter"}],
|
||||
}
|
||||
combos_path = combos_dir / "combos.json"
|
||||
combos_path.write_text(json.dumps(combos), encoding="utf-8")
|
||||
|
||||
counts = apply_combo_tags(colors=["izzet"], combos_path=str(combos_path), csv_dir=str(csv_dir))
|
||||
assert counts.get("izzet", 0) >= 1
|
||||
df = pd.read_csv(csv_dir / "izzet_cards.csv")
|
||||
row = df[df["name"] == "Fire // Ice"].iloc[0]
|
||||
assert "Isochron Scepter" in row["comboTags"]
|
||||
51
code/tests/test_detect_combos.py
Normal file
51
code/tests/test_detect_combos.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from deck_builder.combos import detect_combos, detect_synergies
|
||||
|
||||
|
||||
def _write_json(path: Path, obj: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(obj), encoding="utf-8")
|
||||
|
||||
|
||||
def test_detect_combos_positive(tmp_path: Path):
|
||||
combos = {
|
||||
"list_version": "0.1.0",
|
||||
"pairs": [
|
||||
{"a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": True, "tags": ["wincon"]},
|
||||
{"a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts"},
|
||||
],
|
||||
}
|
||||
cpath = tmp_path / "config/card_lists/combos.json"
|
||||
_write_json(cpath, combos)
|
||||
|
||||
deck = ["Thassa’s Oracle", "Demonic Consultation", "Island"]
|
||||
found = detect_combos(deck, combos_path=str(cpath))
|
||||
assert any((fc.a.startswith("Thassa") and fc.b.startswith("Demonic")) for fc in found)
|
||||
assert any(fc.cheap_early for fc in found)
|
||||
|
||||
|
||||
def test_detect_synergies_positive(tmp_path: Path):
|
||||
syn = {
|
||||
"list_version": "0.1.0",
|
||||
"pairs": [
|
||||
{"a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats"]},
|
||||
],
|
||||
}
|
||||
spath = tmp_path / "config/card_lists/synergies.json"
|
||||
_write_json(spath, syn)
|
||||
|
||||
deck = ["Swamp", "Grave Pact", "Phyrexian Altar"]
|
||||
found = detect_synergies(deck, synergies_path=str(spath))
|
||||
assert any((fs.a == "Grave Pact" and fs.b == "Phyrexian Altar") for fs in found)
|
||||
|
||||
|
||||
def test_detect_combos_negative(tmp_path: Path):
|
||||
combos = {"list_version": "0.1.0", "pairs": [{"a": "A", "b": "B"}]}
|
||||
cpath = tmp_path / "config/card_lists/combos.json"
|
||||
_write_json(cpath, combos)
|
||||
found = detect_combos(["A"], combos_path=str(cpath))
|
||||
assert not found
|
||||
17
code/tests/test_detect_combos_expanded.py
Normal file
17
code/tests/test_detect_combos_expanded.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from deck_builder.combos import detect_combos
|
||||
|
||||
|
||||
def test_detect_expanded_pairs():
|
||||
names = [
|
||||
"Isochron Scepter",
|
||||
"Dramatic Reversal",
|
||||
"Basalt Monolith",
|
||||
"Rings of Brighthearth",
|
||||
"Some Other Card",
|
||||
]
|
||||
combos = detect_combos(names, combos_path="config/card_lists/combos.json")
|
||||
found = {(c.a, c.b) for c in combos}
|
||||
assert ("Isochron Scepter", "Dramatic Reversal") in found
|
||||
assert ("Basalt Monolith", "Rings of Brighthearth") in found
|
||||
19
code/tests/test_detect_combos_more_new.py
Normal file
19
code/tests/test_detect_combos_more_new.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from deck_builder.combos import detect_combos
|
||||
|
||||
|
||||
def test_detect_more_new_pairs():
|
||||
names = [
|
||||
"Godo, Bandit Warlord",
|
||||
"Helm of the Host",
|
||||
"Narset, Parter of Veils",
|
||||
"Windfall",
|
||||
"Grand Architect",
|
||||
"Pili-Pala",
|
||||
]
|
||||
combos = detect_combos(names, combos_path="config/card_lists/combos.json")
|
||||
pairs = {(c.a, c.b) for c in combos}
|
||||
assert ("Godo, Bandit Warlord", "Helm of the Host") in pairs
|
||||
assert ("Narset, Parter of Veils", "Windfall") in pairs
|
||||
assert ("Grand Architect", "Pili-Pala") in pairs
|
||||
58
code/tests/test_diagnostics_combos_api.py
Normal file
58
code/tests/test_diagnostics_combos_api.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _write_json(path: Path, obj: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(obj), encoding="utf-8")
|
||||
|
||||
|
||||
def test_diagnostics_combos_endpoint(tmp_path: Path, monkeypatch):
|
||||
# Enable diagnostics
|
||||
monkeypatch.setenv("SHOW_DIAGNOSTICS", "1")
|
||||
|
||||
# Lazy import app after env set
|
||||
import importlib
|
||||
import code.web.app as app_module
|
||||
importlib.reload(app_module)
|
||||
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
cpath = tmp_path / "config/card_lists/combos.json"
|
||||
spath = tmp_path / "config/card_lists/synergies.json"
|
||||
_write_json(
|
||||
cpath,
|
||||
{
|
||||
"list_version": "0.1.0",
|
||||
"pairs": [
|
||||
{"a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": True, "setup_dependent": False}
|
||||
],
|
||||
},
|
||||
)
|
||||
_write_json(
|
||||
spath,
|
||||
{
|
||||
"list_version": "0.1.0",
|
||||
"pairs": [{"a": "Grave Pact", "b": "Phyrexian Altar"}],
|
||||
},
|
||||
)
|
||||
|
||||
payload = {
|
||||
"names": ["Thassa’s Oracle", "Demonic Consultation", "Grave Pact", "Phyrexian Altar"],
|
||||
"combos_path": str(cpath),
|
||||
"synergies_path": str(spath),
|
||||
}
|
||||
resp = client.post("/diagnostics/combos", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["counts"]["combos"] == 1
|
||||
assert data["counts"]["synergies"] == 1
|
||||
assert data["versions"]["combos"] == "0.1.0"
|
||||
# Ensure flags are present from payload
|
||||
c = data["combos"][0]
|
||||
assert c.get("cheap_early") is True
|
||||
assert c.get("setup_dependent") is False
|
||||
24
code/tests/test_diagnostics_page.py
Normal file
24
code/tests/test_diagnostics_page.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_diagnostics_page_gated_and_visible(monkeypatch):
|
||||
# Ensure disabled first
|
||||
monkeypatch.delenv("SHOW_DIAGNOSTICS", raising=False)
|
||||
import code.web.app as app_module
|
||||
importlib.reload(app_module)
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/diagnostics")
|
||||
assert r.status_code == 404
|
||||
|
||||
# Enabled: should render
|
||||
monkeypatch.setenv("SHOW_DIAGNOSTICS", "1")
|
||||
importlib.reload(app_module)
|
||||
client2 = TestClient(app_module.app)
|
||||
r2 = client2.get("/diagnostics")
|
||||
assert r2.status_code == 200
|
||||
body = r2.text
|
||||
assert "Diagnostics" in body
|
||||
assert "Combos & Synergies" in body
|
||||
|
|
@ -2,6 +2,11 @@ from __future__ import annotations
|
|||
|
||||
from fastapi import FastAPI, Request, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response
|
||||
from deck_builder.combos import (
|
||||
detect_combos as _detect_combos,
|
||||
detect_synergies as _detect_synergies,
|
||||
)
|
||||
from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
|
|
@ -396,3 +401,42 @@ async def diagnostics_perf(request: Request) -> HTMLResponse:
|
|||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse("diagnostics/perf.html", {"request": request})
|
||||
|
||||
# --- Diagnostics: combos & synergies ---
|
||||
@app.post("/diagnostics/combos")
|
||||
async def diagnostics_combos(request: Request) -> JSONResponse:
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Diagnostics disabled")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
names = payload.get("names") or []
|
||||
combos_path = payload.get("combos_path") or "config/card_lists/combos.json"
|
||||
synergies_path = payload.get("synergies_path") or "config/card_lists/synergies.json"
|
||||
|
||||
combos_model = _load_combos(combos_path)
|
||||
synergies_model = _load_synergies(synergies_path)
|
||||
combos = _detect_combos(names, combos_path=combos_path)
|
||||
synergies = _detect_synergies(names, synergies_path=synergies_path)
|
||||
|
||||
def as_dict_combo(c):
|
||||
return {
|
||||
"a": c.a,
|
||||
"b": c.b,
|
||||
"cheap_early": bool(c.cheap_early),
|
||||
"setup_dependent": bool(c.setup_dependent),
|
||||
"tags": list(c.tags or []),
|
||||
}
|
||||
|
||||
def as_dict_syn(s):
|
||||
return {"a": s.a, "b": s.b, "tags": list(s.tags or [])}
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"counts": {"combos": len(combos), "synergies": len(synergies)},
|
||||
"versions": {"combos": combos_model.list_version, "synergies": synergies_model.list_version},
|
||||
"combos": [as_dict_combo(c) for c in combos],
|
||||
"synergies": [as_dict_syn(s) for s in synergies],
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from ..services.tasks import get_session, new_sid
|
|||
from html import escape as _esc
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_utils as bu
|
||||
from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies
|
||||
from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
|
@ -67,6 +69,9 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
|||
locks=locks,
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
except Exception:
|
||||
# If rebuild fails (e.g., commander not found in test), fall back to injecting
|
||||
|
|
@ -287,6 +292,8 @@ async def multicopy_save(
|
|||
return HTMLResponse(chip)
|
||||
|
||||
|
||||
|
||||
|
||||
# Unified "New Deck" modal (steps 1–3 condensed)
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def build_new_modal(request: Request) -> HTMLResponse:
|
||||
|
|
@ -350,6 +357,9 @@ async def build_new_submit(
|
|||
wipes: int = Form(None),
|
||||
card_advantage: int = Form(None),
|
||||
protection: int = Form(None),
|
||||
prefer_combos: bool = Form(False),
|
||||
combo_count: int | None = Form(None),
|
||||
combo_balance: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
|
@ -372,6 +382,9 @@ async def build_new_submit(
|
|||
"tertiary_tag": tertiary_tag or "",
|
||||
"tag_mode": tag_mode or "AND",
|
||||
"bracket": bracket,
|
||||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
}
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
|
|
@ -416,6 +429,24 @@ async def build_new_submit(
|
|||
except Exception:
|
||||
pass
|
||||
sess["ideals"] = ideals
|
||||
# Persist preferences
|
||||
try:
|
||||
sess["prefer_combos"] = bool(prefer_combos)
|
||||
except Exception:
|
||||
sess["prefer_combos"] = False
|
||||
# Combos config from modal
|
||||
try:
|
||||
if combo_count is not None:
|
||||
sess["combo_target_count"] = max(0, min(10, int(combo_count)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if combo_balance:
|
||||
bval = str(combo_balance).strip().lower()
|
||||
if bval in ("early","late","mix"):
|
||||
sess["combo_balance"] = bval
|
||||
except Exception:
|
||||
pass
|
||||
# Clear any old staged build context
|
||||
for k in ["build_ctx", "locks", "replace_mode"]:
|
||||
if k in sess:
|
||||
|
|
@ -465,6 +496,9 @@ async def build_new_submit(
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
|
||||
status = "Build complete" if res.get("done") else "Stage complete"
|
||||
|
|
@ -500,6 +534,9 @@ async def build_new_submit(
|
|||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -707,6 +744,9 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
ctx = sess["build_ctx"]
|
||||
# Run forward until reaching target
|
||||
|
|
@ -748,6 +788,9 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"history": ctx.get("history", []),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -990,6 +1033,183 @@ async def build_step4_get(request: Request) -> HTMLResponse:
|
|||
)
|
||||
|
||||
|
||||
# --- Combos & Synergies panel (M3) ---
|
||||
def _get_current_deck_names(sess: dict) -> list[str]:
|
||||
try:
|
||||
ctx = sess.get("build_ctx") or {}
|
||||
b = ctx.get("builder")
|
||||
lib = getattr(b, "card_library", {}) if b is not None else {}
|
||||
names = [str(n) for n in lib.keys()]
|
||||
return sorted(dict.fromkeys(names))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/combos", response_class=HTMLResponse)
|
||||
async def build_combos_panel(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
names = _get_current_deck_names(sess)
|
||||
if not names:
|
||||
# No active build; render nothing to avoid UI clutter
|
||||
return HTMLResponse("")
|
||||
|
||||
# Preferences (persisted in session)
|
||||
policy = (sess.get("combos_policy") or "neutral").lower()
|
||||
if policy not in {"avoid", "neutral", "prefer"}:
|
||||
policy = "neutral"
|
||||
try:
|
||||
target = int(sess.get("combos_target") or 0)
|
||||
except Exception:
|
||||
target = 0
|
||||
if target < 0:
|
||||
target = 0
|
||||
|
||||
# Load lists and run detection
|
||||
try:
|
||||
combos_model = _load_combos("config/card_lists/combos.json")
|
||||
except Exception:
|
||||
combos_model = None
|
||||
try:
|
||||
combos = _detect_combos(names, combos_path="config/card_lists/combos.json")
|
||||
except Exception:
|
||||
combos = []
|
||||
try:
|
||||
synergies = _detect_synergies(names, synergies_path="config/card_lists/synergies.json")
|
||||
except Exception:
|
||||
synergies = []
|
||||
try:
|
||||
synergies_model = _load_synergies("config/card_lists/synergies.json")
|
||||
except Exception:
|
||||
synergies_model = None
|
||||
|
||||
# Suggestions
|
||||
suggestions: list[dict] = []
|
||||
present = {s.strip().lower() for s in names}
|
||||
suggested_names: set[str] = set()
|
||||
if combos_model is not None:
|
||||
# Prefer policy: suggest adding a missing partner to hit target count
|
||||
if policy == "prefer":
|
||||
try:
|
||||
for p in combos_model.pairs:
|
||||
a = str(p.a).strip()
|
||||
b = str(p.b).strip()
|
||||
a_in = a.lower() in present
|
||||
b_in = b.lower() in present
|
||||
if a_in ^ b_in: # exactly one present
|
||||
missing = b if a_in else a
|
||||
have = a if a_in else b
|
||||
item = {
|
||||
"kind": "add",
|
||||
"have": have,
|
||||
"name": missing,
|
||||
"cheap_early": bool(getattr(p, "cheap_early", False)),
|
||||
"setup_dependent": bool(getattr(p, "setup_dependent", False)),
|
||||
}
|
||||
key = str(missing).strip().lower()
|
||||
if key not in present and key not in suggested_names:
|
||||
suggestions.append(item)
|
||||
suggested_names.add(key)
|
||||
# Rank: cheap/early first, then setup-dependent, then name
|
||||
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
|
||||
# If we still have room below target, add synergy-based suggestions
|
||||
rem = (max(0, int(target)) if target > 0 else 8) - len(suggestions)
|
||||
if rem > 0 and synergies_model is not None:
|
||||
# lightweight tag weights to bias common engines
|
||||
weights = {
|
||||
"treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3,
|
||||
"engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9,
|
||||
"counters": 1.8, "equipment": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
|
||||
"damage": 1.3, "stax": 1.2
|
||||
}
|
||||
syn_sugs: list[dict] = []
|
||||
for p in synergies_model.pairs:
|
||||
a = str(p.a).strip()
|
||||
b = str(p.b).strip()
|
||||
a_in = a.lower() in present
|
||||
b_in = b.lower() in present
|
||||
if a_in ^ b_in:
|
||||
missing = b if a_in else a
|
||||
have = a if a_in else b
|
||||
mkey = missing.strip().lower()
|
||||
if mkey in present or mkey in suggested_names:
|
||||
continue
|
||||
tags = list(getattr(p, "tags", []) or [])
|
||||
score = 1.0 + sum(weights.get(str(t).lower(), 1.0) for t in tags) / max(1, len(tags) or 1)
|
||||
syn_sugs.append({
|
||||
"kind": "add",
|
||||
"have": have,
|
||||
"name": missing,
|
||||
"cheap_early": False,
|
||||
"setup_dependent": False,
|
||||
"tags": tags,
|
||||
"_score": score,
|
||||
})
|
||||
suggested_names.add(mkey)
|
||||
# rank by score desc then name
|
||||
syn_sugs.sort(key=lambda s: (-float(s.get("_score", 0.0)), str(s.get("name")).lower()))
|
||||
if rem > 0:
|
||||
suggestions.extend(syn_sugs[:rem])
|
||||
# Finally trim to target or default cap
|
||||
cap = (int(target) if target > 0 else 8)
|
||||
suggestions = suggestions[:cap]
|
||||
except Exception:
|
||||
suggestions = []
|
||||
elif policy == "avoid":
|
||||
# Avoid policy: suggest cutting one piece from detected combos
|
||||
try:
|
||||
for c in combos:
|
||||
# pick the second card as default cut to vary suggestions
|
||||
suggestions.append({
|
||||
"kind": "cut",
|
||||
"name": c.b,
|
||||
"partner": c.a,
|
||||
"cheap_early": bool(getattr(c, "cheap_early", False)),
|
||||
"setup_dependent": bool(getattr(c, "setup_dependent", False)),
|
||||
})
|
||||
# Rank: cheap/early first
|
||||
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
|
||||
if target > 0:
|
||||
suggestions = suggestions[: target]
|
||||
else:
|
||||
suggestions = suggestions[: 8]
|
||||
except Exception:
|
||||
suggestions = []
|
||||
|
||||
ctx = {
|
||||
"request": request,
|
||||
"policy": policy,
|
||||
"target": target,
|
||||
"combos": combos,
|
||||
"synergies": synergies,
|
||||
"versions": {
|
||||
"combos": getattr(combos_model, "list_version", None) if combos_model else None,
|
||||
"synergies": getattr(synergies_model, "list_version", None) if synergies_model else None,
|
||||
},
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
return templates.TemplateResponse("build/_combos_panel.html", ctx)
|
||||
|
||||
|
||||
@router.post("/combos/prefs", response_class=HTMLResponse)
|
||||
async def build_combos_save_prefs(request: Request, policy: str = Form("neutral"), target: int = Form(0)) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
pol = (policy or "neutral").strip().lower()
|
||||
if pol not in {"avoid", "neutral", "prefer"}:
|
||||
pol = "neutral"
|
||||
try:
|
||||
tgt = int(target)
|
||||
except Exception:
|
||||
tgt = 0
|
||||
if tgt < 0:
|
||||
tgt = 0
|
||||
sess["combos_policy"] = pol
|
||||
sess["combos_target"] = tgt
|
||||
# Re-render the panel
|
||||
return await build_combos_panel(request)
|
||||
|
||||
|
||||
@router.post("/toggle-owned-review", response_class=HTMLResponse)
|
||||
async def build_toggle_owned_review(
|
||||
request: Request,
|
||||
|
|
@ -1056,6 +1276,9 @@ async def build_step5_get(request: Request) -> HTMLResponse:
|
|||
"skipped": False,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -1098,6 +1321,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
else:
|
||||
# If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet
|
||||
|
|
@ -1196,6 +1422,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -1236,6 +1465,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
else:
|
||||
# Ensure latest locks are reflected in the existing context
|
||||
|
|
@ -1408,6 +1640,9 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
show_skipped = False
|
||||
try:
|
||||
|
|
@ -1572,12 +1807,14 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
|
|||
"skipped": False,
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode")),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
|
||||
|
||||
@router.post("/lock")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import json
|
|||
from ..app import templates
|
||||
from ..services import owned_store
|
||||
from ..services import orchestrator as orch
|
||||
from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies
|
||||
from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
|
|
@ -143,6 +145,33 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s
|
|||
|
||||
owned_names = owned_store.get_names() if owned_flag else None
|
||||
|
||||
# Optional combos preferences
|
||||
prefer_combos = False
|
||||
try:
|
||||
pc = cfg.get("prefer_combos")
|
||||
if isinstance(pc, bool):
|
||||
prefer_combos = pc
|
||||
elif isinstance(pc, str):
|
||||
prefer_combos = pc.strip().lower() in ("1","true","yes","on")
|
||||
except Exception:
|
||||
prefer_combos = False
|
||||
combo_target_count = None
|
||||
try:
|
||||
ctc = cfg.get("combo_target_count")
|
||||
if isinstance(ctc, int):
|
||||
combo_target_count = ctc
|
||||
elif isinstance(ctc, str) and ctc.strip().isdigit():
|
||||
combo_target_count = int(ctc.strip())
|
||||
except Exception:
|
||||
combo_target_count = None
|
||||
combo_balance = None
|
||||
try:
|
||||
cb = cfg.get("combo_balance")
|
||||
if isinstance(cb, str) and cb.strip().lower() in ("early","late","mix"):
|
||||
combo_balance = cb.strip().lower()
|
||||
except Exception:
|
||||
combo_balance = None
|
||||
|
||||
# Run build headlessly with orchestrator
|
||||
res = orch.run_build(
|
||||
commander=commander,
|
||||
|
|
@ -152,6 +181,10 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s
|
|||
tag_mode=tag_mode,
|
||||
use_owned_only=owned_flag,
|
||||
owned_names=owned_names,
|
||||
# Thread combo prefs through staged headless run
|
||||
prefer_combos=prefer_combos,
|
||||
combo_target_count=combo_target_count,
|
||||
combo_balance=combo_balance,
|
||||
)
|
||||
if not res.get("ok"):
|
||||
return templates.TemplateResponse(
|
||||
|
|
@ -183,6 +216,23 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s
|
|||
"use_owned_only": owned_flag,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
# Combos & Synergies for summary panel
|
||||
**(lambda _sum: (lambda names: (lambda _cm,_sm: {
|
||||
"combos": (_detect_combos(names, combos_path="config/card_lists/combos.json") if names else []),
|
||||
"synergies": (_detect_synergies(names, synergies_path="config/card_lists/synergies.json") if names else []),
|
||||
"versions": {
|
||||
"combos": getattr(_cm, 'list_version', None) if _cm else None,
|
||||
"synergies": getattr(_sm, 'list_version', None) if _sm else None,
|
||||
}
|
||||
})(
|
||||
(lambda: (_load_combos("config/card_lists/combos.json")))(),
|
||||
(lambda: (_load_synergies("config/card_lists/synergies.json")))(),
|
||||
))(
|
||||
(lambda s, cmd: (lambda names_set: sorted(names_set | ({cmd} if cmd else set())))(
|
||||
set([str((c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))) for _t, cl in (((s or {}).get('type_breakdown', {}) or {}).get('cards', {}).items()) for c in (cl or []) if (c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))])
|
||||
| set([str((c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))) for _b, cl in ((((s or {}).get('mana_curve', {}) or {}).get('cards', {}) or {}).items()) for c in (cl or []) if (c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))])
|
||||
))(_sum, commander)
|
||||
))(res.get("summary"))
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from typing import Dict, List, Tuple, Optional
|
|||
|
||||
from ..app import templates
|
||||
from ..services import owned_store
|
||||
from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies
|
||||
from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
|
|
@ -292,6 +294,61 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
|||
parts = stem.split('_')
|
||||
commander_name = parts[0] if parts else ''
|
||||
|
||||
# Prepare combos/synergies detections for summary panel
|
||||
combos = []
|
||||
synergies = []
|
||||
versions = {"combos": None, "synergies": None}
|
||||
try:
|
||||
# Collect deck card names from summary (types + curve) and include commander
|
||||
names_set: set[str] = set()
|
||||
try:
|
||||
tb = (summary or {}).get('type_breakdown', {})
|
||||
cards_by_type = tb.get('cards', {}) if isinstance(tb, dict) else {}
|
||||
for _typ, clist in (cards_by_type.items() if isinstance(cards_by_type, dict) else []):
|
||||
for c in (clist or []):
|
||||
n = str(c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))
|
||||
if n:
|
||||
names_set.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
# Also pull from mana curve cards for robustness
|
||||
try:
|
||||
mc = (summary or {}).get('mana_curve', {})
|
||||
curve_cards = mc.get('cards', {}) if isinstance(mc, dict) else {}
|
||||
for _bucket, clist in (curve_cards.items() if isinstance(curve_cards, dict) else []):
|
||||
for c in (clist or []):
|
||||
n = str(c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))
|
||||
if n:
|
||||
names_set.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
# Ensure commander is included
|
||||
if commander_name:
|
||||
names_set.add(str(commander_name))
|
||||
|
||||
names = sorted(names_set)
|
||||
if names:
|
||||
try:
|
||||
combos = _detect_combos(names, combos_path="config/card_lists/combos.json")
|
||||
except Exception:
|
||||
combos = []
|
||||
try:
|
||||
synergies = _detect_synergies(names, synergies_path="config/card_lists/synergies.json")
|
||||
except Exception:
|
||||
synergies = []
|
||||
try:
|
||||
cm = _load_combos("config/card_lists/combos.json")
|
||||
versions["combos"] = getattr(cm, 'list_version', None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sm = _load_synergies("config/card_lists/synergies.json")
|
||||
versions["synergies"] = getattr(sm, 'list_version', None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ctx = {
|
||||
"request": request,
|
||||
"name": p.name,
|
||||
|
|
@ -303,6 +360,9 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
|||
"display_name": display_name,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"combos": combos,
|
||||
"synergies": synergies,
|
||||
"versions": versions,
|
||||
}
|
||||
return templates.TemplateResponse("decks/view.html", ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -668,7 +668,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
_write_status({"running": False, "phase": "error", "message": "Setup check failed"})
|
||||
|
||||
|
||||
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None) -> Dict[str, Any]:
|
||||
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None, prefer_combos: bool | None = None, combo_target_count: int | None = None, combo_balance: str | None = None) -> Dict[str, Any]:
|
||||
"""Run the deck build end-to-end with provided selections and capture logs.
|
||||
|
||||
Returns: { ok: bool, log: str, csv_path: Optional[str], txt_path: Optional[str], error: Optional[str] }
|
||||
|
|
@ -751,6 +751,19 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
|||
except Exception as e:
|
||||
out(f"Failed to load color identity/card pool: {e}")
|
||||
|
||||
# Thread combo preferences (if provided)
|
||||
try:
|
||||
if prefer_combos is not None:
|
||||
b.prefer_combos = bool(prefer_combos) # type: ignore[attr-defined]
|
||||
if combo_target_count is not None:
|
||||
b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined]
|
||||
if combo_balance:
|
||||
bal = str(combo_balance).strip().lower()
|
||||
if bal in ('early','late','mix'):
|
||||
b.combo_balance = bal # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
b._run_land_build_steps()
|
||||
except Exception as e:
|
||||
|
|
@ -763,6 +776,126 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
|||
out(f"Creature phase failed: {e}")
|
||||
try:
|
||||
if hasattr(b, 'add_spells_phase'):
|
||||
# When combos are preferred, run auto-complete before bulk spells so additions aren't clamped
|
||||
try:
|
||||
if bool(getattr(b, 'prefer_combos', False)):
|
||||
# Re-use the staged runner logic for auto-combos
|
||||
_ = run_stage # anchor for mypy
|
||||
# Minimal inline runner: mimic '__auto_complete_combos__' block
|
||||
try:
|
||||
# Load curated combos
|
||||
from tagging.combo_schema import load_and_validate_combos as _load_combos
|
||||
combos_model = None
|
||||
try:
|
||||
combos_model = _load_combos("config/card_lists/combos.json")
|
||||
except Exception:
|
||||
combos_model = None
|
||||
# Build current name set including commander
|
||||
names: list[str] = []
|
||||
try:
|
||||
names.extend(list(getattr(b, 'card_library', {}).keys()))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cmd = getattr(b, 'commander_name', None)
|
||||
if cmd:
|
||||
names.append(cmd)
|
||||
except Exception:
|
||||
pass
|
||||
# Count existing completed combos to reduce target budget
|
||||
existing_pairs = 0
|
||||
try:
|
||||
if combos_model:
|
||||
present = {str(x).strip().lower() for x in names if str(x).strip()}
|
||||
for p in combos_model.pairs:
|
||||
a = str(p.a).strip().lower()
|
||||
bnm = str(p.b).strip().lower()
|
||||
if a in present and bnm in present:
|
||||
existing_pairs += 1
|
||||
except Exception:
|
||||
existing_pairs = 0
|
||||
# Determine target and balance
|
||||
try:
|
||||
target_total = int(getattr(b, 'combo_target_count', 2))
|
||||
except Exception:
|
||||
target_total = 2
|
||||
try:
|
||||
balance = str(getattr(b, 'combo_balance', 'mix')).strip().lower()
|
||||
except Exception:
|
||||
balance = 'mix'
|
||||
if balance not in ('early','late','mix'):
|
||||
balance = 'mix'
|
||||
remaining_pairs = max(0, target_total - existing_pairs)
|
||||
lib_lower = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}
|
||||
added_any = False
|
||||
# Determine missing partners
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for p in (combos_model.pairs if combos_model else []):
|
||||
a = str(p.a).strip()
|
||||
bnm = str(p.b).strip()
|
||||
a_l = a.lower()
|
||||
b_l = bnm.lower()
|
||||
has_a = (a_l in lib_lower) or (a_l == str(getattr(b, 'commander_name', '')).lower())
|
||||
has_b = (b_l in lib_lower) or (b_l == str(getattr(b, 'commander_name', '')).lower())
|
||||
target: str | None = None
|
||||
if has_a and not has_b:
|
||||
target = bnm
|
||||
elif has_b and not has_a:
|
||||
target = a
|
||||
if not target:
|
||||
continue
|
||||
# Score per balance
|
||||
score = 0
|
||||
try:
|
||||
if balance == 'early':
|
||||
score += (5 if getattr(p, 'cheap_early', False) else 0)
|
||||
score += (0 if getattr(p, 'setup_dependent', False) else 1)
|
||||
elif balance == 'late':
|
||||
score += (4 if getattr(p, 'setup_dependent', False) else 0)
|
||||
score += (0 if getattr(p, 'cheap_early', False) else 1)
|
||||
else:
|
||||
score += (3 if getattr(p, 'cheap_early', False) else 0)
|
||||
score += (2 if getattr(p, 'setup_dependent', False) else 0)
|
||||
except Exception:
|
||||
pass
|
||||
candidates.append((score, target))
|
||||
candidates.sort(key=lambda x: (-x[0], x[1].lower()))
|
||||
for _ in range(remaining_pairs):
|
||||
if not candidates:
|
||||
break
|
||||
_score, pick = candidates.pop(0)
|
||||
# Resolve in current pool; enrich type/mana
|
||||
try:
|
||||
df_pool = getattr(b, '_combined_cards_df', None)
|
||||
df_full = getattr(b, '_full_cards_df', None)
|
||||
row = None
|
||||
for df in (df_pool, df_full):
|
||||
if df is not None and not df.empty and 'name' in df.columns:
|
||||
r = df[df['name'].astype(str).str.lower() == pick.lower()]
|
||||
if not r.empty:
|
||||
row = r
|
||||
break
|
||||
if row is None or row.empty:
|
||||
continue
|
||||
pick = str(row.iloc[0]['name'])
|
||||
card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
|
||||
mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '')
|
||||
except Exception:
|
||||
card_type = ''
|
||||
mana_cost = ''
|
||||
try:
|
||||
b.add_card(pick, card_type=card_type, mana_cost=mana_cost, role='Support', sub_role='Combo Partner', added_by='AutoCombos')
|
||||
out(f"Auto-Complete Combos: added '{pick}' to complete a detected pair.")
|
||||
added_any = True
|
||||
lib_lower.add(pick.lower())
|
||||
except Exception:
|
||||
continue
|
||||
if not added_any:
|
||||
out("No combo partners added.")
|
||||
except Exception as _e:
|
||||
out(f"Auto-Complete Combos failed: {_e}")
|
||||
except Exception:
|
||||
pass
|
||||
b.add_spells_phase()
|
||||
except Exception as e:
|
||||
out(f"Spell phase failed: {e}")
|
||||
|
|
@ -859,6 +992,7 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
|||
# Multi-Copy package first (if selected) so lands & targets can account for it
|
||||
if mc_selected:
|
||||
stages.append({"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"})
|
||||
# Note: Combos auto-complete now runs late (near theme autofill), so we defer adding it here.
|
||||
# Land steps 1..8 (if present)
|
||||
for i in range(1, 9):
|
||||
fn = getattr(b, f"run_land_step{i}", None)
|
||||
|
|
@ -896,10 +1030,23 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
|||
# Web UI: omit confirm stages; show only the action stage
|
||||
label_action = label.replace("Confirm ", "")
|
||||
stages.append({"key": f"spells_{key}", "label": label_action, "runner_name": runner})
|
||||
# When combos are preferred, run Auto-Complete Combos BEFORE final theme fill so there is room to add partners.
|
||||
try:
|
||||
prefer_c = bool(getattr(b, 'prefer_combos', False))
|
||||
except Exception:
|
||||
prefer_c = False
|
||||
if prefer_c:
|
||||
stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"})
|
||||
# Ensure we include the theme filler step to top up to 100 cards
|
||||
if callable(getattr(b, 'fill_remaining_theme_spells', None)):
|
||||
stages.append({"key": "spells_fill", "label": "Theme Spell Fill", "runner_name": "fill_remaining_theme_spells"})
|
||||
elif hasattr(b, 'add_spells_phase'):
|
||||
# For monolithic spells, insert combos BEFORE the big spells stage so additions aren't clamped away
|
||||
try:
|
||||
if bool(getattr(b, 'prefer_combos', False)):
|
||||
stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"})
|
||||
except Exception:
|
||||
pass
|
||||
stages.append({"key": "spells", "label": "Spells", "runner_name": "add_spells_phase"})
|
||||
# Post-adjust
|
||||
if hasattr(b, 'post_spell_land_adjust'):
|
||||
|
|
@ -924,6 +1071,9 @@ def start_build_ctx(
|
|||
locks: List[str] | None = None,
|
||||
custom_export_base: str | None = None,
|
||||
multi_copy: Dict[str, Any] | None = None,
|
||||
prefer_combos: bool | None = None,
|
||||
combo_target_count: int | None = None,
|
||||
combo_balance: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
|
|
@ -994,6 +1144,24 @@ def start_build_ctx(
|
|||
b._web_multi_copy = (multi_copy or None)
|
||||
except Exception:
|
||||
pass
|
||||
# Preference flags
|
||||
try:
|
||||
b.prefer_combos = bool(prefer_combos)
|
||||
except Exception:
|
||||
pass
|
||||
# Thread combo config
|
||||
try:
|
||||
if combo_target_count is not None:
|
||||
b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if combo_balance:
|
||||
bal = str(combo_balance).strip().lower()
|
||||
if bal in ('early','late','mix'):
|
||||
b.combo_balance = bal # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# Stages
|
||||
stages = _make_stages(b)
|
||||
ctx = {
|
||||
|
|
@ -1308,6 +1476,143 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
logs.append("No multi-copy additions (empty selection).")
|
||||
except Exception as e:
|
||||
logs.append(f"Stage '{label}' failed: {e}")
|
||||
elif runner_name == '__auto_complete_combos__':
|
||||
try:
|
||||
# Load curated combos
|
||||
from tagging.combo_schema import load_and_validate_combos as _load_combos
|
||||
combos_model = None
|
||||
try:
|
||||
combos_model = _load_combos("config/card_lists/combos.json")
|
||||
except Exception:
|
||||
combos_model = None
|
||||
# Build current name set including commander
|
||||
names: list[str] = []
|
||||
try:
|
||||
names.extend(list(getattr(b, 'card_library', {}).keys()))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cmd = getattr(b, 'commander_name', None)
|
||||
if cmd:
|
||||
names.append(cmd)
|
||||
except Exception:
|
||||
pass
|
||||
# Count existing completed combos to reduce target budget
|
||||
existing_pairs = 0
|
||||
try:
|
||||
if combos_model:
|
||||
present = {str(x).strip().lower() for x in names if str(x).strip()}
|
||||
for p in combos_model.pairs:
|
||||
a = str(p.a).strip().lower()
|
||||
bnm = str(p.b).strip().lower()
|
||||
if a in present and bnm in present:
|
||||
existing_pairs += 1
|
||||
except Exception:
|
||||
existing_pairs = 0
|
||||
# Determine target and balance
|
||||
try:
|
||||
target_total = int(getattr(b, 'combo_target_count', 2))
|
||||
except Exception:
|
||||
target_total = 2
|
||||
try:
|
||||
balance = str(getattr(b, 'combo_balance', 'mix')).strip().lower()
|
||||
except Exception:
|
||||
balance = 'mix'
|
||||
if balance not in ('early','late','mix'):
|
||||
balance = 'mix'
|
||||
# Remaining pairs to aim for
|
||||
remaining_pairs = max(0, target_total - existing_pairs)
|
||||
# Determine missing partners for any pair where exactly one is present
|
||||
lib_lower = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}
|
||||
locks_lower = locks_set
|
||||
added_any = False
|
||||
if remaining_pairs <= 0:
|
||||
logs.append("Combo plan met by existing pairs; no additions needed.")
|
||||
# Build candidate list with scoring for balance
|
||||
candidates: list[tuple[int, str, dict]] = [] # (score, target_name, enrich_meta)
|
||||
for p in (combos_model.pairs if combos_model else []):
|
||||
a = str(p.a).strip()
|
||||
bname = str(p.b).strip()
|
||||
a_l = a.lower()
|
||||
b_l = bname.lower()
|
||||
has_a = (a_l in lib_lower) or (a_l == str(getattr(b, 'commander_name', '')).lower())
|
||||
has_b = (b_l in lib_lower) or (b_l == str(getattr(b, 'commander_name', '')).lower())
|
||||
# If exactly one side present, attempt to add the other
|
||||
target: str | None = None
|
||||
if has_a and not has_b:
|
||||
target = bname
|
||||
elif has_b and not has_a:
|
||||
target = a
|
||||
if not target:
|
||||
continue
|
||||
# Respect locks
|
||||
if target.lower() in locks_lower:
|
||||
continue
|
||||
# Owned-only check
|
||||
try:
|
||||
if getattr(b, 'use_owned_only', False):
|
||||
owned = getattr(b, 'owned_card_names', set()) or set()
|
||||
if owned and target.lower() not in {n.lower() for n in owned}:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# Score per balance
|
||||
score = 0
|
||||
try:
|
||||
if balance == 'early':
|
||||
score += (5 if getattr(p, 'cheap_early', False) else 0)
|
||||
score += (0 if getattr(p, 'setup_dependent', False) else 1)
|
||||
elif balance == 'late':
|
||||
score += (4 if getattr(p, 'setup_dependent', False) else 0)
|
||||
score += (0 if getattr(p, 'cheap_early', False) else 1)
|
||||
else: # mix
|
||||
score += (3 if getattr(p, 'cheap_early', False) else 0)
|
||||
score += (2 if getattr(p, 'setup_dependent', False) else 0)
|
||||
except Exception:
|
||||
pass
|
||||
# Prefer targets that aren't already in library (already ensured), and stable name sort as tiebreaker
|
||||
score_tuple = (score, target.lower(), {})
|
||||
candidates.append(score_tuple)
|
||||
# Sort candidates descending by score then name asc
|
||||
candidates.sort(key=lambda x: (-x[0], x[1]))
|
||||
# Add up to remaining_pairs partners
|
||||
for _ in range(remaining_pairs):
|
||||
if not candidates:
|
||||
break
|
||||
_score, pick, meta = candidates.pop(0)
|
||||
# Resolve display name and enrich type/mana
|
||||
card_type = ''
|
||||
mana_cost = ''
|
||||
try:
|
||||
# Only consider the current filtered pool first (color-identity compliant).
|
||||
df_pool = getattr(b, '_combined_cards_df', None)
|
||||
df_full = getattr(b, '_full_cards_df', None)
|
||||
row = None
|
||||
for df in (df_pool, df_full):
|
||||
if df is not None and not df.empty and 'name' in df.columns:
|
||||
r = df[df['name'].astype(str).str.lower() == pick.lower()]
|
||||
if not r.empty:
|
||||
row = r
|
||||
break
|
||||
if row is None or row.empty:
|
||||
# Skip if we cannot resolve in current pool (likely off-color/unavailable)
|
||||
continue
|
||||
pick = str(row.iloc[0]['name'])
|
||||
card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
|
||||
mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
b.add_card(pick, card_type=card_type, mana_cost=mana_cost, role='Support', sub_role='Combo Partner', added_by='AutoCombos')
|
||||
logs.append(f"Auto-Complete Combos: added '{pick}' to complete a detected pair.")
|
||||
added_any = True
|
||||
lib_lower.add(pick.lower())
|
||||
except Exception:
|
||||
continue
|
||||
if not added_any:
|
||||
logs.append("No combo partners added.")
|
||||
except Exception as e:
|
||||
logs.append(f"Stage '{label}' failed: {e}")
|
||||
elif callable(fn):
|
||||
try:
|
||||
fn()
|
||||
|
|
@ -1331,12 +1636,26 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
row = df[df['name'].astype(str).str.lower() == lname]
|
||||
if not row.empty:
|
||||
target_name = str(row.iloc[0]['name'])
|
||||
target_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
|
||||
target_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '')
|
||||
else:
|
||||
target_type = ''
|
||||
target_cost = ''
|
||||
else:
|
||||
target_type = ''
|
||||
target_cost = ''
|
||||
except Exception:
|
||||
target_name = None
|
||||
target_type = ''
|
||||
target_cost = ''
|
||||
# Only add a lock placeholder if we can resolve this name in the current pool
|
||||
if target_name is None:
|
||||
target_name = lname
|
||||
# Unresolvable (likely off-color or unavailable) -> skip placeholder
|
||||
continue
|
||||
b.card_library[target_name] = {
|
||||
'Count': 1,
|
||||
'Card Type': target_type,
|
||||
'Mana Cost': target_cost,
|
||||
'Role': 'Locked',
|
||||
'SubRole': '',
|
||||
'AddedBy': 'Lock',
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@
|
|||
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
|
||||
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
|
||||
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background: var(--panel); }
|
||||
.card-hover .dual {
|
||||
display:flex; gap:12px; align-items:flex-start;
|
||||
}
|
||||
.card-meta { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
|
||||
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
|
||||
.card-meta li { margin:.1rem 0; }
|
||||
|
|
@ -180,9 +183,15 @@
|
|||
inner.className = 'card-hover-inner';
|
||||
var img = document.createElement('img');
|
||||
img.alt = 'Card preview';
|
||||
var img2 = document.createElement('img');
|
||||
img2.alt = 'Card preview'; img2.style.display = 'none';
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'card-meta';
|
||||
inner.appendChild(img);
|
||||
var dual = document.createElement('div');
|
||||
dual.className = 'dual';
|
||||
dual.appendChild(img);
|
||||
dual.appendChild(img2);
|
||||
inner.appendChild(dual);
|
||||
inner.appendChild(meta);
|
||||
pop.appendChild(inner);
|
||||
document.body.appendChild(pop);
|
||||
|
|
@ -259,12 +268,14 @@
|
|||
if (x + rect.width + 8 > vw) cardPop.style.left = (e.clientX - rect.width - 16) + 'px';
|
||||
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
|
||||
}
|
||||
function attachCardHover() {
|
||||
function attachCardHover() {
|
||||
document.querySelectorAll('[data-card-name]').forEach(function(el) {
|
||||
if (el.__cardHoverBound) return; // avoid duplicate bindings
|
||||
el.__cardHoverBound = true;
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
var img = cardPop.querySelector('img');
|
||||
var img = cardPop.querySelector('.card-hover-inner img');
|
||||
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
|
||||
if (img2) img2.style.display = 'none';
|
||||
var meta = cardPop.querySelector('.card-meta');
|
||||
var name = el.getAttribute('data-card-name') || '';
|
||||
var vi = 0; // always start at 'normal' on hover
|
||||
|
|
@ -304,6 +315,33 @@
|
|||
el.addEventListener('mousemove', positionCard);
|
||||
el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; });
|
||||
});
|
||||
// Dual-card hover for combo rows
|
||||
document.querySelectorAll('[data-combo-names]').forEach(function(el){
|
||||
if (el.__comboHoverBound) return; el.__comboHoverBound = true;
|
||||
el.addEventListener('mouseenter', function(e){
|
||||
var namesAttr = el.getAttribute('data-combo-names') || '';
|
||||
var parts = namesAttr.split('||');
|
||||
var a = (parts[0]||'').trim(); var b = (parts[1]||'').trim();
|
||||
if (!a || !b) return;
|
||||
var img = cardPop.querySelector('.card-hover-inner img');
|
||||
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
|
||||
var meta = cardPop.querySelector('.card-meta');
|
||||
if (img2) img2.style.display = '';
|
||||
var vi1 = 0, vi2 = 0; var triedNoCache1 = false, triedNoCache2 = false;
|
||||
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);
|
||||
img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);
|
||||
function err1(){ if (vi1 < PREVIEW_VERSIONS.length - 1){ vi1 += 1; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);} else if (!triedNoCache1){ triedNoCache1 = true; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], true);} else { img.removeEventListener('error', err1);} }
|
||||
function err2(){ if (vi2 < PREVIEW_VERSIONS.length - 1){ vi2 += 1; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);} else if (!triedNoCache2){ triedNoCache2 = true; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], true);} else { img2.removeEventListener('error', err2);} }
|
||||
img.addEventListener('error', err1, { once:false });
|
||||
img2.addEventListener('error', err2, { once:false });
|
||||
img.addEventListener('load', function on1(){ img.removeEventListener('load', on1); img.removeEventListener('error', err1); });
|
||||
img2.addEventListener('load', function on2(){ img2.removeEventListener('load', on2); img2.removeEventListener('error', err2); });
|
||||
meta.style.display = 'none'; meta.innerHTML = '';
|
||||
positionCard(e);
|
||||
});
|
||||
el.addEventListener('mousemove', positionCard);
|
||||
el.addEventListener('mouseleave', function(){ cardPop.style.display='none'; });
|
||||
});
|
||||
}
|
||||
attachCardHover();
|
||||
bindAllCardImageRetries();
|
||||
|
|
|
|||
28
code/web/templates/build/_combo_limit_modal.html
Normal file
28
code/web/templates/build/_combo_limit_modal.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<div class="modal" id="combo-modal" role="dialog" aria-modal="true" aria-labelledby="combo-modal-title">
|
||||
<div class="modal-content">
|
||||
<h3 id="combo-modal-title" style="margin-top:0;">Combos & Synergies — Auto-complete plan</h3>
|
||||
<p class="muted" style="margin:.25rem 0 .75rem 0;">You're prioritizing combos. Choose how many to aim for and the balance of early vs late-game pieces.</p>
|
||||
<form hx-post="/build/combos/save" hx-target="#combo-modal" hx-swap="outerHTML" style="display:grid; gap:.75rem;">
|
||||
<div>
|
||||
<label for="combo_count"><strong>How many combos would you like?</strong></label>
|
||||
<input id="combo_count" name="count" type="number" min="0" max="10" step="1" value="{{ count|default(2) }}" style="width:6rem; margin-left:.5rem;" />
|
||||
</div>
|
||||
<fieldset style="border:1px solid var(--border); padding:.5rem; border-radius:8px;">
|
||||
<legend><strong>Balance of early-game vs late-game</strong></legend>
|
||||
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
|
||||
<input type="radio" name="balance" value="early" {% if balance == 'early' %}checked{% endif %}/> Early-game focus (cheap, quick setups)
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
|
||||
<input type="radio" name="balance" value="late" {% if balance == 'late' %}checked{% endif %}/> Late-game focus (setup-dependent payoffs)
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
|
||||
<input type="radio" name="balance" value="mix" {% if balance == 'mix' or not balance %}checked{% endif %}/> Mix of both
|
||||
</label>
|
||||
</fieldset>
|
||||
<div style="display:flex; gap:.5rem; justify-content:flex-end;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<button type="button" class="btn" hx-post="/build/combos/save" hx-vals='{"skip":"1"}' hx-target="#combo-modal" hx-swap="outerHTML">Dismiss</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
76
code/web/templates/build/_combos_panel.html
Normal file
76
code/web/templates/build/_combos_panel.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<div class="panel" style="margin-top:1rem;">
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
|
||||
<h3 style="margin:0;">Combos & Synergies</h3>
|
||||
{% if versions and (versions.combos or versions.synergies) %}
|
||||
<span class="muted">lists v{{ versions.combos }}{% if versions.synergies %} / {{ versions.synergies }}{% endif %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section style="margin-top:.5rem;">
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected combos ({{ combos|length }})</div>
|
||||
{% if combos and combos|length %}
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
|
||||
{% for c in combos %}
|
||||
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
|
||||
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
|
||||
<span class="muted"> + </span>
|
||||
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
|
||||
{% if c.cheap_early or c.setup_dependent %}
|
||||
<span class="muted" style="margin-left:.4rem; font-size:12px;">
|
||||
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
|
||||
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="muted">None found.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section style="margin-top:.5rem;">
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected synergies ({{ synergies|length }})</div>
|
||||
{% if synergies and synergies|length %}
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
|
||||
{% for s in synergies %}
|
||||
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
|
||||
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
|
||||
<span class="muted"> + </span>
|
||||
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
|
||||
{% if s.tags %}
|
||||
<span class="muted" style="margin-left:.4rem; font-size:12px;">
|
||||
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="muted">None found.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if suggestions and suggestions|length %}
|
||||
<div style="margin-top:.75rem;">
|
||||
<h4 style="margin:0 0 .25rem 0;">Suggestions</h4>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
|
||||
{% for s in suggestions %}
|
||||
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;">
|
||||
{% if s.kind == 'add' %}Add <strong data-card-name="{{ s.name }}">{{ s.name }}</strong> (partner: <span data-card-name="{{ s.have }}">{{ s.have }}</span>)
|
||||
{% elif s.kind == 'cut' %}Cut <strong data-card-name="{{ s.name }}">{{ s.name }}</strong> (pairs with <span data-card-name="{{ s.partner }}">{{ s.partner }}</span>)
|
||||
{% else %}{{ s.kind|title }} <strong data-card-name="{{ s.name }}">{{ s.name }}</strong>{% endif %}
|
||||
{% set badges = [] %}
|
||||
{% if s.cheap_early %}{% set _ = badges.append('cheap/early') %}{% endif %}
|
||||
{% if s.setup_dependent %}{% set _ = badges.append('setup-dependent') %}{% endif %}
|
||||
{% if badges and badges|length %}
|
||||
<span class="muted">{ {{ badges|join(', ') }} }</span>
|
||||
{% endif %}
|
||||
{% if s.tags and s.tags|length %}
|
||||
<span class="muted">[{{ s.tags|join(', ') }}]</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -49,6 +49,32 @@
|
|||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Preferences</legend>
|
||||
<label title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" /> Prioritize combos (auto-complete partners)
|
||||
</label>
|
||||
<div id="pref-combos-config" style="margin-top:.5rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; display:none;">
|
||||
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
|
||||
<label>
|
||||
<span>How many combos?</span>
|
||||
<input type="number" name="combo_count" min="0" max="10" step="1" value="{{ form.combo_count if form and form.combo_count is not none else 2 }}" style="width:6rem; margin-left:.5rem;" />
|
||||
</label>
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.25rem;">Balance of early vs late-game</div>
|
||||
<label style="display:inline-flex; align-items:center; gap:.25rem; margin-right:.5rem;">
|
||||
<input type="radio" name="combo_balance" value="early" {% if form and form.combo_balance == 'early' %}checked{% endif %}/> Early
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:.25rem; margin-right:.5rem;">
|
||||
<input type="radio" name="combo_balance" value="late" {% if form and form.combo_balance == 'late' %}checked{% endif %}/> Late
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:.25rem;">
|
||||
<input type="radio" name="combo_balance" value="mix" {% if not form or (form and (not form.combo_balance or form.combo_balance == 'mix')) %}checked{% endif %}/> Mix
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>Advanced options (ideals)</summary>
|
||||
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
|
||||
|
|
@ -146,4 +172,18 @@
|
|||
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); closeModal(); } }
|
||||
document.addEventListener('keydown', onKey);
|
||||
})();
|
||||
|
||||
// Toggle combos config visibility based on checkbox
|
||||
(function(){
|
||||
try {
|
||||
var form = document.querySelector('.modal form');
|
||||
var chk = form && form.querySelector('#pref-combos-chk');
|
||||
var box = form && form.querySelector('#pref-combos-config');
|
||||
if (!chk || !box) return;
|
||||
function sync(){ box.style.display = chk.checked ? 'block' : 'none'; }
|
||||
chk.addEventListener('change', sync);
|
||||
// Initial state
|
||||
sync();
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
|
|
@ -49,6 +49,9 @@
|
|||
{% if added_total is not none %}
|
||||
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
|
||||
{% endif %}
|
||||
{% if prefer_combos %}
|
||||
<span class="chip" title="Combos plan"><span class="dot" style="background: var(--orange-main);"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
|
||||
{% endif %}
|
||||
{% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %}
|
||||
<span class="chip" title="Trimmed overflow from this stage"><span class="dot" style="background: var(--red-main);"></span> Clamped {{ clamped_overflow }}</span>
|
||||
{% endif %}
|
||||
|
|
@ -77,6 +80,10 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
|
||||
{% endif %}
|
||||
|
||||
{% if locked_cards is defined and locked_cards %}
|
||||
<details id="locked-section" style="margin-top:.5rem;">
|
||||
<summary>Locked cards (always kept)</summary>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
|
||||
|
||||
{% if summary %}
|
||||
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
|
||||
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<form method="get" action="/decks/compare" class="panel" style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<label>Deck A
|
||||
<select name="A" required>
|
||||
<option value="">Choose…</option>
|
||||
<option value="" data-mtime="0">Choose…</option>
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if A == opt.name %}selected{% endif %}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
</label>
|
||||
<label>Deck B
|
||||
<select name="B" required>
|
||||
<option value="">Choose…</option>
|
||||
<option value="" data-mtime="0">Choose…</option>
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if B == opt.name %}selected{% endif %}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
|
||||
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
|
||||
{% else %}
|
||||
<div class="muted">No summary available.</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@
|
|||
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Combos & Synergies (ad-hoc)</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Paste card names (one per line) and detect two-card combos and synergies using current lists.</div>
|
||||
<textarea id="diag-combos-input" rows="6" style="width:100%; resize:vertical; font-family: var(--mono);"></textarea>
|
||||
<div style="margin-top:.5rem; display:flex; gap:.5rem; align-items:center">
|
||||
<button class="btn" id="diag-combos-run">Detect</button>
|
||||
<small class="muted">Runs in diagnostics mode only.</small>
|
||||
</div>
|
||||
<pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre>
|
||||
</div>
|
||||
{% if enable_pwa %}
|
||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">PWA status</h3>
|
||||
|
|
@ -86,6 +96,48 @@
|
|||
});
|
||||
}
|
||||
}catch(_){ }
|
||||
// Combos & synergies ad-hoc tester
|
||||
try{
|
||||
var runBtn = document.getElementById('diag-combos-run');
|
||||
var ta = document.getElementById('diag-combos-input');
|
||||
var out = document.getElementById('diag-combos-out');
|
||||
function parseLines(){
|
||||
var v = (ta && ta.value) || '';
|
||||
return v.split(/\r?\n/).map(function(s){ return s.trim(); }).filter(Boolean);
|
||||
}
|
||||
async function run(){
|
||||
if (!ta || !out) return;
|
||||
out.textContent = 'Running…';
|
||||
try{
|
||||
var resp = await fetch('/diagnostics/combos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names: parseLines() })});
|
||||
if (!resp.ok){ out.textContent = 'Error '+resp.status; return; }
|
||||
var data = await resp.json();
|
||||
var lines = [];
|
||||
// Versions
|
||||
try{
|
||||
if (data.versions){
|
||||
var vLine = 'List versions: ';
|
||||
if (data.versions.combos) vLine += 'combos v'+ String(data.versions.combos);
|
||||
if (data.versions.synergies) vLine += (data.versions.combos? ', ' : '') + 'synergies v'+ String(data.versions.synergies);
|
||||
lines.push(vLine);
|
||||
}
|
||||
}catch(_){ }
|
||||
lines.push('Combos: '+ data.counts.combos);
|
||||
(data.combos||[]).forEach(function(c){
|
||||
var badges = [];
|
||||
if (c.cheap_early) badges.push('cheap/early');
|
||||
if (c.setup_dependent) badges.push('setup-dependent');
|
||||
var tagStr = (c.tags && c.tags.length? ' ['+c.tags.join(', ')+']' : '');
|
||||
var badgeStr = badges.length ? ' {'+badges.join(', ')+'}' : '';
|
||||
lines.push(' - '+c.a+' + '+c.b+ tagStr + badgeStr);
|
||||
});
|
||||
lines.push('Synergies: '+ data.counts.synergies);
|
||||
(data.synergies||[]).forEach(function(s){ lines.push(' - '+s.a+' + '+s.b+(s.tags && s.tags.length? ' ['+s.tags.join(', ')+']':'')); });
|
||||
out.textContent = lines.join('\n');
|
||||
}catch(e){ out.textContent = 'Failed: '+ (e && e.message? e.message : 'Unknown error'); }
|
||||
}
|
||||
if (runBtn){ runBtn.addEventListener('click', run); }
|
||||
}catch(_){ }
|
||||
try{
|
||||
var p = document.getElementById('pwaStatus');
|
||||
if (p){
|
||||
|
|
|
|||
|
|
@ -1,11 +1,60 @@
|
|||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
{% if versions and (versions.combos or versions.synergies) %}
|
||||
<div class="muted" style="font-size:12px; margin:.1rem 0 .4rem 0;">Combos/Synergies lists: v{{ versions.combos or '?' }} / v{{ versions.synergies or '?' }}</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Legend:</span>
|
||||
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
|
||||
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✔</span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✖</span>Not owned</span>
|
||||
</div>
|
||||
|
||||
<!-- Detected Combos & Synergies (top) -->
|
||||
{% if combos or synergies %}
|
||||
<section style="margin-top:.25rem;">
|
||||
<h5>Combos & Synergies</h5>
|
||||
{% if combos %}
|
||||
<div style="margin:.25rem 0 .5rem 0;">
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Combos ({{ combos|length }})</div>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
|
||||
{% for c in combos %}
|
||||
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
|
||||
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
|
||||
<span class="muted"> + </span>
|
||||
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
|
||||
{% if c.cheap_early or c.setup_dependent %}
|
||||
<span class="muted" style="margin-left:.4rem; font-size:12px;">
|
||||
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
|
||||
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if synergies %}
|
||||
<div style="margin:.25rem 0 .5rem 0;">
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Synergies ({{ synergies|length }})</div>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
|
||||
{% for s in synergies %}
|
||||
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
|
||||
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
|
||||
<span class="muted"> + </span>
|
||||
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
|
||||
{% if s.tags %}
|
||||
<span class="muted" style="margin-left:.4rem; font-size:12px;">
|
||||
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Card Type Breakdown with names-only list and hover preview -->
|
||||
<section style="margin-top:.5rem;">
|
||||
<h5>Card Types</h5>
|
||||
|
|
@ -99,6 +148,7 @@
|
|||
<div class="muted">No type data available.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var listBtn = document.querySelector('.seg-btn[data-view="list"]');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue