mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
feat(web): Multi-Copy modal earlier; Multi-Copy stage before lands; bump version to 2.1.1; update CHANGELOG\n\n- Modal triggers after commander selection (Step 2)\n- Multi-Copy applied first in Step 5, lands next\n- Keep mc_summary/clamp/adjustments wiring intact\n- Tests green
This commit is contained in:
parent
be672ac5d2
commit
341a216ed3
20 changed files with 1271 additions and 21 deletions
|
@ -8,6 +8,8 @@ from ..services import orchestrator as orch
|
|||
from ..services import owned_store
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
@ -31,6 +33,76 @@ def _alts_set_cached(key: tuple[str, str, bool], html: str) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
||||
"""Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
|
||||
|
||||
This ensures the added cards are accounted for before lands and later phases,
|
||||
which keeps totals near targets and shows the multi-copy additions ahead of basics.
|
||||
"""
|
||||
try:
|
||||
if not sess or not sess.get("commander"):
|
||||
return
|
||||
# Build fresh ctx with the same options, threading multi_copy explicitly
|
||||
opts = orch.bracket_options()
|
||||
default_bracket = (opts[0]["level"] if opts else 1)
|
||||
bracket_val = sess.get("bracket")
|
||||
try:
|
||||
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
|
||||
except Exception:
|
||||
safe_bracket = int(default_bracket)
|
||||
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||
use_owned = bool(sess.get("use_owned_only"))
|
||||
prefer = bool(sess.get("prefer_owned"))
|
||||
owned_names = owned_store.get_names() if (use_owned or prefer) else None
|
||||
locks = list(sess.get("locks", []))
|
||||
sess["build_ctx"] = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
tag_mode=sess.get("tag_mode", "AND"),
|
||||
use_owned_only=use_owned,
|
||||
prefer_owned=prefer,
|
||||
owned_names=owned_names,
|
||||
locks=locks,
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
)
|
||||
except Exception:
|
||||
# If rebuild fails (e.g., commander not found in test), fall back to injecting
|
||||
# a minimal Multi-Copy stage on the existing builder so the UI can render additions.
|
||||
try:
|
||||
ctx = sess.get("build_ctx")
|
||||
if not isinstance(ctx, dict):
|
||||
return
|
||||
b = ctx.get("builder")
|
||||
if b is None:
|
||||
return
|
||||
# Thread selection onto the builder; runner will be resilient without full DFs
|
||||
try:
|
||||
setattr(b, "_web_multi_copy", sess.get("multi_copy") or None)
|
||||
except Exception:
|
||||
pass
|
||||
# Ensure minimal structures exist
|
||||
try:
|
||||
if not isinstance(getattr(b, "card_library", None), dict):
|
||||
b.card_library = {}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if not isinstance(getattr(b, "ideal_counts", None), dict):
|
||||
b.ideal_counts = {}
|
||||
except Exception:
|
||||
pass
|
||||
# Inject a single Multi-Copy stage
|
||||
ctx["stages"] = [{"key": "multi_copy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}]
|
||||
ctx["idx"] = 0
|
||||
ctx["last_visible_idx"] = 0
|
||||
except Exception:
|
||||
# Leave existing context untouched on unexpected failure
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def build_index(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
@ -63,6 +135,158 @@ async def build_index(request: Request) -> HTMLResponse:
|
|||
return resp
|
||||
|
||||
|
||||
# --- Multi-copy archetype suggestion modal (Web-first flow) ---
|
||||
|
||||
@router.get("/multicopy/check", response_class=HTMLResponse)
|
||||
async def multicopy_check(request: Request) -> HTMLResponse:
|
||||
"""If current commander/tags suggest a multi-copy archetype, render a choose-one modal.
|
||||
|
||||
Returns empty content when not applicable to avoid flashing a modal unnecessarily.
|
||||
"""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
commander = str(sess.get("commander") or "").strip()
|
||||
tags = list(sess.get("tags") or [])
|
||||
if not commander:
|
||||
return HTMLResponse("")
|
||||
# Avoid re-prompting repeatedly for the same selection context
|
||||
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
|
||||
seen = set(sess.get("mc_seen_keys", []) or [])
|
||||
if key in seen:
|
||||
return HTMLResponse("")
|
||||
# Build a light DeckBuilder seeded with commander + tags (no heavy data load required)
|
||||
try:
|
||||
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||
df = tmp.load_commander_data()
|
||||
row = df[df["name"].astype(str) == commander]
|
||||
if row.empty:
|
||||
return HTMLResponse("")
|
||||
tmp._apply_commander_selection(row.iloc[0])
|
||||
tmp.selected_tags = list(tags or [])
|
||||
try:
|
||||
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
|
||||
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
|
||||
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
|
||||
except Exception:
|
||||
pass
|
||||
# Establish color identity from the selected commander
|
||||
try:
|
||||
tmp.determine_color_identity()
|
||||
except Exception:
|
||||
pass
|
||||
# Detect viable archetypes
|
||||
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
|
||||
if not results:
|
||||
# Remember this key to avoid re-checking until tags/commander change
|
||||
try:
|
||||
seen.add(key)
|
||||
sess["mc_seen_keys"] = list(seen)
|
||||
except Exception:
|
||||
pass
|
||||
return HTMLResponse("")
|
||||
# Render modal template with top N (cap small for UX)
|
||||
items = results[:5]
|
||||
ctx = {
|
||||
"request": request,
|
||||
"items": items,
|
||||
"commander": commander,
|
||||
"tags": tags,
|
||||
}
|
||||
return templates.TemplateResponse("build/_multi_copy_modal.html", ctx)
|
||||
except Exception:
|
||||
return HTMLResponse("")
|
||||
|
||||
|
||||
@router.post("/multicopy/save", response_class=HTMLResponse)
|
||||
async def multicopy_save(
|
||||
request: Request,
|
||||
choice_id: str = Form(None),
|
||||
count: int = Form(None),
|
||||
thrumming: str | None = Form(None),
|
||||
skip: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Persist user selection (or skip) for multi-copy archetype in session and close modal.
|
||||
|
||||
Returns a tiny confirmation chip via OOB swap (optional) and removes the modal.
|
||||
"""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
commander = str(sess.get("commander") or "").strip()
|
||||
tags = list(sess.get("tags") or [])
|
||||
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
|
||||
# Update seen set to avoid re-prompt next load
|
||||
seen = set(sess.get("mc_seen_keys", []) or [])
|
||||
seen.add(key)
|
||||
sess["mc_seen_keys"] = list(seen)
|
||||
# Handle skip explicitly
|
||||
if skip and str(skip).strip() in ("1","true","on","yes"):
|
||||
# Clear any prior choice for this run
|
||||
try:
|
||||
if sess.get("multi_copy"):
|
||||
del sess["multi_copy"]
|
||||
if sess.get("mc_applied_key"):
|
||||
del sess["mc_applied_key"]
|
||||
except Exception:
|
||||
pass
|
||||
# Return nothing (modal will be removed client-side)
|
||||
# Also emit an OOB chip indicating skip
|
||||
chip = (
|
||||
'<div id="last-action" hx-swap-oob="true">'
|
||||
'<span class="chip" title="Click to dismiss">Dismissed multi-copy suggestions</span>'
|
||||
'</div>'
|
||||
)
|
||||
return HTMLResponse(chip)
|
||||
# Persist selection when provided
|
||||
payload = None
|
||||
try:
|
||||
meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {})
|
||||
name = meta.get("name") or str(choice_id)
|
||||
printed_cap = meta.get("printed_cap")
|
||||
# Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present
|
||||
if count is None:
|
||||
count = int(meta.get("default_count", 25))
|
||||
try:
|
||||
count = int(count)
|
||||
except Exception:
|
||||
count = int(meta.get("default_count", 25))
|
||||
if isinstance(printed_cap, int) and printed_cap > 0:
|
||||
count = max(1, min(printed_cap, count))
|
||||
payload = {
|
||||
"id": str(choice_id),
|
||||
"name": name,
|
||||
"count": int(count),
|
||||
"thrumming": True if (thrumming and str(thrumming).strip() in ("1","true","on","yes")) else False,
|
||||
}
|
||||
sess["multi_copy"] = payload
|
||||
# Mark as not yet applied so the next build start/continue can account for it once
|
||||
try:
|
||||
if sess.get("mc_applied_key"):
|
||||
del sess["mc_applied_key"]
|
||||
except Exception:
|
||||
pass
|
||||
# If there's an active build context, rebuild it so Multi-Copy runs first
|
||||
if sess.get("build_ctx"):
|
||||
_rebuild_ctx_with_multicopy(sess)
|
||||
except Exception:
|
||||
payload = None
|
||||
# Return OOB chip summarizing the selection
|
||||
if payload:
|
||||
chip = (
|
||||
'<div id="last-action" hx-swap-oob="true">'
|
||||
f'<span class="chip" title="Click to dismiss">Selected multi-copy: '
|
||||
f"<strong>{_esc(payload.get('name',''))}</strong> x{int(payload.get('count',0))}"
|
||||
f"{' + Thrumming Stone' if payload.get('thrumming') else ''}</span>"
|
||||
'</div>'
|
||||
)
|
||||
else:
|
||||
chip = (
|
||||
'<div id="last-action" hx-swap-oob="true">'
|
||||
'<span class="chip" title="Click to dismiss">Saved</span>'
|
||||
'</div>'
|
||||
)
|
||||
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:
|
||||
|
@ -199,6 +423,13 @@ async def build_new_submit(
|
|||
del sess[k]
|
||||
except Exception:
|
||||
pass
|
||||
# Reset multi-copy suggestion debounce and selection for a fresh run
|
||||
for k in ["mc_seen_keys", "multi_copy"]:
|
||||
if k in sess:
|
||||
try:
|
||||
del sess[k]
|
||||
except Exception:
|
||||
pass
|
||||
# Persist optional custom export base name
|
||||
if isinstance(name, str) and name.strip():
|
||||
sess["custom_export_base"] = name.strip()
|
||||
|
@ -233,6 +464,7 @@ async def build_new_submit(
|
|||
owned_names=owned_names,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
|
||||
status = "Build complete" if res.get("done") else "Stage complete"
|
||||
|
@ -262,6 +494,9 @@ async def build_new_submit(
|
|||
"show_skipped": False,
|
||||
"total_cards": res.get("total_cards"),
|
||||
"added_total": res.get("added_total"),
|
||||
"mc_adjustments": res.get("mc_adjustments"),
|
||||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
|
@ -361,7 +596,7 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
|
|||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
# Reset sticky selections from previous runs
|
||||
for k in ["tags", "ideals", "bracket", "build_ctx", "last_step", "tag_mode"]:
|
||||
for k in ["tags", "ideals", "bracket", "build_ctx", "last_step", "tag_mode", "mc_seen_keys", "multi_copy"]:
|
||||
try:
|
||||
if k in sess:
|
||||
del sess[k]
|
||||
|
@ -471,6 +706,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
owned_names=owned_names,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
)
|
||||
ctx = sess["build_ctx"]
|
||||
# Run forward until reaching target
|
||||
|
@ -505,6 +741,9 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
"show_skipped": True,
|
||||
"total_cards": res.get("total_cards"),
|
||||
"added_total": res.get("added_total"),
|
||||
"mc_adjustments": res.get("mc_adjustments"),
|
||||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
|
@ -594,6 +833,16 @@ async def build_step2_submit(
|
|||
sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
||||
sess["tag_mode"] = (tag_mode or "AND").upper()
|
||||
sess["bracket"] = int(bracket)
|
||||
# Clear multi-copy seen/selection to re-evaluate on Step 3
|
||||
try:
|
||||
if "mc_seen_keys" in sess:
|
||||
del sess["mc_seen_keys"]
|
||||
if "multi_copy" in sess:
|
||||
del sess["multi_copy"]
|
||||
if "mc_applied_key" in sess:
|
||||
del sess["mc_applied_key"]
|
||||
except Exception:
|
||||
pass
|
||||
# Proceed to Step 3 placeholder for now
|
||||
sess["last_step"] = 3
|
||||
resp = templates.TemplateResponse(
|
||||
|
@ -675,6 +924,12 @@ async def build_step3_submit(
|
|||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["ideals"] = submitted
|
||||
# Any change to ideals should clear the applied marker, we may want to re-stage
|
||||
try:
|
||||
if "mc_applied_key" in sess:
|
||||
del sess["mc_applied_key"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Proceed to review (Step 4)
|
||||
sess["last_step"] = 4
|
||||
|
@ -842,7 +1097,46 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
owned_names=owned_names,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
)
|
||||
else:
|
||||
# If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet
|
||||
try:
|
||||
mc = sess.get("multi_copy") or None
|
||||
selkey = None
|
||||
if mc:
|
||||
selkey = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
||||
applied = sess.get("mc_applied_key") if mc else None
|
||||
if mc and (not applied or applied != selkey):
|
||||
_rebuild_ctx_with_multicopy(sess)
|
||||
# If we still have no stages (e.g., minimal test context), inject a minimal multi-copy stage inline
|
||||
try:
|
||||
ctx = sess.get("build_ctx") or {}
|
||||
stages = ctx.get("stages") if isinstance(ctx, dict) else None
|
||||
if (not stages or len(stages) == 0) and mc:
|
||||
b = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||
if b is not None:
|
||||
try:
|
||||
setattr(b, "_web_multi_copy", mc)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if not isinstance(getattr(b, "card_library", None), dict):
|
||||
b.card_library = {}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if not isinstance(getattr(b, "ideal_counts", None), dict):
|
||||
b.ideal_counts = {}
|
||||
except Exception:
|
||||
pass
|
||||
ctx["stages"] = [{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}]
|
||||
ctx["idx"] = 0
|
||||
ctx["last_visible_idx"] = 0
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Read show_skipped from either query or form safely
|
||||
show_skipped = True if (request.query_params.get('show_skipped') == '1') else False
|
||||
try:
|
||||
|
@ -856,6 +1150,13 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
stage_label = res.get("label")
|
||||
log = res.get("log_delta", "")
|
||||
added_cards = res.get("added_cards", [])
|
||||
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
|
||||
try:
|
||||
if stage_label == "Multi-Copy Package" and sess.get("multi_copy"):
|
||||
mc = sess.get("multi_copy")
|
||||
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
||||
except Exception:
|
||||
pass
|
||||
# Progress & downloads
|
||||
i = res.get("idx")
|
||||
n = res.get("total")
|
||||
|
@ -889,6 +1190,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
"show_skipped": show_skipped,
|
||||
"total_cards": total_cards,
|
||||
"added_total": added_total,
|
||||
"mc_adjustments": res.get("mc_adjustments"),
|
||||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
|
@ -930,6 +1234,8 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
prefer_owned=prefer,
|
||||
owned_names=owned_names,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
)
|
||||
else:
|
||||
# Ensure latest locks are reflected in the existing context
|
||||
|
@ -1049,6 +1355,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
"show_skipped": show_skipped,
|
||||
"total_cards": total_cards,
|
||||
"added_total": added_total,
|
||||
"mc_adjustments": res.get("mc_adjustments"),
|
||||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
|
@ -1098,6 +1407,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
owned_names=owned_names,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
)
|
||||
show_skipped = False
|
||||
try:
|
||||
|
@ -1110,6 +1420,13 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
stage_label = res.get("label")
|
||||
log = res.get("log_delta", "")
|
||||
added_cards = res.get("added_cards", [])
|
||||
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
|
||||
try:
|
||||
if stage_label == "Multi-Copy Package" and sess.get("multi_copy"):
|
||||
mc = sess.get("multi_copy")
|
||||
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
||||
except Exception:
|
||||
pass
|
||||
i = res.get("idx")
|
||||
n = res.get("total")
|
||||
csv_path = res.get("csv_path") if res.get("done") else None
|
||||
|
@ -1139,6 +1456,9 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
"summary": summary,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"show_skipped": show_skipped,
|
||||
"mc_adjustments": res.get("mc_adjustments"),
|
||||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
},
|
||||
|
|
|
@ -849,7 +849,16 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
|||
# -----------------
|
||||
def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
||||
stages: List[Dict[str, Any]] = []
|
||||
# Run Multi-Copy before land steps (per web-first flow preference)
|
||||
mc_selected = False
|
||||
try:
|
||||
mc_selected = bool(getattr(b, '_web_multi_copy', None))
|
||||
except Exception:
|
||||
mc_selected = False
|
||||
# Web UI: skip theme confirmation stages (CLI-only pauses)
|
||||
# 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__"})
|
||||
# Land steps 1..8 (if present)
|
||||
for i in range(1, 9):
|
||||
fn = getattr(b, f"run_land_step{i}", None)
|
||||
|
@ -914,6 +923,7 @@ def start_build_ctx(
|
|||
owned_names: List[str] | None = None,
|
||||
locks: List[str] | None = None,
|
||||
custom_export_base: str | None = None,
|
||||
multi_copy: Dict[str, Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
|
@ -979,6 +989,11 @@ def start_build_ctx(
|
|||
# Data load
|
||||
b.determine_color_identity()
|
||||
b.setup_dataframes()
|
||||
# Thread multi-copy selection onto builder for stage generation/runner
|
||||
try:
|
||||
b._web_multi_copy = (multi_copy or None)
|
||||
except Exception:
|
||||
pass
|
||||
# Stages
|
||||
stages = _make_stages(b)
|
||||
ctx = {
|
||||
|
@ -1166,7 +1181,134 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
# Run the stage and capture logs delta
|
||||
start_log = len(logs)
|
||||
fn = getattr(b, runner_name, None)
|
||||
if callable(fn):
|
||||
if runner_name == '__add_multi_copy__':
|
||||
try:
|
||||
sel = getattr(b, '_web_multi_copy', None) or {}
|
||||
card_name = str(sel.get('name') or '').strip()
|
||||
count = int(sel.get('count') or 0)
|
||||
add_thrum = bool(sel.get('thrumming'))
|
||||
sel_id = str(sel.get('id') or '').strip()
|
||||
# Look up archetype meta for type hints
|
||||
try:
|
||||
from deck_builder import builder_constants as _bc
|
||||
meta = (_bc.MULTI_COPY_ARCHETYPES or {}).get(sel_id, {})
|
||||
type_hint = str(meta.get('type_hint') or '').strip().lower()
|
||||
except Exception:
|
||||
type_hint = ''
|
||||
added_any = False
|
||||
mc_adjustments: list[str] = []
|
||||
# Helper: resolve display name via combined DF if possible for correct casing
|
||||
def _resolve_name(nm: str) -> str:
|
||||
try:
|
||||
df = getattr(b, '_combined_cards_df', None)
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['name'].astype(str).str.lower() == str(nm).strip().lower()]
|
||||
if not row.empty:
|
||||
return str(row.iloc[0]['name'])
|
||||
except Exception:
|
||||
pass
|
||||
return nm
|
||||
# Helper: enrich library entry with type and mana cost from DF when possible
|
||||
def _enrich_from_df(entry: dict, nm: str) -> None:
|
||||
try:
|
||||
df = getattr(b, '_combined_cards_df', None)
|
||||
if df is None or getattr(df, 'empty', True):
|
||||
return
|
||||
row = df[df['name'].astype(str).str.lower() == str(nm).strip().lower()]
|
||||
if row.empty:
|
||||
return
|
||||
r0 = row.iloc[0]
|
||||
tline = str(r0.get('type', r0.get('type_line', '')) or '')
|
||||
if tline:
|
||||
entry['Card Type'] = tline
|
||||
mc = r0.get('mana_cost', r0.get('manaCost'))
|
||||
if isinstance(mc, str) and mc:
|
||||
entry['Mana Cost'] = mc
|
||||
except Exception:
|
||||
return
|
||||
mc_summary_parts: list[str] = []
|
||||
if card_name and count > 0:
|
||||
dn = _resolve_name(card_name)
|
||||
entry = b.card_library.get(dn)
|
||||
prev = int(entry.get('Count', 0)) if isinstance(entry, dict) else 0
|
||||
new_count = prev + count
|
||||
new_entry = {
|
||||
'Count': new_count,
|
||||
'Role': 'Theme',
|
||||
'SubRole': 'Multi-Copy',
|
||||
'AddedBy': 'MultiCopy',
|
||||
'TriggerTag': ''
|
||||
}
|
||||
_enrich_from_df(new_entry, dn)
|
||||
b.card_library[dn] = new_entry
|
||||
logs.append(f"Added multi-copy package: {dn} x{count} (total {new_count}).")
|
||||
mc_summary_parts.append(f"{dn} ×{count}")
|
||||
added_any = True
|
||||
if add_thrum:
|
||||
try:
|
||||
tn = _resolve_name('Thrumming Stone')
|
||||
e2 = b.card_library.get(tn)
|
||||
prev2 = int(e2.get('Count', 0)) if isinstance(e2, dict) else 0
|
||||
new_e2 = {
|
||||
'Count': prev2 + 1,
|
||||
'Role': 'Support',
|
||||
'SubRole': 'Multi-Copy',
|
||||
'AddedBy': 'MultiCopy',
|
||||
'TriggerTag': ''
|
||||
}
|
||||
_enrich_from_df(new_e2, tn)
|
||||
b.card_library[tn] = new_e2
|
||||
logs.append("Included Thrumming Stone (1x).")
|
||||
mc_summary_parts.append("Thrumming Stone ×1")
|
||||
added_any = True
|
||||
except Exception:
|
||||
pass
|
||||
# Adjust ideal targets to prevent overfilling later phases
|
||||
try:
|
||||
# Reduce creature target when the multi-copy is a creature-type archetype
|
||||
if type_hint == 'creature':
|
||||
cur = int(getattr(b, 'ideal_counts', {}).get('creatures', 0))
|
||||
new_val = max(0, cur - max(0, count))
|
||||
b.ideal_counts['creatures'] = new_val
|
||||
logs.append(f"Adjusted target: creatures {cur} -> {new_val} due to multi-copy ({count}).")
|
||||
mc_adjustments.append(f"creatures {cur}→{new_val}")
|
||||
else:
|
||||
# Spread reduction across spell categories in a stable order
|
||||
to_spread = max(0, count + (1 if add_thrum else 0))
|
||||
order = ['card_advantage', 'protection', 'removal', 'wipes']
|
||||
for key in order:
|
||||
if to_spread <= 0:
|
||||
break
|
||||
try:
|
||||
cur = int(getattr(b, 'ideal_counts', {}).get(key, 0))
|
||||
except Exception:
|
||||
cur = 0
|
||||
if cur <= 0:
|
||||
continue
|
||||
take = min(cur, to_spread)
|
||||
b.ideal_counts[key] = cur - take
|
||||
to_spread -= take
|
||||
logs.append(f"Adjusted target: {key} {cur} -> {cur - take} due to multi-copy.")
|
||||
mc_adjustments.append(f"{key} {cur}→{cur - take}")
|
||||
except Exception:
|
||||
pass
|
||||
# Surface adjustments for Step 5 UI
|
||||
try:
|
||||
if mc_adjustments:
|
||||
ctx.setdefault('mc_adjustments', mc_adjustments)
|
||||
except Exception:
|
||||
pass
|
||||
# Surface a concise summary for UI chip
|
||||
try:
|
||||
if mc_summary_parts:
|
||||
ctx['mc_summary'] = ' + '.join(mc_summary_parts)
|
||||
except Exception:
|
||||
pass
|
||||
if not added_any:
|
||||
logs.append("No multi-copy additions (empty selection).")
|
||||
except Exception as e:
|
||||
logs.append(f"Stage '{label}' failed: {e}")
|
||||
elif callable(fn):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
|
@ -1245,6 +1387,65 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
except Exception:
|
||||
added_cards = []
|
||||
|
||||
# Final safety clamp: keep total deck size <= 100 by trimming this stage's additions first
|
||||
clamped_overflow = 0
|
||||
# Compute current total_cards upfront (used below and in response)
|
||||
try:
|
||||
total_cards = 0
|
||||
for _n, _e in getattr(b, 'card_library', {}).items():
|
||||
try:
|
||||
total_cards += int(_e.get('Count', 1))
|
||||
except Exception:
|
||||
total_cards += 1
|
||||
except Exception:
|
||||
total_cards = None
|
||||
try:
|
||||
overflow = max(0, int(total_cards) - 100)
|
||||
if overflow > 0 and added_cards:
|
||||
# Trim from added cards without reducing below pre-stage counts; skip locked names
|
||||
remaining = overflow
|
||||
for ac in reversed(added_cards):
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
name = str(ac.get('name'))
|
||||
if not name:
|
||||
continue
|
||||
if name.strip().lower() in locks_set:
|
||||
continue
|
||||
prev_entry = (snap_before.get('card_library') or {}).get(name)
|
||||
prev_cnt = int(prev_entry.get('Count', 0)) if isinstance(prev_entry, dict) else 0
|
||||
cur_entry = getattr(b, 'card_library', {}).get(name)
|
||||
cur_cnt = int(cur_entry.get('Count', 1)) if isinstance(cur_entry, dict) else 1
|
||||
can_reduce = max(0, cur_cnt - prev_cnt)
|
||||
if can_reduce <= 0:
|
||||
continue
|
||||
take = min(can_reduce, remaining, int(ac.get('count', 0) or 0))
|
||||
if take <= 0:
|
||||
continue
|
||||
new_cnt = cur_cnt - take
|
||||
if new_cnt <= 0:
|
||||
try:
|
||||
del b.card_library[name]
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
cur_entry['Count'] = new_cnt
|
||||
ac['count'] = max(0, int(ac.get('count', 0) or 0) - take)
|
||||
remaining -= take
|
||||
clamped_overflow += take
|
||||
except Exception:
|
||||
continue
|
||||
# Drop any zero-count added rows
|
||||
added_cards = [x for x in added_cards if int(x.get('count', 0) or 0) > 0]
|
||||
if clamped_overflow > 0:
|
||||
try:
|
||||
logs.append(f"Clamped {clamped_overflow} card(s) from this stage to remain at 100.")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
clamped_overflow = 0
|
||||
|
||||
# If this stage added cards, present it and advance idx
|
||||
if added_cards:
|
||||
# Progress counts
|
||||
|
@ -1283,6 +1484,9 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
"total": len(stages),
|
||||
"total_cards": total_cards,
|
||||
"added_total": added_total,
|
||||
"mc_adjustments": ctx.get('mc_adjustments'),
|
||||
"clamped_overflow": clamped_overflow,
|
||||
"mc_summary": ctx.get('mc_summary'),
|
||||
}
|
||||
|
||||
# No cards added: either skip or surface as a 'skipped' stage
|
||||
|
|
79
code/web/templates/build/_multi_copy_modal.html
Normal file
79
code/web/templates/build/_multi_copy_modal.html
Normal file
|
@ -0,0 +1,79 @@
|
|||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="mcTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
|
||||
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
|
||||
<div class="modal-content" style="position:relative; max-width:620px; width:clamp(320px, 90vw, 620px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
|
||||
<div class="modal-header" style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;">
|
||||
<h3 id="mcTitle" style="margin:0;">Consider a multi-copy package?</h3>
|
||||
<button type="button" class="btn" aria-label="Close" onclick="try{this.closest('.modal').remove();}catch(_){ }">×</button>
|
||||
</div>
|
||||
<form hx-post="/build/multicopy/save" hx-target="closest .modal" hx-swap="outerHTML" onsubmit="return validateMultiCopyForm(this);">
|
||||
<fieldset>
|
||||
<legend>Choose one archetype</legend>
|
||||
<div style="display:grid; gap:.5rem;">
|
||||
{% for it in items %}
|
||||
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
|
||||
<input type="radio" name="choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
|
||||
<div>
|
||||
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
|
||||
{% if it.reasons %}
|
||||
<div class="muted" style="font-size:12px;">Signals: {{ ', '.join(it.reasons) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset style="margin-top:.5rem;">
|
||||
<legend>How many copies?</legend>
|
||||
{% set first = items[0] %}
|
||||
{% set cap = first.printed_cap %}
|
||||
{% set rec = first.rec_window if first.rec_window else (20,30) %}
|
||||
<div id="mc-count-row" class="mc-count" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
|
||||
<input type="number" min="1" name="count" value="{{ first.default_count or 25 }}" />
|
||||
{% if cap %}
|
||||
<small class="muted">Max {{ cap }}</small>
|
||||
{% else %}
|
||||
<small class="muted">Suggested {{ rec[0] }}–{{ rec[1] }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="mc-thrum-row" style="margin-top:.35rem;">
|
||||
<label title="Adds 1 copy of Thrumming Stone if applicable.">
|
||||
<input type="checkbox" name="thrumming" value="1" {% if first.thrumming_stone_synergy %}checked{% endif %} /> Include Thrumming Stone
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<button type="submit" class="btn-continue">Save</button>
|
||||
<button type="submit" class="btn" name="skip" value="1" title="Don't ask again for this commander/theme combo">Skip</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
function qs(sel, root){ return (root||document).querySelector(sel); }
|
||||
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
|
||||
var form = modal ? modal.querySelector('form') : null;
|
||||
function updateForChoice(choice){
|
||||
try {
|
||||
var countRow = qs('#mc-count-row', modal);
|
||||
var thrumRow = qs('#mc-thrum-row', modal);
|
||||
if (!choice || !countRow) return;
|
||||
// Server provides only items array; embed metadata via dataset for dynamic hints when switching radio
|
||||
var metaEl = choice.closest('label.mc-option');
|
||||
var printedCap = metaEl && metaEl.querySelector('.muted') && metaEl.querySelector('.muted').textContent.match(/Cap: (\d+)/);
|
||||
var cap = printedCap ? parseInt(printedCap[1], 10) : null;
|
||||
var num = countRow.querySelector('input[name="count"]');
|
||||
if (cap){ num.max = String(cap); if (parseInt(num.value||'0',10) > cap){ num.value = String(cap); } }
|
||||
else { num.removeAttribute('max'); }
|
||||
} catch(_){}
|
||||
}
|
||||
if (form){
|
||||
var radios = form.querySelectorAll('input[name="choice_id"]');
|
||||
Array.prototype.forEach.call(radios, function(r){ r.addEventListener('change', function(){ updateForChoice(r); }); });
|
||||
if (radios.length){ updateForChoice(radios[0]); }
|
||||
}
|
||||
window.validateMultiCopyForm = function(f){ try{ return true; }catch(_){ return true; } };
|
||||
document.addEventListener('keydown', function(e){ if (e.key === 'Escape'){ try{ modal && modal.remove(); }catch(_){ } } });
|
||||
})();
|
||||
</script>
|
|
@ -8,6 +8,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>
|
||||
|
||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
<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>
|
||||
|
||||
|
||||
|
||||
{% if error %}
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<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>
|
||||
{% if locks_restored and locks_restored > 0 %}
|
||||
<div class="muted" style="margin:.35rem 0;">
|
||||
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>
|
||||
|
|
|
@ -26,6 +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>
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
|
@ -48,6 +49,12 @@
|
|||
{% if added_total is not none %}
|
||||
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</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 %}
|
||||
{% if stage_label and stage_label == 'Multi-Copy Package' and mc_summary is defined and mc_summary %}
|
||||
<span class="chip" title="Multi-Copy package summary"><span class="dot" style="background: var(--purple-main);"></span> {{ mc_summary }}</span>
|
||||
{% endif %}
|
||||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||||
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
|
||||
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
|
||||
|
@ -60,6 +67,10 @@
|
|||
<div class="bar"></div>
|
||||
</div>
|
||||
|
||||
{% if mc_adjustments is defined and mc_adjustments and stage_label and stage_label == 'Multi-Copy Package' %}
|
||||
<div class="muted" style="margin:.35rem 0 .25rem 0;">Adjusted targets: {{ mc_adjustments|join(', ') }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status %}
|
||||
<div style="margin-top:1rem;">
|
||||
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
|
||||
|
@ -216,7 +227,7 @@
|
|||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
|
@ -254,7 +265,7 @@
|
|||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue