141 lines
6.3 KiB
Python
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.mgt"))
|
|
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.mgt")
|
|
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.mgt 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()
|