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
141 lines
5.3 KiB
Python
141 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Run the full analysis suite for one deck variant and write it to <deck>/decks/<name>/.
|
|
|
|
python3 tools/variant.py --deck decks/reaper-king-no-more no-mikaeus
|
|
python3 tools/variant.py --deck decks/reaper-king-no-more mikaeus --sim 24
|
|
|
|
Each variant directory ends up self-contained:
|
|
|
|
<deck>/decks/<name>/
|
|
decklist.txt the 100 cards
|
|
bracket.txt bracket-3 + house-rule check
|
|
combos.txt combos in the deck, and one card away
|
|
sim/ Forge runs: <stamp>.{log,txt,json}
|
|
README.md what this variant is and what the numbers say
|
|
"""
|
|
import sys, os, subprocess, glob
|
|
from datetime import datetime, timezone
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from deckctx import DeckContext, ROOT as TOOLS_ROOT
|
|
|
|
|
|
def run(cmd, out_path, cwd=None):
|
|
r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
|
|
text = r.stdout + (("\n[stderr]\n" + r.stderr) if r.stderr.strip() else "")
|
|
open(out_path, "w").write(text)
|
|
return text
|
|
|
|
|
|
def main():
|
|
ctx = DeckContext.from_args() # pops --deck/-d out of sys.argv first
|
|
args = sys.argv[1:]
|
|
if not args:
|
|
sys.exit(__doc__)
|
|
name = args[0]
|
|
games = int(args[args.index("--sim") + 1]) if "--sim" in args else 0
|
|
seed = int(args[args.index("--seed") + 1]) if "--seed" in args else 20260907
|
|
tools = os.path.join(TOOLS_ROOT, "tools")
|
|
|
|
vdir = ctx.path("decks", name)
|
|
deck = os.path.join(vdir, "decklist.txt")
|
|
if not os.path.exists(deck):
|
|
sys.exit(f"no decklist at {deck}")
|
|
os.makedirs(os.path.join(vdir, "sim"), exist_ok=True)
|
|
|
|
print(f"[{name}] bracket check ...")
|
|
bracket = run([sys.executable, os.path.join(tools, "bracket_check.py"),
|
|
"--deck", ctx.dir, deck],
|
|
os.path.join(vdir, "bracket.txt"))
|
|
verdict = "PASS" if "BRACKET 3: PASS" in bracket else "FAIL"
|
|
|
|
print(f"[{name}] combos ...")
|
|
combos = run([sys.executable, os.path.join(tools, "combos.py"),
|
|
"--deck", ctx.dir, "--deck-file", deck],
|
|
os.path.join(vdir, "combos.txt"))
|
|
|
|
if games:
|
|
print(f"[{name}] forge: {games} games (this is the slow part) ...")
|
|
subprocess.run(["bash", os.path.join(tools, "run_forge.sh"),
|
|
ctx.dir, str(games), str(seed), name])
|
|
|
|
# per-card stats for the newest verbose sim log
|
|
logs = sorted(glob.glob(os.path.join(vdir, "sim", "*.log")))
|
|
if logs:
|
|
stats_out = os.path.join(vdir, "card_stats.txt")
|
|
run([sys.executable, os.path.join(tools, "card_stats.py"),
|
|
"--deck", ctx.dir, logs[-1]], stats_out)
|
|
|
|
# power metrics (needs a verbose log; silently skipped for -q logs)
|
|
if logs:
|
|
pw = os.path.join(vdir, "power.txt")
|
|
out_txt = run([sys.executable, os.path.join(tools, "power.py"),
|
|
"--deck", ctx.dir, logs[-1]], pw)
|
|
if "no cast events" in out_txt:
|
|
os.remove(pw)
|
|
|
|
# index of sim runs
|
|
sims = sorted(glob.glob(os.path.join(vdir, "sim", "*.txt")))
|
|
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%MZ")
|
|
n_deck = sum(1 for l in open(deck) if l.strip() and l.strip()[0].isdigit())
|
|
|
|
vac = "0"
|
|
for line in bracket.splitlines():
|
|
if "VACUUM" in line:
|
|
vac = line.split(":")[1].split()[0]
|
|
|
|
readme = [
|
|
f"# {name}",
|
|
"",
|
|
f"_generated {stamp} by `tools/variant.py`_",
|
|
"",
|
|
f"- **bracket 3: {verdict}** (two-card vacuum combos: {vac})",
|
|
f"- {n_deck} lines in `decklist.txt`",
|
|
"",
|
|
"## files",
|
|
"",
|
|
"| file | what it is |",
|
|
"| --- | --- |",
|
|
"| `decklist.txt` | the deck, `N Card Name` per line |",
|
|
"| `bracket.txt` | bracket-3 + house-rule check |",
|
|
"| `combos.txt` | combos in the deck, and one card away |",
|
|
"| `sim/` | Forge runs — `.log` raw, `.txt` report, `.json` data |",
|
|
"| `card_stats.txt` | per-card cast rate, avg round, win% (newest sim) |",
|
|
"| `power.txt` | what the deck DID (engine assembly, pressure) win or lose |",
|
|
"",
|
|
]
|
|
# surface the actual house-rule violations, not just a count
|
|
vac_lines = []
|
|
grab = False
|
|
for line in bracket.splitlines():
|
|
if "VACUUM" in line:
|
|
grab = True; continue
|
|
if grab:
|
|
if line.strip().startswith("-"):
|
|
vac_lines.append(line.strip()[2:])
|
|
elif line.strip() == "":
|
|
break
|
|
if vac_lines:
|
|
readme += ["## two-card vacuum combos (house-rule violations)", "",
|
|
"These go infinite with no third card and no board state:", ""]
|
|
readme += [f"- {v}" for v in vac_lines] + [""]
|
|
|
|
if sims:
|
|
readme += ["## simulation runs", ""]
|
|
for s in sims:
|
|
base = os.path.basename(s)
|
|
wr = ""
|
|
for line in open(s):
|
|
if "our deck" in line:
|
|
wr = line.strip(); break
|
|
readme.append(f"- `sim/{base}` — {wr}")
|
|
readme.append("")
|
|
else:
|
|
readme += ["## simulation runs", "", "_none yet — run with `--sim 24`_", ""]
|
|
|
|
open(os.path.join(vdir, "README.md"), "w").write("\n".join(readme) + "\n")
|
|
print(f"[{name}] -> {ctx.slug}/decks/{name}/ (bracket 3: {verdict})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|