from __future__ import annotations from typing import Dict, List, Optional, Tuple, Set from pathlib import Path import json # Lightweight, internal utilities to avoid circular imports from .brackets_compliance import evaluate_deck, POLICY_FILES def _load_list_cards(paths: List[str]) -> Set[str]: out: Set[str] = set() for p in paths: try: data = json.loads(Path(p).read_text(encoding="utf-8")) for n in (data.get("cards") or []): if isinstance(n, str) and n.strip(): out.add(n.strip()) except Exception: continue return out def _candidate_pool_for_role(builder, role: str) -> List[Tuple[str, dict]]: """Return a prioritized list of (name, rowdict) candidates for a replacement of a given role. This consults the current combined card pool, filters out lands and already-chosen names, and applies a role->tag mapping to find suitable replacements. """ df = getattr(builder, "_combined_cards_df", None) if df is None or getattr(df, "empty", True): return [] if "name" not in df.columns: return [] # Normalize tag list per row def _norm_tags(x): return [str(t).lower() for t in x] if isinstance(x, list) else [] work = df.copy() work["_ltags"] = work.get("themeTags", []).apply(_norm_tags) # Role to tag predicates def _is_protection(tags: List[str]) -> bool: return any("protection" 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 _is_removal(tags: List[str]) -> bool: return any(("removal" in t) or ("spot removal" in t) for t in tags) and not any(("board wipe" in t) or ("mass removal" in t) for t in tags) def _is_wipe(tags: List[str]) -> bool: return any(("board wipe" in t) or ("mass removal" in t) for t in tags) # Theme fallback: anything that matches selected tags (primary/secondary/tertiary) sel_tags = [str(getattr(builder, k, "") or "").strip().lower() for k in ("primary_tag", "secondary_tag", "tertiary_tag")] sel_tags = [t for t in sel_tags if t] def _matches_theme(tags: List[str]) -> bool: if not sel_tags: return False for t in tags: for st in sel_tags: if st in t: return True return False pred = None r = str(role or "").strip().lower() if r == "protection": pred = _is_protection elif r == "card_advantage": pred = _is_draw elif r == "removal": pred = _is_removal elif r in ("wipe", "board_wipe", "wipes"): pred = _is_wipe else: pred = _matches_theme pool = work[~work["type"].fillna("").str.contains("Land", case=False, na=False)] if pred is _matches_theme: pool = pool[pool["_ltags"].apply(_matches_theme)] else: pool = pool[pool["_ltags"].apply(pred)] # Exclude names already in the library already_lower = {str(n).lower() for n in getattr(builder, "card_library", {}).keys()} pool = pool[~pool["name"].astype(str).str.lower().isin(already_lower)] # Sort by edhrecRank then manaValue try: from . import builder_utils as bu sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # type: ignore[attr-defined] # Prefer-owned bias if getattr(builder, "prefer_owned", False): owned = getattr(builder, "owned_card_names", None) if owned: sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) # type: ignore[attr-defined] except Exception: sorted_df = pool out: List[Tuple[str, dict]] = [] for _, r in sorted_df.iterrows(): nm = str(r.get("name")) if not nm: continue out.append((nm, r.to_dict())) return out def _remove_card(builder, name: str) -> bool: entry = getattr(builder, "card_library", {}).get(name) if not entry: return False # Protect commander and locks if bool(entry.get("Commander")): return False if str(entry.get("AddedBy", "")).strip().lower() == "lock": return False try: del builder.card_library[name] return True except Exception: return False def _try_add_replacement(builder, target_role: Optional[str], forbidden: Set[str]) -> Optional[str]: """Attempt to add one replacement card for the given role, avoiding forbidden names. Returns the name added, or None if no suitable candidate was found/added. """ role = (target_role or "").strip().lower() tried_roles = [role] if role else [] if role not in ("protection", "card_advantage", "removal", "wipe", "board_wipe", "wipes"): tried_roles.append("card_advantage") tried_roles.append("protection") tried_roles.append("removal") for r in tried_roles or ["card_advantage"]: candidates = _candidate_pool_for_role(builder, r) for nm, row in candidates: if nm in forbidden: continue # Enforce owned-only and color identity legality via builder.add_card (it will silently skip if illegal) before = set(getattr(builder, "card_library", {}).keys()) builder.add_card( nm, card_type=str(row.get("type", row.get("type_line", "")) or ""), mana_cost=str(row.get("mana_cost", row.get("manaCost", "")) or ""), role=target_role or ("card_advantage" if r == "card_advantage" else ("protection" if r == "protection" else ("removal" if r == "removal" else "theme_spell"))), added_by="enforcement" ) after = set(getattr(builder, "card_library", {}).keys()) added = list(after - before) if added: return added[0] return None def enforce_bracket_compliance(builder, mode: str = "prompt") -> Dict: """Trim over-limit bracket categories and add role-consistent replacements. mode: 'prompt' for interactive CLI (respects builder.headless); 'auto' for non-interactive. Returns the final compliance report after enforcement (or the original if no changes). """ # Compute initial report bracket_key = str(getattr(builder, 'bracket_name', '') or getattr(builder, 'bracket_level', 'core')).lower() commander = getattr(builder, 'commander_name', None) report = evaluate_deck(getattr(builder, 'card_library', {}), commander_name=commander, bracket=bracket_key) if report.get("overall") != "FAIL": return report # Prepare prohibited set (avoid adding these during replacement) forbidden_lists = list(POLICY_FILES.values()) prohibited: Set[str] = _load_list_cards(forbidden_lists) # Determine offenders per category cats = report.get("categories", {}) or {} to_remove: List[str] = [] # Build a helper to rank offenders: keep better (lower edhrecRank) ones df = getattr(builder, "_combined_cards_df", None) def _score(name: str) -> Tuple[int, float, str]: try: if df is not None and not getattr(df, 'empty', True) and 'name' in df.columns: r = df[df['name'].astype(str) == str(name)] if not r.empty: rank = int(r.iloc[0].get('edhrecRank') or 10**9) mv = float(r.iloc[0].get('manaValue') or r.iloc[0].get('cmc') or 0.0) return (rank, mv, str(name)) except Exception: pass return (10**9, 99.0, str(name)) # Interactive helper interactive = (mode == 'prompt' and not bool(getattr(builder, 'headless', False))) for key, cat in cats.items(): if key not in ("game_changers", "extra_turns", "mass_land_denial", "tutors_nonland"): continue lim = cat.get("limit") cnt = int(cat.get("count", 0) or 0) if lim is None or cnt <= int(lim): continue flagged = [n for n in (cat.get("flagged") or []) if isinstance(n, str)] # Only consider flagged names that are actually in the library now lib = getattr(builder, 'card_library', {}) present = [n for n in flagged if n in lib] if not present: continue # Determine how many need trimming over = cnt - int(lim) # Sort by ascending desirability to keep: worst ranks first for removal present_sorted = sorted(present, key=_score, reverse=True) # worst first if interactive: # Present choices to keep try: out = getattr(builder, 'output_func', print) inp = getattr(builder, 'input_func', input) out(f"\nEnforcement: {key.replace('_',' ').title()} is over the limit ({cnt} > {lim}).") out("Select the indices to KEEP (comma-separated). Press Enter to auto-keep the best:") for i, nm in enumerate(sorted(present, key=_score)): sc = _score(nm) out(f" [{i}] {nm} (edhrecRank={sc[0] if sc[0] < 10**9 else 'n/a'})") raw = str(inp("Keep which? ").strip()) keep_idx: Set[int] = set() if raw: for tok in raw.split(','): tok = tok.strip() if tok.isdigit(): keep_idx.add(int(tok)) # Compute the names to keep up to the allowed count allowed = max(0, int(lim)) keep_list: List[str] = [] for i, nm in enumerate(sorted(present, key=_score)): if len(keep_list) >= allowed: break if i in keep_idx: keep_list.append(nm) # If still short, fill with best-ranked remaining for nm in sorted(present, key=_score): if len(keep_list) >= allowed: break if nm not in keep_list: keep_list.append(nm) # Remove the others (beyond keep_list) for nm in present: if nm not in keep_list and over > 0: to_remove.append(nm) over -= 1 if over > 0: # If user kept too many, trim worst extras for nm in present_sorted: if over <= 0: break if nm in keep_list: to_remove.append(nm) over -= 1 except Exception: # Fallback to auto behavior to_remove.extend(present_sorted[:over]) else: # Auto: remove the worst-ranked extras first to_remove.extend(present_sorted[:over]) # Execute removals and replacements actually_removed: List[str] = [] actually_added: List[str] = [] swaps: List[dict] = [] # Load preferred replacements mapping (lowercased keys/values) pref_map_lower: Dict[str, str] = {} try: raw = getattr(builder, 'preferred_replacements', {}) or {} for k, v in raw.items(): ks = str(k).strip().lower() vs = str(v).strip().lower() if ks and vs: pref_map_lower[ks] = vs except Exception: pref_map_lower = {} for nm in to_remove: entry = getattr(builder, 'card_library', {}).get(nm) if not entry: continue role = entry.get('Role') or None if _remove_card(builder, nm): actually_removed.append(nm) # First, honor any explicit user-chosen replacement added = None try: want = pref_map_lower.get(str(nm).strip().lower()) if want: # Avoid adding prohibited or duplicates lib_l = {str(x).strip().lower() for x in getattr(builder, 'card_library', {}).keys()} if (want not in prohibited) and (want not in lib_l): df = getattr(builder, '_combined_cards_df', None) target_name = None card_type = '' mana_cost = '' if df is not None and not getattr(df, 'empty', True) and 'name' in df.columns: r = df[df['name'].astype(str).str.lower() == want] if not r.empty: target_name = str(r.iloc[0]['name']) card_type = str(r.iloc[0].get('type', r.iloc[0].get('type_line', '')) or '') mana_cost = str(r.iloc[0].get('mana_cost', r.iloc[0].get('manaCost', '')) or '') # If we couldn't resolve row, still try to add by name target = target_name or want before = set(getattr(builder, 'card_library', {}).keys()) builder.add_card(target, card_type=card_type, mana_cost=mana_cost, role=role, added_by='enforcement') after = set(getattr(builder, 'card_library', {}).keys()) delta = list(after - before) if delta: added = delta[0] except Exception: added = None # If no explicit or failed, try to add an automatic role-consistent replacement if not added: added = _try_add_replacement(builder, role, prohibited) if added: actually_added.append(added) swaps.append({"removed": nm, "added": added, "role": role}) else: swaps.append({"removed": nm, "added": None, "role": role}) # Recompute report after initial category-based changes final_report = evaluate_deck(getattr(builder, 'card_library', {}), commander_name=commander, bracket=bracket_key) # --- Second pass: break cheap/early two-card combos if still over the limit --- try: cats2 = final_report.get("categories", {}) or {} two = cats2.get("two_card_combos") or {} curr = int(two.get("count", 0) or 0) lim = two.get("limit") if lim is not None and curr > int(lim): # Build present cheap/early pairs from the report pairs: List[Tuple[str, str]] = [] for p in (final_report.get("combos") or []): try: if not p.get("cheap_early"): continue a = str(p.get("a") or "").strip() b = str(p.get("b") or "").strip() if not a or not b: continue # Only consider if both still present lib = getattr(builder, 'card_library', {}) or {} if a in lib and b in lib: pairs.append((a, b)) except Exception: continue # Helper to recompute count and frequencies from current pairs def _freq(ps: List[Tuple[str, str]]) -> Dict[str, int]: mp: Dict[str, int] = {} for (a, b) in ps: mp[a] = mp.get(a, 0) + 1 mp[b] = mp.get(b, 0) + 1 return mp current_pairs = list(pairs) blocked: Set[str] = set() # Keep removing until combos count <= limit or no progress possible while len(current_pairs) > int(lim): freq = _freq(current_pairs) if not freq: break # Rank candidates: break the most combos first; break ties by worst desirability cand_names = list(freq.keys()) cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) # type: ignore[arg-type] removed_any = False for nm in cand_names: if nm in blocked: continue entry = getattr(builder, 'card_library', {}).get(nm) role = entry.get('Role') if isinstance(entry, dict) else None # Try to remove; protects commander/locks inside helper if _remove_card(builder, nm): actually_removed.append(nm) # Preferred replacement first added = None try: want = pref_map_lower.get(str(nm).strip().lower()) if want: lib_l = {str(x).strip().lower() for x in getattr(builder, 'card_library', {}).keys()} if (want not in prohibited) and (want not in lib_l): df2 = getattr(builder, '_combined_cards_df', None) target_name = None card_type = '' mana_cost = '' if df2 is not None and not getattr(df2, 'empty', True) and 'name' in df2.columns: r = df2[df2['name'].astype(str).str.lower() == want] if not r.empty: target_name = str(r.iloc[0]['name']) card_type = str(r.iloc[0].get('type', r.iloc[0].get('type_line', '')) or '') mana_cost = str(r.iloc[0].get('mana_cost', r.iloc[0].get('manaCost', '')) or '') target = target_name or want before = set(getattr(builder, 'card_library', {}).keys()) builder.add_card(target, card_type=card_type, mana_cost=mana_cost, role=role, added_by='enforcement') after = set(getattr(builder, 'card_library', {}).keys()) delta = list(after - before) if delta: added = delta[0] except Exception: added = None if not added: added = _try_add_replacement(builder, role, prohibited) if added: actually_added.append(added) swaps.append({"removed": nm, "added": added, "role": role}) else: swaps.append({"removed": nm, "added": None, "role": role}) # Update pairs by removing any that contain nm current_pairs = [(a, b) for (a, b) in current_pairs if (a != nm and b != nm)] removed_any = True break else: blocked.add(nm) if not removed_any: # Cannot break further due to locks/commander; stop to avoid infinite loop break # Recompute report after combo-breaking final_report = evaluate_deck(getattr(builder, 'card_library', {}), commander_name=commander, bracket=bracket_key) except Exception: # If combo-breaking fails for any reason, fall back to the current report pass # Attach enforcement actions for downstream consumers try: final_report.setdefault('enforcement', {}) final_report['enforcement']['removed'] = list(actually_removed) final_report['enforcement']['added'] = list(actually_added) final_report['enforcement']['swaps'] = list(swaps) except Exception: pass # Log concise summary if possible try: out = getattr(builder, 'output_func', print) if actually_removed or actually_added: out("\nEnforcement applied:") if actually_removed: out("Removed:") for x in actually_removed: out(f" - {x}") if actually_added: out("Added:") for x in actually_added: out(f" + {x}") out(f"Compliance after enforcement: {final_report.get('overall')}") except Exception: pass return final_report