mtg/tools/compare.py
2026-09-12 00:46:38 -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.mtg"))
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.mtg")
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.mtg 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()