mtg/tools/generate.py
mark rosenberg d4a8a587f0 Restructure into a multi-deck engine; generalize tools/ off Jund/Reaper hardcodes
Migrates the reaper-king-no-more proof of concept into decks/reaper-king-no-more/
as the first deck under a shared tools/ engine, so the repo can hold many
Commander decks instead of one implicit one.

- tools/deckctx.py: new shared DeckContext, resolves --deck / $MTG_DECK / cwd,
  loads a deck's config.yaml, and derives color identity from the commander's
  own card data (never a hardcoded color combination).
- cards.py: drop the JUND=frozenset("BRG") constant; jund_legal() renamed to
  identity_legal(card, identity); DB() takes an explicit blacklist_path since
  the blacklist is per-deck, not shared.
- combos.py, roles.py, similar.py, similar_text.py, bracket_check.py: take
  --deck, use the deck's real identity instead of Jund/BRG.
- power.py, card_stats.py, forge_report.py: OURS deck-name constant now comes
  from the deck's config (name: / commander), matched via DeckContext instead
  of hardcoded "Reaper King No More".
- to_dck.py, batch_run.py, ab_test.py: .dck filename and [metadata] Name= are
  derived from the deck (slug / config name), not a hardcoded "reaper.dck".
- generate.py: RECIPES and make_variants.py's VARIANTS (deck-specific
  hypotheses) moved out of Python into decks/reaper-king-no-more/variants.yaml;
  protected cards now come from config.yaml's protected: list instead of a
  hardcoded set.
- New tools/mtg dispatcher: `tools/mtg decks/<name> <tool> [args...]` runs any
  tool against any deck without cd-ing into its directory.
- data/ (Scryfall/MTGJSON/Spellbook bulk, ~1.2GB, gitignored) and embeddings.npz
  move to the repo root as shared, commander-format-wide data.
- docs/howto.md: moved from the deck dir (it documents the tool, not the deck)
  and updated its example commands to the real generalized CLI.
- README.md: rewritten to describe the multi-deck repo and point at
  decks/reaper-king-no-more as the reference example.

Verified after the change: bracket_check, combos, and roles against
reaper-king-no-more reproduce the exact PASS/11-combos/role-profile numbers
from before the move; generate.py --list now loads recipes from YAML;
make_variants.py reproduces all 7 hand-curated variants including the
mikaeus FAIL control case; to_dck.py derives the right Name= and filename;
variant.py and compare.py reproduce decks/README.md's original numbers
exactly. The original POC at ~/mtg/reaper-king-no-more is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPKgLYpMR8szWNFXyK1XNu
2026-09-12 00:37:42 -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.txt"))
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.txt"), "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()