from __future__ import annotations from fastapi import APIRouter, Request, Form, Query, BackgroundTasks from fastapi.responses import HTMLResponse, JSONResponse from typing import Any, Dict, Iterable import json from ..app import ( ALLOW_MUST_HAVES, ENABLE_CUSTOM_THEMES, SHOW_MUST_HAVE_BUTTONS, USER_THEME_LIMIT, DEFAULT_THEME_MATCH_MODE, _sanitize_theme, ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS, WEB_IDEALS_UI, ENABLE_BATCH_BUILD, ) from ..services.build_utils import ( step5_base_ctx, step5_ctx_from_result, step5_error_ctx, step5_empty_ctx, start_ctx_from_session, owned_set as owned_set_helper, builder_present_names, builder_display_map, commander_hover_context, ) from ..app import templates from deck_builder import builder_constants as bc from ..services import orchestrator as orch from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore from ..services.build_utils import owned_names as owned_names_helper from ..services.tasks import get_session, new_sid from html import escape as _esc from deck_builder.builder import DeckBuilder from deck_builder import builder_utils as bu from ..services.combo_utils import detect_all as _detect_all from ..services import custom_theme_manager as theme_mgr from path_util import csv_dir as _csv_dir from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached from ..services.telemetry import ( log_commander_create_deck, log_partner_suggestion_selected, log_include_exclude_toggle, ) from ..services.partner_suggestions import get_partner_suggestions from urllib.parse import urlparse, quote_plus from commander_exclusions import lookup_commander_detail from ..services.commander_catalog_loader import ( load_commander_catalog, find_commander_record, CommanderRecord, normalized_restricted_labels, shared_restricted_partner_label, ) from deck_builder.background_loader import load_background_cards from deck_builder.partner_selection import apply_partner_inputs from exceptions import CommanderPartnerError from code.logging_util import get_logger LOGGER = get_logger(__name__) # Cache for available card names used by validation endpoints _AVAILABLE_CARDS_CACHE: set[str] | None = None _AVAILABLE_CARDS_NORM_SET: set[str] | None = None _AVAILABLE_CARDS_NORM_MAP: dict[str, str] | None = None def _available_cards() -> set[str]: """Fast load of available card names using the csv module (no pandas). Reads only once and caches results in memory. """ global _AVAILABLE_CARDS_CACHE if _AVAILABLE_CARDS_CACHE is not None: return _AVAILABLE_CARDS_CACHE try: import csv path = f"{_csv_dir()}/cards.csv" with open(path, 'r', encoding='utf-8', newline='') as f: reader = csv.DictReader(f) fields = reader.fieldnames or [] name_col = None for col in ['name', 'Name', 'card_name', 'CardName']: if col in fields: name_col = col break if name_col is None and fields: # Heuristic: pick first field containing 'name' for col in fields: if 'name' in col.lower(): name_col = col break if name_col is None: raise ValueError(f"No name-like column found in {path}: {fields}") names: set[str] = set() for row in reader: try: v = row.get(name_col) if v: names.add(str(v)) except Exception: continue _AVAILABLE_CARDS_CACHE = names return _AVAILABLE_CARDS_CACHE except Exception: _AVAILABLE_CARDS_CACHE = set() return _AVAILABLE_CARDS_CACHE def _available_cards_normalized() -> tuple[set[str], dict[str, str]]: """Return cached normalized card names and mapping to originals.""" global _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP if _AVAILABLE_CARDS_NORM_SET is not None and _AVAILABLE_CARDS_NORM_MAP is not None: return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP # Build from available cards set names = _available_cards() try: from deck_builder.include_exclude_utils import normalize_punctuation except Exception: # Fallback: identity normalization def normalize_punctuation(x: str) -> str: # type: ignore return str(x).strip().casefold() norm_map: dict[str, str] = {} for name in names: try: n = normalize_punctuation(name) if n not in norm_map: norm_map[n] = name except Exception: continue _AVAILABLE_CARDS_NORM_MAP = norm_map _AVAILABLE_CARDS_NORM_SET = set(norm_map.keys()) return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP def warm_validation_name_cache() -> None: """Pre-populate the available-cards caches to avoid first-call latency.""" try: _ = _available_cards() _ = _available_cards_normalized() except Exception: # Best-effort warmup; proceed silently on failure pass 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 def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str: text = message or "Deck summary will appear after the build completes." return ( f'
' f'
{_esc(text)}
' '
' ) def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]: 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: 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 def _current_builder_summary(sess: dict) -> Any | None: try: ctx = sess.get("build_ctx") or {} builder = ctx.get("builder") if isinstance(ctx, dict) else None if builder is None: return None summary_fn = getattr(builder, "build_deck_summary", None) if callable(summary_fn): return summary_fn() except Exception: return None return None _COLOR_NAME_MAP = { "W": "White", "U": "Blue", "B": "Black", "R": "Red", "G": "Green", "C": "Colorless", } _WUBRG_ORDER = ("W", "U", "B", "R", "G", "C") _PARTNER_MODE_LABELS = { "partner": "Partner", "partner_restricted": "Partner (Restricted)", "partner_with": "Partner With", "background": "Choose a Background", "doctor_companion": "Doctor & Companion", } def _color_code(identity: Iterable[str]) -> str: colors = [str(c).strip().upper() for c in identity if str(c).strip()] if not colors: return "C" ordered: list[str] = [c for c in _WUBRG_ORDER if c in colors] for color in colors: if color not in ordered: ordered.append(color) return "".join(ordered) or "C" def _format_color_label(identity: Iterable[str]) -> str: code = _color_code(identity) if code == "C": return "Colorless (C)" names = [_COLOR_NAME_MAP.get(ch, ch) for ch in code] return " / ".join(names) + f" ({code})" def _partner_mode_label(mode: str | None) -> str: if not mode: return "Partner Mechanics" return _PARTNER_MODE_LABELS.get(mode, mode.title()) def _scryfall_image_url(card_name: str, version: str = "normal") -> str | None: name = str(card_name or "").strip() if not name: return None return f"https://api.scryfall.com/cards/named?fuzzy={quote_plus(name)}&format=image&version={version}" def _scryfall_page_url(card_name: str) -> str | None: name = str(card_name or "").strip() if not name: return None return f"https://scryfall.com/search?q={quote_plus(name)}" def _secondary_role_label(mode: str | None, secondary_name: str | None) -> str | None: if not mode: return None mode_lower = mode.lower() if mode_lower == "background": return "Background" if mode_lower == "partner_with": return "Partner With" if mode_lower == "doctor_companion": record = find_commander_record(secondary_name or "") if secondary_name else None if record and getattr(record, "is_doctor", False): return "Doctor" if record and getattr(record, "is_doctors_companion", False): return "Doctor's Companion" return "Doctor pairing" return "Partner commander" def _combined_to_payload(combined: Any) -> dict[str, Any]: color_identity = tuple(getattr(combined, "color_identity", ()) or ()) warnings = list(getattr(combined, "warnings", []) or []) mode_obj = getattr(combined, "partner_mode", None) mode_value = getattr(mode_obj, "value", None) if mode_obj is not None else None secondary = getattr(combined, "secondary_name", None) secondary_image = _scryfall_image_url(secondary) secondary_url = _scryfall_page_url(secondary) secondary_role = _secondary_role_label(mode_value, secondary) return { "primary_name": getattr(combined, "primary_name", None), "secondary_name": secondary, "partner_mode": mode_value, "partner_mode_label": _partner_mode_label(mode_value), "color_identity": list(color_identity), "color_code": _color_code(color_identity), "color_label": _format_color_label(color_identity), "theme_tags": list(getattr(combined, "theme_tags", []) or []), "warnings": warnings, "secondary_image_url": secondary_image, "secondary_scryfall_url": secondary_url, "secondary_role_label": secondary_role, } def _build_partner_options(primary: CommanderRecord | None) -> tuple[list[dict[str, Any]], str | None]: if not ENABLE_PARTNER_MECHANICS: return [], None try: catalog = load_commander_catalog() except Exception: return [], None if primary is None: return [], None primary_name = primary.display_name.casefold() primary_partner_targets = {target.casefold() for target in (primary.partner_with or ())} primary_is_partner = bool(primary.is_partner or primary_partner_targets) primary_restricted_labels = normalized_restricted_labels(primary) primary_is_doctor = bool(primary.is_doctor) primary_is_companion = bool(primary.is_doctors_companion) variant: str | None = None if primary_is_doctor or primary_is_companion: variant = "doctor_companion" elif primary_is_partner: variant = "partner" options: list[dict[str, Any]] = [] if variant is None: return [], None for record in catalog.entries: if record.display_name.casefold() == primary_name: continue pairing_mode: str | None = None role_label: str | None = None restriction_label: str | None = None record_name_cf = record.display_name.casefold() is_direct_pair = bool(primary_partner_targets and record_name_cf in primary_partner_targets) if variant == "doctor_companion": if is_direct_pair: pairing_mode = "partner_with" role_label = "Partner With" elif primary_is_doctor and record.is_doctors_companion: pairing_mode = "doctor_companion" role_label = "Doctor's Companion" elif primary_is_companion and record.is_doctor: pairing_mode = "doctor_companion" role_label = "Doctor" else: if not record.is_partner or record.is_background: continue if primary_partner_targets: if not is_direct_pair: continue pairing_mode = "partner_with" role_label = "Partner With" elif primary_restricted_labels: restriction = shared_restricted_partner_label(primary, record) if not restriction: continue pairing_mode = "partner_restricted" restriction_label = restriction else: if record.partner_with: continue if not getattr(record, "has_plain_partner", False): continue if record.is_doctors_companion: continue pairing_mode = "partner" if not pairing_mode: continue options.append( { "name": record.display_name, "color_code": _color_code(record.color_identity), "color_label": _format_color_label(record.color_identity), "partner_with": list(record.partner_with or ()), "pairing_mode": pairing_mode, "role_label": role_label, "restriction_label": restriction_label, "mode_label": _partner_mode_label(pairing_mode), "image_url": _scryfall_image_url(record.display_name), "scryfall_url": _scryfall_page_url(record.display_name), } ) options.sort(key=lambda item: item["name"].casefold()) return options, variant def _build_background_options() -> list[dict[str, Any]]: if not ENABLE_PARTNER_MECHANICS: return [] options: list[dict[str, Any]] = [] try: catalog = load_background_cards() except FileNotFoundError as exc: LOGGER.warning("background_cards_missing fallback_to_commander_catalog", extra={"error": str(exc)}) catalog = None except Exception as exc: # pragma: no cover - unexpected loader failure LOGGER.warning("background_cards_failed fallback_to_commander_catalog", exc_info=exc) catalog = None if catalog and getattr(catalog, "entries", None): seen: set[str] = set() for card in catalog.entries: name_key = card.display_name.casefold() if name_key in seen: continue seen.add(name_key) options.append( { "name": card.display_name, "color_code": _color_code(card.color_identity), "color_label": _format_color_label(card.color_identity), "image_url": _scryfall_image_url(card.display_name), "scryfall_url": _scryfall_page_url(card.display_name), "role_label": "Background", } ) if options: options.sort(key=lambda item: item["name"].casefold()) return options fallback_options = _background_options_from_commander_catalog() if fallback_options: return fallback_options return options def _background_options_from_commander_catalog() -> list[dict[str, Any]]: try: catalog = load_commander_catalog() except Exception as exc: # pragma: no cover - catalog load issues handled elsewhere LOGGER.warning("commander_catalog_background_fallback_failed", exc_info=exc) return [] seen: set[str] = set() options: list[dict[str, Any]] = [] for record in getattr(catalog, "entries", ()): # type: ignore[attr-defined] if not getattr(record, "is_background", False): continue name = getattr(record, "display_name", None) if not name: continue key = str(name).casefold() if key in seen: continue seen.add(key) color_identity = getattr(record, "color_identity", tuple()) options.append( { "name": name, "color_code": _color_code(color_identity), "color_label": _format_color_label(color_identity), "image_url": _scryfall_image_url(name), "scryfall_url": _scryfall_page_url(name), "role_label": "Background", } ) options.sort(key=lambda item: item["name"].casefold()) return options def _partner_ui_context( commander_name: str, *, partner_enabled: bool, secondary_selection: str | None, background_selection: str | None, combined_preview: dict[str, Any] | None, warnings: Iterable[str] | None, partner_error: str | None, auto_note: str | None, auto_assigned: bool | None = None, auto_prefill_allowed: bool = True, ) -> dict[str, Any]: record = find_commander_record(commander_name) partner_options, partner_variant = _build_partner_options(record) supports_backgrounds = bool(record.supports_backgrounds) if record else False background_options = _build_background_options() if supports_backgrounds else [] selected_secondary = (secondary_selection or "").strip() selected_background = (background_selection or "").strip() warnings_list = list(warnings or []) preview_payload: dict[str, Any] | None = combined_preview if isinstance(combined_preview, dict) else None preview_error: str | None = None auto_prefill_applied = False auto_default_name: str | None = None auto_note_value = auto_note if ( ENABLE_PARTNER_MECHANICS and partner_variant == "partner" and record and record.partner_with and not selected_secondary and not selected_background and auto_prefill_allowed ): target_names = [name.strip() for name in record.partner_with if str(name).strip()] for target in target_names: for option in partner_options: if option["name"].casefold() == target.casefold(): selected_secondary = option["name"] auto_default_name = option["name"] auto_prefill_applied = True if not auto_note_value: auto_note_value = f"Automatically paired with {option['name']} (Partner With)." break if auto_prefill_applied: break partner_active = bool((selected_secondary or selected_background) and ENABLE_PARTNER_MECHANICS) partner_capable = bool(ENABLE_PARTNER_MECHANICS and (partner_options or background_options)) placeholder = "Select a partner" select_label = "Partner commander" role_hint: str | None = None if partner_variant == "doctor_companion" and record: has_partner_with_option = any(option.get("pairing_mode") == "partner_with" for option in partner_options) if record.is_doctor: if has_partner_with_option: placeholder = "Select a companion or Partner With match" select_label = "Companion or Partner" role_hint = "Choose a Doctor's Companion or Partner With match for this Doctor." else: placeholder = "Select a companion" select_label = "Companion" role_hint = "Choose a Doctor's Companion to pair with this Doctor." elif record.is_doctors_companion: if has_partner_with_option: placeholder = "Select a Doctor or Partner With match" select_label = "Doctor or Partner" role_hint = "Choose a Doctor or Partner With pairing for this companion." else: placeholder = "Select a Doctor" select_label = "Doctor partner" role_hint = "Choose a Doctor to accompany this companion." suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS) suggestions_visible: list[dict[str, Any]] = [] suggestions_hidden: list[dict[str, Any]] = [] suggestions_total = 0 suggestions_metadata: dict[str, Any] = {} suggestions_error: str | None = None suggestions_loaded = False if suggestions_enabled and record: try: suggestion_result = get_partner_suggestions(record.display_name) except Exception as exc: # pragma: no cover - defensive logging LOGGER.warning("partner suggestions failed", exc_info=exc) suggestion_result = None if suggestion_result is None: suggestions_error = "Partner suggestions dataset is unavailable." else: suggestions_loaded = True partner_names = [opt.get("name") for opt in (partner_options or []) if opt.get("name")] background_names = [opt.get("name") for opt in (background_options or []) if opt.get("name")] try: visible, hidden = suggestion_result.flatten(partner_names, background_names, visible_limit=3) except Exception as exc: # pragma: no cover - defensive LOGGER.warning("partner suggestions flatten failed", exc_info=exc) visible = [] hidden = [] suggestions_visible = visible suggestions_hidden = hidden suggestions_total = suggestion_result.total if isinstance(suggestion_result.metadata, dict): suggestions_metadata = dict(suggestion_result.metadata) context = { "partner_feature_available": ENABLE_PARTNER_MECHANICS, "partner_capable": partner_capable, "partner_enabled": partner_active, "selected_secondary_commander": selected_secondary, "selected_background": selected_background if supports_backgrounds else "", "partner_options": partner_options if partner_options else [], "background_options": background_options if background_options else [], "primary_partner_with": list(record.partner_with) if record else [], "primary_supports_backgrounds": supports_backgrounds, "primary_is_partner": bool(record.is_partner) if record else False, "primary_commander_display": record.display_name if record else commander_name, "partner_preview": preview_payload, "partner_warnings": warnings_list, "partner_error": partner_error, "partner_auto_note": auto_note_value, "partner_auto_assigned": bool(auto_prefill_applied or auto_assigned), "partner_auto_default": auto_default_name, "partner_select_variant": partner_variant, "partner_select_label": select_label, "partner_select_placeholder": placeholder, "partner_role_hint": role_hint, "partner_suggestions_enabled": suggestions_enabled, "partner_suggestions": suggestions_visible, "partner_suggestions_hidden": suggestions_hidden, "partner_suggestions_total": suggestions_total, "partner_suggestions_metadata": suggestions_metadata, "partner_suggestions_loaded": suggestions_loaded, "partner_suggestions_error": suggestions_error, "partner_suggestions_available": bool(suggestions_visible or suggestions_hidden), "partner_suggestions_has_hidden": bool(suggestions_hidden), "partner_suggestions_endpoint": "/api/partner/suggestions", } context["has_partner_options"] = bool(partner_options) context["has_background_options"] = bool(background_options) context["partner_hidden_value"] = "1" if partner_capable else "0" context["partner_auto_opt_out"] = not bool(auto_prefill_allowed) context["partner_prefill_available"] = bool(partner_variant == "partner" and partner_options) if preview_payload is None and ENABLE_PARTNER_MECHANICS and (selected_secondary or selected_background): try: builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) combined_obj = apply_partner_inputs( builder, primary_name=commander_name, secondary_name=selected_secondary or None, background_name=selected_background or None, feature_enabled=True, ) except CommanderPartnerError as exc: preview_error = str(exc) or "Invalid partner selection." except Exception as exc: preview_error = f"Partner preview failed: {exc}" else: if combined_obj is not None: preview_payload = _combined_to_payload(combined_obj) if combined_obj.warnings: for warn in combined_obj.warnings: if warn not in warnings_list: warnings_list.append(warn) if preview_payload: context["partner_preview"] = preview_payload preview_tags = preview_payload.get("theme_tags") if preview_tags: context["partner_theme_tags"] = list(preview_tags) if preview_error and not partner_error: context["partner_error"] = preview_error partner_error = preview_error context["partner_warnings"] = warnings_list return context def _resolve_partner_selection( commander_name: str, *, feature_enabled: bool, partner_enabled: bool, secondary_candidate: str | None, background_candidate: str | None, auto_opt_out: bool = False, selection_source: str | None = None, ) -> tuple[ str | None, dict[str, Any] | None, list[str], str | None, str | None, str | None, str | None, bool, ]: if not (feature_enabled and ENABLE_PARTNER_MECHANICS): return None, None, [], None, None, None, None, False secondary = (secondary_candidate or "").strip() background = (background_candidate or "").strip() auto_note: str | None = None auto_assigned = False selection_source_clean = (selection_source or "").strip().lower() or None record = find_commander_record(commander_name) partner_options, partner_variant = _build_partner_options(record) supports_backgrounds = bool(record and record.supports_backgrounds) background_options = _build_background_options() if supports_backgrounds else [] if not partner_enabled and not secondary and not background: return None, None, [], None, None, None, None, False if not supports_backgrounds: background = "" if not partner_options: secondary = "" if secondary and background: return "Provide either a secondary commander or a background, not both.", None, [], auto_note, secondary, background, None, False option_lookup = {opt["name"].casefold(): opt for opt in partner_options} if secondary: key = secondary.casefold() if key not in option_lookup: return "Selected partner is not valid for this commander.", None, [], auto_note, secondary, background or None, None, False if background: normalized_backgrounds = {opt["name"].casefold() for opt in background_options} if background.casefold() not in normalized_backgrounds: return "Selected background is not available.", None, [], auto_note, secondary or None, background, None, False if not secondary and not background and not auto_opt_out and partner_variant == "partner" and record and record.partner_with: target_names = [name.strip() for name in record.partner_with if str(name).strip()] for target in target_names: opt = option_lookup.get(target.casefold()) if opt: secondary = opt["name"] auto_note = f"Automatically paired with {secondary} (Partner With)." auto_assigned = True break if not secondary and not background: return None, None, [], auto_note, None, None, None, auto_assigned builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) try: combined = apply_partner_inputs( builder, primary_name=commander_name, secondary_name=secondary or None, background_name=background or None, feature_enabled=True, selection_source=selection_source_clean, ) except CommanderPartnerError as exc: message = str(exc) or "Invalid partner selection." return message, None, [], auto_note, secondary or None, background or None, None, auto_assigned except Exception as exc: return f"Partner selection failed: {exc}", None, [], auto_note, secondary or None, background or None, None, auto_assigned if combined is None: return "Unable to resolve partner selection.", None, [], auto_note, secondary or None, background or None, None, auto_assigned payload = _combined_to_payload(combined) warnings = payload.get("warnings", []) or [] mode = payload.get("partner_mode") if mode == "background": resolved_background = payload.get("secondary_name") return None, payload, warnings, auto_note, None, resolved_background, mode, auto_assigned return None, payload, warnings, auto_note, payload.get("secondary_name"), None, mode, auto_assigned router = APIRouter(prefix="/build") @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"), ): 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]: 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 # Alternatives cache moved to services/alts_utils @router.post("/partner/preview", response_class=JSONResponse) async def build_partner_preview( request: Request, commander: str = Form(...), 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), scope: str | None = Form(None), selection_source: str | None = Form(None), ) -> JSONResponse: partner_feature_enabled = ENABLE_PARTNER_MECHANICS raw_partner_enabled = (partner_enabled or "").strip().lower() partner_flag = partner_feature_enabled and raw_partner_enabled in {"1", "true", "on", "yes"} auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"} selection_source_value = (selection_source or "").strip().lower() or None try: ( 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=selection_source_value, ) except Exception as exc: # pragma: no cover - defensive return JSONResponse( { "ok": False, "error": f"Partner preview failed: {exc}", "scope": scope or "", } ) partner_ctx = _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, ) preview_payload = partner_ctx.get("partner_preview") theme_tags = partner_ctx.get("partner_theme_tags") or [] warnings_list = partner_ctx.get("partner_warnings") or partner_warnings or [] response = { "ok": True, "scope": scope or "", "preview": preview_payload, "theme_tags": theme_tags, "warnings": warnings_list, "auto_note": partner_auto_note, "resolved_secondary": resolved_secondary, "resolved_background": resolved_background, "partner_mode": partner_mode, "auto_assigned": bool(partner_auto_assigned_flag), } if partner_error: response["error"] = partner_error try: log_partner_suggestion_selected( request, commander=commander, scope=scope, partner_enabled=partner_flag, auto_opt_out=auto_opt_out_flag, auto_assigned=bool(partner_auto_assigned_flag), selection_source=selection_source_value, secondary_candidate=secondary_commander, background_candidate=background, resolved_secondary=resolved_secondary, resolved_background=resolved_background, partner_mode=partner_mode, has_preview=bool(preview_payload), warnings=warnings_list, error=response.get("error"), ) except Exception: # pragma: no cover - telemetry should not break responses pass return JSONResponse(response) def _custom_theme_context( request: Request, sess: dict, *, message: str | None = None, level: str = "info", ) -> dict[str, Any]: """Assemble the Additional Themes section context for the modal.""" if not ENABLE_CUSTOM_THEMES: return { "request": request, "theme_state": None, "theme_message": message, "theme_message_level": level, "theme_limit": USER_THEME_LIMIT, "enable_custom_themes": False, } theme_mgr.set_limit(sess, USER_THEME_LIMIT) state = theme_mgr.get_view_state(sess, default_mode=DEFAULT_THEME_MATCH_MODE) return { "request": request, "theme_state": state, "theme_message": message, "theme_message_level": level, "theme_limit": USER_THEME_LIMIT, "enable_custom_themes": ENABLE_CUSTOM_THEMES, } _INVALID_THEME_MESSAGE = ( "Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores." ) def _rebuild_ctx_with_multicopy(sess: dict) -> None: """Rebuild the staged context so Multi-Copy runs first, avoiding overfill. This ensures the added cards are accounted for before lands and later phases, which keeps totals near targets and shows the multi-copy additions ahead of basics. """ try: if not sess or not sess.get("commander"): return # Build fresh ctx with the same options, threading multi_copy explicitly opts = orch.bracket_options() default_bracket = (opts[0]["level"] if opts else 1) bracket_val = sess.get("bracket") try: safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket except Exception: safe_bracket = int(default_bracket) ideals_val = sess.get("ideals") or orch.ideal_defaults() use_owned = bool(sess.get("use_owned_only")) prefer = bool(sess.get("prefer_owned")) owned_names = owned_names_helper() if (use_owned or prefer) else None locks = list(sess.get("locks", [])) sess["build_ctx"] = orch.start_build_ctx( commander=sess.get("commander"), tags=sess.get("tags", []), bracket=safe_bracket, ideals=ideals_val, tag_mode=sess.get("tag_mode", "AND"), use_owned_only=use_owned, prefer_owned=prefer, owned_names=owned_names, locks=locks, custom_export_base=sess.get("custom_export_base"), multi_copy=sess.get("multi_copy"), prefer_combos=bool(sess.get("prefer_combos")), combo_target_count=int(sess.get("combo_target_count", 2)), combo_balance=str(sess.get("combo_balance", "mix")), 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("/", response_class=HTMLResponse) async def build_index(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) # Seed commander from query string when arriving from commander browser q_commander = None try: q_commander = request.query_params.get("commander") if q_commander: # Persist a human-friendly commander name into session for the wizard sess["commander"] = str(q_commander) except Exception: pass return_url = None try: raw_return = request.query_params.get("return") if raw_return: parsed = urlparse(raw_return) if not parsed.scheme and not parsed.netloc and parsed.path: safe_path = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}" safe_return = safe_path if parsed.query: safe_return += f"?{parsed.query}" if parsed.fragment: safe_return += f"#{parsed.fragment}" return_url = safe_return except Exception: return_url = None if q_commander: try: log_commander_create_deck( request, commander=str(q_commander), return_url=return_url, ) except Exception: pass # Determine last step (fallback heuristics if not set) last_step = sess.get("last_step") if not last_step: if sess.get("build_ctx"): last_step = 5 elif sess.get("ideals"): last_step = 4 elif sess.get("bracket"): last_step = 3 elif sess.get("commander"): last_step = 2 else: last_step = 1 resp = templates.TemplateResponse( request, "build/index.html", { "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, "return_url": return_url, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp # Support /build without trailing slash @router.get("", response_class=HTMLResponse) async def build_index_alias(request: Request) -> HTMLResponse: return await build_index(request) @router.get("/multicopy/check", response_class=HTMLResponse) async def multicopy_check(request: Request) -> HTMLResponse: """If current commander/tags suggest a multi-copy archetype, render a choose-one modal. Returns empty content when not applicable to avoid flashing a modal unnecessarily. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) commander = str(sess.get("commander") or "").strip() tags = list(sess.get("tags") or []) if not commander: return HTMLResponse("") # Avoid re-prompting repeatedly for the same selection context key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()])) seen = set(sess.get("mc_seen_keys", []) or []) if key in seen: return HTMLResponse("") # Build a light DeckBuilder seeded with commander + tags (no heavy data load required) try: tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) df = tmp.load_commander_data() row = df[df["name"].astype(str) == commander] if row.empty: return HTMLResponse("") tmp._apply_commander_selection(row.iloc[0]) tmp.selected_tags = list(tags or []) try: tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None except Exception: pass # Establish color identity from the selected commander try: tmp.determine_color_identity() except Exception: pass # Detect viable archetypes results = bu.detect_viable_multi_copy_archetypes(tmp) or [] if not results: # Remember this key to avoid re-checking until tags/commander change try: seen.add(key) sess["mc_seen_keys"] = list(seen) except Exception: pass return HTMLResponse("") # Render modal template with top N (cap small for UX) items = results[:5] ctx = { "request": request, "items": items, "commander": commander, "tags": tags, } return templates.TemplateResponse("build/_multi_copy_modal.html", ctx) except Exception: return HTMLResponse("") @router.post("/multicopy/save", response_class=HTMLResponse) async def multicopy_save( request: Request, choice_id: str = Form(None), count: int = Form(None), thrumming: str | None = Form(None), skip: str | None = Form(None), ) -> HTMLResponse: """Persist user selection (or skip) for multi-copy archetype in session and close modal. Returns a tiny confirmation chip via OOB swap (optional) and removes the modal. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) commander = str(sess.get("commander") or "").strip() tags = list(sess.get("tags") or []) key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()])) # Update seen set to avoid re-prompt next load seen = set(sess.get("mc_seen_keys", []) or []) seen.add(key) sess["mc_seen_keys"] = list(seen) # Handle skip explicitly if skip and str(skip).strip() in ("1","true","on","yes"): # Clear any prior choice for this run try: if sess.get("multi_copy"): del sess["multi_copy"] if sess.get("mc_applied_key"): del sess["mc_applied_key"] except Exception: pass # Return nothing (modal will be removed client-side) # Also emit an OOB chip indicating skip chip = ( '
' 'Dismissed multi-copy suggestions' '
' ) return HTMLResponse(chip) # Persist selection when provided payload = None try: meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {}) name = meta.get("name") or str(choice_id) printed_cap = meta.get("printed_cap") # Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present if count is None: count = int(meta.get("default_count", 25)) try: count = int(count) except Exception: count = int(meta.get("default_count", 25)) if isinstance(printed_cap, int) and printed_cap > 0: count = max(1, min(printed_cap, count)) payload = { "id": str(choice_id), "name": name, "count": int(count), "thrumming": True if (thrumming and str(thrumming).strip() in ("1","true","on","yes")) else False, } sess["multi_copy"] = payload # Mark as not yet applied so the next build start/continue can account for it once try: if sess.get("mc_applied_key"): del sess["mc_applied_key"] except Exception: pass # If there's an active build context, rebuild it so Multi-Copy runs first if sess.get("build_ctx"): _rebuild_ctx_with_multicopy(sess) except Exception: payload = None # Return OOB chip summarizing the selection if payload: chip = ( '
' f'Selected multi-copy: ' f"{_esc(payload.get('name',''))} x{int(payload.get('count',0))}" f"{' + Thrumming Stone' if payload.get('thrumming') else ''}" '
' ) else: chip = ( '
' 'Saved' '
' ) return HTMLResponse(chip) # Unified "New Deck" modal (steps 1–3 condensed) @router.get("/new", response_class=HTMLResponse) async def build_new_modal(request: Request) -> HTMLResponse: """Return the New Deck modal content (for an overlay).""" sid = request.cookies.get("sid") or new_sid() 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: Clear commander and form selections for fresh start 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": { "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) # type: ignore[attr-defined] ctx["idx"] = int(target_i) - 1 ctx["last_visible_idx"] = int(target_i) - 1 except Exception: # As a fallback, restart ctx and run forward until target sess["build_ctx"] = start_ctx_from_session(sess) ctx = sess["build_ctx"] # Run forward until reaching target while True: res = orch.run_stage(ctx, rerun=False, show_skipped=False) if int(res.get("idx", 0)) >= int(target_i): break if res.get("done"): break # Finally show the target stage by running it with show_skipped True to get a view try: res = orch.run_stage(ctx, rerun=False, show_skipped=True) status = "Stage (rewound)" if not res.get("done") else "Build complete" ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={ "history": ctx.get("history", []), }) except Exception as e: sess["last_step"] = 5 ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}") resp = templates.TemplateResponse("build/_step5.html", ctx_resp) resp.set_cookie("sid", sid, httponly=True, samesite="lax") _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 = ( '
' '
' f'' '' '
' '
' ) return HTMLResponse(html) @router.post("/step5/reset-stage", response_class=HTMLResponse) async def build_step5_reset_stage(request: Request) -> HTMLResponse: """Reset current visible stage to the pre-stage snapshot (if available) without running it.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) ctx = sess.get("build_ctx") if not ctx or not ctx.get("snapshot"): return await build_step5_get(request) try: orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined] except Exception: return await build_step5_get(request) # Re-render step 5 with cleared added list base = step5_empty_ctx(request, sess, extras={ "status": "Stage reset", "i": ctx.get("idx"), "n": len(ctx.get("stages", [])), }) resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") _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.""" import logging logger = logging.getLogger(__name__) sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) from ..services.build_cache import BuildCache batch_status = BuildCache.get_batch_status(sess, batch_id) logger.info(f"[Batch Progress Poll] batch_id={batch_id}, status={batch_status}") if not batch_status: return HTMLResponse('
Batch not found. Please refresh.
') if batch_status["status"] == "completed": # All builds complete - redirect to comparison page response = HTMLResponse(f'') response.set_cookie("sid", sid, httponly=True, samesite="lax") return response # Get config to determine color count for time estimate config = BuildCache.get_batch_config(sess, batch_id) commander_name = config.get("commander", "") if config else "" # Estimate time based on color count (from testing data) time_estimate = "1-3 minutes" if commander_name and config: # Try to get commander's color identity try: from ..services import orchestrator as orch cmd_data = orch.load_commander(commander_name) if cmd_data and "colorIdentity" in cmd_data: color_count = len(cmd_data.get("colorIdentity", [])) if color_count <= 2: time_estimate = "1-3 minutes" elif color_count == 3: time_estimate = "2-4 minutes" else: # 4-5 colors time_estimate = "3-5 minutes" except Exception: pass # Default to 1-3 if we can't determine # Build still running - return progress content partial only ctx = { "request": request, "batch_id": batch_id, "build_count": batch_status["count"], "completed": batch_status["completed"], "progress_pct": batch_status["progress_pct"], "status": f"Building deck {batch_status['completed'] + 1} of {batch_status['count']}..." if batch_status['completed'] < batch_status['count'] else "Finalizing...", "has_errors": batch_status["has_errors"], "error_count": batch_status["error_count"], "time_estimate": time_estimate } response = templates.TemplateResponse("build/_batch_progress_content.html", ctx) 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 # type: ignore 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) # type: ignore[arg-type] except Exception: return {nm: nm for nm in lower_pool} # Common exclusions # in_deck already computed above def _exclude(df0): out = df0.copy() if "name" in out.columns: out["_lname"] = out["name"].astype(str).str.strip().str.lower() mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set())) out = out[mask] return out # If we have data and a recognized role, mirror the phase logic if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}): pool = df.copy() try: pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell) except Exception: # best-effort normalize pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else []) # Role-specific base filtering if used_role != "land": # Exclude lands for non-land roles if "type" in pool.columns: pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)] else: # Keep only lands if "type" in pool.columns: pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)] # Seed info to guide filtering seed_is_basic = False try: seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])}) except Exception: seed_is_basic = False if seed_is_basic: # For basics: show other basics (different colors) to allow quick swaps try: pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] except Exception: pass else: # For non-basics: prefer other non-basics try: pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] except Exception: pass # Apply mono-color misc land filters (no debug CSV dependency) try: colors = list(getattr(b, 'color_identity', []) or []) mono = len(colors) <= 1 mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])} mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])} kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])} any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])] extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])] fetch_names = set() for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): for nm in seq: fetch_names.add(nm.lower()) for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []): fetch_names.add(nm.lower()) # World Tree check needs all five colors need_all_colors = {'w','u','b','r','g'} def _illegal_world_tree(nm: str) -> bool: return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors # Text column fallback text_col = 'text' if text_col not in pool.columns: for c in pool.columns: if 'text' in c.lower(): text_col = c break def _exclude_row(row) -> bool: nm_l = str(row['name']).strip().lower() if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all: return True if mono and nm_l not in mono_keep and nm_l not in kindred_all: try: txt = str(row.get(text_col, '') or '').lower() if any(p in txt for p in any_color_phrases + extra_rainbow_terms): return True except Exception: pass if nm_l in fetch_names: return True if _illegal_world_tree(nm_l): return True return False pool = pool[~pool.apply(_exclude_row, axis=1)] except Exception: pass # Optional sub-role filtering (only if enough depth) try: subrole = str((entry or {}).get('SubRole') or '').strip().lower() if subrole: # Heuristic categories for grouping cat_map = { 'fetch': 'fetch', 'dual': 'dual', 'triple': 'triple', 'misc': 'misc', 'utility': 'misc', 'basic': 'basic' } target_cat = None for key, val in cat_map.items(): if key in subrole: target_cat = val break if target_cat and len(pool) > 25: # Lightweight textual filter using known markers def _cat_row(rname: str, rtype: str) -> str: rl = rname.lower() rt = rtype.lower() if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')): return 'fetch' if 'triple' in rt or 'three' in rt: return 'triple' if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt: # Basic-check crude return 'basic' return 'misc' try: tmp = pool.copy() tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1) sub_pool = tmp[tmp['_cat'] == target_cat] if len(sub_pool) >= 10: pool = sub_pool.drop(columns=['_cat']) except Exception: pass except Exception: pass # Exclude commander explicitly if "name" in pool.columns and commander_l: pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l] # Role-specific filter def _is_wipe(tags: list[str]) -> bool: return any(("board wipe" in t) or ("mass removal" in t) for t in tags) def _is_removal(tags: list[str]) -> bool: return any(("removal" in t) or ("spot removal" in t) for t in tags) def _is_draw(tags: list[str]) -> bool: return any(("draw" in t) or ("card advantage" in t) for t in tags) def _matches_selected(tags: list[str]) -> bool: try: sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()] if not sel: return True st = set(sel) return any(any(s in t for s in st) for t in tags) except Exception: return True if used_role == "ramp": pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))] elif used_role == "removal": pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)] elif used_role == "wipe": pool = pool[pool["_ltags"].apply(_is_wipe)] elif used_role == "card_advantage": pool = pool[pool["_ltags"].apply(_is_draw)] elif used_role == "protection": pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))] elif used_role == "creature": # Keep only creatures; bias toward selected theme tags when available if "type" in pool.columns: pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)] try: pool = pool[pool["_ltags"].apply(_matches_selected)] except Exception: pass elif used_role == "land": # Already constrained to lands; no additional tag filter needed pass # Sort by priority like the builder try: pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] except Exception: pass # Exclusions and ownership (for non-random roles this stays before slicing) pool = _exclude(pool) try: if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None): pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())}) except Exception: pass 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.
    ' '
    ' '
    ' '' '' '
    ' '
    ' f'' f'' '' '
    ' '' '
    ' '
    ' ) chip = ( f'
    ' f'Replaced {old}{new}' f'
    ' ) # Also add old to exclusions and bump version for future alt calls try: ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} ex.add(o) sess["alts_exclude"] = list(ex) sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 except Exception: pass return HTMLResponse(hint + chip) @router.post("/replace/undo", response_class=HTMLResponse) async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse: """Undo the last replace by restoring the previous lock state (best-effort).""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) last = sess.get("last_replace") or {} try: # Prefer provided args, else fallback to last recorded o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip() n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip() except Exception: o, n = "", "" locks = set(sess.get("locks", [])) changed = False if n and n in locks: locks.discard(n) changed = True if o: locks.add(o) changed = True sess["locks"] = list(locks) if sess.get("build_ctx"): try: sess["build_ctx"]["locks"] = {str(x) for x in locks} except Exception: pass # Clear last_replace after undo try: if sess.get("last_replace"): del sess["last_replace"] except Exception: pass # Return confirmation panel and OOB chip msg = 'Undid replace' if changed else 'No changes to undo' html = ( '
    ' f'
    {msg}.
    ' '
    Rerun the stage to recompute picks if needed.
    ' '
    ' '
    ' '' '' '
    ' '' '
    ' '
    ' ) chip = ( f'
    ' f'{msg}' f'
    ' ) return HTMLResponse(html + chip) @router.get("/compare") async def build_compare(runA: str, runB: str): """Stub: return empty diffs; later we can diff summary files under deck_files.""" return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []}) @router.get("/compliance", response_class=HTMLResponse) async def build_compliance_panel(request: Request) -> HTMLResponse: """Render a live Bracket compliance panel with manual enforcement controls. Computes compliance against the current builder state without exporting, attaches a non-destructive enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial. Returns empty content when no active build context exists. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) ctx = sess.get("build_ctx") or {} b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None if not b: return HTMLResponse("") # Compute compliance snapshot in-memory and attach planning preview comp = None try: if hasattr(b, 'compute_and_print_compliance'): comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass if not comp: return HTMLResponse("") # Build flagged metadata (role, owned) for visual tiles and role-aware alternatives # For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced. flagged_meta: list[dict] = [] try: cats = comp.get('categories') or {} owned_lower = owned_set_helper() lib = getattr(b, 'card_library', {}) or {} commander_l = str((sess.get('commander') or '')).strip().lower() # map category key -> display label labels = { 'game_changers': 'Game Changers', 'extra_turns': 'Extra Turns', 'mass_land_denial': 'Mass Land Denial', 'tutors_nonland': 'Nonland Tutors', 'two_card_combos': 'Two-Card Combos', } seen_lower: set[str] = set() for key, cat in cats.items(): try: status = str(cat.get('status') or '').upper() # Only surface tiles for WARN and FAIL if status not in {"WARN", "FAIL"}: continue # For two-card combos, split pairs into individual cards and skip commander if key == 'two_card_combos' and status == 'FAIL': # Prefer the structured combos list to ensure we only expand counted pairs pairs = [] try: for p in (comp.get('combos') or []): if p.get('cheap_early'): pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip())) except Exception: pairs = [] # Fallback to parsing flagged strings like "A + B" if not pairs: try: for s in (cat.get('flagged') or []): if not isinstance(s, str): continue parts = [x.strip() for x in s.split('+') if x and x.strip()] if len(parts) == 2: pairs.append((parts[0], parts[1])) except Exception: pass for a, bname in pairs: for nm in (a, bname): if not nm: continue nm_l = nm.strip().lower() if nm_l == commander_l: # Don't prompt replacing the commander continue if nm_l in seen_lower: continue seen_lower.add(nm_l) entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {} role = entry.get('Role') or '' flagged_meta.append({ 'name': nm, 'category': labels.get(key, key.replace('_',' ').title()), 'role': role, 'owned': (nm_l in owned_lower), 'severity': status, }) continue # Default handling for list/tag categories names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)] for nm in names: nm_l = str(nm).strip().lower() if nm_l in seen_lower: continue seen_lower.add(nm_l) entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {} role = entry.get('Role') or '' flagged_meta.append({ 'name': nm, 'category': labels.get(key, key.replace('_',' ').title()), 'role': role, 'owned': (nm_l in owned_lower), 'severity': status, }) except Exception: continue except Exception: flagged_meta = [] # Render partial ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta} return templates.TemplateResponse("build/_compliance_panel.html", ctx2) @router.post("/enforce/apply", response_class=HTMLResponse) async def build_enforce_apply(request: Request) -> HTMLResponse: """Apply bracket enforcement now using current locks as user guidance. This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) # Ensure build context exists ctx = sess.get("build_ctx") or {} b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None if not b: # No active build: show Step 5 with an error err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") _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() # type: ignore[attr-defined] import os as _os base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] # Also produce a text export for completeness ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined] except Exception: base_stem = None # Add lock placeholders into the library before enforcement so user choices are present try: locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} if locks: df = getattr(b, "_combined_cards_df", None) lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()} for lname in locks: if lname in lib_l: continue target_name = None card_type = '' mana_cost = '' try: if df is not None and not df.empty: row = df[df['name'].astype(str).str.lower() == lname] if not row.empty: target_name = str(row.iloc[0]['name']) card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '') mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '') except Exception: target_name = None if target_name: b.card_library[target_name] = { 'Count': 1, 'Card Type': card_type, 'Mana Cost': mana_cost, 'Role': 'Locked', 'SubRole': '', 'AddedBy': 'Lock', 'TriggerTag': '', } except Exception: pass # Thread preferred replacements from context onto builder so enforcement can honor them try: pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None if isinstance(pref, dict): setattr(b, 'preferred_replacements', dict(pref)) except Exception: pass # Run enforcement + re-exports (tops up to 100 internally) try: rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined] except Exception as e: err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") _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) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass 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)