#!/usr/bin/env python3 """Analysis layer for the combined-pool combat sim (spec §12). Consumes the CSV/JSON that ``combat-sim`` exports and renders the headline views that make "feel" measurable instead of eyeballed: 1. Difficulty curve — player win-rate vs. con tier, one line per policy. 2. Duration tent — time-to-resolution vs. con tier (trivial → contested → lethal). 3. Auto-attack guardrail — auto-only win-rate vs. the full kit (§12 anti-turtle). 4. Stagger frequency — staggers inflicted vs. con tier. 5. One fight's ceiling-decay — the combined pool (fill) and ceiling (cap) over time. Run after a sweep: python analyze.py --data ../out --plots plots The file is also organized into ``# %%`` cells so it doubles as a notebook in Jupyter / VS Code interactive. """ # %% from __future__ import annotations import argparse import json from pathlib import Path import matplotlib matplotlib.use("Agg") # headless: write PNGs, never block on a window import matplotlib.pyplot as plt import pandas as pd # %% def load(data_dir: Path): """Load the three sim artifacts; tolerate a missing sample fight.""" fights = pd.read_csv(data_dir / "fights.csv") summary = pd.read_csv(data_dir / "summary.csv") sample_path = data_dir / "sample_fight.json" sample = json.loads(sample_path.read_text()) if sample_path.exists() else None return fights, summary, sample # %% def plot_difficulty_curve(summary: pd.DataFrame, out: Path): """Win-rate vs. con tier, one line per player policy — the difficulty curve and the skill gradient in one panel.""" con = summary[summary["sweep"] == "con"] fig, ax = plt.subplots(figsize=(8, 5)) for ctrl, grp in sorted(con.groupby("player_ctrl")): grp = grp.sort_values("con_delta") ax.plot(grp["con_delta"], grp["player_win_rate"] * 100, marker="o", label=ctrl) ax.axvline(0, color="grey", lw=0.8, ls=":") ax.set_xlabel("con tier (enemy_level − player_level)") ax.set_ylabel("player win rate (%)") ax.set_title("Difficulty curve — win rate by con tier and policy") ax.set_ylim(-3, 103) ax.grid(True, alpha=0.3) ax.legend(title="player policy") fig.tight_layout() fig.savefig(out / "difficulty_curve.png", dpi=120) plt.close(fig) # %% def plot_duration_tent(summary: pd.DataFrame, out: Path): """Mean fight duration vs. con tier — the 'trivial → contested → lethal' tent.""" con = summary[(summary["sweep"] == "con") & (summary["player_ctrl"] == "skill80")] con = con.sort_values("con_delta") fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(con["con_delta"], con["mean_seconds"], marker="o", color="darkorange") ax.axvline(0, color="grey", lw=0.8, ls=":") ax.set_xlabel("con tier (enemy_level − player_level)") ax.set_ylabel("mean time-to-resolution (s)") ax.set_title("Duration tent — fights are longest (most contested) at even con") ax.grid(True, alpha=0.3) fig.tight_layout() fig.savefig(out / "duration_tent.png", dpi=120) plt.close(fig) # %% def plot_guardrail(summary: pd.DataFrame, out: Path): """The §12 anti-turtle guardrail: auto-attack-only win-rate against the best full-kit policy. If auto-only ever matches the kit, the interesting verbs are a tax and the thesis is broken.""" con = summary[summary["sweep"] == "con"] auto = con[con["player_ctrl"] == "auto_only"].sort_values("con_delta") best = ( con[con["player_ctrl"] != "auto_only"] .groupby("con_delta")["player_win_rate"] .max() .reset_index() .sort_values("con_delta") ) fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(auto["con_delta"], auto["player_win_rate"] * 100, marker="o", label="auto-attack only") ax.plot(best["con_delta"], best["player_win_rate"] * 100, marker="s", label="best full kit") ax.fill_between( best["con_delta"], auto.set_index("con_delta").reindex(best["con_delta"])["player_win_rate"].values * 100, best["player_win_rate"] * 100, alpha=0.15, color="green", label="value of abilities", ) ax.set_xlabel("con tier (enemy_level − player_level)") ax.set_ylabel("player win rate (%)") ax.set_title("Anti-turtle guardrail — abilities must beat auto-attack") ax.grid(True, alpha=0.3) ax.legend() fig.tight_layout() fig.savefig(out / "guardrail.png", dpi=120) plt.close(fig) # %% def plot_stagger_frequency(summary: pd.DataFrame, out: Path): """Staggers the player lands vs. con tier (a skilled player).""" con = summary[(summary["sweep"] == "con") & (summary["player_ctrl"] == "skill80")] con = con.sort_values("con_delta") fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(con["con_delta"], con["mean_player_staggers_inflicted"], marker="o", color="purple") ax.set_xlabel("con tier (enemy_level − player_level)") ax.set_ylabel("mean staggers inflicted / fight") ax.set_title("Stagger frequency by con tier (skilled player)") ax.grid(True, alpha=0.3) fig.tight_layout() fig.savefig(out / "stagger_frequency.png", dpi=120) plt.close(fig) # %% def plot_sample_fight(sample: dict, out: Path): """One fight's combined pool over time: fill (the visible bar) and cap (the ceiling that attrition grinds down). The squeeze made legible.""" if sample is None: print(" (no sample_fight.json — skipping time-series plot)") return metrics = sample["metrics"] tick_rate = metrics.get("tick_rate", 10) fig, ax = plt.subplots(figsize=(9, 5)) for actor in metrics["actors"]: fill = [v / 1000.0 for v in actor.get("fill_series", [])] cap = [v / 1000.0 for v in actor.get("cap_series", [])] if not fill: continue t = [i / tick_rate for i in range(len(fill))] (line,) = ax.plot(t, fill, label=f'{actor["name"]} — vigor (fill)') ax.plot(t, cap, ls="--", color=line.get_color(), alpha=0.7, label=f'{actor["name"]} — cap (ceiling)') ax.set_xlabel("time (s)") ax.set_ylabel("vigor") ax.set_title("One fight — fill spent toward zero, ceiling ground down by attrition") ax.grid(True, alpha=0.3) ax.legend(fontsize=8) fig.tight_layout() fig.savefig(out / "sample_fight.png", dpi=120) plt.close(fig) # %% def print_findings(summary: pd.DataFrame): """A terse text read-out of the headline numbers.""" con = summary[summary["sweep"] == "con"] print("\n=== headline findings ===") even = con[con["con_delta"] == 0] if not even.empty: print("Even-con win rate by policy (skill should climb monotonically):") for _, r in even.sort_values("player_ctrl").iterrows(): print(f" {r['player_ctrl']:>10}: {r['player_win_rate'] * 100:5.1f}% " f"(mean {r['mean_seconds']:.1f}s)") # The guardrail only means something in the CONTESTED band: at trivial # (under-con) and lethal (over-con) tiers every policy wins 100% / 0%, so the # kit can't add value to an already-decided fight. Averaging those in hides # the signal. Restrict to tiers where the full kit is genuinely in play. kit_by_delta = ( con[con["player_ctrl"] == "skill80"].set_index("con_delta")["player_win_rate"] ) auto_by_delta = ( con[con["player_ctrl"] == "auto_only"].set_index("con_delta")["player_win_rate"] ) contested = [d for d, w in kit_by_delta.items() if 0.05 < w < 0.95] if contested: auto_c = auto_by_delta.reindex(contested).mean() kit_c = kit_by_delta.reindex(contested).mean() print(f"\nContested tiers (kit win-rate in 5–95%): {sorted(contested)}") print(f" auto-attack-only mean win rate: {auto_c * 100:5.1f}%") print(f" full-kit (skill80) mean win rate: {kit_c * 100:5.1f}%") verdict = ( "GREEN — abilities beat turtling where it matters" if kit_c > auto_c + 0.05 else "RED — auto-attack dominates even contested fights" ) print(f" guardrail: {verdict}") else: print("\nNo contested tiers found (curve is a step) — widen the con curve.") arch = summary[summary["sweep"] == "archetypes"] if not arch.empty: print("\nArchetype matchups (skill80 player win rate):") for _, r in arch.sort_values("matchup").iterrows(): print(f" vs {r['matchup']:>10} (con {r['con_delta']:+d}): " f"{r['player_win_rate'] * 100:5.1f}% mean {r['mean_seconds']:.1f}s") # %% def main(): ap = argparse.ArgumentParser(description="Analyze combat-sim output.") ap.add_argument("--data", default="../out", type=Path, help="dir with fights.csv/summary.csv") ap.add_argument("--plots", default="plots", type=Path, help="dir to write PNGs") args = ap.parse_args() fights, summary, sample = load(args.data) args.plots.mkdir(parents=True, exist_ok=True) print(f"loaded {len(fights):,} fights across {len(summary)} cells from {args.data}") plot_difficulty_curve(summary, args.plots) plot_duration_tent(summary, args.plots) plot_guardrail(summary, args.plots) plot_stagger_frequency(summary, args.plots) plot_sample_fight(sample, args.plots) print_findings(summary) print(f"\nplots written to {args.plots}/") if __name__ == "__main__": main()