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
214 lines
8.3 KiB
Python
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()
|