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
2025-08-26 09:48:25 -07:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2025-08-28 14:57:22 -07:00
|
|
|
|
from fastapi import APIRouter, Request, Form, Query
|
|
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
2025-09-09 09:36:17 -07:00
|
|
|
|
from ..app import ALLOW_MUST_HAVES # Import feature flag
|
2025-09-02 11:39:14 -07:00
|
|
|
|
from ..services.build_utils import (
|
|
|
|
|
step5_ctx_from_result,
|
|
|
|
|
step5_error_ctx,
|
|
|
|
|
step5_empty_ctx,
|
|
|
|
|
start_ctx_from_session,
|
|
|
|
|
owned_set as owned_set_helper,
|
|
|
|
|
builder_present_names,
|
|
|
|
|
builder_display_map,
|
|
|
|
|
)
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
from ..app import templates
|
|
|
|
|
from deck_builder import builder_constants as bc
|
|
|
|
|
from ..services import orchestrator as orch
|
2025-09-02 11:39:14 -07:00
|
|
|
|
from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore
|
|
|
|
|
from ..services.build_utils import owned_names as owned_names_helper
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
from ..services.tasks import get_session, new_sid
|
2025-08-28 14:57:22 -07:00
|
|
|
|
from html import escape as _esc
|
2025-08-29 09:19:03 -07:00
|
|
|
|
from deck_builder.builder import DeckBuilder
|
|
|
|
|
from deck_builder import builder_utils as bu
|
2025-09-02 11:39:14 -07:00
|
|
|
|
from ..services.combo_utils import detect_all as _detect_all
|
|
|
|
|
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/build")
|
|
|
|
|
|
2025-09-02 11:39:14 -07:00
|
|
|
|
# Alternatives cache moved to services/alts_utils
|
2025-08-28 14:57:22 -07: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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
2025-08-29 09:19:03 -07:00
|
|
|
|
def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
|
|
|
|
"""Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
|
|
|
|
|
|
|
|
|
|
This ensures the added cards are accounted for before lands and later phases,
|
|
|
|
|
which keeps totals near targets and shows the multi-copy additions ahead of basics.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
if not sess or not sess.get("commander"):
|
|
|
|
|
return
|
|
|
|
|
# Build fresh ctx with the same options, threading multi_copy explicitly
|
|
|
|
|
opts = orch.bracket_options()
|
|
|
|
|
default_bracket = (opts[0]["level"] if opts else 1)
|
|
|
|
|
bracket_val = sess.get("bracket")
|
|
|
|
|
try:
|
2025-09-02 11:39:14 -07:00
|
|
|
|
safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket
|
2025-08-29 09:19:03 -07:00
|
|
|
|
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"))
|
2025-09-02 11:39:14 -07:00
|
|
|
|
owned_names = owned_names_helper() if (use_owned or prefer) else None
|
2025-08-29 09:19:03 -07:00
|
|
|
|
locks = list(sess.get("locks", []))
|
|
|
|
|
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,
|
|
|
|
|
locks=locks,
|
|
|
|
|
custom_export_base=sess.get("custom_export_base"),
|
|
|
|
|
multi_copy=sess.get("multi_copy"),
|
2025-09-01 16:55:24 -07:00
|
|
|
|
prefer_combos=bool(sess.get("prefer_combos")),
|
|
|
|
|
combo_target_count=int(sess.get("combo_target_count", 2)),
|
|
|
|
|
combo_balance=str(sess.get("combo_balance", "mix")),
|
2025-08-29 09:19:03 -07:00
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
# If rebuild fails (e.g., commander not found in test), fall back to injecting
|
|
|
|
|
# a minimal Multi-Copy stage on the existing builder so the UI can render additions.
|
|
|
|
|
try:
|
|
|
|
|
ctx = sess.get("build_ctx")
|
|
|
|
|
if not isinstance(ctx, dict):
|
|
|
|
|
return
|
|
|
|
|
b = ctx.get("builder")
|
|
|
|
|
if b is None:
|
|
|
|
|
return
|
|
|
|
|
# Thread selection onto the builder; runner will be resilient without full DFs
|
|
|
|
|
try:
|
|
|
|
|
setattr(b, "_web_multi_copy", sess.get("multi_copy") or None)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Ensure minimal structures exist
|
|
|
|
|
try:
|
|
|
|
|
if not isinstance(getattr(b, "card_library", None), dict):
|
|
|
|
|
b.card_library = {}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
if not isinstance(getattr(b, "ideal_counts", None), dict):
|
|
|
|
|
b.ideal_counts = {}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Inject a single Multi-Copy stage
|
|
|
|
|
ctx["stages"] = [{"key": "multi_copy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}]
|
|
|
|
|
ctx["idx"] = 0
|
|
|
|
|
ctx["last_visible_idx"] = 0
|
|
|
|
|
except Exception:
|
|
|
|
|
# Leave existing context untouched on unexpected failure
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
|
|
|
async def build_index(request: Request) -> HTMLResponse:
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
# Determine last step (fallback heuristics if not set)
|
|
|
|
|
last_step = sess.get("last_step")
|
|
|
|
|
if not last_step:
|
|
|
|
|
if sess.get("build_ctx"):
|
|
|
|
|
last_step = 5
|
|
|
|
|
elif sess.get("ideals"):
|
|
|
|
|
last_step = 4
|
|
|
|
|
elif sess.get("bracket"):
|
|
|
|
|
last_step = 3
|
|
|
|
|
elif sess.get("commander"):
|
|
|
|
|
last_step = 2
|
|
|
|
|
else:
|
|
|
|
|
last_step = 1
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
resp = templates.TemplateResponse(
|
|
|
|
|
"build/index.html",
|
2025-08-26 20:00:07 -07:00
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"sid": sid,
|
|
|
|
|
"commander": sess.get("commander"),
|
|
|
|
|
"tags": sess.get("tags", []),
|
2025-08-28 14:57:22 -07:00
|
|
|
|
"name": sess.get("custom_export_base"),
|
2025-08-26 20:00:07 -07:00
|
|
|
|
"last_step": last_step,
|
|
|
|
|
},
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Support /build without trailing slash
|
|
|
|
|
@router.get("", response_class=HTMLResponse)
|
|
|
|
|
async def build_index_alias(request: Request) -> HTMLResponse:
|
|
|
|
|
return await build_index(request)
|
|
|
|
|
|
2025-08-29 09:19:03 -07:00
|
|
|
|
|
|
|
|
|
@router.get("/multicopy/check", response_class=HTMLResponse)
|
|
|
|
|
async def multicopy_check(request: Request) -> HTMLResponse:
|
|
|
|
|
"""If current commander/tags suggest a multi-copy archetype, render a choose-one modal.
|
|
|
|
|
|
|
|
|
|
Returns empty content when not applicable to avoid flashing a modal unnecessarily.
|
|
|
|
|
"""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
commander = str(sess.get("commander") or "").strip()
|
|
|
|
|
tags = list(sess.get("tags") or [])
|
|
|
|
|
if not commander:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
# Avoid re-prompting repeatedly for the same selection context
|
|
|
|
|
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
|
|
|
|
|
seen = set(sess.get("mc_seen_keys", []) or [])
|
|
|
|
|
if key in seen:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
# Build a light DeckBuilder seeded with commander + tags (no heavy data load required)
|
|
|
|
|
try:
|
|
|
|
|
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
|
|
|
|
df = tmp.load_commander_data()
|
|
|
|
|
row = df[df["name"].astype(str) == commander]
|
|
|
|
|
if row.empty:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
tmp._apply_commander_selection(row.iloc[0])
|
|
|
|
|
tmp.selected_tags = list(tags or [])
|
|
|
|
|
try:
|
|
|
|
|
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
|
|
|
|
|
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
|
|
|
|
|
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Establish color identity from the selected commander
|
|
|
|
|
try:
|
|
|
|
|
tmp.determine_color_identity()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Detect viable archetypes
|
|
|
|
|
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
|
|
|
|
|
if not results:
|
|
|
|
|
# Remember this key to avoid re-checking until tags/commander change
|
|
|
|
|
try:
|
|
|
|
|
seen.add(key)
|
|
|
|
|
sess["mc_seen_keys"] = list(seen)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
# Render modal template with top N (cap small for UX)
|
|
|
|
|
items = results[:5]
|
|
|
|
|
ctx = {
|
|
|
|
|
"request": request,
|
|
|
|
|
"items": items,
|
|
|
|
|
"commander": commander,
|
|
|
|
|
"tags": tags,
|
|
|
|
|
}
|
|
|
|
|
return templates.TemplateResponse("build/_multi_copy_modal.html", ctx)
|
|
|
|
|
except Exception:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/multicopy/save", response_class=HTMLResponse)
|
|
|
|
|
async def multicopy_save(
|
|
|
|
|
request: Request,
|
|
|
|
|
choice_id: str = Form(None),
|
|
|
|
|
count: int = Form(None),
|
|
|
|
|
thrumming: str | None = Form(None),
|
|
|
|
|
skip: str | None = Form(None),
|
|
|
|
|
) -> HTMLResponse:
|
|
|
|
|
"""Persist user selection (or skip) for multi-copy archetype in session and close modal.
|
|
|
|
|
|
|
|
|
|
Returns a tiny confirmation chip via OOB swap (optional) and removes the modal.
|
|
|
|
|
"""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
commander = str(sess.get("commander") or "").strip()
|
|
|
|
|
tags = list(sess.get("tags") or [])
|
|
|
|
|
key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()]))
|
|
|
|
|
# Update seen set to avoid re-prompt next load
|
|
|
|
|
seen = set(sess.get("mc_seen_keys", []) or [])
|
|
|
|
|
seen.add(key)
|
|
|
|
|
sess["mc_seen_keys"] = list(seen)
|
|
|
|
|
# Handle skip explicitly
|
|
|
|
|
if skip and str(skip).strip() in ("1","true","on","yes"):
|
|
|
|
|
# Clear any prior choice for this run
|
|
|
|
|
try:
|
|
|
|
|
if sess.get("multi_copy"):
|
|
|
|
|
del sess["multi_copy"]
|
|
|
|
|
if sess.get("mc_applied_key"):
|
|
|
|
|
del sess["mc_applied_key"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Return nothing (modal will be removed client-side)
|
|
|
|
|
# Also emit an OOB chip indicating skip
|
|
|
|
|
chip = (
|
|
|
|
|
'<div id="last-action" hx-swap-oob="true">'
|
|
|
|
|
'<span class="chip" title="Click to dismiss">Dismissed multi-copy suggestions</span>'
|
|
|
|
|
'</div>'
|
|
|
|
|
)
|
|
|
|
|
return HTMLResponse(chip)
|
|
|
|
|
# Persist selection when provided
|
|
|
|
|
payload = None
|
|
|
|
|
try:
|
|
|
|
|
meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {})
|
|
|
|
|
name = meta.get("name") or str(choice_id)
|
|
|
|
|
printed_cap = meta.get("printed_cap")
|
|
|
|
|
# Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present
|
|
|
|
|
if count is None:
|
|
|
|
|
count = int(meta.get("default_count", 25))
|
|
|
|
|
try:
|
|
|
|
|
count = int(count)
|
|
|
|
|
except Exception:
|
|
|
|
|
count = int(meta.get("default_count", 25))
|
|
|
|
|
if isinstance(printed_cap, int) and printed_cap > 0:
|
|
|
|
|
count = max(1, min(printed_cap, count))
|
|
|
|
|
payload = {
|
|
|
|
|
"id": str(choice_id),
|
|
|
|
|
"name": name,
|
|
|
|
|
"count": int(count),
|
|
|
|
|
"thrumming": True if (thrumming and str(thrumming).strip() in ("1","true","on","yes")) else False,
|
|
|
|
|
}
|
|
|
|
|
sess["multi_copy"] = payload
|
|
|
|
|
# Mark as not yet applied so the next build start/continue can account for it once
|
|
|
|
|
try:
|
|
|
|
|
if sess.get("mc_applied_key"):
|
|
|
|
|
del sess["mc_applied_key"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# If there's an active build context, rebuild it so Multi-Copy runs first
|
|
|
|
|
if sess.get("build_ctx"):
|
|
|
|
|
_rebuild_ctx_with_multicopy(sess)
|
|
|
|
|
except Exception:
|
|
|
|
|
payload = None
|
|
|
|
|
# Return OOB chip summarizing the selection
|
|
|
|
|
if payload:
|
|
|
|
|
chip = (
|
|
|
|
|
'<div id="last-action" hx-swap-oob="true">'
|
|
|
|
|
f'<span class="chip" title="Click to dismiss">Selected multi-copy: '
|
|
|
|
|
f"<strong>{_esc(payload.get('name',''))}</strong> x{int(payload.get('count',0))}"
|
|
|
|
|
f"{' + Thrumming Stone' if payload.get('thrumming') else ''}</span>"
|
|
|
|
|
'</div>'
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
chip = (
|
|
|
|
|
'<div id="last-action" hx-swap-oob="true">'
|
|
|
|
|
'<span class="chip" title="Click to dismiss">Saved</span>'
|
|
|
|
|
'</div>'
|
|
|
|
|
)
|
|
|
|
|
return HTMLResponse(chip)
|
|
|
|
|
|
|
|
|
|
|
2025-09-01 16:55:24 -07:00
|
|
|
|
|
|
|
|
|
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Unified "New Deck" modal (steps 1–3 condensed)
|
|
|
|
|
@router.get("/new", response_class=HTMLResponse)
|
|
|
|
|
async def build_new_modal(request: Request) -> HTMLResponse:
|
|
|
|
|
"""Return the New Deck modal content (for an overlay)."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
ctx = {
|
|
|
|
|
"request": request,
|
|
|
|
|
"brackets": orch.bracket_options(),
|
|
|
|
|
"labels": orch.ideal_labels(),
|
|
|
|
|
"defaults": orch.ideal_defaults(),
|
2025-09-09 09:36:17 -07:00
|
|
|
|
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
2025-08-28 14:57:22 -07:00
|
|
|
|
}
|
|
|
|
|
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/new/candidates", response_class=HTMLResponse)
|
|
|
|
|
async def build_new_candidates(request: Request, commander: str = Query("")) -> HTMLResponse:
|
|
|
|
|
"""Return a small list of commander candidates for the modal live search."""
|
|
|
|
|
q = (commander or "").strip()
|
|
|
|
|
items = orch.commander_candidates(q, limit=8) if q else []
|
|
|
|
|
ctx = {"request": request, "query": q, "candidates": items}
|
|
|
|
|
return templates.TemplateResponse("build/_new_deck_candidates.html", ctx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/new/inspect", response_class=HTMLResponse)
|
|
|
|
|
async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse:
|
|
|
|
|
"""When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates)."""
|
|
|
|
|
info = orch.commander_select(name)
|
|
|
|
|
if not info.get("ok"):
|
|
|
|
|
return HTMLResponse(f'<div class="muted">Commander not found: {name}</div>')
|
|
|
|
|
tags = orch.tags_for_commander(info["name"]) or []
|
|
|
|
|
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
|
|
|
|
|
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
|
|
|
|
|
# Render tags slot content and OOB commander preview simultaneously
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer)
|
|
|
|
|
is_gc = False
|
|
|
|
|
try:
|
|
|
|
|
is_gc = bool(info["name"] in getattr(bc, 'GAME_CHANGERS', []))
|
|
|
|
|
except Exception:
|
|
|
|
|
is_gc = False
|
2025-08-28 14:57:22 -07:00
|
|
|
|
ctx = {
|
|
|
|
|
"request": request,
|
|
|
|
|
"commander": {"name": info["name"]},
|
|
|
|
|
"tags": tags,
|
|
|
|
|
"recommended": recommended,
|
|
|
|
|
"recommended_reasons": recommended_reasons,
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"gc_commander": is_gc,
|
|
|
|
|
"brackets": orch.bracket_options(),
|
2025-08-28 14:57:22 -07:00
|
|
|
|
}
|
|
|
|
|
return templates.TemplateResponse("build/_new_deck_tags.html", ctx)
|
|
|
|
|
|
|
|
|
|
|
2025-09-02 16:03:12 -07:00
|
|
|
|
@router.get("/new/multicopy", response_class=HTMLResponse)
|
|
|
|
|
async def build_new_multicopy(
|
|
|
|
|
request: Request,
|
|
|
|
|
commander: str = Query(""),
|
|
|
|
|
primary_tag: str | None = Query(None),
|
|
|
|
|
secondary_tag: str | None = Query(None),
|
|
|
|
|
tertiary_tag: str | None = Query(None),
|
|
|
|
|
tag_mode: str | None = Query("AND"),
|
|
|
|
|
) -> HTMLResponse:
|
|
|
|
|
"""Return multi-copy suggestions for the New Deck modal based on commander + selected tags.
|
|
|
|
|
|
|
|
|
|
This does not mutate the session; it simply renders a form snippet that posts with the main modal.
|
|
|
|
|
"""
|
|
|
|
|
name = (commander or "").strip()
|
|
|
|
|
if not name:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
try:
|
|
|
|
|
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
|
|
|
|
df = tmp.load_commander_data()
|
|
|
|
|
row = df[df["name"].astype(str) == name]
|
|
|
|
|
if row.empty:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
tmp._apply_commander_selection(row.iloc[0])
|
|
|
|
|
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
|
|
|
|
tmp.selected_tags = list(tags or [])
|
|
|
|
|
try:
|
|
|
|
|
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
|
|
|
|
|
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
|
|
|
|
|
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
tmp.determine_color_identity()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
|
|
|
|
|
# For the New Deck modal, only show suggestions where the matched tags intersect
|
|
|
|
|
# the explicitly selected tags (ignore commander-default themes).
|
|
|
|
|
sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()}
|
|
|
|
|
def _matched_reason_tags(item: dict) -> set[str]:
|
|
|
|
|
out = set()
|
|
|
|
|
try:
|
|
|
|
|
for r in item.get('reasons', []) or []:
|
|
|
|
|
if not isinstance(r, str):
|
|
|
|
|
continue
|
|
|
|
|
rl = r.strip().lower()
|
|
|
|
|
if rl.startswith('tags:'):
|
|
|
|
|
body = rl.split('tags:', 1)[1].strip()
|
|
|
|
|
parts = [p.strip() for p in body.split(',') if p.strip()]
|
|
|
|
|
out.update(parts)
|
|
|
|
|
except Exception:
|
|
|
|
|
return set()
|
|
|
|
|
return out
|
|
|
|
|
if sel_tags:
|
|
|
|
|
results = [it for it in results if (_matched_reason_tags(it) & sel_tags)]
|
|
|
|
|
else:
|
|
|
|
|
# If no selected tags, do not show any multi-copy suggestions in the modal
|
|
|
|
|
results = []
|
|
|
|
|
if not results:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
items = results[:5]
|
|
|
|
|
ctx = {"request": request, "items": items}
|
|
|
|
|
return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx)
|
|
|
|
|
except Exception:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
|
|
|
|
|
|
2025-08-28 14:57:22 -07:00
|
|
|
|
@router.post("/new", response_class=HTMLResponse)
|
|
|
|
|
async def build_new_submit(
|
|
|
|
|
request: Request,
|
|
|
|
|
name: str = Form("") ,
|
|
|
|
|
commander: str = Form(...),
|
|
|
|
|
primary_tag: str | None = Form(None),
|
|
|
|
|
secondary_tag: str | None = Form(None),
|
|
|
|
|
tertiary_tag: str | None = Form(None),
|
|
|
|
|
tag_mode: str | None = Form("AND"),
|
|
|
|
|
bracket: int = Form(...),
|
|
|
|
|
ramp: int = Form(None),
|
|
|
|
|
lands: int = Form(None),
|
|
|
|
|
basic_lands: int = Form(None),
|
|
|
|
|
creatures: int = Form(None),
|
|
|
|
|
removal: int = Form(None),
|
|
|
|
|
wipes: int = Form(None),
|
|
|
|
|
card_advantage: int = Form(None),
|
|
|
|
|
protection: int = Form(None),
|
2025-09-01 16:55:24 -07:00
|
|
|
|
prefer_combos: bool = Form(False),
|
|
|
|
|
combo_count: int | None = Form(None),
|
|
|
|
|
combo_balance: str | None = Form(None),
|
2025-09-02 16:03:12 -07:00
|
|
|
|
enable_multicopy: bool = Form(False),
|
|
|
|
|
# Integrated Multi-Copy (optional)
|
|
|
|
|
multi_choice_id: str | None = Form(None),
|
|
|
|
|
multi_count: int | None = Form(None),
|
|
|
|
|
multi_thrumming: str | None = Form(None),
|
2025-09-09 09:36:17 -07:00
|
|
|
|
# Must-haves/excludes (optional)
|
2025-09-09 18:15:30 -07:00
|
|
|
|
include_cards: str = Form(""),
|
2025-09-09 09:36:17 -07:00
|
|
|
|
exclude_cards: str = Form(""),
|
2025-09-09 18:15:30 -07:00
|
|
|
|
enforcement_mode: str = Form("warn"),
|
|
|
|
|
allow_illegal: bool = Form(False),
|
|
|
|
|
fuzzy_matching: bool = Form(True),
|
2025-08-28 14:57:22 -07:00
|
|
|
|
) -> HTMLResponse:
|
|
|
|
|
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
# Normalize and validate commander selection (best-effort via orchestrator)
|
|
|
|
|
sel = orch.commander_select(commander)
|
|
|
|
|
if not sel.get("ok"):
|
|
|
|
|
# Re-render modal with error
|
|
|
|
|
ctx = {
|
|
|
|
|
"request": request,
|
|
|
|
|
"error": sel.get("error", "Commander not found"),
|
|
|
|
|
"brackets": orch.bracket_options(),
|
|
|
|
|
"labels": orch.ideal_labels(),
|
|
|
|
|
"defaults": orch.ideal_defaults(),
|
2025-09-09 09:36:17 -07:00
|
|
|
|
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
2025-08-28 14:57:22 -07:00
|
|
|
|
"form": {
|
|
|
|
|
"name": name,
|
|
|
|
|
"commander": commander,
|
|
|
|
|
"primary_tag": primary_tag or "",
|
|
|
|
|
"secondary_tag": secondary_tag or "",
|
|
|
|
|
"tertiary_tag": tertiary_tag or "",
|
|
|
|
|
"tag_mode": tag_mode or "AND",
|
|
|
|
|
"bracket": bracket,
|
2025-09-01 16:55:24 -07:00
|
|
|
|
"combo_count": combo_count,
|
|
|
|
|
"combo_balance": (combo_balance or "mix"),
|
|
|
|
|
"prefer_combos": bool(prefer_combos),
|
2025-09-09 18:15:30 -07:00
|
|
|
|
"include_cards": include_cards or "",
|
2025-09-09 09:36:17 -07:00
|
|
|
|
"exclude_cards": exclude_cards or "",
|
2025-09-09 18:15:30 -07:00
|
|
|
|
"enforcement_mode": enforcement_mode or "warn",
|
|
|
|
|
"allow_illegal": bool(allow_illegal),
|
|
|
|
|
"fuzzy_matching": bool(fuzzy_matching),
|
2025-08-28 14:57:22 -07:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Enforce GC bracket restriction before saving session (silently coerce to 3)
|
|
|
|
|
try:
|
|
|
|
|
is_gc = bool((sel.get("name") or commander) in getattr(bc, 'GAME_CHANGERS', []))
|
|
|
|
|
except Exception:
|
|
|
|
|
is_gc = False
|
|
|
|
|
if is_gc:
|
|
|
|
|
try:
|
|
|
|
|
if int(bracket) < 3:
|
|
|
|
|
bracket = 3
|
|
|
|
|
except Exception:
|
|
|
|
|
bracket = 3
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Save to session
|
|
|
|
|
sess["commander"] = sel.get("name") or commander
|
|
|
|
|
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
|
|
|
|
# If commander has a tag list and primary missing, set first recommended as default
|
|
|
|
|
if not tags:
|
|
|
|
|
try:
|
|
|
|
|
rec = orch.recommended_tags_for_commander(sess["commander"]) or []
|
|
|
|
|
if rec:
|
|
|
|
|
tags = [rec[0]]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
sess["tags"] = tags
|
|
|
|
|
sess["tag_mode"] = (tag_mode or "AND").upper()
|
|
|
|
|
try:
|
|
|
|
|
# Default to bracket 3 (Upgraded) when not provided
|
|
|
|
|
sess["bracket"] = int(bracket) if (bracket is not None) else 3
|
|
|
|
|
except Exception:
|
|
|
|
|
try:
|
|
|
|
|
sess["bracket"] = int(bracket)
|
|
|
|
|
except Exception:
|
|
|
|
|
sess["bracket"] = 3
|
|
|
|
|
# Ideals: use provided values if any, else defaults
|
|
|
|
|
ideals = orch.ideal_defaults()
|
|
|
|
|
overrides = {k: v for k, v in {
|
|
|
|
|
"ramp": ramp,
|
|
|
|
|
"lands": lands,
|
|
|
|
|
"basic_lands": basic_lands,
|
|
|
|
|
"creatures": creatures,
|
|
|
|
|
"removal": removal,
|
|
|
|
|
"wipes": wipes,
|
|
|
|
|
"card_advantage": card_advantage,
|
|
|
|
|
"protection": protection,
|
|
|
|
|
}.items() if v is not None}
|
|
|
|
|
for k, v in overrides.items():
|
|
|
|
|
try:
|
|
|
|
|
ideals[k] = int(v)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
sess["ideals"] = ideals
|
2025-09-01 16:55:24 -07:00
|
|
|
|
# Persist preferences
|
|
|
|
|
try:
|
|
|
|
|
sess["prefer_combos"] = bool(prefer_combos)
|
|
|
|
|
except Exception:
|
|
|
|
|
sess["prefer_combos"] = False
|
|
|
|
|
# Combos config from modal
|
|
|
|
|
try:
|
|
|
|
|
if combo_count is not None:
|
|
|
|
|
sess["combo_target_count"] = max(0, min(10, int(combo_count)))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
if combo_balance:
|
|
|
|
|
bval = str(combo_balance).strip().lower()
|
|
|
|
|
if bval in ("early","late","mix"):
|
|
|
|
|
sess["combo_balance"] = bval
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-02 16:03:12 -07:00
|
|
|
|
# Multi-Copy selection from modal (opt-in)
|
|
|
|
|
try:
|
|
|
|
|
# Clear any prior selection first; this flow should define it explicitly when present
|
|
|
|
|
if "multi_copy" in sess:
|
|
|
|
|
del sess["multi_copy"]
|
|
|
|
|
if enable_multicopy and multi_choice_id and str(multi_choice_id).strip():
|
|
|
|
|
meta = bc.MULTI_COPY_ARCHETYPES.get(str(multi_choice_id), {})
|
|
|
|
|
printed_cap = meta.get("printed_cap")
|
|
|
|
|
cnt: int
|
|
|
|
|
if multi_count is None:
|
|
|
|
|
cnt = int(meta.get("default_count", 25))
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
cnt = int(multi_count)
|
|
|
|
|
except Exception:
|
|
|
|
|
cnt = int(meta.get("default_count", 25))
|
|
|
|
|
if isinstance(printed_cap, int) and printed_cap > 0:
|
|
|
|
|
cnt = max(1, min(printed_cap, cnt))
|
|
|
|
|
sess["multi_copy"] = {
|
|
|
|
|
"id": str(multi_choice_id),
|
|
|
|
|
"name": meta.get("name") or str(multi_choice_id),
|
|
|
|
|
"count": int(cnt),
|
|
|
|
|
"thrumming": True if (multi_thrumming and str(multi_thrumming).strip() in ("1","true","on","yes")) else False,
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
# Ensure disabled when not opted-in
|
|
|
|
|
if "multi_copy" in sess:
|
|
|
|
|
del sess["multi_copy"]
|
|
|
|
|
# Reset the applied marker so the run can account for the new selection
|
|
|
|
|
if "mc_applied_key" in sess:
|
|
|
|
|
del sess["mc_applied_key"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Process include/exclude cards (M3: Phase 2 - Full Include/Exclude)
|
2025-09-09 09:36:17 -07:00
|
|
|
|
try:
|
|
|
|
|
from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics
|
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Clear any old include/exclude data
|
|
|
|
|
for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]:
|
2025-09-09 09:36:17 -07:00
|
|
|
|
if k in sess:
|
|
|
|
|
del sess[k]
|
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Process include cards
|
|
|
|
|
if include_cards and include_cards.strip():
|
|
|
|
|
print(f"DEBUG: Raw include_cards input: '{include_cards}'")
|
|
|
|
|
include_list = parse_card_list_input(include_cards.strip())
|
|
|
|
|
print(f"DEBUG: Parsed include_list: {include_list}")
|
|
|
|
|
sess["include_cards"] = include_list
|
|
|
|
|
else:
|
|
|
|
|
print(f"DEBUG: include_cards is empty or None: '{include_cards}'")
|
|
|
|
|
|
|
|
|
|
# Process exclude cards
|
2025-09-09 09:36:17 -07:00
|
|
|
|
if exclude_cards and exclude_cards.strip():
|
2025-09-09 18:15:30 -07:00
|
|
|
|
print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'")
|
2025-09-09 09:36:17 -07:00
|
|
|
|
exclude_list = parse_card_list_input(exclude_cards.strip())
|
2025-09-09 18:15:30 -07:00
|
|
|
|
print(f"DEBUG: Parsed exclude_list: {exclude_list}")
|
2025-09-09 09:36:17 -07:00
|
|
|
|
sess["exclude_cards"] = exclude_list
|
2025-09-09 18:15:30 -07:00
|
|
|
|
else:
|
|
|
|
|
print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'")
|
|
|
|
|
|
|
|
|
|
# Store advanced options
|
|
|
|
|
sess["enforcement_mode"] = enforcement_mode
|
|
|
|
|
sess["allow_illegal"] = allow_illegal
|
|
|
|
|
sess["fuzzy_matching"] = fuzzy_matching
|
|
|
|
|
|
|
|
|
|
# Create basic diagnostics for status tracking
|
|
|
|
|
if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()):
|
2025-09-09 09:36:17 -07:00
|
|
|
|
diagnostics = IncludeExcludeDiagnostics(
|
|
|
|
|
missing_includes=[],
|
|
|
|
|
ignored_color_identity=[],
|
|
|
|
|
illegal_dropped=[],
|
|
|
|
|
illegal_allowed=[],
|
2025-09-09 18:15:30 -07:00
|
|
|
|
excluded_removed=sess.get("exclude_cards", []),
|
2025-09-09 09:36:17 -07:00
|
|
|
|
duplicates_collapsed={},
|
|
|
|
|
include_added=[],
|
|
|
|
|
include_over_ideal={},
|
|
|
|
|
fuzzy_corrections={},
|
|
|
|
|
confirmation_needed=[],
|
2025-09-09 18:15:30 -07:00
|
|
|
|
list_size_warnings={
|
|
|
|
|
"includes_count": len(sess.get("include_cards", [])),
|
|
|
|
|
"excludes_count": len(sess.get("exclude_cards", [])),
|
|
|
|
|
"includes_limit": 10,
|
|
|
|
|
"excludes_limit": 15
|
|
|
|
|
}
|
2025-09-09 09:36:17 -07:00
|
|
|
|
)
|
2025-09-09 18:15:30 -07:00
|
|
|
|
sess["include_exclude_diagnostics"] = diagnostics.__dict__
|
2025-09-09 09:36:17 -07:00
|
|
|
|
except Exception as e:
|
|
|
|
|
# If exclude parsing fails, log but don't block the build
|
|
|
|
|
import logging
|
|
|
|
|
logging.warning(f"Failed to parse exclude cards: {e}")
|
|
|
|
|
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Clear any old staged build context
|
|
|
|
|
for k in ["build_ctx", "locks", "replace_mode"]:
|
|
|
|
|
if k in sess:
|
|
|
|
|
try:
|
|
|
|
|
del sess[k]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-02 16:03:12 -07:00
|
|
|
|
# Reset multi-copy suggestion debounce for a fresh run (keep selected choice)
|
|
|
|
|
if "mc_seen_keys" in sess:
|
|
|
|
|
try:
|
|
|
|
|
del sess["mc_seen_keys"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Persist optional custom export base name
|
|
|
|
|
if isinstance(name, str) and name.strip():
|
|
|
|
|
sess["custom_export_base"] = name.strip()
|
|
|
|
|
else:
|
|
|
|
|
if "custom_export_base" in sess:
|
|
|
|
|
try:
|
|
|
|
|
del sess["custom_export_base"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-02 11:39:14 -07:00
|
|
|
|
# If setup/tagging is not ready or stale, show a modal prompt instead of auto-running.
|
|
|
|
|
try:
|
|
|
|
|
if not _is_setup_ready():
|
|
|
|
|
return templates.TemplateResponse(
|
|
|
|
|
"build/_setup_prompt_modal.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"title": "Setup required",
|
|
|
|
|
"message": "The card database and tags need to be prepared before building a deck.",
|
|
|
|
|
"action_url": "/setup/running?start=1&next=/build",
|
|
|
|
|
"action_label": "Run Setup",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if _is_setup_stale():
|
|
|
|
|
return templates.TemplateResponse(
|
|
|
|
|
"build/_setup_prompt_modal.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"title": "Data refresh recommended",
|
|
|
|
|
"message": "Your card database is stale. Refreshing ensures up-to-date results.",
|
|
|
|
|
"action_url": "/setup/running?start=1&force=1&next=/build",
|
|
|
|
|
"action_label": "Refresh Now",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
# If readiness check fails, continue and let downstream handling surface errors
|
|
|
|
|
pass
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Immediately initialize a build context and run the first stage, like hitting Build Deck on review
|
|
|
|
|
if "replace_mode" not in sess:
|
|
|
|
|
sess["replace_mode"] = True
|
2025-09-02 11:39:14 -07:00
|
|
|
|
# Centralized staged context creation
|
|
|
|
|
sess["build_ctx"] = start_ctx_from_session(sess)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
|
2025-09-02 16:03:12 -07:00
|
|
|
|
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
|
|
|
|
|
try:
|
|
|
|
|
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
|
|
|
|
|
mc = sess.get("multi_copy")
|
|
|
|
|
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-08-28 14:57:22 -07:00
|
|
|
|
status = "Build complete" if res.get("done") else "Stage complete"
|
|
|
|
|
sess["last_step"] = 5
|
2025-09-02 11:39:14 -07:00
|
|
|
|
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False)
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
@router.get("/step1", response_class=HTMLResponse)
|
|
|
|
|
async def build_step1(request: Request) -> HTMLResponse:
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
sess["last_step"] = 1
|
|
|
|
|
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/step1", response_class=HTMLResponse)
|
2025-08-26 11:34:42 -07:00
|
|
|
|
async def build_step1_search(
|
|
|
|
|
request: Request,
|
|
|
|
|
query: str = Form(""),
|
|
|
|
|
auto: str | None = Form(None),
|
|
|
|
|
active: str | None = Form(None),
|
|
|
|
|
) -> HTMLResponse:
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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"):
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
sess["last_step"] = 2
|
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"build/_step2.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"commander": res,
|
|
|
|
|
"tags": orch.tags_for_commander(res["name"]),
|
2025-08-26 11:34:42 -07:00
|
|
|
|
"recommended": orch.recommended_tags_for_commander(res["name"]),
|
|
|
|
|
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"brackets": orch.bracket_options(),
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"gc_commander": (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])),
|
|
|
|
|
"selected_bracket": (3 if (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) else None),
|
2025-08-28 14:57:22 -07:00
|
|
|
|
"clear_persisted": True,
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
sess["last_step"] = 1
|
|
|
|
|
resp = templates.TemplateResponse(
|
2025-08-26 11:34:42 -07:00
|
|
|
|
"build/_step1.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"query": query,
|
|
|
|
|
"candidates": candidates,
|
|
|
|
|
"auto": auto_enabled,
|
|
|
|
|
"active": active,
|
|
|
|
|
"count": len(candidates) if candidates else 0,
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/step1/inspect", response_class=HTMLResponse)
|
|
|
|
|
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
sess["last_step"] = 1
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
info = orch.commander_inspect(name)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"build/_step1.html",
|
|
|
|
|
{"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"):
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
sess["last_step"] = 1
|
|
|
|
|
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Proceed to step2 placeholder and reset any prior build/session selections
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Reset sticky selections from previous runs
|
2025-08-29 09:19:03 -07:00
|
|
|
|
for k in ["tags", "ideals", "bracket", "build_ctx", "last_step", "tag_mode", "mc_seen_keys", "multi_copy"]:
|
2025-08-28 14:57:22 -07:00
|
|
|
|
try:
|
|
|
|
|
if k in sess:
|
|
|
|
|
del sess[k]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 2
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Determine if commander is a Game Changer to drive bracket UI hiding
|
|
|
|
|
is_gc = False
|
|
|
|
|
try:
|
|
|
|
|
is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', []))
|
|
|
|
|
except Exception:
|
|
|
|
|
is_gc = False
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"build/_step2.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"commander": res,
|
|
|
|
|
"tags": orch.tags_for_commander(res["name"]),
|
2025-08-26 11:34:42 -07:00
|
|
|
|
"recommended": orch.recommended_tags_for_commander(res["name"]),
|
|
|
|
|
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"brackets": orch.bracket_options(),
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"gc_commander": is_gc,
|
|
|
|
|
"selected_bracket": (3 if is_gc else None),
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Signal that this navigation came from a fresh commander confirmation,
|
|
|
|
|
# so the Step 2 UI should clear any localStorage theme persistence.
|
|
|
|
|
"clear_persisted": True,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
@router.post("/reset-all", response_class=HTMLResponse)
|
|
|
|
|
async def build_reset_all(request: Request) -> HTMLResponse:
|
|
|
|
|
"""Clear all build-related session state and return Step 1."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
keys = [
|
|
|
|
|
"commander","tags","tag_mode","bracket","ideals","build_ctx","last_step",
|
|
|
|
|
"locks","replace_mode"
|
|
|
|
|
]
|
|
|
|
|
for k in keys:
|
|
|
|
|
try:
|
|
|
|
|
if k in sess:
|
|
|
|
|
del sess[k]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
sess["last_step"] = 1
|
|
|
|
|
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
@router.post("/step5/rewind", response_class=HTMLResponse)
|
|
|
|
|
async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLResponse:
|
|
|
|
|
"""Rewind the staged build to a previous visible stage by index or key and show that stage.
|
|
|
|
|
|
|
|
|
|
Param `to` can be an integer index (1-based stage index) or a stage key string.
|
|
|
|
|
"""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
ctx = sess.get("build_ctx")
|
|
|
|
|
if not ctx:
|
|
|
|
|
return await build_step5_get(request)
|
|
|
|
|
target_i: int | None = None
|
|
|
|
|
# Resolve by numeric index first
|
|
|
|
|
try:
|
|
|
|
|
idx_val = int(str(to).strip())
|
|
|
|
|
target_i = idx_val
|
|
|
|
|
except Exception:
|
|
|
|
|
target_i = None
|
|
|
|
|
if target_i is None:
|
|
|
|
|
# attempt by key
|
|
|
|
|
key = str(to).strip()
|
|
|
|
|
try:
|
|
|
|
|
for h in ctx.get("history", []) or []:
|
|
|
|
|
if str(h.get("key")) == key or str(h.get("label")) == key:
|
|
|
|
|
target_i = int(h.get("i"))
|
|
|
|
|
break
|
|
|
|
|
except Exception:
|
|
|
|
|
target_i = None
|
|
|
|
|
if not target_i:
|
|
|
|
|
return await build_step5_get(request)
|
|
|
|
|
# Try to restore snapshot stored for that history entry
|
|
|
|
|
try:
|
|
|
|
|
hist = ctx.get("history", []) or []
|
|
|
|
|
snap = None
|
|
|
|
|
for h in hist:
|
|
|
|
|
if int(h.get("i")) == int(target_i):
|
|
|
|
|
snap = h.get("snapshot")
|
|
|
|
|
break
|
|
|
|
|
if snap is not None:
|
|
|
|
|
orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined]
|
|
|
|
|
ctx["idx"] = int(target_i) - 1
|
|
|
|
|
ctx["last_visible_idx"] = int(target_i) - 1
|
|
|
|
|
except Exception:
|
|
|
|
|
# As a fallback, restart ctx and run forward until target
|
2025-09-02 11:39:14 -07:00
|
|
|
|
sess["build_ctx"] = start_ctx_from_session(sess)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
ctx = sess["build_ctx"]
|
|
|
|
|
# Run forward until reaching target
|
|
|
|
|
while True:
|
|
|
|
|
res = orch.run_stage(ctx, rerun=False, show_skipped=False)
|
|
|
|
|
if int(res.get("idx", 0)) >= int(target_i):
|
|
|
|
|
break
|
|
|
|
|
if res.get("done"):
|
|
|
|
|
break
|
|
|
|
|
# Finally show the target stage by running it with show_skipped True to get a view
|
2025-09-02 11:39:14 -07:00
|
|
|
|
try:
|
|
|
|
|
res = orch.run_stage(ctx, rerun=False, show_skipped=True)
|
|
|
|
|
status = "Stage (rewound)" if not res.get("done") else "Build complete"
|
|
|
|
|
ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={
|
2025-08-28 14:57:22 -07:00
|
|
|
|
"history": ctx.get("history", []),
|
2025-09-02 11:39:14 -07:00
|
|
|
|
})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
sess["last_step"] = 5
|
|
|
|
|
ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 2
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
commander = sess.get("commander")
|
|
|
|
|
if not commander:
|
|
|
|
|
# Fallback to step1 if no commander in session
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
tags = orch.tags_for_commander(commander)
|
|
|
|
|
selected = sess.get("tags", [])
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Determine if the selected commander is considered a Game Changer (affects bracket choices)
|
|
|
|
|
is_gc = False
|
|
|
|
|
try:
|
|
|
|
|
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
|
|
|
|
|
except Exception:
|
|
|
|
|
is_gc = False
|
|
|
|
|
# Selected bracket: if GC commander and bracket < 3 or missing, default to 3
|
|
|
|
|
sel_br = sess.get("bracket")
|
|
|
|
|
try:
|
|
|
|
|
sel_br = int(sel_br) if sel_br is not None else None
|
|
|
|
|
except Exception:
|
|
|
|
|
sel_br = None
|
|
|
|
|
if is_gc and (sel_br is None or int(sel_br) < 3):
|
|
|
|
|
sel_br = 3
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"build/_step2.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"commander": {"name": commander},
|
|
|
|
|
"tags": tags,
|
2025-08-26 11:34:42 -07:00
|
|
|
|
"recommended": orch.recommended_tags_for_commander(commander),
|
|
|
|
|
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"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 "",
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"selected_bracket": sel_br,
|
2025-08-26 11:34:42 -07:00
|
|
|
|
"tag_mode": sess.get("tag_mode", "AND"),
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"gc_commander": is_gc,
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# If there are no server-side tags for this commander, let the client clear any persisted ones
|
|
|
|
|
# to avoid themes sticking between fresh runs.
|
|
|
|
|
"clear_persisted": False if selected else True,
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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),
|
2025-08-26 11:34:42 -07:00
|
|
|
|
tag_mode: str | None = Form("AND"),
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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()):
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
sess["last_step"] = 2
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Compute GC flag to hide disallowed brackets on error
|
|
|
|
|
is_gc = False
|
|
|
|
|
try:
|
|
|
|
|
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
|
|
|
|
|
except Exception:
|
|
|
|
|
is_gc = False
|
|
|
|
|
try:
|
|
|
|
|
sel_br = int(bracket) if bracket is not None else None
|
|
|
|
|
except Exception:
|
|
|
|
|
sel_br = None
|
|
|
|
|
if is_gc and (sel_br is None or sel_br < 3):
|
|
|
|
|
sel_br = 3
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"build/_step2.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"commander": {"name": commander},
|
|
|
|
|
"tags": available_tags,
|
2025-08-26 11:34:42 -07:00
|
|
|
|
"recommended": orch.recommended_tags_for_commander(commander),
|
|
|
|
|
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"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 "",
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"selected_bracket": sel_br,
|
2025-08-26 11:34:42 -07:00
|
|
|
|
"tag_mode": (tag_mode or "AND"),
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"gc_commander": is_gc,
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed)
|
|
|
|
|
try:
|
|
|
|
|
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
|
|
|
|
|
except Exception:
|
|
|
|
|
is_gc = False
|
|
|
|
|
if is_gc:
|
|
|
|
|
try:
|
|
|
|
|
if int(bracket) < 3:
|
|
|
|
|
bracket = 3 # coerce silently
|
|
|
|
|
except Exception:
|
|
|
|
|
bracket = 3
|
|
|
|
|
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
# 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]
|
2025-08-26 11:34:42 -07:00
|
|
|
|
sess["tag_mode"] = (tag_mode or "AND").upper()
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
sess["bracket"] = int(bracket)
|
2025-08-29 09:19:03 -07:00
|
|
|
|
# Clear multi-copy seen/selection to re-evaluate on Step 3
|
|
|
|
|
try:
|
|
|
|
|
if "mc_seen_keys" in sess:
|
|
|
|
|
del sess["mc_seen_keys"]
|
|
|
|
|
if "multi_copy" in sess:
|
|
|
|
|
del sess["multi_copy"]
|
|
|
|
|
if "mc_applied_key" in sess:
|
|
|
|
|
del sess["mc_applied_key"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
# Proceed to Step 3 placeholder for now
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 3
|
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"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(),
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 3
|
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"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"),
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
# Save to session
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
sess["ideals"] = submitted
|
2025-08-29 09:19:03 -07:00
|
|
|
|
# Any change to ideals should clear the applied marker, we may want to re-stage
|
|
|
|
|
try:
|
|
|
|
|
if "mc_applied_key" in sess:
|
|
|
|
|
del sess["mc_applied_key"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
# Proceed to review (Step 4)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 4
|
|
|
|
|
resp = templates.TemplateResponse(
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
"build/_step4.html",
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"labels": labels,
|
|
|
|
|
"values": submitted,
|
|
|
|
|
"commander": sess.get("commander"),
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 3
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 4
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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,
|
2025-08-26 16:25:34 -07:00
|
|
|
|
"owned_only": bool(sess.get("use_owned_only")),
|
|
|
|
|
"prefer_owned": bool(sess.get("prefer_owned")),
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-09-01 16:55:24 -07:00
|
|
|
|
# --- Combos & Synergies panel (M3) ---
|
|
|
|
|
def _get_current_deck_names(sess: dict) -> list[str]:
|
|
|
|
|
try:
|
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
b = ctx.get("builder")
|
|
|
|
|
lib = getattr(b, "card_library", {}) if b is not None else {}
|
|
|
|
|
names = [str(n) for n in lib.keys()]
|
|
|
|
|
return sorted(dict.fromkeys(names))
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/combos", response_class=HTMLResponse)
|
|
|
|
|
async def build_combos_panel(request: Request) -> HTMLResponse:
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
names = _get_current_deck_names(sess)
|
|
|
|
|
if not names:
|
|
|
|
|
# No active build; render nothing to avoid UI clutter
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
|
|
|
|
|
# Preferences (persisted in session)
|
|
|
|
|
policy = (sess.get("combos_policy") or "neutral").lower()
|
|
|
|
|
if policy not in {"avoid", "neutral", "prefer"}:
|
|
|
|
|
policy = "neutral"
|
|
|
|
|
try:
|
|
|
|
|
target = int(sess.get("combos_target") or 0)
|
|
|
|
|
except Exception:
|
|
|
|
|
target = 0
|
|
|
|
|
if target < 0:
|
|
|
|
|
target = 0
|
|
|
|
|
|
|
|
|
|
# Load lists and run detection
|
2025-09-02 11:39:14 -07:00
|
|
|
|
_det = _detect_all(names)
|
|
|
|
|
combos = _det.get("combos", [])
|
|
|
|
|
synergies = _det.get("synergies", [])
|
|
|
|
|
combos_model = _det.get("combos_model")
|
|
|
|
|
synergies_model = _det.get("synergies_model")
|
2025-09-01 16:55:24 -07:00
|
|
|
|
|
|
|
|
|
# Suggestions
|
|
|
|
|
suggestions: list[dict] = []
|
|
|
|
|
present = {s.strip().lower() for s in names}
|
|
|
|
|
suggested_names: set[str] = set()
|
|
|
|
|
if combos_model is not None:
|
|
|
|
|
# Prefer policy: suggest adding a missing partner to hit target count
|
|
|
|
|
if policy == "prefer":
|
|
|
|
|
try:
|
|
|
|
|
for p in combos_model.pairs:
|
|
|
|
|
a = str(p.a).strip()
|
|
|
|
|
b = str(p.b).strip()
|
|
|
|
|
a_in = a.lower() in present
|
|
|
|
|
b_in = b.lower() in present
|
|
|
|
|
if a_in ^ b_in: # exactly one present
|
|
|
|
|
missing = b if a_in else a
|
|
|
|
|
have = a if a_in else b
|
|
|
|
|
item = {
|
|
|
|
|
"kind": "add",
|
|
|
|
|
"have": have,
|
|
|
|
|
"name": missing,
|
|
|
|
|
"cheap_early": bool(getattr(p, "cheap_early", False)),
|
|
|
|
|
"setup_dependent": bool(getattr(p, "setup_dependent", False)),
|
|
|
|
|
}
|
|
|
|
|
key = str(missing).strip().lower()
|
|
|
|
|
if key not in present and key not in suggested_names:
|
|
|
|
|
suggestions.append(item)
|
|
|
|
|
suggested_names.add(key)
|
|
|
|
|
# Rank: cheap/early first, then setup-dependent, then name
|
|
|
|
|
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
|
|
|
|
|
# If we still have room below target, add synergy-based suggestions
|
|
|
|
|
rem = (max(0, int(target)) if target > 0 else 8) - len(suggestions)
|
|
|
|
|
if rem > 0 and synergies_model is not None:
|
|
|
|
|
# lightweight tag weights to bias common engines
|
|
|
|
|
weights = {
|
|
|
|
|
"treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3,
|
|
|
|
|
"engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9,
|
|
|
|
|
"counters": 1.8, "equipment": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
|
|
|
|
|
"damage": 1.3, "stax": 1.2
|
|
|
|
|
}
|
|
|
|
|
syn_sugs: list[dict] = []
|
|
|
|
|
for p in synergies_model.pairs:
|
|
|
|
|
a = str(p.a).strip()
|
|
|
|
|
b = str(p.b).strip()
|
|
|
|
|
a_in = a.lower() in present
|
|
|
|
|
b_in = b.lower() in present
|
|
|
|
|
if a_in ^ b_in:
|
|
|
|
|
missing = b if a_in else a
|
|
|
|
|
have = a if a_in else b
|
|
|
|
|
mkey = missing.strip().lower()
|
|
|
|
|
if mkey in present or mkey in suggested_names:
|
|
|
|
|
continue
|
|
|
|
|
tags = list(getattr(p, "tags", []) or [])
|
|
|
|
|
score = 1.0 + sum(weights.get(str(t).lower(), 1.0) for t in tags) / max(1, len(tags) or 1)
|
|
|
|
|
syn_sugs.append({
|
|
|
|
|
"kind": "add",
|
|
|
|
|
"have": have,
|
|
|
|
|
"name": missing,
|
|
|
|
|
"cheap_early": False,
|
|
|
|
|
"setup_dependent": False,
|
|
|
|
|
"tags": tags,
|
|
|
|
|
"_score": score,
|
|
|
|
|
})
|
|
|
|
|
suggested_names.add(mkey)
|
|
|
|
|
# rank by score desc then name
|
|
|
|
|
syn_sugs.sort(key=lambda s: (-float(s.get("_score", 0.0)), str(s.get("name")).lower()))
|
|
|
|
|
if rem > 0:
|
|
|
|
|
suggestions.extend(syn_sugs[:rem])
|
|
|
|
|
# Finally trim to target or default cap
|
|
|
|
|
cap = (int(target) if target > 0 else 8)
|
|
|
|
|
suggestions = suggestions[:cap]
|
|
|
|
|
except Exception:
|
|
|
|
|
suggestions = []
|
|
|
|
|
elif policy == "avoid":
|
|
|
|
|
# Avoid policy: suggest cutting one piece from detected combos
|
|
|
|
|
try:
|
|
|
|
|
for c in combos:
|
|
|
|
|
# pick the second card as default cut to vary suggestions
|
|
|
|
|
suggestions.append({
|
|
|
|
|
"kind": "cut",
|
|
|
|
|
"name": c.b,
|
|
|
|
|
"partner": c.a,
|
|
|
|
|
"cheap_early": bool(getattr(c, "cheap_early", False)),
|
|
|
|
|
"setup_dependent": bool(getattr(c, "setup_dependent", False)),
|
|
|
|
|
})
|
|
|
|
|
# Rank: cheap/early first
|
|
|
|
|
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
|
|
|
|
|
if target > 0:
|
|
|
|
|
suggestions = suggestions[: target]
|
|
|
|
|
else:
|
|
|
|
|
suggestions = suggestions[: 8]
|
|
|
|
|
except Exception:
|
|
|
|
|
suggestions = []
|
|
|
|
|
|
|
|
|
|
ctx = {
|
|
|
|
|
"request": request,
|
|
|
|
|
"policy": policy,
|
|
|
|
|
"target": target,
|
|
|
|
|
"combos": combos,
|
|
|
|
|
"synergies": synergies,
|
2025-09-02 11:39:14 -07:00
|
|
|
|
"versions": _det.get("versions", {}),
|
2025-09-01 16:55:24 -07:00
|
|
|
|
"suggestions": suggestions,
|
|
|
|
|
}
|
|
|
|
|
return templates.TemplateResponse("build/_combos_panel.html", ctx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/combos/prefs", response_class=HTMLResponse)
|
|
|
|
|
async def build_combos_save_prefs(request: Request, policy: str = Form("neutral"), target: int = Form(0)) -> HTMLResponse:
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
pol = (policy or "neutral").strip().lower()
|
|
|
|
|
if pol not in {"avoid", "neutral", "prefer"}:
|
|
|
|
|
pol = "neutral"
|
|
|
|
|
try:
|
|
|
|
|
tgt = int(target)
|
|
|
|
|
except Exception:
|
|
|
|
|
tgt = 0
|
|
|
|
|
if tgt < 0:
|
|
|
|
|
tgt = 0
|
|
|
|
|
sess["combos_policy"] = pol
|
|
|
|
|
sess["combos_target"] = tgt
|
|
|
|
|
# Re-render the panel
|
|
|
|
|
return await build_combos_panel(request)
|
|
|
|
|
|
|
|
|
|
|
2025-08-26 16:25:34 -07:00
|
|
|
|
@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)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 4
|
2025-08-26 16:25:34 -07:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
@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)
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 5
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Default replace-mode to ON unless explicitly toggled off
|
|
|
|
|
if "replace_mode" not in sess:
|
|
|
|
|
sess["replace_mode"] = True
|
2025-09-02 11:39:14 -07:00
|
|
|
|
base = step5_empty_ctx(request, sess)
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", base)
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
if "replace_mode" not in sess:
|
|
|
|
|
sess["replace_mode"] = True
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
# 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"):
|
2025-09-02 11:39:14 -07:00
|
|
|
|
sess["build_ctx"] = start_ctx_from_session(sess)
|
2025-08-29 09:19:03 -07:00
|
|
|
|
else:
|
|
|
|
|
# If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet
|
|
|
|
|
try:
|
|
|
|
|
mc = sess.get("multi_copy") or None
|
|
|
|
|
selkey = None
|
|
|
|
|
if mc:
|
|
|
|
|
selkey = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
|
|
|
|
applied = sess.get("mc_applied_key") if mc else None
|
|
|
|
|
if mc and (not applied or applied != selkey):
|
|
|
|
|
_rebuild_ctx_with_multicopy(sess)
|
|
|
|
|
# If we still have no stages (e.g., minimal test context), inject a minimal multi-copy stage inline
|
|
|
|
|
try:
|
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
stages = ctx.get("stages") if isinstance(ctx, dict) else None
|
|
|
|
|
if (not stages or len(stages) == 0) and mc:
|
|
|
|
|
b = ctx.get("builder") if isinstance(ctx, dict) else None
|
|
|
|
|
if b is not None:
|
|
|
|
|
try:
|
|
|
|
|
setattr(b, "_web_multi_copy", mc)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
if not isinstance(getattr(b, "card_library", None), dict):
|
|
|
|
|
b.card_library = {}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
if not isinstance(getattr(b, "ideal_counts", None), dict):
|
|
|
|
|
b.ideal_counts = {}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
ctx["stages"] = [{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}]
|
|
|
|
|
ctx["idx"] = 0
|
|
|
|
|
ctx["last_visible_idx"] = 0
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-08-26 20:00:07 -07:00
|
|
|
|
# Read show_skipped from either query or form safely
|
|
|
|
|
show_skipped = True if (request.query_params.get('show_skipped') == '1') else False
|
2025-08-26 16:25:34 -07:00
|
|
|
|
try:
|
|
|
|
|
form = await request.form()
|
2025-08-26 20:00:07 -07:00
|
|
|
|
if form and form.get('show_skipped') == '1':
|
|
|
|
|
show_skipped = True
|
2025-08-26 16:25:34 -07:00
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-02 11:39:14 -07:00
|
|
|
|
try:
|
|
|
|
|
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
|
|
|
|
|
status = "Build complete" if res.get("done") else "Stage complete"
|
|
|
|
|
except Exception as e:
|
|
|
|
|
sess["last_step"] = 5
|
|
|
|
|
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
stage_label = res.get("label")
|
2025-08-29 09:19:03 -07:00
|
|
|
|
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
|
|
|
|
|
try:
|
|
|
|
|
if stage_label == "Multi-Copy Package" and sess.get("multi_copy"):
|
|
|
|
|
mc = sess.get("multi_copy")
|
|
|
|
|
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Note: no redirect; the inline compliance panel will render inside Step 5
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 5
|
2025-09-02 11:39:14 -07:00
|
|
|
|
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", ctx2)
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
if "replace_mode" not in sess:
|
|
|
|
|
sess["replace_mode"] = True
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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"):
|
2025-09-02 11:39:14 -07:00
|
|
|
|
sess["build_ctx"] = start_ctx_from_session(sess)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
else:
|
|
|
|
|
# Ensure latest locks are reflected in the existing context
|
|
|
|
|
try:
|
|
|
|
|
sess["build_ctx"]["locks"] = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-08-26 16:25:34 -07:00
|
|
|
|
show_skipped = False
|
|
|
|
|
try:
|
|
|
|
|
form = await request.form()
|
|
|
|
|
show_skipped = True if (form.get('show_skipped') == '1') else False
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# If replace-mode is OFF, keep the stage visible even if no new cards were added
|
|
|
|
|
if not bool(sess.get("replace_mode", True)):
|
|
|
|
|
show_skipped = True
|
2025-09-02 11:39:14 -07:00
|
|
|
|
try:
|
|
|
|
|
res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True)))
|
|
|
|
|
status = "Stage rerun complete" if not res.get("done") else "Build complete"
|
|
|
|
|
except Exception as e:
|
|
|
|
|
sess["last_step"] = 5
|
|
|
|
|
err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 5
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Build locked cards list with ownership and in-deck presence
|
|
|
|
|
locked_cards = []
|
|
|
|
|
try:
|
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
b = ctx.get("builder") if isinstance(ctx, dict) else None
|
2025-09-02 11:39:14 -07:00
|
|
|
|
present: set[str] = builder_present_names(b) if b is not None else set()
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# Display-map via combined df when available
|
2025-09-02 11:39:14 -07:00
|
|
|
|
lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
|
|
|
|
|
display_map: dict[str, str] = builder_display_map(b, lock_lower) if b is not None else {}
|
|
|
|
|
owned_lower = owned_set_helper()
|
2025-08-28 14:57:22 -07:00
|
|
|
|
for nm in (sess.get("locks", []) or []):
|
|
|
|
|
key = str(nm).strip().lower()
|
|
|
|
|
disp = display_map.get(key, nm)
|
|
|
|
|
locked_cards.append({
|
|
|
|
|
"name": disp,
|
|
|
|
|
"owned": key in owned_lower,
|
|
|
|
|
"in_deck": key in present,
|
|
|
|
|
})
|
|
|
|
|
except Exception:
|
|
|
|
|
locked_cards = []
|
2025-09-02 11:39:14 -07:00
|
|
|
|
ctx3 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
|
|
|
|
ctx3["locked_cards"] = locked_cards
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", ctx3)
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
if "replace_mode" not in sess:
|
|
|
|
|
sess["replace_mode"] = True
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
# 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
|
2025-09-02 11:39:14 -07:00
|
|
|
|
sess["build_ctx"] = start_ctx_from_session(sess)
|
2025-08-26 16:25:34 -07:00
|
|
|
|
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)
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
status = "Stage complete" if not res.get("done") else "Build complete"
|
2025-08-29 09:19:03 -07:00
|
|
|
|
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
|
|
|
|
|
try:
|
2025-09-02 11:39:14 -07:00
|
|
|
|
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
|
2025-08-29 09:19:03 -07:00
|
|
|
|
mc = sess.get("multi_copy")
|
|
|
|
|
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Note: no redirect; the inline compliance panel will render inside Step 5
|
2025-08-26 20:00:07 -07:00
|
|
|
|
sess["last_step"] = 5
|
2025-09-02 11:39:14 -07:00
|
|
|
|
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
except Exception as e:
|
2025-09-02 11:39:14 -07:00
|
|
|
|
# Surface a friendly error on the step 5 screen with normalized context
|
|
|
|
|
err_ctx = step5_error_ctx(
|
|
|
|
|
request,
|
|
|
|
|
sess,
|
|
|
|
|
f"Failed to start build: {e}",
|
|
|
|
|
include_name=False,
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
)
|
2025-09-02 11:39:14 -07:00
|
|
|
|
# Ensure commander stays visible if set
|
|
|
|
|
err_ctx["commander"] = commander
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
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",
|
2025-08-28 14:57:22 -07:00
|
|
|
|
{"request": request, "commander": commander, "tags": tags, "name": sess.get("custom_export_base")},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/step5/toggle-replace")
|
|
|
|
|
async def build_step5_toggle_replace(request: Request, replace: str = Form("0")):
|
|
|
|
|
"""Toggle replace-mode for reruns and return an updated button HTML."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
enabled = True if str(replace).strip() in ("1","true","on","yes") else False
|
|
|
|
|
sess["replace_mode"] = enabled
|
|
|
|
|
# Return the checkbox control snippet (same as template)
|
|
|
|
|
checked = 'checked' if enabled else ''
|
|
|
|
|
html = (
|
|
|
|
|
'<div class="replace-toggle" role="group" aria-label="Replace toggle">'
|
|
|
|
|
'<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">'
|
|
|
|
|
f'<input type="hidden" name="replace" value="{"1" if enabled else "0"}" />'
|
|
|
|
|
'<label class="muted" style="display:flex; align-items:center; gap:.35rem;">'
|
|
|
|
|
f'<input type="checkbox" name="replace_chk" value="1" {checked} '
|
|
|
|
|
'onchange="try{ const f=this.form; const h=f.querySelector(\'input[name=replace]\'); if(h){ h.value=this.checked?\'1\':\'0\'; } f.requestSubmit(); }catch(_){ }" />'
|
|
|
|
|
'Replace stage picks'
|
|
|
|
|
'</label>'
|
|
|
|
|
'</form>'
|
|
|
|
|
'</div>'
|
|
|
|
|
)
|
|
|
|
|
return HTMLResponse(html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/step5/reset-stage", response_class=HTMLResponse)
|
|
|
|
|
async def build_step5_reset_stage(request: Request) -> HTMLResponse:
|
|
|
|
|
"""Reset current visible stage to the pre-stage snapshot (if available) without running it."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
ctx = sess.get("build_ctx")
|
|
|
|
|
if not ctx or not ctx.get("snapshot"):
|
|
|
|
|
return await build_step5_get(request)
|
|
|
|
|
try:
|
|
|
|
|
orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
return await build_step5_get(request)
|
|
|
|
|
# Re-render step 5 with cleared added list
|
2025-09-02 11:39:14 -07:00
|
|
|
|
base = step5_empty_ctx(request, sess, extras={
|
|
|
|
|
"status": "Stage reset",
|
|
|
|
|
"i": ctx.get("idx"),
|
|
|
|
|
"n": len(ctx.get("stages", [])),
|
|
|
|
|
})
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", base)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
|
|
|
|
|
|
|
|
|
|
@router.post("/lock")
|
|
|
|
|
async def build_lock_toggle(request: Request, name: str = Form(...), locked: str = Form("1"), from_list: str | None = Form(None)):
|
|
|
|
|
"""Toggle lock for a card name in the current session; return an HTML button to swap in-place."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
locks = set(sess.get("locks", []))
|
|
|
|
|
key = str(name).strip().lower()
|
|
|
|
|
want_lock = True if str(locked).strip() in ("1","true","on","yes") else False
|
|
|
|
|
if want_lock:
|
|
|
|
|
locks.add(key)
|
|
|
|
|
else:
|
|
|
|
|
locks.discard(key)
|
|
|
|
|
sess["locks"] = list(locks)
|
|
|
|
|
# If a build context exists, update it too
|
|
|
|
|
if sess.get("build_ctx"):
|
|
|
|
|
try:
|
|
|
|
|
sess["build_ctx"]["locks"] = {str(n) for n in locks}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Return a compact button HTML that flips state on next click, and an OOB last-action chip
|
|
|
|
|
next_state = "0" if want_lock else "1"
|
|
|
|
|
label = "Unlock" if want_lock else "Lock"
|
|
|
|
|
title = ("Click to unlock" if want_lock else "Click to lock")
|
|
|
|
|
icon = ("🔒" if want_lock else "🔓")
|
|
|
|
|
# Include data-locked to reflect the current state for client-side handler
|
|
|
|
|
btn = f'''<button type="button" class="btn-lock" title="{title}" data-locked="{'1' if want_lock else '0'}"
|
|
|
|
|
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
|
|
|
|
hx-vals='{{"name": "{name}", "locked": "{next_state}"}}'>{icon} {label}</button>'''
|
|
|
|
|
# Compute locks count for chip
|
|
|
|
|
locks_count = len(locks)
|
|
|
|
|
if locks_count > 0:
|
|
|
|
|
chip_html = f'<span id="locks-chip" hx-swap-oob="true"><span class="chip" title="Locked cards">🔒 {locks_count} locked</span></span>'
|
|
|
|
|
else:
|
|
|
|
|
chip_html = '<span id="locks-chip" hx-swap-oob="true"></span>'
|
|
|
|
|
# Last action chip for feedback (use hx-swap-oob)
|
|
|
|
|
try:
|
|
|
|
|
disp = (name or '').strip()
|
|
|
|
|
except Exception:
|
|
|
|
|
disp = str(name)
|
|
|
|
|
action = "Locked" if want_lock else "Unlocked"
|
|
|
|
|
chip = (
|
|
|
|
|
f'<div id="last-action" hx-swap-oob="true">'
|
|
|
|
|
f'<span class="chip" title="Click to dismiss">{action} <strong>{disp}</strong></span>'
|
|
|
|
|
f'</div>'
|
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
2025-08-26 09:48:25 -07:00
|
|
|
|
)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
# If this request came from the locked-cards list and it's an unlock, remove the row inline
|
|
|
|
|
try:
|
|
|
|
|
if (from_list is not None) and (not want_lock):
|
|
|
|
|
# Also update the locks-count chip, and if no locks remain, remove the whole section
|
|
|
|
|
extra = chip_html
|
|
|
|
|
if locks_count == 0:
|
|
|
|
|
extra += '<details id="locked-section" hx-swap-oob="true"></details>'
|
|
|
|
|
# Return empty body to delete the <li> via hx-swap=outerHTML, plus OOB updates
|
|
|
|
|
return HTMLResponse('' + extra)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return HTMLResponse(btn + chip + chip_html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/alternatives", response_class=HTMLResponse)
|
|
|
|
|
async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> HTMLResponse:
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"""Suggest alternative cards for a given card name, preferring role-specific pools.
|
|
|
|
|
|
|
|
|
|
Strategy:
|
|
|
|
|
1) Determine the seed card's role from the current deck (Role field) or optional `stage` hint.
|
|
|
|
|
2) Build a candidate pool from the combined DataFrame using the same filters as the build phase
|
|
|
|
|
for that role (ramp/removal/wipes/card_advantage/protection).
|
|
|
|
|
3) Exclude commander, lands (where applicable), in-deck, locked, and the seed itself; then sort
|
|
|
|
|
by edhrecRank/manaValue. Apply owned-only filter if requested.
|
|
|
|
|
4) Fall back to tag-overlap similarity when role cannot be determined or data is missing.
|
2025-08-28 14:57:22 -07:00
|
|
|
|
|
2025-09-03 18:00:06 -07:00
|
|
|
|
Returns an HTML partial listing up to ~10 alternatives with Replace buttons.
|
2025-08-28 14:57:22 -07:00
|
|
|
|
"""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
b = ctx.get("builder") if isinstance(ctx, dict) else None
|
|
|
|
|
# Owned library
|
2025-09-02 11:39:14 -07:00
|
|
|
|
owned_set = owned_set_helper()
|
2025-08-28 14:57:22 -07:00
|
|
|
|
require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only"))
|
|
|
|
|
# If builder context missing, show a guidance message
|
|
|
|
|
if not b:
|
2025-09-02 11:39:14 -07:00
|
|
|
|
html = '<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
|
2025-08-28 14:57:22 -07:00
|
|
|
|
return HTMLResponse(html)
|
|
|
|
|
try:
|
2025-09-03 18:00:06 -07:00
|
|
|
|
name_disp = str(name).strip()
|
|
|
|
|
name_l = name_disp.lower()
|
2025-08-28 14:57:22 -07:00
|
|
|
|
commander_l = str((sess.get("commander") or "")).strip().lower()
|
|
|
|
|
locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Exclusions from prior inline replacements
|
|
|
|
|
alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
|
|
|
|
|
alts_exclude_v = int(sess.get("alts_exclude_v") or 0)
|
|
|
|
|
|
|
|
|
|
# Resolve role from stage hint or current library entry
|
|
|
|
|
stage_hint = (stage or "").strip().lower()
|
|
|
|
|
stage_map = {
|
|
|
|
|
"ramp": "ramp",
|
|
|
|
|
"removal": "removal",
|
|
|
|
|
"wipes": "wipe",
|
|
|
|
|
"wipe": "wipe",
|
|
|
|
|
"board_wipe": "wipe",
|
|
|
|
|
"card_advantage": "card_advantage",
|
|
|
|
|
"draw": "card_advantage",
|
|
|
|
|
"protection": "protection",
|
|
|
|
|
# Additional mappings for creature stages
|
|
|
|
|
"creature": "creature",
|
|
|
|
|
"creatures": "creature",
|
|
|
|
|
"primary": "creature",
|
|
|
|
|
"secondary": "creature",
|
|
|
|
|
}
|
|
|
|
|
hinted_role = stage_map.get(stage_hint) if stage_hint else None
|
|
|
|
|
lib = getattr(b, "card_library", {}) or {}
|
|
|
|
|
# Case-insensitive lookup in deck library
|
|
|
|
|
lib_key = None
|
|
|
|
|
try:
|
|
|
|
|
if name_disp in lib:
|
|
|
|
|
lib_key = name_disp
|
|
|
|
|
else:
|
|
|
|
|
lm = {str(k).strip().lower(): k for k in lib.keys()}
|
|
|
|
|
lib_key = lm.get(name_l)
|
|
|
|
|
except Exception:
|
|
|
|
|
lib_key = None
|
|
|
|
|
entry = lib.get(lib_key) if lib_key else None
|
|
|
|
|
role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None)
|
|
|
|
|
if isinstance(role, str):
|
|
|
|
|
role = role.strip().lower()
|
|
|
|
|
|
|
|
|
|
# Build role-specific pool from combined DataFrame
|
|
|
|
|
items: list[dict] = []
|
|
|
|
|
used_role = role if isinstance(role, str) and role else None
|
|
|
|
|
df = getattr(b, "_combined_cards_df", None)
|
|
|
|
|
|
|
|
|
|
# Compute current deck fingerprint to avoid stale cached alternatives after stage changes
|
|
|
|
|
in_deck: set[str] = builder_present_names(b)
|
|
|
|
|
try:
|
|
|
|
|
import hashlib as _hl
|
|
|
|
|
deck_fp = _hl.md5(
|
|
|
|
|
("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8")
|
|
|
|
|
).hexdigest()[:8]
|
|
|
|
|
except Exception:
|
|
|
|
|
deck_fp = str(len(in_deck))
|
|
|
|
|
|
|
|
|
|
# Use a cache key that includes the exclusions version and deck fingerprint
|
|
|
|
|
cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
cached = _alts_get_cached(cache_key)
|
|
|
|
|
if cached is not None:
|
|
|
|
|
return HTMLResponse(cached)
|
2025-09-03 18:00:06 -07:00
|
|
|
|
|
|
|
|
|
def _render_and_cache(_items: list[dict]):
|
|
|
|
|
html_str = templates.get_template("build/_alternatives.html").render({
|
|
|
|
|
"request": request,
|
|
|
|
|
"name": name_disp,
|
|
|
|
|
"require_owned": require_owned,
|
|
|
|
|
"items": _items,
|
|
|
|
|
})
|
|
|
|
|
try:
|
|
|
|
|
_alts_set_cached(cache_key, html_str)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return HTMLResponse(html_str)
|
|
|
|
|
|
|
|
|
|
# Helper: map display names
|
|
|
|
|
def _display_map_for(lower_pool: set[str]) -> dict[str, str]:
|
|
|
|
|
try:
|
|
|
|
|
return builder_display_map(b, lower_pool) # type: ignore[arg-type]
|
|
|
|
|
except Exception:
|
|
|
|
|
return {nm: nm for nm in lower_pool}
|
|
|
|
|
|
|
|
|
|
# Common exclusions
|
|
|
|
|
# in_deck already computed above
|
|
|
|
|
|
|
|
|
|
def _exclude(df0):
|
|
|
|
|
out = df0.copy()
|
|
|
|
|
if "name" in out.columns:
|
|
|
|
|
out["_lname"] = out["name"].astype(str).str.strip().str.lower()
|
|
|
|
|
mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set()))
|
|
|
|
|
out = out[mask]
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
# If we have data and a recognized role, mirror the phase logic
|
|
|
|
|
if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature"}):
|
|
|
|
|
pool = df.copy()
|
|
|
|
|
try:
|
|
|
|
|
pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell)
|
|
|
|
|
except Exception:
|
|
|
|
|
# best-effort normalize
|
|
|
|
|
pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else [])
|
|
|
|
|
# Exclude lands for all these roles
|
|
|
|
|
if "type" in pool.columns:
|
|
|
|
|
pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)]
|
|
|
|
|
# Exclude commander explicitly
|
|
|
|
|
if "name" in pool.columns and commander_l:
|
|
|
|
|
pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l]
|
|
|
|
|
# Role-specific filter
|
|
|
|
|
def _is_wipe(tags: list[str]) -> bool:
|
|
|
|
|
return any(("board wipe" in t) or ("mass removal" in t) for t in tags)
|
|
|
|
|
def _is_removal(tags: list[str]) -> bool:
|
|
|
|
|
return any(("removal" in t) or ("spot removal" in t) for t in tags)
|
|
|
|
|
def _is_draw(tags: list[str]) -> bool:
|
|
|
|
|
return any(("draw" in t) or ("card advantage" in t) for t in tags)
|
|
|
|
|
def _matches_selected(tags: list[str]) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()]
|
|
|
|
|
if not sel:
|
|
|
|
|
return True
|
|
|
|
|
st = set(sel)
|
|
|
|
|
return any(any(s in t for s in st) for t in tags)
|
|
|
|
|
except Exception:
|
|
|
|
|
return True
|
|
|
|
|
if used_role == "ramp":
|
|
|
|
|
pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))]
|
|
|
|
|
elif used_role == "removal":
|
|
|
|
|
pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)]
|
|
|
|
|
elif used_role == "wipe":
|
|
|
|
|
pool = pool[pool["_ltags"].apply(_is_wipe)]
|
|
|
|
|
elif used_role == "card_advantage":
|
|
|
|
|
pool = pool[pool["_ltags"].apply(_is_draw)]
|
|
|
|
|
elif used_role == "protection":
|
|
|
|
|
pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))]
|
|
|
|
|
elif used_role == "creature":
|
|
|
|
|
# Keep only creatures; bias toward selected theme tags when available
|
|
|
|
|
if "type" in pool.columns:
|
|
|
|
|
pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)]
|
|
|
|
|
try:
|
|
|
|
|
pool = pool[pool["_ltags"].apply(_matches_selected)]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Sort by priority like the builder
|
|
|
|
|
try:
|
|
|
|
|
pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Exclusions and ownership
|
|
|
|
|
pool = _exclude(pool)
|
|
|
|
|
# Prefer-owned bias: stable reorder to put owned first if user prefers owned
|
|
|
|
|
try:
|
|
|
|
|
if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None):
|
|
|
|
|
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Build final items
|
|
|
|
|
lower_pool: list[str] = []
|
|
|
|
|
try:
|
|
|
|
|
lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist()
|
|
|
|
|
except Exception:
|
|
|
|
|
lower_pool = []
|
|
|
|
|
display_map = _display_map_for(set(lower_pool))
|
|
|
|
|
for nm_l in lower_pool:
|
|
|
|
|
is_owned = (nm_l in owned_set)
|
|
|
|
|
if require_owned and not is_owned:
|
|
|
|
|
continue
|
|
|
|
|
# Extra safety: exclude the seed card or anything already in deck
|
|
|
|
|
if nm_l == name_l or (in_deck and nm_l in in_deck):
|
|
|
|
|
continue
|
|
|
|
|
items.append({
|
|
|
|
|
"name": display_map.get(nm_l, nm_l),
|
|
|
|
|
"name_lower": nm_l,
|
|
|
|
|
"owned": is_owned,
|
|
|
|
|
"tags": [], # can be filled from index below if needed
|
|
|
|
|
})
|
|
|
|
|
if len(items) >= 10:
|
|
|
|
|
break
|
|
|
|
|
# If we collected role-aware items, render
|
|
|
|
|
if items:
|
|
|
|
|
return _render_and_cache(items)
|
|
|
|
|
|
|
|
|
|
# Fallback: tag-similarity suggestions (previous behavior)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
tags_idx = getattr(b, "_card_name_tags_index", {}) or {}
|
|
|
|
|
seed_tags = set(tags_idx.get(name_l) or [])
|
|
|
|
|
all_names = set(tags_idx.keys())
|
2025-09-02 11:39:14 -07:00
|
|
|
|
candidates: list[tuple[str, int]] = [] # (name, score)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
for nm in all_names:
|
|
|
|
|
if nm == name_l:
|
|
|
|
|
continue
|
|
|
|
|
if commander_l and nm == commander_l:
|
|
|
|
|
continue
|
|
|
|
|
if in_deck and nm in in_deck:
|
|
|
|
|
continue
|
|
|
|
|
if locked_set and nm in locked_set:
|
|
|
|
|
continue
|
2025-09-03 18:00:06 -07:00
|
|
|
|
if nm in alts_exclude:
|
|
|
|
|
continue
|
2025-08-28 14:57:22 -07:00
|
|
|
|
tgs = set(tags_idx.get(nm) or [])
|
|
|
|
|
score = len(seed_tags & tgs)
|
|
|
|
|
if score <= 0:
|
|
|
|
|
continue
|
|
|
|
|
candidates.append((nm, score))
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# If no tag-based candidates, try shared trigger tag from library entry
|
|
|
|
|
if not candidates and isinstance(entry, dict):
|
2025-08-28 14:57:22 -07:00
|
|
|
|
try:
|
2025-09-03 18:00:06 -07:00
|
|
|
|
trig = str(entry.get("TriggerTag") or "").strip().lower()
|
2025-08-28 14:57:22 -07:00
|
|
|
|
except Exception:
|
|
|
|
|
trig = ""
|
|
|
|
|
if trig:
|
|
|
|
|
for nm, tglist in tags_idx.items():
|
|
|
|
|
if nm == name_l:
|
|
|
|
|
continue
|
|
|
|
|
if nm in {str(k).strip().lower() for k in lib.keys()}:
|
|
|
|
|
continue
|
|
|
|
|
if trig in {str(t).strip().lower() for t in (tglist or [])}:
|
|
|
|
|
candidates.append((nm, 1))
|
|
|
|
|
def _owned(nm: str) -> bool:
|
|
|
|
|
return nm in owned_set
|
|
|
|
|
candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0]))
|
2025-09-02 11:39:14 -07:00
|
|
|
|
pool_lower = {nm for (nm, _s) in candidates}
|
2025-09-03 18:00:06 -07:00
|
|
|
|
display_map = _display_map_for(pool_lower)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
seen = set()
|
|
|
|
|
for nm, score in candidates:
|
|
|
|
|
if nm in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(nm)
|
|
|
|
|
is_owned = (nm in owned_set)
|
|
|
|
|
if require_owned and not is_owned:
|
|
|
|
|
continue
|
2025-09-02 11:39:14 -07:00
|
|
|
|
items.append({
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"name": display_map.get(nm, nm),
|
2025-09-02 11:39:14 -07:00
|
|
|
|
"name_lower": nm,
|
|
|
|
|
"owned": is_owned,
|
|
|
|
|
"tags": list(tags_idx.get(nm) or []),
|
|
|
|
|
})
|
|
|
|
|
if len(items) >= 10:
|
2025-08-28 14:57:22 -07:00
|
|
|
|
break
|
2025-09-03 18:00:06 -07:00
|
|
|
|
return _render_and_cache(items)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
except Exception as e:
|
|
|
|
|
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/replace", response_class=HTMLResponse)
|
|
|
|
|
async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> HTMLResponse:
|
2025-09-03 18:00:06 -07:00
|
|
|
|
"""Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives.
|
2025-08-28 14:57:22 -07:00
|
|
|
|
|
2025-09-03 18:00:06 -07:00
|
|
|
|
Falls back to lock-and-rerun guidance if no active builder is present.
|
2025-08-28 14:57:22 -07:00
|
|
|
|
"""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
2025-09-03 18:00:06 -07:00
|
|
|
|
o_disp = str(old).strip()
|
|
|
|
|
n_disp = str(new).strip()
|
|
|
|
|
o = o_disp.lower()
|
|
|
|
|
n = n_disp.lower()
|
|
|
|
|
|
|
|
|
|
# Maintain locks to bias future picks and enforcement
|
2025-08-28 14:57:22 -07:00
|
|
|
|
locks = set(sess.get("locks", []))
|
|
|
|
|
locks.discard(o)
|
|
|
|
|
locks.add(n)
|
|
|
|
|
sess["locks"] = list(locks)
|
|
|
|
|
# Track last replace for optional undo
|
|
|
|
|
try:
|
|
|
|
|
sess["last_replace"] = {"old": o, "new": n}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-09-03 18:00:06 -07:00
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
try:
|
|
|
|
|
ctx["locks"] = {str(x) for x in locks}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Record preferred replacements
|
|
|
|
|
try:
|
|
|
|
|
pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None
|
|
|
|
|
if not isinstance(pref, dict):
|
|
|
|
|
pref = {}
|
|
|
|
|
ctx["preferred_replacements"] = pref
|
|
|
|
|
pref[o] = n
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
|
|
|
|
if b is not None:
|
2025-08-28 14:57:22 -07:00
|
|
|
|
try:
|
2025-09-03 18:00:06 -07:00
|
|
|
|
lib = getattr(b, "card_library", {}) or {}
|
|
|
|
|
# Find the exact key for `old` in a case-insensitive manner
|
|
|
|
|
old_key = None
|
|
|
|
|
if o_disp in lib:
|
|
|
|
|
old_key = o_disp
|
|
|
|
|
else:
|
|
|
|
|
for k in list(lib.keys()):
|
|
|
|
|
if str(k).strip().lower() == o:
|
|
|
|
|
old_key = k
|
|
|
|
|
break
|
|
|
|
|
if old_key is None:
|
|
|
|
|
raise KeyError("old card not in deck")
|
|
|
|
|
old_info = dict(lib.get(old_key) or {})
|
|
|
|
|
role = str(old_info.get("Role") or "").strip()
|
|
|
|
|
subrole = str(old_info.get("SubRole") or "").strip()
|
|
|
|
|
try:
|
|
|
|
|
count = int(old_info.get("Count", 1))
|
|
|
|
|
except Exception:
|
|
|
|
|
count = 1
|
|
|
|
|
# Remove old entry
|
|
|
|
|
try:
|
|
|
|
|
del lib[old_key]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Resolve canonical name and info for new
|
|
|
|
|
df = getattr(b, "_combined_cards_df", None)
|
|
|
|
|
new_key = n_disp
|
|
|
|
|
card_type = ""
|
|
|
|
|
mana_cost = ""
|
|
|
|
|
trigger_tag = str(old_info.get("TriggerTag") or "")
|
|
|
|
|
if df is not None:
|
|
|
|
|
try:
|
|
|
|
|
row = df[df["name"].astype(str).str.strip().str.lower() == n]
|
|
|
|
|
if not row.empty:
|
|
|
|
|
new_key = str(row.iloc[0]["name"]) or n_disp
|
|
|
|
|
card_type = str(row.iloc[0].get("type", row.iloc[0].get("type_line", "")) or "")
|
|
|
|
|
mana_cost = str(row.iloc[0].get("mana_cost", row.iloc[0].get("manaCost", "")) or "")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
lib[new_key] = {
|
|
|
|
|
"Count": count,
|
|
|
|
|
"Card Type": card_type,
|
|
|
|
|
"Mana Cost": mana_cost,
|
|
|
|
|
"Role": role,
|
|
|
|
|
"SubRole": subrole,
|
|
|
|
|
"AddedBy": "Replace",
|
|
|
|
|
"TriggerTag": trigger_tag,
|
|
|
|
|
}
|
|
|
|
|
# Mirror preferred replacements onto the builder for enforcement
|
|
|
|
|
try:
|
|
|
|
|
cur = getattr(b, "preferred_replacements", {}) or {}
|
|
|
|
|
cur[str(o)] = str(n)
|
|
|
|
|
setattr(b, "preferred_replacements", cur)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Update alternatives exclusion set and bump version to invalidate caches
|
|
|
|
|
try:
|
|
|
|
|
ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
|
|
|
|
|
ex.add(o)
|
|
|
|
|
sess["alts_exclude"] = list(ex)
|
|
|
|
|
sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Success panel and OOB updates (refresh compliance panel)
|
|
|
|
|
# Compute ownership of the new card for UI badge update
|
|
|
|
|
is_owned = (n in owned_set_helper())
|
|
|
|
|
html = (
|
|
|
|
|
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
|
|
|
|
|
f'<div>Replaced <strong>{o_disp}</strong> with <strong>{new_key}</strong>.</div>'
|
|
|
|
|
'<div class="muted" style="margin-top:.35rem;">Compliance panel will refresh.</div>'
|
|
|
|
|
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
|
|
|
|
|
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
|
|
|
|
|
'</div>'
|
|
|
|
|
'</div>'
|
|
|
|
|
)
|
|
|
|
|
# Inline mutate the nearest card tile to reflect the new card without a rerun
|
|
|
|
|
mutator = """
|
|
|
|
|
<script>
|
|
|
|
|
(function(){
|
|
|
|
|
try{
|
|
|
|
|
var panel = document.currentScript && document.currentScript.previousElementSibling && document.currentScript.previousElementSibling.classList && document.currentScript.previousElementSibling.classList.contains('alts') ? document.currentScript.previousElementSibling : null;
|
|
|
|
|
if(!panel){ return; }
|
|
|
|
|
var oldName = panel.getAttribute('data-old') || '';
|
|
|
|
|
var newName = panel.getAttribute('data-new') || '';
|
|
|
|
|
var isOwned = panel.getAttribute('data-owned') === '1';
|
|
|
|
|
var isLocked = panel.getAttribute('data-locked') === '1';
|
|
|
|
|
var tile = panel.closest('.card-tile');
|
|
|
|
|
if(!tile) return;
|
|
|
|
|
tile.setAttribute('data-card-name', newName);
|
|
|
|
|
var img = tile.querySelector('img.card-thumb');
|
|
|
|
|
if(img){
|
|
|
|
|
var base = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=';
|
|
|
|
|
img.src = base + 'normal';
|
|
|
|
|
img.setAttribute('srcset',
|
|
|
|
|
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=small 160w, ' +
|
|
|
|
|
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=normal 488w, ' +
|
|
|
|
|
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=large 672w'
|
|
|
|
|
);
|
|
|
|
|
img.setAttribute('alt', newName + ' image');
|
|
|
|
|
img.setAttribute('data-card-name', newName);
|
|
|
|
|
}
|
|
|
|
|
var nameEl = tile.querySelector('.name');
|
|
|
|
|
if(nameEl){ nameEl.textContent = newName; }
|
|
|
|
|
var own = tile.querySelector('.owned-badge');
|
|
|
|
|
if(own){
|
|
|
|
|
own.textContent = isOwned ? '✔' : '✖';
|
|
|
|
|
own.title = isOwned ? 'Owned' : 'Not owned';
|
|
|
|
|
tile.setAttribute('data-owned', isOwned ? '1' : '0');
|
|
|
|
|
}
|
|
|
|
|
tile.classList.toggle('locked', isLocked);
|
|
|
|
|
var imgBtn = tile.querySelector('.img-btn');
|
|
|
|
|
if(imgBtn){
|
|
|
|
|
try{
|
|
|
|
|
var valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
|
|
|
|
|
var obj = JSON.parse(valsAttr.replace(/"/g, '"'));
|
|
|
|
|
obj.name = newName;
|
|
|
|
|
imgBtn.setAttribute('hx-vals', JSON.stringify(obj));
|
|
|
|
|
}catch(e){}
|
|
|
|
|
}
|
|
|
|
|
var lockBtn = tile.querySelector('.lock-box .btn-lock');
|
|
|
|
|
if(lockBtn){
|
|
|
|
|
try{
|
|
|
|
|
var v = lockBtn.getAttribute('hx-vals') || '{}';
|
|
|
|
|
var o = JSON.parse(v.replace(/"/g, '"'));
|
|
|
|
|
o.name = newName;
|
|
|
|
|
lockBtn.setAttribute('hx-vals', JSON.stringify(o));
|
|
|
|
|
}catch(e){}
|
|
|
|
|
}
|
|
|
|
|
}catch(_){}
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
"""
|
|
|
|
|
chip = (
|
|
|
|
|
f'<div id="last-action" hx-swap-oob="true">'
|
|
|
|
|
f'<span class="chip" title="Click to dismiss">Replaced <strong>{o_disp}</strong> → <strong>{new_key}</strong></span>'
|
|
|
|
|
f'</div>'
|
|
|
|
|
)
|
|
|
|
|
# OOB fetch to refresh compliance panel
|
|
|
|
|
refresher = (
|
|
|
|
|
'<div hx-get="/build/compliance" hx-target="#compliance-panel" hx-swap="outerHTML" '
|
|
|
|
|
'hx-trigger="load" hx-swap-oob="true"></div>'
|
|
|
|
|
)
|
|
|
|
|
# Include data attributes on the panel div for the mutator script
|
|
|
|
|
data_owned = '1' if is_owned else '0'
|
|
|
|
|
data_locked = '1' if (n in locks) else '0'
|
|
|
|
|
prefix = '<div class="alts"'
|
|
|
|
|
replacement = (
|
|
|
|
|
'<div class="alts" '
|
|
|
|
|
+ 'data-old="' + _esc(o_disp) + '" '
|
|
|
|
|
+ 'data-new="' + _esc(new_key) + '" '
|
|
|
|
|
+ 'data-owned="' + data_owned + '" '
|
|
|
|
|
+ 'data-locked="' + data_locked + '"'
|
|
|
|
|
)
|
|
|
|
|
html = html.replace(prefix, replacement, 1)
|
|
|
|
|
return HTMLResponse(html + mutator + chip + refresher)
|
2025-08-28 14:57:22 -07:00
|
|
|
|
except Exception:
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Fall back to rerun guidance if inline swap fails
|
2025-08-28 14:57:22 -07:00
|
|
|
|
pass
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Fallback: advise rerun
|
2025-08-28 14:57:22 -07:00
|
|
|
|
hint = (
|
|
|
|
|
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
|
|
|
|
|
f'<div>Locked <strong>{new}</strong> and unlocked <strong>{old}</strong>.</div>'
|
2025-09-03 18:00:06 -07:00
|
|
|
|
'<div class="muted" style="margin-top:.35rem;">Now click <em>Rerun Stage</em> with Replace: On to apply this change.</div>'
|
2025-08-28 14:57:22 -07:00
|
|
|
|
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
|
|
|
|
|
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
|
|
|
|
|
'<input type="hidden" name="show_skipped" value="1" />'
|
|
|
|
|
'<button type="submit" class="btn-rerun">Rerun stage</button>'
|
|
|
|
|
'</form>'
|
2025-09-03 18:00:06 -07:00
|
|
|
|
'<form hx-post="/build/replace/undo" hx-target="closest .alts" hx-swap="outerHTML" style="display:inline; margin:0;">'
|
|
|
|
|
f'<input type="hidden" name="old" value="{old}" />'
|
|
|
|
|
f'<input type="hidden" name="new" value="{new}" />'
|
|
|
|
|
'<button type="submit" class="btn" title="Undo this replace">Undo</button>'
|
|
|
|
|
'</form>'
|
2025-08-28 14:57:22 -07:00
|
|
|
|
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
|
|
|
|
|
'</div>'
|
|
|
|
|
'</div>'
|
|
|
|
|
)
|
|
|
|
|
chip = (
|
|
|
|
|
f'<div id="last-action" hx-swap-oob="true">'
|
|
|
|
|
f'<span class="chip" title="Click to dismiss">Replaced <strong>{old}</strong> → <strong>{new}</strong></span>'
|
|
|
|
|
f'</div>'
|
|
|
|
|
)
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Also add old to exclusions and bump version for future alt calls
|
|
|
|
|
try:
|
|
|
|
|
ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
|
|
|
|
|
ex.add(o)
|
|
|
|
|
sess["alts_exclude"] = list(ex)
|
|
|
|
|
sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-08-28 14:57:22 -07:00
|
|
|
|
return HTMLResponse(hint + chip)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/replace/undo", response_class=HTMLResponse)
|
|
|
|
|
async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse:
|
|
|
|
|
"""Undo the last replace by restoring the previous lock state (best-effort)."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
last = sess.get("last_replace") or {}
|
|
|
|
|
try:
|
|
|
|
|
# Prefer provided args, else fallback to last recorded
|
|
|
|
|
o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip()
|
|
|
|
|
n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip()
|
|
|
|
|
except Exception:
|
|
|
|
|
o, n = "", ""
|
|
|
|
|
locks = set(sess.get("locks", []))
|
|
|
|
|
changed = False
|
|
|
|
|
if n and n in locks:
|
|
|
|
|
locks.discard(n)
|
|
|
|
|
changed = True
|
|
|
|
|
if o:
|
|
|
|
|
locks.add(o)
|
|
|
|
|
changed = True
|
|
|
|
|
sess["locks"] = list(locks)
|
|
|
|
|
if sess.get("build_ctx"):
|
|
|
|
|
try:
|
|
|
|
|
sess["build_ctx"]["locks"] = {str(x) for x in locks}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Clear last_replace after undo
|
|
|
|
|
try:
|
|
|
|
|
if sess.get("last_replace"):
|
|
|
|
|
del sess["last_replace"]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Return confirmation panel and OOB chip
|
|
|
|
|
msg = 'Undid replace' if changed else 'No changes to undo'
|
|
|
|
|
html = (
|
|
|
|
|
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
|
|
|
|
|
f'<div>{msg}.</div>'
|
|
|
|
|
'<div class="muted" style="margin-top:.35rem;">Rerun the stage to recompute picks if needed.</div>'
|
|
|
|
|
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
|
|
|
|
|
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
|
|
|
|
|
'<input type="hidden" name="show_skipped" value="1" />'
|
|
|
|
|
'<button type="submit" class="btn-rerun">Rerun stage</button>'
|
|
|
|
|
'</form>'
|
|
|
|
|
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
|
|
|
|
|
'</div>'
|
|
|
|
|
'</div>'
|
|
|
|
|
)
|
|
|
|
|
chip = (
|
|
|
|
|
f'<div id="last-action" hx-swap-oob="true">'
|
|
|
|
|
f'<span class="chip" title="Click to dismiss">{msg}</span>'
|
|
|
|
|
f'</div>'
|
|
|
|
|
)
|
|
|
|
|
return HTMLResponse(html + chip)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/compare")
|
|
|
|
|
async def build_compare(runA: str, runB: str):
|
|
|
|
|
"""Stub: return empty diffs; later we can diff summary files under deck_files."""
|
|
|
|
|
return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []})
|
|
|
|
|
|
|
|
|
|
|
2025-09-03 18:00:06 -07:00
|
|
|
|
@router.get("/compliance", response_class=HTMLResponse)
|
|
|
|
|
async def build_compliance_panel(request: Request) -> HTMLResponse:
|
|
|
|
|
"""Render a live Bracket compliance panel with manual enforcement controls.
|
|
|
|
|
|
|
|
|
|
Computes compliance against the current builder state without exporting, attaches a non-destructive
|
|
|
|
|
enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial.
|
|
|
|
|
Returns empty content when no active build context exists.
|
|
|
|
|
"""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
|
|
|
|
if not b:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
# Compute compliance snapshot in-memory and attach planning preview
|
|
|
|
|
comp = None
|
|
|
|
|
try:
|
|
|
|
|
if hasattr(b, 'compute_and_print_compliance'):
|
|
|
|
|
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
comp = None
|
|
|
|
|
try:
|
|
|
|
|
if comp:
|
|
|
|
|
from ..services import orchestrator as orch
|
|
|
|
|
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if not comp:
|
|
|
|
|
return HTMLResponse("")
|
|
|
|
|
# Build flagged metadata (role, owned) for visual tiles and role-aware alternatives
|
|
|
|
|
# For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced.
|
|
|
|
|
flagged_meta: list[dict] = []
|
|
|
|
|
try:
|
|
|
|
|
cats = comp.get('categories') or {}
|
|
|
|
|
owned_lower = owned_set_helper()
|
|
|
|
|
lib = getattr(b, 'card_library', {}) or {}
|
|
|
|
|
commander_l = str((sess.get('commander') or '')).strip().lower()
|
|
|
|
|
# map category key -> display label
|
|
|
|
|
labels = {
|
|
|
|
|
'game_changers': 'Game Changers',
|
|
|
|
|
'extra_turns': 'Extra Turns',
|
|
|
|
|
'mass_land_denial': 'Mass Land Denial',
|
|
|
|
|
'tutors_nonland': 'Nonland Tutors',
|
|
|
|
|
'two_card_combos': 'Two-Card Combos',
|
|
|
|
|
}
|
|
|
|
|
seen_lower: set[str] = set()
|
|
|
|
|
for key, cat in cats.items():
|
|
|
|
|
try:
|
2025-09-04 19:28:48 -07:00
|
|
|
|
status = str(cat.get('status') or '').upper()
|
|
|
|
|
# Only surface tiles for WARN and FAIL
|
|
|
|
|
if status not in {"WARN", "FAIL"}:
|
2025-09-03 18:00:06 -07:00
|
|
|
|
continue
|
|
|
|
|
# For two-card combos, split pairs into individual cards and skip commander
|
2025-09-04 19:28:48 -07:00
|
|
|
|
if key == 'two_card_combos' and status == 'FAIL':
|
2025-09-03 18:00:06 -07:00
|
|
|
|
# Prefer the structured combos list to ensure we only expand counted pairs
|
|
|
|
|
pairs = []
|
|
|
|
|
try:
|
|
|
|
|
for p in (comp.get('combos') or []):
|
|
|
|
|
if p.get('cheap_early'):
|
|
|
|
|
pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip()))
|
|
|
|
|
except Exception:
|
|
|
|
|
pairs = []
|
|
|
|
|
# Fallback to parsing flagged strings like "A + B"
|
|
|
|
|
if not pairs:
|
|
|
|
|
try:
|
|
|
|
|
for s in (cat.get('flagged') or []):
|
|
|
|
|
if not isinstance(s, str):
|
|
|
|
|
continue
|
|
|
|
|
parts = [x.strip() for x in s.split('+') if x and x.strip()]
|
|
|
|
|
if len(parts) == 2:
|
|
|
|
|
pairs.append((parts[0], parts[1]))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
for a, bname in pairs:
|
|
|
|
|
for nm in (a, bname):
|
|
|
|
|
if not nm:
|
|
|
|
|
continue
|
|
|
|
|
nm_l = nm.strip().lower()
|
|
|
|
|
if nm_l == commander_l:
|
|
|
|
|
# Don't prompt replacing the commander
|
|
|
|
|
continue
|
|
|
|
|
if nm_l in seen_lower:
|
|
|
|
|
continue
|
|
|
|
|
seen_lower.add(nm_l)
|
|
|
|
|
entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {}
|
|
|
|
|
role = entry.get('Role') or ''
|
|
|
|
|
flagged_meta.append({
|
|
|
|
|
'name': nm,
|
|
|
|
|
'category': labels.get(key, key.replace('_',' ').title()),
|
|
|
|
|
'role': role,
|
|
|
|
|
'owned': (nm_l in owned_lower),
|
2025-09-04 19:28:48 -07:00
|
|
|
|
'severity': status,
|
2025-09-03 18:00:06 -07:00
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
# Default handling for list/tag categories
|
|
|
|
|
names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)]
|
|
|
|
|
for nm in names:
|
|
|
|
|
nm_l = str(nm).strip().lower()
|
|
|
|
|
if nm_l in seen_lower:
|
|
|
|
|
continue
|
|
|
|
|
seen_lower.add(nm_l)
|
|
|
|
|
entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {}
|
|
|
|
|
role = entry.get('Role') or ''
|
|
|
|
|
flagged_meta.append({
|
|
|
|
|
'name': nm,
|
|
|
|
|
'category': labels.get(key, key.replace('_',' ').title()),
|
|
|
|
|
'role': role,
|
|
|
|
|
'owned': (nm_l in owned_lower),
|
2025-09-04 19:28:48 -07:00
|
|
|
|
'severity': status,
|
2025-09-03 18:00:06 -07:00
|
|
|
|
})
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
except Exception:
|
|
|
|
|
flagged_meta = []
|
|
|
|
|
# Render partial
|
|
|
|
|
ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta}
|
|
|
|
|
return templates.TemplateResponse("build/_compliance_panel.html", ctx2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/enforce/apply", response_class=HTMLResponse)
|
|
|
|
|
async def build_enforce_apply(request: Request) -> HTMLResponse:
|
|
|
|
|
"""Apply bracket enforcement now using current locks as user guidance.
|
|
|
|
|
|
|
|
|
|
This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5.
|
|
|
|
|
"""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
# Ensure build context exists
|
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
|
|
|
|
if not b:
|
|
|
|
|
# No active build: show Step 5 with an error
|
|
|
|
|
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
# Ensure we have a CSV base stem for consistent re-exports
|
|
|
|
|
base_stem = None
|
|
|
|
|
try:
|
|
|
|
|
csv_path = ctx.get("csv_path")
|
|
|
|
|
if isinstance(csv_path, str) and csv_path:
|
|
|
|
|
import os as _os
|
|
|
|
|
base_stem = _os.path.splitext(_os.path.basename(csv_path))[0]
|
|
|
|
|
except Exception:
|
|
|
|
|
base_stem = None
|
|
|
|
|
# If missing, export once to establish base
|
|
|
|
|
if not base_stem:
|
|
|
|
|
try:
|
|
|
|
|
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
|
|
|
|
|
import os as _os
|
|
|
|
|
base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0]
|
|
|
|
|
# Also produce a text export for completeness
|
|
|
|
|
ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
base_stem = None
|
|
|
|
|
# Add lock placeholders into the library before enforcement so user choices are present
|
|
|
|
|
try:
|
|
|
|
|
locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
|
|
|
|
|
if locks:
|
|
|
|
|
df = getattr(b, "_combined_cards_df", None)
|
|
|
|
|
lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}
|
|
|
|
|
for lname in locks:
|
|
|
|
|
if lname in lib_l:
|
|
|
|
|
continue
|
|
|
|
|
target_name = None
|
|
|
|
|
card_type = ''
|
|
|
|
|
mana_cost = ''
|
|
|
|
|
try:
|
|
|
|
|
if df is not None and not df.empty:
|
|
|
|
|
row = df[df['name'].astype(str).str.lower() == lname]
|
|
|
|
|
if not row.empty:
|
|
|
|
|
target_name = str(row.iloc[0]['name'])
|
|
|
|
|
card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
|
|
|
|
|
mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '')
|
|
|
|
|
except Exception:
|
|
|
|
|
target_name = None
|
|
|
|
|
if target_name:
|
|
|
|
|
b.card_library[target_name] = {
|
|
|
|
|
'Count': 1,
|
|
|
|
|
'Card Type': card_type,
|
|
|
|
|
'Mana Cost': mana_cost,
|
|
|
|
|
'Role': 'Locked',
|
|
|
|
|
'SubRole': '',
|
|
|
|
|
'AddedBy': 'Lock',
|
|
|
|
|
'TriggerTag': '',
|
|
|
|
|
}
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Thread preferred replacements from context onto builder so enforcement can honor them
|
|
|
|
|
try:
|
|
|
|
|
pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None
|
|
|
|
|
if isinstance(pref, dict):
|
|
|
|
|
setattr(b, 'preferred_replacements', dict(pref))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Run enforcement + re-exports (tops up to 100 internally)
|
|
|
|
|
try:
|
|
|
|
|
rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
# Reload compliance JSON and summary
|
|
|
|
|
compliance = None
|
|
|
|
|
try:
|
|
|
|
|
if base_stem:
|
|
|
|
|
import os as _os
|
|
|
|
|
import json as _json
|
|
|
|
|
comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
|
|
|
|
|
if _os.path.exists(comp_path):
|
|
|
|
|
with open(comp_path, 'r', encoding='utf-8') as _cf:
|
|
|
|
|
compliance = _json.load(_cf)
|
|
|
|
|
except Exception:
|
|
|
|
|
compliance = None
|
|
|
|
|
# Rebuild Step 5 context (done state)
|
|
|
|
|
# Ensure csv/txt paths on ctx reflect current base
|
|
|
|
|
try:
|
|
|
|
|
import os as _os
|
|
|
|
|
ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path")
|
|
|
|
|
ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Compute total_cards
|
|
|
|
|
try:
|
|
|
|
|
total_cards = 0
|
|
|
|
|
for _n, _e in getattr(b, 'card_library', {}).items():
|
|
|
|
|
try:
|
|
|
|
|
total_cards += int(_e.get('Count', 1))
|
|
|
|
|
except Exception:
|
|
|
|
|
total_cards += 1
|
|
|
|
|
except Exception:
|
|
|
|
|
total_cards = None
|
|
|
|
|
res = {
|
|
|
|
|
"done": True,
|
|
|
|
|
"label": "Complete",
|
|
|
|
|
"log_delta": "",
|
|
|
|
|
"idx": len(ctx.get("stages", []) or []),
|
|
|
|
|
"total": len(ctx.get("stages", []) or []),
|
|
|
|
|
"csv_path": ctx.get("csv_path"),
|
|
|
|
|
"txt_path": ctx.get("txt_path"),
|
|
|
|
|
"summary": getattr(b, 'build_deck_summary', lambda: None)(),
|
|
|
|
|
"total_cards": total_cards,
|
|
|
|
|
"added_total": 0,
|
|
|
|
|
"compliance": compliance or rep,
|
|
|
|
|
}
|
|
|
|
|
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", page_ctx)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/enforcement", response_class=HTMLResponse)
|
|
|
|
|
async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
|
|
|
|
|
"""Full-page enforcement review: show compliance panel with swaps and controls."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
ctx = sess.get("build_ctx") or {}
|
|
|
|
|
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
|
|
|
|
|
if not b:
|
|
|
|
|
# No active build
|
|
|
|
|
base = step5_empty_ctx(request, sess)
|
|
|
|
|
resp = templates.TemplateResponse("build/_step5.html", base)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
# Compute compliance snapshot and attach planning preview
|
|
|
|
|
comp = None
|
|
|
|
|
try:
|
|
|
|
|
if hasattr(b, 'compute_and_print_compliance'):
|
|
|
|
|
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
comp = None
|
|
|
|
|
try:
|
|
|
|
|
if comp:
|
|
|
|
|
from ..services import orchestrator as orch
|
|
|
|
|
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
ctx2 = {"request": request, "compliance": comp}
|
|
|
|
|
resp = templates.TemplateResponse("build/enforcement.html", ctx2)
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
2025-08-28 14:57:22 -07:00
|
|
|
|
@router.get("/permalink")
|
|
|
|
|
async def build_permalink(request: Request):
|
|
|
|
|
"""Return a URL-safe JSON payload representing current run config (basic)."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
payload = {
|
|
|
|
|
"commander": sess.get("commander"),
|
|
|
|
|
"tags": sess.get("tags", []),
|
|
|
|
|
"bracket": sess.get("bracket"),
|
|
|
|
|
"ideals": sess.get("ideals"),
|
|
|
|
|
"tag_mode": sess.get("tag_mode", "AND"),
|
|
|
|
|
"flags": {
|
|
|
|
|
"owned_only": bool(sess.get("use_owned_only")),
|
|
|
|
|
"prefer_owned": bool(sess.get("prefer_owned")),
|
|
|
|
|
},
|
|
|
|
|
"locks": list(sess.get("locks", [])),
|
|
|
|
|
}
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Add include/exclude cards and advanced options if feature is enabled
|
|
|
|
|
if ALLOW_MUST_HAVES:
|
|
|
|
|
if sess.get("include_cards"):
|
|
|
|
|
payload["include_cards"] = sess.get("include_cards")
|
|
|
|
|
if sess.get("exclude_cards"):
|
|
|
|
|
payload["exclude_cards"] = sess.get("exclude_cards")
|
|
|
|
|
if sess.get("enforcement_mode"):
|
|
|
|
|
payload["enforcement_mode"] = sess.get("enforcement_mode")
|
|
|
|
|
if sess.get("allow_illegal") is not None:
|
|
|
|
|
payload["allow_illegal"] = sess.get("allow_illegal")
|
|
|
|
|
if sess.get("fuzzy_matching") is not None:
|
|
|
|
|
payload["fuzzy_matching"] = sess.get("fuzzy_matching")
|
2025-08-28 14:57:22 -07:00
|
|
|
|
try:
|
|
|
|
|
import base64
|
|
|
|
|
import json as _json
|
|
|
|
|
raw = _json.dumps(payload, separators=(",", ":"))
|
|
|
|
|
token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
|
|
|
|
|
# Also include decoded state for convenience/testing
|
|
|
|
|
return JSONResponse({"ok": True, "permalink": f"/build/from?state={token}", "state": payload})
|
|
|
|
|
except Exception:
|
|
|
|
|
return JSONResponse({"ok": True, "state": payload})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/from", response_class=HTMLResponse)
|
|
|
|
|
async def build_from(request: Request, state: str | None = None) -> HTMLResponse:
|
|
|
|
|
"""Load a run from a permalink token."""
|
|
|
|
|
sid = request.cookies.get("sid") or new_sid()
|
|
|
|
|
sess = get_session(sid)
|
|
|
|
|
if state:
|
|
|
|
|
try:
|
|
|
|
|
import base64
|
|
|
|
|
import json as _json
|
|
|
|
|
pad = '=' * (-len(state) % 4)
|
|
|
|
|
raw = base64.urlsafe_b64decode((state + pad).encode("ascii")).decode("utf-8")
|
|
|
|
|
data = _json.loads(raw)
|
|
|
|
|
sess["commander"] = data.get("commander")
|
|
|
|
|
sess["tags"] = data.get("tags", [])
|
|
|
|
|
sess["bracket"] = data.get("bracket")
|
|
|
|
|
if data.get("ideals"):
|
|
|
|
|
sess["ideals"] = data.get("ideals")
|
|
|
|
|
sess["tag_mode"] = data.get("tag_mode", "AND")
|
|
|
|
|
flags = data.get("flags") or {}
|
|
|
|
|
sess["use_owned_only"] = bool(flags.get("owned_only"))
|
|
|
|
|
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
|
|
|
|
|
sess["locks"] = list(data.get("locks", []))
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
|
|
|
|
# Import exclude_cards if feature is enabled and present
|
|
|
|
|
if ALLOW_MUST_HAVES and data.get("exclude_cards"):
|
|
|
|
|
sess["exclude_cards"] = data.get("exclude_cards")
|
|
|
|
|
|
2025-08-28 14:57:22 -07:00
|
|
|
|
sess["last_step"] = 4
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
locks_restored = 0
|
|
|
|
|
try:
|
|
|
|
|
locks_restored = len(sess.get("locks", []) or [])
|
|
|
|
|
except Exception:
|
|
|
|
|
locks_restored = 0
|
|
|
|
|
resp = templates.TemplateResponse("build/_step4.html", {
|
|
|
|
|
"request": request,
|
|
|
|
|
"labels": orch.ideal_labels(),
|
|
|
|
|
"values": sess.get("ideals") or orch.ideal_defaults(),
|
|
|
|
|
"commander": sess.get("commander"),
|
|
|
|
|
"owned_only": bool(sess.get("use_owned_only")),
|
|
|
|
|
"prefer_owned": bool(sess.get("prefer_owned")),
|
|
|
|
|
"locks_restored": locks_restored,
|
|
|
|
|
})
|
|
|
|
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
|
|
|
|
return resp
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/validate/exclude_cards")
|
|
|
|
|
async def validate_exclude_cards(
|
|
|
|
|
request: Request,
|
|
|
|
|
exclude_cards: str = Form(default=""),
|
|
|
|
|
commander: str = Form(default="")
|
|
|
|
|
):
|
2025-09-09 18:15:30 -07:00
|
|
|
|
"""Legacy exclude cards validation endpoint - redirect to new unified endpoint."""
|
|
|
|
|
if not ALLOW_MUST_HAVES:
|
|
|
|
|
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
|
|
|
|
|
|
|
|
|
# Call new unified endpoint
|
|
|
|
|
result = await validate_include_exclude_cards(
|
|
|
|
|
request=request,
|
|
|
|
|
include_cards="",
|
|
|
|
|
exclude_cards=exclude_cards,
|
|
|
|
|
commander=commander,
|
|
|
|
|
enforcement_mode="warn",
|
|
|
|
|
allow_illegal=False,
|
|
|
|
|
fuzzy_matching=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Transform to legacy format for backward compatibility
|
|
|
|
|
if hasattr(result, 'body'):
|
|
|
|
|
import json
|
|
|
|
|
data = json.loads(result.body)
|
|
|
|
|
if 'excludes' in data:
|
|
|
|
|
excludes = data['excludes']
|
|
|
|
|
return JSONResponse({
|
|
|
|
|
"count": excludes.get("count", 0),
|
|
|
|
|
"limit": excludes.get("limit", 15),
|
|
|
|
|
"over_limit": excludes.get("over_limit", False),
|
|
|
|
|
"cards": excludes.get("cards", []),
|
|
|
|
|
"duplicates": excludes.get("duplicates", {}),
|
|
|
|
|
"warnings": excludes.get("warnings", [])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/validate/include_exclude")
|
|
|
|
|
async def validate_include_exclude_cards(
|
|
|
|
|
request: Request,
|
|
|
|
|
include_cards: str = Form(default=""),
|
|
|
|
|
exclude_cards: str = Form(default=""),
|
|
|
|
|
commander: str = Form(default=""),
|
|
|
|
|
enforcement_mode: str = Form(default="warn"),
|
|
|
|
|
allow_illegal: bool = Form(default=False),
|
|
|
|
|
fuzzy_matching: bool = Form(default=True)
|
|
|
|
|
):
|
|
|
|
|
"""Validate include/exclude card lists with comprehensive diagnostics."""
|
2025-09-09 09:36:17 -07:00
|
|
|
|
if not ALLOW_MUST_HAVES:
|
|
|
|
|
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
|
|
|
|
|
|
|
|
|
try:
|
2025-09-09 18:15:30 -07:00
|
|
|
|
from deck_builder.include_exclude_utils import (
|
|
|
|
|
parse_card_list_input, collapse_duplicates,
|
|
|
|
|
fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES
|
|
|
|
|
)
|
|
|
|
|
from deck_builder.builder import DeckBuilder
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Parse inputs
|
|
|
|
|
include_list = parse_card_list_input(include_cards) if include_cards.strip() else []
|
|
|
|
|
exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else []
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Collapse duplicates
|
|
|
|
|
include_unique, include_dupes = collapse_duplicates(include_list)
|
|
|
|
|
exclude_unique, exclude_dupes = collapse_duplicates(exclude_list)
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Initialize result structure
|
2025-09-09 09:36:17 -07:00
|
|
|
|
result = {
|
2025-09-09 18:15:30 -07:00
|
|
|
|
"includes": {
|
|
|
|
|
"count": len(include_unique),
|
|
|
|
|
"limit": MAX_INCLUDES,
|
|
|
|
|
"over_limit": len(include_unique) > MAX_INCLUDES,
|
|
|
|
|
"duplicates": include_dupes,
|
|
|
|
|
"cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."],
|
|
|
|
|
"warnings": [],
|
|
|
|
|
"legal": [],
|
|
|
|
|
"illegal": [],
|
|
|
|
|
"color_mismatched": [],
|
|
|
|
|
"fuzzy_matches": {}
|
|
|
|
|
},
|
|
|
|
|
"excludes": {
|
|
|
|
|
"count": len(exclude_unique),
|
|
|
|
|
"limit": MAX_EXCLUDES,
|
|
|
|
|
"over_limit": len(exclude_unique) > MAX_EXCLUDES,
|
|
|
|
|
"duplicates": exclude_dupes,
|
|
|
|
|
"cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."],
|
|
|
|
|
"warnings": [],
|
|
|
|
|
"legal": [],
|
|
|
|
|
"illegal": [],
|
|
|
|
|
"fuzzy_matches": {}
|
|
|
|
|
},
|
|
|
|
|
"conflicts": [], # Cards that appear in both lists
|
|
|
|
|
"confirmation_needed": [], # Cards needing fuzzy match confirmation
|
|
|
|
|
"overall_warnings": []
|
2025-09-09 09:36:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
2025-09-09 18:15:30 -07:00
|
|
|
|
# Check for conflicts (cards in both lists)
|
|
|
|
|
conflicts = set(include_unique) & set(exclude_unique)
|
|
|
|
|
if conflicts:
|
|
|
|
|
result["conflicts"] = list(conflicts)
|
|
|
|
|
result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}")
|
|
|
|
|
|
|
|
|
|
# Size warnings based on actual counts
|
|
|
|
|
if result["includes"]["over_limit"]:
|
|
|
|
|
result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}")
|
|
|
|
|
elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning
|
|
|
|
|
result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}")
|
|
|
|
|
|
|
|
|
|
if result["excludes"]["over_limit"]:
|
|
|
|
|
result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
|
|
|
|
elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning
|
|
|
|
|
result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
|
|
|
|
|
|
|
|
|
# Do fuzzy matching regardless of commander (for basic card validation)
|
|
|
|
|
if fuzzy_matching and (include_unique or exclude_unique):
|
|
|
|
|
print(f"DEBUG: Attempting fuzzy matching with {len(include_unique)} includes, {len(exclude_unique)} excludes")
|
|
|
|
|
try:
|
|
|
|
|
# Get card names directly from CSV without requiring commander setup
|
|
|
|
|
import pandas as pd
|
|
|
|
|
cards_df = pd.read_csv('csv_files/cards.csv')
|
|
|
|
|
print(f"DEBUG: CSV columns: {list(cards_df.columns)}")
|
|
|
|
|
|
|
|
|
|
# Try to find the name column
|
|
|
|
|
name_column = None
|
|
|
|
|
for col in ['Name', 'name', 'card_name', 'CardName']:
|
|
|
|
|
if col in cards_df.columns:
|
|
|
|
|
name_column = col
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if name_column is None:
|
|
|
|
|
raise ValueError(f"Could not find name column. Available columns: {list(cards_df.columns)}")
|
|
|
|
|
|
|
|
|
|
available_cards = set(cards_df[name_column].tolist())
|
|
|
|
|
print(f"DEBUG: Loaded {len(available_cards)} available cards")
|
|
|
|
|
|
|
|
|
|
# Validate includes with fuzzy matching
|
|
|
|
|
for card_name in include_unique:
|
|
|
|
|
print(f"DEBUG: Testing include card: {card_name}")
|
|
|
|
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
|
|
|
|
print(f"DEBUG: Match result - name: {match_result.matched_name}, auto_accepted: {match_result.auto_accepted}, confidence: {match_result.confidence}")
|
|
|
|
|
|
|
|
|
|
if match_result.matched_name and match_result.auto_accepted:
|
|
|
|
|
# Exact or high-confidence match
|
|
|
|
|
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
|
|
|
|
result["includes"]["legal"].append(match_result.matched_name)
|
|
|
|
|
elif not match_result.auto_accepted and match_result.suggestions:
|
|
|
|
|
# Needs confirmation - has suggestions but low confidence
|
|
|
|
|
print(f"DEBUG: Adding confirmation for {card_name}")
|
|
|
|
|
result["confirmation_needed"].append({
|
|
|
|
|
"input": card_name,
|
|
|
|
|
"suggestions": match_result.suggestions,
|
|
|
|
|
"confidence": match_result.confidence,
|
|
|
|
|
"type": "include"
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
# No match found at all, add to illegal
|
|
|
|
|
result["includes"]["illegal"].append(card_name)
|
|
|
|
|
|
|
|
|
|
# Validate excludes with fuzzy matching
|
|
|
|
|
for card_name in exclude_unique:
|
|
|
|
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
|
|
|
|
if match_result.matched_name:
|
|
|
|
|
if match_result.auto_accepted:
|
|
|
|
|
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
|
|
|
|
result["excludes"]["legal"].append(match_result.matched_name)
|
|
|
|
|
else:
|
|
|
|
|
# Needs confirmation
|
|
|
|
|
result["confirmation_needed"].append({
|
|
|
|
|
"input": card_name,
|
|
|
|
|
"suggestions": match_result.suggestions,
|
|
|
|
|
"confidence": match_result.confidence,
|
|
|
|
|
"type": "exclude"
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
# No match found, add to illegal
|
|
|
|
|
result["excludes"]["illegal"].append(card_name)
|
|
|
|
|
|
|
|
|
|
except Exception as fuzzy_error:
|
|
|
|
|
print(f"DEBUG: Fuzzy matching error: {str(fuzzy_error)}")
|
|
|
|
|
import traceback
|
|
|
|
|
traceback.print_exc()
|
|
|
|
|
result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}")
|
|
|
|
|
|
|
|
|
|
# If we have a commander, do advanced validation (color identity, etc.)
|
|
|
|
|
if commander and commander.strip():
|
|
|
|
|
try:
|
|
|
|
|
# Create a temporary builder to get available card names
|
|
|
|
|
builder = DeckBuilder()
|
|
|
|
|
builder.setup_dataframes()
|
|
|
|
|
|
|
|
|
|
# Get available card names for fuzzy matching
|
|
|
|
|
available_cards = set(builder._full_cards_df['Name'].tolist())
|
|
|
|
|
|
|
|
|
|
# Validate includes with fuzzy matching
|
|
|
|
|
for card_name in include_unique:
|
|
|
|
|
if fuzzy_matching:
|
|
|
|
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
|
|
|
|
if match_result.matched_name:
|
|
|
|
|
if match_result.auto_accepted:
|
|
|
|
|
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
|
|
|
|
result["includes"]["legal"].append(match_result.matched_name)
|
|
|
|
|
else:
|
|
|
|
|
# Needs confirmation
|
|
|
|
|
result["confirmation_needed"].append({
|
|
|
|
|
"input": card_name,
|
|
|
|
|
"suggestions": match_result.suggestions,
|
|
|
|
|
"confidence": match_result.confidence,
|
|
|
|
|
"type": "include"
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
result["includes"]["illegal"].append(card_name)
|
|
|
|
|
else:
|
|
|
|
|
# Exact match only
|
|
|
|
|
if card_name in available_cards:
|
|
|
|
|
result["includes"]["legal"].append(card_name)
|
|
|
|
|
else:
|
|
|
|
|
result["includes"]["illegal"].append(card_name)
|
|
|
|
|
|
|
|
|
|
# Validate excludes with fuzzy matching
|
|
|
|
|
for card_name in exclude_unique:
|
|
|
|
|
if fuzzy_matching:
|
|
|
|
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
|
|
|
|
if match_result.matched_name:
|
|
|
|
|
if match_result.auto_accepted:
|
|
|
|
|
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
|
|
|
|
result["excludes"]["legal"].append(match_result.matched_name)
|
|
|
|
|
else:
|
|
|
|
|
# Needs confirmation
|
|
|
|
|
result["confirmation_needed"].append({
|
|
|
|
|
"input": card_name,
|
|
|
|
|
"suggestions": match_result.suggestions,
|
|
|
|
|
"confidence": match_result.confidence,
|
|
|
|
|
"type": "exclude"
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
result["excludes"]["illegal"].append(card_name)
|
|
|
|
|
else:
|
|
|
|
|
# Exact match only
|
|
|
|
|
if card_name in available_cards:
|
|
|
|
|
result["excludes"]["legal"].append(card_name)
|
|
|
|
|
else:
|
|
|
|
|
result["excludes"]["illegal"].append(card_name)
|
|
|
|
|
|
|
|
|
|
except Exception as validation_error:
|
|
|
|
|
# Advanced validation failed, but return basic validation
|
|
|
|
|
result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}")
|
2025-09-09 09:36:17 -07:00
|
|
|
|
|
|
|
|
|
return JSONResponse(result)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return JSONResponse({"error": str(e)}, status_code=400)
|