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>
159 lines
7.8 KiB
Markdown
159 lines
7.8 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
|
||
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).
|