mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
Bracket enforcement + inline gating; global pool prune; compliance JSON artifacts; UI combos gating; compose envs consolidated; fix YAML; bump version to 2.2.5
This commit is contained in:
parent
42c8fc9f9e
commit
4e03997923
32 changed files with 2819 additions and 125 deletions
448
code/deck_builder/enforcement.py
Normal file
448
code/deck_builder/enforcement.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue