from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse, JSONResponse
from ..app import templates
from deck_builder import builder_constants as bc
from ..services import orchestrator as orch
from ..services import owned_store
from ..services.tasks import get_session, new_sid
from html import escape as _esc
router = APIRouter(prefix="/build")
# --- lightweight in-memory TTL cache for alternatives (Phase 9 planned item) ---
_ALTS_CACHE: dict[tuple[str, str, bool], tuple[float, str]] = {}
_ALTS_TTL_SECONDS = 60.0 # short TTL; avoids stale UI while helping burst traffic
def _alts_get_cached(key: tuple[str, str, bool]) -> str | None:
try:
ts, html = _ALTS_CACHE.get(key, (0.0, ""))
import time as _t
if ts and (_t.time() - ts) < _ALTS_TTL_SECONDS:
return html
except Exception:
return None
return None
def _alts_set_cached(key: tuple[str, str, bool], html: str) -> None:
try:
import time as _t
_ALTS_CACHE[key] = (_t.time(), html)
except Exception:
pass
@router.get("/", response_class=HTMLResponse)
async def build_index(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Determine last step (fallback heuristics if not set)
last_step = sess.get("last_step")
if not last_step:
if sess.get("build_ctx"):
last_step = 5
elif sess.get("ideals"):
last_step = 4
elif sess.get("bracket"):
last_step = 3
elif sess.get("commander"):
last_step = 2
else:
last_step = 1
resp = templates.TemplateResponse(
"build/index.html",
{
"request": request,
"sid": sid,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"name": sess.get("custom_export_base"),
"last_step": last_step,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Unified "New Deck" modal (steps 1–3 condensed)
@router.get("/new", response_class=HTMLResponse)
async def build_new_modal(request: Request) -> HTMLResponse:
"""Return the New Deck modal content (for an overlay)."""
sid = request.cookies.get("sid") or new_sid()
ctx = {
"request": request,
"brackets": orch.bracket_options(),
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
}
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 []
ctx = {"request": request, "query": q, "candidates": items}
return templates.TemplateResponse("build/_new_deck_candidates.html", ctx)
@router.get("/new/inspect", response_class=HTMLResponse)
async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse:
"""When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates)."""
info = orch.commander_select(name)
if not info.get("ok"):
return HTMLResponse(f'
Commander not found: {name}
')
tags = orch.tags_for_commander(info["name"]) or []
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
# Render tags slot content and OOB commander preview simultaneously
ctx = {
"request": request,
"commander": {"name": info["name"]},
"tags": tags,
"recommended": recommended,
"recommended_reasons": recommended_reasons,
}
return templates.TemplateResponse("build/_new_deck_tags.html", ctx)
@router.post("/new", response_class=HTMLResponse)
async def build_new_submit(
request: Request,
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"),
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),
) -> 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)
# 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(),
"form": {
"name": name,
"commander": commander,
"primary_tag": primary_tag or "",
"secondary_tag": secondary_tag or "",
"tertiary_tag": tertiary_tag or "",
"tag_mode": tag_mode or "AND",
"bracket": bracket,
}
}
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Save to session
sess["commander"] = sel.get("name") or commander
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
# If commander has a tag list and primary missing, set first recommended as default
if not tags:
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
# 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
# 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
# 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
opts = orch.bracket_options()
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
locks=list(sess.get("locks", [])),
custom_export_base=sess.get("custom_export_base"),
)
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
status = "Build complete" if res.get("done") else "Stage complete"
sess["last_step"] = 5
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": sess.get("commander"),
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": res.get("label"),
"log": res.get("log_delta", ""),
"added_cards": res.get("added_cards", []),
"i": res.get("idx"),
"n": res.get("total"),
"csv_path": res.get("csv_path") if res.get("done") else None,
"txt_path": res.get("txt_path") if res.get("done") else None,
"summary": res.get("summary") if res.get("done") else None,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": False,
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
"skipped": bool(res.get("skipped")),
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step1", response_class=HTMLResponse)
async def build_step1(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1", response_class=HTMLResponse)
async def build_step1_search(
request: Request,
query: str = Form(""),
auto: str | None = Form(None),
active: str | None = Form(None),
) -> HTMLResponse:
query = (query or "").strip()
auto_enabled = True if (auto == "1") else False
candidates = []
if query:
candidates = orch.commander_candidates(query, limit=10)
# Optional auto-select at a stricter threshold
if auto_enabled and candidates and len(candidates[0]) >= 2 and int(candidates[0][1]) >= 98:
top_name = candidates[0][0]
res = orch.commander_select(top_name)
if res.get("ok"):
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
resp = templates.TemplateResponse(
"build/_step2.html",
{
"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(),
"clear_persisted": True,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse(
"build/_step1.html",
{
"request": request,
"query": query,
"candidates": candidates,
"auto": auto_enabled,
"active": active,
"count": len(candidates) if candidates else 0,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1/inspect", response_class=HTMLResponse)
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
info = orch.commander_inspect(name)
resp = templates.TemplateResponse(
"build/_step1.html",
{"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1/confirm", response_class=HTMLResponse)
async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse:
res = orch.commander_select(name)
if not res.get("ok"):
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Proceed to step2 placeholder and reset any prior build/session selections
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Reset sticky selections from previous runs
for k in ["tags", "ideals", "bracket", "build_ctx", "last_step", "tag_mode"]:
try:
if k in sess:
del sess[k]
except Exception:
pass
sess["last_step"] = 2
resp = templates.TemplateResponse(
"build/_step2.html",
{
"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(),
# Signal that this navigation came from a fresh commander confirmation,
# so the Step 2 UI should clear any localStorage theme persistence.
"clear_persisted": True,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/reset-all", response_class=HTMLResponse)
async def build_reset_all(request: Request) -> HTMLResponse:
"""Clear all build-related session state and return Step 1."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
keys = [
"commander","tags","tag_mode","bracket","ideals","build_ctx","last_step",
"locks","replace_mode"
]
for k in keys:
try:
if k in sess:
del sess[k]
except Exception:
pass
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step5/rewind", response_class=HTMLResponse)
async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLResponse:
"""Rewind the staged build to a previous visible stage by index or key and show that stage.
Param `to` can be an integer index (1-based stage index) or a stage key string.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx")
if not ctx:
return await build_step5_get(request)
target_i: int | None = None
# Resolve by numeric index first
try:
idx_val = int(str(to).strip())
target_i = idx_val
except Exception:
target_i = None
if target_i is None:
# attempt by key
key = str(to).strip()
try:
for h in ctx.get("history", []) or []:
if str(h.get("key")) == key or str(h.get("label")) == key:
target_i = int(h.get("i"))
break
except Exception:
target_i = None
if not target_i:
return await build_step5_get(request)
# Try to restore snapshot stored for that history entry
try:
hist = ctx.get("history", []) or []
snap = None
for h in hist:
if int(h.get("i")) == int(target_i):
snap = h.get("snapshot")
break
if snap is not None:
orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined]
ctx["idx"] = int(target_i) - 1
ctx["last_visible_idx"] = int(target_i) - 1
except Exception:
# As a fallback, restart ctx and run forward until target
opts = orch.bracket_options()
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
locks=list(sess.get("locks", [])),
custom_export_base=sess.get("custom_export_base"),
)
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
res = orch.run_stage(ctx, rerun=False, show_skipped=True)
status = "Stage (rewound)" if not res.get("done") else "Build complete"
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": sess.get("commander"),
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": res.get("label"),
"log": res.get("log_delta", ""),
"added_cards": res.get("added_cards", []),
"i": res.get("idx"),
"n": res.get("total"),
"game_changers": bc.GAME_CHANGERS,
"show_skipped": True,
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
"skipped": bool(res.get("skipped")),
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
"history": ctx.get("history", []),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step2", response_class=HTMLResponse)
async def build_step2_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
commander = sess.get("commander")
if not commander:
# Fallback to step1 if no commander in session
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
tags = orch.tags_for_commander(commander)
selected = sess.get("tags", [])
resp = templates.TemplateResponse(
"build/_step2.html",
{
"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": sess.get("bracket"),
"tag_mode": sess.get("tag_mode", "AND"),
# 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,
},
)
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(...),
) -> HTMLResponse:
# 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()):
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
resp = templates.TemplateResponse(
"build/_step2.html",
{
"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": int(bracket) if bracket is not None else None,
"tag_mode": (tag_mode or "AND"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Save selection to session (basic MVP; real build will use this later)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
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)
# Proceed to Step 3 placeholder for now
sess["last_step"] = 3
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
"commander": commander,
"tags": sess["tags"],
"bracket": sess["bracket"],
"defaults": orch.ideal_defaults(),
"labels": orch.ideal_labels(),
"values": orch.ideal_defaults(),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step3", response_class=HTMLResponse)
async def build_step3_submit(
request: Request,
ramp: int = Form(...),
lands: int = Form(...),
basic_lands: int = Form(...),
creatures: int = Form(...),
removal: int = Form(...),
wipes: int = Form(...),
card_advantage: int = Form(...),
protection: int = Form(...),
) -> HTMLResponse:
labels = orch.ideal_labels()
submitted = {
"ramp": ramp,
"lands": lands,
"basic_lands": basic_lands,
"creatures": creatures,
"removal": removal,
"wipes": wipes,
"card_advantage": card_advantage,
"protection": protection,
}
errors: list[str] = []
for k, v in submitted.items():
try:
iv = int(v)
except Exception:
errors.append(f"{labels.get(k, k)} must be a number.")
continue
if iv < 0:
errors.append(f"{labels.get(k, k)} cannot be negative.")
submitted[k] = iv
# Cross-field validation: basic lands should not exceed total lands
if isinstance(submitted.get("basic_lands"), int) and isinstance(submitted.get("lands"), int):
if submitted["basic_lands"] > submitted["lands"]:
errors.append("Basic Lands cannot exceed Total Lands.")
if errors:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 3
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
"defaults": orch.ideal_defaults(),
"labels": labels,
"values": submitted,
"error": " ".join(errors),
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Save to session
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["ideals"] = submitted
# 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"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step3", response_class=HTMLResponse)
async def build_step3_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 3
defaults = orch.ideal_defaults()
values = sess.get("ideals") or defaults
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
"defaults": defaults,
"labels": orch.ideal_labels(),
"values": values,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step4", response_class=HTMLResponse)
async def build_step4_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 4
labels = orch.ideal_labels()
values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander")
return templates.TemplateResponse(
"build/_step4.html",
{
"request": request,
"labels": labels,
"values": values,
"commander": commander,
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
},
)
@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),
) -> 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
sess["use_owned_only"] = only_val
sess["prefer_owned"] = pref_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")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step5", response_class=HTMLResponse)
async def build_step5_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 5
# Default replace-mode to ON unless explicitly toggled off
if "replace_mode" not in sess:
sess["replace_mode"] = True
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": sess.get("commander"),
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"locks": list(sess.get("locks", [])),
"status": None,
"stage_label": None,
"log": None,
"added_cards": [],
"i": None,
"n": None,
"total_cards": None,
"added_total": 0,
"show_skipped": False,
"skipped": False,
"game_changers": bc.GAME_CHANGERS,
"replace_mode": bool(sess.get("replace_mode", True)),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step5/continue", response_class=HTMLResponse)
async def build_step5_continue(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if "replace_mode" not in sess:
sess["replace_mode"] = True
# Validate commander; redirect to step1 if missing
if not sess.get("commander"):
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Ensure build context exists; if not, start it first
if not sess.get("build_ctx"):
opts = orch.bracket_options()
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
# Owned-only integration for staged builds
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
locks=list(sess.get("locks", [])),
custom_export_base=sess.get("custom_export_base"),
)
# 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
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Build complete" if res.get("done") else "Stage complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
added_cards = res.get("added_cards", [])
# Progress & downloads
i = res.get("idx")
n = res.get("total")
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
total_cards = res.get("total_cards")
added_total = res.get("added_total")
sess["last_step"] = 5
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
"added_cards": added_cards,
"i": i,
"n": n,
"csv_path": csv_path,
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"total_cards": total_cards,
"added_total": added_total,
"skipped": bool(res.get("skipped")),
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step5/rerun", response_class=HTMLResponse)
async def build_step5_rerun(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if "replace_mode" not in sess:
sess["replace_mode"] = True
if not sess.get("commander"):
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Rerun requires an existing context; if missing, create it and run first stage as rerun
if not sess.get("build_ctx"):
opts = orch.bracket_options()
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
locks=list(sess.get("locks", [])),
)
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
res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True)))
status = "Stage rerun complete" if not res.get("done") else "Build complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
added_cards = res.get("added_cards", [])
i = res.get("idx")
n = res.get("total")
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
total_cards = res.get("total_cards")
added_total = res.get("added_total")
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] = set()
def _add_names(x):
try:
if not x:
return
if isinstance(x, dict):
for k, v in x.items():
if isinstance(k, str) and k.strip():
present.add(k.strip().lower())
elif isinstance(v, dict) and v.get('name'):
present.add(str(v.get('name')).strip().lower())
elif isinstance(x, (list, tuple, set)):
for item in x:
if isinstance(item, str):
present.add(item.strip().lower())
elif isinstance(item, dict) and item.get('name'):
present.add(str(item.get('name')).strip().lower())
else:
try:
nm = getattr(item, 'name', None)
if isinstance(nm, str) and nm.strip():
present.add(nm.strip().lower())
except Exception:
pass
except Exception:
pass
if b is not None:
for attr in (
'current_deck', 'deck', 'final_deck', 'final_cards',
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
):
_add_names(getattr(b, attr, None))
for attr in ('current_names', 'deck_names', 'final_names'):
val = getattr(b, attr, None)
if isinstance(val, (list, tuple, set)):
for n in val:
if isinstance(n, str) and n.strip():
present.add(n.strip().lower())
# Display-map via combined df when available
display_map: dict[str, str] = {}
try:
if b is not None:
df = getattr(b, "_combined_cards_df", None)
if df is not None and not df.empty:
lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
sub = df[df["name"].astype(str).str.lower().isin(lock_lower)]
for _idx, row in sub.iterrows():
display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip()
except Exception:
display_map = {}
owned_lower = {str(n).strip().lower() for n in owned_store.get_names()}
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 = []
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
"added_cards": added_cards,
"i": i,
"n": n,
"csv_path": csv_path,
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"total_cards": total_cards,
"added_total": added_total,
"skipped": bool(res.get("skipped")),
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
"locked_cards": locked_cards,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step5/start", response_class=HTMLResponse)
async def build_step5_start(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if "replace_mode" not in sess:
sess["replace_mode"] = True
# Validate commander exists before starting
commander = sess.get("commander")
if not commander:
resp = templates.TemplateResponse(
"build/_step1.html",
{"request": request, "candidates": [], "error": "Please select a commander first."},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
try:
# Initialize step-by-step build context and run first stage
opts = orch.bracket_options()
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=commander,
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
locks=list(sess.get("locks", [])),
custom_export_base=sess.get("custom_export_base"),
)
show_skipped = False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else False
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Stage complete" if not res.get("done") else "Build complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
added_cards = res.get("added_cards", [])
i = res.get("idx")
n = res.get("total")
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
sess["last_step"] = 5
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": commander,
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
"added_cards": added_cards,
"i": i,
"n": n,
"csv_path": csv_path,
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
except Exception as e:
# Surface a friendly error on the step 5 screen
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": commander,
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": "Error",
"stage_label": None,
"log": f"Failed to start build: {e}",
"added_cards": [],
"i": None,
"n": None,
"csv_path": None,
"txt_path": None,
"summary": None,
"game_changers": bc.GAME_CHANGERS,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step5/start", response_class=HTMLResponse)
async def build_step5_start_get(request: Request) -> HTMLResponse:
# Allow GET as a fallback to start the build (delegates to POST handler)
return await build_step5_start(request)
@router.get("/banner", response_class=HTMLResponse)
async def build_banner(request: Request, step: str = "", i: int | None = None, n: int | None = None) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
commander = sess.get("commander")
tags = sess.get("tags", [])
# Render only the inner text for the subtitle
return templates.TemplateResponse(
"build/_banner_subtitle.html",
{"request": request, "commander": commander, "tags": tags, "name": sess.get("custom_export_base")},
)
@router.post("/step5/toggle-replace")
async def build_step5_toggle_replace(request: Request, replace: str = Form("0")):
"""Toggle replace-mode for reruns and return an updated button HTML."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
enabled = True if str(replace).strip() in ("1","true","on","yes") else False
sess["replace_mode"] = enabled
# Return the checkbox control snippet (same as template)
checked = 'checked' if enabled else ''
html = (
'
'
''
'
'
)
return HTMLResponse(html)
@router.post("/step5/reset-stage", response_class=HTMLResponse)
async def build_step5_reset_stage(request: Request) -> HTMLResponse:
"""Reset current visible stage to the pre-stage snapshot (if available) without running it."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx")
if not ctx or not ctx.get("snapshot"):
return await build_step5_get(request)
try:
orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined]
except Exception:
return await build_step5_get(request)
# Re-render step 5 with cleared added list
resp = templates.TemplateResponse(
"build/_step5.html",
{
"request": request,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": "Stage reset",
"stage_label": None,
"log": None,
"added_cards": [],
"i": ctx.get("idx"),
"n": len(ctx.get("stages", [])),
"game_changers": bc.GAME_CHANGERS,
"show_skipped": False,
"total_cards": None,
"added_total": 0,
"skipped": False,
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
@router.post("/lock")
async def build_lock_toggle(request: Request, name: str = Form(...), locked: str = Form("1"), from_list: str | None = Form(None)):
"""Toggle lock for a card name in the current session; return an HTML button to swap in-place."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
locks = set(sess.get("locks", []))
key = str(name).strip().lower()
want_lock = True if str(locked).strip() in ("1","true","on","yes") else False
if want_lock:
locks.add(key)
else:
locks.discard(key)
sess["locks"] = list(locks)
# If a build context exists, update it too
if sess.get("build_ctx"):
try:
sess["build_ctx"]["locks"] = {str(n) for n in locks}
except Exception:
pass
# Return a compact button HTML that flips state on next click, and an OOB last-action chip
next_state = "0" if want_lock else "1"
label = "Unlock" if want_lock else "Lock"
title = ("Click to unlock" if want_lock else "Click to lock")
icon = ("🔒" if want_lock else "🔓")
# Include data-locked to reflect the current state for client-side handler
btn = f''''''
# Compute locks count for chip
locks_count = len(locks)
if locks_count > 0:
chip_html = f'🔒 {locks_count} locked'
else:
chip_html = ''
# Last action chip for feedback (use hx-swap-oob)
try:
disp = (name or '').strip()
except Exception:
disp = str(name)
action = "Locked" if want_lock else "Unlocked"
chip = (
f'
'
f'{action} {disp}'
f'
'
)
# If this request came from the locked-cards list and it's an unlock, remove the row inline
try:
if (from_list is not None) and (not want_lock):
# Also update the locks-count chip, and if no locks remain, remove the whole section
extra = chip_html
if locks_count == 0:
extra += ''
# Return empty body to delete the
via hx-swap=outerHTML, plus OOB updates
return HTMLResponse('' + extra)
except Exception:
pass
return HTMLResponse(btn + chip + chip_html)
@router.get("/alternatives", response_class=HTMLResponse)
async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> HTMLResponse:
"""Suggest alternative cards for a given card name using tag overlap and availability.
Returns a small HTML snippet listing up to ~10 alternatives with Replace buttons.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx") or {}
b = ctx.get("builder") if isinstance(ctx, dict) else None
# Owned library
owned_set = {str(n).strip().lower() for n in owned_store.get_names()}
require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only"))
# If builder context missing, show a guidance message
if not b:
html = (
'
Start the build to see alternatives.
'
)
return HTMLResponse(html)
try:
name_l = str(name).strip().lower()
commander_l = str((sess.get("commander") or "")).strip().lower()
locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
# Check cache: key = (seed, commander, require_owned)
cache_key = (name_l, commander_l, require_owned)
cached = _alts_get_cached(cache_key)
if cached is not None:
return HTMLResponse(cached)
# Tags index provides quick similarity candidates
tags_idx = getattr(b, "_card_name_tags_index", {}) or {}
seed_tags = set(tags_idx.get(name_l) or [])
# Fallback: use the card's role/sub-role from current library if available
lib = getattr(b, "card_library", {}) or {}
lib_entry = lib.get(name) or lib.get(name_l)
# Best-effort set of names currently in the deck to avoid duplicates
in_deck: set[str] = set()
try:
def _add_names(x):
try:
if not x:
return
if isinstance(x, dict):
for k, v in x.items():
# dict of name->count or name->obj
if isinstance(k, str) and k.strip():
in_deck.add(k.strip().lower())
elif isinstance(v, dict) and v.get('name'):
in_deck.add(str(v.get('name')).strip().lower())
elif isinstance(x, (list, tuple, set)):
for item in x:
if isinstance(item, str):
in_deck.add(item.strip().lower())
elif isinstance(item, dict) and item.get('name'):
in_deck.add(str(item.get('name')).strip().lower())
else:
try:
nm = getattr(item, 'name', None)
if isinstance(nm, str) and nm.strip():
in_deck.add(nm.strip().lower())
except Exception:
pass
except Exception:
pass
# Probe a few likely attributes; ignore if missing
for attr in (
'current_deck', 'deck', 'final_deck', 'final_cards',
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
):
_add_names(getattr(b, attr, None))
# Some builders may expose a flat set of names
for attr in ('current_names', 'deck_names', 'final_names'):
val = getattr(b, attr, None)
if isinstance(val, (list, tuple, set)):
for n in val:
if isinstance(n, str) and n.strip():
in_deck.add(n.strip().lower())
except Exception:
in_deck = set()
# Build candidate pool from tags overlap
all_names = set(tags_idx.keys())
candidates: list[tuple[str,int]] = [] # (name, score)
for nm in all_names:
if nm == name_l:
continue
# Exclude commander and any names we believe are already in the current deck
if commander_l and nm == commander_l:
continue
if in_deck and nm in in_deck:
continue
# Also exclude any card currently locked (these are intended to be kept)
if locked_set and nm in locked_set:
continue
tgs = set(tags_idx.get(nm) or [])
score = len(seed_tags & tgs)
if score <= 0:
continue
candidates.append((nm, score))
# If no tag-based candidates, try using same trigger tag if present
if not candidates and isinstance(lib_entry, dict):
try:
trig = str(lib_entry.get("TriggerTag") or "").strip().lower()
except Exception:
trig = ""
if trig:
for nm, tglist in tags_idx.items():
if nm == name_l:
continue
if nm in {str(k).strip().lower() for k in lib.keys()}:
continue
if trig in {str(t).strip().lower() for t in (tglist or [])}:
candidates.append((nm, 1))
# Sort by score desc, then owned-first, then name asc
def _owned(nm: str) -> bool:
return nm in owned_set
candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0]))
# Map back to display names using combined DF when possible for proper casing
display_map: dict[str, str] = {}
try:
df = getattr(b, "_combined_cards_df", None)
if df is not None and not df.empty:
# Build lower->original map limited to candidate pool for speed
pool_lower = {nm for (nm, _s) in candidates}
sub = df[df["name"].astype(str).str.lower().isin(pool_lower)]
for _idx, row in sub.iterrows():
display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip()
except Exception:
display_map = {}
# Apply owned filter and cap list
items_html: list[str] = []
seen = set()
count = 0
for nm, score in candidates:
if nm in seen:
continue
seen.add(nm)
disp = display_map.get(nm, nm)
is_owned = (nm in owned_set)
if require_owned and not is_owned:
continue
badge = "✔" if is_owned else "✖"
title = "Owned" if is_owned else "Not owned"
# Replace button posts to /build/replace; we'll update locks and prompt rerun
# Provide hover-preview metadata so moving the mouse over the alternative shows that card
cand_tags = tags_idx.get(nm) or []
data_tags = ", ".join([str(t) for t in cand_tags])
items_html.append(
f'
{badge} '
f'
'
)
count += 1
if count >= 10:
break
# Build HTML
if not items_html:
owned_msg = " (owned only)" if require_owned else ""
html = f'
No alternatives found{owned_msg}.
'
else:
toggle_q = "0" if require_owned else "1"
toggle_label = ("Owned only: On" if require_owned else "Owned only: Off")
html = (
'
'
f'
Alternatives'
f'
'
'
'
+ "".join(items_html) +
'
'
'
'
)
# Save to cache and return
_alts_set_cached(cache_key, html)
return HTMLResponse(html)
except Exception as e:
return HTMLResponse(f'
No alternatives: {e}
')
@router.post("/replace", response_class=HTMLResponse)
async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> HTMLResponse:
"""Update locks to prefer `new` over `old` and prompt the user to rerun the stage with Replace enabled.
This does not immediately mutate the builder; users should click Rerun Stage (Replace: On) to apply.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
locks = set(sess.get("locks", []))
o = str(old).strip().lower()
n = str(new).strip().lower()
# Always ensure new is locked and old is unlocked
locks.discard(o)
locks.add(n)
sess["locks"] = list(locks)
# Track last replace for optional undo
try:
sess["last_replace"] = {"old": o, "new": n}
except Exception:
pass
if sess.get("build_ctx"):
try:
sess["build_ctx"]["locks"] = {str(x) for x in locks}
except Exception:
pass
# Return a small confirmation with a shortcut to rerun
hint = (
'
'
f'
Locked {new} and unlocked {old}.
'
'
Now click Rerun Stage with Replace: On to apply this change.
'
'
'
''
''
''
'
'
'
'
)
# Also emit an OOB last-action chip
chip = (
f'
'
f'Replaced {old} → {new}'
f'
'
)
return HTMLResponse(hint + chip)
@router.post("/replace/undo", response_class=HTMLResponse)
async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse:
"""Undo the last replace by restoring the previous lock state (best-effort)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
last = sess.get("last_replace") or {}
try:
# Prefer provided args, else fallback to last recorded
o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip()
n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip()
except Exception:
o, n = "", ""
locks = set(sess.get("locks", []))
changed = False
if n and n in locks:
locks.discard(n)
changed = True
if o:
locks.add(o)
changed = True
sess["locks"] = list(locks)
if sess.get("build_ctx"):
try:
sess["build_ctx"]["locks"] = {str(x) for x in locks}
except Exception:
pass
# Clear last_replace after undo
try:
if sess.get("last_replace"):
del sess["last_replace"]
except Exception:
pass
# Return confirmation panel and OOB chip
msg = 'Undid replace' if changed else 'No changes to undo'
html = (
'
'
f'
{msg}.
'
'
Rerun the stage to recompute picks if needed.
'
'
'
''
''
'
'
'
'
)
chip = (
f'
'
f'{msg}'
f'
'
)
return HTMLResponse(html + chip)
@router.get("/compare")
async def build_compare(runA: str, runB: str):
"""Stub: return empty diffs; later we can diff summary files under deck_files."""
return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []})
@router.get("/permalink")
async def build_permalink(request: Request):
"""Return a URL-safe JSON payload representing current run config (basic)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
payload = {
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"ideals": sess.get("ideals"),
"tag_mode": sess.get("tag_mode", "AND"),
"flags": {
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
},
"locks": list(sess.get("locks", [])),
}
try:
import base64
import json as _json
raw = _json.dumps(payload, separators=(",", ":"))
token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
# Also include decoded state for convenience/testing
return JSONResponse({"ok": True, "permalink": f"/build/from?state={token}", "state": payload})
except Exception:
return JSONResponse({"ok": True, "state": payload})
@router.get("/from", response_class=HTMLResponse)
async def build_from(request: Request, state: str | None = None) -> HTMLResponse:
"""Load a run from a permalink token."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if state:
try:
import base64
import json as _json
pad = '=' * (-len(state) % 4)
raw = base64.urlsafe_b64decode((state + pad).encode("ascii")).decode("utf-8")
data = _json.loads(raw)
sess["commander"] = data.get("commander")
sess["tags"] = data.get("tags", [])
sess["bracket"] = data.get("bracket")
if data.get("ideals"):
sess["ideals"] = data.get("ideals")
sess["tag_mode"] = data.get("tag_mode", "AND")
flags = data.get("flags") or {}
sess["use_owned_only"] = bool(flags.get("owned_only"))
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
sess["locks"] = list(data.get("locks", []))
sess["last_step"] = 4
except Exception:
pass
locks_restored = 0
try:
locks_restored = len(sess.get("locks", []) or [])
except Exception:
locks_restored = 0
resp = templates.TemplateResponse("build/_step4.html", {
"request": request,
"labels": orch.ideal_labels(),
"values": sess.get("ideals") or orch.ideal_defaults(),
"commander": sess.get("commander"),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"locks_restored": locks_restored,
})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp