183 lines
6.6 KiB
Python
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.mtg")
|
|
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()
|