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
102 lines
3.8 KiB
Python
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.txt")
|
|
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.txt"), "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()
|