from __future__ import annotations from fastapi import APIRouter, Request, Form, Query from fastapi.responses import HTMLResponse, JSONResponse from ..app import ALLOW_MUST_HAVES # Import feature flag from ..services.build_utils import ( step5_ctx_from_result, step5_error_ctx, step5_empty_ctx, start_ctx_from_session, owned_set as owned_set_helper, builder_present_names, builder_display_map, ) from ..app import templates from deck_builder import builder_constants as bc from ..services import orchestrator as orch from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore from ..services.build_utils import owned_names as owned_names_helper 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 ..services.combo_utils import detect_all as _detect_all from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached router = APIRouter(prefix="/build") # Alternatives cache moved to services/alts_utils 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 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_names_helper() 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"), 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 # 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() sess = get_session(sid) # Determine last step (fallback heuristics if not set) last_step = sess.get("last_step") if not last_step: if sess.get("build_ctx"): last_step = 5 elif sess.get("ideals"): last_step = 4 elif sess.get("bracket"): last_step = 3 elif sess.get("commander"): last_step = 2 else: last_step = 1 resp = templates.TemplateResponse( "build/index.html", { "request": request, "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Support /build without trailing slash @router.get("", response_class=HTMLResponse) async def build_index_alias(request: Request) -> HTMLResponse: return await build_index(request) @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 = ( '
' 'Dismissed multi-copy suggestions' '
' ) 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 = ( '
' f'Selected multi-copy: ' f"{_esc(payload.get('name',''))} x{int(payload.get('count',0))}" f"{' + Thrumming Stone' if payload.get('thrumming') else ''}" '
' ) else: chip = ( '
' 'Saved' '
' ) 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: """Return the New Deck modal content (for an overlay).""" sid = request.cookies.get("sid") or new_sid() ctx = { "request": request, "brackets": orch.bracket_options(), "labels": orch.ideal_labels(), "defaults": orch.ideal_defaults(), "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag } resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/new/candidates", response_class=HTMLResponse) async def build_new_candidates(request: Request, commander: str = Query("")) -> HTMLResponse: """Return a small list of commander candidates for the modal live search.""" q = (commander or "").strip() items = orch.commander_candidates(q, limit=8) if q else [] ctx = {"request": request, "query": q, "candidates": items} return templates.TemplateResponse("build/_new_deck_candidates.html", ctx) @router.get("/new/inspect", response_class=HTMLResponse) async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse: """When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates).""" info = orch.commander_select(name) if not info.get("ok"): return HTMLResponse(f'
Commander not found: {name}
') tags = orch.tags_for_commander(info["name"]) or [] recommended = orch.recommended_tags_for_commander(info["name"]) if tags else [] recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {} # Render tags slot content and OOB commander preview simultaneously # Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer) is_gc = False try: is_gc = bool(info["name"] in getattr(bc, 'GAME_CHANGERS', [])) except Exception: is_gc = False ctx = { "request": request, "commander": {"name": info["name"]}, "tags": tags, "recommended": recommended, "recommended_reasons": recommended_reasons, "gc_commander": is_gc, "brackets": orch.bracket_options(), } return templates.TemplateResponse("build/_new_deck_tags.html", ctx) @router.get("/new/multicopy", response_class=HTMLResponse) async def build_new_multicopy( request: Request, commander: str = Query(""), primary_tag: str | None = Query(None), secondary_tag: str | None = Query(None), tertiary_tag: str | None = Query(None), tag_mode: str | None = Query("AND"), ) -> HTMLResponse: """Return multi-copy suggestions for the New Deck modal based on commander + selected tags. This does not mutate the session; it simply renders a form snippet that posts with the main modal. """ name = (commander or "").strip() if not name: return HTMLResponse("") try: tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) df = tmp.load_commander_data() row = df[df["name"].astype(str) == name] if row.empty: return HTMLResponse("") tmp._apply_commander_selection(row.iloc[0]) tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] 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 try: tmp.determine_color_identity() except Exception: pass results = bu.detect_viable_multi_copy_archetypes(tmp) or [] # For the New Deck modal, only show suggestions where the matched tags intersect # the explicitly selected tags (ignore commander-default themes). sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()} def _matched_reason_tags(item: dict) -> set[str]: out = set() try: for r in item.get('reasons', []) or []: if not isinstance(r, str): continue rl = r.strip().lower() if rl.startswith('tags:'): body = rl.split('tags:', 1)[1].strip() parts = [p.strip() for p in body.split(',') if p.strip()] out.update(parts) except Exception: return set() return out if sel_tags: results = [it for it in results if (_matched_reason_tags(it) & sel_tags)] else: # If no selected tags, do not show any multi-copy suggestions in the modal results = [] if not results: return HTMLResponse("") items = results[:5] ctx = {"request": request, "items": items} return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx) except Exception: return HTMLResponse("") @router.post("/new", response_class=HTMLResponse) async def build_new_submit( request: Request, name: str = Form("") , commander: str = Form(...), primary_tag: str | None = Form(None), secondary_tag: str | None = Form(None), tertiary_tag: str | None = Form(None), tag_mode: str | None = Form("AND"), bracket: int = Form(...), ramp: int = Form(None), lands: int = Form(None), basic_lands: int = Form(None), creatures: int = Form(None), removal: int = Form(None), 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), enable_multicopy: bool = Form(False), # Integrated Multi-Copy (optional) multi_choice_id: str | None = Form(None), multi_count: int | None = Form(None), multi_thrumming: str | None = Form(None), # Must-haves/excludes (optional) include_cards: str = Form(""), exclude_cards: str = Form(""), enforcement_mode: str = Form("warn"), allow_illegal: bool = Form(False), fuzzy_matching: bool = Form(True), ) -> HTMLResponse: """Handle New Deck modal submit and immediately start the build (skip separate review page).""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) # Normalize and validate commander selection (best-effort via orchestrator) sel = orch.commander_select(commander) if not sel.get("ok"): # Re-render modal with error ctx = { "request": request, "error": sel.get("error", "Commander not found"), "brackets": orch.bracket_options(), "labels": orch.ideal_labels(), "defaults": orch.ideal_defaults(), "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag "form": { "name": name, "commander": commander, "primary_tag": primary_tag or "", "secondary_tag": secondary_tag or "", "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), "include_cards": include_cards or "", "exclude_cards": exclude_cards or "", "enforcement_mode": enforcement_mode or "warn", "allow_illegal": bool(allow_illegal), "fuzzy_matching": bool(fuzzy_matching), } } resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Enforce GC bracket restriction before saving session (silently coerce to 3) try: is_gc = bool((sel.get("name") or commander) in getattr(bc, 'GAME_CHANGERS', [])) except Exception: is_gc = False if is_gc: try: if int(bracket) < 3: bracket = 3 except Exception: bracket = 3 # Save to session sess["commander"] = sel.get("name") or commander tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] # If commander has a tag list and primary missing, set first recommended as default if not tags: try: rec = orch.recommended_tags_for_commander(sess["commander"]) or [] if rec: tags = [rec[0]] except Exception: pass sess["tags"] = tags sess["tag_mode"] = (tag_mode or "AND").upper() try: # Default to bracket 3 (Upgraded) when not provided sess["bracket"] = int(bracket) if (bracket is not None) else 3 except Exception: try: sess["bracket"] = int(bracket) except Exception: sess["bracket"] = 3 # Ideals: use provided values if any, else defaults ideals = orch.ideal_defaults() overrides = {k: v for k, v in { "ramp": ramp, "lands": lands, "basic_lands": basic_lands, "creatures": creatures, "removal": removal, "wipes": wipes, "card_advantage": card_advantage, "protection": protection, }.items() if v is not None} for k, v in overrides.items(): try: ideals[k] = int(v) 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 # Multi-Copy selection from modal (opt-in) try: # Clear any prior selection first; this flow should define it explicitly when present if "multi_copy" in sess: del sess["multi_copy"] if enable_multicopy and multi_choice_id and str(multi_choice_id).strip(): meta = bc.MULTI_COPY_ARCHETYPES.get(str(multi_choice_id), {}) printed_cap = meta.get("printed_cap") cnt: int if multi_count is None: cnt = int(meta.get("default_count", 25)) else: try: cnt = int(multi_count) except Exception: cnt = int(meta.get("default_count", 25)) if isinstance(printed_cap, int) and printed_cap > 0: cnt = max(1, min(printed_cap, cnt)) sess["multi_copy"] = { "id": str(multi_choice_id), "name": meta.get("name") or str(multi_choice_id), "count": int(cnt), "thrumming": True if (multi_thrumming and str(multi_thrumming).strip() in ("1","true","on","yes")) else False, } else: # Ensure disabled when not opted-in if "multi_copy" in sess: del sess["multi_copy"] # Reset the applied marker so the run can account for the new selection if "mc_applied_key" in sess: del sess["mc_applied_key"] except Exception: pass # Process include/exclude cards (M3: Phase 2 - Full Include/Exclude) try: from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics # Clear any old include/exclude data for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]: if k in sess: del sess[k] # Process include cards if include_cards and include_cards.strip(): print(f"DEBUG: Raw include_cards input: '{include_cards}'") include_list = parse_card_list_input(include_cards.strip()) print(f"DEBUG: Parsed include_list: {include_list}") sess["include_cards"] = include_list else: print(f"DEBUG: include_cards is empty or None: '{include_cards}'") # Process exclude cards if exclude_cards and exclude_cards.strip(): print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'") exclude_list = parse_card_list_input(exclude_cards.strip()) print(f"DEBUG: Parsed exclude_list: {exclude_list}") sess["exclude_cards"] = exclude_list else: print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'") # Store advanced options sess["enforcement_mode"] = enforcement_mode sess["allow_illegal"] = allow_illegal sess["fuzzy_matching"] = fuzzy_matching # Create basic diagnostics for status tracking if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()): diagnostics = IncludeExcludeDiagnostics( missing_includes=[], ignored_color_identity=[], illegal_dropped=[], illegal_allowed=[], excluded_removed=sess.get("exclude_cards", []), duplicates_collapsed={}, include_added=[], include_over_ideal={}, fuzzy_corrections={}, confirmation_needed=[], list_size_warnings={ "includes_count": len(sess.get("include_cards", [])), "excludes_count": len(sess.get("exclude_cards", [])), "includes_limit": 10, "excludes_limit": 15 } ) sess["include_exclude_diagnostics"] = diagnostics.__dict__ except Exception as e: # If exclude parsing fails, log but don't block the build import logging logging.warning(f"Failed to parse exclude cards: {e}") # Clear any old staged build context for k in ["build_ctx", "locks", "replace_mode"]: if k in sess: try: del sess[k] except Exception: pass # Reset multi-copy suggestion debounce for a fresh run (keep selected choice) if "mc_seen_keys" in sess: try: del sess["mc_seen_keys"] except Exception: pass # Persist optional custom export base name if isinstance(name, str) and name.strip(): sess["custom_export_base"] = name.strip() else: if "custom_export_base" in sess: try: del sess["custom_export_base"] except Exception: pass # If setup/tagging is not ready or stale, show a modal prompt instead of auto-running. try: if not _is_setup_ready(): return templates.TemplateResponse( "build/_setup_prompt_modal.html", { "request": request, "title": "Setup required", "message": "The card database and tags need to be prepared before building a deck.", "action_url": "/setup/running?start=1&next=/build", "action_label": "Run Setup", }, ) if _is_setup_stale(): return templates.TemplateResponse( "build/_setup_prompt_modal.html", { "request": request, "title": "Data refresh recommended", "message": "Your card database is stale. Refreshing ensures up-to-date results.", "action_url": "/setup/running?start=1&force=1&next=/build", "action_label": "Refresh Now", }, ) except Exception: # If readiness check fails, continue and let downstream handling surface errors pass # Immediately initialize a build context and run the first stage, like hitting Build Deck on review if "replace_mode" not in sess: sess["replace_mode"] = True # Centralized staged context creation sess["build_ctx"] = start_ctx_from_session(sess) res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue try: if res.get("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 status = "Build complete" if res.get("done") else "Stage complete" sess["last_step"] = 5 ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) resp = templates.TemplateResponse("build/_step5.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/step1", response_class=HTMLResponse) async def build_step1(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 1 resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step1", response_class=HTMLResponse) async def build_step1_search( request: Request, query: str = Form(""), auto: str | None = Form(None), active: str | None = Form(None), ) -> HTMLResponse: query = (query or "").strip() auto_enabled = True if (auto == "1") else False candidates = [] if query: candidates = orch.commander_candidates(query, limit=10) # Optional auto-select at a stricter threshold if auto_enabled and candidates and len(candidates[0]) >= 2 and int(candidates[0][1]) >= 98: top_name = candidates[0][0] res = orch.commander_select(top_name) if res.get("ok"): sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 2 resp = templates.TemplateResponse( "build/_step2.html", { "request": request, "commander": res, "tags": orch.tags_for_commander(res["name"]), "recommended": orch.recommended_tags_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), "gc_commander": (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])), "selected_bracket": (3 if (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) else None), "clear_persisted": True, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 1 resp = templates.TemplateResponse( "build/_step1.html", { "request": request, "query": query, "candidates": candidates, "auto": auto_enabled, "active": active, "count": len(candidates) if candidates else 0, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step1/inspect", response_class=HTMLResponse) async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 1 info = orch.commander_inspect(name) resp = templates.TemplateResponse( "build/_step1.html", {"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)}, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step1/confirm", response_class=HTMLResponse) async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse: res = orch.commander_select(name) if not res.get("ok"): sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 1 resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Proceed to step2 placeholder and reset any prior build/session selections 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", "mc_seen_keys", "multi_copy"]: try: if k in sess: del sess[k] except Exception: pass sess["last_step"] = 2 # Determine if commander is a Game Changer to drive bracket UI hiding is_gc = False try: is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) except Exception: is_gc = False resp = templates.TemplateResponse( "build/_step2.html", { "request": request, "commander": res, "tags": orch.tags_for_commander(res["name"]), "recommended": orch.recommended_tags_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), "gc_commander": is_gc, "selected_bracket": (3 if is_gc else None), # Signal that this navigation came from a fresh commander confirmation, # so the Step 2 UI should clear any localStorage theme persistence. "clear_persisted": True, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/reset-all", response_class=HTMLResponse) async def build_reset_all(request: Request) -> HTMLResponse: """Clear all build-related session state and return Step 1.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) keys = [ "commander","tags","tag_mode","bracket","ideals","build_ctx","last_step", "locks","replace_mode" ] for k in keys: try: if k in sess: del sess[k] except Exception: pass sess["last_step"] = 1 resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step5/rewind", response_class=HTMLResponse) async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLResponse: """Rewind the staged build to a previous visible stage by index or key and show that stage. Param `to` can be an integer index (1-based stage index) or a stage key string. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) ctx = sess.get("build_ctx") if not ctx: return await build_step5_get(request) target_i: int | None = None # Resolve by numeric index first try: idx_val = int(str(to).strip()) target_i = idx_val except Exception: target_i = None if target_i is None: # attempt by key key = str(to).strip() try: for h in ctx.get("history", []) or []: if str(h.get("key")) == key or str(h.get("label")) == key: target_i = int(h.get("i")) break except Exception: target_i = None if not target_i: return await build_step5_get(request) # Try to restore snapshot stored for that history entry try: hist = ctx.get("history", []) or [] snap = None for h in hist: if int(h.get("i")) == int(target_i): snap = h.get("snapshot") break if snap is not None: orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined] ctx["idx"] = int(target_i) - 1 ctx["last_visible_idx"] = int(target_i) - 1 except Exception: # As a fallback, restart ctx and run forward until target sess["build_ctx"] = start_ctx_from_session(sess) ctx = sess["build_ctx"] # Run forward until reaching target while True: res = orch.run_stage(ctx, rerun=False, show_skipped=False) if int(res.get("idx", 0)) >= int(target_i): break if res.get("done"): break # Finally show the target stage by running it with show_skipped True to get a view try: res = orch.run_stage(ctx, rerun=False, show_skipped=True) status = "Stage (rewound)" if not res.get("done") else "Build complete" ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={ "history": ctx.get("history", []), }) except Exception as e: sess["last_step"] = 5 ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}") resp = templates.TemplateResponse("build/_step5.html", ctx_resp) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/step2", response_class=HTMLResponse) async def build_step2_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 2 commander = sess.get("commander") if not commander: # Fallback to step1 if no commander in session resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp tags = orch.tags_for_commander(commander) selected = sess.get("tags", []) # Determine if the selected commander is considered a Game Changer (affects bracket choices) is_gc = False try: is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) except Exception: is_gc = False # Selected bracket: if GC commander and bracket < 3 or missing, default to 3 sel_br = sess.get("bracket") try: sel_br = int(sel_br) if sel_br is not None else None except Exception: sel_br = None if is_gc and (sel_br is None or int(sel_br) < 3): sel_br = 3 resp = templates.TemplateResponse( "build/_step2.html", { "request": request, "commander": {"name": commander}, "tags": tags, "recommended": orch.recommended_tags_for_commander(commander), "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "brackets": orch.bracket_options(), "primary_tag": selected[0] if len(selected) > 0 else "", "secondary_tag": selected[1] if len(selected) > 1 else "", "tertiary_tag": selected[2] if len(selected) > 2 else "", "selected_bracket": sel_br, "tag_mode": sess.get("tag_mode", "AND"), "gc_commander": is_gc, # If there are no server-side tags for this commander, let the client clear any persisted ones # to avoid themes sticking between fresh runs. "clear_persisted": False if selected else True, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step2", response_class=HTMLResponse) async def build_step2_submit( request: Request, commander: str = Form(...), primary_tag: str | None = Form(None), secondary_tag: str | None = Form(None), tertiary_tag: str | None = Form(None), tag_mode: str | None = Form("AND"), bracket: int = Form(...), ) -> HTMLResponse: # Validate primary tag selection if tags are available available_tags = orch.tags_for_commander(commander) if available_tags and not (primary_tag and primary_tag.strip()): sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 2 # Compute GC flag to hide disallowed brackets on error is_gc = False try: is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) except Exception: is_gc = False try: sel_br = int(bracket) if bracket is not None else None except Exception: sel_br = None if is_gc and (sel_br is None or sel_br < 3): sel_br = 3 resp = templates.TemplateResponse( "build/_step2.html", { "request": request, "commander": {"name": commander}, "tags": available_tags, "recommended": orch.recommended_tags_for_commander(commander), "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "brackets": orch.bracket_options(), "error": "Please choose a primary theme.", "primary_tag": primary_tag or "", "secondary_tag": secondary_tag or "", "tertiary_tag": tertiary_tag or "", "selected_bracket": sel_br, "tag_mode": (tag_mode or "AND"), "gc_commander": is_gc, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed) try: is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) except Exception: is_gc = False if is_gc: try: if int(bracket) < 3: bracket = 3 # coerce silently except Exception: bracket = 3 # Save selection to session (basic MVP; real build will use this later) sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["commander"] = commander 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( "build/_step3.html", { "request": request, "commander": commander, "tags": sess["tags"], "bracket": sess["bracket"], "defaults": orch.ideal_defaults(), "labels": orch.ideal_labels(), "values": orch.ideal_defaults(), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step3", response_class=HTMLResponse) async def build_step3_submit( request: Request, ramp: int = Form(...), lands: int = Form(...), basic_lands: int = Form(...), creatures: int = Form(...), removal: int = Form(...), wipes: int = Form(...), card_advantage: int = Form(...), protection: int = Form(...), ) -> HTMLResponse: labels = orch.ideal_labels() submitted = { "ramp": ramp, "lands": lands, "basic_lands": basic_lands, "creatures": creatures, "removal": removal, "wipes": wipes, "card_advantage": card_advantage, "protection": protection, } errors: list[str] = [] for k, v in submitted.items(): try: iv = int(v) except Exception: errors.append(f"{labels.get(k, k)} must be a number.") continue if iv < 0: errors.append(f"{labels.get(k, k)} cannot be negative.") submitted[k] = iv # Cross-field validation: basic lands should not exceed total lands if isinstance(submitted.get("basic_lands"), int) and isinstance(submitted.get("lands"), int): if submitted["basic_lands"] > submitted["lands"]: errors.append("Basic Lands cannot exceed Total Lands.") if errors: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 3 resp = templates.TemplateResponse( "build/_step3.html", { "request": request, "defaults": orch.ideal_defaults(), "labels": labels, "values": submitted, "error": " ".join(errors), "commander": sess.get("commander"), "tags": sess.get("tags", []), "bracket": sess.get("bracket"), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Save to session 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 resp = templates.TemplateResponse( "build/_step4.html", { "request": request, "labels": labels, "values": submitted, "commander": sess.get("commander"), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/step3", response_class=HTMLResponse) async def build_step3_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 3 defaults = orch.ideal_defaults() values = sess.get("ideals") or defaults resp = templates.TemplateResponse( "build/_step3.html", { "request": request, "defaults": defaults, "labels": orch.ideal_labels(), "values": values, "commander": sess.get("commander"), "tags": sess.get("tags", []), "bracket": sess.get("bracket"), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/step4", response_class=HTMLResponse) async def build_step4_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 4 labels = orch.ideal_labels() values = sess.get("ideals") or orch.ideal_defaults() commander = sess.get("commander") return templates.TemplateResponse( "build/_step4.html", { "request": request, "labels": labels, "values": values, "commander": commander, "owned_only": bool(sess.get("use_owned_only")), "prefer_owned": bool(sess.get("prefer_owned")), }, ) # --- 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 _det = _detect_all(names) combos = _det.get("combos", []) synergies = _det.get("synergies", []) combos_model = _det.get("combos_model") synergies_model = _det.get("synergies_model") # 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": _det.get("versions", {}), "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, use_owned_only: str | None = Form(None), prefer_owned: str | None = Form(None), ) -> HTMLResponse: """Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 4 only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False sess["use_owned_only"] = only_val sess["prefer_owned"] = pref_val # Do not touch build_ctx here; user hasn't started the build yet from review labels = orch.ideal_labels() values = sess.get("ideals") or orch.ideal_defaults() commander = sess.get("commander") resp = templates.TemplateResponse( "build/_step4.html", { "request": request, "labels": labels, "values": values, "commander": commander, "owned_only": bool(sess.get("use_owned_only")), "prefer_owned": bool(sess.get("prefer_owned")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/step5", response_class=HTMLResponse) async def build_step5_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 5 # Default replace-mode to ON unless explicitly toggled off if "replace_mode" not in sess: sess["replace_mode"] = True base = step5_empty_ctx(request, sess) resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step5/continue", response_class=HTMLResponse) async def build_step5_continue(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) if "replace_mode" not in sess: sess["replace_mode"] = True # Validate commander; redirect to step1 if missing if not sess.get("commander"): resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Ensure build context exists; if not, start it first if not sess.get("build_ctx"): sess["build_ctx"] = start_ctx_from_session(sess) 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: form = await request.form() if form and form.get('show_skipped') == '1': show_skipped = True except Exception: pass try: res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) status = "Build complete" if res.get("done") else "Stage complete" except Exception as e: sess["last_step"] = 5 err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp stage_label = res.get("label") # 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 # Note: no redirect; the inline compliance panel will render inside Step 5 sess["last_step"] = 5 ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) resp = templates.TemplateResponse("build/_step5.html", ctx2) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step5/rerun", response_class=HTMLResponse) async def build_step5_rerun(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) if "replace_mode" not in sess: sess["replace_mode"] = True if not sess.get("commander"): resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Rerun requires an existing context; if missing, create it and run first stage as rerun if not sess.get("build_ctx"): sess["build_ctx"] = start_ctx_from_session(sess) else: # Ensure latest locks are reflected in the existing context try: sess["build_ctx"]["locks"] = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} except Exception: pass show_skipped = False try: form = await request.form() show_skipped = True if (form.get('show_skipped') == '1') else False except Exception: pass # If replace-mode is OFF, keep the stage visible even if no new cards were added if not bool(sess.get("replace_mode", True)): show_skipped = True try: res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True))) status = "Stage rerun complete" if not res.get("done") else "Build complete" except Exception as e: sess["last_step"] = 5 err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp sess["last_step"] = 5 # Build locked cards list with ownership and in-deck presence locked_cards = [] try: ctx = sess.get("build_ctx") or {} b = ctx.get("builder") if isinstance(ctx, dict) else None present: set[str] = builder_present_names(b) if b is not None else set() # Display-map via combined df when available lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} display_map: dict[str, str] = builder_display_map(b, lock_lower) if b is not None else {} owned_lower = owned_set_helper() for nm in (sess.get("locks", []) or []): key = str(nm).strip().lower() disp = display_map.get(key, nm) locked_cards.append({ "name": disp, "owned": key in owned_lower, "in_deck": key in present, }) except Exception: locked_cards = [] ctx3 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) ctx3["locked_cards"] = locked_cards resp = templates.TemplateResponse("build/_step5.html", ctx3) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/step5/start", response_class=HTMLResponse) async def build_step5_start(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) if "replace_mode" not in sess: sess["replace_mode"] = True # Validate commander exists before starting commander = sess.get("commander") if not commander: resp = templates.TemplateResponse( "build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp try: # Initialize step-by-step build context and run first stage sess["build_ctx"] = start_ctx_from_session(sess) show_skipped = False try: form = await request.form() show_skipped = True if (form.get('show_skipped') == '1') else False except Exception: pass res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) status = "Stage complete" if not res.get("done") else "Build complete" # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue try: if res.get("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 # Note: no redirect; the inline compliance panel will render inside Step 5 sess["last_step"] = 5 ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) resp = templates.TemplateResponse("build/_step5.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp except Exception as e: # Surface a friendly error on the step 5 screen with normalized context err_ctx = step5_error_ctx( request, sess, f"Failed to start build: {e}", include_name=False, ) # Ensure commander stays visible if set err_ctx["commander"] = commander resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/step5/start", response_class=HTMLResponse) async def build_step5_start_get(request: Request) -> HTMLResponse: # Allow GET as a fallback to start the build (delegates to POST handler) return await build_step5_start(request) @router.get("/banner", response_class=HTMLResponse) async def build_banner(request: Request, step: str = "", i: int | None = None, n: int | None = None) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) commander = sess.get("commander") tags = sess.get("tags", []) # Render only the inner text for the subtitle return templates.TemplateResponse( "build/_banner_subtitle.html", {"request": request, "commander": commander, "tags": tags, "name": sess.get("custom_export_base")}, ) @router.post("/step5/toggle-replace") async def build_step5_toggle_replace(request: Request, replace: str = Form("0")): """Toggle replace-mode for reruns and return an updated button HTML.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) enabled = True if str(replace).strip() in ("1","true","on","yes") else False sess["replace_mode"] = enabled # Return the checkbox control snippet (same as template) checked = 'checked' if enabled else '' html = ( '
' '
' f'' '' '
' '
' ) return HTMLResponse(html) @router.post("/step5/reset-stage", response_class=HTMLResponse) async def build_step5_reset_stage(request: Request) -> HTMLResponse: """Reset current visible stage to the pre-stage snapshot (if available) without running it.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) ctx = sess.get("build_ctx") if not ctx or not ctx.get("snapshot"): return await build_step5_get(request) try: orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined] except Exception: return await build_step5_get(request) # Re-render step 5 with cleared added list base = step5_empty_ctx(request, sess, extras={ "status": "Stage reset", "i": ctx.get("idx"), "n": len(ctx.get("stages", [])), }) resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- @router.post("/lock") async def build_lock_toggle(request: Request, name: str = Form(...), locked: str = Form("1"), from_list: str | None = Form(None)): """Toggle lock for a card name in the current session; return an HTML button to swap in-place.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) locks = set(sess.get("locks", [])) key = str(name).strip().lower() want_lock = True if str(locked).strip() in ("1","true","on","yes") else False if want_lock: locks.add(key) else: locks.discard(key) sess["locks"] = list(locks) # If a build context exists, update it too if sess.get("build_ctx"): try: sess["build_ctx"]["locks"] = {str(n) for n in locks} except Exception: pass # Return a compact button HTML that flips state on next click, and an OOB last-action chip next_state = "0" if want_lock else "1" label = "Unlock" if want_lock else "Lock" title = ("Click to unlock" if want_lock else "Click to lock") icon = ("🔒" if want_lock else "🔓") # Include data-locked to reflect the current state for client-side handler btn = f'''''' # Compute locks count for chip locks_count = len(locks) if locks_count > 0: chip_html = f'🔒 {locks_count} locked' else: chip_html = '' # Last action chip for feedback (use hx-swap-oob) try: disp = (name or '').strip() except Exception: disp = str(name) action = "Locked" if want_lock else "Unlocked" chip = ( f'
' f'{action} {disp}' f'
' ) # If this request came from the locked-cards list and it's an unlock, remove the row inline try: if (from_list is not None) and (not want_lock): # Also update the locks-count chip, and if no locks remain, remove the whole section extra = chip_html if locks_count == 0: extra += '
' # Return empty body to delete the
  • via hx-swap=outerHTML, plus OOB updates return HTMLResponse('' + extra) except Exception: pass return HTMLResponse(btn + chip + chip_html) @router.get("/alternatives", response_class=HTMLResponse) async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> HTMLResponse: """Suggest alternative cards for a given card name, preferring role-specific pools. Strategy: 1) Determine the seed card's role from the current deck (Role field) or optional `stage` hint. 2) Build a candidate pool from the combined DataFrame using the same filters as the build phase for that role (ramp/removal/wipes/card_advantage/protection). 3) Exclude commander, lands (where applicable), in-deck, locked, and the seed itself; then sort by edhrecRank/manaValue. Apply owned-only filter if requested. 4) Fall back to tag-overlap similarity when role cannot be determined or data is missing. Returns an HTML partial listing up to ~10 alternatives with Replace buttons. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) ctx = sess.get("build_ctx") or {} b = ctx.get("builder") if isinstance(ctx, dict) else None # Owned library owned_set = owned_set_helper() require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only")) # If builder context missing, show a guidance message if not b: html = '
    Start the build to see alternatives.
    ' return HTMLResponse(html) try: name_disp = str(name).strip() name_l = name_disp.lower() commander_l = str((sess.get("commander") or "")).strip().lower() locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} # Exclusions from prior inline replacements alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} alts_exclude_v = int(sess.get("alts_exclude_v") or 0) # Resolve role from stage hint or current library entry stage_hint = (stage or "").strip().lower() stage_map = { "ramp": "ramp", "removal": "removal", "wipes": "wipe", "wipe": "wipe", "board_wipe": "wipe", "card_advantage": "card_advantage", "draw": "card_advantage", "protection": "protection", # Additional mappings for creature stages "creature": "creature", "creatures": "creature", "primary": "creature", "secondary": "creature", # Land-related hints "land": "land", "lands": "land", "utility": "land", "misc": "land", "fetch": "land", "dual": "land", } hinted_role = stage_map.get(stage_hint) if stage_hint else None lib = getattr(b, "card_library", {}) or {} # Case-insensitive lookup in deck library lib_key = None try: if name_disp in lib: lib_key = name_disp else: lm = {str(k).strip().lower(): k for k in lib.keys()} lib_key = lm.get(name_l) except Exception: lib_key = None entry = lib.get(lib_key) if lib_key else None role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None) if isinstance(role, str): role = role.strip().lower() # Build role-specific pool from combined DataFrame items: list[dict] = [] used_role = role if isinstance(role, str) and role else None # Promote to 'land' role when the seed card is a land (regardless of stored role) try: if entry and isinstance(entry, dict): ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower() if "land" in ctype: used_role = "land" except Exception: pass df = getattr(b, "_combined_cards_df", None) # Compute current deck fingerprint to avoid stale cached alternatives after stage changes in_deck: set[str] = builder_present_names(b) try: import hashlib as _hl deck_fp = _hl.md5( ("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8") ).hexdigest()[:8] except Exception: deck_fp = str(len(in_deck)) # Use a cache key that includes the exclusions version and deck fingerprint cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp) # Disable caching for land alternatives to keep randomness per request cached = None if used_role == 'land' else _alts_get_cached(cache_key) if cached is not None: return HTMLResponse(cached) def _render_and_cache(_items: list[dict]): html_str = templates.get_template("build/_alternatives.html").render({ "request": request, "name": name_disp, "require_owned": require_owned, "items": _items, }) # Skip caching when used_role == land for per-call randomness if used_role != 'land': try: _alts_set_cached(cache_key, html_str) except Exception: pass return HTMLResponse(html_str) # Helper: map display names def _display_map_for(lower_pool: set[str]) -> dict[str, str]: try: return builder_display_map(b, lower_pool) # type: ignore[arg-type] except Exception: return {nm: nm for nm in lower_pool} # Common exclusions # in_deck already computed above def _exclude(df0): out = df0.copy() if "name" in out.columns: out["_lname"] = out["name"].astype(str).str.strip().str.lower() mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set())) out = out[mask] return out # If we have data and a recognized role, mirror the phase logic if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}): pool = df.copy() try: pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell) except Exception: # best-effort normalize pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else []) # Role-specific base filtering if used_role != "land": # Exclude lands for non-land roles if "type" in pool.columns: pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)] else: # Keep only lands if "type" in pool.columns: pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)] # Seed info to guide filtering seed_is_basic = False try: seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])}) except Exception: seed_is_basic = False if seed_is_basic: # For basics: show other basics (different colors) to allow quick swaps try: pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] except Exception: pass else: # For non-basics: prefer other non-basics try: pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] except Exception: pass # Apply mono-color misc land filters (no debug CSV dependency) try: colors = list(getattr(b, 'color_identity', []) or []) mono = len(colors) <= 1 mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])} mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])} kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])} any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])] extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])] fetch_names = set() for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): for nm in seq: fetch_names.add(nm.lower()) for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []): fetch_names.add(nm.lower()) # World Tree check needs all five colors need_all_colors = {'w','u','b','r','g'} def _illegal_world_tree(nm: str) -> bool: return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors # Text column fallback text_col = 'text' if text_col not in pool.columns: for c in pool.columns: if 'text' in c.lower(): text_col = c break def _exclude_row(row) -> bool: nm_l = str(row['name']).strip().lower() if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all: return True if mono and nm_l not in mono_keep and nm_l not in kindred_all: try: txt = str(row.get(text_col, '') or '').lower() if any(p in txt for p in any_color_phrases + extra_rainbow_terms): return True except Exception: pass if nm_l in fetch_names: return True if _illegal_world_tree(nm_l): return True return False pool = pool[~pool.apply(_exclude_row, axis=1)] except Exception: pass # Optional sub-role filtering (only if enough depth) try: subrole = str((entry or {}).get('SubRole') or '').strip().lower() if subrole: # Heuristic categories for grouping cat_map = { 'fetch': 'fetch', 'dual': 'dual', 'triple': 'triple', 'misc': 'misc', 'utility': 'misc', 'basic': 'basic' } target_cat = None for key, val in cat_map.items(): if key in subrole: target_cat = val break if target_cat and len(pool) > 25: # Lightweight textual filter using known markers def _cat_row(rname: str, rtype: str) -> str: rl = rname.lower() rt = rtype.lower() if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')): return 'fetch' if 'triple' in rt or 'three' in rt: return 'triple' if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt: # Basic-check crude return 'basic' return 'misc' try: tmp = pool.copy() tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1) sub_pool = tmp[tmp['_cat'] == target_cat] if len(sub_pool) >= 10: pool = sub_pool.drop(columns=['_cat']) except Exception: pass except Exception: pass # Exclude commander explicitly if "name" in pool.columns and commander_l: pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l] # Role-specific filter def _is_wipe(tags: list[str]) -> bool: return any(("board wipe" in t) or ("mass removal" in t) for t in tags) def _is_removal(tags: list[str]) -> bool: return any(("removal" in t) or ("spot removal" in t) for t in tags) def _is_draw(tags: list[str]) -> bool: return any(("draw" in t) or ("card advantage" in t) for t in tags) def _matches_selected(tags: list[str]) -> bool: try: sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()] if not sel: return True st = set(sel) return any(any(s in t for s in st) for t in tags) except Exception: return True if used_role == "ramp": pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))] elif used_role == "removal": pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)] elif used_role == "wipe": pool = pool[pool["_ltags"].apply(_is_wipe)] elif used_role == "card_advantage": pool = pool[pool["_ltags"].apply(_is_draw)] elif used_role == "protection": pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))] elif used_role == "creature": # Keep only creatures; bias toward selected theme tags when available if "type" in pool.columns: pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)] try: pool = pool[pool["_ltags"].apply(_matches_selected)] except Exception: pass elif used_role == "land": # Already constrained to lands; no additional tag filter needed pass # Sort by priority like the builder try: pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] except Exception: pass # Exclusions and ownership (for non-random roles this stays before slicing) pool = _exclude(pool) try: if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None): pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())}) except Exception: pass # Land role: random 12 from top 60-100 window if used_role == 'land': import random as _rnd total = len(pool) if total == 0: pass else: cap = min(100, total) floor = min(60, cap) # if fewer than 60 just use all if cap <= 12: window_size = cap else: if cap == floor: window_size = cap else: rng_obj = getattr(b, 'rng', None) if rng_obj: window_size = rng_obj.randint(floor, cap) else: window_size = _rnd.randint(floor, cap) window_df = pool.head(window_size) names = window_df['name'].astype(str).str.strip().tolist() # Random sample up to 12 distinct names sample_n = min(12, len(names)) if sample_n > 0: if getattr(b, 'rng', None): chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names else: chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names lower_map = {n.strip().lower(): n for n in chosen} display_map = _display_map_for(set(k for k in lower_map.keys())) for nm_lc, orig in lower_map.items(): is_owned = (nm_lc in owned_set) if require_owned and not is_owned: continue if nm_lc == name_l or (in_deck and nm_lc in in_deck): continue items.append({ 'name': display_map.get(nm_lc, orig), 'name_lower': nm_lc, 'owned': is_owned, 'tags': [] }) if items: return _render_and_cache(items) else: # Default deterministic top-N (increase to 12 for parity) lower_pool: list[str] = [] try: lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist() except Exception: lower_pool = [] display_map = _display_map_for(set(lower_pool)) for nm_l in lower_pool: is_owned = (nm_l in owned_set) if require_owned and not is_owned: continue if nm_l == name_l or (in_deck and nm_l in in_deck): continue items.append({ "name": display_map.get(nm_l, nm_l), "name_lower": nm_l, "owned": is_owned, "tags": [], }) if len(items) >= 12: break if items: return _render_and_cache(items) # Fallback: tag-similarity suggestions (previous behavior) tags_idx = getattr(b, "_card_name_tags_index", {}) or {} seed_tags = set(tags_idx.get(name_l) or []) all_names = set(tags_idx.keys()) candidates: list[tuple[str, int]] = [] # (name, score) for nm in all_names: if nm == name_l: continue if commander_l and nm == commander_l: continue if in_deck and nm in in_deck: continue if locked_set and nm in locked_set: continue if nm in alts_exclude: continue tgs = set(tags_idx.get(nm) or []) score = len(seed_tags & tgs) if score <= 0: continue candidates.append((nm, score)) # If no tag-based candidates, try shared trigger tag from library entry if not candidates and isinstance(entry, dict): try: trig = str(entry.get("TriggerTag") or "").strip().lower() except Exception: trig = "" if trig: for nm, tglist in tags_idx.items(): if nm == name_l: continue if nm in {str(k).strip().lower() for k in lib.keys()}: continue if trig in {str(t).strip().lower() for t in (tglist or [])}: candidates.append((nm, 1)) def _owned(nm: str) -> bool: return nm in owned_set candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0])) pool_lower = {nm for (nm, _s) in candidates} display_map = _display_map_for(pool_lower) seen = set() for nm, score in candidates: if nm in seen: continue seen.add(nm) is_owned = (nm in owned_set) if require_owned and not is_owned: continue items.append({ "name": display_map.get(nm, nm), "name_lower": nm, "owned": is_owned, "tags": list(tags_idx.get(nm) or []), }) if len(items) >= 10: break return _render_and_cache(items) except Exception as e: return HTMLResponse(f'
    No alternatives: {e}
    ') @router.post("/replace", response_class=HTMLResponse) async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> HTMLResponse: """Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives. Falls back to lock-and-rerun guidance if no active builder is present. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) o_disp = str(old).strip() n_disp = str(new).strip() o = o_disp.lower() n = n_disp.lower() # Maintain locks to bias future picks and enforcement locks = set(sess.get("locks", [])) locks.discard(o) locks.add(n) sess["locks"] = list(locks) # Track last replace for optional undo try: sess["last_replace"] = {"old": o, "new": n} except Exception: pass ctx = sess.get("build_ctx") or {} try: ctx["locks"] = {str(x) for x in locks} except Exception: pass # Record preferred replacements try: pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None if not isinstance(pref, dict): pref = {} ctx["preferred_replacements"] = pref pref[o] = n except Exception: pass b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None if b is not None: try: lib = getattr(b, "card_library", {}) or {} # Find the exact key for `old` in a case-insensitive manner old_key = None if o_disp in lib: old_key = o_disp else: for k in list(lib.keys()): if str(k).strip().lower() == o: old_key = k break if old_key is None: raise KeyError("old card not in deck") old_info = dict(lib.get(old_key) or {}) role = str(old_info.get("Role") or "").strip() subrole = str(old_info.get("SubRole") or "").strip() try: count = int(old_info.get("Count", 1)) except Exception: count = 1 # Remove old entry try: del lib[old_key] except Exception: pass # Resolve canonical name and info for new df = getattr(b, "_combined_cards_df", None) new_key = n_disp card_type = "" mana_cost = "" trigger_tag = str(old_info.get("TriggerTag") or "") if df is not None: try: row = df[df["name"].astype(str).str.strip().str.lower() == n] if not row.empty: new_key = str(row.iloc[0]["name"]) or n_disp 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 lib[new_key] = { "Count": count, "Card Type": card_type, "Mana Cost": mana_cost, "Role": role, "SubRole": subrole, "AddedBy": "Replace", "TriggerTag": trigger_tag, } # Mirror preferred replacements onto the builder for enforcement try: cur = getattr(b, "preferred_replacements", {}) or {} cur[str(o)] = str(n) setattr(b, "preferred_replacements", cur) except Exception: pass # Update alternatives exclusion set and bump version to invalidate caches try: ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} ex.add(o) sess["alts_exclude"] = list(ex) sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 except Exception: pass # Success panel and OOB updates (refresh compliance panel) # Compute ownership of the new card for UI badge update is_owned = (n in owned_set_helper()) html = ( '
    ' f'
    Replaced {o_disp} with {new_key}.
    ' '
    Compliance panel will refresh.
    ' '
    ' '' '
    ' '
    ' ) # Inline mutate the nearest card tile to reflect the new card without a rerun mutator = """ """ chip = ( f'
    ' f'Replaced {o_disp}{new_key}' f'
    ' ) # OOB fetch to refresh compliance panel refresher = ( '
    ' ) # Include data attributes on the panel div for the mutator script data_owned = '1' if is_owned else '0' data_locked = '1' if (n in locks) else '0' prefix = '
    ' f'
    Locked {new} and unlocked {old}.
    ' '
    Now click Rerun Stage with Replace: On to apply this change.
    ' '
    ' '
    ' '' '' '
    ' '
    ' f'' f'' '' '
    ' '' '
    ' '
    ' ) chip = ( f'
    ' f'Replaced {old}{new}' f'
    ' ) # Also add old to exclusions and bump version for future alt calls try: ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} ex.add(o) sess["alts_exclude"] = list(ex) sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 except Exception: pass return HTMLResponse(hint + chip) @router.post("/replace/undo", response_class=HTMLResponse) async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse: """Undo the last replace by restoring the previous lock state (best-effort).""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) last = sess.get("last_replace") or {} try: # Prefer provided args, else fallback to last recorded o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip() n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip() except Exception: o, n = "", "" locks = set(sess.get("locks", [])) changed = False if n and n in locks: locks.discard(n) changed = True if o: locks.add(o) changed = True sess["locks"] = list(locks) if sess.get("build_ctx"): try: sess["build_ctx"]["locks"] = {str(x) for x in locks} except Exception: pass # Clear last_replace after undo try: if sess.get("last_replace"): del sess["last_replace"] except Exception: pass # Return confirmation panel and OOB chip msg = 'Undid replace' if changed else 'No changes to undo' html = ( '
    ' f'
    {msg}.
    ' '
    Rerun the stage to recompute picks if needed.
    ' '
    ' '
    ' '' '' '
    ' '' '
    ' '
    ' ) chip = ( f'
    ' f'{msg}' f'
    ' ) return HTMLResponse(html + chip) @router.get("/compare") async def build_compare(runA: str, runB: str): """Stub: return empty diffs; later we can diff summary files under deck_files.""" return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []}) @router.get("/compliance", response_class=HTMLResponse) async def build_compliance_panel(request: Request) -> HTMLResponse: """Render a live Bracket compliance panel with manual enforcement controls. Computes compliance against the current builder state without exporting, attaches a non-destructive enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial. Returns empty content when no active build context exists. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) ctx = sess.get("build_ctx") or {} b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None if not b: return HTMLResponse("") # Compute compliance snapshot in-memory and attach planning preview comp = None try: if hasattr(b, 'compute_and_print_compliance'): comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass if not comp: return HTMLResponse("") # Build flagged metadata (role, owned) for visual tiles and role-aware alternatives # For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced. flagged_meta: list[dict] = [] try: cats = comp.get('categories') or {} owned_lower = owned_set_helper() lib = getattr(b, 'card_library', {}) or {} commander_l = str((sess.get('commander') or '')).strip().lower() # map category key -> display label labels = { 'game_changers': 'Game Changers', 'extra_turns': 'Extra Turns', 'mass_land_denial': 'Mass Land Denial', 'tutors_nonland': 'Nonland Tutors', 'two_card_combos': 'Two-Card Combos', } seen_lower: set[str] = set() for key, cat in cats.items(): try: status = str(cat.get('status') or '').upper() # Only surface tiles for WARN and FAIL if status not in {"WARN", "FAIL"}: continue # For two-card combos, split pairs into individual cards and skip commander if key == 'two_card_combos' and status == 'FAIL': # Prefer the structured combos list to ensure we only expand counted pairs pairs = [] try: for p in (comp.get('combos') or []): if p.get('cheap_early'): pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip())) except Exception: pairs = [] # Fallback to parsing flagged strings like "A + B" if not pairs: try: for s in (cat.get('flagged') or []): if not isinstance(s, str): continue parts = [x.strip() for x in s.split('+') if x and x.strip()] if len(parts) == 2: pairs.append((parts[0], parts[1])) except Exception: pass for a, bname in pairs: for nm in (a, bname): if not nm: continue nm_l = nm.strip().lower() if nm_l == commander_l: # Don't prompt replacing the commander continue if nm_l in seen_lower: continue seen_lower.add(nm_l) entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {} role = entry.get('Role') or '' flagged_meta.append({ 'name': nm, 'category': labels.get(key, key.replace('_',' ').title()), 'role': role, 'owned': (nm_l in owned_lower), 'severity': status, }) continue # Default handling for list/tag categories names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)] for nm in names: nm_l = str(nm).strip().lower() if nm_l in seen_lower: continue seen_lower.add(nm_l) entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {} role = entry.get('Role') or '' flagged_meta.append({ 'name': nm, 'category': labels.get(key, key.replace('_',' ').title()), 'role': role, 'owned': (nm_l in owned_lower), 'severity': status, }) except Exception: continue except Exception: flagged_meta = [] # Render partial ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta} return templates.TemplateResponse("build/_compliance_panel.html", ctx2) @router.post("/enforce/apply", response_class=HTMLResponse) async def build_enforce_apply(request: Request) -> HTMLResponse: """Apply bracket enforcement now using current locks as user guidance. This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) # Ensure build context exists ctx = sess.get("build_ctx") or {} b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None if not b: # No active build: show Step 5 with an error err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Ensure we have a CSV base stem for consistent re-exports base_stem = None try: csv_path = ctx.get("csv_path") if isinstance(csv_path, str) and csv_path: import os as _os base_stem = _os.path.splitext(_os.path.basename(csv_path))[0] except Exception: base_stem = None # If missing, export once to establish base if not base_stem: try: ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] import os as _os base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] # Also produce a text export for completeness ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined] except Exception: base_stem = None # Add lock placeholders into the library before enforcement so user choices are present try: locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} if locks: df = getattr(b, "_combined_cards_df", None) lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()} for lname in locks: if lname in lib_l: continue target_name = None card_type = '' mana_cost = '' try: if df is not None and not df.empty: row = df[df['name'].astype(str).str.lower() == lname] if not row.empty: target_name = 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: target_name = None if target_name: b.card_library[target_name] = { 'Count': 1, 'Card Type': card_type, 'Mana Cost': mana_cost, 'Role': 'Locked', 'SubRole': '', 'AddedBy': 'Lock', 'TriggerTag': '', } except Exception: pass # Thread preferred replacements from context onto builder so enforcement can honor them try: pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None if isinstance(pref, dict): setattr(b, 'preferred_replacements', dict(pref)) except Exception: pass # Run enforcement + re-exports (tops up to 100 internally) try: rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined] except Exception as e: err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Reload compliance JSON and summary compliance = None try: if base_stem: import os as _os import json as _json comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") if _os.path.exists(comp_path): with open(comp_path, 'r', encoding='utf-8') as _cf: compliance = _json.load(_cf) except Exception: compliance = None # Rebuild Step 5 context (done state) # Ensure csv/txt paths on ctx reflect current base try: import os as _os ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path") ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path") except Exception: pass # Compute total_cards 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 res = { "done": True, "label": "Complete", "log_delta": "", "idx": len(ctx.get("stages", []) or []), "total": len(ctx.get("stages", []) or []), "csv_path": ctx.get("csv_path"), "txt_path": ctx.get("txt_path"), "summary": getattr(b, 'build_deck_summary', lambda: None)(), "total_cards": total_cards, "added_total": 0, "compliance": compliance or rep, } page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True) resp = templates.TemplateResponse("build/_step5.html", page_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/enforcement", response_class=HTMLResponse) async def build_enforcement_fullpage(request: Request) -> HTMLResponse: """Full-page enforcement review: show compliance panel with swaps and controls.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) ctx = sess.get("build_ctx") or {} b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None if not b: # No active build base = step5_empty_ctx(request, sess) resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Compute compliance snapshot and attach planning preview comp = None try: if hasattr(b, 'compute_and_print_compliance'): comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass ctx2 = {"request": request, "compliance": comp} resp = templates.TemplateResponse("build/enforcement.html", ctx2) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.get("/permalink") async def build_permalink(request: Request): """Return a URL-safe JSON payload representing current run config (basic).""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) payload = { "commander": sess.get("commander"), "tags": sess.get("tags", []), "bracket": sess.get("bracket"), "ideals": sess.get("ideals"), "tag_mode": sess.get("tag_mode", "AND"), "flags": { "owned_only": bool(sess.get("use_owned_only")), "prefer_owned": bool(sess.get("prefer_owned")), }, "locks": list(sess.get("locks", [])), } # Add include/exclude cards and advanced options if feature is enabled if ALLOW_MUST_HAVES: if sess.get("include_cards"): payload["include_cards"] = sess.get("include_cards") if sess.get("exclude_cards"): payload["exclude_cards"] = sess.get("exclude_cards") if sess.get("enforcement_mode"): payload["enforcement_mode"] = sess.get("enforcement_mode") if sess.get("allow_illegal") is not None: payload["allow_illegal"] = sess.get("allow_illegal") if sess.get("fuzzy_matching") is not None: payload["fuzzy_matching"] = sess.get("fuzzy_matching") try: import base64 import json as _json raw = _json.dumps(payload, separators=(",", ":")) token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=") # Also include decoded state for convenience/testing return JSONResponse({"ok": True, "permalink": f"/build/from?state={token}", "state": payload}) except Exception: return JSONResponse({"ok": True, "state": payload}) @router.get("/from", response_class=HTMLResponse) async def build_from(request: Request, state: str | None = None) -> HTMLResponse: """Load a run from a permalink token.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) if state: try: import base64 import json as _json pad = '=' * (-len(state) % 4) raw = base64.urlsafe_b64decode((state + pad).encode("ascii")).decode("utf-8") data = _json.loads(raw) sess["commander"] = data.get("commander") sess["tags"] = data.get("tags", []) sess["bracket"] = data.get("bracket") if data.get("ideals"): sess["ideals"] = data.get("ideals") sess["tag_mode"] = data.get("tag_mode", "AND") flags = data.get("flags") or {} sess["use_owned_only"] = bool(flags.get("owned_only")) sess["prefer_owned"] = bool(flags.get("prefer_owned")) sess["locks"] = list(data.get("locks", [])) # Import exclude_cards if feature is enabled and present if ALLOW_MUST_HAVES and data.get("exclude_cards"): sess["exclude_cards"] = data.get("exclude_cards") sess["last_step"] = 4 except Exception: pass locks_restored = 0 try: locks_restored = len(sess.get("locks", []) or []) except Exception: locks_restored = 0 resp = templates.TemplateResponse("build/_step4.html", { "request": request, "labels": orch.ideal_labels(), "values": sess.get("ideals") or orch.ideal_defaults(), "commander": sess.get("commander"), "owned_only": bool(sess.get("use_owned_only")), "prefer_owned": bool(sess.get("prefer_owned")), "locks_restored": locks_restored, }) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @router.post("/validate/exclude_cards") async def validate_exclude_cards( request: Request, exclude_cards: str = Form(default=""), commander: str = Form(default="") ): """Legacy exclude cards validation endpoint - redirect to new unified endpoint.""" if not ALLOW_MUST_HAVES: return JSONResponse({"error": "Feature not enabled"}, status_code=404) # Call new unified endpoint result = await validate_include_exclude_cards( request=request, include_cards="", exclude_cards=exclude_cards, commander=commander, enforcement_mode="warn", allow_illegal=False, fuzzy_matching=True ) # Transform to legacy format for backward compatibility if hasattr(result, 'body'): import json data = json.loads(result.body) if 'excludes' in data: excludes = data['excludes'] return JSONResponse({ "count": excludes.get("count", 0), "limit": excludes.get("limit", 15), "over_limit": excludes.get("over_limit", False), "cards": excludes.get("cards", []), "duplicates": excludes.get("duplicates", {}), "warnings": excludes.get("warnings", []) }) return result @router.post("/validate/include_exclude") async def validate_include_exclude_cards( request: Request, include_cards: str = Form(default=""), exclude_cards: str = Form(default=""), commander: str = Form(default=""), enforcement_mode: str = Form(default="warn"), allow_illegal: bool = Form(default=False), fuzzy_matching: bool = Form(default=True) ): """Validate include/exclude card lists with comprehensive diagnostics.""" if not ALLOW_MUST_HAVES: return JSONResponse({"error": "Feature not enabled"}, status_code=404) try: from deck_builder.include_exclude_utils import ( parse_card_list_input, collapse_duplicates, fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES ) from deck_builder.builder import DeckBuilder # Parse inputs include_list = parse_card_list_input(include_cards) if include_cards.strip() else [] exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else [] # Collapse duplicates include_unique, include_dupes = collapse_duplicates(include_list) exclude_unique, exclude_dupes = collapse_duplicates(exclude_list) # Initialize result structure result = { "includes": { "count": len(include_unique), "limit": MAX_INCLUDES, "over_limit": len(include_unique) > MAX_INCLUDES, "duplicates": include_dupes, "cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."], "warnings": [], "legal": [], "illegal": [], "color_mismatched": [], "fuzzy_matches": {} }, "excludes": { "count": len(exclude_unique), "limit": MAX_EXCLUDES, "over_limit": len(exclude_unique) > MAX_EXCLUDES, "duplicates": exclude_dupes, "cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."], "warnings": [], "legal": [], "illegal": [], "fuzzy_matches": {} }, "conflicts": [], # Cards that appear in both lists "confirmation_needed": [], # Cards needing fuzzy match confirmation "overall_warnings": [] } # Check for conflicts (cards in both lists) conflicts = set(include_unique) & set(exclude_unique) if conflicts: result["conflicts"] = list(conflicts) result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}") # Size warnings based on actual counts if result["includes"]["over_limit"]: result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}") elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}") if result["excludes"]["over_limit"]: result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}") elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}") # If we have a commander, do advanced validation (color identity, etc.) if commander and commander.strip(): try: # Create a temporary builder builder = DeckBuilder() # Set up commander FIRST (before setup_dataframes) df = builder.load_commander_data() commander_rows = df[df["name"] == commander.strip()] if not commander_rows.empty: # Apply commander selection (this sets commander_row properly) builder._apply_commander_selection(commander_rows.iloc[0]) # Now setup dataframes (this will use the commander info) builder.setup_dataframes() # Get available card names for fuzzy matching name_col = 'name' if 'name' in builder._full_cards_df.columns else 'Name' available_cards = set(builder._full_cards_df[name_col].tolist()) # Validate includes with fuzzy matching for card_name in include_unique: if fuzzy_matching: match_result = fuzzy_match_card_name(card_name, available_cards) if match_result.matched_name: if match_result.auto_accepted: result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name result["includes"]["legal"].append(match_result.matched_name) else: # Needs confirmation result["confirmation_needed"].append({ "input": card_name, "suggestions": match_result.suggestions, "confidence": match_result.confidence, "type": "include" }) else: result["includes"]["illegal"].append(card_name) else: # Exact match only if card_name in available_cards: result["includes"]["legal"].append(card_name) else: result["includes"]["illegal"].append(card_name) # Validate excludes with fuzzy matching for card_name in exclude_unique: if fuzzy_matching: match_result = fuzzy_match_card_name(card_name, available_cards) if match_result.matched_name: if match_result.auto_accepted: result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name result["excludes"]["legal"].append(match_result.matched_name) else: # Needs confirmation result["confirmation_needed"].append({ "input": card_name, "suggestions": match_result.suggestions, "confidence": match_result.confidence, "type": "exclude" }) else: result["excludes"]["illegal"].append(card_name) else: # Exact match only if card_name in available_cards: result["excludes"]["legal"].append(card_name) else: result["excludes"]["illegal"].append(card_name) # Color identity validation for includes (only if we have a valid commander with colors) commander_colors = getattr(builder, 'color_identity', []) if commander_colors: color_validated_includes = [] for card_name in result["includes"]["legal"]: if builder._validate_card_color_identity(card_name): color_validated_includes.append(card_name) else: # Add color-mismatched cards to illegal instead of separate category result["includes"]["illegal"].append(card_name) # Update legal includes to only those that pass color identity result["includes"]["legal"] = color_validated_includes except Exception as validation_error: # Advanced validation failed, but return basic validation result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}") else: # No commander provided, do basic fuzzy matching only if fuzzy_matching and (include_unique or exclude_unique): try: # Get card names directly from CSV without requiring commander setup import pandas as pd cards_df = pd.read_csv('csv_files/cards.csv') # Try to find the name column name_column = None for col in ['Name', 'name', 'card_name', 'CardName']: if col in cards_df.columns: name_column = col break if name_column is None: raise ValueError(f"Could not find name column. Available columns: {list(cards_df.columns)}") available_cards = set(cards_df[name_column].tolist()) # Validate includes with fuzzy matching for card_name in include_unique: match_result = fuzzy_match_card_name(card_name, available_cards) if match_result.matched_name and match_result.auto_accepted: # Exact or high-confidence match result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name result["includes"]["legal"].append(match_result.matched_name) elif not match_result.auto_accepted and match_result.suggestions: # Needs confirmation - has suggestions but low confidence result["confirmation_needed"].append({ "input": card_name, "suggestions": match_result.suggestions, "confidence": match_result.confidence, "type": "include" }) else: # No match found at all, add to illegal result["includes"]["illegal"].append(card_name) # Validate excludes with fuzzy matching for card_name in exclude_unique: match_result = fuzzy_match_card_name(card_name, available_cards) if match_result.matched_name: if match_result.auto_accepted: result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name result["excludes"]["legal"].append(match_result.matched_name) else: # Needs confirmation result["confirmation_needed"].append({ "input": card_name, "suggestions": match_result.suggestions, "confidence": match_result.confidence, "type": "exclude" }) else: # No match found, add to illegal result["excludes"]["illegal"].append(card_name) except Exception as fuzzy_error: result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}") return JSONResponse(result) except Exception as e: return JSONResponse({"error": str(e)}, status_code=400)