mtg_python_deckbuilder/code/web/routes/build.py

1681 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query
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 13 condensed)
@router.get("/new", response_class=HTMLResponse)
async def build_new_modal(request: Request) -> HTMLResponse:
"""Return the New Deck modal content (for an overlay)."""
sid = request.cookies.get("sid") or new_sid()
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'<div class="muted">Commander not found: {name}</div>')
tags = orch.tags_for_commander(info["name"]) or []
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
# 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 = (
'<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"]) # 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'''<button type="button" class="btn-lock" title="{title}" data-locked="{'1' if want_lock else '0'}"
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
hx-vals='{{"name": "{name}", "locked": "{next_state}"}}'>{icon} {label}</button>'''
# Compute locks count for chip
locks_count = len(locks)
if locks_count > 0:
chip_html = f'<span id="locks-chip" hx-swap-oob="true"><span class="chip" title="Locked cards">🔒 {locks_count} locked</span></span>'
else:
chip_html = '<span id="locks-chip" hx-swap-oob="true"></span>'
# Last action chip for feedback (use hx-swap-oob)
try:
disp = (name or '').strip()
except Exception:
disp = str(name)
action = "Locked" if want_lock else "Unlocked"
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">{action} <strong>{disp}</strong></span>'
f'</div>'
)
# If this request came from the locked-cards list and it's an unlock, remove the row inline
try:
if (from_list is not None) and (not want_lock):
# Also update the locks-count chip, and if no locks remain, remove the whole section
extra = chip_html
if locks_count == 0:
extra += '<details id="locked-section" hx-swap-oob="true"></details>'
# Return empty body to delete the <li> via hx-swap=outerHTML, plus OOB updates
return HTMLResponse('' + extra)
except Exception:
pass
return HTMLResponse(btn + chip + chip_html)
@router.get("/alternatives", response_class=HTMLResponse)
async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> 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 = (
'<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
)
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'<li><span class="owned-badge" title="{title}">{badge}</span> '
f'<button class="btn" data-card-name="{_esc(disp)}" data-tags="{_esc(data_tags)}" hx-post="/build/replace" '
f'hx-vals=' + "'" + f'{{"old":"{name}", "new":"{disp}"}}' + "'" + ' '
f'hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick">Replace with {_esc(disp)}</button></li>'
)
count += 1
if count >= 10:
break
# Build HTML
if not items_html:
owned_msg = " (owned only)" if require_owned else ""
html = f'<div class="alts"><div class="muted">No alternatives found{owned_msg}.</div></div>'
else:
toggle_q = "0" if require_owned else "1"
toggle_label = ("Owned only: On" if require_owned else "Owned only: Off")
html = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem;"><strong>Alternatives</strong>'
f'<button class="btn" hx-get="/build/alternatives?name={name}&owned_only={toggle_q}" hx-target="closest .alts" hx-swap="outerHTML">{toggle_label}</button></div>'
'<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">'
+ "".join(items_html) +
'</ul>'
'</div>'
)
# Save to cache and return
_alts_set_cached(cache_key, html)
return HTMLResponse(html)
except Exception as e:
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
@router.post("/replace", response_class=HTMLResponse)
async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> 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 = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div>Locked <strong>{new}</strong> and unlocked <strong>{old}</strong>.</div>'
'<div class="muted" style="margin-top:.35rem;">Now click <em>Rerun Stage</em> with Replace: On to apply this change.</div>'
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
'<input type="hidden" name="show_skipped" value="1" />'
'<button type="submit" class="btn-rerun">Rerun stage</button>'
'</form>'
'<form hx-post="/build/replace/undo" hx-target="closest .alts" hx-swap="outerHTML" style="display:inline; margin:0;">'
f'<input type="hidden" name="old" value="{old}" />'
f'<input type="hidden" name="new" value="{new}" />'
'<button type="submit" class="btn" title="Undo this replace">Undo</button>'
'</form>'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>'
'</div>'
)
# Also emit an OOB last-action chip
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">Replaced <strong>{old}</strong> → <strong>{new}</strong></span>'
f'</div>'
)
return HTMLResponse(hint + chip)
@router.post("/replace/undo", response_class=HTMLResponse)
async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse:
"""Undo the last replace by restoring the previous lock state (best-effort)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
last = sess.get("last_replace") or {}
try:
# Prefer provided args, else fallback to last recorded
o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip()
n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip()
except Exception:
o, n = "", ""
locks = set(sess.get("locks", []))
changed = False
if n and n in locks:
locks.discard(n)
changed = True
if o:
locks.add(o)
changed = True
sess["locks"] = list(locks)
if sess.get("build_ctx"):
try:
sess["build_ctx"]["locks"] = {str(x) for x in locks}
except Exception:
pass
# Clear last_replace after undo
try:
if sess.get("last_replace"):
del sess["last_replace"]
except Exception:
pass
# Return confirmation panel and OOB chip
msg = 'Undid replace' if changed else 'No changes to undo'
html = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div>{msg}.</div>'
'<div class="muted" style="margin-top:.35rem;">Rerun the stage to recompute picks if needed.</div>'
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
'<input type="hidden" name="show_skipped" value="1" />'
'<button type="submit" class="btn-rerun">Rerun stage</button>'
'</form>'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>'
'</div>'
)
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">{msg}</span>'
f'</div>'
)
return HTMLResponse(html + chip)
@router.get("/compare")
async def build_compare(runA: str, runB: str):
"""Stub: return empty diffs; later we can diff summary files under deck_files."""
return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []})
@router.get("/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