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

214 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Generate deck variants automatically from a role budget.
A variant is a *reallocation*: shift N slots from one role to another, then let
the tool pick which specific cards to cut and add. Cuts come from the weakest
cards in the over-served role; adds come from the best unused cards in the
under-served one.
python3 tools/generate.py --deck decks/reaper-king-no-more --list
python3 tools/generate.py --deck decks/reaper-king-no-more
python3 tools/generate.py --deck decks/reaper-king-no-more --only aggro-hate
python3 tools/generate.py --deck decks/reaper-king-no-more --from-stats decks/reaper-king-no-more/decks/fast-ramp/card_stats.txt
Every generated deck is validated: 100 cards, singleton, on-identity, no
blacklisted cards. Anything that fails is reported and not written.
Recipes (the hypotheses -- which roles to reallocate and why) are deck-specific
content, not code: they live in that deck's variants.yaml (config.yaml
`variants_file:`), not hardcoded here. Protected cards -- the ones the
generator may never cut -- come from config.yaml's `protected:` list.
"""
import sys, os, re, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cards import DB, load_decklist
from deckctx import DeckContext
from roles import classify, profile, pool, ROLES
from collections import Counter
def load_recipes(ctx):
"""{name: (why, {role: delta})} from this deck's variants.yaml."""
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, r in (spec.get("recipes") or {}).items():
out[name] = (r.get("why", "").strip(), dict(r.get("deltas") or {}))
return out
def load_base(base_path):
_, deck = load_decklist(base_path)
return deck
def weakest(names, role, db, n, protect):
"""Which cards to cut from a role: worst EDHREC rank first, never protected."""
cands = [db[x] for x in names
if db.get(x) and not db[x].is_land
and classify(db[x], db) == role and x not in protect]
cands.sort(key=lambda c: -(c.edhrec_rank or 10**9)) # worst first
return [c.name for c in cands[:n]]
def creates_vacuum_combo(candidate, deck, db, commander):
"""Would adding this card create a two-card infinite that needs no board state?
This is the house rule, enforced at generation time so bad candidates are
never picked rather than caught afterwards.
"""
_, idx = db.combos
present = set(deck) | {commander}
for combo in idx.get(candidate, []):
uses = [u["card"]["name"] for u in combo["uses"]]
if len(uses) != 2 or candidate not in uses:
continue
other = [u for u in uses if u != candidate]
if not other or other[0] not in present:
continue
if (combo.get("notablePrerequisites") or "").strip():
continue # needs board state -> allowed
if any("Infinite" in p["feature"]["name"] or "wins the game" in p["feature"]["name"]
for p in combo["produces"]):
return True
return False
def build(name, why, deltas, base, db, protect, commander, identity, stats=None):
deck = list(base)
cuts, adds = [], []
for role, d in sorted(deltas.items(), key=lambda kv: kv[1]):
if d >= 0:
continue
picked = weakest(deck, role, db, -d, protect)
if len(picked) < -d:
return None, f"cannot cut {-d} from '{role}' (only {len(picked)} available)"
for p in picked:
deck.remove(p)
cuts += picked
used = set(deck) | {commander} | set(cuts)
for role, d in sorted(deltas.items(), key=lambda kv: -kv[1]):
if d <= 0:
continue
cands = pool(role, db, identity, exclude=used)
picked = []
for c in cands:
if len(picked) == d:
break
if creates_vacuum_combo(c.name, deck, db, commander):
continue # house rule: never generate a vacuum combo
picked.append(c.name)
deck.append(c.name)
if len(picked) < d:
return None, f"only {len(picked)} usable candidates for '{role}', need {d}"
adds += picked
used |= set(picked)
return {"deck": deck, "cuts": cuts, "adds": adds, "why": why}, None
def validate(deck, db, identity):
errs = []
if len(deck) != 99:
errs.append(f"{len(deck)+1} cards, need 100")
dupes = {n: c for n, c in Counter(deck).items()
if c > 1 and db.get(n) and "Basic" not in db[n].type_line}
if dupes:
errs.append(f"singleton violated: {dupes}")
off = [n for n in deck if db.get(n) and not db[n].color_identity <= identity]
if off:
errs.append(f"off-identity: {off}")
bl = [n for n in deck if n in db.blacklist]
if bl:
errs.append(f"blacklisted: {bl}")
unknown = [n for n in deck if not db.get(n)]
if unknown:
errs.append(f"unknown: {unknown}")
return errs
def never_cast(stats_path):
"""Cards a previous sim never cast -- prime cut candidates."""
if not stats_path or not os.path.exists(stats_path):
return set()
out, grab = set(), False
for line in open(stats_path):
if line.startswith("NEVER CAST"):
grab = True; continue
if grab:
if line.startswith("-") or line.strip().startswith("("):
continue
if line.strip() and line.startswith(" "):
out.add(line.strip())
elif line.strip() == "":
break
return out
def main():
ctx = DeckContext.from_args() # pops --deck/-d out of sys.argv first
args = sys.argv[1:]
only = args[args.index("--only") + 1] if "--only" in args else None
stats = args[args.index("--from-stats") + 1] if "--from-stats" in args else None
base_path = args[args.index("--base") + 1] if "--base" in args else \
ctx.path(ctx.config.get("generate_base", "decks/no-mikaeus/decklist.mgt"))
recipes = load_recipes(ctx)
if "--list" in args:
if not recipes:
print(f"no recipes -- add them to {ctx.path(ctx.config.get('variants_file', 'variants.yaml'))}")
for n, (why, d) in recipes.items():
print(f"{n:18} {d}")
print(f"{'':18} {why}")
return
db = DB(blacklist_path=ctx.blacklist_path)
commander = ctx.commander
identity = ctx.identity(db)
base = load_base(base_path)
# never cut the deck's identity-defining cards -- from config.yaml `protected:`
protect = ctx.protected
skip = never_cast(stats)
if skip:
print(f"(using {len(skip)} never-cast cards as preferred cuts)\n")
for name, (why, deltas) in recipes.items():
if only and name != only:
continue
assert sum(deltas.values()) == 0, f"{name}: deltas must sum to 0"
res, err = build(name, why, deltas, base, db, protect, commander, identity, skip)
if err:
print(f"{name:18} SKIPPED — {err}")
continue
errs = validate(res["deck"], db, identity)
if errs:
print(f"{name:18} INVALID — {'; '.join(errs)}")
continue
vdir = ctx.path("decks", name)
os.makedirs(vdir, exist_ok=True)
base_rel = os.path.relpath(base_path, ctx.dir)
header = [f"# {name}", f"# {why}", "",
f"# generated by tools/generate.py from {base_rel}",
f"# roles: {deltas}", "",
"# Commander", f"1 {commander}", "", "# Main"]
body = [f"1 {n}" for n in sorted(set(res["deck"]) - {"Swamp", "Forest", "Mountain"})]
basics = [f"{Counter(res['deck'])[b]} {b}" for b in ("Swamp", "Forest", "Mountain")
if Counter(res["deck"])[b]]
open(os.path.join(vdir, "decklist.mgt"), "w").write(
"\n".join(header + body + basics) + "\n")
print(f"{name:18} ok -{len(res['cuts'])} +{len(res['adds'])}")
print(f"{'':18} cut: {', '.join(res['cuts'][:4])}{'...' if len(res['cuts'])>4 else ''}")
print(f"{'':18} add: {', '.join(res['adds'][:4])}{'...' if len(res['adds'])>4 else ''}")
if __name__ == "__main__":
main()