"""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 = '
Start the build to see alternatives.
' 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'
No alternatives: {e}
')