mtg_python_deckbuilder/code/web/routes/build.py
matt e81b47bccf refactor: modular route organization (Phase 1-2 complete)
- Split monolithic build route handler into focused modules
- Extract validation, multi-copy, include/exclude, themes, and partner routes
- Add response utilities and telemetry decorators
- Create route pattern documentation
- Fix multi-copy detection bug (tag key mismatch)
- Improve code maintainability and testability

Roadmap 9 M1 Phase 1-2
2026-03-03 21:49:08 -08:00

4207 lines
178 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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
from .build_themes import _custom_theme_context, _INVALID_THEME_MESSAGE # noqa: E402
from .build_partners import ( # noqa: E402
_partner_ui_context,
_resolve_partner_selection,
_scryfall_image_url,
_scryfall_page_url,
)
LOGGER = get_logger(__name__)
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'<div id="deck-summary" data-summary '
f'hx-get="/build/step5/summary?token={token}" '
'hx-trigger="step5:refresh from:body" hx-swap="outerHTML">'
f'<div class="muted" style="margin-top:1rem;">{_esc(text)}</div>'
'</div>'
)
# Include/Exclude helpers moved to build_include_exclude.py
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
# Partner helpers moved to build_partners.py
router = APIRouter(prefix="/build")
# Include/Exclude routes moved to build_include_exclude.py
# Alternatives cache moved to services/alts_utils
# Partner routes moved to build_partners.py
# Theme helpers moved to build_themes.py
@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)
# Set flag to indicate this is a quick-build scenario
sess["quick_build"] = True
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
# Only pass commander to template if coming from commander browser (?commander= query param)
# This prevents stale commander from being pre-filled on subsequent builds
# The query param only exists on initial navigation from commander browser
should_auto_fill = q_commander is not None
resp = templates.TemplateResponse(
request,
"build/index.html",
{
"sid": sid,
"commander": sess.get("commander") if should_auto_fill else None,
"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)
# Unified "New Deck" modal (steps 13 condensed)
@router.get("/new", response_class=HTMLResponse)
async def build_new_modal(request: Request) -> HTMLResponse:
"""Return the New Deck modal content (for an overlay)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Clear build context to allow skip controls to work
# (Otherwise toggle endpoint thinks build is in progress)
if "build_ctx" in sess:
try:
del sess["build_ctx"]
except Exception:
pass
# M2: Clear all skip preferences for true "New Deck"
skip_keys = [
"skip_lands", "skip_to_misc", "skip_basics", "skip_staples",
"skip_kindred", "skip_fetches", "skip_duals", "skip_triomes",
"skip_all_creatures",
"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill",
"skip_all_spells",
"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage",
"skip_protection", "skip_spell_fill",
"skip_post_adjust"
]
for key in skip_keys:
sess.pop(key, None)
# M2: Check if this is a quick-build scenario (from commander browser)
# Use the quick_build flag set by /build route when ?commander= param present
is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag
# M2: Clear commander and form selections for fresh start (unless quick build)
if not is_quick_build:
commander_keys = [
"commander", "partner", "background", "commander_mode",
"themes", "bracket"
]
for key in commander_keys:
sess.pop(key, None)
theme_context = _custom_theme_context(request, sess)
ctx = {
"request": request,
"brackets": orch.bracket_options(),
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"form": {
"commander": sess.get("commander", ""), # Pre-fill for quick-build
"prefer_combos": bool(sess.get("prefer_combos")),
"combo_count": sess.get("combo_target_count"),
"combo_balance": sess.get("combo_balance"),
"enable_multicopy": bool(sess.get("multi_copy")),
"use_owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
# Add ideal values from session (will be None on first load, triggering defaults)
"ramp": sess.get("ideals", {}).get("ramp"),
"lands": sess.get("ideals", {}).get("lands"),
"basic_lands": sess.get("ideals", {}).get("basic_lands"),
"creatures": sess.get("ideals", {}).get("creatures"),
"removal": sess.get("ideals", {}).get("removal"),
"wipes": sess.get("ideals", {}).get("wipes"),
"card_advantage": sess.get("ideals", {}).get("card_advantage"),
"protection": sess.get("ideals", {}).get("protection"),
},
"tag_slot_html": None,
}
for key, value in theme_context.items():
if key == "request":
continue
ctx[key] = value
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/new/candidates", response_class=HTMLResponse)
async def build_new_candidates(request: Request, commander: str = Query("")) -> HTMLResponse:
"""Return a small list of commander candidates for the modal live search."""
q = (commander or "").strip()
items = orch.commander_candidates(q, limit=8) if q else []
candidates: list[dict[str, Any]] = []
for name, score, colors in items:
detail = lookup_commander_detail(name)
preferred = name
warning = None
if detail:
eligible_raw = detail.get("eligible_faces")
eligible = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
norm_name = str(name).strip().casefold()
eligible_norms = [face.casefold() for face in eligible]
if eligible and norm_name not in eligible_norms:
preferred = eligible[0]
primary = str(detail.get("primary_face") or detail.get("name") or name).strip()
if len(eligible) == 1:
warning = (
f"Use the back face '{preferred}' when building. Front face '{primary}' can't lead a deck."
)
else:
faces = ", ".join(f"'{face}'" for face in eligible)
warning = (
f"This commander only works from specific faces: {faces}."
)
candidates.append(
{
"display": name,
"value": preferred,
"score": score,
"colors": colors,
"warning": warning,
}
)
ctx = {"request": request, "query": q, "candidates": candidates}
return templates.TemplateResponse("build/_new_deck_candidates.html", ctx)
@router.get("/new/inspect", response_class=HTMLResponse)
async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse:
"""When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates)."""
info = orch.commander_select(name)
if not info.get("ok"):
return HTMLResponse(f'<div class="muted">Commander not found: {name}</div>')
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)
# Theme routes moved to build_themes.py
@router.post("/new/toggle-skip", response_class=JSONResponse)
async def build_new_toggle_skip(
request: Request,
skip_key: str = Form(...),
enabled: str = Form(...),
) -> JSONResponse:
"""Toggle a skip configuration flag (wizard-only, before build starts).
Enforces mutual exclusivity:
- skip_lands and skip_to_misc are mutually exclusive with individual land flags
- Individual land flags are mutually exclusive with each other
"""
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
if not sid:
return JSONResponse({"error": "No session ID"}, status_code=400)
sess = get_session(sid)
# Wizard-only: reject if build has started
if "build_ctx" in sess:
return JSONResponse({"error": "Cannot modify skip settings after build has started"}, status_code=400)
# Validate skip_key
valid_keys = {
"skip_lands", "skip_to_misc", "skip_basics", "skip_staples",
"skip_kindred", "skip_fetches", "skip_duals", "skip_triomes",
"skip_all_creatures",
"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill",
"skip_all_spells",
"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage",
"skip_protection", "skip_spell_fill",
"skip_post_adjust"
}
if skip_key not in valid_keys:
return JSONResponse({"error": f"Invalid skip key: {skip_key}"}, status_code=400)
# Parse enabled flag
enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"}
# Mutual exclusivity rules
land_group_flags = {"skip_lands", "skip_to_misc"}
individual_land_flags = {"skip_basics", "skip_staples", "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes"}
creature_specific_flags = {"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill"}
spell_specific_flags = {"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", "skip_protection", "skip_spell_fill"}
# If enabling a flag, check for conflicts
if enabled_flag:
# Rule 1: skip_lands/skip_to_misc disables all individual land flags
if skip_key in land_group_flags:
for key in individual_land_flags:
sess[key] = False
# Rule 2: Individual land flags disable skip_lands/skip_to_misc
elif skip_key in individual_land_flags:
for key in land_group_flags:
sess[key] = False
# Rule 3: skip_all_creatures disables specific creature flags
elif skip_key == "skip_all_creatures":
for key in creature_specific_flags:
sess[key] = False
# Rule 4: Specific creature flags disable skip_all_creatures
elif skip_key in creature_specific_flags:
sess["skip_all_creatures"] = False
# Rule 5: skip_all_spells disables specific spell flags
elif skip_key == "skip_all_spells":
for key in spell_specific_flags:
sess[key] = False
# Rule 6: Specific spell flags disable skip_all_spells
elif skip_key in spell_specific_flags:
sess["skip_all_spells"] = False
# Set the requested flag
sess[skip_key] = enabled_flag
# Auto-enable skip_post_adjust when any other skip is enabled
if enabled_flag and skip_key != "skip_post_adjust":
sess["skip_post_adjust"] = True
# Auto-disable skip_post_adjust when all other skips are disabled
if not enabled_flag:
any_other_skip = any(
sess.get(k, False) for k in valid_keys
if k != "skip_post_adjust" and k != skip_key
)
if not any_other_skip:
sess["skip_post_adjust"] = False
return JSONResponse({
"success": True,
"skip_key": skip_key,
"enabled": enabled_flag,
"skip_post_adjust": bool(sess.get("skip_post_adjust", False))
})
def _get_descriptive_stage_label(stage: Dict[str, Any], ctx: Dict[str, Any]) -> str:
"""Generate a more descriptive label for Quick Build progress display."""
key = stage.get("key", "")
base_label = stage.get("label", "")
# Land stages - show what type of lands
land_types = {
"land1": "Basics",
"land2": "Staples",
"land3": "Fetches",
"land4": "Duals",
"land5": "Triomes",
"land6": "Kindred",
"land7": "Misc Utility",
"land8": "Final Lands"
}
if key in land_types:
return f"Lands: {land_types[key]}"
# Creature stages - show associated theme
if "creatures" in key:
tags = ctx.get("tags", [])
if key == "creatures_all_theme":
if tags:
all_tags = " + ".join(tags[:3]) # Show up to 3 tags
return f"Creatures: All Themes ({all_tags})"
return "Creatures: All Themes"
elif key == "creatures_primary" and len(tags) >= 1:
return f"Creatures: {tags[0]}"
elif key == "creatures_secondary" and len(tags) >= 2:
return f"Creatures: {tags[1]}"
elif key == "creatures_tertiary" and len(tags) >= 3:
return f"Creatures: {tags[2]}"
# Let creatures_fill use default "Creatures: Fill" label
# Theme spell fill stage - adds any card type (artifacts, enchantments, instants, etc.) that fits theme
if key == "spells_fill":
return "Theme Spell Fill"
# Default: return original label
return base_label
def _run_quick_build_stages(sid: str):
"""Background task: Run all stages for Quick Build and update progress in session."""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[Quick Build] Starting background task for sid={sid}")
sess = get_session(sid)
logger.info(f"[Quick Build] Retrieved session: {sess is not None}")
ctx = sess.get("build_ctx")
if not ctx:
logger.error(f"[Quick Build] No build_ctx found in session")
sess["quick_build_progress"] = {
"running": False,
"current_stage": "Error: No build context",
"completed_stages": []
}
return
logger.info(f"[Quick Build] build_ctx found with {len(ctx.get('stages', []))} stages")
# CRITICAL: Inject session reference into context so skip config can be read
ctx["session"] = sess
logger.info("[Quick Build] Injected session reference into context")
stages = ctx.get("stages", [])
res = None
# Initialize progress tracking
sess["quick_build_progress"] = {
"running": True,
"current_stage": "Starting build..."
}
try:
logger.info("[Quick Build] Starting stage loop")
# Track which phase we're in for simplified progress display
current_phase = None
while True:
current_idx = ctx.get("idx", 0)
if current_idx >= len(stages):
logger.info(f"[Quick Build] Reached end of stages (idx={current_idx})")
break
current_stage = stages[current_idx]
stage_key = current_stage.get("key", "")
logger.info(f"[Quick Build] Stage {current_idx} key: {stage_key}")
# Determine simplified phase label
if stage_key.startswith("creatures"):
new_phase = "Adding Creatures"
elif stage_key.startswith("spells") or stage_key in ["spells_ramp", "spells_removal", "spells_wipes", "spells_card_advantage", "spells_protection", "spells_fill"]:
new_phase = "Adding Spells"
elif stage_key.startswith("land"):
new_phase = "Adding Lands"
elif stage_key in ["post_spell_land_adjust", "reporting"]:
new_phase = "Doing Some Final Touches"
else:
new_phase = "Building Deck"
# Only update progress if phase changed
if new_phase != current_phase:
current_phase = new_phase
sess["quick_build_progress"]["current_stage"] = current_phase
logger.info(f"[Quick Build] Phase: {current_phase}")
# Run stage with show_skipped=False
res = orch.run_stage(ctx, rerun=False, show_skipped=False)
logger.info(f"[Quick Build] Stage {stage_key} completed, done={res.get('done')}")
# Handle Multi-Copy package marking
try:
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
mc = sess.get("multi_copy")
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception:
pass
# Check if build is done (reporting stage marks done=True)
if res.get("done"):
break
# run_stage() advances ctx["idx"] internally when stage completes successfully
# If stage is gated, it also advances the index, so we just continue the loop
# Show summary generation message (stay here for a moment)
sess["quick_build_progress"]["current_stage"] = "Generating Summary"
import time
time.sleep(2) # Pause briefly so user sees this stage
# Store final result for polling endpoint
sess["last_result"] = res or {}
sess["last_step"] = 5
# Small delay to show finishing message
import time
time.sleep(1.5)
except Exception as e:
# Store error state
logger.exception(f"[Quick Build] Error during stage execution: {e}")
sess["quick_build_progress"]["current_stage"] = f"Error: {str(e)}"
finally:
# Mark build as complete
logger.info("[Quick Build] Background task completed")
sess["quick_build_progress"]["running"] = False
sess["quick_build_progress"]["current_stage"] = "Complete"
@router.post("/new", response_class=HTMLResponse)
async def build_new_submit(
request: Request,
background_tasks: BackgroundTasks,
name: str = Form("") ,
commander: str = Form(...),
primary_tag: str | None = Form(None),
secondary_tag: str | None = Form(None),
tertiary_tag: str | None = Form(None),
tag_mode: str | None = Form("AND"),
partner_enabled: str | None = Form(None),
secondary_commander: str | None = Form(None),
background: str | None = Form(None),
partner_auto_opt_out: str | None = Form(None),
partner_selection_source: str | None = Form(None),
bracket: int = Form(...),
ramp: int = Form(None),
lands: int = Form(None),
basic_lands: int = Form(None),
creatures: int = Form(None),
removal: int = Form(None),
wipes: int = Form(None),
card_advantage: int = Form(None),
protection: int = Form(None),
prefer_combos: bool = Form(False),
combo_count: int | None = Form(None),
combo_balance: str | None = Form(None),
enable_multicopy: bool = Form(False),
use_owned_only: bool = Form(False),
prefer_owned: bool = Form(False),
swap_mdfc_basics: bool = Form(False),
# Integrated Multi-Copy (optional)
multi_choice_id: str | None = Form(None),
multi_count: int | None = Form(None),
multi_thrumming: str | None = Form(None),
# Must-haves/excludes (optional)
include_cards: str = Form(""),
exclude_cards: str = Form(""),
enforcement_mode: str = Form("warn"),
allow_illegal: bool = Form(False),
fuzzy_matching: bool = Form(True),
# Build count for multi-build
build_count: int = Form(1),
# Quick Build flag
quick_build: str | None = Form(None),
) -> HTMLResponse:
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
raw_partner_flag = (partner_enabled or "").strip().lower()
partner_checkbox = partner_feature_enabled and raw_partner_flag in {"1", "true", "on", "yes"}
initial_secondary = (secondary_commander or "").strip()
initial_background = (background or "").strip()
auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"}
partner_form_state: dict[str, Any] = {
"partner_enabled": bool(partner_checkbox),
"secondary_commander": initial_secondary,
"background": initial_background,
"partner_mode": None,
"partner_auto_note": None,
"partner_warnings": [],
"combined_preview": None,
"partner_auto_assigned": False,
}
def _form_state(commander_value: str) -> dict[str, Any]:
return {
"name": name,
"commander": commander_value,
"primary_tag": primary_tag or "",
"secondary_tag": secondary_tag or "",
"tertiary_tag": tertiary_tag or "",
"tag_mode": tag_mode or "AND",
"bracket": bracket,
"combo_count": combo_count,
"combo_balance": (combo_balance or "mix"),
"prefer_combos": bool(prefer_combos),
"enable_multicopy": bool(enable_multicopy),
"use_owned_only": bool(use_owned_only),
"prefer_owned": bool(prefer_owned),
"swap_mdfc_basics": bool(swap_mdfc_basics),
"include_cards": include_cards or "",
"exclude_cards": exclude_cards or "",
"enforcement_mode": enforcement_mode or "warn",
"allow_illegal": bool(allow_illegal),
"fuzzy_matching": bool(fuzzy_matching),
"partner_enabled": partner_form_state["partner_enabled"],
"secondary_commander": partner_form_state["secondary_commander"],
"background": partner_form_state["background"],
}
commander_detail = lookup_commander_detail(commander)
if commander_detail:
eligible_raw = commander_detail.get("eligible_faces")
eligible_faces = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
if eligible_faces:
norm_input = str(commander).strip().casefold()
eligible_norms = [face.casefold() for face in eligible_faces]
if norm_input not in eligible_norms:
suggested = eligible_faces[0]
primary_face = str(commander_detail.get("primary_face") or commander_detail.get("name") or commander).strip()
faces_str = ", ".join(f"'{face}'" for face in eligible_faces)
error_msg = (
f"'{primary_face or commander}' can't lead a deck. Use {faces_str} as the commander instead. "
"We've updated the commander field for you."
)
ctx = {
"request": request,
"error": error_msg,
"brackets": orch.bracket_options(),
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES,
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"form": _form_state(suggested),
"tag_slot_html": None,
}
theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
for key, value in theme_ctx.items():
if key == "request":
continue
ctx[key] = value
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Normalize and validate commander selection (best-effort via orchestrator)
sel = orch.commander_select(commander)
if not sel.get("ok"):
# Re-render modal with error
ctx = {
"request": request,
"error": sel.get("error", "Commander not found"),
"brackets": orch.bracket_options(),
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"form": _form_state(commander),
"tag_slot_html": None,
}
theme_ctx = _custom_theme_context(request, sess, message=ctx["error"], level="error")
for key, value in theme_ctx.items():
if key == "request":
continue
ctx[key] = value
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
primary_commander_name = sel.get("name") or commander
# Enforce GC bracket restriction before saving session (silently coerce to 3)
try:
is_gc = bool(primary_commander_name in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
if is_gc:
try:
if int(bracket) < 3:
bracket = 3
except Exception:
bracket = 3
# Save to session
sess["commander"] = primary_commander_name
(
partner_error,
combined_payload,
partner_warnings,
partner_auto_note,
resolved_secondary,
resolved_background,
partner_mode,
partner_auto_assigned_flag,
) = _resolve_partner_selection(
primary_commander_name,
feature_enabled=partner_feature_enabled,
partner_enabled=partner_checkbox,
secondary_candidate=secondary_commander,
background_candidate=background,
auto_opt_out=auto_opt_out_flag,
selection_source=partner_selection_source,
)
partner_form_state["partner_mode"] = partner_mode
partner_form_state["partner_auto_note"] = partner_auto_note
partner_form_state["partner_warnings"] = partner_warnings
partner_form_state["combined_preview"] = combined_payload
if resolved_secondary:
partner_form_state["secondary_commander"] = resolved_secondary
if resolved_background:
partner_form_state["background"] = resolved_background
partner_form_state["partner_auto_assigned"] = bool(partner_auto_assigned_flag)
combined_theme_pool: list[str] = []
if isinstance(combined_payload, dict):
raw_tags = combined_payload.get("theme_tags") or []
for tag in raw_tags:
token = str(tag).strip()
if not token:
continue
if token not in combined_theme_pool:
combined_theme_pool.append(token)
if partner_error:
available_tags = orch.tags_for_commander(primary_commander_name)
recommended_tags = orch.recommended_tags_for_commander(primary_commander_name)
recommended_reasons = orch.recommended_tag_reasons_for_commander(primary_commander_name)
inspect_ctx: dict[str, Any] = {
"request": request,
"commander": {"name": primary_commander_name, "exclusion": lookup_commander_detail(primary_commander_name)},
"tags": available_tags,
"recommended": recommended_tags,
"recommended_reasons": recommended_reasons,
"gc_commander": is_gc,
"brackets": orch.bracket_options(),
}
inspect_ctx.update(
_partner_ui_context(
primary_commander_name,
partner_enabled=partner_checkbox,
secondary_selection=partner_form_state["secondary_commander"] or None,
background_selection=partner_form_state["background"] or None,
combined_preview=combined_payload,
warnings=partner_warnings,
partner_error=partner_error,
auto_note=partner_auto_note,
auto_assigned=partner_form_state["partner_auto_assigned"],
auto_prefill_allowed=not auto_opt_out_flag,
)
)
partner_tags = inspect_ctx.pop("partner_theme_tags", None)
if partner_tags:
inspect_ctx["tags"] = partner_tags
tag_slot_html = templates.get_template("build/_new_deck_tags.html").render(inspect_ctx)
ctx = {
"request": request,
"error": partner_error,
"brackets": orch.bracket_options(),
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES,
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"form": _form_state(primary_commander_name),
"tag_slot_html": tag_slot_html,
}
theme_ctx = _custom_theme_context(request, sess, message=partner_error, level="error")
for key, value in theme_ctx.items():
if key == "request":
continue
ctx[key] = value
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
if partner_checkbox and combined_payload:
sess["partner_enabled"] = True
if resolved_secondary:
sess["secondary_commander"] = resolved_secondary
else:
sess.pop("secondary_commander", None)
if resolved_background:
sess["background"] = resolved_background
else:
sess.pop("background", None)
if partner_mode:
sess["partner_mode"] = partner_mode
else:
sess.pop("partner_mode", None)
sess["combined_commander"] = combined_payload
sess["partner_warnings"] = partner_warnings
if partner_auto_note:
sess["partner_auto_note"] = partner_auto_note
else:
sess.pop("partner_auto_note", None)
sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag)
sess["partner_auto_opt_out"] = bool(auto_opt_out_flag)
else:
sess["partner_enabled"] = False
for key in [
"secondary_commander",
"background",
"partner_mode",
"partner_warnings",
"combined_commander",
"partner_auto_note",
]:
try:
sess.pop(key)
except KeyError:
pass
for key in ["partner_auto_assigned", "partner_auto_opt_out"]:
try:
sess.pop(key)
except KeyError:
pass
# 1) Start from explicitly selected tags (order preserved)
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
user_explicit = bool(tags) # whether the user set any theme in the form
# 2) Consider user-added supplemental themes from the Additional Themes UI
additional_from_session = []
try:
# custom_theme_manager stores resolved list here on add/resolve; present before submit
additional_from_session = [
str(x) for x in (sess.get("additional_themes") or []) if isinstance(x, str) and x.strip()
]
except Exception:
additional_from_session = []
# 3) If no explicit themes were selected, prefer additional themes as primary/secondary/tertiary
if not user_explicit and additional_from_session:
# Cap to three and preserve order
tags = list(additional_from_session[:3])
# 4) If user selected some themes, fill remaining slots with additional themes (deduping)
elif user_explicit and additional_from_session:
seen = {str(t).strip().casefold() for t in tags}
for name in additional_from_session:
key = name.strip().casefold()
if key in seen:
continue
tags.append(name)
seen.add(key)
if len(tags) >= 3:
break
# 5) If still empty (no explicit and no additional), fall back to commander-recommended default
if not tags:
if combined_theme_pool:
tags = combined_theme_pool[:3]
else:
try:
rec = orch.recommended_tags_for_commander(sess["commander"]) or []
if rec:
tags = [rec[0]]
except Exception:
pass
sess["tags"] = tags
sess["tag_mode"] = (tag_mode or "AND").upper()
try:
# Default to bracket 3 (Upgraded) when not provided
sess["bracket"] = int(bracket) if (bracket is not None) else 3
except Exception:
try:
sess["bracket"] = int(bracket)
except Exception:
sess["bracket"] = 3
# Ideals: use provided values if any, else defaults
ideals = orch.ideal_defaults()
overrides = {k: v for k, v in {
"ramp": ramp,
"lands": lands,
"basic_lands": basic_lands,
"creatures": creatures,
"removal": removal,
"wipes": wipes,
"card_advantage": card_advantage,
"protection": protection,
}.items() if v is not None}
for k, v in overrides.items():
try:
ideals[k] = int(v)
except Exception:
pass
sess["ideals"] = ideals
if ENABLE_CUSTOM_THEMES:
try:
theme_mgr.refresh_resolution(
sess,
commander_tags=tags,
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
)
except ValueError as exc:
error_msg = str(exc)
ctx = {
"request": request,
"error": error_msg,
"brackets": orch.bracket_options(),
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES,
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"form": _form_state(sess.get("commander", "")),
"tag_slot_html": None,
}
theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
for key, value in theme_ctx.items():
if key == "request":
continue
ctx[key] = value
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Persist preferences
try:
sess["prefer_combos"] = bool(prefer_combos)
except Exception:
sess["prefer_combos"] = False
try:
sess["use_owned_only"] = bool(use_owned_only)
except Exception:
sess["use_owned_only"] = False
try:
sess["prefer_owned"] = bool(prefer_owned)
except Exception:
sess["prefer_owned"] = False
try:
sess["swap_mdfc_basics"] = bool(swap_mdfc_basics)
except Exception:
sess["swap_mdfc_basics"] = False
# Combos config from modal
try:
if combo_count is not None:
sess["combo_target_count"] = max(0, min(10, int(combo_count)))
except Exception:
pass
try:
if combo_balance:
bval = str(combo_balance).strip().lower()
if bval in ("early","late","mix"):
sess["combo_balance"] = bval
except Exception:
pass
# Multi-Copy selection from modal (opt-in)
try:
# Clear any prior selection first; this flow should define it explicitly when present
if "multi_copy" in sess:
del sess["multi_copy"]
if enable_multicopy and multi_choice_id and str(multi_choice_id).strip():
meta = bc.MULTI_COPY_ARCHETYPES.get(str(multi_choice_id), {})
printed_cap = meta.get("printed_cap")
cnt: int
if multi_count is None:
cnt = int(meta.get("default_count", 25))
else:
try:
cnt = int(multi_count)
except Exception:
cnt = int(meta.get("default_count", 25))
if isinstance(printed_cap, int) and printed_cap > 0:
cnt = max(1, min(printed_cap, cnt))
sess["multi_copy"] = {
"id": str(multi_choice_id),
"name": meta.get("name") or str(multi_choice_id),
"count": int(cnt),
"thrumming": True if (multi_thrumming and str(multi_thrumming).strip() in ("1","true","on","yes")) else False,
}
else:
# Ensure disabled when not opted-in
if "multi_copy" in sess:
del sess["multi_copy"]
# Reset the applied marker so the run can account for the new selection
if "mc_applied_key" in sess:
del sess["mc_applied_key"]
except Exception:
pass
# Process include/exclude cards (M3: Phase 2 - Full Include/Exclude)
try:
from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics
# Clear any old include/exclude data
for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]:
if k in sess:
del sess[k]
# Process include cards
if include_cards and include_cards.strip():
print(f"DEBUG: Raw include_cards input: '{include_cards}'")
include_list = parse_card_list_input(include_cards.strip())
print(f"DEBUG: Parsed include_list: {include_list}")
sess["include_cards"] = include_list
else:
print(f"DEBUG: include_cards is empty or None: '{include_cards}'")
# Process exclude cards
if exclude_cards and exclude_cards.strip():
print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'")
exclude_list = parse_card_list_input(exclude_cards.strip())
print(f"DEBUG: Parsed exclude_list: {exclude_list}")
sess["exclude_cards"] = exclude_list
else:
print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'")
# Store advanced options
sess["enforcement_mode"] = enforcement_mode
sess["allow_illegal"] = allow_illegal
sess["fuzzy_matching"] = fuzzy_matching
# Create basic diagnostics for status tracking
if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()):
diagnostics = IncludeExcludeDiagnostics(
missing_includes=[],
ignored_color_identity=[],
illegal_dropped=[],
illegal_allowed=[],
excluded_removed=sess.get("exclude_cards", []),
duplicates_collapsed={},
include_added=[],
include_over_ideal={},
fuzzy_corrections={},
confirmation_needed=[],
list_size_warnings={
"includes_count": len(sess.get("include_cards", [])),
"excludes_count": len(sess.get("exclude_cards", [])),
"includes_limit": 10,
"excludes_limit": 15
}
)
sess["include_exclude_diagnostics"] = diagnostics.__dict__
except Exception as e:
# If exclude parsing fails, log but don't block the build
import logging
logging.warning(f"Failed to parse exclude cards: {e}")
# Clear any old staged build context
for k in ["build_ctx", "locks", "replace_mode"]:
if k in sess:
try:
del sess[k]
except Exception:
pass
# Reset multi-copy suggestion debounce for a fresh run (keep selected choice)
if "mc_seen_keys" in sess:
try:
del sess["mc_seen_keys"]
except Exception:
pass
# Persist optional custom export base name
if isinstance(name, str) and name.strip():
sess["custom_export_base"] = name.strip()
else:
if "custom_export_base" in sess:
try:
del sess["custom_export_base"]
except Exception:
pass
# If setup/tagging is not ready or stale, show a modal prompt instead of auto-running.
try:
if not _is_setup_ready():
return templates.TemplateResponse(
"build/_setup_prompt_modal.html",
{
"request": request,
"title": "Setup required",
"message": "The card database and tags need to be prepared before building a deck.",
"action_url": "/setup/running?start=1&next=/build",
"action_label": "Run Setup",
},
)
if _is_setup_stale():
return templates.TemplateResponse(
"build/_setup_prompt_modal.html",
{
"request": request,
"title": "Data refresh recommended",
"message": "Your card database is stale. Refreshing ensures up-to-date results.",
"action_url": "/setup/running?start=1&force=1&next=/build",
"action_label": "Refresh Now",
},
)
except Exception:
# If readiness check fails, continue and let downstream handling surface errors
pass
# Immediately initialize a build context and run the first stage, like hitting Build Deck on review
if "replace_mode" not in sess:
sess["replace_mode"] = True
# Centralized staged context creation
sess["build_ctx"] = start_ctx_from_session(sess)
# Validate and normalize build_count
try:
build_count = max(1, min(10, int(build_count)))
except Exception:
build_count = 1
# Check if this is a multi-build request (build_count > 1)
if build_count > 1:
# Multi-Build: Queue parallel builds and return batch progress page
from ..services.multi_build_orchestrator import queue_builds, run_batch_async
# Create config dict from session for batch builds
batch_config = {
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"tag_mode": sess.get("tag_mode", "AND"),
"bracket": sess.get("bracket", 3),
"ideals": sess.get("ideals", {}),
"prefer_combos": sess.get("prefer_combos", False),
"combo_target_count": sess.get("combo_target_count"),
"combo_balance": sess.get("combo_balance"),
"multi_copy": sess.get("multi_copy"),
"use_owned_only": sess.get("use_owned_only", False),
"prefer_owned": sess.get("prefer_owned", False),
"swap_mdfc_basics": sess.get("swap_mdfc_basics", False),
"include_cards": sess.get("include_cards", []),
"exclude_cards": sess.get("exclude_cards", []),
"enforcement_mode": sess.get("enforcement_mode", "warn"),
"allow_illegal": sess.get("allow_illegal", False),
"fuzzy_matching": sess.get("fuzzy_matching", True),
"locks": list(sess.get("locks", [])),
}
# Handle partner mechanics if present
if sess.get("partner_enabled"):
batch_config["partner_enabled"] = True
if sess.get("secondary_commander"):
batch_config["secondary_commander"] = sess["secondary_commander"]
if sess.get("background"):
batch_config["background"] = sess["background"]
if sess.get("partner_mode"):
batch_config["partner_mode"] = sess["partner_mode"]
if sess.get("combined_commander"):
batch_config["combined_commander"] = sess["combined_commander"]
# Add color identity for synergy builder (needed for basic land allocation)
try:
tmp_builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
# Handle partner mechanics if present
if sess.get("partner_enabled") and sess.get("secondary_commander"):
from deck_builder.partner_selection import apply_partner_inputs
combined_obj = apply_partner_inputs(
tmp_builder,
primary_name=sess["commander"],
secondary_name=sess.get("secondary_commander"),
background_name=sess.get("background"),
feature_enabled=True,
)
if combined_obj and hasattr(combined_obj, "color_identity"):
batch_config["colors"] = list(combined_obj.color_identity)
else:
# Single commander
df = tmp_builder.load_commander_data()
row = df[df["name"] == sess["commander"]]
if not row.empty:
# Get colorIdentity from dataframe (it's a string like "RG" or "G")
color_str = row.iloc[0].get("colorIdentity", "")
if color_str:
batch_config["colors"] = list(color_str) # Convert "RG" to ['R', 'G']
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"[Batch] Failed to load color identity for {sess.get('commander')}: {e}")
pass # Not critical, synergy builder will skip basics if missing
# Queue the batch
batch_id = queue_builds(batch_config, build_count, sid)
# Start background task for parallel builds
background_tasks.add_task(run_batch_async, batch_id, sid)
# Return batch progress template
progress_ctx = {
"request": request,
"batch_id": batch_id,
"build_count": build_count,
"completed": 0,
"current_build": 1,
"status": "Starting builds..."
}
resp = templates.TemplateResponse("build/_batch_progress.html", progress_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Check if Quick Build was requested (single build only)
is_quick_build = (quick_build or "").strip() == "1"
if is_quick_build:
# Quick Build: Start background task and return progress template immediately
ctx = sess["build_ctx"]
# Initialize progress tracking with dynamic counting (total starts at 0)
sess["quick_build_progress"] = {
"running": True,
"total": 0,
"completed": 0,
"current_stage": "Starting build..."
}
# Start background task to run all stages
background_tasks.add_task(_run_quick_build_stages, sid)
# Return progress template immediately
progress_ctx = {
"request": request,
"progress_pct": 0,
"completed": 0,
"total": 0,
"current_stage": "Starting build..."
}
resp = templates.TemplateResponse("build/_quick_build_progress.html", progress_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
else:
# Normal build: Run first stage and wait for user input
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
try:
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
mc = sess.get("multi_copy")
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception:
pass
status = "Build complete" if res.get("done") else "Stage complete"
sess["last_step"] = 5
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False)
resp = templates.TemplateResponse("build/_step5.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step1", response_class=HTMLResponse)
async def build_step1(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1", response_class=HTMLResponse)
async def build_step1_search(
request: Request,
query: str = Form(""),
auto: str | None = Form(None),
active: str | None = Form(None),
) -> HTMLResponse:
query = (query or "").strip()
auto_enabled = True if (auto == "1") else False
candidates = []
if query:
candidates = orch.commander_candidates(query, limit=10)
# Optional auto-select at a stricter threshold
if auto_enabled and candidates and len(candidates[0]) >= 2 and int(candidates[0][1]) >= 98:
top_name = candidates[0][0]
res = orch.commander_select(top_name)
if res.get("ok"):
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
commander_name = res.get("name")
gc_flag = commander_name in getattr(bc, 'GAME_CHANGERS', [])
context = {
"request": request,
"commander": res,
"tags": orch.tags_for_commander(commander_name),
"recommended": orch.recommended_tags_for_commander(commander_name),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name),
"brackets": orch.bracket_options(),
"gc_commander": gc_flag,
"selected_bracket": (3 if gc_flag else None),
"clear_persisted": True,
}
context.update(
_partner_ui_context(
commander_name,
partner_enabled=False,
secondary_selection=None,
background_selection=None,
combined_preview=None,
warnings=None,
partner_error=None,
auto_note=None,
)
)
resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse(
"build/_step1.html",
{
"request": request,
"query": query,
"candidates": candidates,
"auto": auto_enabled,
"active": active,
"count": len(candidates) if candidates else 0,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1/inspect", response_class=HTMLResponse)
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
info = orch.commander_inspect(name)
resp = templates.TemplateResponse(
"build/_step1.html",
{"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1/confirm", response_class=HTMLResponse)
async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse:
res = orch.commander_select(name)
if not res.get("ok"):
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Proceed to step2 placeholder and reset any prior build/session selections
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Reset sticky selections from previous runs
for k in [
"tags",
"ideals",
"bracket",
"build_ctx",
"last_step",
"tag_mode",
"mc_seen_keys",
"multi_copy",
"partner_enabled",
"secondary_commander",
"background",
"partner_mode",
"partner_warnings",
"combined_commander",
"partner_auto_note",
]:
try:
if k in sess:
del sess[k]
except Exception:
pass
sess["last_step"] = 2
# Determine if commander is a Game Changer to drive bracket UI hiding
is_gc = False
try:
is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
context = {
"request": request,
"commander": res,
"tags": orch.tags_for_commander(res["name"]),
"recommended": orch.recommended_tags_for_commander(res["name"]),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(),
"gc_commander": is_gc,
"selected_bracket": (3 if is_gc else None),
# Signal that this navigation came from a fresh commander confirmation,
# so the Step 2 UI should clear any localStorage theme persistence.
"clear_persisted": True,
}
context.update(
_partner_ui_context(
res["name"],
partner_enabled=False,
secondary_selection=None,
background_selection=None,
combined_preview=None,
warnings=None,
partner_error=None,
auto_note=None,
)
)
resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/reset-all", response_class=HTMLResponse)
async def build_reset_all(request: Request) -> HTMLResponse:
"""Clear all build-related session state and return Step 1."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
keys = [
"commander","tags","tag_mode","bracket","ideals","build_ctx","last_step",
"locks","replace_mode"
]
for k in keys:
try:
if k in sess:
del sess[k]
except Exception:
pass
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step5/rewind", response_class=HTMLResponse)
async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLResponse:
"""Rewind the staged build to a previous visible stage by index or key and show that stage.
Param `to` can be an integer index (1-based stage index) or a stage key string.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx")
if not ctx:
return await build_step5_get(request)
target_i: int | None = None
# Resolve by numeric index first
try:
idx_val = int(str(to).strip())
target_i = idx_val
except Exception:
target_i = None
if target_i is None:
# attempt by key
key = str(to).strip()
try:
for h in ctx.get("history", []) or []:
if str(h.get("key")) == key or str(h.get("label")) == key:
target_i = int(h.get("i"))
break
except Exception:
target_i = None
if not target_i:
return await build_step5_get(request)
# Try to restore snapshot stored for that history entry
try:
hist = ctx.get("history", []) or []
snap = None
for h in hist:
if int(h.get("i")) == int(target_i):
snap = h.get("snapshot")
break
if snap is not None:
orch._restore_builder(ctx["builder"], snap)
ctx["idx"] = int(target_i) - 1
ctx["last_visible_idx"] = int(target_i) - 1
except Exception:
# As a fallback, restart ctx and run forward until target
sess["build_ctx"] = start_ctx_from_session(sess)
ctx = sess["build_ctx"]
# Run forward until reaching target
while True:
res = orch.run_stage(ctx, rerun=False, show_skipped=False)
if int(res.get("idx", 0)) >= int(target_i):
break
if res.get("done"):
break
# Finally show the target stage by running it with show_skipped True to get a view
try:
res = orch.run_stage(ctx, rerun=False, show_skipped=True)
status = "Stage (rewound)" if not res.get("done") else "Build complete"
ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={
"history": ctx.get("history", []),
})
except Exception as e:
sess["last_step"] = 5
ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}})
return resp
@router.get("/step2", response_class=HTMLResponse)
async def build_step2_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
commander = sess.get("commander")
if not commander:
# Fallback to step1 if no commander in session
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
tags = orch.tags_for_commander(commander)
selected = sess.get("tags", [])
# Determine if the selected commander is considered a Game Changer (affects bracket choices)
is_gc = False
try:
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
# Selected bracket: if GC commander and bracket < 3 or missing, default to 3
sel_br = sess.get("bracket")
try:
sel_br = int(sel_br) if sel_br is not None else None
except Exception:
sel_br = None
if is_gc and (sel_br is None or int(sel_br) < 3):
sel_br = 3
partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS)
import logging
logger = logging.getLogger(__name__)
logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}")
context = {
"request": request,
"commander": {"name": commander},
"tags": tags,
"recommended": orch.recommended_tags_for_commander(commander),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
"brackets": orch.bracket_options(),
"primary_tag": selected[0] if len(selected) > 0 else "",
"secondary_tag": selected[1] if len(selected) > 1 else "",
"tertiary_tag": selected[2] if len(selected) > 2 else "",
"selected_bracket": sel_br,
"tag_mode": sess.get("tag_mode", "AND"),
"gc_commander": is_gc,
# If there are no server-side tags for this commander, let the client clear any persisted ones
# to avoid themes sticking between fresh runs.
"clear_persisted": False if selected else True,
}
context.update(
_partner_ui_context(
commander,
partner_enabled=partner_enabled,
secondary_selection=sess.get("secondary_commander") if partner_enabled else None,
background_selection=sess.get("background") if partner_enabled else None,
combined_preview=sess.get("combined_commander") if partner_enabled else None,
warnings=sess.get("partner_warnings") if partner_enabled else None,
partner_error=None,
auto_note=sess.get("partner_auto_note") if partner_enabled else None,
auto_assigned=sess.get("partner_auto_assigned") if partner_enabled else None,
auto_prefill_allowed=not bool(sess.get("partner_auto_opt_out")) if partner_enabled else True,
)
)
partner_tags = context.pop("partner_theme_tags", None)
if partner_tags:
import logging
logger = logging.getLogger(__name__)
context["tags"] = partner_tags
# Deduplicate recommended tags: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
original_recommended = context.get("recommended", [])
deduplicated_recommended = [
tag for tag in original_recommended
if str(tag).strip().casefold() not in partner_tags_lower
]
logger.info(
f"Step2: partner_tags={len(partner_tags)}, "
f"original_recommended={len(original_recommended)}, "
f"deduplicated_recommended={len(deduplicated_recommended)}"
)
context["recommended"] = deduplicated_recommended
resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step2", response_class=HTMLResponse)
async def build_step2_submit(
request: Request,
commander: str = Form(...),
primary_tag: str | None = Form(None),
secondary_tag: str | None = Form(None),
tertiary_tag: str | None = Form(None),
tag_mode: str | None = Form("AND"),
bracket: int = Form(...),
partner_enabled: str | None = Form(None),
secondary_commander: str | None = Form(None),
background: str | None = Form(None),
partner_selection_source: str | None = Form(None),
partner_auto_opt_out: str | None = Form(None),
) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
partner_flag = False
if partner_feature_enabled:
raw_partner_enabled = (partner_enabled or "").strip().lower()
partner_flag = raw_partner_enabled in {"1", "true", "on", "yes"}
auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"}
# Validate primary tag selection if tags are available
available_tags = orch.tags_for_commander(commander)
if available_tags and not (primary_tag and primary_tag.strip()):
# Compute GC flag to hide disallowed brackets on error
is_gc = False
try:
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
try:
sel_br = int(bracket) if bracket is not None else None
except Exception:
sel_br = None
if is_gc and (sel_br is None or sel_br < 3):
sel_br = 3
context = {
"request": request,
"commander": {"name": commander},
"tags": available_tags,
"recommended": orch.recommended_tags_for_commander(commander),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
"brackets": orch.bracket_options(),
"error": "Please choose a primary theme.",
"primary_tag": primary_tag or "",
"secondary_tag": secondary_tag or "",
"tertiary_tag": tertiary_tag or "",
"selected_bracket": sel_br,
"tag_mode": (tag_mode or "AND"),
"gc_commander": is_gc,
}
context.update(
_partner_ui_context(
commander,
partner_enabled=partner_flag,
secondary_selection=secondary_commander if partner_flag else None,
background_selection=background if partner_flag else None,
combined_preview=None,
warnings=[],
partner_error=None,
auto_note=None,
auto_assigned=None,
auto_prefill_allowed=not auto_opt_out_flag,
)
)
partner_tags = context.pop("partner_theme_tags", None)
if partner_tags:
context["tags"] = partner_tags
resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed)
try:
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
if is_gc:
try:
if int(bracket) < 3:
bracket = 3 # coerce silently
except Exception:
bracket = 3
(
partner_error,
combined_payload,
partner_warnings,
partner_auto_note,
resolved_secondary,
resolved_background,
partner_mode,
partner_auto_assigned_flag,
) = _resolve_partner_selection(
commander,
feature_enabled=partner_feature_enabled,
partner_enabled=partner_flag,
secondary_candidate=secondary_commander,
background_candidate=background,
auto_opt_out=auto_opt_out_flag,
selection_source=partner_selection_source,
)
if partner_error:
try:
sel_br = int(bracket)
except Exception:
sel_br = None
context: dict[str, Any] = {
"request": request,
"commander": {"name": commander},
"tags": available_tags,
"recommended": orch.recommended_tags_for_commander(commander),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
"brackets": orch.bracket_options(),
"primary_tag": primary_tag or "",
"secondary_tag": secondary_tag or "",
"tertiary_tag": tertiary_tag or "",
"selected_bracket": sel_br,
"tag_mode": (tag_mode or "AND"),
"gc_commander": is_gc,
"error": None,
}
context.update(
_partner_ui_context(
commander,
partner_enabled=partner_flag,
secondary_selection=resolved_secondary or secondary_commander,
background_selection=resolved_background or background,
combined_preview=combined_payload,
warnings=partner_warnings,
partner_error=partner_error,
auto_note=partner_auto_note,
auto_assigned=partner_auto_assigned_flag,
auto_prefill_allowed=not auto_opt_out_flag,
)
)
partner_tags = context.pop("partner_theme_tags", None)
if partner_tags:
context["tags"] = partner_tags
resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Save selection to session (basic MVP; real build will use this later)
sess["commander"] = commander
sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
sess["tag_mode"] = (tag_mode or "AND").upper()
sess["bracket"] = int(bracket)
if partner_flag and combined_payload:
sess["partner_enabled"] = True
if resolved_secondary:
sess["secondary_commander"] = resolved_secondary
else:
sess.pop("secondary_commander", None)
if resolved_background:
sess["background"] = resolved_background
else:
sess.pop("background", None)
if partner_mode:
sess["partner_mode"] = partner_mode
else:
sess.pop("partner_mode", None)
sess["combined_commander"] = combined_payload
sess["partner_warnings"] = partner_warnings
if partner_auto_note:
sess["partner_auto_note"] = partner_auto_note
else:
sess.pop("partner_auto_note", None)
sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag)
sess["partner_auto_opt_out"] = bool(auto_opt_out_flag)
else:
sess["partner_enabled"] = False
for key in [
"secondary_commander",
"background",
"partner_mode",
"partner_warnings",
"combined_commander",
"partner_auto_note",
]:
try:
sess.pop(key)
except KeyError:
pass
for key in ["partner_auto_assigned", "partner_auto_opt_out"]:
try:
sess.pop(key)
except KeyError:
pass
# Clear multi-copy seen/selection to re-evaluate on Step 3
try:
if "mc_seen_keys" in sess:
del sess["mc_seen_keys"]
if "multi_copy" in sess:
del sess["multi_copy"]
if "mc_applied_key" in sess:
del sess["mc_applied_key"]
except Exception:
pass
# Proceed to Step 3 placeholder for now
sess["last_step"] = 3
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
"commander": commander,
"tags": sess["tags"],
"bracket": sess["bracket"],
"defaults": orch.ideal_defaults(),
"labels": orch.ideal_labels(),
"values": orch.ideal_defaults(),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step3", response_class=HTMLResponse)
async def build_step3_submit(
request: Request,
ramp: int = Form(...),
lands: int = Form(...),
basic_lands: int = Form(...),
creatures: int = Form(...),
removal: int = Form(...),
wipes: int = Form(...),
card_advantage: int = Form(...),
protection: int = Form(...),
) -> HTMLResponse:
labels = orch.ideal_labels()
submitted = {
"ramp": ramp,
"lands": lands,
"basic_lands": basic_lands,
"creatures": creatures,
"removal": removal,
"wipes": wipes,
"card_advantage": card_advantage,
"protection": protection,
}
errors: list[str] = []
for k, v in submitted.items():
try:
iv = int(v)
except Exception:
errors.append(f"{labels.get(k, k)} must be a number.")
continue
if iv < 0:
errors.append(f"{labels.get(k, k)} cannot be negative.")
submitted[k] = iv
# Cross-field validation: basic lands should not exceed total lands
if isinstance(submitted.get("basic_lands"), int) and isinstance(submitted.get("lands"), int):
if submitted["basic_lands"] > submitted["lands"]:
errors.append("Basic Lands cannot exceed Total Lands.")
if errors:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 3
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
"defaults": orch.ideal_defaults(),
"labels": labels,
"values": submitted,
"error": " ".join(errors),
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Save to session
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["ideals"] = submitted
# Any change to ideals should clear the applied marker, we may want to re-stage
try:
if "mc_applied_key" in sess:
del sess["mc_applied_key"]
except Exception:
pass
# Proceed to review (Step 4)
sess["last_step"] = 4
resp = templates.TemplateResponse(
"build/_step4.html",
{
"request": request,
"labels": labels,
"values": submitted,
"commander": sess.get("commander"),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step3", response_class=HTMLResponse)
async def build_step3_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 3
defaults = orch.ideal_defaults()
values = sess.get("ideals") or defaults
# Check if any skip flags are enabled to show skeleton automation page
skip_flags = {
"skip_lands": "land selection",
"skip_to_misc": "land selection",
"skip_basics": "basic lands",
"skip_staples": "staple lands",
"skip_kindred": "kindred lands",
"skip_fetches": "fetch lands",
"skip_duals": "dual lands",
"skip_triomes": "triome lands",
"skip_all_creatures": "creature selection",
"skip_creature_primary": "primary creatures",
"skip_creature_secondary": "secondary creatures",
"skip_creature_fill": "creature fills",
"skip_all_spells": "spell selection",
"skip_ramp": "ramp spells",
"skip_removal": "removal spells",
"skip_wipes": "board wipes",
"skip_card_advantage": "card advantage spells",
"skip_protection": "protection spells",
"skip_spell_fill": "spell fills",
}
active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)]
if active_skips:
# Show skeleton automation page with auto-submit
automation_parts = []
if any("land" in s for s in active_skips):
automation_parts.append("lands")
if any("creature" in s for s in active_skips):
automation_parts.append("creatures")
if any("spell" in s for s in active_skips):
automation_parts.append("spells")
automation_message = f"Applying default values for {', '.join(automation_parts)}..."
resp = templates.TemplateResponse(
"build/_step3_skeleton.html",
{
"request": request,
"defaults": defaults,
"commander": sess.get("commander"),
"automation_message": automation_message,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# No skips enabled, show normal form
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
"defaults": defaults,
"labels": orch.ideal_labels(),
"values": values,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step4", response_class=HTMLResponse)
async def build_step4_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 4
labels = orch.ideal_labels()
values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander")
return templates.TemplateResponse(
"build/_step4.html",
{
"request": request,
"labels": labels,
"values": values,
"commander": commander,
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
},
)
# --- Combos & Synergies panel (M3) ---
def _get_current_deck_names(sess: dict) -> list[str]:
try:
ctx = sess.get("build_ctx") or {}
b = ctx.get("builder")
lib = getattr(b, "card_library", {}) if b is not None else {}
names = [str(n) for n in lib.keys()]
return sorted(dict.fromkeys(names))
except Exception:
return []
@router.get("/combos", response_class=HTMLResponse)
async def build_combos_panel(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
names = _get_current_deck_names(sess)
if not names:
# No active build; render nothing to avoid UI clutter
return HTMLResponse("")
# Preferences (persisted in session)
policy = (sess.get("combos_policy") or "neutral").lower()
if policy not in {"avoid", "neutral", "prefer"}:
policy = "neutral"
try:
target = int(sess.get("combos_target") or 0)
except Exception:
target = 0
if target < 0:
target = 0
# Load lists and run detection
_det = _detect_all(names)
combos = _det.get("combos", [])
synergies = _det.get("synergies", [])
combos_model = _det.get("combos_model")
synergies_model = _det.get("synergies_model")
# Suggestions
suggestions: list[dict] = []
present = {s.strip().lower() for s in names}
suggested_names: set[str] = set()
if combos_model is not None:
# Prefer policy: suggest adding a missing partner to hit target count
if policy == "prefer":
try:
for p in combos_model.pairs:
a = str(p.a).strip()
b = str(p.b).strip()
a_in = a.lower() in present
b_in = b.lower() in present
if a_in ^ b_in: # exactly one present
missing = b if a_in else a
have = a if a_in else b
item = {
"kind": "add",
"have": have,
"name": missing,
"cheap_early": bool(getattr(p, "cheap_early", False)),
"setup_dependent": bool(getattr(p, "setup_dependent", False)),
}
key = str(missing).strip().lower()
if key not in present and key not in suggested_names:
suggestions.append(item)
suggested_names.add(key)
# Rank: cheap/early first, then setup-dependent, then name
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
# If we still have room below target, add synergy-based suggestions
rem = (max(0, int(target)) if target > 0 else 8) - len(suggestions)
if rem > 0 and synergies_model is not None:
# lightweight tag weights to bias common engines
weights = {
"treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3,
"engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9,
"counters": 1.8, "equipment matters": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
"damage": 1.3, "stax": 1.2
}
syn_sugs: list[dict] = []
for p in synergies_model.pairs:
a = str(p.a).strip()
b = str(p.b).strip()
a_in = a.lower() in present
b_in = b.lower() in present
if a_in ^ b_in:
missing = b if a_in else a
have = a if a_in else b
mkey = missing.strip().lower()
if mkey in present or mkey in suggested_names:
continue
tags = list(getattr(p, "tags", []) or [])
score = 1.0 + sum(weights.get(str(t).lower(), 1.0) for t in tags) / max(1, len(tags) or 1)
syn_sugs.append({
"kind": "add",
"have": have,
"name": missing,
"cheap_early": False,
"setup_dependent": False,
"tags": tags,
"_score": score,
})
suggested_names.add(mkey)
# rank by score desc then name
syn_sugs.sort(key=lambda s: (-float(s.get("_score", 0.0)), str(s.get("name")).lower()))
if rem > 0:
suggestions.extend(syn_sugs[:rem])
# Finally trim to target or default cap
cap = (int(target) if target > 0 else 8)
suggestions = suggestions[:cap]
except Exception:
suggestions = []
elif policy == "avoid":
# Avoid policy: suggest cutting one piece from detected combos
try:
for c in combos:
# pick the second card as default cut to vary suggestions
suggestions.append({
"kind": "cut",
"name": c.b,
"partner": c.a,
"cheap_early": bool(getattr(c, "cheap_early", False)),
"setup_dependent": bool(getattr(c, "setup_dependent", False)),
})
# Rank: cheap/early first
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
if target > 0:
suggestions = suggestions[: target]
else:
suggestions = suggestions[: 8]
except Exception:
suggestions = []
ctx = {
"request": request,
"policy": policy,
"target": target,
"combos": combos,
"synergies": synergies,
"versions": _det.get("versions", {}),
"suggestions": suggestions,
}
return templates.TemplateResponse("build/_combos_panel.html", ctx)
@router.post("/combos/prefs", response_class=HTMLResponse)
async def build_combos_save_prefs(request: Request, policy: str = Form("neutral"), target: int = Form(0)) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
pol = (policy or "neutral").strip().lower()
if pol not in {"avoid", "neutral", "prefer"}:
pol = "neutral"
try:
tgt = int(target)
except Exception:
tgt = 0
if tgt < 0:
tgt = 0
sess["combos_policy"] = pol
sess["combos_target"] = tgt
# Re-render the panel
return await build_combos_panel(request)
@router.post("/toggle-owned-review", response_class=HTMLResponse)
async def build_toggle_owned_review(
request: Request,
use_owned_only: str | None = Form(None),
prefer_owned: str | None = Form(None),
swap_mdfc_basics: str | None = Form(None),
) -> HTMLResponse:
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 4
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False
sess["use_owned_only"] = only_val
sess["prefer_owned"] = pref_val
sess["swap_mdfc_basics"] = swap_val
# Do not touch build_ctx here; user hasn't started the build yet from review
labels = orch.ideal_labels()
values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander")
resp = templates.TemplateResponse(
"build/_step4.html",
{
"request": request,
"labels": labels,
"values": values,
"commander": commander,
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step5", response_class=HTMLResponse)
async def build_step5_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 5
# Default replace-mode to ON unless explicitly toggled off
if "replace_mode" not in sess:
sess["replace_mode"] = True
base = step5_empty_ctx(request, sess)
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
return resp
@router.post("/step5/continue", response_class=HTMLResponse)
async def build_step5_continue(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if "replace_mode" not in sess:
sess["replace_mode"] = True
# Validate commander; redirect to step1 if missing
if not sess.get("commander"):
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Ensure build context exists; if not, start it first
if not sess.get("build_ctx"):
sess["build_ctx"] = start_ctx_from_session(sess)
else:
# If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet
try:
mc = sess.get("multi_copy") or None
selkey = None
if mc:
selkey = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
applied = sess.get("mc_applied_key") if mc else None
if mc and (not applied or applied != selkey):
_rebuild_ctx_with_multicopy(sess)
# If we still have no stages (e.g., minimal test context), inject a minimal multi-copy stage inline
try:
ctx = sess.get("build_ctx") or {}
stages = ctx.get("stages") if isinstance(ctx, dict) else None
if (not stages or len(stages) == 0) and mc:
b = ctx.get("builder") if isinstance(ctx, dict) else None
if b is not None:
try:
setattr(b, "_web_multi_copy", mc)
except Exception:
pass
try:
if not isinstance(getattr(b, "card_library", None), dict):
b.card_library = {}
except Exception:
pass
try:
if not isinstance(getattr(b, "ideal_counts", None), dict):
b.ideal_counts = {}
except Exception:
pass
ctx["stages"] = [{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}]
ctx["idx"] = 0
ctx["last_visible_idx"] = 0
except Exception:
pass
except Exception:
pass
# Read show_skipped from either query or form safely
show_skipped = True if (request.query_params.get('show_skipped') == '1') else False
try:
form = await request.form()
if form and form.get('show_skipped') == '1':
show_skipped = True
except Exception:
pass
try:
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Build complete" if res.get("done") else "Stage complete"
# Clear commander from session after build completes
if res.get("done"):
sess.pop("commander", None)
sess.pop("commander_name", None)
except Exception as e:
sess["last_step"] = 5
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
stage_label = res.get("label")
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
try:
if stage_label == "Multi-Copy Package" and sess.get("multi_copy"):
mc = sess.get("multi_copy")
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception:
pass
# Note: no redirect; the inline compliance panel will render inside Step 5
sess["last_step"] = 5
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx2)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
return resp
@router.post("/step5/rerun", response_class=HTMLResponse)
async def build_step5_rerun(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if "replace_mode" not in sess:
sess["replace_mode"] = True
if not sess.get("commander"):
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Rerun requires an existing context; if missing, create it and run first stage as rerun
if not sess.get("build_ctx"):
sess["build_ctx"] = start_ctx_from_session(sess)
else:
# Ensure latest locks are reflected in the existing context
try:
sess["build_ctx"]["locks"] = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
except Exception:
pass
show_skipped = False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else False
except Exception:
pass
# If replace-mode is OFF, keep the stage visible even if no new cards were added
if not bool(sess.get("replace_mode", True)):
show_skipped = True
try:
res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True)))
status = "Stage rerun complete" if not res.get("done") else "Build complete"
except Exception as e:
sess["last_step"] = 5
err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
sess["last_step"] = 5
# Build locked cards list with ownership and in-deck presence
locked_cards = []
try:
ctx = sess.get("build_ctx") or {}
b = ctx.get("builder") if isinstance(ctx, dict) else None
present: set[str] = builder_present_names(b) if b is not None else set()
# Display-map via combined df when available
lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
display_map: dict[str, str] = builder_display_map(b, lock_lower) if b is not None else {}
owned_lower = owned_set_helper()
for nm in (sess.get("locks", []) or []):
key = str(nm).strip().lower()
disp = display_map.get(key, nm)
locked_cards.append({
"name": disp,
"owned": key in owned_lower,
"in_deck": key in present,
})
except Exception:
locked_cards = []
ctx3 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
ctx3["locked_cards"] = locked_cards
resp = templates.TemplateResponse("build/_step5.html", ctx3)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}})
return resp
@router.post("/step5/start", response_class=HTMLResponse)
async def build_step5_start(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if "replace_mode" not in sess:
sess["replace_mode"] = True
# Validate commander exists before starting
commander = sess.get("commander")
if not commander:
resp = templates.TemplateResponse(
"build/_step1.html",
{"request": request, "candidates": [], "error": "Please select a commander first."},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
try:
# Initialize step-by-step build context and run first stage
sess["build_ctx"] = start_ctx_from_session(sess)
show_skipped = False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else False
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Stage complete" if not res.get("done") else "Build complete"
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
try:
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
mc = sess.get("multi_copy")
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception:
pass
# Note: no redirect; the inline compliance panel will render inside Step 5
sess["last_step"] = 5
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
return resp
except Exception as e:
# Surface a friendly error on the step 5 screen with normalized context
err_ctx = step5_error_ctx(
request,
sess,
f"Failed to start build: {e}",
include_name=False,
)
# Ensure commander stays visible if set
err_ctx["commander"] = commander
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
@router.get("/step5/start", response_class=HTMLResponse)
async def build_step5_start_get(request: Request) -> HTMLResponse:
# Allow GET as a fallback to start the build (delegates to POST handler)
return await build_step5_start(request)
@router.get("/banner", response_class=HTMLResponse)
async def build_banner(request: Request, step: str = "", i: int | None = None, n: int | None = None) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
commander = sess.get("commander")
tags = sess.get("tags", [])
# Render only the inner text for the subtitle
return templates.TemplateResponse(
"build/_banner_subtitle.html",
{"request": request, "commander": commander, "tags": tags, "name": sess.get("custom_export_base")},
)
@router.post("/step5/toggle-replace")
async def build_step5_toggle_replace(request: Request, replace: str = Form("0")):
"""Toggle replace-mode for reruns and return an updated button HTML."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
enabled = True if str(replace).strip() in ("1","true","on","yes") else False
sess["replace_mode"] = enabled
# Return the checkbox control snippet (same as template)
checked = 'checked' if enabled else ''
html = (
'<div class="replace-toggle" role="group" aria-label="Replace toggle">'
'<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">'
f'<input type="hidden" name="replace" value="{"1" if enabled else "0"}" />'
'<label class="muted" style="display:flex; align-items:center; gap:.35rem;">'
f'<input type="checkbox" name="replace_chk" value="1" {checked} '
'onchange="try{ const f=this.form; const h=f.querySelector(\'input[name=replace]\'); if(h){ h.value=this.checked?\'1\':\'0\'; } f.requestSubmit(); }catch(_){ }" />'
'Replace stage picks'
'</label>'
'</form>'
'</div>'
)
return HTMLResponse(html)
@router.post("/step5/reset-stage", response_class=HTMLResponse)
async def build_step5_reset_stage(request: Request) -> HTMLResponse:
"""Reset current visible stage to the pre-stage snapshot (if available) without running it."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx")
if not ctx or not ctx.get("snapshot"):
return await build_step5_get(request)
try:
orch._restore_builder(ctx["builder"], ctx["snapshot"])
except Exception:
return await build_step5_get(request)
# Re-render step 5 with cleared added list
base = step5_empty_ctx(request, sess, extras={
"status": "Stage reset",
"i": ctx.get("idx"),
"n": len(ctx.get("stages", [])),
})
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
return resp
@router.get("/step5/summary", response_class=HTMLResponse)
async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse:
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
if not sid:
sid = new_sid()
sess = get_session(sid)
try:
session_token = int(sess.get("step5_summary_token", 0))
except Exception:
session_token = 0
try:
requested_token = int(token)
except Exception:
requested_token = 0
ready = bool(sess.get("step5_summary_ready"))
summary_data = sess.get("step5_summary") if ready else None
if summary_data is None and ready:
summary_data = _current_builder_summary(sess)
if summary_data is not None:
try:
sess["step5_summary"] = summary_data
except Exception:
pass
synergies: list[str] = []
try:
raw_synergies = sess.get("step5_synergies")
if isinstance(raw_synergies, (list, tuple, set)):
synergies = [str(item) for item in raw_synergies if str(item).strip()]
except Exception:
synergies = []
active_token = session_token if session_token >= requested_token else requested_token
if not ready or summary_data is None:
message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage."
placeholder = _step5_summary_placeholder_html(active_token, message=message)
response = HTMLResponse(placeholder)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
ctx = step5_base_ctx(request, sess)
ctx["summary"] = summary_data
ctx["synergies"] = synergies
ctx["summary_ready"] = True
ctx["summary_token"] = active_token
# Add commander hover context for color identity and theme tags
hover_meta = commander_hover_context(
commander_name=ctx.get("commander"),
deck_tags=sess.get("tags"),
summary=summary_data,
combined=ctx.get("combined_commander"),
)
ctx.update(hover_meta)
response = templates.TemplateResponse("partials/deck_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
@router.get("/quick-progress")
def quick_build_progress(request: Request):
"""Poll endpoint for Quick Build progress. Returns either progress indicator or final Step 5."""
import logging
logger = logging.getLogger(__name__)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
progress = sess.get("quick_build_progress")
logger.info(f"[Progress Poll] sid={sid}, progress={progress is not None}, running={progress.get('running') if progress else None}")
if not progress or not progress.get("running"):
# Build complete - return Step 5 content that replaces the entire wizard container
res = sess.get("last_result")
if res and res.get("done"):
ctx = step5_ctx_from_result(request, sess, res)
# Return Step 5 which will replace the whole wizard div
response = templates.TemplateResponse("build/_step5.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
# Tell HTMX to target #wizard and swap outerHTML to replace the container
response.headers["HX-Retarget"] = "#wizard"
response.headers["HX-Reswap"] = "outerHTML"
return response
# Fallback if no result yet
return HTMLResponse('Build complete. Please refresh.')
# Build still running - return progress content partial only (innerHTML swap)
current_stage = progress.get("current_stage", "Processing...")
ctx = {
"request": request,
"current_stage": current_stage
}
response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
@router.get("/batch-progress")
def batch_build_progress(request: Request, batch_id: str = Query(...)):
"""Poll endpoint for Batch Build progress. Returns either progress indicator or redirect to comparison."""
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('<div class="error">Batch not found. Please refresh.</div>')
if batch_status["status"] == "completed":
# All builds complete - redirect to comparison page
response = HTMLResponse(f'<script>window.location.href = "/compare/{batch_id}";</script>')
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'''<button type="button" class="btn-lock" title="{title}" data-locked="{'1' if want_lock else '0'}"
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
hx-vals='{{"name": "{name}", "locked": "{next_state}"}}'>{icon} {label}</button>'''
# Compute locks count for chip
locks_count = len(locks)
if locks_count > 0:
chip_html = f'<span id="locks-chip" hx-swap-oob="true"><span class="chip" title="Locked cards">🔒 {locks_count} locked</span></span>'
else:
chip_html = '<span id="locks-chip" hx-swap-oob="true"></span>'
# 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'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">{action} <strong>{disp}</strong></span>'
f'</div>'
)
# 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 += '<details id="locked-section" hx-swap-oob="true"></details>'
# Return empty body to delete the <li> 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 = '<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
return HTMLResponse(html)
try:
name_disp = str(name).strip()
name_l = name_disp.lower()
commander_l = str((sess.get("commander") or "")).strip().lower()
locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
# Exclusions from prior inline replacements
alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
alts_exclude_v = int(sess.get("alts_exclude_v") or 0)
# Resolve role from stage hint or current library entry
stage_hint = (stage or "").strip().lower()
stage_map = {
"ramp": "ramp",
"removal": "removal",
"wipes": "wipe",
"wipe": "wipe",
"board_wipe": "wipe",
"card_advantage": "card_advantage",
"draw": "card_advantage",
"protection": "protection",
# Additional mappings for creature stages
"creature": "creature",
"creatures": "creature",
"primary": "creature",
"secondary": "creature",
# Land-related hints
"land": "land",
"lands": "land",
"utility": "land",
"misc": "land",
"fetch": "land",
"dual": "land",
}
hinted_role = stage_map.get(stage_hint) if stage_hint else None
lib = getattr(b, "card_library", {}) or {}
# Case-insensitive lookup in deck library
lib_key = None
try:
if name_disp in lib:
lib_key = name_disp
else:
lm = {str(k).strip().lower(): k for k in lib.keys()}
lib_key = lm.get(name_l)
except Exception:
lib_key = None
entry = lib.get(lib_key) if lib_key else None
role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None)
if isinstance(role, str):
role = role.strip().lower()
# Build role-specific pool from combined DataFrame
items: list[dict] = []
def _clean(value: Any) -> str:
try:
if value is None:
return ""
if isinstance(value, float) and value != value:
return ""
text = str(value)
return text.strip()
except Exception:
return ""
def _normalize_tags(raw: Any) -> list[str]:
if not raw:
return []
if isinstance(raw, (list, tuple, set)):
return [str(t).strip() for t in raw if str(t).strip()]
if isinstance(raw, str):
txt = raw.strip()
if not txt:
return []
if txt.startswith("[") and txt.endswith("]"):
try:
import json as _json
parsed = _json.loads(txt)
if isinstance(parsed, list):
return [str(t).strip() for t in parsed if str(t).strip()]
except Exception:
pass
return [s.strip() for s in txt.split(',') if s.strip()]
return []
def _meta_from_row(row_obj: Any) -> dict[str, Any]:
meta = {
"mana": "",
"rarity": "",
"role": "",
"tags": [],
"hover_simple": True,
}
if row_obj is None:
meta["role"] = _clean(used_role or "")
return meta
def _pull(*keys: str) -> Any:
for key in keys:
try:
if isinstance(row_obj, dict):
val = row_obj.get(key)
elif hasattr(row_obj, "get"):
val = row_obj.get(key)
else:
val = getattr(row_obj, key, None)
except Exception:
val = None
if val not in (None, ""):
if isinstance(val, float) and val != val:
continue
return val
return None
meta["mana"] = _clean(_pull("mana_cost", "manaCost", "mana", "manaValue", "cmc", "mv"))
meta["rarity"] = _clean(_pull("rarity"))
role_val = _pull("role", "primaryRole", "subRole")
if not role_val:
role_val = used_role or ""
meta["role"] = _clean(role_val)
tags_val = _pull("themeTags", "_ltags", "tags")
meta_tags = _normalize_tags(tags_val)
meta["tags"] = meta_tags
meta["hover_simple"] = not (meta["mana"] or meta["rarity"] or (meta_tags and len(meta_tags) > 0))
return meta
def _build_meta_map(df_obj) -> dict[str, dict[str, Any]]:
mapping: dict[str, dict[str, Any]] = {}
try:
if df_obj is None or not hasattr(df_obj, "iterrows"):
return mapping
for _, row in df_obj.iterrows():
try:
nm_val = str(row.get("name") or "").strip()
except Exception:
nm_val = ""
if not nm_val:
continue
key = nm_val.lower()
if key in mapping:
continue
mapping[key] = _meta_from_row(row)
except Exception:
return mapping
return mapping
def _sampler(seq: list[str], limit: int) -> list[str]:
if limit <= 0:
return []
if len(seq) <= limit:
return list(seq)
rng = getattr(b, "rng", None)
try:
if rng is not None:
return rng.sample(seq, limit) if len(seq) >= limit else list(seq)
import random as _rnd
return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq)
except Exception:
return list(seq[:limit])
used_role = role if isinstance(role, str) and role else None
# Promote to 'land' role when the seed card is a land (regardless of stored role)
try:
if entry and isinstance(entry, dict):
ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower()
if "land" in ctype:
used_role = "land"
except Exception:
pass
df = getattr(b, "_combined_cards_df", None)
# Compute current deck fingerprint to avoid stale cached alternatives after stage changes
in_deck: set[str] = builder_present_names(b)
try:
import hashlib as _hl
deck_fp = _hl.md5(
("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8")
).hexdigest()[:8]
except Exception:
deck_fp = str(len(in_deck))
# Use a cache key that includes the exclusions version and deck fingerprint
cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp)
cached = None
if used_role != 'land' and not refresh_requested:
cached = _alts_get_cached(cache_key)
if cached is not None:
return HTMLResponse(cached)
def _render_and_cache(_items: list[dict]):
html_str = templates.get_template("build/_alternatives.html").render({
"request": request,
"name": name_disp,
"require_owned": require_owned,
"items": _items,
})
# Skip caching when used_role == land or refresh requested for per-call randomness
if used_role != 'land' and not refresh_requested:
try:
_alts_set_cached(cache_key, html_str)
except Exception:
pass
return HTMLResponse(html_str)
# Helper: map display names
def _display_map_for(lower_pool: set[str]) -> dict[str, str]:
try:
return builder_display_map(b, lower_pool)
except Exception:
return {nm: nm for nm in lower_pool}
# Common exclusions
# in_deck already computed above
def _exclude(df0):
out = df0.copy()
if "name" in out.columns:
out["_lname"] = out["name"].astype(str).str.strip().str.lower()
mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set()))
out = out[mask]
return out
# If we have data and a recognized role, mirror the phase logic
if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}):
pool = df.copy()
try:
pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell)
except Exception:
# best-effort normalize
pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else [])
# Role-specific base filtering
if used_role != "land":
# Exclude lands for non-land roles
if "type" in pool.columns:
pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)]
else:
# Keep only lands
if "type" in pool.columns:
pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)]
# Seed info to guide filtering
seed_is_basic = False
try:
seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])})
except Exception:
seed_is_basic = False
if seed_is_basic:
# For basics: show other basics (different colors) to allow quick swaps
try:
pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
except Exception:
pass
else:
# For non-basics: prefer other non-basics
try:
pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
except Exception:
pass
# Apply mono-color misc land filters (no debug CSV dependency)
try:
colors = list(getattr(b, 'color_identity', []) or [])
mono = len(colors) <= 1
mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])}
mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])}
kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])}
any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])]
extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])]
fetch_names = set()
for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values():
for nm in seq:
fetch_names.add(nm.lower())
for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []):
fetch_names.add(nm.lower())
# World Tree check needs all five colors
need_all_colors = {'w','u','b','r','g'}
def _illegal_world_tree(nm: str) -> bool:
return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors
# Text column fallback
text_col = 'text'
if text_col not in pool.columns:
for c in pool.columns:
if 'text' in c.lower():
text_col = c
break
def _exclude_row(row) -> bool:
nm_l = str(row['name']).strip().lower()
if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all:
return True
if mono and nm_l not in mono_keep and nm_l not in kindred_all:
try:
txt = str(row.get(text_col, '') or '').lower()
if any(p in txt for p in any_color_phrases + extra_rainbow_terms):
return True
except Exception:
pass
if nm_l in fetch_names:
return True
if _illegal_world_tree(nm_l):
return True
return False
pool = pool[~pool.apply(_exclude_row, axis=1)]
except Exception:
pass
# Optional sub-role filtering (only if enough depth)
try:
subrole = str((entry or {}).get('SubRole') or '').strip().lower()
if subrole:
# Heuristic categories for grouping
cat_map = {
'fetch': 'fetch',
'dual': 'dual',
'triple': 'triple',
'misc': 'misc',
'utility': 'misc',
'basic': 'basic'
}
target_cat = None
for key, val in cat_map.items():
if key in subrole:
target_cat = val
break
if target_cat and len(pool) > 25:
# Lightweight textual filter using known markers
def _cat_row(rname: str, rtype: str) -> str:
rl = rname.lower()
rt = rtype.lower()
if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')):
return 'fetch'
if 'triple' in rt or 'three' in rt:
return 'triple'
if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt:
# Basic-check crude
return 'basic'
return 'misc'
try:
tmp = pool.copy()
tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1)
sub_pool = tmp[tmp['_cat'] == target_cat]
if len(sub_pool) >= 10:
pool = sub_pool.drop(columns=['_cat'])
except Exception:
pass
except Exception:
pass
# Exclude commander explicitly
if "name" in pool.columns and commander_l:
pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l]
# Role-specific filter
def _is_wipe(tags: list[str]) -> bool:
return any(("board wipe" in t) or ("mass removal" in t) for t in tags)
def _is_removal(tags: list[str]) -> bool:
return any(("removal" in t) or ("spot removal" in t) for t in tags)
def _is_draw(tags: list[str]) -> bool:
return any(("draw" in t) or ("card advantage" in t) for t in tags)
def _matches_selected(tags: list[str]) -> bool:
try:
sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()]
if not sel:
return True
st = set(sel)
return any(any(s in t for s in st) for t in tags)
except Exception:
return True
if used_role == "ramp":
pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))]
elif used_role == "removal":
pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)]
elif used_role == "wipe":
pool = pool[pool["_ltags"].apply(_is_wipe)]
elif used_role == "card_advantage":
pool = pool[pool["_ltags"].apply(_is_draw)]
elif used_role == "protection":
pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))]
elif used_role == "creature":
# Keep only creatures; bias toward selected theme tags when available
if "type" in pool.columns:
pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)]
try:
pool = pool[pool["_ltags"].apply(_matches_selected)]
except Exception:
pass
elif used_role == "land":
# Already constrained to lands; no additional tag filter needed
pass
# Sort by priority like the builder
try:
pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"])
except Exception:
pass
# Exclusions and ownership (for non-random roles this stays before slicing)
pool = _exclude(pool)
try:
if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None):
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())})
except Exception:
pass
row_meta = _build_meta_map(pool)
# Land role: random 12 from top 60-100 window
if used_role == 'land':
import random as _rnd
total = len(pool)
if total == 0:
pass
else:
cap = min(100, total)
floor = min(60, cap) # if fewer than 60 just use all
if cap <= 12:
window_size = cap
else:
if cap == floor:
window_size = cap
else:
rng_obj = getattr(b, 'rng', None)
if rng_obj:
window_size = rng_obj.randint(floor, cap)
else:
window_size = _rnd.randint(floor, cap)
window_df = pool.head(window_size)
names = window_df['name'].astype(str).str.strip().tolist()
# Random sample up to 12 distinct names
sample_n = min(12, len(names))
if sample_n > 0:
if getattr(b, 'rng', None):
chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names
else:
chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names
lower_map = {n.strip().lower(): n for n in chosen}
display_map = _display_map_for(set(k for k in lower_map.keys()))
for nm_lc, orig in lower_map.items():
is_owned = (nm_lc in owned_set)
if require_owned and not is_owned:
continue
if nm_lc == name_l or (in_deck and nm_lc in in_deck):
continue
meta = row_meta.get(nm_lc) or _meta_from_row(None)
items.append({
'name': display_map.get(nm_lc, orig),
'name_lower': nm_lc,
'owned': is_owned,
'tags': meta.get('tags') or [],
'role': meta.get('role', ''),
'mana': meta.get('mana', ''),
'rarity': meta.get('rarity', ''),
'hover_simple': bool(meta.get('hover_simple', True)),
})
if items:
return _render_and_cache(items)
else:
# Default deterministic top-N (increase to 12 for parity)
lower_pool: list[str] = []
try:
lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist()
except Exception:
lower_pool = []
display_map = _display_map_for(set(lower_pool))
iteration_order = lower_pool
if refresh_requested and len(lower_pool) > 12:
window_size = min(len(lower_pool), 30)
window = lower_pool[:window_size]
sampled = _sampler(window, min(window_size, 12))
seen_sampled = set(sampled)
iteration_order = sampled + [nm for nm in lower_pool if nm not in seen_sampled]
for nm_l in iteration_order:
is_owned = (nm_l in owned_set)
if require_owned and not is_owned:
continue
if nm_l == name_l or (in_deck and nm_l in in_deck):
continue
meta = row_meta.get(nm_l) or _meta_from_row(None)
items.append({
"name": display_map.get(nm_l, nm_l),
"name_lower": nm_l,
"owned": is_owned,
"tags": meta.get("tags") or [],
"role": meta.get("role", ""),
"mana": meta.get("mana", ""),
"rarity": meta.get("rarity", ""),
"hover_simple": bool(meta.get("hover_simple", True)),
})
if len(items) >= 12:
break
if items:
return _render_and_cache(items)
# Fallback: tag-similarity suggestions (previous behavior)
tags_idx = getattr(b, "_card_name_tags_index", {}) or {}
seed_tags = set(tags_idx.get(name_l) or [])
all_names = set(tags_idx.keys())
candidates: list[tuple[str, int]] = [] # (name, score)
for nm in all_names:
if nm == name_l:
continue
if commander_l and nm == commander_l:
continue
if in_deck and nm in in_deck:
continue
if locked_set and nm in locked_set:
continue
if nm in alts_exclude:
continue
tgs = set(tags_idx.get(nm) or [])
score = len(seed_tags & tgs)
if score <= 0:
continue
candidates.append((nm, score))
# If no tag-based candidates, try shared trigger tag from library entry
if not candidates and isinstance(entry, dict):
try:
trig = str(entry.get("TriggerTag") or "").strip().lower()
except Exception:
trig = ""
if trig:
for nm, tglist in tags_idx.items():
if nm == name_l:
continue
if nm in {str(k).strip().lower() for k in lib.keys()}:
continue
if trig in {str(t).strip().lower() for t in (tglist or [])}:
candidates.append((nm, 1))
def _owned(nm: str) -> bool:
return nm in owned_set
candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0]))
if refresh_requested and len(candidates) > 1:
name_sequence = [nm for nm, _score in candidates]
sampled_names = _sampler(name_sequence, min(len(name_sequence), 10))
sampled_set = set(sampled_names)
reordered: list[tuple[str, int]] = []
for nm in sampled_names:
for cand_nm, cand_score in candidates:
if cand_nm == nm:
reordered.append((cand_nm, cand_score))
break
for cand_nm, cand_score in candidates:
if cand_nm not in sampled_set:
reordered.append((cand_nm, cand_score))
candidates = reordered
pool_lower = {nm for (nm, _s) in candidates}
display_map = _display_map_for(pool_lower)
seen = set()
for nm, score in candidates:
if nm in seen:
continue
seen.add(nm)
is_owned = (nm in owned_set)
if require_owned and not is_owned:
continue
items.append({
"name": display_map.get(nm, nm),
"name_lower": nm,
"owned": is_owned,
"tags": list(tags_idx.get(nm) or []),
"role": "",
"mana": "",
"rarity": "",
"hover_simple": True,
})
if len(items) >= 10:
break
return _render_and_cache(items)
except Exception as e:
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
@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 = (
'<div hx-get="/build/alternatives?name='
+ quote_plus(new_key)
+ f'&owned_only={owned_only_int}" hx-trigger="load delay:80ms" '
'hx-target="closest .alts" hx-swap="outerHTML" aria-hidden="true"></div>'
)
html = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div>Replaced <strong>{o_disp}</strong> with <strong>{new_key}</strong>.</div>'
'<div class="muted" style="margin-top:.35rem;">Compliance panel will refresh.</div>'
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>'
+ refresh +
'</div>'
)
# Inline mutate the nearest card tile to reflect the new card without a rerun
mutator = """
<script>
(function(){
try{
var panel = document.currentScript && document.currentScript.previousElementSibling && document.currentScript.previousElementSibling.classList && document.currentScript.previousElementSibling.classList.contains('alts') ? document.currentScript.previousElementSibling : null;
if(!panel){ return; }
var oldName = panel.getAttribute('data-old') || '';
var newName = panel.getAttribute('data-new') || '';
var isOwned = panel.getAttribute('data-owned') === '1';
var isLocked = panel.getAttribute('data-locked') === '1';
var tile = panel.closest('.card-tile');
if(!tile) return;
tile.setAttribute('data-card-name', newName);
var img = tile.querySelector('img.card-thumb');
if(img){
var base = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=';
img.src = base + 'normal';
img.setAttribute('srcset',
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=small 160w, ' +
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=normal 488w, ' +
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=large 672w'
);
img.setAttribute('alt', newName + ' image');
img.setAttribute('data-card-name', newName);
}
var nameEl = tile.querySelector('.name');
if(nameEl){ nameEl.textContent = newName; }
var own = tile.querySelector('.owned-badge');
if(own){
own.textContent = isOwned ? '' : '';
own.title = isOwned ? 'Owned' : 'Not owned';
tile.setAttribute('data-owned', isOwned ? '1' : '0');
}
tile.classList.toggle('locked', isLocked);
var imgBtn = tile.querySelector('.img-btn');
if(imgBtn){
try{
var valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
var obj = JSON.parse(valsAttr.replace(/&quot;/g, '"'));
obj.name = newName;
imgBtn.setAttribute('hx-vals', JSON.stringify(obj));
}catch(e){}
}
var lockBtn = tile.querySelector('.lock-box .btn-lock');
if(lockBtn){
try{
var v = lockBtn.getAttribute('hx-vals') || '{}';
var o = JSON.parse(v.replace(/&quot;/g, '"'));
o.name = newName;
lockBtn.setAttribute('hx-vals', JSON.stringify(o));
}catch(e){}
}
}catch(_){}
})();
</script>
"""
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">Replaced <strong>{o_disp}</strong> → <strong>{new_key}</strong></span>'
f'</div>'
)
# OOB fetch to refresh compliance panel
refresher = (
'<div hx-get="/build/compliance" hx-target="#compliance-panel" hx-swap="outerHTML" '
'hx-trigger="load" hx-swap-oob="true"></div>'
)
# 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 = '<div class="alts"'
replacement = (
'<div class="alts" '
+ 'data-old="' + _esc(o_disp) + '" '
+ 'data-new="' + _esc(new_key) + '" '
+ 'data-owned="' + data_owned + '" '
+ 'data-locked="' + data_locked + '"'
)
html = html.replace(prefix, replacement, 1)
return HTMLResponse(html + mutator + chip + refresher)
except Exception:
# Fall back to rerun guidance if inline swap fails
pass
# Fallback: advise rerun
hint = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div>Locked <strong>{new}</strong> and unlocked <strong>{old}</strong>.</div>'
'<div class="muted" style="margin-top:.35rem;">Now click <em>Rerun Stage</em> with Replace: On to apply this change.</div>'
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
'<input type="hidden" name="show_skipped" value="1" />'
'<button type="submit" class="btn-rerun">Rerun stage</button>'
'</form>'
'<form hx-post="/build/replace/undo" hx-target="closest .alts" hx-swap="outerHTML" style="display:inline; margin:0;">'
f'<input type="hidden" name="old" value="{old}" />'
f'<input type="hidden" name="new" value="{new}" />'
'<button type="submit" class="btn" title="Undo this replace">Undo</button>'
'</form>'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>'
'</div>'
)
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">Replaced <strong>{old}</strong> → <strong>{new}</strong></span>'
f'</div>'
)
# 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 = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div>{msg}.</div>'
'<div class="muted" style="margin-top:.35rem;">Rerun the stage to recompute picks if needed.</div>'
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
'<input type="hidden" name="show_skipped" value="1" />'
'<button type="submit" class="btn-rerun">Rerun stage</button>'
'</form>'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>'
'</div>'
)
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">{msg}</span>'
f'</div>'
)
return HTMLResponse(html + chip)
@router.get("/compare")
async def build_compare(runA: str, runB: str):
"""Stub: return empty diffs; later we can diff summary files under deck_files."""
return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []})
@router.get("/compliance", response_class=HTMLResponse)
async def build_compliance_panel(request: Request) -> HTMLResponse:
"""Render a live Bracket compliance panel with manual enforcement controls.
Computes compliance against the current builder state without exporting, attaches a non-destructive
enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial.
Returns empty content when no active build context exists.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx") or {}
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
if not b:
return HTMLResponse("")
# Compute compliance snapshot in-memory and attach planning preview
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp = None
try:
if comp:
from ..services import orchestrator as orch
comp = orch._attach_enforcement_plan(b, comp)
except Exception:
pass
if not comp:
return HTMLResponse("")
# Build flagged metadata (role, owned) for visual tiles and role-aware alternatives
# For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced.
flagged_meta: list[dict] = []
try:
cats = comp.get('categories') or {}
owned_lower = owned_set_helper()
lib = getattr(b, 'card_library', {}) or {}
commander_l = str((sess.get('commander') or '')).strip().lower()
# map category key -> display label
labels = {
'game_changers': 'Game Changers',
'extra_turns': 'Extra Turns',
'mass_land_denial': 'Mass Land Denial',
'tutors_nonland': 'Nonland Tutors',
'two_card_combos': 'Two-Card Combos',
}
seen_lower: set[str] = set()
for key, cat in cats.items():
try:
status = str(cat.get('status') or '').upper()
# Only surface tiles for WARN and FAIL
if status not in {"WARN", "FAIL"}:
continue
# For two-card combos, split pairs into individual cards and skip commander
if key == 'two_card_combos' and status == 'FAIL':
# Prefer the structured combos list to ensure we only expand counted pairs
pairs = []
try:
for p in (comp.get('combos') or []):
if p.get('cheap_early'):
pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip()))
except Exception:
pairs = []
# Fallback to parsing flagged strings like "A + B"
if not pairs:
try:
for s in (cat.get('flagged') or []):
if not isinstance(s, str):
continue
parts = [x.strip() for x in s.split('+') if x and x.strip()]
if len(parts) == 2:
pairs.append((parts[0], parts[1]))
except Exception:
pass
for a, bname in pairs:
for nm in (a, bname):
if not nm:
continue
nm_l = nm.strip().lower()
if nm_l == commander_l:
# Don't prompt replacing the commander
continue
if nm_l in seen_lower:
continue
seen_lower.add(nm_l)
entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {}
role = entry.get('Role') or ''
flagged_meta.append({
'name': nm,
'category': labels.get(key, key.replace('_',' ').title()),
'role': role,
'owned': (nm_l in owned_lower),
'severity': status,
})
continue
# Default handling for list/tag categories
names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)]
for nm in names:
nm_l = str(nm).strip().lower()
if nm_l in seen_lower:
continue
seen_lower.add(nm_l)
entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {}
role = entry.get('Role') or ''
flagged_meta.append({
'name': nm,
'category': labels.get(key, key.replace('_',' ').title()),
'role': role,
'owned': (nm_l in owned_lower),
'severity': status,
})
except Exception:
continue
except Exception:
flagged_meta = []
# Render partial
ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta}
return templates.TemplateResponse("build/_compliance_panel.html", ctx2)
@router.post("/enforce/apply", response_class=HTMLResponse)
async def build_enforce_apply(request: Request) -> HTMLResponse:
"""Apply bracket enforcement now using current locks as user guidance.
This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Ensure build context exists
ctx = sess.get("build_ctx") or {}
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
if not b:
# No active build: show Step 5 with an error
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
# Ensure we have a CSV base stem for consistent re-exports
base_stem = None
try:
csv_path = ctx.get("csv_path")
if isinstance(csv_path, str) and csv_path:
import os as _os
base_stem = _os.path.splitext(_os.path.basename(csv_path))[0]
except Exception:
base_stem = None
# If missing, export once to establish base
if not base_stem:
try:
ctx["csv_path"] = b.export_decklist_csv()
import os as _os
base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0]
# Also produce a text export for completeness
ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt')
except Exception:
base_stem = None
# Add lock placeholders into the library before enforcement so user choices are present
try:
locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
if locks:
df = getattr(b, "_combined_cards_df", None)
lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}
for lname in locks:
if lname in lib_l:
continue
target_name = None
card_type = ''
mana_cost = ''
try:
if df is not None and not df.empty:
row = df[df['name'].astype(str).str.lower() == lname]
if not row.empty:
target_name = str(row.iloc[0]['name'])
card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '')
except Exception:
target_name = None
if target_name:
b.card_library[target_name] = {
'Count': 1,
'Card Type': card_type,
'Mana Cost': mana_cost,
'Role': 'Locked',
'SubRole': '',
'AddedBy': 'Lock',
'TriggerTag': '',
}
except Exception:
pass
# Thread preferred replacements from context onto builder so enforcement can honor them
try:
pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None
if isinstance(pref, dict):
setattr(b, 'preferred_replacements', dict(pref))
except Exception:
pass
# Run enforcement + re-exports (tops up to 100 internally)
try:
rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto')
except Exception as e:
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
# Reload compliance JSON and summary
compliance = None
try:
if base_stem:
import os as _os
import json as _json
comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
if _os.path.exists(comp_path):
with open(comp_path, 'r', encoding='utf-8') as _cf:
compliance = _json.load(_cf)
except Exception:
compliance = None
# Rebuild Step 5 context (done state)
# Ensure csv/txt paths on ctx reflect current base
try:
import os as _os
ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path")
ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path")
except Exception:
pass
# Compute total_cards
try:
total_cards = 0
for _n, _e in getattr(b, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
except Exception:
total_cards = None
res = {
"done": True,
"label": "Complete",
"log_delta": "",
"idx": len(ctx.get("stages", []) or []),
"total": len(ctx.get("stages", []) or []),
"csv_path": ctx.get("csv_path"),
"txt_path": ctx.get("txt_path"),
"summary": getattr(b, 'build_deck_summary', lambda: None)(),
"total_cards": total_cards,
"added_total": 0,
"compliance": compliance or rep,
}
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
return resp
@router.get("/enforcement", response_class=HTMLResponse)
async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
"""Full-page enforcement review: show compliance panel with swaps and controls."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx") or {}
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
if not b:
# No active build
base = step5_empty_ctx(request, sess)
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Compute compliance snapshot and attach planning preview
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp = None
try:
if comp:
from ..services import orchestrator as orch
comp = orch._attach_enforcement_plan(b, comp)
except Exception:
pass
try:
summary_token = int(sess.get("step5_summary_token", 0))
except Exception:
summary_token = 0
ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
return resp
@router.get("/permalink")
async def build_permalink(request: Request):
"""Return a URL-safe JSON payload representing current run config (basic)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
payload = {
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"ideals": sess.get("ideals"),
"tag_mode": sess.get("tag_mode", "AND"),
"flags": {
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
},
"locks": list(sess.get("locks", [])),
}
# Optional: random build fields (if present in session)
try:
rb = sess.get("random_build") or {}
if rb:
# Only include known keys to avoid leaking unrelated session data
inc: dict[str, Any] = {}
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
if rb.get(key) is not None:
inc[key] = rb.get(key)
resolved_list = rb.get("resolved_themes")
if isinstance(resolved_list, list):
inc["resolved_themes"] = list(resolved_list)
resolved_info = rb.get("resolved_theme_info")
if isinstance(resolved_info, dict):
inc["resolved_theme_info"] = dict(resolved_info)
if rb.get("combo_fallback") is not None:
inc["combo_fallback"] = bool(rb.get("combo_fallback"))
if rb.get("synergy_fallback") is not None:
inc["synergy_fallback"] = bool(rb.get("synergy_fallback"))
if rb.get("fallback_reason") is not None:
inc["fallback_reason"] = rb.get("fallback_reason")
requested = rb.get("requested_themes")
if isinstance(requested, dict):
inc["requested_themes"] = dict(requested)
if rb.get("auto_fill_enabled") is not None:
inc["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled"))
if rb.get("auto_fill_applied") is not None:
inc["auto_fill_applied"] = bool(rb.get("auto_fill_applied"))
auto_filled = rb.get("auto_filled_themes")
if isinstance(auto_filled, list):
inc["auto_filled_themes"] = list(auto_filled)
display = rb.get("display_themes")
if isinstance(display, list):
inc["display_themes"] = list(display)
if inc:
payload["random"] = inc
except Exception:
pass
# Add include/exclude cards and advanced options if feature is enabled
if ALLOW_MUST_HAVES:
if sess.get("include_cards"):
payload["include_cards"] = sess.get("include_cards")
if sess.get("exclude_cards"):
payload["exclude_cards"] = sess.get("exclude_cards")
if sess.get("enforcement_mode"):
payload["enforcement_mode"] = sess.get("enforcement_mode")
if sess.get("allow_illegal") is not None:
payload["allow_illegal"] = sess.get("allow_illegal")
if sess.get("fuzzy_matching") is not None:
payload["fuzzy_matching"] = sess.get("fuzzy_matching")
try:
import base64
import json as _json
raw = _json.dumps(payload, separators=(",", ":"))
token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
# Also include decoded state for convenience/testing
return JSONResponse({"ok": True, "permalink": f"/build/from?state={token}", "state": payload})
except Exception:
return JSONResponse({"ok": True, "state": payload})
@router.get("/from", response_class=HTMLResponse)
async def build_from(request: Request, state: str | None = None) -> HTMLResponse:
"""Load a run from a permalink token."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if state:
try:
import base64
import json as _json
pad = '=' * (-len(state) % 4)
raw = base64.urlsafe_b64decode((state + pad).encode("ascii")).decode("utf-8")
data = _json.loads(raw)
sess["commander"] = data.get("commander")
sess["tags"] = data.get("tags", [])
sess["bracket"] = data.get("bracket")
if data.get("ideals"):
sess["ideals"] = data.get("ideals")
sess["tag_mode"] = data.get("tag_mode", "AND")
flags = data.get("flags") or {}
sess["use_owned_only"] = bool(flags.get("owned_only"))
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
sess["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics"))
sess["locks"] = list(data.get("locks", []))
# Optional random build rehydration
try:
r = data.get("random") or {}
if r:
rb_payload: dict[str, Any] = {}
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
if r.get(key) is not None:
rb_payload[key] = r.get(key)
if isinstance(r.get("resolved_themes"), list):
rb_payload["resolved_themes"] = list(r.get("resolved_themes") or [])
if isinstance(r.get("resolved_theme_info"), dict):
rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info"))
if r.get("combo_fallback") is not None:
rb_payload["combo_fallback"] = bool(r.get("combo_fallback"))
if r.get("synergy_fallback") is not None:
rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback"))
if r.get("fallback_reason") is not None:
rb_payload["fallback_reason"] = r.get("fallback_reason")
if isinstance(r.get("requested_themes"), dict):
requested_payload = dict(r.get("requested_themes"))
if "auto_fill_enabled" in requested_payload:
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
rb_payload["requested_themes"] = requested_payload
if r.get("auto_fill_enabled") is not None:
rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled"))
if r.get("auto_fill_applied") is not None:
rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied"))
auto_filled = r.get("auto_filled_themes")
if isinstance(auto_filled, list):
rb_payload["auto_filled_themes"] = list(auto_filled)
display = r.get("display_themes")
if isinstance(display, list):
rb_payload["display_themes"] = list(display)
if "seed" in rb_payload:
try:
seed_int = int(rb_payload["seed"])
rb_payload["seed"] = seed_int
rb_payload.setdefault("recent_seeds", [seed_int])
except Exception:
rb_payload.setdefault("recent_seeds", [])
sess["random_build"] = rb_payload
except Exception:
pass
# Import exclude_cards if feature is enabled and present
if ALLOW_MUST_HAVES and data.get("exclude_cards"):
sess["exclude_cards"] = data.get("exclude_cards")
sess["last_step"] = 4
except Exception:
pass
locks_restored = 0
try:
locks_restored = len(sess.get("locks", []) or [])
except Exception:
locks_restored = 0
resp = templates.TemplateResponse(request, "build/_step4.html", {
"labels": orch.ideal_labels(),
"values": sess.get("ideals") or orch.ideal_defaults(),
"commander": sess.get("commander"),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
"locks_restored": locks_restored,
})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Validation endpoints moved to build_validation.py module