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

240 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""Power-level metrics: did the deck DO ITS THING, win or lose?
Win rate is a terrible measuring stick at low game counts (+/-15.5pp at n=30) and
it throws away everything that happened in games we lost. This scores the deck on
what it actually did -- pressure applied, engine assembled, commander deployed,
sacrifices made -- which is both more informative and far more sensitive for A/B
testing a change.
python3 tools/power.py --deck decks/reaper-king-no-more <log> [<log> ...]
python3 tools/power.py --deck decks/reaper-king-no-more decks/reaper-king-no-more/decks/fast-ramp/sim/*.log --json out.json
Parses the verbose Forge logs already produced (runs made with -q have no
play-by-play and cannot be scored).
"Our seat" is matched by the deck's Forge metadata name (see
DeckContext.forge_metadata_name(), from config.yaml -- never a hardcoded
deck name), because Forge's log output identifies players by the [metadata]
Name= value from the .dck file.
"""
import sys, os, re, json
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cards import DB, load_decklist
from deckctx import DeckContext
TURN = re.compile(r"^Turn: Turn (\d+) \(Ai\(\d+\)-(.+?)\)")
CAST = re.compile(r"^Add To Stack: Ai\(\d+\)-(.+?) cast (.+?)(?: targeting .*)?$")
RESOLVE = re.compile(r"^Resolve Stack: (.+?)(?: -|$)")
LIFE = re.compile(r"^Life: Life: Ai\(\d+\)-(.+?) (\d+) > (\d+)$")
WON = re.compile(r"^Game Outcome: Ai\(\d+\)-(.+?) has won")
LOST = re.compile(r"^Game Outcome: Ai\(\d+\)-(.+?) has lost")
END = re.compile(r"^Game Result: Game (\d+) ended")
PLAYERS = 4
# Engine roles: the deck is "assembled" when all three are on the battlefield.
ENGINE = {
"sac_outlet": {"Viscera Seer", "Carrion Feeder", "Ashnod's Altar", "Phyrexian Altar",
"Goblin Bombardment", "Woe Strider", "Yahenni, Undying Partisan",
"Greater Good"},
"drain": {"Blood Artist", "Zulaport Cutthroat", "Vengeful Bloodwitch",
"Bastion of Remembrance", "Mirkwood Bats", "Poison-Tip Archer",
"Syr Konrad, the Grim"},
"minus_engine": {"Hapatra, Vizier of Poisons", "Blowfly Infestation",
"Nest of Scarabs", "Flourishing Defenses", "Necroskitter",
"Yawgmoth, Thran Physician", "Obelisk Spider"},
}
# Module-level defaults; configure() overrides them for the deck in play.
# Never hardcode a deck name -- derive it from DeckContext.
OURS = "our deck"
COMMANDER = None
def configure(ours, commander):
"""Set which Forge seat name is 'ours' and which card is the commander.
Call this before parse()/score() when importing power.py as a library
(see ab_test.py). main() calls it automatically from --deck.
"""
global OURS, COMMANDER
OURS, COMMANDER = ours, commander
def is_ours(name):
"""Match our seat even when -a <profile> renames it to 'NAME [Profile]'."""
return name == OURS or name.startswith(OURS + " [")
def parse(path):
games, cur, turn = [], None, 0
def fresh():
return {"round": 0, "life_taken": 0, "life_lost": 0, "casts": 0,
"commander_turn": None, "commander_casts": 0,
"on_battlefield": set(), "engine_turn": {}, "assembled_turn": None,
"won": None, "survived": 0, "timeout": False}
for line in open(path, errors="ignore"):
line = line.rstrip("\n")
if cur is None:
cur = fresh()
m = TURN.match(line)
if m:
turn = (int(m.group(1)) + PLAYERS - 1) // PLAYERS
cur["round"] = max(cur["round"], turn)
continue
m = CAST.match(line)
if m:
who, card = m.group(1).strip(), m.group(2).strip()
if is_ours(who):
cur["casts"] += 1
if card == COMMANDER:
cur["commander_casts"] += 1
if cur["commander_turn"] is None:
cur["commander_turn"] = turn
cur["on_battlefield"].add(card)
for role, names in ENGINE.items():
if card in names and role not in cur["engine_turn"]:
cur["engine_turn"][role] = turn
if len(cur["engine_turn"]) == len(ENGINE) and cur["assembled_turn"] is None:
cur["assembled_turn"] = turn
continue
m = LIFE.match(line)
if m:
who, before, after = m.group(1).strip(), int(m.group(2)), int(m.group(3))
delta = after - before
if is_ours(who):
if delta < 0:
cur["life_lost"] += -delta
elif delta < 0:
# life an opponent lost; not all of it is ours, but in aggregate
# it tracks the pressure our deck contributes to the table
cur["life_taken"] += -delta
continue
if "Stopping slow match as draw" in line:
cur["timeout"] = True
continue
m = WON.match(line)
if m:
cur["won"] = is_ours(m.group(1).strip())
continue
m = LOST.match(line)
if m and is_ours(m.group(1).strip()):
cur["survived"] = cur["round"]
continue
if END.match(line):
cur["on_battlefield"] = sorted(cur["on_battlefield"])
if cur["survived"] == 0:
cur["survived"] = cur["round"]
games.append(cur)
cur, turn = None, 0
return games
def score(g):
"""Composite power score. Deliberately simple and documented, not tuned.
Rewards doing the deck's job: applying pressure, deploying the commander,
assembling the engine, and surviving. Winning is a bonus, not the whole story.
"""
s = 0.0
s += min(g["life_taken"], 120) * 0.25 # pressure, capped
s += 15 if g["commander_turn"] else 0 # got the commander down
s += 25 if g["assembled_turn"] else 0 # engine online
if g["assembled_turn"]: # earlier is better
s += max(0, 15 - g["assembled_turn"])
s += min(g["survived"], 20) * 0.5 # stayed alive
s += 30 if g["won"] else 0
return round(s, 1)
def main():
ctx = DeckContext.from_args()
configure(ctx.forge_metadata_name(), ctx.commander)
args = [a for a in sys.argv[1:] if not a.startswith("--")]
jsonp = None
if "--json" in sys.argv:
jsonp = sys.argv[sys.argv.index("--json") + 1]
if not args:
sys.exit(__doc__)
allg, per_log = [], {}
for path in args:
g = parse(path)
if g:
per_log[path] = g
allg += g
if not allg:
sys.exit("no games parsed -- logs must be VERBOSE (made without -q)")
# A -q log has outcomes but no play-by-play; every per-card metric reads 0,
# which looks like a catastrophic result rather than missing data. Refuse it.
if sum(g["casts"] for g in allg) == 0:
sys.exit(f"{args[0]}: no cast events found.\n"
"This log was made with -q (quiet), which omits the play-by-play.\n"
"Power metrics need a verbose log -- re-run without -q.")
n = len(allg)
wins = sum(1 for g in allg if g["won"])
to = sum(1 for g in allg if g["timeout"])
asm = [g["assembled_turn"] for g in allg if g["assembled_turn"]]
cmd = [g["commander_turn"] for g in allg if g["commander_turn"]]
scores = [score(g) for g in allg]
print("=" * 70)
print("POWER REPORT — what the deck did, win or lose")
print("=" * 70)
print(f"games: {n} wins: {wins} ({100*wins/n:.0f}%) "
f"timeout draws: {to} ({100*to/n:.0f}%)")
print()
print("DID THE DECK DO ITS THING?")
print("-" * 70)
print(f" commander deployed {len(cmd):>3}/{n} games ({100*len(cmd)/n:>3.0f}%)"
f" median round {sorted(cmd)[len(cmd)//2] if cmd else '-'}")
print(f" engine assembled {len(asm):>3}/{n} games ({100*len(asm)/n:>3.0f}%)"
f" median round {sorted(asm)[len(asm)//2] if asm else '-'}")
for role in ENGINE:
hit = [g["engine_turn"][role] for g in allg if role in g["engine_turn"]]
med = sorted(hit)[len(hit)//2] if hit else "-"
print(f" {role:<16}{len(hit):>3}/{n} ({100*len(hit)/n:>3.0f}%) median round {med}")
print()
print("PRESSURE & SURVIVAL")
print("-" * 70)
print(f" life taken from table mean {sum(g['life_taken'] for g in allg)/n:>6.1f}")
print(f" life we lost mean {sum(g['life_lost'] for g in allg)/n:>6.1f}")
print(f" spells cast mean {sum(g['casts'] for g in allg)/n:>6.1f}")
print(f" rounds survived mean {sum(g['survived'] for g in allg)/n:>6.1f}")
print()
print("POWER SCORE (composite; see score() for weights)")
print("-" * 70)
ss = sorted(scores)
print(f" mean {sum(scores)/n:.1f} median {ss[n//2]:.1f} "
f"min {ss[0]:.1f} max {ss[-1]:.1f}")
print()
print(" Use this, not win rate, to A/B a change: it moves on far fewer games")
print(" because it scores every game instead of only the ones we won.")
print("=" * 70)
if jsonp:
json.dump({"games": n, "wins": wins, "timeout_draws": to,
"power_mean": sum(scores)/n,
"assembled_pct": len(asm)/n,
"commander_pct": len(cmd)/n,
"per_game": [dict(g, power=score(g)) for g in allg]},
open(jsonp, "w"), indent=1)
print(f"raw: {jsonp}")
if __name__ == "__main__":
main()