# 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
  combat-wasm/   wasm-bindgen bridge — drive the engine from a browser
  combat-web/    a playable real-time battler (the front-end consumer, §14)
  analysis/      Python (pandas/matplotlib) over the exported data
  configs/       the tunable "table" (default.json)
  tools/         portraits.py — AI monster art via the LAN Z-Image stack
```

`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.

### Play it (browser)

The same engine, real-time, with AI-rendered monster portraits — pick a foe and
fight. See [`combat-web/README.md`](combat-web/README.md) for details.

```sh
wasm-pack build combat-wasm --dev --target web \
  --out-dir ../combat-web/pkg --out-name combat_wasm   # build the bridge → pkg/
python3 -m http.server 8013 --directory .              # serve combat/ as root
open http://127.0.0.1:8013/combat-web/
```

## 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):

- **Fights last ~20–30s** — monster matchups (a skilled player) resolve in roughly
  16–38s (melee bruisers faster, casters longer). The pace is set by the **global
  cooldown** (the swing timer, §10): you act ~once per 1.5s, so no button-masher
  can out-DPS the intended tempo.
- **Skill matters** — at even con, win rate climbs steeply and monotonically with
  policy: auto-only **0%** → skill20 **2%** → skill50 **12%** → skill80 **44%** →
  skill100 **78%**.
- **Anti-turtle guardrail is green** — the full kit dominates auto-attack-only
  (78% vs 0% at even con). Spending on the interesting verbs is worth it, which is
  the whole thesis.
- **All six monsters are winnable** by skilled play; the Golem and Troll demand it.

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 strictly worse than the free auto-attack, so auto-only
out-won the full kit. Slowing the fights to a ~20–30s feel surfaced the next
lesson: under a global cooldown, any ability that just out-damages auto-attack
either trivializes the pace or isn't worth its fill, so the kit is tuned
**DPS-neutral** — abilities match auto's damage-per-second and win through
*stagger leverage and timing*, not raw throughput. **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 **`global_cooldown`** (raise it → slower, more
deliberate fights; the cleanest uniform pace knob), 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 *stagger /
utility* (under the global cooldown, raw DPS just trivializes the pace).
