feat: revamp multicopy flow with include/exclude conflict dialogs

This commit is contained in:
matt 2026-03-21 19:23:21 -07:00
parent 4aa41adb20
commit 804c750bb2
14 changed files with 665 additions and 166 deletions

View file

@ -8,9 +8,10 @@ for deck building, including the card toggle endpoint and summary rendering.
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request, Form
from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse, JSONResponse
from deck_builder import builder_constants as bc
from ..app import ALLOW_MUST_HAVES, templates
from ..services.build_utils import step5_base_ctx
from ..services.tasks import get_session, new_sid
@ -21,6 +22,15 @@ from .build import _merge_hx_trigger
router = APIRouter()
def _is_multi_copy_archetype(card_name: str) -> dict | None:
"""Return archetype dict if card_name exactly matches a known multi-copy archetype, else None."""
normalized = card_name.strip().lower()
for archetype in bc.MULTI_COPY_ARCHETYPES.values():
if str(archetype.get("name", "")).strip().lower() == normalized:
return archetype
return None
def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
"""
Extract include/exclude card lists and enforcement settings from session.
@ -129,6 +139,22 @@ async def toggle_must_haves(
sid = new_sid()
sess = get_session(sid)
# R13: Multi-copy archetype conflict detection
if enabled_flag and ALLOW_MUST_HAVES:
archetype = _is_multi_copy_archetype(name)
if list_key == "include" and archetype:
ctx = {"request": request, "archetype": archetype, "card_name": name}
resp = templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
if list_key == "exclude":
active_mc = sess.get("multi_copy") or {}
if active_mc and str(active_mc.get("name", "")).strip().lower() == name.lower():
ctx = {"request": request, "archetype": active_mc, "card_name": name}
resp = templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
includes = list(sess.get("include_cards") or [])
excludes = list(sess.get("exclude_cards") or [])
include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()}
@ -214,3 +240,129 @@ async def toggle_must_haves(
except Exception:
pass
return response
@router.get("/must-haves/summary", response_class=HTMLResponse)
async def must_haves_summary(request: Request) -> HTMLResponse:
"""Return the current include/exclude summary fragment (used by dialog cancel actions)."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
return _render_include_exclude_summary(request, sess, sid)
@router.get("/must-haves/multicopy-dialog", response_class=HTMLResponse)
async def multicopy_include_dialog_view(
request: Request,
card_name: str = Query(...),
) -> HTMLResponse:
"""Return the count-picker dialog fragment for a multi-copy archetype card."""
archetype = _is_multi_copy_archetype(card_name)
if not archetype:
return HTMLResponse("", status_code=200)
ctx = {"request": request, "archetype": archetype, "card_name": card_name}
return templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx)
@router.get("/must-haves/exclude-archetype-warning", response_class=HTMLResponse)
async def exclude_archetype_warning_view(
request: Request,
card_name: str = Query(...),
) -> HTMLResponse:
"""Return the warning dialog fragment for excluding the currently-active archetype."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
active_mc = sess.get("multi_copy") or {}
ctx = {"request": request, "archetype": active_mc, "card_name": card_name}
return templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx)
@router.post("/must-haves/save-archetype-include", response_class=HTMLResponse)
async def save_archetype_include(
request: Request,
card_name: str = Form(...),
choice_id: str = Form(...),
count: int = Form(None),
thrumming: str | None = Form(None),
) -> HTMLResponse:
"""Save multi-copy archetype selection and add card to the must-include list.
Combines the archetype save (multicopy/save) and include toggle into a single
action, called from the multicopy_include_dialog when the user confirms.
"""
if not ALLOW_MUST_HAVES:
return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Persist archetype selection (mirrors multicopy/save logic)
meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {})
archetype_name = meta.get("name") or card_name.strip()
if count is None:
count = int(meta.get("default_count", 25))
try:
count = int(count)
except Exception:
count = int(meta.get("default_count", 25))
printed_cap = meta.get("printed_cap")
if isinstance(printed_cap, int) and printed_cap > 0:
count = max(1, min(printed_cap, count))
sess["multi_copy"] = {
"id": str(choice_id),
"name": archetype_name,
"count": count,
"thrumming": str(thrumming or "").strip().lower() in {"1", "true", "on", "yes"},
}
try:
if "mc_applied_key" in sess:
del sess["mc_applied_key"]
except Exception:
pass
# Add card to include list
name = card_name.strip()
key = name.lower()
includes = list(sess.get("include_cards") or [])
include_lookup = {str(v).strip().lower(): str(v) for v in includes}
if key not in include_lookup:
includes.append(name)
# Remove from excludes if present
excludes = [c for c in (sess.get("exclude_cards") or []) if str(c).strip().lower() != key]
sess["include_cards"] = includes
sess["exclude_cards"] = excludes
return _render_include_exclude_summary(request, sess, sid)
@router.post("/must-haves/clear-archetype", response_class=HTMLResponse)
async def clear_archetype(
request: Request,
card_name: str | None = Form(None),
) -> HTMLResponse:
"""Clear the active multi-copy archetype and optionally add card to the exclude list.
Called when user confirms the exclude-archetype-warning dialog.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
for k in ("multi_copy", "mc_applied_key", "mc_seen_keys"):
try:
if k in sess:
del sess[k]
except Exception:
pass
if card_name:
name = str(card_name).strip()
key = name.lower()
excludes = list(sess.get("exclude_cards") or [])
exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes}
if key not in exclude_lookup:
excludes.append(name)
# Remove from includes if present
includes = [c for c in (sess.get("include_cards") or []) if str(c).strip().lower() != key]
sess["include_cards"] = includes
sess["exclude_cards"] = excludes
return _render_include_exclude_summary(request, sess, sid)

