reikhelm/combat/README.md
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

137 lines
6.7 KiB
Markdown
Raw 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.

# combat — a combined-pool combat system
> A standalone experiment in the reikhelm sandbox. It borrows the repo's
> conventions (pure deterministic core, serde config, ChaCha8 RNG, headless-first,
> front-ends as consumers) but has **no dependency** on `reikhelm-core`. Its own
> Cargo workspace; one experiment, one isolated build graph.
>
> Design spec: [`../.dev/2026-06-02-combat-system/spec/design.md`](../.dev/2026-06-02-combat-system/spec/design.md).
## The idea
A real-time (tick-based) combat system in the EverQuest / Daggerfall lineage —
auto-attack + skills + magic — built on one deliberately unusual idea: **a single
combined resource pool, `Vigor`**, that replaces separate HP / mana / stamina.
Everything you do *and* everything done to you draws on the same bar. **Offense
literally spends your survival.**
It is **a deterministic simulation first, a playable game second.** "Feel" (fight
duration, difficulty curve) is not eyeballed — it is *measured* across tens of
thousands of simulated fights and tuned to target shapes.
### The spine — two axes and a squeeze
Every combatant has one pool with three derived quantities:
| Term | Meaning |
|------|---------|
| **Vigor (fill)** | Current value. **Also your absorb capacity** for the stagger cascade. |
| **Cap (ceiling)** | `max_vigor fatigue`. The highest fill can regen to *right now*. |
| **Fatigue** | Fight-scoped ceiling loss. Every point that *leaves* the pool adds some. |
Every combat verb is a point on two axes — cost to *fill* and cost to *ceiling*.
Offense costs both; auto-attack/defense cost a little ceiling; recovery is the
inverse. You don't lose by emptying fill once — you lose because **attrition
grinds your ceiling down**, a lower ceiling caps a lower fill, and a low fill lets
the next hit overrun your capacity and trigger the **stagger cascade**
(absorbed → dazed → unconscious → dead). Everything points at the same death.
## Layout
```
combat/
combat-core/ pure deterministic engine — the single source of truth
combat-sim/ batch harness: load config, run sweeps, export CSV/JSON
analysis/ Python (pandas/matplotlib) over the exported data
configs/ the tunable "table" (default.json)
```
`combat-core` has no I/O, no rendering, no real-time. A fight is a pure function
of `(rules, seed, actors, controllers)`, reproducible bit-for-bit. The sim and
(later) any visual front-end are just consumers.
### Inside `combat-core`
| Module | Role |
|--------|------|
| `fixed` | integer fixed-point math — the only place magnitudes round (banker's rounding) |
| `rng` | vendored order-independent, version-stable deterministic RNG (+ integer `chance_bp`) |
| `con` | the level-delta → multiplier con curve |
| `mitigation` | the ordered "how much lands" pipeline (armor → con → defense → stance → buff) |
| `effect` / `ability` | the composable effect library + unified ability model (skills == spells) |
| `actor` | the symmetric combatant: combined pool, stagger cascade, statuses, competency, loadout |
| `controller` | pluggable intent — `SwingOnCooldown`, `ScriptedPlayer { skill }`, `HoldAndPunish` |
| `config` | the full tunable surface, compiled to an integer-only `Rules` |
| `event` | the structured event stream + metrics recorder |
| `engine` | the fixed-tick loop + canonical within-tick resolution order |
### Determinism is load-bearing
"Change one knob, observe the isolated delta" only holds if unrelated rolls don't
reshuffle and arithmetic doesn't drift. Two guarantees make it true:
- **Order-independent RNG forking** — every roll is keyed
`root.fork("actor{id}:{kind}:tick{t}")`, so adding an effect to one ability
never shifts another actor's or tick's stream. (Vendored verbatim; a hand-rolled
splitmix64 + FNV-1a mix, *not* `DefaultHasher`.)
- **Integer fixed-point magnitudes** — `vigor`/`cap`/`fatigue` and every formula
are integer milli-units with one documented rounding rule; `f64` is confined to
config parsing. Bit-exact across compilers / opt levels / machines.
The within-tick order is itself an invariant (reordering changes outcomes), and
**decisions are simultaneous within a tick** — all controllers read the same
start-of-tick snapshot, then commit in id order — so a perfect mirror match is
fair.
## Build & run
```sh
# (cargo on PATH: export PATH="$HOME/.cargo/bin:$PATH")
cd combat
cargo test # unit + determinism integration tests
cargo build --release
# Run the sweeps → out/{fights.csv, summary.csv, sample_fight.json}
./target/release/combat-sim --config configs/default.json --out out --seeds 300
# Analyze → plots/*.png + a text read-out
cd analysis
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
./.venv/bin/python analyze.py --data ../out --plots plots
```
`combat-sim` runs two sweeps:
- **con** — player vs. a fixed competent benchmark across level delta × player
policy (auto-only, skill 20/50/80/100). The auto-only policy is the §12
anti-turtle guardrail.
- **archetypes** — a skilled player against golem / rat / battlemage.
## What the data shows (default config)
With the shipped `configs/default.json` (these numbers are tuned placeholders — the
whole point is that they're a table you edit):
- **Duration tent** — fights are trivially short at the con extremes (~15s) and
longest/most-contested at even con (~22s). The intended "trivial → satisfying →
lethal" shape.
- **Skill matters** — at even con, win rate climbs monotonically with policy:
auto-only **~3%** → skill20 **29%** → skill80 **42%** → skill100 **44%**.
- **Anti-turtle guardrail is green** — in the contested band the full kit beats
auto-attack-only decisively (≈42% vs ≈3% at even con). Spending on the
interesting verbs is worth it, which is the whole thesis.
- **Mirror matches are fair** — even-con skill-vs-skill is ≈ symmetric.
Getting there was the sim working as designed: the *first* default numbers
produced a one-tick cliff and 82% mutual-KO draws, and revealed the §15 deepest
risk live — a poke skill that was strictly worse than the free auto-attack, so
auto-only out-won the full kit. Tuning the table (con curve, competency coupling,
ability efficiency, damage variance) moved it to the curves above. **That loop —
sim → measure → tune → re-sim — is the deliverable.**
## Tuning
Everything balance-relevant lives in `configs/default.json`. Editing it is the
primary balancing act; only structural rule changes need a recompile. The levers
the feel rides on (spec §15): the **con curve** shape, the **regen vs. drain vs.
spend** rates (co-tuned or even-con fights are un-loseable or stagger-locked), and
keeping abilities ahead of auto-attack on damage-per-resource or utility.