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

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

View file

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

View file

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

View file

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

View file

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

View 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)

View 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}")

View 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))

View 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": "Thassas 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"] == "Thassas 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"]

View 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 = ["Thassas 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

View 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

View 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

View 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": ["Thassas 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

View 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

View file

@ -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],
}
)

View file

@ -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 13 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")

View file

@ -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"))
},
)

View file

@ -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)

View file

@ -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',

View file

@ -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();

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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){

View file

@ -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"]');