"""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 = ( '