mtg/tools/compare.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

141 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Compare every variant side by side. Writes <deck>/decks/README.md.
python3 tools/compare.py --deck decks/reaper-king-no-more
"""
import sys, os, re, glob, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cards import DB, load_decklist
from deckctx import DeckContext
from collections import Counter
def variant_order(ctx):
"""Hand-curated variants (from variants.yaml `handwritten:`) first, in the
order they're declared there, then any generated/other variant dirs."""
hand = []
vfile = ctx.path(ctx.config.get("variants_file", "variants.yaml"))
if os.path.exists(vfile):
try:
import yaml
spec = yaml.safe_load(open(vfile)) or {}
hand = list((spec.get("handwritten") or {}).keys())
except ImportError:
pass
rest = sorted(os.path.basename(os.path.dirname(p))
for p in glob.glob(ctx.path("decks", "*", "decklist.txt"))
if os.path.basename(os.path.dirname(p)) not in hand)
return hand + rest
def hypothesis(path, hand_names):
for line in open(path):
if line.startswith("# ") and "Commander" not in line and "Main" not in line:
t = line[2:].strip()
if t and not t.endswith(tuple(hand_names)):
return t
return ""
def main():
ctx = DeckContext.from_args()
db = DB(blacklist_path=ctx.blacklist_path)
order = variant_order(ctx)
DECKS = ctx.path("decks")
baseline = order[0] if order else None
rows = []
for name in order:
vdir = os.path.join(DECKS, name)
deck_f = os.path.join(vdir, "decklist.txt")
if not os.path.exists(deck_f):
continue
cmd, deck = load_decklist(deck_f)
cards = [db[n] for n in deck if db.get(n)]
nl = [c for c in cards if not c.is_land]
lands = len(cards) - len(nl)
mv = sum(c.mana_value for c in nl) / len(nl) if nl else 0
bracket = open(os.path.join(vdir, "bracket.txt")).read() if os.path.exists(
os.path.join(vdir, "bracket.txt")) else ""
verdict = "PASS" if "BRACKET 3: PASS" in bracket else "FAIL"
m = re.search(r"VACUUM: (\d+)", bracket)
vac = m.group(1) if m else "?"
combos_f = os.path.join(vdir, "combos.txt")
n_combos = "?"
if os.path.exists(combos_f):
m = re.search(r"COMBOS ALREADY IN THE DECK: (\d+)", open(combos_f).read())
n_combos = m.group(1) if m else "?"
# latest sim result
wr = "—"
sims = sorted(f for f in glob.glob(os.path.join(vdir, "sim", "*.json"))
if not f.endswith("-cards.json"))
if sims:
d = json.load(open(sims[-1]))
r = d.get("our_win_rate")
if r is not None:
wr = f"{100*r:.0f}% ({d['meta']['games_completed']}g)"
rows.append({
"name": name, "verdict": verdict, "vac": vac, "combos": n_combos,
"lands": lands, "mv": mv, "wr": wr,
"outlets": sum(1 for c in nl if "free-sacrifice-outlet" in c.tags),
"synergy": sum(1 for c in nl if "death-trigger-opponent" in c.tags),
"why": hypothesis(deck_f, order),
})
L = ["# decks/", "",
"Forge simulation results and deck analysis, one directory per **archetype "
"variant**.", "",
"Each variant is a hypothesis: change a few cards, then measure what it does "
"to legality, combo density, and win rate. Every file here is generated — see "
"*regenerating* below.", "",
"## variants", "",
"| variant | bracket 3 | vacuum combos | combos | sac outlets | cmdr synergy | lands | avg MV | Forge win rate |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
for r in rows:
star = " ⭐" if r["name"] == baseline else ""
v = f"**{r['verdict']}**" if r["verdict"] == "PASS" else f"**{r['verdict']}**"
L.append(f"| [`{r['name']}/`]({r['name']}/){star} | {v} | {r['vac']} | "
f"{r['combos']} | {r['outlets']} | {r['synergy']} | {r['lands']} | "
f"{r['mv']:.2f} | {r['wr']} |")
L += ["", "⭐ = current baseline", "", "## the hypotheses", ""]
for r in rows:
L.append(f"**`{r['name']}`** — {r['why']}")
L.append("")
slug = ctx.slug
L += ["## layout", "", "```", "decks/<variant>/",
" decklist.txt the 100 cards, \"N Card Name\" per line",
" bracket.txt bracket-3 + house-rule check",
" combos.txt combos already in the deck, and one card away",
" sim/ Forge runs, one triple per batch:",
" <stamp>.log raw Forge output, every game",
" <stamp>.txt human-readable report",
" <stamp>.json machine-readable results",
" README.md generated summary",
"```", "",
"## regenerating", "", "```bash",
f"python3 tools/make_variants.py --deck decks/{slug} # rebuild every decklist from the spec",
f"python3 tools/variant.py --deck decks/{slug} <name> # analysis for one variant (~10s)",
f"python3 tools/variant.py --deck decks/{slug} <name> --sim 24 # + 24 Forge games (~25 min)",
f"python3 tools/compare.py --deck decks/{slug} # rebuild this file",
"```", "",
"The decklists are generated from `tools/make_variants.py`, which holds each "
"variant as a cut/add list against the baseline. Change the baseline and every "
"variant follows. Analysis files are rewritten each run; **sim runs are "
"additive**, so batches accumulate and stay comparable.", "",
"## reading the win rates", "",
"Forge's AI is heuristic and **weak at combo decks** by its own documentation. "
"Ours is a combo deck, so the AI under-plays it. Treat win rates as a **floor** "
"and as evidence the deck functions and its cards interact — not as a power "
"rating. Random baseline in a 4-player pod is 25%. Opponents are stock "
"Commander precons (roughly bracket 2).", ""]
open(os.path.join(DECKS, "README.md"), "w").write("\n".join(L) + "\n")
print("\n".join(L[:20]))
if __name__ == "__main__":
main()