View file

@ -150,6 +150,10 @@ async def multicopy_check(request: Request) -> HTMLResponse:
pass
# Detect viable archetypes
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
# R13: Filter out archetypes whose card is in the must-exclude list
exclude_names = {str(c).strip().lower() for c in (sess.get("exclude_cards") or [])}
if exclude_names:
results = [r for r in results if str(r.get("name", "")).strip().lower() not in exclude_names]
if not results:
# Remember this key to avoid re-checking until tags/commander change
try:

View file

@ -37,12 +37,27 @@ from .build_partners import (
_partner_ui_context,
_resolve_partner_selection,
)
from .build_wizard import _prepare_step2_theme_data, _section_themes_by_pool_size # R21: Pool size data
from .build_themes import _prepare_step2_theme_data, _section_themes_by_pool_size
from ..services import custom_theme_manager as theme_mgr
router = APIRouter()
# Pre-built JS-serialisable map of multi-copy archetypes for client-side popup detection.
# Keys are lowercased card names; values contain what the popup needs.
_ARCHETYPE_JS_MAP: dict[str, dict] = {
str(a["name"]).strip().lower(): {
"id": a["id"],
"name": a["name"],
"default_count": a.get("default_count", 25),
"printed_cap": a.get("printed_cap"),
"rec_window": list(a["rec_window"]) if a.get("rec_window") else None,
"thrumming_stone_synergy": bool(a.get("thrumming_stone_synergy", False)),
}
for a in bc.MULTI_COPY_ARCHETYPES.values()
}
# ==============================================================================
# New Deck Modal and Commander Search
# ==============================================================================
@ -99,6 +114,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": {
"commander": sess.get("commander", ""), # Pre-fill for quick-build
"prefer_combos": bool(sess.get("prefer_combos")),
@ -485,6 +501,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(suggested),
"tag_slot_html": None,
}
@ -510,6 +527,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(commander),
"tag_slot_html": None,
}
@ -615,6 +633,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(primary_commander_name),
"tag_slot_html": tag_slot_html,
}
@ -754,6 +773,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(sess.get("commander", "")),
"tag_slot_html": None,
}

View file

@ -20,11 +20,58 @@ from ..app import (
)
from ..services.tasks import get_session, new_sid
from ..services import custom_theme_manager as theme_mgr
from ..services.theme_catalog_loader import load_index, slugify
router = APIRouter()
def _prepare_step2_theme_data(tags: list[str], recommended: list[str]) -> tuple[list[str], list[str], dict[str, int]]:
"""Load pool size data and sort themes for display.
Returns:
Tuple of (sorted_tags, sorted_recommended, pool_size_dict)
"""
import logging
logger = logging.getLogger(__name__)
try:
theme_index = load_index()
pool_size_by_slug = theme_index.pool_size_by_slug
except Exception as e:
logger.warning(f"Failed to load theme index for pool sizes: {e}")
pool_size_by_slug = {}
def sort_by_pool_size(theme_list: list[str]) -> list[str]:
return sorted(
theme_list,
key=lambda t: (-pool_size_by_slug.get(slugify(t), 0), t.lower())
)
return sort_by_pool_size(tags), sort_by_pool_size(recommended), pool_size_by_slug
def _section_themes_by_pool_size(themes: list[str], pool_size: dict[str, int]) -> list[dict[str, Any]]:
"""Group themes into sections by pool size.
Thresholds: Vast 1000, Large 500-999, Moderate 200-499, Small 50-199, Tiny <50
"""
sections = [
{"label": "Vast", "min": 1000, "max": 9999999, "themes": []},
{"label": "Large", "min": 500, "max": 999, "themes": []},
{"label": "Moderate", "min": 200, "max": 499, "themes": []},
{"label": "Small", "min": 50, "max": 199, "themes": []},
{"label": "Tiny", "min": 0, "max": 49, "themes": []},
]
for theme in themes:
theme_pool = pool_size.get(slugify(theme), 0)
for section in sections:
if section["min"] <= theme_pool <= section["max"]:
section["themes"].append(theme)
break
return [s for s in sections if s["themes"]]
_INVALID_THEME_MESSAGE = (
"Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores."
)

View file

