mtg/tools/forge_report.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
8.8 KiB
Python

#!/usr/bin/env python3
"""Parse a Forge sim log into a readable report + JSON artifact.
python3 tools/forge_report.py <log> <json_out> <txt_out> [games] [seed] [deck_path] [ours_name]
Usually invoked by tools/run_forge.sh / batch_run.py / variant.py rather than
directly. `ours_name` is the Forge [metadata] Name= value for our seat
(DeckContext.forge_metadata_name(), from config.yaml) -- never a hardcoded
deck name. If omitted, it's derived from deck_path's deck directory.
"""
import sys, os, re, json, statistics as st
from datetime import datetime, timezone
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
OURS = "our deck"
def is_ours(name):
"""Match our seat even when an AI profile is in play.
Passing -a <profile> makes Forge rename the seat to "<name> [Combo]".
Exact matching would silently report a 0% win rate and an empty card
table, so match the bare name or name + [profile]. Opponent precons
legitimately contain brackets ("[C19] [2019]"), so this deliberately
anchors on OURS rather than stripping all bracket suffixes.
"""
return name == OURS or name.startswith(OURS + " [")
def deck_name_for(deck_path):
"""Best-effort: walk up from a decklist path to find the deck's config.yaml
and return its Forge metadata name. Falls back to None (caller keeps OURS)."""
d = os.path.dirname(os.path.abspath(deck_path))
for _ in range(6):
cfg = os.path.join(d, "config.yaml")
if os.path.exists(cfg):
from deckctx import DeckContext
return DeckContext(d).forge_metadata_name()
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def parse_match_result(log_path):
"""The 'Match Result:' line is authoritative for wins per player.
Individual 'Game Result' lines can be missing if output was truncated, so
prefer this tally when present.
"""
tally, total = {}, 0
for line in open(log_path, errors="ignore"):
if not line.startswith("Match Result:"):
continue
body = line[len("Match Result:"):].strip()
for m in re.finditer(r"(Ai\(\d+\)-.+?):\s*(\d+)(?=\s+Ai\(|\s*$)", body):
name = re.sub(r"^Ai\(\d+\)-", "", m.group(1)).strip()
tally[name] = int(m.group(2))
total = sum(tally.values())
return tally, total
def count_timeouts(log_path):
"""Games Forge abandoned as a draw for exceeding the -c clock.
A timeout is scored a Draw -- nobody wins -- so these games are unwinnable
by construction. They cluster in long grindy games, which is exactly what
an aristocrats deck is built to win, so they bias our win rate DOWN.
"""
n = 0
for line in open(log_path, errors="ignore"):
if "Stopping slow match as draw" in line:
n += 1
return n
def parse(log_path):
games, cur = [], {}
for line in open(log_path, errors="ignore"):
line = line.strip()
m = re.match(r"Game Outcome: Turn (\d+)", line)
if m:
cur = {"turns": int(m.group(1)), "winner": None, "losers": []}
continue
m = re.match(r"Game Outcome: (.+?) has (won|lost) because (.+)", line)
if m:
who, verdict, why = m.group(1), m.group(2), m.group(3)
name = re.sub(r"^Ai\(\d+\)-", "", who)
if verdict == "won":
cur["winner"] = name
cur["win_reason"] = why
else:
cur["losers"].append({"player": name, "reason": why})
continue
m = re.match(r"Game Result: Game (\d+) ended in (\d+) ms\. (.+?) has won", line)
if m:
cur["game"] = int(m.group(1))
cur["ms"] = int(m.group(2))
games.append(cur)
cur = {}
return games
def report(games, n_req, seed, log, jsonp, txtp, deck_path="decklist.txt",
tally=None, tally_total=0, timeouts=0):
L, add = [], lambda s: L.append(s)
# prefer the authoritative Match Result tally over per-game lines
if tally and tally_total:
wins, n = Counter(tally), tally_total
else:
wins = Counter(g["winner"] for g in games if g.get("winner"))
n = len(games)
ours = sum(v for k, v in wins.items() if is_ours(k))
turns = [g["turns"] for g in games if g.get("turns")]
secs = [g["ms"] / 1000 for g in games if g.get("ms")]
add("=" * 68)
add("FORGE SIMULATION REPORT — 4-player Commander pod")
add("=" * 68)
add(f"games completed : {n} (requested {n_req}) seed: {seed}")
add(f"decklist : {deck_path} (full 100 cards)")
add(f"engine : Forge 2.0.14, full rules, AI vs AI")
add("")
add("WIN RATE")
add("-" * 68)
if n:
add(f" our deck : {ours}/{n} ({100*ours/n:.1f}%) "
f"[random baseline in a 4-pod = 25%]")
add("")
add(" all players:")
for name, w in wins.most_common():
tag = " <-- ours" if is_ours(name) else ""
bar = "#" * int(30 * w / n)
add(f" {name[:38]:<40} {w:>3} ({100*w/n:4.1f}%) {bar}{tag}")
add("")
add("TIMEOUT DRAWS")
add("-" * 68)
if timeouts:
add(f" {timeouts} game(s) abandoned as a draw for exceeding the clock "
f"({100*timeouts/max(n,1):.0f}% of games)")
add(" A draw means NOBODY wins -- these games are unwinnable by")
add(" construction, and they cluster in long grindy games, which is")
add(" exactly what this deck is built to win. Raise -c to fix.")
else:
add(" none -- no games hit the clock limit")
add("")
add("GAME LENGTH")
add("-" * 68)
if turns:
add(f" turns median {int(st.median(turns))} min {min(turns)} max {max(turns)}")
if secs:
add(f" seconds median {st.median(secs):.0f} total {sum(secs)/60:.1f} min")
add("")
add("HOW OUR DECK LOST")
add("-" * 68)
reasons = Counter()
for g in games:
for l in g.get("losers", []):
if is_ours(l["player"]):
reasons[l["reason"][:52]] += 1
if reasons:
for r, c in reasons.most_common():
add(f" {c:>3}x {r}")
else:
add(" (never lost)")
add("")
add("WHO BEAT US (winner, in games our deck lost)")
add("-" * 68)
beat = Counter()
for g in games:
w = g.get("winner")
if w and not is_ours(w) and any(is_ours(l["player"]) for l in g.get("losers", [])):
beat[w] += 1
if beat:
for name, c in beat.most_common():
add(f" {c:>3}x {name}")
else:
add(" (n/a)")
add("")
add("CAVEAT — READ THIS BEFORE TRUSTING THE WIN RATE")
add("-" * 68)
add(" Forge's AI is heuristic and, by its own documentation, WEAK AT COMBO")
add(" decks. Ours is a combo/aristocrats deck, so the AI systematically")
add(" under-plays it: it will miss sac-loop lines a human would see.")
add(" Read this as a FLOOR on the deck's power, and as proof the deck")
add(" functions and its cards interact — not as a power rating.")
add(" Opponents are stock precons, which are roughly bracket 2.")
add("")
add("=" * 68)
add(f"decklist : {deck_path}")
add(f"full log : {log} (every game, all Forge output)")
add(f"raw data : {jsonp}")
add(f"this report: {txtp}")
add("=" * 68)
return "\n".join(L)
def main():
global OURS
log, jsonp, txtp = sys.argv[1], sys.argv[2], sys.argv[3]
n_req = sys.argv[4] if len(sys.argv) > 4 else "?"
seed = sys.argv[5] if len(sys.argv) > 5 else "?"
deck_path = sys.argv[6] if len(sys.argv) > 6 else "decklist.txt"
ours_name = sys.argv[7] if len(sys.argv) > 7 else deck_name_for(deck_path)
if ours_name:
OURS = ours_name
games = parse(log)
tally, total = parse_match_result(log)
timeouts = count_timeouts(log)
txt = report(games, n_req, seed, log, jsonp, txtp, deck_path=deck_path,
tally=tally, tally_total=total, timeouts=timeouts)
wins = Counter(tally) if tally else Counter(g["winner"] for g in games if g.get("winner"))
json.dump({
"meta": {"generated": datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
"engine": "Forge 2.0.14", "format": "Commander, 4 players",
"games_requested": n_req, "games_completed": len(games), "seed": seed,
"decklist_file": deck_path, "log_file": log,
"caveat": "Forge AI is weak at combo decks; treat win rate as a floor"},
"win_counts": dict(wins),
"timeout_draws": timeouts,
"our_win_rate": ((sum(v for k, v in wins.items() if is_ours(k)) / n_games)
if (n_games := (total or len(games))) else None),
"games": games,
}, open(jsonp, "w"), indent=1)
open(txtp, "w").write(txt + "\n")
print(txt)
print(f"\nsaved: {txtp}")
if __name__ == "__main__":
main()