Web/builder: Owned stability+enrichment+exports; prefer-owned toggle & bias; staged build show-skipped; UI polish; docs update

This commit is contained in:
mwisnowski 2025-08-26 16:25:34 -07:00
parent fd7fc01071
commit 625f6abb13
26 changed files with 1618 additions and 229 deletions

View file

@ -5,6 +5,7 @@ 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 import owned_store
from ..services.tasks import get_session, new_sid
router = APIRouter(prefix="/build")
@ -286,10 +287,44 @@ async def build_step4_get(request: Request) -> HTMLResponse:
"labels": labels,
"values": values,
"commander": commander,
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
},
)
@router.post("/toggle-owned-review", response_class=HTMLResponse)
async def build_toggle_owned_review(
request: Request,
use_owned_only: str | None = Form(None),
prefer_owned: str | None = Form(None),
) -> HTMLResponse:
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
sess["use_owned_only"] = only_val
sess["prefer_owned"] = pref_val
# Do not touch build_ctx here; user hasn't started the build yet from review
labels = orch.ideal_labels()
values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander")
resp = templates.TemplateResponse(
"build/_step4.html",
{
"request": request,
"labels": labels,
"values": values,
"commander": commander,
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step5", response_class=HTMLResponse)
async def build_step5_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
@ -302,6 +337,9 @@ async def build_step5_get(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": None,
"stage_label": None,
"log": None,
@ -331,14 +369,27 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
# Owned-only integration for staged builds
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
)
res = orch.run_stage(sess["build_ctx"], rerun=False)
show_skipped = True if (request.query_params.get('show_skipped') == '1' or (await request.form().get('show_skipped', None) == '1') if hasattr(request, 'form') else False) else False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else show_skipped
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Build complete" if res.get("done") else "Stage complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
@ -357,6 +408,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
@ -367,6 +421,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -390,14 +445,26 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
)
res = orch.run_stage(sess["build_ctx"], rerun=True)
show_skipped = False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else False
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped)
status = "Stage rerun complete" if not res.get("done") else "Build complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
@ -415,6 +482,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
@ -425,6 +495,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -454,14 +525,26 @@ async def build_step5_start(request: Request) -> HTMLResponse:
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=commander,
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
)
res = orch.run_stage(sess["build_ctx"], rerun=False)
show_skipped = False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else False
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Stage complete" if not res.get("done") else "Build complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
@ -479,6 +562,9 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
@ -489,6 +575,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -503,6 +590,8 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": "Error",
"stage_label": None,
"log": f"Failed to start build: {e}",

View file

@ -6,6 +6,7 @@ from pathlib import Path
import os
import json
from ..app import templates
from ..services import owned_store
from ..services import orchestrator as orch
from deck_builder import builder_constants as bc
@ -92,7 +93,7 @@ async def configs_view(request: Request, name: str) -> HTMLResponse:
@router.post("/run", response_class=HTMLResponse)
async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
async def configs_run(request: Request, name: str = Form(...), use_owned_only: str | None = Form(None)) -> HTMLResponse:
base = _config_dir()
p = (base / name).resolve()
try:
@ -125,8 +126,33 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
except Exception:
tag_mode = "AND"
# Optional owned-only for headless runs via JSON flag or form override
owned_flag = False
try:
uo = cfg.get("use_owned_only")
if isinstance(uo, bool):
owned_flag = uo
elif isinstance(uo, str):
owned_flag = uo.strip().lower() in ("1","true","yes","on")
except Exception:
owned_flag = False
# Form override takes precedence if provided
if use_owned_only is not None:
owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on")
owned_names = owned_store.get_names() if owned_flag else None
# Run build headlessly with orchestrator
res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals, tag_mode=tag_mode)
res = orch.run_build(
commander=commander,
tags=tags,
bracket=bracket,
ideals=ideals,
tag_mode=tag_mode,
use_owned_only=owned_flag,
owned_names=owned_names,
)
if not res.get("ok"):
return templates.TemplateResponse(
"configs/run_result.html",
@ -138,6 +164,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()},
},
)
return templates.TemplateResponse(
@ -152,6 +180,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()},
"game_changers": bc.GAME_CHANGERS,
},
)

View file

@ -8,6 +8,7 @@ import os
from typing import Dict, List, Tuple
from ..app import templates
from ..services import owned_store
from deck_builder import builder_constants as bc
@ -263,5 +264,6 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
"commander": commander_name,
"tags": tags,
"game_changers": bc.GAME_CHANGERS,
"owned_set": {n.lower() for n in owned_store.get_names()},
}
return templates.TemplateResponse("decks/view.html", ctx)

179
code/web/routes/owned.py Normal file
View file

