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
226
code/deck_builder/brackets_compliance.py
Normal file
226
code/deck_builder/brackets_compliance.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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]) -> str:
|
||||
if limit is None:
|
||||
return "PASS"
|
||||
return "PASS" if count <= int(limit) else "FAIL"
|
||||
|
||||
|
||||
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)
|
||||
status = _status_for(c, lim)
|
||||
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}")
|
||||
|
||||
# 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)
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue