reikhelm/combat/README.md
Parley Hatch 57dba5e149 feat(combat): order-of-magnitude slowdown — fights now ~20-30s
Fights were ~3-5s; the user wanted 20-30s. Tuned the table (and added one
mechanic) so they land there, re-validated against the sim throughout.

- Engine: a `global_cooldown` (the genre swing timer, §10 "attack delay") — an
  actor can't start a new action until it elapses, so no button-masher can
  out-DPS the intended tempo. Config-driven (`global_cooldown` ticks; 0 = off).
  Actor gains `next_action_at`; engine gates decisions on it.
- Controller: ScriptedPlayer now pressures with its heavy proactively when
  healthy (not only on the exact overrun frame), so the sim measures what a real
  player experiences instead of an unrealistically patient bound.
- Config rebalance: damage cut + flattened, fatigue/regen/recovery co-tuned, and
  the kit made roughly **DPS-neutral** with auto-attack (each ability's cooldown
  matched to auto's damage-per-second). Under the global cooldown that's the key
  insight: abilities win through stagger leverage + timing, not raw throughput —
  otherwise strong abilities trivialize the pace and flat ones aren't worth their
  fill (the guardrail kept inverting until this landed). Auto-attacks (difficulty
  0) also no longer fizzle.
- Front-end: cache-bust the config fetch so edits are always picked up; widened
  the sim's archetype sweep to all six monsters.

Result (sim, skilled play ≈ real play): monster fights 16-38s (centered ~24s);
guardrail steep and green (even-con auto 0% → skill20 2% → skill50 12% →
skill80 44% → skill100 78%); all six monsters winnable. Verified live in-browser:
realistic play lands ~15-30s and a clean mid-fight reads perfectly on the dual bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:34:10 -06:00

159 lines
7.8 KiB
Markdown
Raw Permalink 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
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 ~2030s** — monster matchups (a skilled player) resolve in roughly
1638s (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 ~2030s 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).