mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-19 03:36:30 +01:00
refactor: backend standardization (service layer, validation, route splitting) + image cache and Scryfall API fixes
This commit is contained in:
parent
e81b47bccf
commit
f784741416
35 changed files with 7054 additions and 4344 deletions
|
|
@ -31,18 +31,46 @@ async def get_download_status():
|
|||
import json
|
||||
|
||||
status_file = Path("card_files/images/.download_status.json")
|
||||
last_result_file = Path("card_files/images/.last_download_result.json")
|
||||
|
||||
if not status_file.exists():
|
||||
# Check cache statistics if no download in progress
|
||||
# No active download - return cache stats plus last download result if available
|
||||
stats = _image_cache.cache_statistics()
|
||||
last_download = None
|
||||
if last_result_file.exists():
|
||||
try:
|
||||
with last_result_file.open('r', encoding='utf-8') as f:
|
||||
last_download = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse({
|
||||
"running": False,
|
||||
"last_download": last_download,
|
||||
"stats": stats
|
||||
})
|
||||
|
||||
try:
|
||||
with status_file.open('r', encoding='utf-8') as f:
|
||||
status = json.load(f)
|
||||
|
||||
# If download is complete (or errored), persist result, clean up status file
|
||||
if not status.get("running", False):
|
||||
try:
|
||||
with last_result_file.open('w', encoding='utf-8') as f:
|
||||
json.dump(status, f)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
status_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
cache_stats = _image_cache.cache_statistics()
|
||||
return JSONResponse({
|
||||
"running": False,
|
||||
"last_download": status,
|
||||
"stats": cache_stats
|
||||
})
|
||||
|
||||
return JSONResponse(status)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read status file: {e}")
|
||||
|
|
@ -136,7 +164,7 @@ async def get_card_image(size: str, card_name: str, face: str = Query(default="f
|
|||
image_path = _image_cache.get_image_path(card_name, size)
|
||||
|
||||
if image_path and image_path.exists():
|
||||
logger.info(f"Serving cached image: {card_name} ({size}, {face})")
|
||||
logger.debug(f"Serving cached image: {card_name} ({size}, {face})")
|
||||
return FileResponse(
|
||||
image_path,
|
||||
media_type="image/jpeg",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
615
code/web/routes/build_alternatives.py
Normal file
615
code/web/routes/build_alternatives.py
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
"""Build Alternatives Route
|
||||
|
||||
Phase 5 extraction from build.py:
|
||||
- GET /build/alternatives - Role-based card suggestions with tag overlap fallback
|
||||
|
||||
This module provides intelligent alternative card suggestions based on deck role,
|
||||
tags, and builder context. Supports owned-only filtering and caching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from typing import Any
|
||||
from ..app import templates
|
||||
from ..services.tasks import get_session, new_sid
|
||||
from ..services.build_utils import owned_set as owned_set_helper, builder_present_names, builder_display_map
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_constants as bc
|
||||
from deck_builder import builder_utils as bu
|
||||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
@router.get("/alternatives", response_class=HTMLResponse)
|
||||
async def build_alternatives(
|
||||
request: Request,
|
||||
name: str,
|
||||
stage: str | None = None,
|
||||
owned_only: int = Query(0),
|
||||
refresh: int = Query(0),
|
||||
) -> HTMLResponse:
|
||||
"""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.
|
||||
|
||||
Returns an HTML partial listing up to ~10 alternatives with Replace buttons.
|
||||
"""
|
||||
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
|
||||
owned_set = owned_set_helper()
|
||||
require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only"))
|
||||
refresh_requested = bool(int(refresh or 0))
|
||||
# If builder context missing, show a guidance message
|
||||
if not b:
|
||||
html = '<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
|
||||
return HTMLResponse(html)
|
||||
try:
|
||||
name_disp = str(name).strip()
|
||||
name_l = name_disp.lower()
|
||||
commander_l = str((sess.get("commander") or "")).strip().lower()
|
||||
locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
|
||||
# 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",
|
||||
# Land-related hints
|
||||
"land": "land",
|
||||
"lands": "land",
|
||||
"utility": "land",
|
||||
"misc": "land",
|
||||
"fetch": "land",
|
||||
"dual": "land",
|
||||
}
|
||||
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] = []
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
try:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value != value:
|
||||
return ""
|
||||
text = str(value)
|
||||
return text.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _normalize_tags(raw: Any) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
return [str(t).strip() for t in raw if str(t).strip()]
|
||||
if isinstance(raw, str):
|
||||
txt = raw.strip()
|
||||
if not txt:
|
||||
return []
|
||||
if txt.startswith("[") and txt.endswith("]"):
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(txt)
|
||||
if isinstance(parsed, list):
|
||||
return [str(t).strip() for t in parsed if str(t).strip()]
|
||||
except Exception:
|
||||
pass
|
||||
return [s.strip() for s in txt.split(',') if s.strip()]
|
||||
return []
|
||||
|
||||
def _meta_from_row(row_obj: Any) -> dict[str, Any]:
|
||||
meta = {
|
||||
"mana": "",
|
||||
"rarity": "",
|
||||
"role": "",
|
||||
"tags": [],
|
||||
"hover_simple": True,
|
||||
}
|
||||
if row_obj is None:
|
||||
meta["role"] = _clean(used_role or "")
|
||||
return meta
|
||||
|
||||
def _pull(*keys: str) -> Any:
|
||||
for key in keys:
|
||||
try:
|
||||
if isinstance(row_obj, dict):
|
||||
val = row_obj.get(key)
|
||||
elif hasattr(row_obj, "get"):
|
||||
val = row_obj.get(key)
|
||||
else:
|
||||
val = getattr(row_obj, key, None)
|
||||
except Exception:
|
||||
val = None
|
||||
if val not in (None, ""):
|
||||
if isinstance(val, float) and val != val:
|
||||
continue
|
||||
return val
|
||||
return None
|
||||
|
||||
meta["mana"] = _clean(_pull("mana_cost", "manaCost", "mana", "manaValue", "cmc", "mv"))
|
||||
meta["rarity"] = _clean(_pull("rarity"))
|
||||
role_val = _pull("role", "primaryRole", "subRole")
|
||||
if not role_val:
|
||||
role_val = used_role or ""
|
||||
meta["role"] = _clean(role_val)
|
||||
tags_val = _pull("themeTags", "_ltags", "tags")
|
||||
meta_tags = _normalize_tags(tags_val)
|
||||
meta["tags"] = meta_tags
|
||||
meta["hover_simple"] = not (meta["mana"] or meta["rarity"] or (meta_tags and len(meta_tags) > 0))
|
||||
return meta
|
||||
|
||||
def _build_meta_map(df_obj) -> dict[str, dict[str, Any]]:
|
||||
mapping: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
if df_obj is None or not hasattr(df_obj, "iterrows"):
|
||||
return mapping
|
||||
for _, row in df_obj.iterrows():
|
||||
try:
|
||||
nm_val = str(row.get("name") or "").strip()
|
||||
except Exception:
|
||||
nm_val = ""
|
||||
if not nm_val:
|
||||
continue
|
||||
key = nm_val.lower()
|
||||
if key in mapping:
|
||||
continue
|
||||
mapping[key] = _meta_from_row(row)
|
||||
except Exception:
|
||||
return mapping
|
||||
return mapping
|
||||
|
||||
def _sampler(seq: list[str], limit: int) -> list[str]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
if len(seq) <= limit:
|
||||
return list(seq)
|
||||
rng = getattr(b, "rng", None)
|
||||
try:
|
||||
if rng is not None:
|
||||
return rng.sample(seq, limit) if len(seq) >= limit else list(seq)
|
||||
import random as _rnd
|
||||
return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq)
|
||||
except Exception:
|
||||
return list(seq[:limit])
|
||||
used_role = role if isinstance(role, str) and role else None
|
||||
# Promote to 'land' role when the seed card is a land (regardless of stored role)
|
||||
try:
|
||||
if entry and isinstance(entry, dict):
|
||||
ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower()
|
||||
if "land" in ctype:
|
||||
used_role = "land"
|
||||
except Exception:
|
||||
pass
|
||||
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)
|
||||
cached = None
|
||||
if used_role != 'land' and not refresh_requested:
|
||||
cached = _alts_get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return HTMLResponse(cached)
|
||||
|
||||
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,
|
||||
})
|
||||
# Skip caching when used_role == land or refresh requested for per-call randomness
|
||||
if used_role != 'land' and not refresh_requested:
|
||||
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)
|
||||
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","land"}):
|
||||
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 [])
|
||||
# Role-specific base filtering
|
||||
if used_role != "land":
|
||||
# Exclude lands for non-land roles
|
||||
if "type" in pool.columns:
|
||||
pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)]
|
||||
else:
|
||||
# Keep only lands
|
||||
if "type" in pool.columns:
|
||||
pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)]
|
||||
# Seed info to guide filtering
|
||||
seed_is_basic = False
|
||||
try:
|
||||
seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])})
|
||||
except Exception:
|
||||
seed_is_basic = False
|
||||
if seed_is_basic:
|
||||
# For basics: show other basics (different colors) to allow quick swaps
|
||||
try:
|
||||
pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# For non-basics: prefer other non-basics
|
||||
try:
|
||||
pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
|
||||
except Exception:
|
||||
pass
|
||||
# Apply mono-color misc land filters (no debug CSV dependency)
|
||||
try:
|
||||
colors = list(getattr(b, 'color_identity', []) or [])
|
||||
mono = len(colors) <= 1
|
||||
mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])}
|
||||
mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])}
|
||||
kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])}
|
||||
any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])]
|
||||
extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])]
|
||||
fetch_names = set()
|
||||
for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values():
|
||||
for nm in seq:
|
||||
fetch_names.add(nm.lower())
|
||||
for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []):
|
||||
fetch_names.add(nm.lower())
|
||||
# World Tree check needs all five colors
|
||||
need_all_colors = {'w','u','b','r','g'}
|
||||
def _illegal_world_tree(nm: str) -> bool:
|
||||
return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors
|
||||
# Text column fallback
|
||||
text_col = 'text'
|
||||
if text_col not in pool.columns:
|
||||
for c in pool.columns:
|
||||
if 'text' in c.lower():
|
||||
text_col = c
|
||||
break
|
||||
def _exclude_row(row) -> bool:
|
||||
nm_l = str(row['name']).strip().lower()
|
||||
if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all:
|
||||
return True
|
||||
if mono and nm_l not in mono_keep and nm_l not in kindred_all:
|
||||
try:
|
||||
txt = str(row.get(text_col, '') or '').lower()
|
||||
if any(p in txt for p in any_color_phrases + extra_rainbow_terms):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
if nm_l in fetch_names:
|
||||
return True
|
||||
if _illegal_world_tree(nm_l):
|
||||
return True
|
||||
return False
|
||||
pool = pool[~pool.apply(_exclude_row, axis=1)]
|
||||
except Exception:
|
||||
pass
|
||||
# Optional sub-role filtering (only if enough depth)
|
||||
try:
|
||||
subrole = str((entry or {}).get('SubRole') or '').strip().lower()
|
||||
if subrole:
|
||||
# Heuristic categories for grouping
|
||||
cat_map = {
|
||||
'fetch': 'fetch',
|
||||
'dual': 'dual',
|
||||
'triple': 'triple',
|
||||
'misc': 'misc',
|
||||
'utility': 'misc',
|
||||
'basic': 'basic'
|
||||
}
|
||||
target_cat = None
|
||||
for key, val in cat_map.items():
|
||||
if key in subrole:
|
||||
target_cat = val
|
||||
break
|
||||
if target_cat and len(pool) > 25:
|
||||
# Lightweight textual filter using known markers
|
||||
def _cat_row(rname: str, rtype: str) -> str:
|
||||
rl = rname.lower()
|
||||
rt = rtype.lower()
|
||||
if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')):
|
||||
return 'fetch'
|
||||
if 'triple' in rt or 'three' in rt:
|
||||
return 'triple'
|
||||
if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt:
|
||||
# Basic-check crude
|
||||
return 'basic'
|
||||
return 'misc'
|
||||
try:
|
||||
tmp = pool.copy()
|
||||
tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1)
|
||||
sub_pool = tmp[tmp['_cat'] == target_cat]
|
||||
if len(sub_pool) >= 10:
|
||||
pool = sub_pool.drop(columns=['_cat'])
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# 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
|
||||
elif used_role == "land":
|
||||
# Already constrained to lands; no additional tag filter needed
|
||||
pass
|
||||
# Sort by priority like the builder
|
||||
try:
|
||||
pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"])
|
||||
except Exception:
|
||||
pass
|
||||
# Exclusions and ownership (for non-random roles this stays before slicing)
|
||||
pool = _exclude(pool)
|
||||
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
|
||||
row_meta = _build_meta_map(pool)
|
||||
# Land role: random 12 from top 60-100 window
|
||||
if used_role == 'land':
|
||||
import random as _rnd
|
||||
total = len(pool)
|
||||
if total == 0:
|
||||
pass
|
||||
else:
|
||||
cap = min(100, total)
|
||||
floor = min(60, cap) # if fewer than 60 just use all
|
||||
if cap <= 12:
|
||||
window_size = cap
|
||||
else:
|
||||
if cap == floor:
|
||||
window_size = cap
|
||||
else:
|
||||
rng_obj = getattr(b, 'rng', None)
|
||||
if rng_obj:
|
||||
window_size = rng_obj.randint(floor, cap)
|
||||
else:
|
||||
window_size = _rnd.randint(floor, cap)
|
||||
window_df = pool.head(window_size)
|
||||
names = window_df['name'].astype(str).str.strip().tolist()
|
||||
# Random sample up to 12 distinct names
|
||||
sample_n = min(12, len(names))
|
||||
if sample_n > 0:
|
||||
if getattr(b, 'rng', None):
|
||||
chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names
|
||||
else:
|
||||
chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names
|
||||
lower_map = {n.strip().lower(): n for n in chosen}
|
||||
display_map = _display_map_for(set(k for k in lower_map.keys()))
|
||||
for nm_lc, orig in lower_map.items():
|
||||
is_owned = (nm_lc in owned_set)
|
||||
if require_owned and not is_owned:
|
||||
continue
|
||||
if nm_lc == name_l or (in_deck and nm_lc in in_deck):
|
||||
continue
|
||||
meta = row_meta.get(nm_lc) or _meta_from_row(None)
|
||||
items.append({
|
||||
'name': display_map.get(nm_lc, orig),
|
||||
'name_lower': nm_lc,
|
||||
'owned': is_owned,
|
||||
'tags': meta.get('tags') or [],
|
||||
'role': meta.get('role', ''),
|
||||
'mana': meta.get('mana', ''),
|
||||
'rarity': meta.get('rarity', ''),
|
||||
'hover_simple': bool(meta.get('hover_simple', True)),
|
||||
})
|
||||
if items:
|
||||
return _render_and_cache(items)
|
||||
else:
|
||||
# Default deterministic top-N (increase to 12 for parity)
|
||||
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))
|
||||
iteration_order = lower_pool
|
||||
if refresh_requested and len(lower_pool) > 12:
|
||||
window_size = min(len(lower_pool), 30)
|
||||
window = lower_pool[:window_size]
|
||||
sampled = _sampler(window, min(window_size, 12))
|
||||
seen_sampled = set(sampled)
|
||||
iteration_order = sampled + [nm for nm in lower_pool if nm not in seen_sampled]
|
||||
for nm_l in iteration_order:
|
||||
is_owned = (nm_l in owned_set)
|
||||
if require_owned and not is_owned:
|
||||
continue
|
||||
if nm_l == name_l or (in_deck and nm_l in in_deck):
|
||||
continue
|
||||
meta = row_meta.get(nm_l) or _meta_from_row(None)
|
||||
items.append({
|
||||
"name": display_map.get(nm_l, nm_l),
|
||||
"name_lower": nm_l,
|
||||
"owned": is_owned,
|
||||
"tags": meta.get("tags") or [],
|
||||
"role": meta.get("role", ""),
|
||||
"mana": meta.get("mana", ""),
|
||||
"rarity": meta.get("rarity", ""),
|
||||
"hover_simple": bool(meta.get("hover_simple", True)),
|
||||
})
|
||||
if len(items) >= 12:
|
||||
break
|
||||
if items:
|
||||
return _render_and_cache(items)
|
||||
|
||||
# Fallback: tag-similarity suggestions (previous behavior)
|
||||
tags_idx = getattr(b, "_card_name_tags_index", {}) or {}
|
||||
seed_tags = set(tags_idx.get(name_l) or [])
|
||||
all_names = set(tags_idx.keys())
|
||||
candidates: list[tuple[str, int]] = [] # (name, score)
|
||||
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
|
||||
if nm in alts_exclude:
|
||||
continue
|
||||
tgs = set(tags_idx.get(nm) or [])
|
||||
score = len(seed_tags & tgs)
|
||||
if score <= 0:
|
||||
continue
|
||||
candidates.append((nm, score))
|
||||
# If no tag-based candidates, try shared trigger tag from library entry
|
||||
if not candidates and isinstance(entry, dict):
|
||||
try:
|
||||
trig = str(entry.get("TriggerTag") or "").strip().lower()
|
||||
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]))
|
||||
if refresh_requested and len(candidates) > 1:
|
||||
name_sequence = [nm for nm, _score in candidates]
|
||||
sampled_names = _sampler(name_sequence, min(len(name_sequence), 10))
|
||||
sampled_set = set(sampled_names)
|
||||
reordered: list[tuple[str, int]] = []
|
||||
for nm in sampled_names:
|
||||
for cand_nm, cand_score in candidates:
|
||||
if cand_nm == nm:
|
||||
reordered.append((cand_nm, cand_score))
|
||||
break
|
||||
for cand_nm, cand_score in candidates:
|
||||
if cand_nm not in sampled_set:
|
||||
reordered.append((cand_nm, cand_score))
|
||||
candidates = reordered
|
||||
pool_lower = {nm for (nm, _s) in candidates}
|
||||
display_map = _display_map_for(pool_lower)
|
||||
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
|
||||
items.append({
|
||||
"name": display_map.get(nm, nm),
|
||||
"name_lower": nm,
|
||||
"owned": is_owned,
|
||||
"tags": list(tags_idx.get(nm) or []),
|
||||
"role": "",
|
||||
"mana": "",
|
||||
"rarity": "",
|
||||
"hover_simple": True,
|
||||
})
|
||||
if len(items) >= 10:
|
||||
break
|
||||
return _render_and_cache(items)
|
||||
except Exception as e:
|
||||
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
|
||||
653
code/web/routes/build_compliance.py
Normal file
653
code/web/routes/build_compliance.py
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
"""Build Compliance and Card Replacement Routes
|
||||
|
||||
Phase 5 extraction from build.py:
|
||||
- POST /build/replace - Inline card replacement with undo tracking
|
||||
- POST /build/replace/undo - Undo last replacement
|
||||
- GET /build/compare - Batch build comparison stub
|
||||
- GET /build/compliance - Bracket compliance panel
|
||||
- POST /build/enforce/apply - Apply bracket enforcement
|
||||
- GET /build/enforcement - Full-page enforcement review
|
||||
|
||||
This module handles card replacement, bracket compliance checking, and enforcement.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from typing import Any
|
||||
import json
|
||||
from ..app import templates
|
||||
from ..services.tasks import get_session, new_sid
|
||||
from ..services.build_utils import (
|
||||
step5_ctx_from_result,
|
||||
step5_error_ctx,
|
||||
step5_empty_ctx,
|
||||
owned_set as owned_set_helper,
|
||||
)
|
||||
from ..services import orchestrator as orch
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from html import escape as _esc
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
|
||||
if not payload or response is None:
|
||||
return
|
||||
try:
|
||||
existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
|
||||
except Exception:
|
||||
existing = None
|
||||
try:
|
||||
if existing:
|
||||
try:
|
||||
data = json.loads(existing)
|
||||
except Exception:
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
data.update(payload)
|
||||
response.headers["HX-Trigger"] = json.dumps(data)
|
||||
return
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
try:
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/replace", response_class=HTMLResponse)
|
||||
async def build_replace(request: Request, old: str = Form(...), new: str = Form(...), owned_only: str = Form("0")) -> HTMLResponse:
|
||||
"""Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives.
|
||||
|
||||
Falls back to lock-and-rerun guidance if no active builder is present.
|
||||
"""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
o_disp = str(old).strip()
|
||||
n_disp = str(new).strip()
|
||||
o = o_disp.lower()
|
||||
n = n_disp.lower()
|
||||
owned_only_flag = str(owned_only or "").strip().lower()
|
||||
owned_only_int = 1 if owned_only_flag in {"1", "true", "yes", "on"} else 0
|
||||
|
||||
# Maintain locks to bias future picks and enforcement
|
||||
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
|
||||
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:
|
||||
try:
|
||||
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())
|
||||
refresh = (
|
||||
'<div hx-get="/build/alternatives?name='
|
||||
+ quote_plus(new_key)
|
||||
+ f'&owned_only={owned_only_int}" hx-trigger="load delay:80ms" '
|
||||
'hx-target="closest .alts" hx-swap="outerHTML" aria-hidden="true"></div>'
|
||||
)
|
||||
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>'
|
||||
+ refresh +
|
||||
'</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)
|
||||
except Exception:
|
||||
# Fall back to rerun guidance if inline swap fails
|
||||
pass
|
||||
# Fallback: advise rerun
|
||||
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>'
|
||||
'<div class="muted" style="margin-top:.35rem;">Now click <em>Rerun Stage</em> with Replace: On to apply this change.</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>'
|
||||
'<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>'
|
||||
'<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>'
|
||||
)
|
||||
# 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
|
||||
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": []})
|
||||
|
||||
|
||||
@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)
|
||||
except Exception:
|
||||
comp = None
|
||||
try:
|
||||
if comp:
|
||||
from ..services import orchestrator as orch
|
||||
comp = orch._attach_enforcement_plan(b, comp)
|
||||
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:
|
||||
status = str(cat.get('status') or '').upper()
|
||||
# Only surface tiles for WARN and FAIL
|
||||
if status not in {"WARN", "FAIL"}:
|
||||
continue
|
||||
# For two-card combos, split pairs into individual cards and skip commander
|
||||
if key == 'two_card_combos' and status == 'FAIL':
|
||||
# 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),
|
||||
'severity': status,
|
||||
})
|
||||
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),
|
||||
'severity': status,
|
||||
})
|
||||
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")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
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()
|
||||
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')
|
||||
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')
|
||||
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")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
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(request, "build/_step5.html", page_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
|
||||
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)
|
||||
except Exception:
|
||||
comp = None
|
||||
try:
|
||||
if comp:
|
||||
from ..services import orchestrator as orch
|
||||
comp = orch._attach_enforcement_plan(b, comp)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
summary_token = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
summary_token = 0
|
||||
ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
|
||||
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||
return resp
|
||||
1262
code/web/routes/build_newflow.py
Normal file
1262
code/web/routes/build_newflow.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,6 @@ from fastapi.responses import JSONResponse
|
|||
|
||||
from ..app import (
|
||||
ENABLE_PARTNER_MECHANICS,
|
||||
ENABLE_PARTNER_SUGGESTIONS,
|
||||
)
|
||||
from ..services.telemetry import log_partner_suggestion_selected
|
||||
from ..services.partner_suggestions import get_partner_suggestions
|
||||
|
|
@ -408,7 +407,7 @@ def _partner_ui_context(
|
|||
role_hint = "Choose a Doctor to accompany this companion."
|
||||
|
||||
# Partner suggestions
|
||||
suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS)
|
||||
suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS)
|
||||
suggestions_visible: list[dict[str, Any]] = []
|
||||
suggestions_hidden: list[dict[str, Any]] = []
|
||||
suggestions_total = 0
|
||||
|
|
|
|||
257
code/web/routes/build_permalinks.py
Normal file
257
code/web/routes/build_permalinks.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""Build Permalinks and Lock Management Routes
|
||||
|
||||
Phase 5 extraction from build.py:
|
||||
- POST /build/lock - Card lock toggle with HTMX swap
|
||||
- GET /build/permalink - State serialization (base64 JSON)
|
||||
- GET /build/from - State restoration from permalink
|
||||
|
||||
This module handles build state persistence and card lock management.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from typing import Any
|
||||
import json
|
||||
import gzip
|
||||
from ..app import ALLOW_MUST_HAVES, templates
|
||||
from ..services.tasks import get_session, new_sid
|
||||
from ..services import orchestrator as orch
|
||||
from html import escape as _esc
|
||||
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
|
||||
if not payload or response is None:
|
||||
return
|
||||
try:
|
||||
existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
|
||||
except Exception:
|
||||
existing = None
|
||||
try:
|
||||
if existing:
|
||||
try:
|
||||
data = json.loads(existing)
|
||||
except Exception:
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
data.update(payload)
|
||||
response.headers["HX-Trigger"] = json.dumps(data)
|
||||
return
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
try:
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/lock")
|
||||
async def build_lock(request: Request, name: str = Form(...), locked: int = Form(...), from_list: str = Form(None)) -> HTMLResponse:
|
||||
"""Toggle card lock for a given card name (HTMX-based).
|
||||
|
||||
Maintains an in-session locks set and reflects changes in the build context.
|
||||
Returns an updated HTML button with HTMX attributes for easy swapping.
|
||||
"""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
name_l = str(name).strip().lower()
|
||||
locks = set(sess.get("locks", []))
|
||||
is_locked = bool(int(locked or 0))
|
||||
if is_locked:
|
||||
locks.add(name_l)
|
||||
else:
|
||||
locks.discard(name_l)
|
||||
sess["locks"] = list(locks)
|
||||
# Update build context if it exists
|
||||
try:
|
||||
ctx = sess.get("build_ctx") or {}
|
||||
if ctx and isinstance(ctx, dict):
|
||||
ctx["locks"] = {str(x) for x in locks}
|
||||
except Exception:
|
||||
pass
|
||||
# Build lock button HTML
|
||||
if is_locked:
|
||||
label = "🔒"
|
||||
title = f"Unlock {name}"
|
||||
next_state = 0
|
||||
else:
|
||||
label = "🔓"
|
||||
title = f"Lock {name}"
|
||||
next_state = 1
|
||||
html = (
|
||||
f'<button class="btn btn-lock" type="button" title="{_esc(title)}" '
|
||||
f'hx-post="/build/lock" hx-target="this" hx-swap="outerHTML" '
|
||||
f'hx-vals=\'{{"name":"{_esc(name)}","locked":{next_state}}}\'>{label}</button>'
|
||||
)
|
||||
# OOB chip and lock count update
|
||||
lock_count = len(locks)
|
||||
chip = (
|
||||
f'<div id="locks-chip" hx-swap-oob="true">'
|
||||
f'<span class="chip">🔒 {lock_count}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
# If coming from locked-cards list, remove the row on unlock
|
||||
if from_list and not is_locked:
|
||||
# Return empty content to remove the <li> parent of the button
|
||||
html = ""
|
||||
return HTMLResponse(html + chip)
|
||||
|
||||
|
||||
@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: dict[str, Any] = {
|
||||
"commander": sess.get("commander"),
|
||||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"ideals": sess.get("ideals"),
|
||||
"locks": list(sess.get("locks", []) or []),
|
||||
"tag_mode": sess.get("tag_mode", "AND"),
|
||||
"flags": {
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
}
|
||||
# Include random build fields if present
|
||||
try:
|
||||
rb = sess.get("random_build")
|
||||
if isinstance(rb, dict) and rb:
|
||||
random_payload: dict[str, Any] = {}
|
||||
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
|
||||
if rb.get(key) is not None:
|
||||
random_payload[key] = rb.get(key)
|
||||
if isinstance(rb.get("resolved_themes"), list):
|
||||
random_payload["resolved_themes"] = list(rb.get("resolved_themes") or [])
|
||||
if isinstance(rb.get("resolved_theme_info"), dict):
|
||||
random_payload["resolved_theme_info"] = dict(rb.get("resolved_theme_info"))
|
||||
if rb.get("combo_fallback") is not None:
|
||||
random_payload["combo_fallback"] = bool(rb.get("combo_fallback"))
|
||||
if rb.get("synergy_fallback") is not None:
|
||||
random_payload["synergy_fallback"] = bool(rb.get("synergy_fallback"))
|
||||
if rb.get("fallback_reason") is not None:
|
||||
random_payload["fallback_reason"] = rb.get("fallback_reason")
|
||||
if isinstance(rb.get("requested_themes"), dict):
|
||||
requested_payload = dict(rb.get("requested_themes"))
|
||||
if "auto_fill_enabled" in requested_payload:
|
||||
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
|
||||
random_payload["requested_themes"] = requested_payload
|
||||
if rb.get("auto_fill_enabled") is not None:
|
||||
random_payload["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled"))
|
||||
if rb.get("auto_fill_applied") is not None:
|
||||
random_payload["auto_fill_applied"] = bool(rb.get("auto_fill_applied"))
|
||||
auto_filled = rb.get("auto_filled_themes")
|
||||
if isinstance(auto_filled, list):
|
||||
random_payload["auto_filled_themes"] = list(auto_filled)
|
||||
display = rb.get("display_themes")
|
||||
if isinstance(display, list):
|
||||
random_payload["display_themes"] = list(display)
|
||||
if random_payload:
|
||||
payload["random"] = random_payload
|
||||
except Exception:
|
||||
pass
|
||||
# Include exclude_cards if feature is enabled and present
|
||||
if ALLOW_MUST_HAVES and sess.get("exclude_cards"):
|
||||
payload["exclude_cards"] = sess.get("exclude_cards")
|
||||
# Compress and base64 encode the JSON payload for shorter URLs
|
||||
try:
|
||||
import base64
|
||||
raw = json.dumps(payload, separators=(',', ':')).encode("utf-8")
|
||||
# Use gzip compression to significantly reduce permalink length
|
||||
compressed = gzip.compress(raw, compresslevel=9)
|
||||
token = base64.urlsafe_b64encode(compressed).decode("ascii").rstrip("=")
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Failed to generate permalink"}, status_code=500)
|
||||
link = f"/build/from?state={token}"
|
||||
return JSONResponse({
|
||||
"permalink": link,
|
||||
"state": payload,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/from")
|
||||
async def build_from(request: Request, state: str | None = None) -> RedirectResponse:
|
||||
"""Load a run from a permalink token and redirect to main build page."""
|
||||
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)
|
||||
compressed = base64.urlsafe_b64decode((state + pad).encode("ascii"))
|
||||
# Decompress the state data
|
||||
raw = gzip.decompress(compressed).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["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics"))
|
||||
sess["locks"] = list(data.get("locks", []))
|
||||
# Optional random build rehydration
|
||||
try:
|
||||
r = data.get("random") or {}
|
||||
if r:
|
||||
rb_payload: dict[str, Any] = {}
|
||||
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
|
||||
if r.get(key) is not None:
|
||||
rb_payload[key] = r.get(key)
|
||||
if isinstance(r.get("resolved_themes"), list):
|
||||
rb_payload["resolved_themes"] = list(r.get("resolved_themes") or [])
|
||||
if isinstance(r.get("resolved_theme_info"), dict):
|
||||
rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info"))
|
||||
if r.get("combo_fallback") is not None:
|
||||
rb_payload["combo_fallback"] = bool(r.get("combo_fallback"))
|
||||
if r.get("synergy_fallback") is not None:
|
||||
rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback"))
|
||||
if r.get("fallback_reason") is not None:
|
||||
rb_payload["fallback_reason"] = r.get("fallback_reason")
|
||||
if isinstance(r.get("requested_themes"), dict):
|
||||
requested_payload = dict(r.get("requested_themes"))
|
||||
if "auto_fill_enabled" in requested_payload:
|
||||
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
|
||||
rb_payload["requested_themes"] = requested_payload
|
||||
if r.get("auto_fill_enabled") is not None:
|
||||
rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled"))
|
||||
if r.get("auto_fill_applied") is not None:
|
||||
rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied"))
|
||||
auto_filled = r.get("auto_filled_themes")
|
||||
if isinstance(auto_filled, list):
|
||||
rb_payload["auto_filled_themes"] = list(auto_filled)
|
||||
display = r.get("display_themes")
|
||||
if isinstance(display, list):
|
||||
rb_payload["display_themes"] = list(display)
|
||||
if "seed" in rb_payload:
|
||||
try:
|
||||
seed_int = int(rb_payload["seed"])
|
||||
rb_payload["seed"] = seed_int
|
||||
rb_payload.setdefault("recent_seeds", [seed_int])
|
||||
except Exception:
|
||||
rb_payload.setdefault("recent_seeds", [])
|
||||
sess["random_build"] = rb_payload
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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")
|
||||
|
||||
sess["last_step"] = 4
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Redirect to main build page which will render the proper layout
|
||||
resp = RedirectResponse(url="/build/", status_code=303)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
1462
code/web/routes/build_wizard.py
Normal file
1462
code/web/routes/build_wizard.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
|||
|
||||
from deck_builder.combined_commander import PartnerMode
|
||||
|
||||
from ..app import ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS
|
||||
from ..app import ENABLE_PARTNER_MECHANICS
|
||||
from ..services.partner_suggestions import get_partner_suggestions
|
||||
from ..services.telemetry import log_partner_suggestions_generated
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ async def partner_suggestions_api(
|
|||
mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"),
|
||||
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
|
||||
):
|
||||
if not (ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS):
|
||||
if not ENABLE_PARTNER_MECHANICS:
|
||||
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
|
||||
|
||||
commander_name = (commander or "").strip()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue