mtg/tools/card_stats.py
2026-09-12 00:41:47 -04:00

183 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Per-card statistics from Forge play-by-play logs.
Forge (without -q) logs every cast, zone change, and life change attributed to a
player. That lets us ask, per card:
* how often was it CAST at all, when drawn into a game?
* what turn did it typically come down?
* did our deck WIN more when this card got cast?
python3 tools/card_stats.py --deck decks/reaper-king-no-more decks/reaper-king-no-more/decks/<variant>/sim/<stamp>.log
The win-rate delta is the interesting column, but see the caveats it prints:
this is correlation over a small sample, not causation.
"Our seat" is matched by the deck's Forge metadata name (config.yaml, via
DeckContext) -- never a hardcoded deck name.
"""
import sys, os, re, json
from collections import defaultdict, Counter
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cards import DB, load_decklist
from deckctx import DeckContext
OURS = "our deck"
def is_ours(name):
"""Match our seat even when -a <profile> renames it to 'NAME [Profile]'."""
return name == OURS or name.startswith(OURS + " [")
CAST = re.compile(r"^Add To Stack: Ai\(\d+\)-(.+?) cast (.+?)(?: targeting .*)?$")
# In a 4-player pod Forge numbers EVERY player's turn sequentially, so "Turn 24"
# is round 6, not turn 24. Track the seat too so we can convert to rounds.
TURN = re.compile(r"^Turn: Turn (\d+) \(Ai\(\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")
def parse_games(path):
"""Split the log into per-game records of what OUR deck cast, and who won."""
games, cur = [], None
turn = 0
for line in open(path, errors="ignore"):
line = line.rstrip("\n")
if cur is None:
cur = {"casts": [], "won": None, "turns": 0}
m = TURN.match(line)
if m:
seq = int(m.group(1))
n_players = cur.get("players") or 4
turn = (seq + n_players - 1) // n_players # -> round number
cur["turns"] = max(cur["turns"], turn)
continue
m = CAST.match(line)
if m and is_ours(m.group(1).strip()):
cur["casts"].append((m.group(2).strip(), turn))
continue
m = WON.match(line)
if m:
cur["won"] = is_ours(m.group(1).strip())
continue
if END.match(line):
games.append(cur)
cur, turn = None, 0
return games
def main():
global OURS
ctx = DeckContext.from_args()
OURS = ctx.forge_metadata_name()
args = sys.argv[1:]
if not args:
sys.exit(__doc__)
log = args[0]
games = parse_games(log)
if not games:
sys.exit("no games parsed -- was the log made without -q?")
n = len(games)
wins = sum(1 for g in games if g["won"])
base = wins / n
cast_games = defaultdict(int) # games where card was cast >=1x
cast_wins = defaultdict(int)
turns = defaultdict(list)
total_casts = Counter()
for g in games:
seen = set()
for name, t in g["casts"]:
total_casts[name] += 1
if name not in seen:
seen.add(name)
cast_games[name] += 1
if g["won"]:
cast_wins[name] += 1
if t:
turns[name].append(t)
# only cards actually in our decklist
db = DB(blacklist_path=ctx.blacklist_path)
variant_dir = os.path.dirname(os.path.dirname(log))
deck_file = os.path.join(variant_dir, "decklist.mgt")
commander, deck = load_decklist(deck_file)
deck_set = set(deck) | {commander}
rows = []
for name in deck_set:
c = db.get(name)
if c and c.is_land:
continue
g = cast_games.get(name, 0)
wr = (cast_wins[name] / g) if g else None
rows.append({
"card": name,
"games_cast": g,
"cast_rate": g / n,
"avg_turn": (sum(turns[name]) / len(turns[name])) if turns.get(name) else None,
"win_rate_when_cast": wr,
"delta": (wr - base) if wr is not None else None,
"total_casts": total_casts.get(name, 0),
})
print("=" * 74)
print(f"CARD STATISTICS — {os.path.basename(log)}")
print("=" * 74)
print(f"games: {n} our wins: {wins} ({100*base:.1f}%) deck: {deck_file}")
print()
never = sorted(r["card"] for r in rows if r["games_cast"] == 0)
blocked = sorted(c for c in never if db.get(c) and db[c].ai_removed)
playable = sorted(set(never) - set(blocked))
print(f"NEVER CAST — AI-BLOCKED: {len(blocked)} (simulator artifact, IGNORE)")
print("-" * 74)
print(" These carry `AI:RemoveDeck:All` in Forge's card scripts, so the engine")
print(" is forbidden from ever playing them. Their absence says nothing about")
print(" the card's value -- only that Forge cannot pilot it.")
for c in blocked:
print(f" {c}")
print()
print(f"NEVER CAST — PLAYABLE: {len(playable)} (real signal)")
print("-" * 74)
print(" Forge COULD have played these and didn't: too expensive, never drawn,")
print(" or genuinely weak. These are worth investigating.")
for c in playable:
print(f" {c}")
print()
played = [r for r in rows if r["games_cast"] > 0]
played.sort(key=lambda r: -r["cast_rate"])
print("MOST-CAST CARDS")
print("-" * 74)
print(f" {'card':<34}{'cast in':>9}{'avg round':>11}{'win% when cast':>16}")
for r in played[:20]:
wt = f"{100*r['win_rate_when_cast']:.0f}%" if r["win_rate_when_cast"] is not None else "-"
at = f"R{r['avg_turn']:.1f}" if r["avg_turn"] else "-"
print(f" {r['card'][:32]:<34}{r['games_cast']:>4}/{n:<4}{at:>11}{wt:>16}")
print()
print("CAVEATS — DO NOT CUT CARDS ON THIS ALONE")
print("-" * 74)
print(f" * {n} games is a tiny sample. A card cast in 5 games has a win-rate")
print(" swing of +/-40% from noise alone.")
print(" * 'Never cast' conflates three very different things: a bad card, a")
print(" card the AI cannot use, and a card that was simply never drawn.")
print(" * Correlation only. Cards cast in LONG games look good because long")
print(" games are ones we survived -- the card didn't cause the win.")
print(" * Forge's AI is weak at combo; it under-casts combo pieces.")
print()
out = log.replace(".log", "-cards.json")
json.dump({"log": log, "games": n, "base_win_rate": base,
"cards": sorted(rows, key=lambda r: -r["cast_rate"])},
open(out, "w"), indent=1)
print(f"raw: {out}")
if __name__ == "__main__":
main()