mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50: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
1
code/web/routes/__init__.py
Normal file
1
code/web/routes/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Routes package marker
|
||||
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},
|
||||
)
|
||||
179
code/web/routes/configs.py
Normal file
179
code/web/routes/configs.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Form, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json
|
||||
from ..app import templates
|
||||
from ..services import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
router = APIRouter(prefix="/configs")
|
||||
|
||||
|
||||
def _config_dir() -> Path:
|
||||
# Prefer explicit env var if provided, else default to ./config
|
||||
p = os.getenv("DECK_CONFIG")
|
||||
if p:
|
||||
# If env points to a file, use its parent dir; else treat as dir
|
||||
pp = Path(p)
|
||||
return (pp.parent if pp.suffix else pp).resolve()
|
||||
return (Path.cwd() / "config").resolve()
|
||||
|
||||
|
||||
def _list_configs() -> list[dict]:
|
||||
d = _config_dir()
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
items: list[dict] = []
|
||||
for p in sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
|
||||
try:
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
meta["commander"] = data.get("commander")
|
||||
tags = [t for t in [data.get("primary_tag"), data.get("secondary_tag"), data.get("tertiary_tag")] if t]
|
||||
meta["tags"] = tags
|
||||
meta["bracket_level"] = data.get("bracket_level")
|
||||
except Exception:
|
||||
pass
|
||||
items.append(meta)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def configs_index(request: Request) -> HTMLResponse:
|
||||
items = _list_configs()
|
||||
# Load example deck.json from the config directory, if present
|
||||
example_json = None
|
||||
example_name = "deck.json"
|
||||
try:
|
||||
example_path = _config_dir() / example_name
|
||||
if example_path.exists() and example_path.is_file():
|
||||
example_json = example_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
example_json = None
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": items, "example_json": example_json, "example_name": example_name},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/view", response_class=HTMLResponse)
|
||||
async def configs_view(request: Request, name: str) -> HTMLResponse:
|
||||
base = _config_dir()
|
||||
p = (base / name).resolve()
|
||||
# Safety: ensure the resolved path is within config dir
|
||||
try:
|
||||
if base not in p.parents and p != base:
|
||||
raise ValueError("Access denied")
|
||||
except Exception:
|
||||
pass
|
||||
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||
)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"configs/view.html",
|
||||
{"request": request, "path": str(p), "name": p.name, "data": data},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_class=HTMLResponse)
|
||||
async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
base = _config_dir()
|
||||
p = (base / name).resolve()
|
||||
try:
|
||||
if base not in p.parents and p != base:
|
||||
raise ValueError("Access denied")
|
||||
except Exception:
|
||||
pass
|
||||
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||
)
|
||||
try:
|
||||
cfg = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||
)
|
||||
|
||||
commander = cfg.get("commander", "")
|
||||
tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t]
|
||||
bracket = int(cfg.get("bracket_level") or 0)
|
||||
ideals = cfg.get("ideal_counts", {}) or {}
|
||||
|
||||
# Run build headlessly with orchestrator
|
||||
res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals)
|
||||
if not res.get("ok"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/run_result.html",
|
||||
{
|
||||
"request": request,
|
||||
"ok": False,
|
||||
"error": res.get("error") or "Build failed",
|
||||
"log": res.get("log", ""),
|
||||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"configs/run_result.html",
|
||||
{
|
||||
"request": request,
|
||||
"ok": True,
|
||||
"log": res.get("log", ""),
|
||||
"csv_path": res.get("csv_path"),
|
||||
"txt_path": res.get("txt_path"),
|
||||
"summary": res.get("summary"),
|
||||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
async def configs_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
|
||||
# Optional helper: allow uploading a JSON config
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode("utf-8"))
|
||||
# Minimal validation
|
||||
if not data.get("commander"):
|
||||
raise ValueError("Missing 'commander'")
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Invalid JSON: {e}"},
|
||||
)
|
||||
# Save to config dir with original filename (or unique)
|
||||
d = _config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
fname = file.filename or "config.json"
|
||||
out = d / fname
|
||||
i = 1
|
||||
while out.exists():
|
||||
stem = out.stem
|
||||
out = d / f"{stem}_{i}.json"
|
||||
i += 1
|
||||
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "notice": f"Uploaded {out.name}"},
|
||||
)
|
||||
267
code/web/routes/decks.py
Normal file
267
code/web/routes/decks.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import os
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from ..app import templates
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
router = APIRouter(prefix="/decks")
|
||||
|
||||
|
||||
def _deck_dir() -> Path:
|
||||
# Prefer explicit env var if provided, else default to ./deck_files
|
||||
p = os.getenv("DECK_EXPORTS")
|
||||
if p:
|
||||
return Path(p).resolve()
|
||||
return (Path.cwd() / "deck_files").resolve()
|
||||
|
||||
|
||||
def _list_decks() -> list[dict]:
|
||||
d = _deck_dir()
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
items: list[dict] = []
|
||||
# Prefer CSV entries and pair with matching TXT if present
|
||||
for p in sorted(d.glob("*.csv"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
|
||||
stem = p.stem
|
||||
txt = p.with_suffix('.txt')
|
||||
if txt.exists():
|
||||
meta["txt_name"] = txt.name
|
||||
meta["txt_path"] = str(txt)
|
||||
# Prefer sidecar summary meta if present
|
||||
sidecar = p.with_suffix('.summary.json')
|
||||
if sidecar.exists():
|
||||
try:
|
||||
import json as _json
|
||||
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
|
||||
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
|
||||
meta["commander"] = _m.get('commander') or meta.get("commander")
|
||||
meta["tags"] = _m.get('tags') or meta.get("tags") or []
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
|
||||
if not meta.get("commander"):
|
||||
parts = stem.split('_')
|
||||
if len(parts) >= 3:
|
||||
meta["commander"] = parts[0]
|
||||
meta["tags"] = parts[1:-1]
|
||||
else:
|
||||
meta["commander"] = stem
|
||||
meta["tags"] = []
|
||||
items.append(meta)
|
||||
return items
|
||||
|
||||
|
||||
def _safe_within(base: Path, target: Path) -> bool:
|
||||
try:
|
||||
base_r = base.resolve()
|
||||
targ_r = target.resolve()
|
||||
return (base_r == targ_r) or (base_r in targ_r.parents)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, int], Dict[str, List[dict]]]:
|
||||
"""Parse CSV export to reconstruct minimal summary pieces.
|
||||
|
||||
Returns: (meta, type_counts, curve_counts, type_cards)
|
||||
meta: { 'commander': str, 'colors': [..] }
|
||||
"""
|
||||
headers = []
|
||||
type_counts: Dict[str, int] = {}
|
||||
type_cards: Dict[str, List[dict]] = {}
|
||||
curve_bins = ['0','1','2','3','4','5','6+']
|
||||
curve_counts: Dict[str, int] = {b: 0 for b in curve_bins}
|
||||
curve_cards: Dict[str, List[dict]] = {b: [] for b in curve_bins}
|
||||
meta: dict = {"commander": "", "colors": []}
|
||||
commander_seen = False
|
||||
# Infer commander from filename stem (pattern Commander_Themes_YYYYMMDD)
|
||||
stem_parts = csv_path.stem.split('_')
|
||||
inferred_commander = stem_parts[0] if stem_parts else ''
|
||||
|
||||
def classify_mv(raw) -> str:
|
||||
try:
|
||||
v = float(raw)
|
||||
except Exception:
|
||||
v = 0.0
|
||||
return '6+' if v >= 6 else str(int(v))
|
||||
|
||||
try:
|
||||
with csv_path.open('r', encoding='utf-8') as f:
|
||||
reader = csv.reader(f)
|
||||
headers = next(reader, [])
|
||||
# Expected columns include: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, ..., Tags, Text, Owned
|
||||
name_idx = headers.index('Name') if 'Name' in headers else 0
|
||||
count_idx = headers.index('Count') if 'Count' in headers else 1
|
||||
type_idx = headers.index('Type') if 'Type' in headers else 2
|
||||
mv_idx = headers.index('ManaValue') if 'ManaValue' in headers else (headers.index('Mana Value') if 'Mana Value' in headers else -1)
|
||||
role_idx = headers.index('Role') if 'Role' in headers else -1
|
||||
tags_idx = headers.index('Tags') if 'Tags' in headers else -1
|
||||
colors_idx = headers.index('Colors') if 'Colors' in headers else -1
|
||||
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
try:
|
||||
name = row[name_idx]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
cnt = int(float(row[count_idx])) if row[count_idx] else 1
|
||||
except Exception:
|
||||
cnt = 1
|
||||
type_line = row[type_idx] if type_idx >= 0 and type_idx < len(row) else ''
|
||||
role = (row[role_idx] if role_idx >= 0 and role_idx < len(row) else '')
|
||||
tags = (row[tags_idx] if tags_idx >= 0 and tags_idx < len(row) else '')
|
||||
tags_list = [t.strip() for t in tags.split(';') if t.strip()]
|
||||
|
||||
# Commander detection: prefer filename inference; else best-effort via type line containing 'Commander'
|
||||
is_commander = (inferred_commander and name == inferred_commander)
|
||||
if not is_commander:
|
||||
is_commander = isinstance(type_line, str) and ('commander' in type_line.lower())
|
||||
if is_commander and not commander_seen:
|
||||
meta['commander'] = name
|
||||
commander_seen = True
|
||||
|
||||
# Map type_line to broad category
|
||||
tl = (type_line or '').lower()
|
||||
if 'battle' in tl:
|
||||
cat = 'Battle'
|
||||
elif 'planeswalker' in tl:
|
||||
cat = 'Planeswalker'
|
||||
elif 'creature' in tl:
|
||||
cat = 'Creature'
|
||||
elif 'instant' in tl:
|
||||
cat = 'Instant'
|
||||
elif 'sorcery' in tl:
|
||||
cat = 'Sorcery'
|
||||
elif 'artifact' in tl:
|
||||
cat = 'Artifact'
|
||||
elif 'enchantment' in tl:
|
||||
cat = 'Enchantment'
|
||||
elif 'land' in tl:
|
||||
cat = 'Land'
|
||||
else:
|
||||
cat = 'Other'
|
||||
|
||||
# Type counts/cards (exclude commander entry from distribution)
|
||||
if not is_commander:
|
||||
type_counts[cat] = type_counts.get(cat, 0) + cnt
|
||||
type_cards.setdefault(cat, []).append({
|
||||
'name': name,
|
||||
'count': cnt,
|
||||
'role': role,
|
||||
'tags': tags_list,
|
||||
})
|
||||
|
||||
# Curve
|
||||
if mv_idx >= 0 and mv_idx < len(row):
|
||||
bucket = classify_mv(row[mv_idx])
|
||||
if bucket not in curve_counts:
|
||||
bucket = '6+'
|
||||
curve_counts[bucket] += cnt
|
||||
curve_cards[bucket].append({'name': name, 'count': cnt})
|
||||
|
||||
# Colors (from Colors col for commander/overall)
|
||||
if is_commander and colors_idx >= 0 and colors_idx < len(row):
|
||||
cid = row[colors_idx] or ''
|
||||
if isinstance(cid, str):
|
||||
meta['colors'] = list(cid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Precedence ordering
|
||||
precedence_order = [
|
||||
'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
|
||||
]
|
||||
prec_index = {k: i for i, k in enumerate(precedence_order)}
|
||||
type_order = sorted(type_counts.keys(), key=lambda k: prec_index.get(k, 999))
|
||||
|
||||
summary = {
|
||||
'type_breakdown': {
|
||||
'counts': type_counts,
|
||||
'order': type_order,
|
||||
'cards': type_cards,
|
||||
'total': sum(type_counts.values()),
|
||||
},
|
||||
'pip_distribution': {
|
||||
# Not recoverable from CSV without mana symbols; leave zeros
|
||||
'counts': {c: 0 for c in ('W','U','B','R','G')},
|
||||
'weights': {c: 0 for c in ('W','U','B','R','G')},
|
||||
},
|
||||
'mana_generation': {
|
||||
# Not recoverable from CSV alone
|
||||
'W': 0, 'U': 0, 'B': 0, 'R': 0, 'G': 0, 'total_sources': 0,
|
||||
},
|
||||
'mana_curve': {
|
||||
**curve_counts,
|
||||
'total_spells': sum(curve_counts.values()),
|
||||
'cards': curve_cards,
|
||||
},
|
||||
'colors': meta.get('colors', []),
|
||||
}
|
||||
return summary, type_counts, curve_counts, type_cards
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def decks_index(request: Request) -> HTMLResponse:
|
||||
items = _list_decks()
|
||||
return templates.TemplateResponse("decks/index.html", {"request": request, "items": items})
|
||||
|
||||
|
||||
@router.get("/view", response_class=HTMLResponse)
|
||||
async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||
base = _deck_dir()
|
||||
p = (base / name).resolve()
|
||||
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
|
||||
return templates.TemplateResponse("decks/index.html", {"request": request, "items": _list_decks(), "error": "Deck not found."})
|
||||
|
||||
# Try to load sidecar summary JSON first
|
||||
summary = None
|
||||
commander_name = ''
|
||||
tags: List[str] = []
|
||||
sidecar = p.with_suffix('.summary.json')
|
||||
if sidecar.exists():
|
||||
try:
|
||||
import json as _json
|
||||
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
|
||||
if isinstance(payload, dict):
|
||||
summary = payload.get('summary')
|
||||
meta = payload.get('meta', {})
|
||||
if isinstance(meta, dict):
|
||||
commander_name = meta.get('commander') or ''
|
||||
_tags = meta.get('tags') or []
|
||||
if isinstance(_tags, list):
|
||||
tags = [str(t) for t in _tags]
|
||||
except Exception:
|
||||
summary = None
|
||||
if not summary:
|
||||
# Reconstruct minimal summary from CSV
|
||||
summary, _tc, _cc, _tcs = _read_csv_summary(p)
|
||||
stem = p.stem
|
||||
txt_path = p.with_suffix('.txt')
|
||||
# If missing still, infer from filename stem
|
||||
if not commander_name:
|
||||
parts = stem.split('_')
|
||||
commander_name = parts[0] if parts else ''
|
||||
|
||||
ctx = {
|
||||
"request": request,
|
||||
"name": p.name,
|
||||
"csv_path": str(p),
|
||||
"txt_path": str(txt_path) if txt_path.exists() else None,
|
||||
"summary": summary,
|
||||
"commander": commander_name,
|
||||
"tags": tags,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
}
|
||||
return templates.TemplateResponse("decks/view.html", ctx)
|
||||
11
code/web/routes/home.py
Normal file
11
code/web/routes/home.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from ..app import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("home.html", {"request": request})
|
||||
105
code/web/routes/setup.py
Normal file
105
code/web/routes/setup.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import Body
|
||||
from pathlib import Path
|
||||
import json as _json
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from ..app import templates
|
||||
from ..services.orchestrator import _ensure_setup_ready # type: ignore
|
||||
|
||||
router = APIRouter(prefix="/setup")
|
||||
|
||||
|
||||
def _kickoff_setup_async(force: bool = False):
|
||||
def runner():
|
||||
try:
|
||||
_ensure_setup_ready(lambda _m: None, force=force) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
pass
|
||||
t = threading.Thread(target=runner, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
@router.get("/running", response_class=HTMLResponse)
|
||||
async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override]
|
||||
# Optionally start the setup/tagging in the background if requested
|
||||
try:
|
||||
if start and int(start) != 0:
|
||||
# honor optional force flag from query
|
||||
f = False
|
||||
try:
|
||||
if force is not None:
|
||||
f = bool(force)
|
||||
else:
|
||||
q_force = request.query_params.get('force')
|
||||
if q_force is not None:
|
||||
f = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
f = False
|
||||
_kickoff_setup_async(force=f)
|
||||
except Exception:
|
||||
pass
|
||||
return templates.TemplateResponse("setup/running.html", {"request": request, "next_url": next})
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def setup_start(request: Request, force: bool = Body(False)): # accept JSON body {"force": true}
|
||||
try:
|
||||
# Allow query string override as well (?force=1)
|
||||
try:
|
||||
q_force = request.query_params.get('force')
|
||||
if q_force is not None:
|
||||
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
pass
|
||||
# Write immediate status so UI reflects the start
|
||||
try:
|
||||
p = Path("csv_files")
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
|
||||
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
|
||||
_json.dump(status, f)
|
||||
except Exception:
|
||||
pass
|
||||
_kickoff_setup_async(force=bool(force))
|
||||
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/start")
|
||||
async def setup_start_get(request: Request):
|
||||
"""GET alias to start setup/tagging via query string (?force=1).
|
||||
|
||||
Useful as a fallback from clients that cannot POST JSON.
|
||||
"""
|
||||
try:
|
||||
# Determine force from query params
|
||||
force = False
|
||||
try:
|
||||
q_force = request.query_params.get('force')
|
||||
if q_force is not None:
|
||||
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
pass
|
||||
# Write immediate status so UI reflects the start
|
||||
try:
|
||||
p = Path("csv_files")
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
|
||||
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
|
||||
_json.dump(status, f)
|
||||
except Exception:
|
||||
pass
|
||||
_kickoff_setup_async(force=bool(force))
|
||||
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def setup_index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("setup/index.html", {"request": request})
|
||||
Loading…
Add table
Add a link
Reference in a new issue