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>
229 lines
9.2 KiB
Python
229 lines
9.2 KiB
Python
#!/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()
|