mtg/tools/bracket_check.py
2026-09-12 00:46:38 -04:00

153 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Check a decklist against Commander Bracket 3 + our house rules.
python3 tools/bracket_check.py --deck decks/reaper-king-no-more
python3 tools/bracket_check.py --deck decks/reaper-king-no-more decks/reaper-king-no-more/decks/no-mikaeus/decklist.mtg
Enforces:
* <= 3 Game Changers (official)
* no mass land denial (official)
* extra turns sparse, never looping (official)
* NO two-card-in-a-vacuum infinite combos (our house rule, stricter)
* singleton + 100 cards (format)
"""
import os, sys, json
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cards import DB, load_decklist
from deckctx import DeckContext
MLD_TAGS = {"mass-land-denial"} # verified: the real Tagger slug, 114 cards
EXTRA_TURN = "extra-turn"
LIMIT_GC = 3
def vacuum_combos(db, deck_names, commander):
"""Two-card infinite combos among our cards that need NO extra board state.
Spellbook gives prerequisites per combo; a combo requiring creatures on the
battlefield or a third enabler is a build-around, not a vacuum combo.
"""
_, idx = db.combos
pool = set(deck_names) | {commander}
seen, flagged, buildarounds = set(), [], []
for name in pool:
for combo in idx.get(name, []):
if combo["id"] in seen:
continue
uses = [u["card"]["name"] for u in combo["uses"]]
if len(uses) != 2 or not set(uses) <= pool:
continue
seen.add(combo["id"])
prereq = (combo.get("notablePrerequisites") or "").strip()
infinite = any("Infinite" in p["feature"]["name"] or "wins the game" in p["feature"]["name"]
or "loses the game" in p["feature"]["name"] for p in combo["produces"])
if not infinite:
continue
(flagged if not prereq else buildarounds).append((uses, prereq, combo))
return flagged, buildarounds
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
ctx = DeckContext.from_args(argv)
# any remaining positional arg overrides which decklist file to check,
# while --deck still selects the deck (config, identity, blacklist)
path = next((a for a in argv if not a.startswith("-")), None) or ctx.decklist_path
db = DB(blacklist_path=ctx.blacklist_path)
commander, main_deck = load_decklist(path)
if not commander:
sys.exit("no commander found -- decklist needs a '# Commander' section")
print(f"COMMANDER: {commander}")
print(f"DECK SIZE: {len(main_deck)} + 1 commander = {len(main_deck)+1}\n")
ok = True
# --- unknown cards ---
unknown = [n for n in [commander] + main_deck if not db.get(n)]
if unknown:
ok = False
print("!! UNKNOWN CARDS (typo or not in bulk data):")
for n in unknown: print(f" {n}")
print()
known = [db[n] for n in main_deck if db.get(n)]
cmd = db.get(commander)
# --- size + singleton ---
if len(main_deck) + 1 != 100:
ok = False
print(f"!! SIZE: {len(main_deck)+1} cards, need exactly 100\n")
dupes = {n: c for n, c in Counter(main_deck).items() if c > 1
and db.get(n) and "Basic" not in db[n].type_line}
if dupes:
ok = False
print("!! SINGLETON violated:", dupes, "\n")
# --- color identity ---
# Fall back to the configured deck's own commander identity (never a
# hardcoded color combination) if this decklist's own commander card
# wasn't found in the database.
ci = cmd.color_identity if cmd else ctx.identity(db)
off = [c.name for c in known if not c.color_identity <= ci]
if off:
ok = False
print(f"!! OFF-IDENTITY (commander is {''.join(sorted(ci))}):")
for n in off: print(f" {n}")
print()
# --- blacklist (preference, not a bracket rule) ---
bl = [c for c in known if c.name in db.blacklist] + \
([cmd] if cmd and cmd.name in db.blacklist else [])
if bl:
ok = False
print(f"!! BLACKLISTED CARDS IN DECK: {len(bl)} (personal preference, not a bracket rule)")
for c in bl:
print(f" - {c.name} -- {db.blacklist[c.name]}")
print()
# --- game changers ---
gc = [c for c in known if c.game_changer] + ([cmd] if cmd and cmd.game_changer else [])
status = "OK" if len(gc) <= LIMIT_GC else "!! OVER LIMIT"
print(f"GAME CHANGERS: {len(gc)}/{LIMIT_GC} {status}")
for c in gc: print(f" - {c.name}")
if len(gc) > LIMIT_GC: ok = False
print()
# --- mass land denial ---
mld = [c for c in known if c.tags & MLD_TAGS]
print(f"MASS LAND DENIAL: {len(mld)} {'OK' if not mld else '!! NOT ALLOWED IN B3'}")
for c in mld: print(f" - {c.name}")
if mld: ok = False
print()
# --- extra turns ---
et = [c for c in known if EXTRA_TURN in c.tags]
print(f"EXTRA TURNS: {len(et)} {'OK' if len(et) <= 1 else '?? keep sparse, never loop'}")
for c in et: print(f" - {c.name}")
print()
# --- combos ---
vac, build = vacuum_combos(db, main_deck, commander)
print(f"TWO-CARD INFINITE COMBOS IN A VACUUM: {len(vac)} {'OK' if not vac else '!! HOUSE RULE VIOLATION'}")
for uses, _, _ in vac:
print(f" - {' + '.join(uses)}")
if vac: ok = False
print()
if build:
print(f"two-card combos needing board state (ALLOWED, {len(build)}):")
for uses, prereq, _ in build:
print(f" - {' + '.join(uses)}")
for line in prereq.splitlines():
print(f" requires: {line}")
print()
print("=" * 58)
print("BRACKET 3: PASS" if ok else "BRACKET 3: FAIL -- see !! above")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())