@ -13,7 +13,7 @@ Extracted from build.py as part of Phase 3 modularization (Roadmap 9 M1).
from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from typing import Any
from ..app import templates, ENABLE_PARTNER_MECHANICS, THEME_POOL_SECTIONS
@ -182,25 +182,12 @@ def _get_current_deck_names(sess: dict) -> list[str]:
@router.get("/step1", response_class=HTMLResponse)
async def build_step1(request: Request) -> HTMLResponse:
"""Display commander search form."""
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
return RedirectResponse("/build", status_code=302)
@router.post("/step1", response_class=HTMLResponse)
async def build_step1_search(
request: Request,
query: str = Form(""),
auto: str | None = Form(None),
active: str | None = Form(None),
) -> HTMLResponse:
"""Search for commander candidates and optionally auto-select."""
query = (query or "").strip()
auto_enabled = True if (auto == "1") else False
async def build_step1_search(request: Request) -> HTMLResponse:
return RedirectResponse("/build", status_code=302)
candidates = []
if query:
candidates = orch.commander_candidates(query, limit=10)
@ -275,11 +262,8 @@ async def build_step1_search(
@router.post("/step1/inspect", response_class=HTMLResponse)
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
"""Preview commander details before confirmation."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
async def build_step1_inspect(request: Request) -> HTMLResponse:
return RedirectResponse("/build", status_code=302)
info = orch.commander_inspect(name)
resp = templates.TemplateResponse(
"build/_step1.html",
@ -290,9 +274,8 @@ async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLRe
@router.post("/step1/confirm", response_class=HTMLResponse)
async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse:
"""Confirm commander selection and proceed to step 2."""
res = orch.commander_select(name)
async def build_step1_confirm(request: Request) -> HTMLResponse:
return RedirectResponse("/build", status_code=302)
if not res.get("ok"):
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
@ -381,23 +364,7 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
@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
return RedirectResponse("/build", status_code=302)
# ============================================================================
@ -406,11 +373,7 @@ async def build_reset_all(request: Request) -> HTMLResponse:
@router.get("/step2", response_class=HTMLResponse)
async def build_step2_get(request: Request) -> HTMLResponse:
"""Display theme picker and partner selection."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
commander = sess.get("commander")
return RedirectResponse("/build", status_code=302)
if not commander:
# Fallback to step1 if no commander in session
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
@ -513,24 +476,8 @@ async def build_step2_get(request: Request) -> HTMLResponse:
@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),
tag_mode: str | None = Form("AND"),
bracket: int = Form(...),
partner_enabled: str | None = Form(None),
secondary_commander: str | None = Form(None),
background: str | None = Form(None),
partner_selection_source: str | None = Form(None),
partner_auto_opt_out: str | None = Form(None),
) -> HTMLResponse:
"""Submit theme and partner selections, proceed to step 3."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
async def build_step2_submit(request: Request) -> HTMLResponse:
return RedirectResponse("/build", status_code=302)
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
partner_flag = False
@ -776,11 +723,7 @@ async def build_step2_submit(
@router.get("/step3", response_class=HTMLResponse)
async def build_step3_get(request: Request) -> HTMLResponse:
"""Display ideal card count sliders."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 3
defaults = orch.ideal_defaults()
return RedirectResponse("/build", status_code=302)
values = sess.get("ideals") or defaults
# Check if any skip flags are enabled to show skeleton automation page
@ -850,19 +793,8 @@ async def build_step3_get(request: Request) -> HTMLResponse:
@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:
"""Submit ideal card counts, proceed to step 4."""
labels = orch.ideal_labels()
async def build_step3_submit(request: Request) -> HTMLResponse:
return RedirectResponse("/build", status_code=302)
submitted = {
"ramp": ramp,
"lands": lands,
@ -944,11 +876,7 @@ async def build_step3_submit(
@router.get("/step4", response_class=HTMLResponse)
async def build_step4_get(request: Request) -> HTMLResponse:
"""Display review page with owned card preferences."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 4
labels = orch.ideal_labels()
return RedirectResponse("/build", status_code=302)
values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander")
return templates.TemplateResponse(
@ -966,17 +894,8 @@ async def build_step4_get(request: Request) -> HTMLResponse:
@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),
swap_mdfc_basics: 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)
sess["last_step"] = 4
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
async def build_toggle_owned_review(request: Request) -> HTMLResponse:
return RedirectResponse("/build", status_code=302)
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False
sess["use_owned_only"] = only_val
@ -1024,19 +943,12 @@ async def build_step5_get(request: Request) -> HTMLResponse:
@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)
return RedirectResponse("/build", status_code=302)
@router.post("/step5/start", response_class=HTMLResponse)
async def build_step5_start(request: Request) -> HTMLResponse:
"""Initialize build context and run first stage."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
if "replace_mode" not in sess:
sess["replace_mode"] = True
# Validate commander exists before starting
commander = sess.get("commander")
return RedirectResponse("/build", status_code=302)
if not commander:
resp = templates.TemplateResponse(
"build/_step1.html",