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>
137 lines
6.7 KiB
Markdown
137 lines
6.7 KiB
Markdown
# 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 (~1–5s) 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.
|