mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-18 11:16:30 +01:00
1262 lines
52 KiB
Python
1262 lines
52 KiB
Python
"""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'<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)
|
|
|
|
|
|
# ==============================================================================
|
|
# 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
|