@ -0,0 +1,179 @@
from __future__ import annotations
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import HTMLResponse, Response
from ..app import templates
from ..services import owned_store as store
# Session helpers are not required for owned routes
router = APIRouter(prefix="/owned")
def _canon_color_code(seq: list[str] | tuple[str, ...]) -> str:
"""Canonicalize a color identity sequence to a stable code (WUBRG order, no 'C' unless only color)."""
order = {'W':0,'U':1,'B':2,'R':3,'G':4,'C':5}
uniq: list[str] = []
seen: set[str] = set()
for c in (seq or []):
uc = (c or '').upper()
if uc in order and uc not in seen:
seen.add(uc)
uniq.append(uc)
uniq.sort(key=lambda x: order[x])
code = ''.join([c for c in uniq if c != 'C'])
return code or ('C' if 'C' in seen else '')
def _color_combo_label(code: str) -> str:
"""Return friendly label for a 2/3/4-color combo code; empty if unknown.
Uses standard names: Guilds, Shards/Wedges, and Nephilim-style for 4-color.
"""
two_map = {
'WU':'Azorius','UB':'Dimir','BR':'Rakdos','RG':'Gruul','WG':'Selesnya',
'WB':'Orzhov','UR':'Izzet','BG':'Golgari','WR':'Boros','UG':'Simic',
}
three_map = {
'WUB':'Esper','UBR':'Grixis','BRG':'Jund','WRG':'Naya','WUG':'Bant',
'WBR':'Mardu','WUR':'Jeskai','UBG':'Sultai','URG':'Temur','WBG':'Abzan',
}
four_map = {
'WUBR': 'Yore-Tiller', # no G
'WUBG': 'Witch-Maw', # no R
'WURG': 'Ink-Treader', # no B
'WBRG': 'Dune-Brood', # no U
'UBRG': 'Glint-Eye', # no W
}
if len(code) == 2:
return two_map.get(code, '')
if len(code) == 3:
return three_map.get(code, '')
if len(code) == 4:
return four_map.get(code, '')
return ''
def _build_color_combos(names_sorted: list[str], colors_by_name: dict[str, list[str]]) -> list[tuple[str, str]]:
"""Compute present color combos and return [(code, display)], ordered by length then code."""
combo_set: set[str] = set()
for n in names_sorted:
cols = (colors_by_name.get(n) or [])
code = _canon_color_code(cols)
if len(code) >= 2:
combo_set.add(code)
combos: list[tuple[str, str]] = []
for code in sorted(combo_set, key=lambda s: (len(s), s)):
label = _color_combo_label(code)
display = f"{label} ({code})" if label else code
combos.append((code, display))
return combos
def _build_owned_context(request: Request, notice: str | None = None, error: str | None = None) -> dict:
"""Build the template context for the Owned Library page, including
enrichment from csv_files and filter option lists.
"""
# Read enriched data from the store (fast path; avoids per-request CSV parsing)
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
# Default sort by name (case-insensitive)
names_sorted = sorted(names, key=lambda s: s.lower())
# Build filter option sets
all_types = sorted({type_by_name.get(n) for n in names_sorted if type_by_name.get(n)}, key=lambda s: s.lower())
all_tags = sorted({t for n in names_sorted for t in (tags_by_name.get(n) or [])}, key=lambda s: s.lower())
all_colors = ['W','U','B','R','G','C']
# Build color combos displayed in the filter
combos = _build_color_combos(names_sorted, colors_by_name)
ctx = {
"request": request,
"names": names_sorted,
"count": len(names_sorted),
"tags_by_name": tags_by_name,
"type_by_name": type_by_name,
"colors_by_name": colors_by_name,
"all_types": all_types,
"all_tags": all_tags,
"all_colors": all_colors,
"color_combos": combos,
}
if notice:
ctx["notice"] = notice
if error:
ctx["error"] = error
return ctx
@router.get("/", response_class=HTMLResponse)
async def owned_index(request: Request) -> HTMLResponse:
ctx = _build_owned_context(request)
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/upload", response_class=HTMLResponse)
async def owned_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
try:
content = await file.read()
fname = (file.filename or "").lower()
if fname.endswith(".csv"):
names = store.parse_csv_bytes(content)
else:
names = store.parse_txt_bytes(content)
# Add and enrich immediately so the page doesn't need to parse CSVs
added, total = store.add_and_enrich(names)
notice = f"Added {added} new name(s). Total: {total}."
ctx = _build_owned_context(request, notice=notice)
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Upload failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/clear", response_class=HTMLResponse)
async def owned_clear(request: Request) -> HTMLResponse:
try:
store.clear()
ctx = _build_owned_context(request, notice="Library cleared.")
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Clear failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
@router.get("/export")
async def owned_export_txt() -> Response:
"""Download the owned library as a simple TXT (one name per line)."""
names, _, _, _ = store.get_enriched()
# Stable case-insensitive sort
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
return Response(
content=lines + ("\n" if lines else ""),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_cards.txt"},
)
@router.get("/export.csv")
async def owned_export_csv() -> Response:
"""Download the owned library with enrichment as CSV (Name,Type,Colors,Tags)."""
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
# Prepare CSV content
import csv
from io import StringIO
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(["Name", "Type", "Colors", "Tags"])
for n in sorted((names or []), key=lambda s: s.lower()):
tline = type_by_name.get(n, "")
cols = ''.join(colors_by_name.get(n, []) or [])
tags = '|'.join(tags_by_name.get(n, []) or [])
writer.writerow([n, tline, cols, tags])
content = buf.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_cards.csv"},
)