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