mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-22 18:40: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
|
|
@ -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