mtg/tools/make_variants.py
2026-09-12 00:41:47 -04:00

102 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""Generate every deck variant from one declarative spec.
Each variant is a hypothesis: change a few cards, see what it does to legality,
combo density, and Forge win rate. Keeping the spec here (rather than as hand-edited
decklists) means a change to the baseline propagates to every variant.
python3 tools/make_variants.py --deck decks/reaper-king-no-more
python3 tools/make_variants.py --deck decks/reaper-king-no-more --list
The hand-written variant spec (VARIANTS below) is this deck's own hand-curated
cut/add lists -- distinct from tools/generate.py's role-budget recipes.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from deckctx import DeckContext
def load_variants(ctx):
"""{name: (why, cuts, adds)} from this deck's variants.yaml `handwritten:` section."""
vfile = ctx.path(ctx.config.get("variants_file", "variants.yaml"))
if not os.path.exists(vfile):
return {}
try:
import yaml
except ImportError:
sys.exit("pyyaml required to read variants.yaml")
spec = yaml.safe_load(open(vfile)) or {}
out = {}
for name, v in (spec.get("handwritten") or {}).items():
out[name] = (v.get("why", "").strip(), list(v.get("cut") or []), list(v.get("add") or []))
return out
def load(path):
"""Main-deck lines only -- the '# Commander' section is re-added per variant."""
out, section = [], "main"
for line in open(path):
s = line.strip()
if s.startswith("#"):
if not s.startswith("##"):
low = s.lower()
if "commander" in low: section = "commander"
elif "main" in low or "deck" in low: section = "main"
continue
if s and s[0].isdigit() and section == "main":
out.append(s)
return out
def main():
ctx = DeckContext.from_args() # pops --deck/-d out of sys.argv first
from cards import DB
db = DB(blacklist_path=ctx.blacklist_path)
commander = ctx.commander
base_path = ctx.path("decks", "no-mikaeus", "decklist.mgt")
if not os.path.exists(base_path):
base_path = ctx.decklist_path
base = load(base_path)
variants = load_variants(ctx)
for name, (why, cuts, adds) in variants.items():
if "--list" in sys.argv:
print(f"{name:18} -{len(cuts):<2} +{len(adds):<2} {why[:60]}")
continue
lines = base[:]
# cut
missing_cuts = []
for c in cuts:
before = len(lines)
lines = [l for l in lines if l.split(None, 1)[1] != c]
if len(lines) == before:
missing_cuts.append(c)
# add (before the lands block so the file stays readable)
idx = next((i for i, l in enumerate(lines)
if l.split(None, 1)[1] == "Command Tower"), len(lines))
lines[idx:idx] = [f"1 {a}" for a in adds]
# validate
bad = [a for a in adds if not db.get(a)]
present = {l.split(None, 1)[1] for l in lines}
dupes = [a for a in adds if sum(1 for l in lines
if l.split(None, 1)[1] == a) > 1]
total = sum(int(l.split(None, 1)[0]) for l in lines)
vdir = ctx.path("decks", name)
os.makedirs(vdir, exist_ok=True)
header = [f"# {name}", f"# {why}", "", "# Commander",
f"1 {commander}", "", "# Main"]
open(os.path.join(vdir, "decklist.mgt"), "w").write(
"\n".join(header + lines) + "\n")
flags = []
if missing_cuts: flags.append(f"cut-not-found: {missing_cuts}")
if bad: flags.append(f"UNKNOWN CARDS: {bad}")
if dupes: flags.append(f"!! ALREADY IN DECK: {dupes}")
if total != 99: flags.append(f"!! {total} cards, need 99")
print(f"{name:18} {total:>3} cards {' | '.join(flags) if flags else 'ok'}")
if __name__ == "__main__":
main()