#!/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//sim/.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 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.txt") 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()