mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-18 11:16:30 +01:00
- Split monolithic build route handler into focused modules - Extract validation, multi-copy, include/exclude, themes, and partner routes - Add response utilities and telemetry decorators - Create route pattern documentation - Fix multi-copy detection bug (tag key mismatch) - Improve code maintainability and testability Roadmap 9 M1 Phase 1-2
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Multi-copy archetype routes for deck building.
|
|
|
|
Handles multi-copy package detection, selection, and integration with the deck builder.
|
|
Multi-copy archetypes allow multiple copies of specific cards (e.g., Hare Apparent, Dragon's Approach).
|
|
|
|
Routes:
|
|
GET /multicopy/check - Check if commander/tags suggest multi-copy archetype
|
|
POST /multicopy/save - Save or skip multi-copy selection
|
|
GET /new/multicopy - Get multi-copy suggestions for New Deck modal (inline)
|
|
|
|
Created: 2026-02-20
|
|
Roadmap: R9 M1 Phase 2
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Request, Form, Query
|
|
from fastapi.responses import HTMLResponse
|
|
from html import escape as _esc
|
|
|
|
from deck_builder.builder import DeckBuilder
|
|
from deck_builder import builder_utils as bu, builder_constants as bc
|
|
from ..app import templates
|
|
from ..services.tasks import get_session, new_sid
|
|
from ..services import orchestrator as orch
|
|
from ..services.build_utils import owned_names as owned_names_helper
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
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.
|
|
|
|
Args:
|
|
sess: Session dictionary containing build state
|
|
"""
|
|
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:
|
|
safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket
|
|
except Exception:
|
|
safe_bracket = int(default_bracket)
|
|
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
|
use_owned = bool(sess.get("use_owned_only"))
|
|
prefer = bool(sess.get("prefer_owned"))
|
|
owned_names = owned_names_helper() if (use_owned or prefer) else None
|
|
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"),
|
|
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")),
|
|
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
|
|
)
|
|
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
|
|
|
|
|
|
@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.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
|
|
Returns:
|
|
HTMLResponse with multi-copy modal or empty string
|
|
"""
|
|
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.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
choice_id: Multi-copy archetype ID (e.g., 'hare_apparent')
|
|
count: Number of copies to include
|
|
thrumming: Whether to include Thrumming Stone
|
|
skip: Whether to skip multi-copy for this build
|
|
|
|
Returns:
|
|
HTMLResponse with confirmation chip (OOB swap)
|
|
"""
|
|
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)
|
|
|
|
|
|
@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.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
commander: Commander name
|
|
primary_tag: Primary theme tag
|
|
secondary_tag: Secondary theme tag
|
|
tertiary_tag: Tertiary theme tag
|
|
tag_mode: Tag matching mode (AND/OR)
|
|
|
|
Returns:
|
|
HTMLResponse with multi-copy suggestions or empty string
|
|
"""
|
|
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("")
|