reikhelm/combat/README.md
Parley Hatch 902a233229 feat(combat): playable browser front-end + AI monster portraits
A real-time consumer of combat-core (the front-end the spec designs for, §14):
pick a foe, manage your single Vigor pool, try not to spend your survival.

- combat-core: a HumanController (input-driven, spec §5) behind a shared intent
  cell, plus Encounter::tick_once / drain_events so a wall-clock front-end can
  drive the engine a tick at a time (§10). Trivial abilities (difficulty 0, e.g.
  auto-attack) no longer fizzle. Engine unchanged otherwise; 48 tests still green.
- combat-wasm: a wasm-bindgen Game bridge — tick-stepping, JSON world state, the
  player's ability list, and a flavorful MonsterAI that uses each monster's
  signature kit (the wraith curses your ceiling) on a coin-flip so big hits space
  into a readable duel. Own workspace member, kept out of the default host build.
- combat-web: an atmospheric battler. The signature dual Vigor bar shows fill,
  the regen-headroom ceiling, the ceiling marker, and the dark cracked fatigue
  zone in one bar — the squeeze made legible. Floating damage, cast telegraphs,
  combat log, result overlay. Verified end-to-end in a real browser.
- tools/portraits.py: stdlib text-to-image client reusing the proven LAN Z-Image
  stack (minus the depth ControlNet) to render the bestiary. 7 portraits committed.
- Added skeleton / troll / wraith monster stat blocks; bumped the duelist's
  durability so fights are readable (even-con ~30s) and active play beats every
  monster while passive auto-attacking loses the hard ones. Re-validated the sim:
  the skill gradient and anti-turtle guardrail still hold (auto ~1% → skill100 34%
  at even con). README findings updated to match.

Verified live: roster screen, win/lose, the dual bars, telegraphed casts, and a
3s active-play victory over the Skeleton Knight.

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

153 lines
7.3 KiB
Markdown
Raw 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):
- **Duration tent** — fights are trivially short at the con extremes (~15s) and
longest/most-contested at even con (~30s). The intended "trivial → satisfying →
lethal" shape.
- **Skill matters** — at even con, win rate climbs monotonically with policy:
auto-only **~1%** → skill20 **10%** → skill50 **24%** → skill80 **29%**
skill100 **34%**.
- **Anti-turtle guardrail is green** — in the contested band the full kit beats
auto-attack-only decisively (≈29% vs ≈1% 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.