mtg_python_deckbuilder/code/deck_builder/brackets_compliance.py

257 lines
9.8 KiB
Python
Raw Normal View History

from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import json
import yaml
from deck_builder.combos import detect_combos
from .phases.phase0_core import BRACKET_DEFINITIONS
from type_definitions import ComplianceReport, CategoryFinding
POLICY_TAGS = {
"game_changers": "Bracket:GameChanger",
"extra_turns": "Bracket:ExtraTurn",
"mass_land_denial": "Bracket:MassLandDenial",
"tutors_nonland": "Bracket:TutorNonland",
}
# Local policy file mapping (mirrors tagging.bracket_policy_applier)
POLICY_FILES: Dict[str, str] = {
"game_changers": "config/card_lists/game_changers.json",
"extra_turns": "config/card_lists/extra_turns.json",
"mass_land_denial": "config/card_lists/mass_land_denial.json",
"tutors_nonland": "config/card_lists/tutors_nonland.json",
}
def _load_json_cards(path: str | Path) -> Tuple[List[str], Optional[str]]:
p = Path(path)
if not p.exists():
return [], None
try:
data = json.loads(p.read_text(encoding="utf-8"))
cards = [str(x).strip() for x in data.get("cards", []) if str(x).strip()]
version = str(data.get("list_version")) if data.get("list_version") else None
return cards, version
except Exception:
return [], None
def _load_brackets_yaml(path: str | Path = "config/brackets.yml") -> Dict[str, dict]:
p = Path(path)
if not p.exists():
return {}
try:
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
except Exception:
return {}
def _find_bracket_def(bracket_key: str) -> Tuple[str, int, Dict[str, Optional[int]]]:
key = (bracket_key or "core").strip().lower()
# Prefer YAML if available
y = _load_brackets_yaml()
if key in y:
meta = y[key]
name = str(meta.get("name", key.title()))
level = int(meta.get("level", 2))
limits = dict(meta.get("limits", {}))
return name, level, limits
# Fallback to in-code defaults
for bd in BRACKET_DEFINITIONS:
if bd.name.strip().lower() == key or str(bd.level) == key:
return bd.name, bd.level, dict(bd.limits)
# map common aliases
alias = bd.name.strip().lower()
if key in (alias, {1:"exhibition",2:"core",3:"upgraded",4:"optimized",5:"cedh"}.get(bd.level, "")):
return bd.name, bd.level, dict(bd.limits)
# Default to Core
core = next(b for b in BRACKET_DEFINITIONS if b.level == 2)
return core.name, core.level, dict(core.limits)
def _collect_tag_counts(card_library: Dict[str, Dict]) -> Tuple[Dict[str, int], Dict[str, List[str]]]:
counts: Dict[str, int] = {v: 0 for v in POLICY_TAGS.values()}
flagged_names: Dict[str, List[str]] = {k: [] for k in POLICY_TAGS.keys()}
for name, info in (card_library or {}).items():
tags = [t for t in (info.get("Tags") or []) if isinstance(t, str)]
for key, tag in POLICY_TAGS.items():
if tag in tags:
counts[tag] += 1
flagged_names[key].append(name)
return counts, flagged_names
def _canonicalize(name: str | None) -> str:
"""Match normalization similar to the tag applier.
- casefold
- normalize curly apostrophes to straight
- strip A- prefix (Arena/Alchemy variants)
- trim
"""
if not name:
return ""
s = str(name).strip().replace("\u2019", "'")
if s.startswith("A-") and len(s) > 2:
s = s[2:]
return s.casefold()
def _status_for(count: int, limit: Optional[int], warn: Optional[int] = None) -> str:
# Unlimited hard limit -> always PASS (no WARN semantics without a cap)
if limit is None:
return "PASS"
if count > int(limit):
return "FAIL"
# Soft guidance: if warn threshold provided and met, surface WARN
try:
if warn is not None and int(warn) > 0 and count >= int(warn):
return "WARN"
except Exception:
pass
return "PASS"
def evaluate_deck(
deck_cards: Dict[str, Dict],
commander_name: Optional[str],
bracket: str,
enforcement: str = "validate",
combos_path: str | Path = "config/card_lists/combos.json",
) -> ComplianceReport:
name, level, limits = _find_bracket_def(bracket)
counts_by_tag, names_by_key = _collect_tag_counts(deck_cards)
categories: Dict[str, CategoryFinding] = {}
messages: List[str] = []
# Prepare a canonicalized deck name map to support list-based matching
deck_canon_to_display: Dict[str, str] = {}
for n in (deck_cards or {}).keys():
cn = _canonicalize(n)
if cn and cn not in deck_canon_to_display:
deck_canon_to_display[cn] = n
# Map categories by combining tag-based counts with direct list matches by name
for key, tag in POLICY_TAGS.items():
# Start with any names found via tags
flagged_set: set[str] = set()
for nm in names_by_key.get(key, []) or []:
ckey = _canonicalize(nm)
if ckey:
flagged_set.add(ckey)
# Merge in list-based matches (by canonicalized name)
try:
file_path = POLICY_FILES.get(key)
if file_path:
names_list, _ver = _load_json_cards(file_path)
# Fallback for game_changers when file is empty: use in-code constants
if key == 'game_changers' and not names_list:
try:
from deck_builder import builder_constants as _bc
names_list = list(getattr(_bc, 'GAME_CHANGERS', []) or [])
except Exception:
names_list = []
listed = {_canonicalize(x) for x in names_list}
present = set(deck_canon_to_display.keys())
flagged_set |= (listed & present)
except Exception:
pass
# Build final flagged display names from the canonical set
flagged_names_disp = sorted({deck_canon_to_display.get(cn, cn) for cn in flagged_set})
c = len(flagged_set)
lim = limits.get(key)
# Optional warn thresholds live alongside limits as "<key>_warn"
try:
warn_key = f"{key}_warn"
warn_val = limits.get(warn_key)
except Exception:
warn_val = None
status = _status_for(c, lim, warn=warn_val)
cat: CategoryFinding = {
"count": c,
"limit": lim,
"flagged": flagged_names_disp,
"status": status,
"notes": [],
}
categories[key] = cat
if status == "FAIL":
messages.append(f"{key.replace('_',' ').title()}: {c} exceeds limit {lim}")
elif status == "WARN":
try:
if warn_val is not None:
messages.append(f"{key.replace('_',' ').title()}: {c} present (discouraged for this bracket)")
except Exception:
pass
# Conservative fallback: for low brackets (levels 12), tutors/extra-turns should WARN when present
# even if a warn threshold was not provided in YAML.
if status == "PASS" and level in (1, 2) and key in ("tutors_nonland", "extra_turns"):
try:
if (warn_val is None) and (lim is not None) and c > 0 and c <= int(lim):
categories[key]["status"] = "WARN"
messages.append(f"{key.replace('_',' ').title()}: {c} present (discouraged for this bracket)")
except Exception:
pass
# Two-card combos detection
combos = detect_combos(deck_cards.keys(), combos_path=combos_path)
cheap_early_pairs = [p for p in combos if p.cheap_early]
c_limit = limits.get("two_card_combos")
combos_status = _status_for(len(cheap_early_pairs), c_limit, warn=None)
categories["two_card_combos"] = {
"count": len(cheap_early_pairs),
"limit": c_limit,
"flagged": [f"{p.a} + {p.b}" for p in cheap_early_pairs],
"status": combos_status,
"notes": ["Only counting cheap/early combos per policy"],
}
if combos_status == "FAIL":
messages.append("Two-card combos present beyond allowed bracket")
commander_flagged = False
if commander_name:
gch_cards, _ = _load_json_cards("config/card_lists/game_changers.json")
if any(commander_name.strip().lower() == x.lower() for x in gch_cards):
commander_flagged = True
# Exhibition/Core treat this as automatic fail; Upgraded counts toward limit
if level in (1, 2):
messages.append("Commander is on Game Changers list (not allowed for this bracket)")
categories["game_changers"]["status"] = "FAIL"
categories["game_changers"]["flagged"].append(commander_name)
# Build list_versions metadata
_, extra_ver = _load_json_cards("config/card_lists/extra_turns.json")
_, mld_ver = _load_json_cards("config/card_lists/mass_land_denial.json")
_, tutor_ver = _load_json_cards("config/card_lists/tutors_nonland.json")
_, gch_ver = _load_json_cards("config/card_lists/game_changers.json")
list_versions = {
"extra_turns": extra_ver,
"mass_land_denial": mld_ver,
"tutors_nonland": tutor_ver,
"game_changers": gch_ver,
}
# Overall verdict
overall = "PASS"
if any(cat.get("status") == "FAIL" for cat in categories.values()):
overall = "FAIL"
elif any(cat.get("status") == "WARN" for cat in categories.values()):
overall = "WARN"
report: ComplianceReport = {
"bracket": name.lower(),
"level": level,
"enforcement": enforcement,
"overall": overall,
"commander_flagged": commander_flagged,
"categories": categories,
"combos": [{"a": p.a, "b": p.b, "cheap_early": p.cheap_early, "setup_dependent": p.setup_dependent} for p in combos],
"list_versions": list_versions,
"messages": messages,
}
return report