#!/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 [ ...] 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 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()