mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
226 lines
8.4 KiB
Python
226 lines
8.4 KiB
Python
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
|