"""New Build Flow Routes
Handles the New Deck modal, commander search/inspection, skip controls,
new deck submission, Quick Build automation, and batch builds.
Extracted in Phase 4 of Roadmap 9 M1 Backend Standardization.
"""
from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query, BackgroundTasks
from fastapi.responses import HTMLResponse, JSONResponse
from typing import Any, Dict
from ..app import (
ALLOW_MUST_HAVES,
ENABLE_CUSTOM_THEMES,
SHOW_MUST_HAVE_BUTTONS,
ENABLE_PARTNER_MECHANICS,
WEB_IDEALS_UI,
ENABLE_BATCH_BUILD,
DEFAULT_THEME_MATCH_MODE,
)
from ..services.build_utils import (
step5_ctx_from_result,
start_ctx_from_session,
)
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.tasks import get_session, new_sid
from deck_builder.builder import DeckBuilder
from commander_exclusions import lookup_commander_detail
from .build_themes import _custom_theme_context
from .build_partners import (
_partner_ui_context,
_resolve_partner_selection,
)
from ..services import custom_theme_manager as theme_mgr
router = APIRouter()
# ==============================================================================
# New Deck Modal and Commander Search
# ==============================================================================
@router.get("/new", response_class=HTMLResponse)
async def build_new_modal(request: Request) -> HTMLResponse:
"""Return the New Deck modal content (for an overlay)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Clear build context to allow skip controls to work
# (Otherwise toggle endpoint thinks build is in progress)
if "build_ctx" in sess:
try:
del sess["build_ctx"]
except Exception:
pass
# M2: Clear all skip preferences for true "New Deck"
skip_keys = [
"skip_lands", "skip_to_misc", "skip_basics", "skip_staples",
"skip_kindred", "skip_fetches", "skip_duals", "skip_triomes",
"skip_all_creatures",
"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill",
"skip_all_spells",
"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage",
"skip_protection", "skip_spell_fill",
"skip_post_adjust"
]
for key in skip_keys:
sess.pop(key, None)
# M2: Check if this is a quick-build scenario (from commander browser)
# Use the quick_build flag set by /build route when ?commander= param present
is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag
# M2: Clear commander and form selections for fresh start (unless quick build)
if not is_quick_build:
commander_keys = [
"commander", "partner", "background", "commander_mode",
"themes", "bracket"
]
for key in commander_keys:
sess.pop(key, None)
theme_context = _custom_theme_context(request, sess)
ctx = {
"request": request,
"brackets": orch.bracket_options(),
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"form": {
"commander": sess.get("commander", ""), # Pre-fill for quick-build
"prefer_combos": bool(sess.get("prefer_combos")),
"combo_count": sess.get("combo_target_count"),
"combo_balance": sess.get("combo_balance"),
"enable_multicopy": bool(sess.get("multi_copy")),
"use_owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
# Add ideal values from session (will be None on first load, triggering defaults)
"ramp": sess.get("ideals", {}).get("ramp"),
"lands": sess.get("ideals", {}).get("lands"),
"basic_lands": sess.get("ideals", {}).get("basic_lands"),
"creatures": sess.get("ideals", {}).get("creatures"),
"removal": sess.get("ideals", {}).get("removal"),
"wipes": sess.get("ideals", {}).get("wipes"),
"card_advantage": sess.get("ideals", {}).get("card_advantage"),
"protection": sess.get("ideals", {}).get("protection"),
},
"tag_slot_html": None,
}
for key, value in theme_context.items():
if key == "request":
continue
ctx[key] = value
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/new/candidates", response_class=HTMLResponse)
async def build_new_candidates(request: Request, commander: str = Query("")) -> HTMLResponse:
"""Return a small list of commander candidates for the modal live search."""
q = (commander or "").strip()
items = orch.commander_candidates(q, limit=8) if q else []
candidates: list[dict[str, Any]] = []
for name, score, colors in items:
detail = lookup_commander_detail(name)
preferred = name
warning = None
if detail:
eligible_raw = detail.get("eligible_faces")
eligible = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
norm_name = str(name).strip().casefold()
eligible_norms = [face.casefold() for face in eligible]
if eligible and norm_name not in eligible_norms:
preferred = eligible[0]
primary = str(detail.get("primary_face") or detail.get("name") or name).strip()
if len(eligible) == 1:
warning = (
f"Use the back face '{preferred}' when building. Front face '{primary}' can't lead a deck."
)
else:
faces = ", ".join(f"'{face}'" for face in eligible)
warning = (
f"This commander only works from specific faces: {faces}."
)
candidates.append(
{
"display": name,
"value": preferred,
"score": score,
"colors": colors,
"warning": warning,
}
)
ctx = {"request": request, "query": q, "candidates": candidates}
return templates.TemplateResponse("build/_new_deck_candidates.html", ctx)
@router.get("/new/inspect", response_class=HTMLResponse)
async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse:
"""When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates)."""
info = orch.commander_select(name)
if not info.get("ok"):
return HTMLResponse(f'
Commander not found: {name}
')
tags = orch.tags_for_commander(info["name"]) or []
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
exclusion_detail = lookup_commander_detail(info["name"])
# Render tags slot content and OOB commander preview simultaneously
# Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer)
is_gc = False
try:
is_gc = bool(info["name"] in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
ctx = {
"request": request,
"commander": {"name": info["name"], "exclusion": exclusion_detail},
"tags": tags,
"recommended": recommended,
"recommended_reasons": recommended_reasons,
"gc_commander": is_gc,
"brackets": orch.bracket_options(),
}
ctx.update(
_partner_ui_context(
info["name"],
partner_enabled=False,
secondary_selection=None,
background_selection=None,
combined_preview=None,
warnings=None,
partner_error=None,
auto_note=None,
)
)
partner_tags = ctx.get("partner_theme_tags") or []
if partner_tags:
merged_tags: list[str] = []
seen: set[str] = set()
for source in (partner_tags, tags):
for tag in source:
token = str(tag).strip()
if not token:
continue
key = token.casefold()
if key in seen:
continue
seen.add(key)
merged_tags.append(token)
ctx["tags"] = merged_tags
# Deduplicate recommended: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
existing_recommended = ctx.get("recommended") or []
deduplicated_recommended = [
tag for tag in existing_recommended
if str(tag).strip().casefold() not in partner_tags_lower
]
ctx["recommended"] = deduplicated_recommended
reason_map = dict(ctx.get("recommended_reasons") or {})
for tag in partner_tags:
if tag not in reason_map:
reason_map[tag] = "Synergizes with partner pairing"
ctx["recommended_reasons"] = reason_map
return templates.TemplateResponse("build/_new_deck_tags.html", ctx)
# ==============================================================================
# Skip Controls
# ==============================================================================
@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))
})
# ==============================================================================
# New Deck Submission (Main Handler)
# ==============================================================================
@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
# ==============================================================================
# Quick Build Progress Polling
# ==============================================================================
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("[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
# CRITICAL: Persist summary to session (bug fix from Phase 3)
if res and res.get("summary"):
sess["summary"] = res["summary"]
# Small delay to show finishing message
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.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