'
- )
- 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()
- sess = get_session(sid)
-
- # Clear build context to allow skip controls to work
- # (Otherwise toggle endpoint thinks build is in progress)
- if "build_ctx" in sess:
- try:
- del sess["build_ctx"]
- except Exception:
- pass
-
- # M2: Clear all skip preferences for true "New Deck"
- skip_keys = [
- "skip_lands", "skip_to_misc", "skip_basics", "skip_staples",
- "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes",
- "skip_all_creatures",
- "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill",
- "skip_all_spells",
- "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage",
- "skip_protection", "skip_spell_fill",
- "skip_post_adjust"
- ]
- for key in skip_keys:
- sess.pop(key, None)
-
- # M2: Check if this is a quick-build scenario (from commander browser)
- # Use the quick_build flag set by /build route when ?commander= param present
- is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag
-
- # M2: Clear commander and form selections for fresh start (unless quick build)
- if not is_quick_build:
- commander_keys = [
- "commander", "partner", "background", "commander_mode",
- "themes", "bracket"
- ]
- for key in commander_keys:
- sess.pop(key, None)
-
- theme_context = _custom_theme_context(request, sess)
- ctx = {
- "request": request,
- "brackets": orch.bracket_options(),
- "labels": orch.ideal_labels(),
- "defaults": orch.ideal_defaults(),
- "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
- "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
- "enable_custom_themes": ENABLE_CUSTOM_THEMES,
- "enable_batch_build": ENABLE_BATCH_BUILD,
- "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
- "form": {
- "commander": sess.get("commander", ""), # Pre-fill for quick-build
- "prefer_combos": bool(sess.get("prefer_combos")),
- "combo_count": sess.get("combo_target_count"),
- "combo_balance": sess.get("combo_balance"),
- "enable_multicopy": bool(sess.get("multi_copy")),
- "use_owned_only": bool(sess.get("use_owned_only")),
- "prefer_owned": bool(sess.get("prefer_owned")),
- "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
- # Add ideal values from session (will be None on first load, triggering defaults)
- "ramp": sess.get("ideals", {}).get("ramp"),
- "lands": sess.get("ideals", {}).get("lands"),
- "basic_lands": sess.get("ideals", {}).get("basic_lands"),
- "creatures": sess.get("ideals", {}).get("creatures"),
- "removal": sess.get("ideals", {}).get("removal"),
- "wipes": sess.get("ideals", {}).get("wipes"),
- "card_advantage": sess.get("ideals", {}).get("card_advantage"),
- "protection": sess.get("ideals", {}).get("protection"),
- },
- "tag_slot_html": None,
- }
- for key, value in theme_context.items():
- if key == "request":
- continue
- ctx[key] = value
- 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 []
- candidates: list[dict[str, Any]] = []
- for name, score, colors in items:
- detail = lookup_commander_detail(name)
- preferred = name
- warning = None
- if detail:
- eligible_raw = detail.get("eligible_faces")
- eligible = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
- norm_name = str(name).strip().casefold()
- eligible_norms = [face.casefold() for face in eligible]
- if eligible and norm_name not in eligible_norms:
- preferred = eligible[0]
- primary = str(detail.get("primary_face") or detail.get("name") or name).strip()
- if len(eligible) == 1:
- warning = (
- f"Use the back face '{preferred}' when building. Front face '{primary}' can't lead a deck."
- )
- else:
- faces = ", ".join(f"'{face}'" for face in eligible)
- warning = (
- f"This commander only works from specific faces: {faces}."
- )
- candidates.append(
- {
- "display": name,
- "value": preferred,
- "score": score,
- "colors": colors,
- "warning": warning,
- }
- )
- ctx = {"request": request, "query": q, "candidates": candidates}
- 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 {}
- exclusion_detail = lookup_commander_detail(info["name"])
- # 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"], "exclusion": exclusion_detail},
- "tags": tags,
- "recommended": recommended,
- "recommended_reasons": recommended_reasons,
- "gc_commander": is_gc,
- "brackets": orch.bracket_options(),
- }
- ctx.update(
- _partner_ui_context(
- info["name"],
- partner_enabled=False,
- secondary_selection=None,
- background_selection=None,
- combined_preview=None,
- warnings=None,
- partner_error=None,
- auto_note=None,
- )
- )
- partner_tags = ctx.get("partner_theme_tags") or []
- if partner_tags:
- merged_tags: list[str] = []
- seen: set[str] = set()
- for source in (partner_tags, tags):
- for tag in source:
- token = str(tag).strip()
- if not token:
- continue
- key = token.casefold()
- if key in seen:
- continue
- seen.add(key)
- merged_tags.append(token)
- ctx["tags"] = merged_tags
-
- # Deduplicate recommended: remove any that are already in partner_tags
- partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
- existing_recommended = ctx.get("recommended") or []
- deduplicated_recommended = [
- tag for tag in existing_recommended
- if str(tag).strip().casefold() not in partner_tags_lower
- ]
- ctx["recommended"] = deduplicated_recommended
-
- reason_map = dict(ctx.get("recommended_reasons") or {})
- for tag in partner_tags:
- if tag not in reason_map:
- reason_map[tag] = "Synergizes with partner pairing"
- ctx["recommended_reasons"] = reason_map
- 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("/themes/add", response_class=HTMLResponse)
-async def build_theme_add(request: Request, theme: str = Form("")) -> HTMLResponse:
- if not ENABLE_CUSTOM_THEMES:
- return HTMLResponse("", status_code=204)
- sid = request.cookies.get("sid") or new_sid()
- sess = get_session(sid)
- trimmed = theme.strip()
- sanitized = _sanitize_theme(trimmed) if trimmed else ""
- if trimmed and not sanitized:
- ctx = _custom_theme_context(request, sess, message=_INVALID_THEME_MESSAGE, level="error")
- else:
- value = sanitized if sanitized is not None else trimmed
- _, message, level = theme_mgr.add_theme(
- sess,
- value,
- commander_tags=list(sess.get("tags", [])),
- mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
- limit=USER_THEME_LIMIT,
- )
- ctx = _custom_theme_context(request, sess, message=message, level=level)
- resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
-
-@router.post("/themes/remove", response_class=HTMLResponse)
-async def build_theme_remove(request: Request, theme: str = Form("")) -> HTMLResponse:
- if not ENABLE_CUSTOM_THEMES:
- return HTMLResponse("", status_code=204)
- sid = request.cookies.get("sid") or new_sid()
- sess = get_session(sid)
- value = _sanitize_theme(theme) or theme
- _, message, level = theme_mgr.remove_theme(
- sess,
- value,
- commander_tags=list(sess.get("tags", [])),
- mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
- )
- ctx = _custom_theme_context(request, sess, message=message, level=level)
- resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
-
-@router.post("/themes/choose", response_class=HTMLResponse)
-async def build_theme_choose(
- request: Request,
- original: str = Form(""),
- choice: str = Form(""),
-) -> HTMLResponse:
- if not ENABLE_CUSTOM_THEMES:
- return HTMLResponse("", status_code=204)
- sid = request.cookies.get("sid") or new_sid()
- sess = get_session(sid)
- selection = _sanitize_theme(choice) or choice
- _, message, level = theme_mgr.choose_suggestion(
- sess,
- original,
- selection,
- commander_tags=list(sess.get("tags", [])),
- mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
- )
- ctx = _custom_theme_context(request, sess, message=message, level=level)
- resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
-
-@router.post("/themes/mode", response_class=HTMLResponse)
-async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> HTMLResponse:
- if not ENABLE_CUSTOM_THEMES:
- return HTMLResponse("", status_code=204)
- sid = request.cookies.get("sid") or new_sid()
- sess = get_session(sid)
- _, message, level = theme_mgr.set_mode(
- sess,
- mode,
- commander_tags=list(sess.get("tags", [])),
- )
- ctx = _custom_theme_context(request, sess, message=message, level=level)
- resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
-
-@router.post("/new/toggle-skip", response_class=JSONResponse)
-async def build_new_toggle_skip(
- request: Request,
- skip_key: str = Form(...),
- enabled: str = Form(...),
-) -> JSONResponse:
- """Toggle a skip configuration flag (wizard-only, before build starts).
-
- Enforces mutual exclusivity:
- - skip_lands and skip_to_misc are mutually exclusive with individual land flags
- - Individual land flags are mutually exclusive with each other
- """
- sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
- if not sid:
- return JSONResponse({"error": "No session ID"}, status_code=400)
-
- sess = get_session(sid)
-
- # Wizard-only: reject if build has started
- if "build_ctx" in sess:
- return JSONResponse({"error": "Cannot modify skip settings after build has started"}, status_code=400)
-
- # Validate skip_key
- valid_keys = {
- "skip_lands", "skip_to_misc", "skip_basics", "skip_staples",
- "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes",
- "skip_all_creatures",
- "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill",
- "skip_all_spells",
- "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage",
- "skip_protection", "skip_spell_fill",
- "skip_post_adjust"
- }
-
- if skip_key not in valid_keys:
- return JSONResponse({"error": f"Invalid skip key: {skip_key}"}, status_code=400)
-
- # Parse enabled flag
- enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"}
-
- # Mutual exclusivity rules
- land_group_flags = {"skip_lands", "skip_to_misc"}
- individual_land_flags = {"skip_basics", "skip_staples", "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes"}
- creature_specific_flags = {"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill"}
- spell_specific_flags = {"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", "skip_protection", "skip_spell_fill"}
-
- # If enabling a flag, check for conflicts
- if enabled_flag:
- # Rule 1: skip_lands/skip_to_misc disables all individual land flags
- if skip_key in land_group_flags:
- for key in individual_land_flags:
- sess[key] = False
-
- # Rule 2: Individual land flags disable skip_lands/skip_to_misc
- elif skip_key in individual_land_flags:
- for key in land_group_flags:
- sess[key] = False
-
- # Rule 3: skip_all_creatures disables specific creature flags
- elif skip_key == "skip_all_creatures":
- for key in creature_specific_flags:
- sess[key] = False
-
- # Rule 4: Specific creature flags disable skip_all_creatures
- elif skip_key in creature_specific_flags:
- sess["skip_all_creatures"] = False
-
- # Rule 5: skip_all_spells disables specific spell flags
- elif skip_key == "skip_all_spells":
- for key in spell_specific_flags:
- sess[key] = False
-
- # Rule 6: Specific spell flags disable skip_all_spells
- elif skip_key in spell_specific_flags:
- sess["skip_all_spells"] = False
-
- # Set the requested flag
- sess[skip_key] = enabled_flag
-
- # Auto-enable skip_post_adjust when any other skip is enabled
- if enabled_flag and skip_key != "skip_post_adjust":
- sess["skip_post_adjust"] = True
-
- # Auto-disable skip_post_adjust when all other skips are disabled
- if not enabled_flag:
- any_other_skip = any(
- sess.get(k, False) for k in valid_keys
- if k != "skip_post_adjust" and k != skip_key
- )
- if not any_other_skip:
- sess["skip_post_adjust"] = False
-
- return JSONResponse({
- "success": True,
- "skip_key": skip_key,
- "enabled": enabled_flag,
- "skip_post_adjust": bool(sess.get("skip_post_adjust", False))
- })
-
-
-def _get_descriptive_stage_label(stage: Dict[str, Any], ctx: Dict[str, Any]) -> str:
- """Generate a more descriptive label for Quick Build progress display."""
- key = stage.get("key", "")
- base_label = stage.get("label", "")
-
- # Land stages - show what type of lands
- land_types = {
- "land1": "Basics",
- "land2": "Staples",
- "land3": "Fetches",
- "land4": "Duals",
- "land5": "Triomes",
- "land6": "Kindred",
- "land7": "Misc Utility",
- "land8": "Final Lands"
- }
- if key in land_types:
- return f"Lands: {land_types[key]}"
-
- # Creature stages - show associated theme
- if "creatures" in key:
- tags = ctx.get("tags", [])
- if key == "creatures_all_theme":
- if tags:
- all_tags = " + ".join(tags[:3]) # Show up to 3 tags
- return f"Creatures: All Themes ({all_tags})"
- return "Creatures: All Themes"
- elif key == "creatures_primary" and len(tags) >= 1:
- return f"Creatures: {tags[0]}"
- elif key == "creatures_secondary" and len(tags) >= 2:
- return f"Creatures: {tags[1]}"
- elif key == "creatures_tertiary" and len(tags) >= 3:
- return f"Creatures: {tags[2]}"
- # Let creatures_fill use default "Creatures: Fill" label
-
- # Theme spell fill stage - adds any card type (artifacts, enchantments, instants, etc.) that fits theme
- if key == "spells_fill":
- return "Theme Spell Fill"
-
- # Default: return original label
- return base_label
-
-
-def _run_quick_build_stages(sid: str):
- """Background task: Run all stages for Quick Build and update progress in session."""
- import logging
- logger = logging.getLogger(__name__)
-
- logger.info(f"[Quick Build] Starting background task for sid={sid}")
-
- sess = get_session(sid)
- logger.info(f"[Quick Build] Retrieved session: {sess is not None}")
-
- ctx = sess.get("build_ctx")
- if not ctx:
- logger.error(f"[Quick Build] No build_ctx found in session")
- sess["quick_build_progress"] = {
- "running": False,
- "current_stage": "Error: No build context",
- "completed_stages": []
- }
- return
-
- logger.info(f"[Quick Build] build_ctx found with {len(ctx.get('stages', []))} stages")
-
- # CRITICAL: Inject session reference into context so skip config can be read
- ctx["session"] = sess
- logger.info("[Quick Build] Injected session reference into context")
-
- stages = ctx.get("stages", [])
- res = None
-
- # Initialize progress tracking
- sess["quick_build_progress"] = {
- "running": True,
- "current_stage": "Starting build..."
- }
-
- try:
- logger.info("[Quick Build] Starting stage loop")
-
- # Track which phase we're in for simplified progress display
- current_phase = None
-
- while True:
- current_idx = ctx.get("idx", 0)
- if current_idx >= len(stages):
- logger.info(f"[Quick Build] Reached end of stages (idx={current_idx})")
- break
-
- current_stage = stages[current_idx]
- stage_key = current_stage.get("key", "")
- logger.info(f"[Quick Build] Stage {current_idx} key: {stage_key}")
-
- # Determine simplified phase label
- if stage_key.startswith("creatures"):
- new_phase = "Adding Creatures"
- elif stage_key.startswith("spells") or stage_key in ["spells_ramp", "spells_removal", "spells_wipes", "spells_card_advantage", "spells_protection", "spells_fill"]:
- new_phase = "Adding Spells"
- elif stage_key.startswith("land"):
- new_phase = "Adding Lands"
- elif stage_key in ["post_spell_land_adjust", "reporting"]:
- new_phase = "Doing Some Final Touches"
- else:
- new_phase = "Building Deck"
-
- # Only update progress if phase changed
- if new_phase != current_phase:
- current_phase = new_phase
- sess["quick_build_progress"]["current_stage"] = current_phase
- logger.info(f"[Quick Build] Phase: {current_phase}")
-
- # Run stage with show_skipped=False
- res = orch.run_stage(ctx, rerun=False, show_skipped=False)
- logger.info(f"[Quick Build] Stage {stage_key} completed, done={res.get('done')}")
-
- # Handle Multi-Copy package marking
- 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
-
- # Check if build is done (reporting stage marks done=True)
- if res.get("done"):
- break
-
- # run_stage() advances ctx["idx"] internally when stage completes successfully
- # If stage is gated, it also advances the index, so we just continue the loop
-
- # Show summary generation message (stay here for a moment)
- sess["quick_build_progress"]["current_stage"] = "Generating Summary"
- import time
- time.sleep(2) # Pause briefly so user sees this stage
-
- # Store final result for polling endpoint
- sess["last_result"] = res or {}
- sess["last_step"] = 5
-
- # Small delay to show finishing message
- import time
- time.sleep(1.5)
-
- except Exception as e:
- # Store error state
- logger.exception(f"[Quick Build] Error during stage execution: {e}")
- sess["quick_build_progress"]["current_stage"] = f"Error: {str(e)}"
- finally:
- # Mark build as complete
- logger.info("[Quick Build] Background task completed")
- sess["quick_build_progress"]["running"] = False
- sess["quick_build_progress"]["current_stage"] = "Complete"
-
-
-@router.post("/new", response_class=HTMLResponse)
-async def build_new_submit(
- request: Request,
- background_tasks: BackgroundTasks,
- 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"),
- partner_enabled: str | None = Form(None),
- secondary_commander: str | None = Form(None),
- background: str | None = Form(None),
- partner_auto_opt_out: str | None = Form(None),
- partner_selection_source: str | None = Form(None),
- 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),
- use_owned_only: bool = Form(False),
- prefer_owned: bool = Form(False),
- swap_mdfc_basics: 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),
- # Build count for multi-build
- build_count: int = Form(1),
- # Quick Build flag
- quick_build: str | None = Form(None),
-) -> HTMLResponse:
- """Handle New Deck modal submit and immediately start the build (skip separate review page)."""
- sid = request.cookies.get("sid") or new_sid()
- sess = get_session(sid)
- partner_feature_enabled = ENABLE_PARTNER_MECHANICS
- raw_partner_flag = (partner_enabled or "").strip().lower()
- partner_checkbox = partner_feature_enabled and raw_partner_flag in {"1", "true", "on", "yes"}
- initial_secondary = (secondary_commander or "").strip()
- initial_background = (background or "").strip()
- auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"}
- partner_form_state: dict[str, Any] = {
- "partner_enabled": bool(partner_checkbox),
- "secondary_commander": initial_secondary,
- "background": initial_background,
- "partner_mode": None,
- "partner_auto_note": None,
- "partner_warnings": [],
- "combined_preview": None,
- "partner_auto_assigned": False,
- }
-
- def _form_state(commander_value: str) -> dict[str, Any]:
- return {
- "name": name,
- "commander": commander_value,
- "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),
- "enable_multicopy": bool(enable_multicopy),
- "use_owned_only": bool(use_owned_only),
- "prefer_owned": bool(prefer_owned),
- "swap_mdfc_basics": bool(swap_mdfc_basics),
- "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),
- "partner_enabled": partner_form_state["partner_enabled"],
- "secondary_commander": partner_form_state["secondary_commander"],
- "background": partner_form_state["background"],
- }
-
- commander_detail = lookup_commander_detail(commander)
- if commander_detail:
- eligible_raw = commander_detail.get("eligible_faces")
- eligible_faces = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
- if eligible_faces:
- norm_input = str(commander).strip().casefold()
- eligible_norms = [face.casefold() for face in eligible_faces]
- if norm_input not in eligible_norms:
- suggested = eligible_faces[0]
- primary_face = str(commander_detail.get("primary_face") or commander_detail.get("name") or commander).strip()
- faces_str = ", ".join(f"'{face}'" for face in eligible_faces)
- error_msg = (
- f"'{primary_face or commander}' can't lead a deck. Use {faces_str} as the commander instead. "
- "We've updated the commander field for you."
- )
- ctx = {
- "request": request,
- "error": error_msg,
- "brackets": orch.bracket_options(),
- "labels": orch.ideal_labels(),
- "defaults": orch.ideal_defaults(),
- "allow_must_haves": ALLOW_MUST_HAVES,
- "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
- "enable_custom_themes": ENABLE_CUSTOM_THEMES,
- "enable_batch_build": ENABLE_BATCH_BUILD,
- "form": _form_state(suggested),
- "tag_slot_html": None,
- }
- theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
- for key, value in theme_ctx.items():
- if key == "request":
- continue
- ctx[key] = value
- resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
- # 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
- "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
- "enable_custom_themes": ENABLE_CUSTOM_THEMES,
- "enable_batch_build": ENABLE_BATCH_BUILD,
- "form": _form_state(commander),
- "tag_slot_html": None,
- }
- theme_ctx = _custom_theme_context(request, sess, message=ctx["error"], level="error")
- for key, value in theme_ctx.items():
- if key == "request":
- continue
- ctx[key] = value
- resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
- primary_commander_name = sel.get("name") or commander
- # Enforce GC bracket restriction before saving session (silently coerce to 3)
- try:
- is_gc = bool(primary_commander_name 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"] = primary_commander_name
- (
- partner_error,
- combined_payload,
- partner_warnings,
- partner_auto_note,
- resolved_secondary,
- resolved_background,
- partner_mode,
- partner_auto_assigned_flag,
- ) = _resolve_partner_selection(
- primary_commander_name,
- feature_enabled=partner_feature_enabled,
- partner_enabled=partner_checkbox,
- secondary_candidate=secondary_commander,
- background_candidate=background,
- auto_opt_out=auto_opt_out_flag,
- selection_source=partner_selection_source,
- )
-
- partner_form_state["partner_mode"] = partner_mode
- partner_form_state["partner_auto_note"] = partner_auto_note
- partner_form_state["partner_warnings"] = partner_warnings
- partner_form_state["combined_preview"] = combined_payload
- if resolved_secondary:
- partner_form_state["secondary_commander"] = resolved_secondary
- if resolved_background:
- partner_form_state["background"] = resolved_background
- partner_form_state["partner_auto_assigned"] = bool(partner_auto_assigned_flag)
-
- combined_theme_pool: list[str] = []
- if isinstance(combined_payload, dict):
- raw_tags = combined_payload.get("theme_tags") or []
- for tag in raw_tags:
- token = str(tag).strip()
- if not token:
- continue
- if token not in combined_theme_pool:
- combined_theme_pool.append(token)
-
- if partner_error:
- available_tags = orch.tags_for_commander(primary_commander_name)
- recommended_tags = orch.recommended_tags_for_commander(primary_commander_name)
- recommended_reasons = orch.recommended_tag_reasons_for_commander(primary_commander_name)
- inspect_ctx: dict[str, Any] = {
- "request": request,
- "commander": {"name": primary_commander_name, "exclusion": lookup_commander_detail(primary_commander_name)},
- "tags": available_tags,
- "recommended": recommended_tags,
- "recommended_reasons": recommended_reasons,
- "gc_commander": is_gc,
- "brackets": orch.bracket_options(),
- }
- inspect_ctx.update(
- _partner_ui_context(
- primary_commander_name,
- partner_enabled=partner_checkbox,
- secondary_selection=partner_form_state["secondary_commander"] or None,
- background_selection=partner_form_state["background"] or None,
- combined_preview=combined_payload,
- warnings=partner_warnings,
- partner_error=partner_error,
- auto_note=partner_auto_note,
- auto_assigned=partner_form_state["partner_auto_assigned"],
- auto_prefill_allowed=not auto_opt_out_flag,
- )
- )
- partner_tags = inspect_ctx.pop("partner_theme_tags", None)
- if partner_tags:
- inspect_ctx["tags"] = partner_tags
- tag_slot_html = templates.get_template("build/_new_deck_tags.html").render(inspect_ctx)
- ctx = {
- "request": request,
- "error": partner_error,
- "brackets": orch.bracket_options(),
- "labels": orch.ideal_labels(),
- "defaults": orch.ideal_defaults(),
- "allow_must_haves": ALLOW_MUST_HAVES,
- "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
- "enable_custom_themes": ENABLE_CUSTOM_THEMES,
- "enable_batch_build": ENABLE_BATCH_BUILD,
- "form": _form_state(primary_commander_name),
- "tag_slot_html": tag_slot_html,
- }
- theme_ctx = _custom_theme_context(request, sess, message=partner_error, level="error")
- for key, value in theme_ctx.items():
- if key == "request":
- continue
- ctx[key] = value
- resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
- if partner_checkbox and combined_payload:
- sess["partner_enabled"] = True
- if resolved_secondary:
- sess["secondary_commander"] = resolved_secondary
- else:
- sess.pop("secondary_commander", None)
- if resolved_background:
- sess["background"] = resolved_background
- else:
- sess.pop("background", None)
- if partner_mode:
- sess["partner_mode"] = partner_mode
- else:
- sess.pop("partner_mode", None)
- sess["combined_commander"] = combined_payload
- sess["partner_warnings"] = partner_warnings
- if partner_auto_note:
- sess["partner_auto_note"] = partner_auto_note
- else:
- sess.pop("partner_auto_note", None)
- sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag)
- sess["partner_auto_opt_out"] = bool(auto_opt_out_flag)
- else:
- sess["partner_enabled"] = False
- for key in [
- "secondary_commander",
- "background",
- "partner_mode",
- "partner_warnings",
- "combined_commander",
- "partner_auto_note",
- ]:
- try:
- sess.pop(key)
- except KeyError:
- pass
- for key in ["partner_auto_assigned", "partner_auto_opt_out"]:
- try:
- sess.pop(key)
- except KeyError:
- pass
-
- # 1) Start from explicitly selected tags (order preserved)
- tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
- user_explicit = bool(tags) # whether the user set any theme in the form
- # 2) Consider user-added supplemental themes from the Additional Themes UI
- additional_from_session = []
- try:
- # custom_theme_manager stores resolved list here on add/resolve; present before submit
- additional_from_session = [
- str(x) for x in (sess.get("additional_themes") or []) if isinstance(x, str) and x.strip()
- ]
- except Exception:
- additional_from_session = []
- # 3) If no explicit themes were selected, prefer additional themes as primary/secondary/tertiary
- if not user_explicit and additional_from_session:
- # Cap to three and preserve order
- tags = list(additional_from_session[:3])
- # 4) If user selected some themes, fill remaining slots with additional themes (deduping)
- elif user_explicit and additional_from_session:
- seen = {str(t).strip().casefold() for t in tags}
- for name in additional_from_session:
- key = name.strip().casefold()
- if key in seen:
- continue
- tags.append(name)
- seen.add(key)
- if len(tags) >= 3:
- break
- # 5) If still empty (no explicit and no additional), fall back to commander-recommended default
- if not tags:
- if combined_theme_pool:
- tags = combined_theme_pool[:3]
- else:
- 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
- if ENABLE_CUSTOM_THEMES:
- try:
- theme_mgr.refresh_resolution(
- sess,
- commander_tags=tags,
- mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
- )
- except ValueError as exc:
- error_msg = str(exc)
- ctx = {
- "request": request,
- "error": error_msg,
- "brackets": orch.bracket_options(),
- "labels": orch.ideal_labels(),
- "defaults": orch.ideal_defaults(),
- "allow_must_haves": ALLOW_MUST_HAVES,
- "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
- "enable_custom_themes": ENABLE_CUSTOM_THEMES,
- "enable_batch_build": ENABLE_BATCH_BUILD,
- "form": _form_state(sess.get("commander", "")),
- "tag_slot_html": None,
- }
- theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
- for key, value in theme_ctx.items():
- if key == "request":
- continue
- ctx[key] = value
- resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
- # Persist preferences
- try:
- sess["prefer_combos"] = bool(prefer_combos)
- except Exception:
- sess["prefer_combos"] = False
- try:
- sess["use_owned_only"] = bool(use_owned_only)
- except Exception:
- sess["use_owned_only"] = False
- try:
- sess["prefer_owned"] = bool(prefer_owned)
- except Exception:
- sess["prefer_owned"] = False
- try:
- sess["swap_mdfc_basics"] = bool(swap_mdfc_basics)
- except Exception:
- sess["swap_mdfc_basics"] = 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)
-
- # Validate and normalize build_count
- try:
- build_count = max(1, min(10, int(build_count)))
- except Exception:
- build_count = 1
-
- # Check if this is a multi-build request (build_count > 1)
- if build_count > 1:
- # Multi-Build: Queue parallel builds and return batch progress page
- from ..services.multi_build_orchestrator import queue_builds, run_batch_async
-
- # Create config dict from session for batch builds
- batch_config = {
- "commander": sess.get("commander"),
- "tags": sess.get("tags", []),
- "tag_mode": sess.get("tag_mode", "AND"),
- "bracket": sess.get("bracket", 3),
- "ideals": sess.get("ideals", {}),
- "prefer_combos": sess.get("prefer_combos", False),
- "combo_target_count": sess.get("combo_target_count"),
- "combo_balance": sess.get("combo_balance"),
- "multi_copy": sess.get("multi_copy"),
- "use_owned_only": sess.get("use_owned_only", False),
- "prefer_owned": sess.get("prefer_owned", False),
- "swap_mdfc_basics": sess.get("swap_mdfc_basics", False),
- "include_cards": sess.get("include_cards", []),
- "exclude_cards": sess.get("exclude_cards", []),
- "enforcement_mode": sess.get("enforcement_mode", "warn"),
- "allow_illegal": sess.get("allow_illegal", False),
- "fuzzy_matching": sess.get("fuzzy_matching", True),
- "locks": list(sess.get("locks", [])),
- }
-
- # Handle partner mechanics if present
- if sess.get("partner_enabled"):
- batch_config["partner_enabled"] = True
- if sess.get("secondary_commander"):
- batch_config["secondary_commander"] = sess["secondary_commander"]
- if sess.get("background"):
- batch_config["background"] = sess["background"]
- if sess.get("partner_mode"):
- batch_config["partner_mode"] = sess["partner_mode"]
- if sess.get("combined_commander"):
- batch_config["combined_commander"] = sess["combined_commander"]
-
- # Add color identity for synergy builder (needed for basic land allocation)
- try:
- tmp_builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
-
- # Handle partner mechanics if present
- if sess.get("partner_enabled") and sess.get("secondary_commander"):
- from deck_builder.partner_selection import apply_partner_inputs
- combined_obj = apply_partner_inputs(
- tmp_builder,
- primary_name=sess["commander"],
- secondary_name=sess.get("secondary_commander"),
- background_name=sess.get("background"),
- feature_enabled=True,
- )
- if combined_obj and hasattr(combined_obj, "color_identity"):
- batch_config["colors"] = list(combined_obj.color_identity)
- else:
- # Single commander
- df = tmp_builder.load_commander_data()
- row = df[df["name"] == sess["commander"]]
- if not row.empty:
- # Get colorIdentity from dataframe (it's a string like "RG" or "G")
- color_str = row.iloc[0].get("colorIdentity", "")
- if color_str:
- batch_config["colors"] = list(color_str) # Convert "RG" to ['R', 'G']
- except Exception as e:
- import logging
- logging.getLogger(__name__).warning(f"[Batch] Failed to load color identity for {sess.get('commander')}: {e}")
- pass # Not critical, synergy builder will skip basics if missing
-
- # Queue the batch
- batch_id = queue_builds(batch_config, build_count, sid)
-
- # Start background task for parallel builds
- background_tasks.add_task(run_batch_async, batch_id, sid)
-
- # Return batch progress template
- progress_ctx = {
- "request": request,
- "batch_id": batch_id,
- "build_count": build_count,
- "completed": 0,
- "current_build": 1,
- "status": "Starting builds..."
- }
- resp = templates.TemplateResponse("build/_batch_progress.html", progress_ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
- # Check if Quick Build was requested (single build only)
- is_quick_build = (quick_build or "").strip() == "1"
-
- if is_quick_build:
- # Quick Build: Start background task and return progress template immediately
- ctx = sess["build_ctx"]
-
- # Initialize progress tracking with dynamic counting (total starts at 0)
- sess["quick_build_progress"] = {
- "running": True,
- "total": 0,
- "completed": 0,
- "current_stage": "Starting build..."
- }
-
- # Start background task to run all stages
- background_tasks.add_task(_run_quick_build_stages, sid)
-
- # Return progress template immediately
- progress_ctx = {
- "request": request,
- "progress_pct": 0,
- "completed": 0,
- "total": 0,
- "current_stage": "Starting build..."
- }
- resp = templates.TemplateResponse("build/_quick_build_progress.html", progress_ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
- else:
- # Normal build: Run first stage and wait for user input
- 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
- commander_name = res.get("name")
- gc_flag = commander_name in getattr(bc, 'GAME_CHANGERS', [])
- context = {
- "request": request,
- "commander": res,
- "tags": orch.tags_for_commander(commander_name),
- "recommended": orch.recommended_tags_for_commander(commander_name),
- "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name),
- "brackets": orch.bracket_options(),
- "gc_commander": gc_flag,
- "selected_bracket": (3 if gc_flag else None),
- "clear_persisted": True,
- }
- context.update(
- _partner_ui_context(
- commander_name,
- partner_enabled=False,
- secondary_selection=None,
- background_selection=None,
- combined_preview=None,
- warnings=None,
- partner_error=None,
- auto_note=None,
- )
- )
- resp = templates.TemplateResponse("build/_step2.html", context)
- 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",
- "partner_enabled",
- "secondary_commander",
- "background",
- "partner_mode",
- "partner_warnings",
- "combined_commander",
- "partner_auto_note",
- ]:
- 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
- context = {
- "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,
- }
- context.update(
- _partner_ui_context(
- res["name"],
- partner_enabled=False,
- secondary_selection=None,
- background_selection=None,
- combined_preview=None,
- warnings=None,
- partner_error=None,
- auto_note=None,
- )
- )
- resp = templates.TemplateResponse("build/_step2.html", context)
- 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)
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}})
- 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
- partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS)
-
- import logging
- logger = logging.getLogger(__name__)
- logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}")
-
- context = {
- "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,
- }
- context.update(
- _partner_ui_context(
- commander,
- partner_enabled=partner_enabled,
- secondary_selection=sess.get("secondary_commander") if partner_enabled else None,
- background_selection=sess.get("background") if partner_enabled else None,
- combined_preview=sess.get("combined_commander") if partner_enabled else None,
- warnings=sess.get("partner_warnings") if partner_enabled else None,
- partner_error=None,
- auto_note=sess.get("partner_auto_note") if partner_enabled else None,
- auto_assigned=sess.get("partner_auto_assigned") if partner_enabled else None,
- auto_prefill_allowed=not bool(sess.get("partner_auto_opt_out")) if partner_enabled else True,
- )
- )
- partner_tags = context.pop("partner_theme_tags", None)
- if partner_tags:
- import logging
- logger = logging.getLogger(__name__)
- context["tags"] = partner_tags
- # Deduplicate recommended tags: remove any that are already in partner_tags
- partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
- original_recommended = context.get("recommended", [])
- deduplicated_recommended = [
- tag for tag in original_recommended
- if str(tag).strip().casefold() not in partner_tags_lower
- ]
- logger.info(
- f"Step2: partner_tags={len(partner_tags)}, "
- f"original_recommended={len(original_recommended)}, "
- f"deduplicated_recommended={len(deduplicated_recommended)}"
- )
- context["recommended"] = deduplicated_recommended
- resp = templates.TemplateResponse("build/_step2.html", context)
- 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(...),
- partner_enabled: str | None = Form(None),
- secondary_commander: str | None = Form(None),
- background: str | None = Form(None),
- partner_selection_source: str | None = Form(None),
- partner_auto_opt_out: str | None = Form(None),
-) -> HTMLResponse:
- sid = request.cookies.get("sid") or new_sid()
- sess = get_session(sid)
- sess["last_step"] = 2
-
- partner_feature_enabled = ENABLE_PARTNER_MECHANICS
- partner_flag = False
- if partner_feature_enabled:
- raw_partner_enabled = (partner_enabled or "").strip().lower()
- partner_flag = raw_partner_enabled in {"1", "true", "on", "yes"}
- auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"}
-
- # 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()):
- # 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
- context = {
- "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,
- }
- context.update(
- _partner_ui_context(
- commander,
- partner_enabled=partner_flag,
- secondary_selection=secondary_commander if partner_flag else None,
- background_selection=background if partner_flag else None,
- combined_preview=None,
- warnings=[],
- partner_error=None,
- auto_note=None,
- auto_assigned=None,
- auto_prefill_allowed=not auto_opt_out_flag,
- )
- )
- partner_tags = context.pop("partner_theme_tags", None)
- if partner_tags:
- context["tags"] = partner_tags
- resp = templates.TemplateResponse("build/_step2.html", context)
- 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
-
- (
- partner_error,
- combined_payload,
- partner_warnings,
- partner_auto_note,
- resolved_secondary,
- resolved_background,
- partner_mode,
- partner_auto_assigned_flag,
- ) = _resolve_partner_selection(
- commander,
- feature_enabled=partner_feature_enabled,
- partner_enabled=partner_flag,
- secondary_candidate=secondary_commander,
- background_candidate=background,
- auto_opt_out=auto_opt_out_flag,
- selection_source=partner_selection_source,
- )
-
- if partner_error:
- try:
- sel_br = int(bracket)
- except Exception:
- sel_br = None
- context: dict[str, Any] = {
- "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(),
- "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,
- "error": None,
- }
- context.update(
- _partner_ui_context(
- commander,
- partner_enabled=partner_flag,
- secondary_selection=resolved_secondary or secondary_commander,
- background_selection=resolved_background or background,
- combined_preview=combined_payload,
- warnings=partner_warnings,
- partner_error=partner_error,
- auto_note=partner_auto_note,
- auto_assigned=partner_auto_assigned_flag,
- auto_prefill_allowed=not auto_opt_out_flag,
- )
- )
- partner_tags = context.pop("partner_theme_tags", None)
- if partner_tags:
- context["tags"] = partner_tags
- resp = templates.TemplateResponse("build/_step2.html", context)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
- # Save selection to session (basic MVP; real build will use this later)
- 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)
-
- if partner_flag and combined_payload:
- sess["partner_enabled"] = True
- if resolved_secondary:
- sess["secondary_commander"] = resolved_secondary
- else:
- sess.pop("secondary_commander", None)
- if resolved_background:
- sess["background"] = resolved_background
- else:
- sess.pop("background", None)
- if partner_mode:
- sess["partner_mode"] = partner_mode
- else:
- sess.pop("partner_mode", None)
- sess["combined_commander"] = combined_payload
- sess["partner_warnings"] = partner_warnings
- if partner_auto_note:
- sess["partner_auto_note"] = partner_auto_note
- else:
- sess.pop("partner_auto_note", None)
- sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag)
- sess["partner_auto_opt_out"] = bool(auto_opt_out_flag)
- else:
- sess["partner_enabled"] = False
- for key in [
- "secondary_commander",
- "background",
- "partner_mode",
- "partner_warnings",
- "combined_commander",
- "partner_auto_note",
- ]:
- try:
- sess.pop(key)
- except KeyError:
- pass
- for key in ["partner_auto_assigned", "partner_auto_opt_out"]:
- try:
- sess.pop(key)
- except KeyError:
- pass
-
- # 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"),
- "owned_only": bool(sess.get("use_owned_only")),
- "prefer_owned": bool(sess.get("prefer_owned")),
- "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
- },
- )
- 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
-
- # Check if any skip flags are enabled to show skeleton automation page
- skip_flags = {
- "skip_lands": "land selection",
- "skip_to_misc": "land selection",
- "skip_basics": "basic lands",
- "skip_staples": "staple lands",
- "skip_kindred": "kindred lands",
- "skip_fetches": "fetch lands",
- "skip_duals": "dual lands",
- "skip_triomes": "triome lands",
- "skip_all_creatures": "creature selection",
- "skip_creature_primary": "primary creatures",
- "skip_creature_secondary": "secondary creatures",
- "skip_creature_fill": "creature fills",
- "skip_all_spells": "spell selection",
- "skip_ramp": "ramp spells",
- "skip_removal": "removal spells",
- "skip_wipes": "board wipes",
- "skip_card_advantage": "card advantage spells",
- "skip_protection": "protection spells",
- "skip_spell_fill": "spell fills",
- }
-
- active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)]
-
- if active_skips:
- # Show skeleton automation page with auto-submit
- automation_parts = []
- if any("land" in s for s in active_skips):
- automation_parts.append("lands")
- if any("creature" in s for s in active_skips):
- automation_parts.append("creatures")
- if any("spell" in s for s in active_skips):
- automation_parts.append("spells")
-
- automation_message = f"Applying default values for {', '.join(automation_parts)}..."
-
- resp = templates.TemplateResponse(
- "build/_step3_skeleton.html",
- {
- "request": request,
- "defaults": defaults,
- "commander": sess.get("commander"),
- "automation_message": automation_message,
- },
- )
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- return resp
-
- # No skips enabled, show normal form
- 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")),
- "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
- },
- )
-
-
-# --- 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 matters": 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),
- swap_mdfc_basics: 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
- swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False
- sess["use_owned_only"] = only_val
- sess["prefer_owned"] = pref_val
- sess["swap_mdfc_basics"] = swap_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")),
- "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
- },
- )
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
- 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"
- # Clear commander from session after build completes
- if res.get("done"):
- sess.pop("commander", None)
- sess.pop("commander_name", None)
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}})
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
- 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 = (
- '
'
- ''
- '
'
- )
- 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"])
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
- return resp
-
-
-@router.get("/step5/summary", response_class=HTMLResponse)
-async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse:
- sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
- if not sid:
- sid = new_sid()
- sess = get_session(sid)
-
- try:
- session_token = int(sess.get("step5_summary_token", 0))
- except Exception:
- session_token = 0
- try:
- requested_token = int(token)
- except Exception:
- requested_token = 0
- ready = bool(sess.get("step5_summary_ready"))
- summary_data = sess.get("step5_summary") if ready else None
- if summary_data is None and ready:
- summary_data = _current_builder_summary(sess)
- if summary_data is not None:
- try:
- sess["step5_summary"] = summary_data
- except Exception:
- pass
-
- synergies: list[str] = []
- try:
- raw_synergies = sess.get("step5_synergies")
- if isinstance(raw_synergies, (list, tuple, set)):
- synergies = [str(item) for item in raw_synergies if str(item).strip()]
- except Exception:
- synergies = []
-
- active_token = session_token if session_token >= requested_token else requested_token
-
- if not ready or summary_data is None:
- message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage."
- placeholder = _step5_summary_placeholder_html(active_token, message=message)
- response = HTMLResponse(placeholder)
- response.set_cookie("sid", sid, httponly=True, samesite="lax")
- return response
-
- ctx = step5_base_ctx(request, sess)
- ctx["summary"] = summary_data
- ctx["synergies"] = synergies
- ctx["summary_ready"] = True
- ctx["summary_token"] = active_token
-
- # Add commander hover context for color identity and theme tags
- hover_meta = commander_hover_context(
- commander_name=ctx.get("commander"),
- deck_tags=sess.get("tags"),
- summary=summary_data,
- combined=ctx.get("combined_commander"),
- )
- ctx.update(hover_meta)
-
- response = templates.TemplateResponse("partials/deck_summary.html", ctx)
- response.set_cookie("sid", sid, httponly=True, samesite="lax")
- return response
-
-
-@router.get("/quick-progress")
-def quick_build_progress(request: Request):
- """Poll endpoint for Quick Build progress. Returns either progress indicator or final Step 5."""
- import logging
- logger = logging.getLogger(__name__)
-
- sid = request.cookies.get("sid") or new_sid()
- sess = get_session(sid)
-
- progress = sess.get("quick_build_progress")
- logger.info(f"[Progress Poll] sid={sid}, progress={progress is not None}, running={progress.get('running') if progress else None}")
-
- if not progress or not progress.get("running"):
- # Build complete - return Step 5 content that replaces the entire wizard container
- res = sess.get("last_result")
- if res and res.get("done"):
- ctx = step5_ctx_from_result(request, sess, res)
- # Return Step 5 which will replace the whole wizard div
- response = templates.TemplateResponse("build/_step5.html", ctx)
- response.set_cookie("sid", sid, httponly=True, samesite="lax")
- # Tell HTMX to target #wizard and swap outerHTML to replace the container
- response.headers["HX-Retarget"] = "#wizard"
- response.headers["HX-Reswap"] = "outerHTML"
- return response
- # Fallback if no result yet
- return HTMLResponse('Build complete. Please refresh.')
-
- # Build still running - return progress content partial only (innerHTML swap)
- current_stage = progress.get("current_stage", "Processing...")
-
- ctx = {
- "request": request,
- "current_stage": current_stage
- }
- response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx)
- response.set_cookie("sid", sid, httponly=True, samesite="lax")
- return response
-
-
@router.get("/batch-progress")
def batch_build_progress(request: Request, batch_id: str = Query(...)):
"""Poll endpoint for Batch Build progress. Returns either progress indicator or redirect to comparison."""
@@ -4046,1694 +208,23 @@ def batch_build_progress(request: Request, batch_id: str = Query(...)):
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
-# --- 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),
- refresh: 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"))
- refresh_requested = bool(int(refresh or 0))
- # 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] = []
-
- def _clean(value: Any) -> str:
- try:
- if value is None:
- return ""
- if isinstance(value, float) and value != value:
- return ""
- text = str(value)
- return text.strip()
- except Exception:
- return ""
-
- def _normalize_tags(raw: Any) -> list[str]:
- if not raw:
- return []
- if isinstance(raw, (list, tuple, set)):
- return [str(t).strip() for t in raw if str(t).strip()]
- if isinstance(raw, str):
- txt = raw.strip()
- if not txt:
- return []
- if txt.startswith("[") and txt.endswith("]"):
- try:
- import json as _json
- parsed = _json.loads(txt)
- if isinstance(parsed, list):
- return [str(t).strip() for t in parsed if str(t).strip()]
- except Exception:
- pass
- return [s.strip() for s in txt.split(',') if s.strip()]
- return []
-
- def _meta_from_row(row_obj: Any) -> dict[str, Any]:
- meta = {
- "mana": "",
- "rarity": "",
- "role": "",
- "tags": [],
- "hover_simple": True,
- }
- if row_obj is None:
- meta["role"] = _clean(used_role or "")
- return meta
-
- def _pull(*keys: str) -> Any:
- for key in keys:
- try:
- if isinstance(row_obj, dict):
- val = row_obj.get(key)
- elif hasattr(row_obj, "get"):
- val = row_obj.get(key)
- else:
- val = getattr(row_obj, key, None)
- except Exception:
- val = None
- if val not in (None, ""):
- if isinstance(val, float) and val != val:
- continue
- return val
- return None
-
- meta["mana"] = _clean(_pull("mana_cost", "manaCost", "mana", "manaValue", "cmc", "mv"))
- meta["rarity"] = _clean(_pull("rarity"))
- role_val = _pull("role", "primaryRole", "subRole")
- if not role_val:
- role_val = used_role or ""
- meta["role"] = _clean(role_val)
- tags_val = _pull("themeTags", "_ltags", "tags")
- meta_tags = _normalize_tags(tags_val)
- meta["tags"] = meta_tags
- meta["hover_simple"] = not (meta["mana"] or meta["rarity"] or (meta_tags and len(meta_tags) > 0))
- return meta
-
- def _build_meta_map(df_obj) -> dict[str, dict[str, Any]]:
- mapping: dict[str, dict[str, Any]] = {}
- try:
- if df_obj is None or not hasattr(df_obj, "iterrows"):
- return mapping
- for _, row in df_obj.iterrows():
- try:
- nm_val = str(row.get("name") or "").strip()
- except Exception:
- nm_val = ""
- if not nm_val:
- continue
- key = nm_val.lower()
- if key in mapping:
- continue
- mapping[key] = _meta_from_row(row)
- except Exception:
- return mapping
- return mapping
-
- def _sampler(seq: list[str], limit: int) -> list[str]:
- if limit <= 0:
- return []
- if len(seq) <= limit:
- return list(seq)
- rng = getattr(b, "rng", None)
- try:
- if rng is not None:
- return rng.sample(seq, limit) if len(seq) >= limit else list(seq)
- import random as _rnd
- return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq)
- except Exception:
- return list(seq[:limit])
- 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)
- cached = None
- if used_role != 'land' and not refresh_requested:
- cached = _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 or refresh requested for per-call randomness
- if used_role != 'land' and not refresh_requested:
- 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)
- 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"])
- 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
- row_meta = _build_meta_map(pool)
- # 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
- meta = row_meta.get(nm_lc) or _meta_from_row(None)
- items.append({
- 'name': display_map.get(nm_lc, orig),
- 'name_lower': nm_lc,
- 'owned': is_owned,
- 'tags': meta.get('tags') or [],
- 'role': meta.get('role', ''),
- 'mana': meta.get('mana', ''),
- 'rarity': meta.get('rarity', ''),
- 'hover_simple': bool(meta.get('hover_simple', True)),
- })
- 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))
- iteration_order = lower_pool
- if refresh_requested and len(lower_pool) > 12:
- window_size = min(len(lower_pool), 30)
- window = lower_pool[:window_size]
- sampled = _sampler(window, min(window_size, 12))
- seen_sampled = set(sampled)
- iteration_order = sampled + [nm for nm in lower_pool if nm not in seen_sampled]
- for nm_l in iteration_order:
- 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
- meta = row_meta.get(nm_l) or _meta_from_row(None)
- items.append({
- "name": display_map.get(nm_l, nm_l),
- "name_lower": nm_l,
- "owned": is_owned,
- "tags": meta.get("tags") or [],
- "role": meta.get("role", ""),
- "mana": meta.get("mana", ""),
- "rarity": meta.get("rarity", ""),
- "hover_simple": bool(meta.get("hover_simple", True)),
- })
- 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]))
- if refresh_requested and len(candidates) > 1:
- name_sequence = [nm for nm, _score in candidates]
- sampled_names = _sampler(name_sequence, min(len(name_sequence), 10))
- sampled_set = set(sampled_names)
- reordered: list[tuple[str, int]] = []
- for nm in sampled_names:
- for cand_nm, cand_score in candidates:
- if cand_nm == nm:
- reordered.append((cand_nm, cand_score))
- break
- for cand_nm, cand_score in candidates:
- if cand_nm not in sampled_set:
- reordered.append((cand_nm, cand_score))
- candidates = reordered
- 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 []),
- "role": "",
- "mana": "",
- "rarity": "",
- "hover_simple": True,
- })
- 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(...), owned_only: str = Form("0")) -> 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()
- owned_only_flag = str(owned_only or "").strip().lower()
- owned_only_int = 1 if owned_only_flag in {"1", "true", "yes", "on"} else 0
-
- # 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())
- refresh = (
- ''
- )
- html = (
- '
'
- f'
Replaced {o_disp} with {new_key}.
'
- '
Compliance panel will refresh.
'
- '
'
- ''
- '
'
- + 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.
'
- '
'
- ''
- ''
- ''
- '
'
- '
'
- )
- 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)
- except Exception:
- comp = None
- try:
- if comp:
- from ..services import orchestrator as orch
- comp = orch._attach_enforcement_plan(b, comp)
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
- 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()
- 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')
- 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')
- 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")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
- 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(request, "build/_step5.html", page_ctx)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
- 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)
- except Exception:
- comp = None
- try:
- if comp:
- from ..services import orchestrator as orch
- comp = orch._attach_enforcement_plan(b, comp)
- except Exception:
- pass
- try:
- summary_token = int(sess.get("step5_summary_token", 0))
- except Exception:
- summary_token = 0
- ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
- resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
- resp.set_cookie("sid", sid, httponly=True, samesite="lax")
- _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
- 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")),
- "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
- },
- "locks": list(sess.get("locks", [])),
- }
- # Optional: random build fields (if present in session)
- try:
- rb = sess.get("random_build") or {}
- if rb:
- # Only include known keys to avoid leaking unrelated session data
- inc: dict[str, Any] = {}
- for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
- if rb.get(key) is not None:
- inc[key] = rb.get(key)
- resolved_list = rb.get("resolved_themes")
- if isinstance(resolved_list, list):
- inc["resolved_themes"] = list(resolved_list)
- resolved_info = rb.get("resolved_theme_info")
- if isinstance(resolved_info, dict):
- inc["resolved_theme_info"] = dict(resolved_info)
- if rb.get("combo_fallback") is not None:
- inc["combo_fallback"] = bool(rb.get("combo_fallback"))
- if rb.get("synergy_fallback") is not None:
- inc["synergy_fallback"] = bool(rb.get("synergy_fallback"))
- if rb.get("fallback_reason") is not None:
- inc["fallback_reason"] = rb.get("fallback_reason")
- requested = rb.get("requested_themes")
- if isinstance(requested, dict):
- inc["requested_themes"] = dict(requested)
- if rb.get("auto_fill_enabled") is not None:
- inc["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled"))
- if rb.get("auto_fill_applied") is not None:
- inc["auto_fill_applied"] = bool(rb.get("auto_fill_applied"))
- auto_filled = rb.get("auto_filled_themes")
- if isinstance(auto_filled, list):
- inc["auto_filled_themes"] = list(auto_filled)
- display = rb.get("display_themes")
- if isinstance(display, list):
- inc["display_themes"] = list(display)
- if inc:
- payload["random"] = inc
- except Exception:
- pass
-
- # 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["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics"))
- sess["locks"] = list(data.get("locks", []))
- # Optional random build rehydration
- try:
- r = data.get("random") or {}
- if r:
- rb_payload: dict[str, Any] = {}
- for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
- if r.get(key) is not None:
- rb_payload[key] = r.get(key)
- if isinstance(r.get("resolved_themes"), list):
- rb_payload["resolved_themes"] = list(r.get("resolved_themes") or [])
- if isinstance(r.get("resolved_theme_info"), dict):
- rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info"))
- if r.get("combo_fallback") is not None:
- rb_payload["combo_fallback"] = bool(r.get("combo_fallback"))
- if r.get("synergy_fallback") is not None:
- rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback"))
- if r.get("fallback_reason") is not None:
- rb_payload["fallback_reason"] = r.get("fallback_reason")
- if isinstance(r.get("requested_themes"), dict):
- requested_payload = dict(r.get("requested_themes"))
- if "auto_fill_enabled" in requested_payload:
- requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
- rb_payload["requested_themes"] = requested_payload
- if r.get("auto_fill_enabled") is not None:
- rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled"))
- if r.get("auto_fill_applied") is not None:
- rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied"))
- auto_filled = r.get("auto_filled_themes")
- if isinstance(auto_filled, list):
- rb_payload["auto_filled_themes"] = list(auto_filled)
- display = r.get("display_themes")
- if isinstance(display, list):
- rb_payload["display_themes"] = list(display)
- if "seed" in rb_payload:
- try:
- seed_int = int(rb_payload["seed"])
- rb_payload["seed"] = seed_int
- rb_payload.setdefault("recent_seeds", [seed_int])
- except Exception:
- rb_payload.setdefault("recent_seeds", [])
- sess["random_build"] = rb_payload
- except Exception:
- pass
-
- # 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(request, "build/_step4.html", {
- "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")),
- "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
- "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:
- # Use cached available cards set (1st call populates cache)
- available_cards = _available_cards()
-
- # Fast path: normalized exact matches via cached sets
- norm_set, norm_map = _available_cards_normalized()
- # Validate includes with fuzzy matching
- for card_name in include_unique:
- from deck_builder.include_exclude_utils import normalize_punctuation
- n = normalize_punctuation(card_name)
- if n in norm_set:
- result["includes"]["fuzzy_matches"][card_name] = norm_map[n]
- result["includes"]["legal"].append(norm_map[n])
- continue
- 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:
- from deck_builder.include_exclude_utils import normalize_punctuation
- n = normalize_punctuation(card_name)
- if n in norm_set:
- result["excludes"]["fuzzy_matches"][card_name] = norm_map[n]
- result["excludes"]["legal"].append(norm_map[n])
- continue
- 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)
+# ==============================================================================
+# Phase 5 Routes Moved to Focused Modules (Roadmap 9 M1)
+# ==============================================================================
+# Permalinks and Lock Management → build_permalinks.py:
+# - POST /build/lock - Card lock toggle
+# - GET /build/permalink - State serialization
+# - GET /build/from - State restoration
+#
+# Alternatives → build_alternatives.py:
+# - GET /build/alternatives - Role-based card suggestions
+#
+# Compliance and Replacement → build_compliance.py:
+# - POST /build/replace - Inline card replacement
+# - POST /build/replace/undo - Undo replacement
+# - GET /build/compare - Batch comparison stub
+# - GET /build/compliance - Compliance panel
+# - POST /build/enforce/apply - Apply enforcement
+# - GET /build/enforcement - Full-page enforcement
+# ==============================================================================
diff --git a/code/web/routes/build_alternatives.py b/code/web/routes/build_alternatives.py
new file mode 100644
index 0000000..4c7a651
--- /dev/null
+++ b/code/web/routes/build_alternatives.py
@@ -0,0 +1,615 @@
+"""Build Alternatives Route
+
+Phase 5 extraction from build.py:
+- GET /build/alternatives - Role-based card suggestions with tag overlap fallback
+
+This module provides intelligent alternative card suggestions based on deck role,
+tags, and builder context. Supports owned-only filtering and caching.
+"""
+from __future__ import annotations
+
+from fastapi import APIRouter, Request, Query
+from fastapi.responses import HTMLResponse
+from typing import Any
+from ..app import templates
+from ..services.tasks import get_session, new_sid
+from ..services.build_utils import owned_set as owned_set_helper, builder_present_names, builder_display_map
+from deck_builder.builder import DeckBuilder
+from deck_builder import builder_constants as bc
+from deck_builder import builder_utils as bu
+from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
+
+
+router = APIRouter(prefix="/build")
+
+
+@router.get("/alternatives", response_class=HTMLResponse)
+async def build_alternatives(
+ request: Request,
+ name: str,
+ stage: str | None = None,
+ owned_only: int = Query(0),
+ refresh: 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"))
+ refresh_requested = bool(int(refresh or 0))
+ # 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] = []
+
+ def _clean(value: Any) -> str:
+ try:
+ if value is None:
+ return ""
+ if isinstance(value, float) and value != value:
+ return ""
+ text = str(value)
+ return text.strip()
+ except Exception:
+ return ""
+
+ def _normalize_tags(raw: Any) -> list[str]:
+ if not raw:
+ return []
+ if isinstance(raw, (list, tuple, set)):
+ return [str(t).strip() for t in raw if str(t).strip()]
+ if isinstance(raw, str):
+ txt = raw.strip()
+ if not txt:
+ return []
+ if txt.startswith("[") and txt.endswith("]"):
+ try:
+ import json as _json
+ parsed = _json.loads(txt)
+ if isinstance(parsed, list):
+ return [str(t).strip() for t in parsed if str(t).strip()]
+ except Exception:
+ pass
+ return [s.strip() for s in txt.split(',') if s.strip()]
+ return []
+
+ def _meta_from_row(row_obj: Any) -> dict[str, Any]:
+ meta = {
+ "mana": "",
+ "rarity": "",
+ "role": "",
+ "tags": [],
+ "hover_simple": True,
+ }
+ if row_obj is None:
+ meta["role"] = _clean(used_role or "")
+ return meta
+
+ def _pull(*keys: str) -> Any:
+ for key in keys:
+ try:
+ if isinstance(row_obj, dict):
+ val = row_obj.get(key)
+ elif hasattr(row_obj, "get"):
+ val = row_obj.get(key)
+ else:
+ val = getattr(row_obj, key, None)
+ except Exception:
+ val = None
+ if val not in (None, ""):
+ if isinstance(val, float) and val != val:
+ continue
+ return val
+ return None
+
+ meta["mana"] = _clean(_pull("mana_cost", "manaCost", "mana", "manaValue", "cmc", "mv"))
+ meta["rarity"] = _clean(_pull("rarity"))
+ role_val = _pull("role", "primaryRole", "subRole")
+ if not role_val:
+ role_val = used_role or ""
+ meta["role"] = _clean(role_val)
+ tags_val = _pull("themeTags", "_ltags", "tags")
+ meta_tags = _normalize_tags(tags_val)
+ meta["tags"] = meta_tags
+ meta["hover_simple"] = not (meta["mana"] or meta["rarity"] or (meta_tags and len(meta_tags) > 0))
+ return meta
+
+ def _build_meta_map(df_obj) -> dict[str, dict[str, Any]]:
+ mapping: dict[str, dict[str, Any]] = {}
+ try:
+ if df_obj is None or not hasattr(df_obj, "iterrows"):
+ return mapping
+ for _, row in df_obj.iterrows():
+ try:
+ nm_val = str(row.get("name") or "").strip()
+ except Exception:
+ nm_val = ""
+ if not nm_val:
+ continue
+ key = nm_val.lower()
+ if key in mapping:
+ continue
+ mapping[key] = _meta_from_row(row)
+ except Exception:
+ return mapping
+ return mapping
+
+ def _sampler(seq: list[str], limit: int) -> list[str]:
+ if limit <= 0:
+ return []
+ if len(seq) <= limit:
+ return list(seq)
+ rng = getattr(b, "rng", None)
+ try:
+ if rng is not None:
+ return rng.sample(seq, limit) if len(seq) >= limit else list(seq)
+ import random as _rnd
+ return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq)
+ except Exception:
+ return list(seq[:limit])
+ 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)
+ cached = None
+ if used_role != 'land' and not refresh_requested:
+ cached = _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 or refresh requested for per-call randomness
+ if used_role != 'land' and not refresh_requested:
+ 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)
+ 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"])
+ 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
+ row_meta = _build_meta_map(pool)
+ # 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
+ meta = row_meta.get(nm_lc) or _meta_from_row(None)
+ items.append({
+ 'name': display_map.get(nm_lc, orig),
+ 'name_lower': nm_lc,
+ 'owned': is_owned,
+ 'tags': meta.get('tags') or [],
+ 'role': meta.get('role', ''),
+ 'mana': meta.get('mana', ''),
+ 'rarity': meta.get('rarity', ''),
+ 'hover_simple': bool(meta.get('hover_simple', True)),
+ })
+ 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))
+ iteration_order = lower_pool
+ if refresh_requested and len(lower_pool) > 12:
+ window_size = min(len(lower_pool), 30)
+ window = lower_pool[:window_size]
+ sampled = _sampler(window, min(window_size, 12))
+ seen_sampled = set(sampled)
+ iteration_order = sampled + [nm for nm in lower_pool if nm not in seen_sampled]
+ for nm_l in iteration_order:
+ 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
+ meta = row_meta.get(nm_l) or _meta_from_row(None)
+ items.append({
+ "name": display_map.get(nm_l, nm_l),
+ "name_lower": nm_l,
+ "owned": is_owned,
+ "tags": meta.get("tags") or [],
+ "role": meta.get("role", ""),
+ "mana": meta.get("mana", ""),
+ "rarity": meta.get("rarity", ""),
+ "hover_simple": bool(meta.get("hover_simple", True)),
+ })
+ 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]))
+ if refresh_requested and len(candidates) > 1:
+ name_sequence = [nm for nm, _score in candidates]
+ sampled_names = _sampler(name_sequence, min(len(name_sequence), 10))
+ sampled_set = set(sampled_names)
+ reordered: list[tuple[str, int]] = []
+ for nm in sampled_names:
+ for cand_nm, cand_score in candidates:
+ if cand_nm == nm:
+ reordered.append((cand_nm, cand_score))
+ break
+ for cand_nm, cand_score in candidates:
+ if cand_nm not in sampled_set:
+ reordered.append((cand_nm, cand_score))
+ candidates = reordered
+ 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 []),
+ "role": "",
+ "mana": "",
+ "rarity": "",
+ "hover_simple": True,
+ })
+ if len(items) >= 10:
+ break
+ return _render_and_cache(items)
+ except Exception as e:
+ return HTMLResponse(f'
No alternatives: {e}
')
diff --git a/code/web/routes/build_compliance.py b/code/web/routes/build_compliance.py
new file mode 100644
index 0000000..4d893cb
--- /dev/null
+++ b/code/web/routes/build_compliance.py
@@ -0,0 +1,653 @@
+"""Build Compliance and Card Replacement Routes
+
+Phase 5 extraction from build.py:
+- POST /build/replace - Inline card replacement with undo tracking
+- POST /build/replace/undo - Undo last replacement
+- GET /build/compare - Batch build comparison stub
+- GET /build/compliance - Bracket compliance panel
+- POST /build/enforce/apply - Apply bracket enforcement
+- GET /build/enforcement - Full-page enforcement review
+
+This module handles card replacement, bracket compliance checking, and enforcement.
+"""
+from __future__ import annotations
+
+from fastapi import APIRouter, Request, Form, Query
+from fastapi.responses import HTMLResponse, JSONResponse
+from typing import Any
+import json
+from ..app import templates
+from ..services.tasks import get_session, new_sid
+from ..services.build_utils import (
+ step5_ctx_from_result,
+ step5_error_ctx,
+ step5_empty_ctx,
+ owned_set as owned_set_helper,
+)
+from ..services import orchestrator as orch
+from deck_builder.builder import DeckBuilder
+from html import escape as _esc
+from urllib.parse import quote_plus
+
+
+router = APIRouter(prefix="/build")
+
+
+def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
+ if not payload or response is None:
+ return
+ try:
+ existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
+ except Exception:
+ existing = None
+ try:
+ if existing:
+ try:
+ data = json.loads(existing)
+ except Exception:
+ data = {}
+ if isinstance(data, dict):
+ data.update(payload)
+ response.headers["HX-Trigger"] = json.dumps(data)
+ return
+ response.headers["HX-Trigger"] = json.dumps(payload)
+ except Exception:
+ try:
+ response.headers["HX-Trigger"] = json.dumps(payload)
+ except Exception:
+ pass
+
+
+@router.post("/replace", response_class=HTMLResponse)
+async def build_replace(request: Request, old: str = Form(...), new: str = Form(...), owned_only: str = Form("0")) -> 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()
+ owned_only_flag = str(owned_only or "").strip().lower()
+ owned_only_int = 1 if owned_only_flag in {"1", "true", "yes", "on"} else 0
+
+ # 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())
+ refresh = (
+ ''
+ )
+ html = (
+ '
'
+ f'
Replaced {o_disp} with {new_key}.
'
+ '
Compliance panel will refresh.
'
+ '
'
+ ''
+ '
'
+ + 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.
'
+ '
'
+ ''
+ ''
+ ''
+ '
'
+ '
'
+ )
+ 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)
+ except Exception:
+ comp = None
+ try:
+ if comp:
+ from ..services import orchestrator as orch
+ comp = orch._attach_enforcement_plan(b, comp)
+ 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")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
+ 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()
+ 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')
+ 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')
+ 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")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
+ 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(request, "build/_step5.html", page_ctx)
+ resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
+ 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)
+ except Exception:
+ comp = None
+ try:
+ if comp:
+ from ..services import orchestrator as orch
+ comp = orch._attach_enforcement_plan(b, comp)
+ except Exception:
+ pass
+ try:
+ summary_token = int(sess.get("step5_summary_token", 0))
+ except Exception:
+ summary_token = 0
+ ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
+ resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
+ resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
+ return resp
diff --git a/code/web/routes/build_include_exclude.py b/code/web/routes/build_include_exclude.py
new file mode 100644
index 0000000..caa4ed8
--- /dev/null
+++ b/code/web/routes/build_include_exclude.py
@@ -0,0 +1,216 @@
+"""
+Include/Exclude card list management routes.
+
+Handles user-defined include (must-have) and exclude (forbidden) card lists
+for deck building, including the card toggle endpoint and summary rendering.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from fastapi import APIRouter, Request, Form
+from fastapi.responses import HTMLResponse, JSONResponse
+
+from ..app import ALLOW_MUST_HAVES, templates
+from ..services.build_utils import step5_base_ctx
+from ..services.tasks import get_session, new_sid
+from ..services.telemetry import log_include_exclude_toggle
+from .build import _merge_hx_trigger
+
+
+router = APIRouter()
+
+
+def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
+ """
+ Extract include/exclude card lists and enforcement settings from session.
+
+ Args:
+ sess: Session dictionary containing user state
+
+ Returns:
+ Tuple of (state_dict, includes_list, excludes_list) where:
+ - state_dict contains enforcement mode, fuzzy matching, and list contents
+ - includes_list contains card names to include
+ - excludes_list contains card names to exclude
+ """
+ includes = list(sess.get("include_cards") or [])
+ excludes = list(sess.get("exclude_cards") or [])
+ state = {
+ "includes": includes,
+ "excludes": excludes,
+ "enforcement_mode": (sess.get("enforcement_mode") or "warn"),
+ "allow_illegal": bool(sess.get("allow_illegal")),
+ "fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
+ }
+ return state, includes, excludes
+
+
+def _render_include_exclude_summary(
+ request: Request,
+ sess: dict,
+ sid: str,
+ *,
+ state: dict[str, Any] | None = None,
+ includes: list[str] | None = None,
+ excludes: list[str] | None = None,
+) -> HTMLResponse:
+ """
+ Render the include/exclude summary template.
+
+ Args:
+ request: FastAPI request object
+ sess: Session dictionary
+ sid: Session ID for cookie
+ state: Optional pre-computed state dict
+ includes: Optional pre-computed includes list
+ excludes: Optional pre-computed excludes list
+
+ Returns:
+ HTMLResponse with rendered include/exclude summary
+ """
+ ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
+ if state is None or includes is None or excludes is None:
+ state, includes, excludes = _must_have_state(sess)
+ ctx["must_have_state"] = state
+ ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None
+ ctx["include_cards"] = includes
+ ctx["exclude_cards"] = excludes
+ response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
+ response.set_cookie("sid", sid, httponly=True, samesite="lax")
+ return response
+
+
+@router.post("/must-haves/toggle", response_class=HTMLResponse)
+async def toggle_must_haves(
+ request: Request,
+ card_name: str = Form(...),
+ list_type: str = Form(...),
+ enabled: str = Form("1"),
+):
+ """
+ Toggle a card's inclusion in the include or exclude list.
+
+ This endpoint handles:
+ - Adding/removing cards from include (must-have) lists
+ - Adding/removing cards from exclude (forbidden) lists
+ - Mutual exclusivity (card can't be in both lists)
+ - List size limits (10 includes, 15 excludes)
+ - Case-insensitive duplicate detection
+
+ Args:
+ request: FastAPI request object
+ card_name: Name of the card to toggle
+ list_type: Either "include" or "exclude"
+ enabled: "1"/"true"/"yes"/"on" to add, anything else to remove
+
+ Returns:
+ HTMLResponse with updated include/exclude summary, or
+ JSONResponse with error if validation fails
+
+ HX-Trigger Events:
+ must-haves:toggle: Payload with card, list, enabled status, and counts
+ """
+ if not ALLOW_MUST_HAVES:
+ return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403)
+
+ name = str(card_name or "").strip()
+ if not name:
+ return JSONResponse({"error": "Card name is required"}, status_code=400)
+
+ list_key = str(list_type or "").strip().lower()
+ if list_key not in {"include", "exclude"}:
+ return JSONResponse({"error": "Unsupported toggle type"}, status_code=400)
+
+ enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"}
+
+ sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
+ if not sid:
+ sid = new_sid()
+ sess = get_session(sid)
+
+ includes = list(sess.get("include_cards") or [])
+ excludes = list(sess.get("exclude_cards") or [])
+ include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()}
+ exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes if str(v).strip()}
+ key = name.lower()
+ display_name = include_lookup.get(key) or exclude_lookup.get(key) or name
+
+ changed = False
+ include_limit = 10
+ exclude_limit = 15
+
+ def _remove_casefold(items: list[str], item_key: str) -> list[str]:
+ """Remove items matching the given key (case-insensitive)."""
+ return [c for c in items if str(c).strip().lower() != item_key]
+
+ if list_key == "include":
+ if enabled_flag:
+ if key not in include_lookup:
+ if len(include_lookup) >= include_limit:
+ return JSONResponse({"error": f"Include limit reached ({include_limit})."}, status_code=400)
+ includes.append(name)
+ include_lookup[key] = name
+ changed = True
+ if key in exclude_lookup:
+ excludes = _remove_casefold(excludes, key)
+ exclude_lookup.pop(key, None)
+ changed = True
+ else:
+ if key in include_lookup:
+ includes = _remove_casefold(includes, key)
+ include_lookup.pop(key, None)
+ changed = True
+ else: # exclude
+ if enabled_flag:
+ if key not in exclude_lookup:
+ if len(exclude_lookup) >= exclude_limit:
+ return JSONResponse({"error": f"Exclude limit reached ({exclude_limit})."}, status_code=400)
+ excludes.append(name)
+ exclude_lookup[key] = name
+ changed = True
+ if key in include_lookup:
+ includes = _remove_casefold(includes, key)
+ include_lookup.pop(key, None)
+ changed = True
+ else:
+ if key in exclude_lookup:
+ excludes = _remove_casefold(excludes, key)
+ exclude_lookup.pop(key, None)
+ changed = True
+
+ if changed:
+ sess["include_cards"] = includes
+ sess["exclude_cards"] = excludes
+ if "include_exclude_diagnostics" in sess:
+ try:
+ del sess["include_exclude_diagnostics"]
+ except Exception:
+ pass
+
+ response = _render_include_exclude_summary(request, sess, sid)
+
+ try:
+ log_include_exclude_toggle(
+ request,
+ card_name=display_name,
+ action=list_key,
+ enabled=enabled_flag,
+ include_count=len(includes),
+ exclude_count=len(excludes),
+ )
+ except Exception:
+ pass
+
+ trigger_payload = {
+ "card": display_name,
+ "list": list_key,
+ "enabled": enabled_flag,
+ "include_count": len(includes),
+ "exclude_count": len(excludes),
+ }
+ try:
+ _merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
+ except Exception:
+ pass
+ return response
diff --git a/code/web/routes/build_multicopy.py b/code/web/routes/build_multicopy.py
new file mode 100644
index 0000000..ee1eb16
--- /dev/null
+++ b/code/web/routes/build_multicopy.py
@@ -0,0 +1,349 @@
+"""Multi-copy archetype routes for deck building.
+
+Handles multi-copy package detection, selection, and integration with the deck builder.
+Multi-copy archetypes allow multiple copies of specific cards (e.g., Hare Apparent, Dragon's Approach).
+
+Routes:
+ GET /multicopy/check - Check if commander/tags suggest multi-copy archetype
+ POST /multicopy/save - Save or skip multi-copy selection
+ GET /new/multicopy - Get multi-copy suggestions for New Deck modal (inline)
+
+Created: 2026-02-20
+Roadmap: R9 M1 Phase 2
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Request, Form, Query
+from fastapi.responses import HTMLResponse
+from html import escape as _esc
+
+from deck_builder.builder import DeckBuilder
+from deck_builder import builder_utils as bu, builder_constants as bc
+from ..app import templates
+from ..services.tasks import get_session, new_sid
+from ..services import orchestrator as orch
+from ..services.build_utils import owned_names as owned_names_helper
+
+router = APIRouter()
+
+
+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.
+
+ Args:
+ sess: Session dictionary containing build state
+ """
+ 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")),
+ swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
+ )
+ 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("/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.
+
+ Args:
+ request: FastAPI request object
+
+ Returns:
+ HTMLResponse with multi-copy modal or empty string
+ """
+ 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.
+
+ Args:
+ request: FastAPI request object
+ choice_id: Multi-copy archetype ID (e.g., 'hare_apparent')
+ count: Number of copies to include
+ thrumming: Whether to include Thrumming Stone
+ skip: Whether to skip multi-copy for this build
+
+ Returns:
+ HTMLResponse with confirmation chip (OOB swap)
+ """
+ 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 = (
+ '