mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
Web/builder: Owned stability+enrichment+exports; prefer-owned toggle & bias; staged build show-skipped; UI polish; docs update
This commit is contained in:
parent
fd7fc01071
commit
625f6abb13
26 changed files with 1618 additions and 229 deletions
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
179
code/web/routes/owned.py
Normal 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"},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue