mtg_python_deckbuilder/code/deck_builder/brackets_compliance.py

256 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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