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:
mwisnowski 2025-08-26 09:48:25 -07:00
parent 8fa040a05a
commit 0f73a85a4e
43 changed files with 4515 additions and 105 deletions

View file

@ -0,0 +1 @@
# Routes package marker

507
code/web/routes/build.py Normal file
View 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
View 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
View 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
View 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
View 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})