reikhelm/combat/analysis/analyze.py
Parley Hatch 5055feb4c3 feat(combat): deterministic combined-pool combat engine + sim + analysis
Build the full combat-system experiment from its spec, no slices deferred.

combat-core — pure, headless, deterministic engine (the single source of truth):
- One combined Vigor pool (HP+mana+stamina merged) with the two-axis
  (fill, ceiling) cost model; offense spends survival.
- Stagger cascade (absorbed/dazed/unconscious/dead), capacity = current fill,
  ceiling collapse via fatigue as the real loss condition; revive-on-recovery.
- Unified ability model (skills == spells) with the delivery+effects+potency+
  targets cost composer and a full effect library (fill/ceiling damage, DoT/HoT,
  regen-sabotage, fatigue-amp, mitigation buff/debuff, recovery).
- Ordered mitigation pipeline, config-driven con curve, competency/fizzle/resist,
  loadout/memorization, active-defense windows.
- Symmetric actors; pluggable controllers (SwingOnCooldown, ScriptedPlayer{skill},
  HoldAndPunish). Fixed-tick loop with the canonical within-tick resolution order;
  decisions are simultaneous within a tick so mirror matches are fair.
- Determinism is load-bearing: vendored order-independent RNG fork (+ integer
  chance_bp so no f64 touches the hot path) and integer fixed-point magnitudes
  with banker's rounding. 48 tests incl. bit-reproducible event-log integration.

combat-sim — batch harness: loads the config "table", runs con-tier x skill and
archetype sweeps (tens of thousands of fights), exports per-fight CSV, an
aggregated summary, and one fight's full event log.

analysis — pandas/matplotlib layer: difficulty curve, duration tent, anti-turtle
guardrail, stagger frequency, and one fight's fill/ceiling time series.

The sim did its job: the first default numbers produced a one-tick cliff and 82%
mutual-KO draws and surfaced the spec's deepest risk live (a poke skill strictly
worse than free auto-attack, so auto-only out-won the full kit). Tuning the table
moved it to the intended shape — duration tent peaking ~22s at even con, a
monotone skill->win-rate gradient (auto ~3% -> skill100 44% at even con), and a
green anti-turtle guardrail. That sim->measure->tune loop is the deliverable.

Own Cargo workspace, fully decoupled from the root reikhelm workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:13:04 -06:00

229 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 595%): {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()