mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
This commit is contained in:
parent
8fa040a05a
commit
0f73a85a4e
43 changed files with 4515 additions and 105 deletions
507
code/web/routes/build.py
Normal file
507
code/web/routes/build.py
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from ..app import templates
|
||||
from deck_builder import builder_constants as bc
|
||||
from ..services import orchestrator as orch
|
||||
from ..services.tasks import get_session, new_sid
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def build_index(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
resp = templates.TemplateResponse(
|
||||
"build/index.html",
|
||||
{"request": request, "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", [])},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/step1", response_class=HTMLResponse)
|
||||
async def build_step1(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||
|
||||
|
||||
@router.post("/step1", response_class=HTMLResponse)
|
||||
async def build_step1_search(request: Request, query: str = Form(""), auto: 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"):
|
||||
return templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
"commander": res,
|
||||
"tags": orch.tags_for_commander(res["name"]),
|
||||
"brackets": orch.bracket_options(),
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse("build/_step1.html", {"request": request, "query": query, "candidates": candidates, "auto": auto_enabled})
|
||||
|
||||
|
||||
@router.post("/step1/inspect", response_class=HTMLResponse)
|
||||
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
info = orch.commander_inspect(name)
|
||||
return templates.TemplateResponse(
|
||||
"build/_step1.html",
|
||||
{"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)},
|
||||
)
|
||||
|
||||
|
||||
@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"):
|
||||
return templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
|
||||
# Proceed to step2 placeholder
|
||||
return templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
"commander": res,
|
||||
"tags": orch.tags_for_commander(res["name"]),
|
||||
"brackets": orch.bracket_options(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
commander = sess.get("commander")
|
||||
if not commander:
|
||||
# Fallback to step1 if no commander in session
|
||||
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||
tags = orch.tags_for_commander(commander)
|
||||
selected = sess.get("tags", [])
|
||||
return templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
"commander": {"name": commander},
|
||||
"tags": tags,
|
||||
"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"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
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()):
|
||||
return templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
"commander": {"name": commander},
|
||||
"tags": available_tags,
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
# 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["bracket"] = int(bracket)
|
||||
# Proceed to Step 3 placeholder for now
|
||||
return 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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
return 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"),
|
||||
},
|
||||
)
|
||||
|
||||
# Save to session
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["ideals"] = submitted
|
||||
|
||||
# Proceed to review (Step 4)
|
||||
return templates.TemplateResponse(
|
||||
"build/_step4.html",
|
||||
{
|
||||
"request": request,
|
||||
"labels": labels,
|
||||
"values": submitted,
|
||||
"commander": sess.get("commander"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
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()),
|
||||
"status": None,
|
||||
"stage_label": None,
|
||||
"log": None,
|
||||
"added_cards": [],
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
},
|
||||
)
|
||||
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)
|
||||
# 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()
|
||||
sess["build_ctx"] = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False)
|
||||
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
|
||||
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()),
|
||||
"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,
|
||||
},
|
||||
)
|
||||
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 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()
|
||||
sess["build_ctx"] = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=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
|
||||
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()),
|
||||
"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,
|
||||
},
|
||||
)
|
||||
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)
|
||||
# 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()
|
||||
sess["build_ctx"] = orch.start_build_ctx(
|
||||
commander=commander,
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False)
|
||||
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
|
||||
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()),
|
||||
"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,
|
||||
},
|
||||
)
|
||||
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()),
|
||||
"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, "step": step, "i": i, "n": n},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue