153 lines
5.7 KiB
Python
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())
|