mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-18 11:16:30 +01:00
1462 lines
59 KiB
Python
1462 lines
59 KiB
Python
"""
|
|
Build Wizard Routes - Step-by-step deck building flow.
|
|
|
|
Handles the 5-step wizard interface for deck building:
|
|
- Step 1: Commander selection
|
|
- Step 2: Theme and partner selection
|
|
- Step 3: Ideal card count targets
|
|
- Step 4: Owned card preferences and review
|
|
- Step 5: Build execution and results
|
|
|
|
Extracted from build.py as part of Phase 3 modularization (Roadmap 9 M1).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Request, Form, Query
|
|
from fastapi.responses import HTMLResponse
|
|
from typing import Any
|
|
|
|
from ..app import templates, ENABLE_PARTNER_MECHANICS
|
|
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 ..services import orchestrator as orch
|
|
from ..services.tasks import get_session, new_sid
|
|
from deck_builder import builder_constants as bc
|
|
from ..services.combo_utils import detect_all as _detect_all
|
|
from .build_partners import _partner_ui_context, _resolve_partner_selection
|
|
from .build_multicopy import _rebuild_ctx_with_multicopy
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
|
|
"""Merge HX-Trigger header data into response."""
|
|
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:
|
|
import json
|
|
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:
|
|
import json
|
|
response.headers["HX-Trigger"] = json.dumps(payload)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str:
|
|
"""Generate placeholder HTML for step 5 summary panel."""
|
|
from html import escape as _esc
|
|
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>'
|
|
)
|
|
|
|
|
|
def _current_builder_summary(sess: dict) -> Any | None:
|
|
"""Get current builder's deck summary."""
|
|
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):
|
|
summary_data = summary_fn()
|
|
# Also save to session for consistency
|
|
if summary_data:
|
|
sess["summary"] = summary_data
|
|
return summary_data
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _get_current_deck_names(sess: dict) -> list[str]:
|
|
"""Get names of cards currently in the deck."""
|
|
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 []
|
|
|
|
|
|
# ============================================================================
|
|
# Step 1: Commander Selection
|
|
# ============================================================================
|
|
|
|
@router.get("/step1", response_class=HTMLResponse)
|
|
async def build_step1(request: Request) -> HTMLResponse:
|
|
"""Display commander search form."""
|
|
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:
|
|
"""Search for commander candidates and optionally auto-select."""
|
|
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:
|
|
"""Preview commander details before confirmation."""
|
|
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:
|
|
"""Confirm commander selection and proceed to step 2."""
|
|
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
|
|
|
|
|
|
# ============================================================================
|
|
# Step 2: Theme and Partner Selection
|
|
# ============================================================================
|
|
|
|
@router.get("/step2", response_class=HTMLResponse)
|
|
async def build_step2_get(request: Request) -> HTMLResponse:
|
|
"""Display theme picker and partner selection."""
|
|
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:
|
|
"""Submit theme and partner selections, proceed to step 3."""
|
|
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
|
|
|
|
|
|
# ============================================================================
|
|
# Step 3: Ideal Card Counts
|
|
# ============================================================================
|
|
|
|
@router.get("/step3", response_class=HTMLResponse)
|
|
async def build_step3_get(request: Request) -> HTMLResponse:
|
|
"""Display ideal card count sliders."""
|
|
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.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:
|
|
"""Submit ideal card counts, proceed to step 4."""
|
|
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
|
|
|
|
|
|
# ============================================================================
|
|
# Step 4: Review and Owned Cards
|
|
# ============================================================================
|
|
|
|
@router.get("/step4", response_class=HTMLResponse)
|
|
async def build_step4_get(request: Request) -> HTMLResponse:
|
|
"""Display review page with owned card preferences."""
|
|
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")),
|
|
},
|
|
)
|
|
|
|
|
|
@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
|
|
|
|
|
|
# ============================================================================
|
|
# Step 5: Build Execution and Results
|
|
# ============================================================================
|
|
|
|
@router.get("/step5", response_class=HTMLResponse)
|
|
async def build_step5_get(request: Request) -> HTMLResponse:
|
|
"""Display step 5 initial state (empty/ready to start build)."""
|
|
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.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.post("/step5/start", response_class=HTMLResponse)
|
|
async def build_step5_start(request: Request) -> HTMLResponse:
|
|
"""Initialize build context and run first stage."""
|
|
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)
|
|
# Save summary to session for deck_summary partial to access
|
|
if res.get("summary"):
|
|
sess["summary"] = res["summary"]
|
|
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.post("/step5/continue", response_class=HTMLResponse)
|
|
async def build_step5_continue(request: Request) -> HTMLResponse:
|
|
"""Continue to next stage of the build."""
|
|
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"
|
|
# Save summary to session for deck_summary partial to access
|
|
if res.get("summary"):
|
|
sess["summary"] = res["summary"]
|
|
# Keep commander in session for Step 5 display (will be overwritten on next build)
|
|
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:
|
|
"""Rerun current stage with modifications."""
|
|
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)))
|
|
# Save summary to session for deck_summary partial to access
|
|
if res.get("summary"):
|
|
sess["summary"] = res["summary"]
|
|
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/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.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:
|
|
"""Render deck summary panel for step 5 if build is ready."""
|
|
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)
|
|
|
|
# Add hover_tags_joined for template if missing
|
|
if "hover_tags_joined" not in ctx:
|
|
hover_tags_source = ctx.get("deck_theme_tags") if ctx.get("deck_theme_tags") else ctx.get("commander_combined_tags")
|
|
if hover_tags_source:
|
|
ctx["hover_tags_joined"] = ", ".join(str(t) for t in hover_tags_source)
|
|
|
|
response = templates.TemplateResponse("partials/deck_summary.html", ctx)
|
|
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
return response
|
|
|
|
|
|
# ============================================================================
|
|
# Utility Routes
|
|
# ============================================================================
|
|
|
|
@router.get("/banner", response_class=HTMLResponse)
|
|
async def build_banner(request: Request, step: str = "", i: int | None = None, n: int | None = None) -> HTMLResponse:
|
|
"""Render dynamic wizard banner subtitle."""
|
|
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")},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Combo & Synergy Panel
|
|
# ============================================================================
|
|
|
|
@router.get("/combos", response_class=HTMLResponse)
|
|
async def build_combos_panel(request: Request) -> HTMLResponse:
|
|
"""Display combo and synergy detection panel."""
|
|
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:
|
|
"""Save combo preferences and re-render panel."""
|
|
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)
|