reikhelm/combat
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
..
analysis feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00
combat-core feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00
combat-sim feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00
configs feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00
.gitignore feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00
Cargo.lock feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00
Cargo.toml feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00
README.md feat(combat): deterministic combined-pool combat engine + sim + analysis 2026-06-03 12:13:04 -06:00

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.

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 magnitudesvigor/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

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