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>
This commit is contained in:
parent
e112ef5ee9
commit
5055feb4c3
24 changed files with 5275 additions and 0 deletions
|
|
@ -36,6 +36,14 @@ primitive vocabulary (Partitioner / Shaper / Connector / Placer / Decorator).
|
||||||
workstation) → Floyd–Steinberg dither through a 256-color palette → signature
|
workstation) → Floyd–Steinberg dither through a 256-color palette → signature
|
||||||
pixel art. See `tools/README.md`.
|
pixel art. See `tools/README.md`.
|
||||||
|
|
||||||
|
### Combined-pool combat system (Rust + Python)
|
||||||
|
- `combat/` — a deterministic, headless combat engine built on one combined
|
||||||
|
`Vigor` pool (HP + mana + stamina merged), so offense literally spends your
|
||||||
|
survival. Sim-first: a pure Rust core (`combat-core`) + a batch sweep harness
|
||||||
|
(`combat-sim`) that runs tens of thousands of fights and exports metrics, plus a
|
||||||
|
Python analysis layer that measures "feel" (difficulty curve, fight duration)
|
||||||
|
instead of eyeballing it. Its own Cargo workspace. See `combat/README.md`.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
11
combat/.gitignore
vendored
Normal file
11
combat/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Rust build output (this is its own workspace).
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Python analysis virtualenv + generated plots.
|
||||||
|
analysis/.venv/
|
||||||
|
analysis/plots/
|
||||||
|
|
||||||
|
# Exported sim data (regenerate with `combat-sim`).
|
||||||
|
out/
|
||||||
|
*.csv
|
||||||
|
sample_fight.json
|
||||||
190
combat/Cargo.lock
generated
Normal file
190
combat/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combat-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combat-sim"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"combat-core",
|
||||||
|
"csv",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.150"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.50"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.50"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
9
combat/Cargo.toml
Normal file
9
combat/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["combat-core", "combat-sim"]
|
||||||
|
|
||||||
|
# This is a SELF-CONTAINED workspace, deliberately separate from the root
|
||||||
|
# reikhelm workspace. The root `Cargo.toml` lists its members explicitly (no
|
||||||
|
# globs) and this folder declares its own `[workspace]`, so `combat/` neither
|
||||||
|
# pollutes nor depends on the dungeon-generator crates. One experiment, one
|
||||||
|
# isolated build graph (see ../README.md).
|
||||||
137
combat/README.md
Normal file
137
combat/README.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# 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.
|
||||||
229
combat/analysis/analyze.py
Normal file
229
combat/analysis/analyze.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analysis layer for the combined-pool combat sim (spec §12).
|
||||||
|
|
||||||
|
Consumes the CSV/JSON that ``combat-sim`` exports and renders the headline views
|
||||||
|
that make "feel" measurable instead of eyeballed:
|
||||||
|
|
||||||
|
1. Difficulty curve — player win-rate vs. con tier, one line per policy.
|
||||||
|
2. Duration tent — time-to-resolution vs. con tier (trivial → contested → lethal).
|
||||||
|
3. Auto-attack guardrail — auto-only win-rate vs. the full kit (§12 anti-turtle).
|
||||||
|
4. Stagger frequency — staggers inflicted vs. con tier.
|
||||||
|
5. One fight's ceiling-decay — the combined pool (fill) and ceiling (cap) over time.
|
||||||
|
|
||||||
|
Run after a sweep:
|
||||||
|
|
||||||
|
python analyze.py --data ../out --plots plots
|
||||||
|
|
||||||
|
The file is also organized into ``# %%`` cells so it doubles as a notebook in
|
||||||
|
Jupyter / VS Code interactive.
|
||||||
|
"""
|
||||||
|
# %%
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg") # headless: write PNGs, never block on a window
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def load(data_dir: Path):
|
||||||
|
"""Load the three sim artifacts; tolerate a missing sample fight."""
|
||||||
|
fights = pd.read_csv(data_dir / "fights.csv")
|
||||||
|
summary = pd.read_csv(data_dir / "summary.csv")
|
||||||
|
sample_path = data_dir / "sample_fight.json"
|
||||||
|
sample = json.loads(sample_path.read_text()) if sample_path.exists() else None
|
||||||
|
return fights, summary, sample
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def plot_difficulty_curve(summary: pd.DataFrame, out: Path):
|
||||||
|
"""Win-rate vs. con tier, one line per player policy — the difficulty curve
|
||||||
|
and the skill gradient in one panel."""
|
||||||
|
con = summary[summary["sweep"] == "con"]
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 5))
|
||||||
|
for ctrl, grp in sorted(con.groupby("player_ctrl")):
|
||||||
|
grp = grp.sort_values("con_delta")
|
||||||
|
ax.plot(grp["con_delta"], grp["player_win_rate"] * 100, marker="o", label=ctrl)
|
||||||
|
ax.axvline(0, color="grey", lw=0.8, ls=":")
|
||||||
|
ax.set_xlabel("con tier (enemy_level − player_level)")
|
||||||
|
ax.set_ylabel("player win rate (%)")
|
||||||
|
ax.set_title("Difficulty curve — win rate by con tier and policy")
|
||||||
|
ax.set_ylim(-3, 103)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend(title="player policy")
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out / "difficulty_curve.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def plot_duration_tent(summary: pd.DataFrame, out: Path):
|
||||||
|
"""Mean fight duration vs. con tier — the 'trivial → contested → lethal' tent."""
|
||||||
|
con = summary[(summary["sweep"] == "con") & (summary["player_ctrl"] == "skill80")]
|
||||||
|
con = con.sort_values("con_delta")
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 5))
|
||||||
|
ax.plot(con["con_delta"], con["mean_seconds"], marker="o", color="darkorange")
|
||||||
|
ax.axvline(0, color="grey", lw=0.8, ls=":")
|
||||||
|
ax.set_xlabel("con tier (enemy_level − player_level)")
|
||||||
|
ax.set_ylabel("mean time-to-resolution (s)")
|
||||||
|
ax.set_title("Duration tent — fights are longest (most contested) at even con")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out / "duration_tent.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def plot_guardrail(summary: pd.DataFrame, out: Path):
|
||||||
|
"""The §12 anti-turtle guardrail: auto-attack-only win-rate against the best
|
||||||
|
full-kit policy. If auto-only ever matches the kit, the interesting verbs are
|
||||||
|
a tax and the thesis is broken."""
|
||||||
|
con = summary[summary["sweep"] == "con"]
|
||||||
|
auto = con[con["player_ctrl"] == "auto_only"].sort_values("con_delta")
|
||||||
|
best = (
|
||||||
|
con[con["player_ctrl"] != "auto_only"]
|
||||||
|
.groupby("con_delta")["player_win_rate"]
|
||||||
|
.max()
|
||||||
|
.reset_index()
|
||||||
|
.sort_values("con_delta")
|
||||||
|
)
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 5))
|
||||||
|
ax.plot(auto["con_delta"], auto["player_win_rate"] * 100, marker="o", label="auto-attack only")
|
||||||
|
ax.plot(best["con_delta"], best["player_win_rate"] * 100, marker="s", label="best full kit")
|
||||||
|
ax.fill_between(
|
||||||
|
best["con_delta"],
|
||||||
|
auto.set_index("con_delta").reindex(best["con_delta"])["player_win_rate"].values * 100,
|
||||||
|
best["player_win_rate"] * 100,
|
||||||
|
alpha=0.15,
|
||||||
|
color="green",
|
||||||
|
label="value of abilities",
|
||||||
|
)
|
||||||
|
ax.set_xlabel("con tier (enemy_level − player_level)")
|
||||||
|
ax.set_ylabel("player win rate (%)")
|
||||||
|
ax.set_title("Anti-turtle guardrail — abilities must beat auto-attack")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend()
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out / "guardrail.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def plot_stagger_frequency(summary: pd.DataFrame, out: Path):
|
||||||
|
"""Staggers the player lands vs. con tier (a skilled player)."""
|
||||||
|
con = summary[(summary["sweep"] == "con") & (summary["player_ctrl"] == "skill80")]
|
||||||
|
con = con.sort_values("con_delta")
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 5))
|
||||||
|
ax.plot(con["con_delta"], con["mean_player_staggers_inflicted"], marker="o", color="purple")
|
||||||
|
ax.set_xlabel("con tier (enemy_level − player_level)")
|
||||||
|
ax.set_ylabel("mean staggers inflicted / fight")
|
||||||
|
ax.set_title("Stagger frequency by con tier (skilled player)")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out / "stagger_frequency.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def plot_sample_fight(sample: dict, out: Path):
|
||||||
|
"""One fight's combined pool over time: fill (the visible bar) and cap (the
|
||||||
|
ceiling that attrition grinds down). The squeeze made legible."""
|
||||||
|
if sample is None:
|
||||||
|
print(" (no sample_fight.json — skipping time-series plot)")
|
||||||
|
return
|
||||||
|
metrics = sample["metrics"]
|
||||||
|
tick_rate = metrics.get("tick_rate", 10)
|
||||||
|
fig, ax = plt.subplots(figsize=(9, 5))
|
||||||
|
for actor in metrics["actors"]:
|
||||||
|
fill = [v / 1000.0 for v in actor.get("fill_series", [])]
|
||||||
|
cap = [v / 1000.0 for v in actor.get("cap_series", [])]
|
||||||
|
if not fill:
|
||||||
|
continue
|
||||||
|
t = [i / tick_rate for i in range(len(fill))]
|
||||||
|
(line,) = ax.plot(t, fill, label=f'{actor["name"]} — vigor (fill)')
|
||||||
|
ax.plot(t, cap, ls="--", color=line.get_color(), alpha=0.7, label=f'{actor["name"]} — cap (ceiling)')
|
||||||
|
ax.set_xlabel("time (s)")
|
||||||
|
ax.set_ylabel("vigor")
|
||||||
|
ax.set_title("One fight — fill spent toward zero, ceiling ground down by attrition")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend(fontsize=8)
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out / "sample_fight.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def print_findings(summary: pd.DataFrame):
|
||||||
|
"""A terse text read-out of the headline numbers."""
|
||||||
|
con = summary[summary["sweep"] == "con"]
|
||||||
|
print("\n=== headline findings ===")
|
||||||
|
|
||||||
|
even = con[con["con_delta"] == 0]
|
||||||
|
if not even.empty:
|
||||||
|
print("Even-con win rate by policy (skill should climb monotonically):")
|
||||||
|
for _, r in even.sort_values("player_ctrl").iterrows():
|
||||||
|
print(f" {r['player_ctrl']:>10}: {r['player_win_rate'] * 100:5.1f}% "
|
||||||
|
f"(mean {r['mean_seconds']:.1f}s)")
|
||||||
|
|
||||||
|
# The guardrail only means something in the CONTESTED band: at trivial
|
||||||
|
# (under-con) and lethal (over-con) tiers every policy wins 100% / 0%, so the
|
||||||
|
# kit can't add value to an already-decided fight. Averaging those in hides
|
||||||
|
# the signal. Restrict to tiers where the full kit is genuinely in play.
|
||||||
|
kit_by_delta = (
|
||||||
|
con[con["player_ctrl"] == "skill80"].set_index("con_delta")["player_win_rate"]
|
||||||
|
)
|
||||||
|
auto_by_delta = (
|
||||||
|
con[con["player_ctrl"] == "auto_only"].set_index("con_delta")["player_win_rate"]
|
||||||
|
)
|
||||||
|
contested = [d for d, w in kit_by_delta.items() if 0.05 < w < 0.95]
|
||||||
|
if contested:
|
||||||
|
auto_c = auto_by_delta.reindex(contested).mean()
|
||||||
|
kit_c = kit_by_delta.reindex(contested).mean()
|
||||||
|
print(f"\nContested tiers (kit win-rate in 5–95%): {sorted(contested)}")
|
||||||
|
print(f" auto-attack-only mean win rate: {auto_c * 100:5.1f}%")
|
||||||
|
print(f" full-kit (skill80) mean win rate: {kit_c * 100:5.1f}%")
|
||||||
|
verdict = (
|
||||||
|
"GREEN — abilities beat turtling where it matters"
|
||||||
|
if kit_c > auto_c + 0.05
|
||||||
|
else "RED — auto-attack dominates even contested fights"
|
||||||
|
)
|
||||||
|
print(f" guardrail: {verdict}")
|
||||||
|
else:
|
||||||
|
print("\nNo contested tiers found (curve is a step) — widen the con curve.")
|
||||||
|
|
||||||
|
arch = summary[summary["sweep"] == "archetypes"]
|
||||||
|
if not arch.empty:
|
||||||
|
print("\nArchetype matchups (skill80 player win rate):")
|
||||||
|
for _, r in arch.sort_values("matchup").iterrows():
|
||||||
|
print(f" vs {r['matchup']:>10} (con {r['con_delta']:+d}): "
|
||||||
|
f"{r['player_win_rate'] * 100:5.1f}% mean {r['mean_seconds']:.1f}s")
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Analyze combat-sim output.")
|
||||||
|
ap.add_argument("--data", default="../out", type=Path, help="dir with fights.csv/summary.csv")
|
||||||
|
ap.add_argument("--plots", default="plots", type=Path, help="dir to write PNGs")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
fights, summary, sample = load(args.data)
|
||||||
|
args.plots.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"loaded {len(fights):,} fights across {len(summary)} cells from {args.data}")
|
||||||
|
plot_difficulty_curve(summary, args.plots)
|
||||||
|
plot_duration_tent(summary, args.plots)
|
||||||
|
plot_guardrail(summary, args.plots)
|
||||||
|
plot_stagger_frequency(summary, args.plots)
|
||||||
|
plot_sample_fight(sample, args.plots)
|
||||||
|
print_findings(summary)
|
||||||
|
print(f"\nplots written to {args.plots}/")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2
combat/analysis/requirements.txt
Normal file
2
combat/analysis/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pandas>=2.0
|
||||||
|
matplotlib>=3.7
|
||||||
13
combat/combat-core/Cargo.toml
Normal file
13
combat/combat-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[package]
|
||||||
|
name = "combat-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.96"
|
||||||
|
description = "Pure, deterministic, headless combined-pool combat engine."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rand_chacha = "0.10.0"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1.0.150"
|
||||||
330
combat/combat-core/src/ability.rs
Normal file
330
combat/combat-core/src/ability.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
//! Abilities (spec §6): skills and spells are **one thing**. "Kick" = melee
|
||||||
|
//! delivery + a fill-damage effect. "Fireball" = projectile delivery + a fire
|
||||||
|
//! (fill-damage) effect with a wind-up. Both resolve through the same composed
|
||||||
|
//! two-axis cost grid.
|
||||||
|
//!
|
||||||
|
//! An [`AbilitySpec`] is authored data; [`Ability`] is its compiled form with an
|
||||||
|
//! **absolute** [`AbilityCost`] baked in by [`compose_cost`]. The only structural
|
||||||
|
//! axis separating a skill from a spell is `cast_time` (instant vs. wind-up).
|
||||||
|
|
||||||
|
use crate::effect::{EffectSpec, Valence};
|
||||||
|
use crate::fixed::{apply_bp, units, Milli};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Delivery / shape — sets targeting and a base cost contribution.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Delivery {
|
||||||
|
/// Affects only the caster (recovery, self-buffs).
|
||||||
|
SelfCast,
|
||||||
|
/// One adjacent target, no wind-up cost premium.
|
||||||
|
Touch,
|
||||||
|
/// One target in melee — the auto-attack / weapon-skill shape.
|
||||||
|
Melee,
|
||||||
|
/// One target at range; the canonical spell shape.
|
||||||
|
Projectile,
|
||||||
|
/// One target, channel-flavored ranged.
|
||||||
|
Beam,
|
||||||
|
/// Up to `n` targets in an area.
|
||||||
|
Aoe { n: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Delivery {
|
||||||
|
/// Whether this delivery can reach more than one enemy (informational; the
|
||||||
|
/// authoritative target count is [`AbilitySpec::targets`]).
|
||||||
|
pub fn max_targets(&self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Delivery::Aoe { n } => *n,
|
||||||
|
_ => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coefficients turning a spec into a cost — "the table" for cost (spec §11). All
|
||||||
|
/// base costs are in **displayed units** of fill; fatigue defaults to a ratio of
|
||||||
|
/// the composed fill unless an ability overrides it.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CostModel {
|
||||||
|
pub base_fill_self: i64,
|
||||||
|
pub base_fill_touch: i64,
|
||||||
|
pub base_fill_melee: i64,
|
||||||
|
pub base_fill_projectile: i64,
|
||||||
|
pub base_fill_beam: i64,
|
||||||
|
pub base_fill_aoe: i64,
|
||||||
|
/// Default fatigue ("spend leaves a mark", §3): `fatigue = fill × this_bp`.
|
||||||
|
/// Applied when an ability does not author `fatigue_units`.
|
||||||
|
pub fatigue_ratio_bp: i64,
|
||||||
|
/// Difficulty per unit of composed fill cost (drives fizzle, §9), in bp.
|
||||||
|
/// `difficulty = round(fill_units × this_bp / 10_000)` when not authored.
|
||||||
|
pub difficulty_per_fill_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CostModel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
base_fill_self: 0,
|
||||||
|
base_fill_touch: 1,
|
||||||
|
base_fill_melee: 1,
|
||||||
|
base_fill_projectile: 2,
|
||||||
|
base_fill_beam: 3,
|
||||||
|
base_fill_aoe: 4,
|
||||||
|
fatigue_ratio_bp: 2_000, // 20% of every point spent becomes fatigue
|
||||||
|
difficulty_per_fill_bp: 10_000, // difficulty ≈ fill cost in units
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CostModel {
|
||||||
|
fn base_fill(&self, d: Delivery) -> i64 {
|
||||||
|
match d {
|
||||||
|
Delivery::SelfCast => self.base_fill_self,
|
||||||
|
Delivery::Touch => self.base_fill_touch,
|
||||||
|
Delivery::Melee => self.base_fill_melee,
|
||||||
|
Delivery::Projectile => self.base_fill_projectile,
|
||||||
|
Delivery::Beam => self.base_fill_beam,
|
||||||
|
Delivery::Aoe { .. } => self.base_fill_aoe,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authored, explicit cost in displayed units (used by `cost_override` for
|
||||||
|
/// shapes the composer can't express cleanly, e.g. auto-attack's `(0, small)`).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RawCost {
|
||||||
|
pub fill: i64,
|
||||||
|
pub fatigue: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compiled, absolute cost of an ability, in milli-units. `fatigue` is the
|
||||||
|
/// **net** ceiling change from paying (positive = a mark; recovery's net gain
|
||||||
|
/// comes from its `Recovery` effect, not from a negative cost here).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub struct AbilityCost {
|
||||||
|
pub fill: Milli,
|
||||||
|
pub fatigue: Milli,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authored ability data (config). Compiled to [`Ability`] via [`compile`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AbilitySpec {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// Competency school this draws on for fizzle/effectiveness (§9).
|
||||||
|
pub school: String,
|
||||||
|
pub delivery: Delivery,
|
||||||
|
#[serde(default)]
|
||||||
|
pub effects: Vec<EffectSpec>,
|
||||||
|
/// Scalar on effect magnitude *and* cost. bp; `10_000` == ×1.0.
|
||||||
|
#[serde(default = "default_potency")]
|
||||||
|
pub potency_bp: i64,
|
||||||
|
#[serde(default = "default_targets")]
|
||||||
|
pub targets: u32,
|
||||||
|
/// Wind-up in ticks. `0` == instant (a skill); `>0` == a spell.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cast_time: u32,
|
||||||
|
/// Ticks before this ability is reusable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cooldown: u32,
|
||||||
|
/// Explicit cost in displayed units; bypasses the composer when set.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost_override: Option<RawCost>,
|
||||||
|
/// Explicit fatigue in displayed units; overrides only the fatigue column.
|
||||||
|
#[serde(default)]
|
||||||
|
pub fatigue_units: Option<i64>,
|
||||||
|
/// Explicit difficulty (drives fizzle); derived from cost when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub difficulty: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_potency() -> i64 {
|
||||||
|
10_000
|
||||||
|
}
|
||||||
|
fn default_targets() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compiled ability: spec data plus its baked-in absolute cost and difficulty.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Ability {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub school: String,
|
||||||
|
pub delivery: Delivery,
|
||||||
|
pub effects: Vec<EffectSpec>,
|
||||||
|
pub potency_bp: i64,
|
||||||
|
pub targets: u32,
|
||||||
|
pub cast_time: u32,
|
||||||
|
pub cooldown: u32,
|
||||||
|
pub cost: AbilityCost,
|
||||||
|
pub difficulty: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ability {
|
||||||
|
/// Whether this ability only ever helps its targets (all effects friendly) —
|
||||||
|
/// used by controllers/targeting to route a heal at an ally rather than a foe.
|
||||||
|
pub fn is_support(&self) -> bool {
|
||||||
|
!self.effects.is_empty()
|
||||||
|
&& self.effects.iter().all(|e| e.valence() == Valence::Friendly)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this ability has any hostile effect (it attacks someone).
|
||||||
|
pub fn is_offensive(&self) -> bool {
|
||||||
|
self.effects.iter().any(|e| e.valence() == Valence::Hostile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile a spec into an [`Ability`], composing its absolute cost.
|
||||||
|
pub fn compile(spec: &AbilitySpec, model: &CostModel) -> Ability {
|
||||||
|
let cost = compose_cost(spec, model);
|
||||||
|
let difficulty = spec.difficulty.unwrap_or_else(|| {
|
||||||
|
// Derived difficulty ≈ composed fill cost in units, scaled.
|
||||||
|
let fill_units = cost.fill / crate::fixed::UNIT;
|
||||||
|
crate::fixed::mul_div_round(fill_units, model.difficulty_per_fill_bp, 10_000)
|
||||||
|
});
|
||||||
|
Ability {
|
||||||
|
id: spec.id.clone(),
|
||||||
|
name: spec.name.clone(),
|
||||||
|
school: spec.school.clone(),
|
||||||
|
delivery: spec.delivery,
|
||||||
|
effects: spec.effects.clone(),
|
||||||
|
potency_bp: spec.potency_bp,
|
||||||
|
targets: spec.targets.max(1),
|
||||||
|
cast_time: spec.cast_time,
|
||||||
|
cooldown: spec.cooldown,
|
||||||
|
cost,
|
||||||
|
difficulty: difficulty.max(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose an absolute `(fill, fatigue)` cost (spec §6): base delivery cost plus
|
||||||
|
/// each effect's contribution, scaled by potency, multiplied by target count.
|
||||||
|
///
|
||||||
|
/// `fill = (base_delivery + Σ effect.cost_fill) × potency × targets`, then
|
||||||
|
/// `fatigue = fill × fatigue_ratio` unless authored. An explicit `cost_override`
|
||||||
|
/// short-circuits the whole thing.
|
||||||
|
pub fn compose_cost(spec: &AbilitySpec, model: &CostModel) -> AbilityCost {
|
||||||
|
if let Some(o) = spec.cost_override {
|
||||||
|
return AbilityCost {
|
||||||
|
fill: units(o.fill),
|
||||||
|
fatigue: units(o.fatigue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets = spec.targets.max(1) as i64;
|
||||||
|
let base_units: i64 = model.base_fill(spec.delivery)
|
||||||
|
+ spec.effects.iter().map(|e| e.cost_fill).sum::<i64>();
|
||||||
|
|
||||||
|
// Scale by potency in milli-space, then multiply by target count.
|
||||||
|
let mut fill = apply_bp(units(base_units), spec.potency_bp);
|
||||||
|
fill *= targets;
|
||||||
|
|
||||||
|
let fatigue = match spec.fatigue_units {
|
||||||
|
Some(f) => units(f) * targets, // authored fatigue still scales with targets
|
||||||
|
None => apply_bp(fill, model.fatigue_ratio_bp),
|
||||||
|
};
|
||||||
|
|
||||||
|
AbilityCost { fill, fatigue }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::effect::EffectKind;
|
||||||
|
use crate::fixed::UNIT;
|
||||||
|
|
||||||
|
fn fill_dmg(mag: i64, cost: i64) -> EffectSpec {
|
||||||
|
EffectSpec::new(EffectKind::FillDamage { magnitude_bp: mag }, cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composes_a_simple_skill_cost() {
|
||||||
|
let model = CostModel::default();
|
||||||
|
let spec = AbilitySpec {
|
||||||
|
id: "kick".into(),
|
||||||
|
name: "Kick".into(),
|
||||||
|
school: "martial".into(),
|
||||||
|
delivery: Delivery::Melee,
|
||||||
|
effects: vec![fill_dmg(1_000, 4)], // melee base 1 + effect 4 = 5 fill
|
||||||
|
potency_bp: 10_000,
|
||||||
|
targets: 1,
|
||||||
|
cast_time: 0,
|
||||||
|
cooldown: 0,
|
||||||
|
cost_override: None,
|
||||||
|
fatigue_units: None,
|
||||||
|
difficulty: None,
|
||||||
|
};
|
||||||
|
let ab = compile(&spec, &model);
|
||||||
|
assert_eq!(ab.cost.fill, 5 * UNIT);
|
||||||
|
// 20% fatigue ratio -> 1.0 unit fatigue.
|
||||||
|
assert_eq!(ab.cost.fatigue, UNIT);
|
||||||
|
assert!(ab.is_offensive());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn potency_and_targets_scale_cost() {
|
||||||
|
let model = CostModel::default();
|
||||||
|
let base = AbilitySpec {
|
||||||
|
id: "nuke".into(),
|
||||||
|
name: "Nuke".into(),
|
||||||
|
school: "invocation".into(),
|
||||||
|
delivery: Delivery::Aoe { n: 6 },
|
||||||
|
effects: vec![fill_dmg(2_000, 6)], // aoe base 4 + 6 = 10 fill at 1x,1 target
|
||||||
|
potency_bp: 20_000, // ×2 potency
|
||||||
|
targets: 6, // ×6 targets
|
||||||
|
cast_time: 20,
|
||||||
|
cooldown: 0,
|
||||||
|
cost_override: None,
|
||||||
|
fatigue_units: None,
|
||||||
|
difficulty: None,
|
||||||
|
};
|
||||||
|
let ab = compile(&base, &model);
|
||||||
|
// (10 units × 2.0) × 6 = 120 units fill.
|
||||||
|
assert_eq!(ab.cost.fill, 120 * UNIT);
|
||||||
|
// difficulty derived from fill units (120) × 1.0.
|
||||||
|
assert_eq!(ab.difficulty, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cost_override_makes_auto_attack_shape() {
|
||||||
|
let model = CostModel::default();
|
||||||
|
let spec = AbilitySpec {
|
||||||
|
id: "swing".into(),
|
||||||
|
name: "Swing".into(),
|
||||||
|
school: "martial".into(),
|
||||||
|
delivery: Delivery::Melee,
|
||||||
|
effects: vec![fill_dmg(1_000, 0)],
|
||||||
|
potency_bp: 10_000,
|
||||||
|
targets: 1,
|
||||||
|
cast_time: 0,
|
||||||
|
cooldown: 10,
|
||||||
|
cost_override: Some(RawCost { fill: 0, fatigue: 1 }), // (0 fill, +small)
|
||||||
|
fatigue_units: None,
|
||||||
|
difficulty: None,
|
||||||
|
};
|
||||||
|
let ab = compile(&spec, &model);
|
||||||
|
assert_eq!(ab.cost.fill, 0);
|
||||||
|
assert_eq!(ab.cost.fatigue, UNIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn support_ability_is_detected() {
|
||||||
|
let model = CostModel::default();
|
||||||
|
let spec = AbilitySpec {
|
||||||
|
id: "mend".into(),
|
||||||
|
name: "Mend".into(),
|
||||||
|
school: "restoration".into(),
|
||||||
|
delivery: Delivery::SelfCast,
|
||||||
|
effects: vec![EffectSpec::new(EffectKind::Recovery { magnitude_bp: 1_500 }, 2)],
|
||||||
|
potency_bp: 10_000,
|
||||||
|
targets: 1,
|
||||||
|
cast_time: 0,
|
||||||
|
cooldown: 30,
|
||||||
|
cost_override: None,
|
||||||
|
fatigue_units: None,
|
||||||
|
difficulty: None,
|
||||||
|
};
|
||||||
|
let ab = compile(&spec, &model);
|
||||||
|
assert!(ab.is_support());
|
||||||
|
assert!(!ab.is_offensive());
|
||||||
|
}
|
||||||
|
}
|
||||||
627
combat/combat-core/src/actor.rs
Normal file
627
combat/combat-core/src/actor.rs
Normal file
|
|
@ -0,0 +1,627 @@
|
||||||
|
//! The [`Actor`] (spec §5): every combatant — player or monster — is the *same*
|
||||||
|
//! struct with the *same* combined `Vigor` pool, stagger cascade, and ability
|
||||||
|
//! resolution. Variety is two independent dials: a [`StatBlock`] (archetype) and
|
||||||
|
//! a controller (intent, see `controller.rs`). The Actor never knows who drives
|
||||||
|
//! it.
|
||||||
|
//!
|
||||||
|
//! ## The combined pool (spec §3)
|
||||||
|
//! - `vigor` — current fill. **Also the absorb capacity** the stagger cascade
|
||||||
|
//! compares hits against (§4).
|
||||||
|
//! - `fatigue` — accumulated, fight-scoped ceiling loss. Every point that *leaves*
|
||||||
|
//! the pool (spend or damage) adds some. Recovers only out of combat or via
|
||||||
|
//! recovery effects.
|
||||||
|
//! - `cap = max_vigor − fatigue` — the highest fill can regen to right now. When
|
||||||
|
//! it hits zero, the actor has spent its whole life total and collapses.
|
||||||
|
//!
|
||||||
|
//! This module owns the pure state mutations (pay, take a hit, regen, collapse).
|
||||||
|
//! The engine (`engine.rs`) orchestrates *when* they happen; mitigation is
|
||||||
|
//! computed there and the post-mitigation magnitude handed to [`Actor::take_hit`].
|
||||||
|
|
||||||
|
use crate::ability::{Ability, AbilityCost};
|
||||||
|
use crate::fixed::{apply_bp, clamp, mul_div_round, units, Milli, BP_ONE, UNIT};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub type ActorId = u32;
|
||||||
|
|
||||||
|
/// Where an actor sits on the stagger cascade (spec §4). `Dazed` and
|
||||||
|
/// `Unconscious` are reached by a hit overrunning capacity, or by the ceiling
|
||||||
|
/// collapsing to zero (§3).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum StaggerState {
|
||||||
|
/// Acting normally.
|
||||||
|
Normal,
|
||||||
|
/// Knocked down until `until` (inclusive tick). Regen reduced; cannot act.
|
||||||
|
Dazed { until: u64 },
|
||||||
|
/// Regen zeroed; cannot act. Revivable only by a friendly recovery; otherwise
|
||||||
|
/// follow-up hits grind to 0 → `Dead`.
|
||||||
|
Unconscious,
|
||||||
|
/// Terminal.
|
||||||
|
Dead,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaggerState {
|
||||||
|
pub fn can_act(&self) -> bool {
|
||||||
|
matches!(self, StaggerState::Normal)
|
||||||
|
}
|
||||||
|
pub fn is_down(&self) -> bool {
|
||||||
|
matches!(self, StaggerState::Unconscious | StaggerState::Dead)
|
||||||
|
}
|
||||||
|
pub fn is_dead(&self) -> bool {
|
||||||
|
matches!(self, StaggerState::Dead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a single hit did to the target — returned by [`Actor::take_hit`] so the
|
||||||
|
/// engine can log a precise event and detect interruptions.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum HitOutcome {
|
||||||
|
Absorbed,
|
||||||
|
Dazed,
|
||||||
|
Unconscious,
|
||||||
|
/// The hit (or the grind that followed an earlier one) reached `Dead`.
|
||||||
|
Killed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timed status riding on an actor. `Dazed`/`Unconscious` live in
|
||||||
|
/// [`StaggerState`]; these are the effect-driven ones.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Status {
|
||||||
|
pub kind: StatusKind,
|
||||||
|
/// Inclusive last tick this status applies. Expired when `tick > expires_at`.
|
||||||
|
pub expires_at: u64,
|
||||||
|
pub source: ActorId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum StatusKind {
|
||||||
|
/// Reduce regen by `reduce_bp` (§6.1 regen sabotage).
|
||||||
|
RegenSabotage { reduce_bp: i64 },
|
||||||
|
/// Multiply fatigue-per-spend/per-hit by `(1 + amp_bp)` (§6.1).
|
||||||
|
FatigueAmp { amp_bp: i64 },
|
||||||
|
/// Fill drain per tick, bp of `max_vigor`, with the source's effective level
|
||||||
|
/// for con at tick time.
|
||||||
|
Dot { per_tick_bp: i64, source_eff_level: i32 },
|
||||||
|
/// Fill restore per tick, bp of `max_vigor`.
|
||||||
|
Hot { per_tick_bp: i64 },
|
||||||
|
/// A mitigation contribution at `stage` (block windows, stances, buffs,
|
||||||
|
/// vulnerability debuffs). `amount_bp` is "fraction removed"; negative
|
||||||
|
/// amplifies (a sunder).
|
||||||
|
Mitigation {
|
||||||
|
stage: crate::mitigation::MitigationStage,
|
||||||
|
amount_bp: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-progress spell cast (spec §6 cast time). Cost is already paid; if the
|
||||||
|
/// caster is interrupted before `completes_at`, it is lost (paid for nothing).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Cast {
|
||||||
|
pub ability_id: String,
|
||||||
|
pub targets: Vec<ActorId>,
|
||||||
|
pub completes_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authored archetype (spec §5, config). Compiled into an [`Actor`] at fight
|
||||||
|
/// setup. All magnitudes are **displayed units**; rates are **units per second**
|
||||||
|
/// (converted to per-tick milli at compile, given the tick rate).
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct StatBlock {
|
||||||
|
pub name: String,
|
||||||
|
pub team: u8,
|
||||||
|
pub level: i32,
|
||||||
|
pub max_vigor: i64,
|
||||||
|
pub regen_idle_per_sec: i64,
|
||||||
|
pub regen_combat_per_sec: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub armor_bp: i64,
|
||||||
|
/// Per-school competency (§9). Missing schools default to 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub competency: BTreeMap<String, i64>,
|
||||||
|
/// Ability ids to slot for this fight (memorization, §9). Resolved against
|
||||||
|
/// the config ability library.
|
||||||
|
pub loadout: Vec<String>,
|
||||||
|
/// Starting fill as bp of `max_vigor` (default full).
|
||||||
|
#[serde(default = "default_start_bp")]
|
||||||
|
pub start_vigor_bp: i64,
|
||||||
|
/// Starting fatigue as bp of `max_vigor` (default 0).
|
||||||
|
#[serde(default)]
|
||||||
|
pub start_fatigue_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_start_bp() -> i64 {
|
||||||
|
10_000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compiled, mutable combatant.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Actor {
|
||||||
|
pub id: ActorId,
|
||||||
|
pub name: String,
|
||||||
|
pub team: u8,
|
||||||
|
pub level: i32,
|
||||||
|
|
||||||
|
pub max_vigor: Milli,
|
||||||
|
pub vigor: Milli,
|
||||||
|
pub fatigue: Milli,
|
||||||
|
|
||||||
|
/// Per-tick regen in milli-units, idle and in-combat.
|
||||||
|
pub regen_idle: Milli,
|
||||||
|
pub regen_combat: Milli,
|
||||||
|
|
||||||
|
pub armor_bp: i64,
|
||||||
|
pub competency: BTreeMap<String, i64>,
|
||||||
|
|
||||||
|
/// The slotted abilities usable this fight (memorization commit).
|
||||||
|
pub loadout: Vec<Ability>,
|
||||||
|
/// ability id → tick it becomes usable again.
|
||||||
|
pub cooldowns: BTreeMap<String, u64>,
|
||||||
|
|
||||||
|
pub statuses: Vec<Status>,
|
||||||
|
pub casting: Option<Cast>,
|
||||||
|
pub stagger: StaggerState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor {
|
||||||
|
/// Current ceiling: `max_vigor − fatigue`, clamped to `[0, max_vigor]`.
|
||||||
|
#[inline]
|
||||||
|
pub fn cap(&self) -> Milli {
|
||||||
|
clamp(self.max_vigor - self.fatigue, 0, self.max_vigor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absorb capacity the stagger cascade compares against — current fill (§4).
|
||||||
|
#[inline]
|
||||||
|
pub fn capacity(&self) -> Milli {
|
||||||
|
self.vigor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_alive(&self) -> bool {
|
||||||
|
!self.stagger.is_dead()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "In the fight": conscious enough to matter. A team loses when none of its
|
||||||
|
/// actors satisfy this (spec §3/§4 — knocked out is out).
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
!self.stagger.is_down()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_act(&self) -> bool {
|
||||||
|
self.stagger.can_act() && self.casting.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective level for *outgoing* effectiveness in `school`: base level plus
|
||||||
|
/// competency folded in (spec §7/§9). `per_point_bp` is levels-per-competency-
|
||||||
|
/// point in bp (config). Raw incoming-damage uses plain `level`, not this.
|
||||||
|
pub fn effective_level(&self, school: &str, per_point_bp: i64) -> i32 {
|
||||||
|
let comp = self.competency.get(school).copied().unwrap_or(0);
|
||||||
|
let bonus = mul_div_round(comp, per_point_bp, BP_ONE);
|
||||||
|
self.level + bonus as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn competency_in(&self, school: &str) -> i64 {
|
||||||
|
self.competency.get(school).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this actor afford to *begin* `cost`? Only the fill side gates starting
|
||||||
|
/// (spec §6 affordability). Fatigue is paid regardless once committed.
|
||||||
|
#[inline]
|
||||||
|
pub fn can_afford(&self, cost: AbilityCost) -> bool {
|
||||||
|
cost.fill <= self.vigor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ability(&self, id: &str) -> Option<&Ability> {
|
||||||
|
self.loadout.iter().find(|a| a.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_cooldown(&self, id: &str, tick: u64) -> bool {
|
||||||
|
self.cooldowns.get(id).is_some_and(|&ready| tick < ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pay an ability's absolute cost: spend fill, add the fatigue "mark" (§3),
|
||||||
|
/// scaled by any `FatigueAmp` status. Re-clamps and may trigger collapse.
|
||||||
|
pub fn pay(&mut self, cost: AbilityCost) {
|
||||||
|
self.vigor -= cost.fill;
|
||||||
|
let marked = self.amplified_fatigue(cost.fatigue);
|
||||||
|
self.fatigue += marked;
|
||||||
|
self.reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a post-mitigation fill hit (spec §4 stagger cascade). Returns what
|
||||||
|
/// it did. `landed` is already through the mitigation pipeline. Adds the
|
||||||
|
/// damage-taken fatigue share (§3) and resolves the stagger band.
|
||||||
|
pub fn take_hit(&mut self, landed: Milli, tick: u64, p: &StaggerParams) -> HitOutcome {
|
||||||
|
if self.stagger.is_dead() {
|
||||||
|
return HitOutcome::Killed;
|
||||||
|
}
|
||||||
|
let landed = landed.max(0);
|
||||||
|
|
||||||
|
// A hit landing on an *already* unconscious actor is the follow-up that
|
||||||
|
// grinds them out (spec §4): it kills. The hit that first knocks you
|
||||||
|
// unconscious does not — that's the line between the two.
|
||||||
|
let was_unconscious = matches!(self.stagger, StaggerState::Unconscious);
|
||||||
|
|
||||||
|
// Fatigue share of every hit (the §3 "spend leaves a mark" for damage),
|
||||||
|
// scaled by FatigueAmp. Applied before re-clamping so a hit erodes the
|
||||||
|
// ceiling even if it's later floored.
|
||||||
|
let fat = self.amplified_fatigue(apply_bp(landed, p.fatigue_ratio_bp));
|
||||||
|
self.fatigue += fat;
|
||||||
|
|
||||||
|
// Band the hit against capacity *before* subtracting (capacity = fill).
|
||||||
|
let cap_now = self.capacity();
|
||||||
|
let band = stagger_band(landed, cap_now, p);
|
||||||
|
|
||||||
|
self.vigor -= landed;
|
||||||
|
|
||||||
|
if was_unconscious {
|
||||||
|
self.stagger = StaggerState::Dead;
|
||||||
|
self.casting = None;
|
||||||
|
self.reconcile();
|
||||||
|
return HitOutcome::Killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome = match band {
|
||||||
|
Band::Absorbed => HitOutcome::Absorbed,
|
||||||
|
Band::Dazed => {
|
||||||
|
self.enter_dazed(tick, p.daze_duration);
|
||||||
|
HitOutcome::Dazed
|
||||||
|
}
|
||||||
|
Band::Unconscious => {
|
||||||
|
self.enter_unconscious();
|
||||||
|
HitOutcome::Unconscious
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.reconcile();
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply per-tick regen toward the cap (spec §3). Zero while unconscious;
|
||||||
|
/// reduced (never below a non-zero floor) while dazed or regen-sabotaged.
|
||||||
|
pub fn regen_tick(&mut self, in_combat: bool, p: &RegenParams) {
|
||||||
|
if self.stagger.is_down() {
|
||||||
|
return; // unconscious/dead: no regen
|
||||||
|
}
|
||||||
|
let base = if in_combat { self.regen_combat } else { self.regen_idle };
|
||||||
|
let mut rate = base;
|
||||||
|
|
||||||
|
if matches!(self.stagger, StaggerState::Dazed { .. }) {
|
||||||
|
rate = apply_bp(rate, p.dazed_regen_bp);
|
||||||
|
}
|
||||||
|
for s in &self.statuses {
|
||||||
|
if let StatusKind::RegenSabotage { reduce_bp } = s.kind {
|
||||||
|
rate = apply_bp(rate, (BP_ONE - reduce_bp).max(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-zero floor so an actor can always claw back toward affording an
|
||||||
|
// action — no soft-lock (spec §3/§6).
|
||||||
|
rate = rate.max(p.regen_floor);
|
||||||
|
|
||||||
|
self.vigor = clamp(self.vigor + rate, 0, self.cap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce fatigue (raise the ceiling) by `amount` milli — a recovery effect
|
||||||
|
/// (§6.1). Revives an unconscious (not dead) actor to a brief daze if it
|
||||||
|
/// brings the cap back above zero, the clutch-save path (§12 near-death).
|
||||||
|
pub fn recover_fatigue(&mut self, amount: Milli, tick: u64, revive_daze: u32) {
|
||||||
|
if self.stagger.is_dead() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.fatigue = (self.fatigue - amount).max(0);
|
||||||
|
if matches!(self.stagger, StaggerState::Unconscious) && self.cap() > 0 {
|
||||||
|
// Wake to a daze; the fight isn't over for them yet. Set directly —
|
||||||
|
// `enter_dazed` intentionally refuses to downgrade an unconscious
|
||||||
|
// actor, but a recovery is exactly the sanctioned way back.
|
||||||
|
self.revive_to_daze(tick, revive_daze);
|
||||||
|
}
|
||||||
|
self.reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore fill (a heal / HoT tick), clamped to the cap. Can also revive an
|
||||||
|
/// unconscious actor whose cap is intact.
|
||||||
|
pub fn restore_fill(&mut self, amount: Milli, tick: u64, revive_daze: u32) {
|
||||||
|
if self.stagger.is_dead() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.vigor = clamp(self.vigor + amount, 0, self.cap());
|
||||||
|
if matches!(self.stagger, StaggerState::Unconscious) && self.vigor > 0 {
|
||||||
|
self.revive_to_daze(tick, revive_daze);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add direct fatigue (a `CeilingDamage` curse — attacks the real life total,
|
||||||
|
/// §6.1). May collapse the ceiling.
|
||||||
|
pub fn add_fatigue(&mut self, amount: Milli) {
|
||||||
|
self.fatigue += self.amplified_fatigue(amount);
|
||||||
|
self.reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_status(&mut self, status: Status) {
|
||||||
|
self.statuses.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop expired statuses (spec §10 step 1). A status covering tick `t` is
|
||||||
|
/// inclusive of effects resolving on `t`, so it survives until `tick > expires_at`.
|
||||||
|
pub fn expire_statuses(&mut self, tick: u64) {
|
||||||
|
self.statuses.retain(|s| tick <= s.expires_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear a dazed state that has elapsed (spec §10 step 1).
|
||||||
|
pub fn expire_daze(&mut self, tick: u64) {
|
||||||
|
if let StaggerState::Dazed { until } = self.stagger {
|
||||||
|
if tick > until {
|
||||||
|
self.stagger = StaggerState::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum of mitigation contributions at `stage` from active statuses (block
|
||||||
|
/// windows, stances, buffs, debuffs). Caller adds armor/con separately.
|
||||||
|
pub fn mitigation_at(&self, stage: crate::mitigation::MitigationStage) -> i64 {
|
||||||
|
self.statuses
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s.kind {
|
||||||
|
StatusKind::Mitigation { stage: st, amount_bp } if st == stage => Some(amount_bp),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// Scale a fatigue amount by any active `FatigueAmp` statuses (§6.1).
|
||||||
|
fn amplified_fatigue(&self, base: Milli) -> Milli {
|
||||||
|
let amp: i64 = self
|
||||||
|
.statuses
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s.kind {
|
||||||
|
StatusKind::FatigueAmp { amp_bp } => Some(amp_bp),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if amp == 0 {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
apply_bp(base, BP_ONE + amp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_dazed(&mut self, tick: u64, duration: u32) {
|
||||||
|
// A fresh daze interrupts any cast (paid for nothing, §6) and refreshes
|
||||||
|
// the timer; never downgrades an unconscious actor.
|
||||||
|
if matches!(self.stagger, StaggerState::Unconscious | StaggerState::Dead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.stagger = StaggerState::Dazed { until: tick + duration as u64 };
|
||||||
|
self.casting = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_unconscious(&mut self) {
|
||||||
|
if !matches!(self.stagger, StaggerState::Dead) {
|
||||||
|
self.stagger = StaggerState::Unconscious;
|
||||||
|
self.casting = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revive an unconscious (not dead) actor to a brief daze. The one sanctioned
|
||||||
|
/// way to upgrade *out* of unconsciousness (recovery / heal), bypassing the
|
||||||
|
/// `enter_dazed` downgrade guard.
|
||||||
|
fn revive_to_daze(&mut self, tick: u64, duration: u32) {
|
||||||
|
if matches!(self.stagger, StaggerState::Unconscious) {
|
||||||
|
self.stagger = StaggerState::Dazed { until: tick + duration as u64 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-clamp fatigue/vigor and apply ceiling collapse (§3/§4): if the cap has
|
||||||
|
/// reached zero, the actor self-overruns to unconscious.
|
||||||
|
fn reconcile(&mut self) {
|
||||||
|
self.fatigue = clamp(self.fatigue, 0, self.max_vigor);
|
||||||
|
let cap = self.cap();
|
||||||
|
self.vigor = clamp(self.vigor, 0, cap);
|
||||||
|
if cap == 0 {
|
||||||
|
self.enter_unconscious();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tunables for the stagger cascade (from config).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct StaggerParams {
|
||||||
|
/// `hit/capacity` above this (bp) is at least a daze. `10_000` == 1.0×.
|
||||||
|
pub daze_threshold_bp: i64,
|
||||||
|
/// `hit/capacity` at/above this (bp) is unconscious. `15_000` == 1.5×.
|
||||||
|
pub unconscious_threshold_bp: i64,
|
||||||
|
pub daze_duration: u32,
|
||||||
|
/// Fatigue share of every hit taken (§3), bp.
|
||||||
|
pub fatigue_ratio_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tunables for regen (from config).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct RegenParams {
|
||||||
|
pub dazed_regen_bp: i64,
|
||||||
|
pub regen_floor: Milli,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Band {
|
||||||
|
Absorbed,
|
||||||
|
Dazed,
|
||||||
|
Unconscious,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which stagger band a `landed` hit falls into against `capacity`, using
|
||||||
|
/// integer cross-multiplication (no division, no f64). At `capacity == 0` any
|
||||||
|
/// positive hit is an overrun → unconscious (the cap-collapse death path, §4).
|
||||||
|
fn stagger_band(landed: Milli, capacity: Milli, p: &StaggerParams) -> Band {
|
||||||
|
if landed <= 0 {
|
||||||
|
return Band::Absorbed;
|
||||||
|
}
|
||||||
|
let lhs = landed as i128 * BP_ONE as i128;
|
||||||
|
let daze_rhs = capacity as i128 * p.daze_threshold_bp as i128;
|
||||||
|
let unc_rhs = capacity as i128 * p.unconscious_threshold_bp as i128;
|
||||||
|
if lhs <= daze_rhs {
|
||||||
|
Band::Absorbed
|
||||||
|
} else if lhs < unc_rhs {
|
||||||
|
Band::Dazed
|
||||||
|
} else {
|
||||||
|
Band::Unconscious
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a runtime [`Actor`] from a [`StatBlock`], its resolved loadout, and the
|
||||||
|
/// tick rate (for unit/sec → milli/tick regen conversion).
|
||||||
|
pub fn compile_actor(id: ActorId, sb: &StatBlock, loadout: Vec<Ability>, tick_rate: u32) -> Actor {
|
||||||
|
let max_vigor = units(sb.max_vigor);
|
||||||
|
let to_per_tick = |per_sec: i64| -> Milli {
|
||||||
|
// per_sec units → milli per tick = per_sec * 1000 / tick_rate.
|
||||||
|
mul_div_round(per_sec * UNIT, 1, tick_rate.max(1) as i64)
|
||||||
|
};
|
||||||
|
let fatigue = apply_bp(max_vigor, sb.start_fatigue_bp);
|
||||||
|
let cap = clamp(max_vigor - fatigue, 0, max_vigor);
|
||||||
|
let vigor = clamp(apply_bp(max_vigor, sb.start_vigor_bp), 0, cap);
|
||||||
|
Actor {
|
||||||
|
id,
|
||||||
|
name: sb.name.clone(),
|
||||||
|
team: sb.team,
|
||||||
|
level: sb.level,
|
||||||
|
max_vigor,
|
||||||
|
vigor,
|
||||||
|
fatigue,
|
||||||
|
regen_idle: to_per_tick(sb.regen_idle_per_sec),
|
||||||
|
regen_combat: to_per_tick(sb.regen_combat_per_sec),
|
||||||
|
armor_bp: sb.armor_bp,
|
||||||
|
competency: sb.competency.clone(),
|
||||||
|
loadout,
|
||||||
|
cooldowns: BTreeMap::new(),
|
||||||
|
statuses: Vec::new(),
|
||||||
|
casting: None,
|
||||||
|
stagger: StaggerState::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn params() -> StaggerParams {
|
||||||
|
StaggerParams {
|
||||||
|
daze_threshold_bp: 10_000,
|
||||||
|
unconscious_threshold_bp: 15_000,
|
||||||
|
daze_duration: 5,
|
||||||
|
fatigue_ratio_bp: 2_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dummy(max_units: i64) -> Actor {
|
||||||
|
let sb = StatBlock {
|
||||||
|
name: "T".into(),
|
||||||
|
team: 0,
|
||||||
|
level: 10,
|
||||||
|
max_vigor: max_units,
|
||||||
|
regen_idle_per_sec: 10,
|
||||||
|
regen_combat_per_sec: 5,
|
||||||
|
armor_bp: 0,
|
||||||
|
competency: BTreeMap::new(),
|
||||||
|
loadout: vec![],
|
||||||
|
start_vigor_bp: 10_000,
|
||||||
|
start_fatigue_bp: 0,
|
||||||
|
};
|
||||||
|
compile_actor(1, &sb, vec![], 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_actor_is_full() {
|
||||||
|
let a = dummy(100);
|
||||||
|
assert_eq!(a.vigor, units(100));
|
||||||
|
assert_eq!(a.fatigue, 0);
|
||||||
|
assert_eq!(a.cap(), units(100));
|
||||||
|
// 5 units/sec at 10 tps -> 500 milli/tick.
|
||||||
|
assert_eq!(a.regen_combat, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_hit_is_absorbed_and_marks_fatigue() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
let out = a.take_hit(units(10), 0, ¶ms());
|
||||||
|
assert_eq!(out, HitOutcome::Absorbed);
|
||||||
|
assert_eq!(a.vigor, units(90));
|
||||||
|
// 20% of 10 units = 2 units fatigue.
|
||||||
|
assert_eq!(a.fatigue, units(2));
|
||||||
|
assert_eq!(a.cap(), units(98));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overrun_between_1x_and_1_5x_dazes() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
a.vigor = units(10); // capacity 10
|
||||||
|
let out = a.take_hit(units(12), 3, ¶ms()); // 1.2x capacity
|
||||||
|
assert_eq!(out, HitOutcome::Dazed);
|
||||||
|
assert!(matches!(a.stagger, StaggerState::Dazed { until: 8 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overrun_past_1_5x_knocks_unconscious() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
a.vigor = units(10);
|
||||||
|
let out = a.take_hit(units(20), 0, ¶ms()); // 2.0x capacity
|
||||||
|
assert_eq!(out, HitOutcome::Unconscious);
|
||||||
|
assert!(matches!(a.stagger, StaggerState::Unconscious));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absorbed_to_exactly_zero_is_not_death() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
a.vigor = units(10);
|
||||||
|
let out = a.take_hit(units(10), 0, ¶ms()); // hit == capacity
|
||||||
|
assert_eq!(out, HitOutcome::Absorbed);
|
||||||
|
assert_eq!(a.vigor, 0);
|
||||||
|
assert!(a.is_alive());
|
||||||
|
assert!(a.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unconscious_then_follow_up_hit_dies() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
a.vigor = units(2);
|
||||||
|
// First hit (5x capacity) knocks unconscious but does NOT kill (§4).
|
||||||
|
let first = a.take_hit(units(10), 0, ¶ms());
|
||||||
|
assert_eq!(first, HitOutcome::Unconscious);
|
||||||
|
assert!(matches!(a.stagger, StaggerState::Unconscious));
|
||||||
|
assert!(a.is_alive());
|
||||||
|
// Any follow-up hit while down grinds them out → dead.
|
||||||
|
let second = a.take_hit(units(1), 1, ¶ms());
|
||||||
|
assert_eq!(second, HitOutcome::Killed);
|
||||||
|
assert!(a.stagger.is_dead());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ceiling_collapse_knocks_unconscious() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
a.add_fatigue(units(100)); // cap -> 0
|
||||||
|
assert_eq!(a.cap(), 0);
|
||||||
|
assert!(matches!(a.stagger, StaggerState::Unconscious));
|
||||||
|
assert!(!a.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_revives_unconscious_to_daze() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
a.add_fatigue(units(100)); // collapse
|
||||||
|
assert!(matches!(a.stagger, StaggerState::Unconscious));
|
||||||
|
a.recover_fatigue(units(40), 5, 3); // recovered 40 fatigue -> cap 40
|
||||||
|
assert!(matches!(a.stagger, StaggerState::Dazed { until: 8 }));
|
||||||
|
assert_eq!(a.cap(), units(40));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regen_floor_prevents_softlock() {
|
||||||
|
let mut a = dummy(100);
|
||||||
|
a.vigor = 0;
|
||||||
|
a.add_status(Status {
|
||||||
|
kind: StatusKind::RegenSabotage { reduce_bp: 10_000 }, // 100% sabotage
|
||||||
|
expires_at: 100,
|
||||||
|
source: 2,
|
||||||
|
});
|
||||||
|
let rp = RegenParams { dazed_regen_bp: 5_000, regen_floor: 50 };
|
||||||
|
a.regen_tick(true, &rp);
|
||||||
|
assert!(a.vigor >= 50, "regen floor must keep the actor un-stuck");
|
||||||
|
}
|
||||||
|
}
|
||||||
166
combat/combat-core/src/con.rs
Normal file
166
combat/combat-core/src/con.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
//! The con curve (spec §7): a single level-delta → magnitude-multiplier mapping,
|
||||||
|
//! reused in both directions.
|
||||||
|
//!
|
||||||
|
//! - **Incoming damage**: `delta = attacker_level − defender_level`.
|
||||||
|
//! - **Outgoing effect effectiveness / resist**: `delta = caster_effective_level
|
||||||
|
//! − target_level`, where `caster_effective_level` folds in competency (§9).
|
||||||
|
//!
|
||||||
|
//! Represented as a small **anchor table** with clamped linear interpolation in
|
||||||
|
//! integer space (basis points). Anchors come from config, so the whole feel of
|
||||||
|
//! over/under-con fights is a table you edit, not code.
|
||||||
|
//!
|
||||||
|
//! ## The "double duty" caveat (§7)
|
||||||
|
//! Both callers feed a *delta*, but their domains differ: raw level delta vs. a
|
||||||
|
//! competency-inflated effective delta that can run wider. [`ConCurve`] clamps
|
||||||
|
//! outside its anchor range and exposes [`ConCurve::covers`] so a caller can
|
||||||
|
//! detect when a delta falls past the tuned region. If competency inflation ever
|
||||||
|
//! pushes effective deltas past where the damage-tuned curve behaves, split this
|
||||||
|
//! into two `ConCurve`s — the call sites already pass their own delta, so that
|
||||||
|
//! split is a config change plus one field, not a rewrite.
|
||||||
|
|
||||||
|
use crate::fixed::BP_ONE;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One `(delta, multiplier)` anchor. `mult_bp` is basis points (`10_000` == 1.0×).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ConAnchor {
|
||||||
|
pub delta: i32,
|
||||||
|
pub mult_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A monotone-ish anchor table mapping level delta to a magnitude multiplier.
|
||||||
|
///
|
||||||
|
/// Anchors are kept sorted by `delta`. Between anchors the multiplier is linearly
|
||||||
|
/// interpolated; outside the first/last anchor it is clamped to the endpoint.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ConCurve {
|
||||||
|
/// Sorted (ascending `delta`) anchor points. Always non-empty after
|
||||||
|
/// [`ConCurve::new`].
|
||||||
|
anchors: Vec<ConAnchor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConCurve {
|
||||||
|
/// Build from anchors, sorting by delta and de-duplicating exact-delta
|
||||||
|
/// collisions (last wins). Falls back to an even-con identity if empty so a
|
||||||
|
/// degenerate config can never produce a panicking or zero-length curve.
|
||||||
|
pub fn new(mut anchors: Vec<ConAnchor>) -> Self {
|
||||||
|
anchors.sort_by_key(|a| a.delta);
|
||||||
|
anchors.dedup_by_key(|a| a.delta);
|
||||||
|
if anchors.is_empty() {
|
||||||
|
anchors.push(ConAnchor { delta: 0, mult_bp: BP_ONE });
|
||||||
|
}
|
||||||
|
Self { anchors }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default illustrative curve from the spec (§7): even-con `1.0×`,
|
||||||
|
/// `+5 ≈ 3.0×`, `−5 ≈ 0.2×`, monotone, with a couple of intermediate anchors
|
||||||
|
/// so the interpolation isn't a straight line through the extremes.
|
||||||
|
pub fn default_anchors() -> Vec<ConAnchor> {
|
||||||
|
vec![
|
||||||
|
ConAnchor { delta: -8, mult_bp: 200 }, // 0.02× — hopelessly under-con
|
||||||
|
ConAnchor { delta: -5, mult_bp: 2_000 }, // 0.20×
|
||||||
|
ConAnchor { delta: -2, mult_bp: 6_000 }, // 0.60×
|
||||||
|
ConAnchor { delta: 0, mult_bp: 10_000 }, // 1.00× — even con
|
||||||
|
ConAnchor { delta: 2, mult_bp: 16_000 }, // 1.60×
|
||||||
|
ConAnchor { delta: 5, mult_bp: 30_000 }, // 3.00×
|
||||||
|
ConAnchor { delta: 8, mult_bp: 50_000 }, // 5.00× — brutally over-con
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multiplier in basis points for a given level `delta`, clamped to the
|
||||||
|
/// endpoint multipliers outside the anchored range.
|
||||||
|
pub fn multiplier_bp(&self, delta: i32) -> i64 {
|
||||||
|
let first = self.anchors.first().expect("ConCurve is never empty");
|
||||||
|
let last = self.anchors.last().expect("ConCurve is never empty");
|
||||||
|
if delta <= first.delta {
|
||||||
|
return first.mult_bp;
|
||||||
|
}
|
||||||
|
if delta >= last.delta {
|
||||||
|
return last.mult_bp;
|
||||||
|
}
|
||||||
|
// Find the bracketing pair [lo, hi] with lo.delta <= delta < hi.delta.
|
||||||
|
// Anchor counts are tiny (single digits), so a linear scan is clearest.
|
||||||
|
for pair in self.anchors.windows(2) {
|
||||||
|
let lo = pair[0];
|
||||||
|
let hi = pair[1];
|
||||||
|
if delta >= lo.delta && delta < hi.delta {
|
||||||
|
let span = (hi.delta - lo.delta) as i64; // > 0 (deltas are unique & sorted)
|
||||||
|
let into = (delta - lo.delta) as i64;
|
||||||
|
let rise = hi.mult_bp - lo.mult_bp;
|
||||||
|
// lo.mult + rise * into / span, rounded half-to-even.
|
||||||
|
return lo.mult_bp + crate::fixed::mul_div_round(rise, into, span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unreachable given the clamps above, but stay total.
|
||||||
|
last.mult_bp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `delta` falls inside the anchored (tuned) range. A caller using a
|
||||||
|
/// competency-inflated delta can check this to log/escalate when it leaves
|
||||||
|
/// the region the curve was tuned for (the §7 caveat).
|
||||||
|
pub fn covers(&self, delta: i32) -> bool {
|
||||||
|
let first = self.anchors.first().expect("ConCurve is never empty");
|
||||||
|
let last = self.anchors.last().expect("ConCurve is never empty");
|
||||||
|
(first.delta..=last.delta).contains(&delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConCurve {
|
||||||
|
fn default() -> Self {
|
||||||
|
ConCurve::new(ConCurve::default_anchors())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchors_are_exact() {
|
||||||
|
let c = ConCurve::default();
|
||||||
|
assert_eq!(c.multiplier_bp(0), 10_000);
|
||||||
|
assert_eq!(c.multiplier_bp(5), 30_000);
|
||||||
|
assert_eq!(c.multiplier_bp(-5), 2_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamps_outside_range() {
|
||||||
|
let c = ConCurve::default();
|
||||||
|
assert_eq!(c.multiplier_bp(-100), 200); // floor anchor
|
||||||
|
assert_eq!(c.multiplier_bp(100), 50_000); // ceil anchor
|
||||||
|
assert!(!c.covers(-100));
|
||||||
|
assert!(c.covers(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interpolates_between_anchors() {
|
||||||
|
let c = ConCurve::default();
|
||||||
|
// delta=1 sits halfway between 0 (10_000) and 2 (16_000) -> 13_000.
|
||||||
|
assert_eq!(c.multiplier_bp(1), 13_000);
|
||||||
|
// delta=3 between 2 (16_000) and 5 (30_000): 16000 + 14000*1/3 = 20666.67
|
||||||
|
// round half-to-even of 20666.666... -> 20667.
|
||||||
|
assert_eq!(c.multiplier_bp(3), 20_667);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monotone_nondecreasing() {
|
||||||
|
let c = ConCurve::default();
|
||||||
|
let mut prev = i64::MIN;
|
||||||
|
for d in -10..=10 {
|
||||||
|
let m = c.multiplier_bp(d);
|
||||||
|
assert!(m >= prev, "curve dipped at delta {d}: {m} < {prev}");
|
||||||
|
prev = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsorted_input_is_normalized() {
|
||||||
|
let c = ConCurve::new(vec![
|
||||||
|
ConAnchor { delta: 5, mult_bp: 30_000 },
|
||||||
|
ConAnchor { delta: 0, mult_bp: 10_000 },
|
||||||
|
ConAnchor { delta: -5, mult_bp: 2_000 },
|
||||||
|
]);
|
||||||
|
assert_eq!(c.multiplier_bp(0), 10_000);
|
||||||
|
assert_eq!(c.multiplier_bp(5), 30_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
284
combat/combat-core/src/config.rs
Normal file
284
combat/combat-core/src/config.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
//! Configuration — "the table" (spec §11). A single serde-loadable
|
||||||
|
//! [`CombatConfig`] holds every tunable; **nothing balance-relevant is
|
||||||
|
//! hardcoded**. It compiles once into [`Rules`], the integer-fixed-point form the
|
||||||
|
//! engine actually runs on (f64 is confined to this parse/compile boundary, §10).
|
||||||
|
//!
|
||||||
|
//! Editing config is the primary balancing act; only structural rule changes
|
||||||
|
//! require a recompile.
|
||||||
|
|
||||||
|
use crate::ability::{compile as compile_ability, Ability, AbilitySpec, CostModel};
|
||||||
|
use crate::actor::{RegenParams, StaggerParams};
|
||||||
|
use crate::con::{ConAnchor, ConCurve};
|
||||||
|
use crate::fixed::{mul_div_round, Milli, UNIT};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Regen tunables (§3).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RegenConfig {
|
||||||
|
/// Fraction of normal regen while dazed. `5_000` == half.
|
||||||
|
pub dazed_regen_bp: i64,
|
||||||
|
/// The non-zero floor (units/sec) so an actor can always claw back toward
|
||||||
|
/// affording an action — no soft-lock (§3/§6).
|
||||||
|
pub floor_per_sec: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RegenConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { dazed_regen_bp: 5_000, floor_per_sec: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stagger cascade tunables (§4) plus the damage-taken fatigue share (§3).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct StaggerConfig {
|
||||||
|
/// `hit/capacity` above this is at least a daze. `10_000` == 1.0×.
|
||||||
|
pub daze_threshold_bp: i64,
|
||||||
|
/// `hit/capacity` at/above this is unconscious. `15_000` == 1.5×.
|
||||||
|
pub unconscious_threshold_bp: i64,
|
||||||
|
/// Daze duration in ticks.
|
||||||
|
pub daze_duration: u32,
|
||||||
|
/// Daze granted when a recovery revives an unconscious actor (ticks).
|
||||||
|
pub revive_daze: u32,
|
||||||
|
/// Fatigue share of every hit taken (§3), bp of the landed damage.
|
||||||
|
pub fatigue_ratio_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StaggerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
daze_threshold_bp: 10_000,
|
||||||
|
unconscious_threshold_bp: 15_000,
|
||||||
|
daze_duration: 8,
|
||||||
|
revive_daze: 12,
|
||||||
|
fatigue_ratio_bp: 2_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Competency → effectiveness coupling (§9).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CompetencyConfig {
|
||||||
|
/// Effective-level bonus per competency point, bp. `1_000` == +0.1 level/pt
|
||||||
|
/// (so 50 competency ≈ +5 effective levels).
|
||||||
|
pub level_per_point_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CompetencyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { level_per_point_bp: 1_000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fizzle model (§9): chance an ability fails to fire (paid for nothing).
|
||||||
|
/// `chance_bp = clamp(base + max(0, difficulty − competency) × per_gap, 0, max)`.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FizzleConfig {
|
||||||
|
/// Floor fizzle chance even at/above competency (bp).
|
||||||
|
pub base_bp: i64,
|
||||||
|
/// Added fizzle per point that difficulty exceeds competency (bp).
|
||||||
|
pub per_gap_bp: i64,
|
||||||
|
/// Hard ceiling on fizzle chance (bp).
|
||||||
|
pub max_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FizzleConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { base_bp: 200, per_gap_bp: 150, max_bp: 9_000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole tunable surface (§11), serde-loadable from JSON.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CombatConfig {
|
||||||
|
/// Fixed tick rate (ticks/second, §10).
|
||||||
|
#[serde(default = "default_tick_rate")]
|
||||||
|
pub tick_rate: u32,
|
||||||
|
/// ± spread on every landed fill hit, bp. `1_500` == ±15%. The genre's damage
|
||||||
|
/// range; also the symmetry-breaker that turns a deterministic mirror match
|
||||||
|
/// from a coin-flip-of-draws into smooth win-rate curves.
|
||||||
|
#[serde(default)]
|
||||||
|
pub damage_variance_bp: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub regen: RegenConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stagger: StaggerConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub competency: CompetencyConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fizzle: FizzleConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost_model: CostModel,
|
||||||
|
/// Con curve anchors (§7). Empty ⇒ the spec's default curve.
|
||||||
|
#[serde(default)]
|
||||||
|
pub con_curve: Vec<ConAnchor>,
|
||||||
|
/// The ability library (§6) — data, not code.
|
||||||
|
#[serde(default)]
|
||||||
|
pub abilities: Vec<AbilitySpec>,
|
||||||
|
/// Optional named archetypes the sim can reference when assembling scenarios.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stat_blocks: BTreeMap<String, crate::actor::StatBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tick_rate() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CombatConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
tick_rate: 10,
|
||||||
|
damage_variance_bp: 0,
|
||||||
|
regen: RegenConfig::default(),
|
||||||
|
stagger: StaggerConfig::default(),
|
||||||
|
competency: CompetencyConfig::default(),
|
||||||
|
fizzle: FizzleConfig::default(),
|
||||||
|
cost_model: CostModel::default(),
|
||||||
|
con_curve: ConCurve::default_anchors(),
|
||||||
|
abilities: Vec::new(),
|
||||||
|
stat_blocks: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compiled, integer-fixed-point ruleset the engine runs on.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Rules {
|
||||||
|
pub tick_rate: u32,
|
||||||
|
pub damage_variance_bp: i64,
|
||||||
|
pub stagger: StaggerParams,
|
||||||
|
pub regen: RegenParams,
|
||||||
|
pub revive_daze: u32,
|
||||||
|
pub con: ConCurve,
|
||||||
|
pub competency_level_per_point_bp: i64,
|
||||||
|
pub fizzle: FizzleConfig,
|
||||||
|
pub cost_model: CostModel,
|
||||||
|
/// Default spend-mark ratio (§3), mirrored from the cost model for clarity.
|
||||||
|
pub spend_fatigue_ratio_bp: i64,
|
||||||
|
/// Compiled abilities keyed by id (resolution for loadouts).
|
||||||
|
pub abilities: BTreeMap<String, Ability>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CombatConfig {
|
||||||
|
/// Compile config into [`Rules`]: convert units/sec → milli/tick, fold the
|
||||||
|
/// con anchors, and compose every ability's absolute cost up front.
|
||||||
|
pub fn compile(&self) -> Rules {
|
||||||
|
let tick_rate = self.tick_rate.max(1);
|
||||||
|
|
||||||
|
let stagger = StaggerParams {
|
||||||
|
daze_threshold_bp: self.stagger.daze_threshold_bp,
|
||||||
|
unconscious_threshold_bp: self.stagger.unconscious_threshold_bp,
|
||||||
|
daze_duration: self.stagger.daze_duration,
|
||||||
|
fatigue_ratio_bp: self.stagger.fatigue_ratio_bp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let regen = RegenParams {
|
||||||
|
dazed_regen_bp: self.regen.dazed_regen_bp,
|
||||||
|
regen_floor: per_sec_to_per_tick(self.regen.floor_per_sec, tick_rate),
|
||||||
|
};
|
||||||
|
|
||||||
|
let con = if self.con_curve.is_empty() {
|
||||||
|
ConCurve::default()
|
||||||
|
} else {
|
||||||
|
ConCurve::new(self.con_curve.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let abilities = self
|
||||||
|
.abilities
|
||||||
|
.iter()
|
||||||
|
.map(|spec| (spec.id.clone(), compile_ability(spec, &self.cost_model)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Rules {
|
||||||
|
tick_rate,
|
||||||
|
damage_variance_bp: self.damage_variance_bp.max(0),
|
||||||
|
stagger,
|
||||||
|
regen,
|
||||||
|
revive_daze: self.stagger.revive_daze,
|
||||||
|
con,
|
||||||
|
competency_level_per_point_bp: self.competency.level_per_point_bp,
|
||||||
|
fizzle: self.fizzle.clone(),
|
||||||
|
cost_model: self.cost_model.clone(),
|
||||||
|
spend_fatigue_ratio_bp: self.cost_model.fatigue_ratio_bp,
|
||||||
|
abilities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rules {
|
||||||
|
/// Resolve a loadout of ability ids against the compiled library, dropping
|
||||||
|
/// (and reporting) any unknown ids so a typo in config fails loud-ish but the
|
||||||
|
/// rest of the actor still builds.
|
||||||
|
pub fn resolve_loadout(&self, ids: &[String]) -> (Vec<Ability>, Vec<String>) {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
match self.abilities.get(id) {
|
||||||
|
Some(a) => out.push(a.clone()),
|
||||||
|
None => missing.push(id.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(out, missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fizzle chance (bp) for `difficulty` attempted at `competency` (§9).
|
||||||
|
pub fn fizzle_chance_bp(&self, competency: i64, difficulty: i64) -> i64 {
|
||||||
|
let gap = (difficulty - competency).max(0);
|
||||||
|
let raw = self.fizzle.base_bp + gap * self.fizzle.per_gap_bp;
|
||||||
|
raw.clamp(0, self.fizzle.max_bp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Con multiplier (bp) for an attacker/caster of `attacker_eff_level` against
|
||||||
|
/// `target_level` (§7).
|
||||||
|
pub fn con_mult_bp(&self, attacker_eff_level: i32, target_level: i32) -> i64 {
|
||||||
|
self.con.multiplier_bp(attacker_eff_level - target_level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// units/sec → milli/tick: `per_sec × 1000 / tick_rate`, rounded half-to-even.
|
||||||
|
fn per_sec_to_per_tick(per_sec: i64, tick_rate: u32) -> Milli {
|
||||||
|
mul_div_round(per_sec * UNIT, 1, tick_rate.max(1) as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_compiles() {
|
||||||
|
let cfg = CombatConfig::default();
|
||||||
|
let rules = cfg.compile();
|
||||||
|
assert_eq!(rules.tick_rate, 10);
|
||||||
|
assert_eq!(rules.con.multiplier_bp(0), 10_000);
|
||||||
|
// floor 1 unit/sec at 10 tps -> 100 milli/tick.
|
||||||
|
assert_eq!(rules.regen.regen_floor, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fizzle_grows_with_difficulty_gap() {
|
||||||
|
let rules = CombatConfig::default().compile();
|
||||||
|
// competency >= difficulty -> just the base.
|
||||||
|
assert_eq!(rules.fizzle_chance_bp(50, 20), 200);
|
||||||
|
// a 10-point gap adds 10 * 150 = 1500.
|
||||||
|
assert_eq!(rules.fizzle_chance_bp(20, 30), 200 + 1_500);
|
||||||
|
// clamps at max.
|
||||||
|
assert_eq!(rules.fizzle_chance_bp(0, 1_000), 9_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_roundtrips_json() {
|
||||||
|
let cfg = CombatConfig::default();
|
||||||
|
let j = serde_json::to_string(&cfg).unwrap();
|
||||||
|
let back: CombatConfig = serde_json::from_str(&j).unwrap();
|
||||||
|
assert_eq!(back.tick_rate, cfg.tick_rate);
|
||||||
|
assert_eq!(back.con_curve.len(), cfg.con_curve.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_json_fills_defaults() {
|
||||||
|
// Only override tick rate; everything else should default.
|
||||||
|
let cfg: CombatConfig = serde_json::from_str(r#"{ "tick_rate": 20 }"#).unwrap();
|
||||||
|
assert_eq!(cfg.tick_rate, 20);
|
||||||
|
assert_eq!(cfg.stagger.unconscious_threshold_bp, 15_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
351
combat/combat-core/src/controller.rs
Normal file
351
combat/combat-core/src/controller.rs
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
//! Controllers (spec §5): the pluggable *intent* layer. The [`Actor`] is inert
|
||||||
|
//! state; a `Controller` decides what it tries to do each tick. Players and
|
||||||
|
//! monsters run the identical combat math — only the controller differs, so a
|
||||||
|
//! ladder of scripted policies of varying skill is exactly how we measure the
|
||||||
|
//! gradual→brutal difficulty curve (§12).
|
||||||
|
//!
|
||||||
|
//! A controller returns an [`Action`]; the engine validates affordability,
|
||||||
|
//! cooldown, and fizzle, then resolves it. Controllers only ever *read* the
|
||||||
|
//! world (`&View`) and draw from a deterministic, per-decision forked RNG, so the
|
||||||
|
//! whole decision step is reproducible (§10 step 0).
|
||||||
|
|
||||||
|
use crate::actor::{Actor, ActorId};
|
||||||
|
use crate::ability::Ability;
|
||||||
|
use crate::config::Rules;
|
||||||
|
use crate::effect::EffectKind;
|
||||||
|
use crate::fixed::{apply_bp, Milli, BP_ONE};
|
||||||
|
use crate::mitigation::MitigationStage;
|
||||||
|
use crate::rng::Rng;
|
||||||
|
|
||||||
|
/// What an actor tries to do this tick. The engine has final say (it may block an
|
||||||
|
/// unaffordable/on-cooldown choice and record the reason).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Wait — regen and watch.
|
||||||
|
Idle,
|
||||||
|
/// Begin `ability_id` against `targets` (resolved from the actor's loadout).
|
||||||
|
Use { ability_id: String, targets: Vec<ActorId> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read-only snapshot the controller reasons over.
|
||||||
|
pub struct View<'a> {
|
||||||
|
pub me: &'a Actor,
|
||||||
|
pub actors: &'a [Actor],
|
||||||
|
pub tick: u64,
|
||||||
|
pub rules: &'a Rules,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> View<'a> {
|
||||||
|
/// Conscious enemies (different team, still in the fight).
|
||||||
|
pub fn enemies(&self) -> Vec<&'a Actor> {
|
||||||
|
self.actors
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.team != self.me.team && a.is_active())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conscious allies other than me.
|
||||||
|
pub fn allies(&self) -> Vec<&'a Actor> {
|
||||||
|
self.actors
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.team == self.me.team && a.id != self.me.id && a.is_active())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enemy closest to going down (lowest cap, ties broken by lowest fill
|
||||||
|
/// then id) — the focus-fire / punish target.
|
||||||
|
pub fn weakest_enemy(&self) -> Option<&'a Actor> {
|
||||||
|
self.enemies()
|
||||||
|
.into_iter()
|
||||||
|
.min_by_key(|a| (a.cap(), a.vigor, a.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An enemy mid-cast (a wind-up worth reacting to defensively).
|
||||||
|
pub fn enemy_casting(&self) -> Option<&'a Actor> {
|
||||||
|
self.enemies().into_iter().find(|a| a.casting.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usable(&self, ab: &Ability) -> bool {
|
||||||
|
ab.cost.fill <= self.me.vigor && !self.me.on_cooldown(&ab.id, self.tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ability introspection (role inference, no manual tagging) -------------
|
||||||
|
|
||||||
|
fn is_recovery(ab: &Ability) -> bool {
|
||||||
|
ab.effects.iter().any(|e| {
|
||||||
|
matches!(e.kind, EffectKind::Recovery { .. } | EffectKind::Hot { .. })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active_defense(ab: &Ability) -> bool {
|
||||||
|
ab.effects.iter().any(|e| {
|
||||||
|
matches!(
|
||||||
|
e.kind,
|
||||||
|
EffectKind::MitigationBuff { stage: MitigationStage::ActiveDefense, .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total fill-damage magnitude (bp of target max_vigor) this ability throws,
|
||||||
|
/// folding in potency — the rough "how big a hit" used to spot a punish.
|
||||||
|
fn fill_damage_bp(ab: &Ability) -> i64 {
|
||||||
|
let raw: i64 = ab
|
||||||
|
.effects
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e.kind {
|
||||||
|
EffectKind::FillDamage { magnitude_bp } => Some(magnitude_bp),
|
||||||
|
EffectKind::Dot { per_tick_bp, duration } => Some(per_tick_bp * duration as i64),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
apply_bp(raw, ab.potency_bp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actor's auto-attack: the cheapest offensive ability (fill cost), ties to
|
||||||
|
/// the lowest id-stable order. Falls back to any offensive ability.
|
||||||
|
fn auto_attack(me: &Actor) -> Option<&Ability> {
|
||||||
|
me.loadout
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_offensive())
|
||||||
|
.min_by_key(|a| (a.cost.fill, a.name.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The biggest affordable, off-cooldown offensive hit.
|
||||||
|
fn best_heavy<'a>(view: &View<'a>) -> Option<&'a Ability> {
|
||||||
|
view.me
|
||||||
|
.loadout
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_offensive() && view.usable(a))
|
||||||
|
.max_by_key(|a| (fill_damage_bp(a), a.cost.fill))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A usable recovery ability, if any.
|
||||||
|
fn usable_recovery<'a>(view: &View<'a>) -> Option<&'a Ability> {
|
||||||
|
view.me
|
||||||
|
.loadout
|
||||||
|
.iter()
|
||||||
|
.find(|a| is_recovery(a) && view.usable(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A usable active-defense ability, if any.
|
||||||
|
fn usable_defense<'a>(view: &View<'a>) -> Option<&'a Ability> {
|
||||||
|
view.me
|
||||||
|
.loadout
|
||||||
|
.iter()
|
||||||
|
.find(|a| is_active_defense(a) && view.usable(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate the landed magnitude of `ab` against `target` for punish timing —
|
||||||
|
/// con + armor only (defenses/buffs are unknowable to the attacker). Good enough
|
||||||
|
/// to judge "would this overrun their capacity?".
|
||||||
|
fn estimate_landed(view: &View, ab: &Ability, target: &Actor) -> Milli {
|
||||||
|
let pre = apply_bp(target.max_vigor, fill_damage_bp(ab).max(0));
|
||||||
|
let eff_level = view
|
||||||
|
.me
|
||||||
|
.effective_level(&ab.school, view.rules.competency_level_per_point_bp);
|
||||||
|
let con = view.rules.con_mult_bp(eff_level, target.level);
|
||||||
|
let after_con = apply_bp(pre, con);
|
||||||
|
apply_bp(after_con, (BP_ONE - target.armor_bp).max(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `landed` would push `target` past the daze threshold (overruns
|
||||||
|
/// capacity) — the moment to strike (§4/§5).
|
||||||
|
fn would_overrun(view: &View, landed: Milli, target: &Actor) -> bool {
|
||||||
|
let lhs = landed as i128 * BP_ONE as i128;
|
||||||
|
let rhs = target.vigor as i128 * view.rules.stagger.daze_threshold_bp as i128;
|
||||||
|
lhs > rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decision interface. `decide` is pure w.r.t. the world (`&View`) and draws
|
||||||
|
/// only from the deterministic `rng`.
|
||||||
|
pub trait Controller: std::fmt::Debug {
|
||||||
|
fn decide(&mut self, view: &View, rng: &mut Rng) -> Action;
|
||||||
|
fn label(&self) -> &str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dumb AI **and** the §12 auto-attack-only guardrail policy: only ever swings,
|
||||||
|
/// on cooldown, at the weakest enemy. Never touches an ability. If the fraction
|
||||||
|
/// of fights this wins is high, the interesting verbs are a tax (thesis broken).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SwingOnCooldown;
|
||||||
|
|
||||||
|
impl Controller for SwingOnCooldown {
|
||||||
|
fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action {
|
||||||
|
let Some(target) = view.weakest_enemy() else {
|
||||||
|
return Action::Idle;
|
||||||
|
};
|
||||||
|
match auto_attack(view.me) {
|
||||||
|
Some(ab) if view.usable(ab) => Action::Use {
|
||||||
|
ability_id: ab.id.clone(),
|
||||||
|
targets: vec![target.id],
|
||||||
|
},
|
||||||
|
_ => Action::Idle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn label(&self) -> &str {
|
||||||
|
"swing_on_cooldown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A "player" policy with a **skill** knob (0..100). It knows the right play —
|
||||||
|
/// recover when low, defend a telegraphed cast, punish a spent enemy with the
|
||||||
|
/// heavy, poke otherwise — but executes the optimal line only `skill`% of the
|
||||||
|
/// time, otherwise falling back to a plain swing. Sweeping `skill` traces the
|
||||||
|
/// difficulty curve (§5/§12).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScriptedPlayer {
|
||||||
|
/// 0 = flails, 100 = always optimal.
|
||||||
|
pub skill: i64,
|
||||||
|
/// Recover when cap drops below this fraction of max (bp).
|
||||||
|
pub recover_below_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedPlayer {
|
||||||
|
pub fn new(skill: i64) -> Self {
|
||||||
|
Self { skill: skill.clamp(0, 100), recover_below_bp: 3_500 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plays_well(&self, rng: &mut Rng) -> bool {
|
||||||
|
rng.chance_bp(self.skill * 100) // skill% as bp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Controller for ScriptedPlayer {
|
||||||
|
fn decide(&mut self, view: &View, rng: &mut Rng) -> Action {
|
||||||
|
let Some(target) = view.weakest_enemy() else {
|
||||||
|
return Action::Idle;
|
||||||
|
};
|
||||||
|
let optimal = self.plays_well(rng);
|
||||||
|
let swing = || -> Action {
|
||||||
|
match auto_attack(view.me) {
|
||||||
|
Some(ab) if view.usable(ab) => Action::Use {
|
||||||
|
ability_id: ab.id.clone(),
|
||||||
|
targets: vec![target.id],
|
||||||
|
},
|
||||||
|
_ => Action::Idle,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !optimal {
|
||||||
|
// Misplay: just swing (or idle if it can't).
|
||||||
|
return swing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Survival first: low ceiling → recover.
|
||||||
|
let cap_frac = if view.me.max_vigor > 0 {
|
||||||
|
(view.me.cap() as i128 * BP_ONE as i128 / view.me.max_vigor as i128) as i64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if cap_frac < self.recover_below_bp {
|
||||||
|
if let Some(rec) = usable_recovery(view) {
|
||||||
|
return Action::Use {
|
||||||
|
ability_id: rec.id.clone(),
|
||||||
|
targets: vec![view.me.id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. React to a telegraphed cast: defend.
|
||||||
|
if view.enemy_casting().is_some() {
|
||||||
|
if let Some(def) = usable_defense(view) {
|
||||||
|
return Action::Use {
|
||||||
|
ability_id: def.id.clone(),
|
||||||
|
targets: vec![view.me.id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Punish a spent enemy with the heavy if it would overrun them.
|
||||||
|
if let Some(heavy) = best_heavy(view) {
|
||||||
|
let est = estimate_landed(view, heavy, target);
|
||||||
|
if would_overrun(view, est, target) {
|
||||||
|
return Action::Use {
|
||||||
|
ability_id: heavy.id.clone(),
|
||||||
|
targets: vec![target.id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Otherwise poke with the cheapest usable offensive ability.
|
||||||
|
swing()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &str {
|
||||||
|
"scripted_player"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hold-and-punish AI (§5): conserve, watch the target's fill, and unload the
|
||||||
|
/// biggest hit exactly when they've spent low enough to overrun their capacity.
|
||||||
|
/// Defends telegraphed casts. The timing meta-game, played by the machine.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HoldAndPunish {
|
||||||
|
/// Pure-poke fallback below this cap fraction (don't sit at death's door).
|
||||||
|
pub panic_below_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HoldAndPunish {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { panic_below_bp: 3_000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Controller for HoldAndPunish {
|
||||||
|
fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action {
|
||||||
|
let Some(target) = view.weakest_enemy() else {
|
||||||
|
return Action::Idle;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Self-preservation: if our own ceiling is collapsing, recover.
|
||||||
|
let cap_frac = if view.me.max_vigor > 0 {
|
||||||
|
(view.me.cap() as i128 * BP_ONE as i128 / view.me.max_vigor as i128) as i64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if cap_frac < self.panic_below_bp {
|
||||||
|
if let Some(rec) = usable_recovery(view) {
|
||||||
|
return Action::Use {
|
||||||
|
ability_id: rec.id.clone(),
|
||||||
|
targets: vec![view.me.id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defend a telegraphed cast.
|
||||||
|
if view.enemy_casting().is_some() {
|
||||||
|
if let Some(def) = usable_defense(view) {
|
||||||
|
return Action::Use {
|
||||||
|
ability_id: def.id.clone(),
|
||||||
|
targets: vec![view.me.id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The haymaker: fire the heaviest hit the instant it would overrun.
|
||||||
|
if let Some(heavy) = best_heavy(view) {
|
||||||
|
let est = estimate_landed(view, heavy, target);
|
||||||
|
if would_overrun(view, est, target) {
|
||||||
|
return Action::Use {
|
||||||
|
ability_id: heavy.id.clone(),
|
||||||
|
targets: vec![target.id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not the moment — chip with a cheap swing, conserving the bar for the
|
||||||
|
// punish window rather than burning the heavy early.
|
||||||
|
match auto_attack(view.me) {
|
||||||
|
Some(ab) if view.usable(ab) => Action::Use {
|
||||||
|
ability_id: ab.id.clone(),
|
||||||
|
targets: vec![target.id],
|
||||||
|
},
|
||||||
|
_ => Action::Idle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &str {
|
||||||
|
"hold_and_punish"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
combat/combat-core/src/effect.rs
Normal file
143
combat/combat-core/src/effect.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
//! The effect library (spec §6.1): the small, data-driven vocabulary every
|
||||||
|
//! ability is composed from. Adding an effect is a new enum variant + its
|
||||||
|
//! handling in the engine's apply step — a library entry, not an engine rewrite.
|
||||||
|
//!
|
||||||
|
//! Effects target *either* the fill or the ceiling. The ceiling-attacking ones
|
||||||
|
//! (`CeilingDamage`, `RegenSabotage`, `FatigueAmp`) are the most on-thesis
|
||||||
|
//! content the system has: they attack the real life total instead of the
|
||||||
|
//! visible bar.
|
||||||
|
//!
|
||||||
|
//! Magnitudes are authored as **basis points of the target's `max_vigor`** (so
|
||||||
|
//! content scales with whoever it hits) except mitigation amounts, which are
|
||||||
|
//! basis points of *reduction*. `cost_fill` is the resource (displayed units)
|
||||||
|
//! this effect contributes to its ability's composed cost.
|
||||||
|
|
||||||
|
use crate::mitigation::MitigationStage;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Whether an effect helps its target (route to self/ally) or harms it (route to
|
||||||
|
/// an enemy). Derived from the kind so targeting can be inferred without extra
|
||||||
|
/// authoring.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Valence {
|
||||||
|
Hostile,
|
||||||
|
Friendly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One effect, tagged by kind with its per-kind parameters. `duration` is in
|
||||||
|
/// ticks; `0`/absent means instant.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum EffectKind {
|
||||||
|
/// Damage to the fill bar, as bp of target `max_vigor`, routed through con +
|
||||||
|
/// the mitigation pipeline before it lands.
|
||||||
|
FillDamage { magnitude_bp: i64 },
|
||||||
|
/// Direct fatigue gain on the target (lowers their cap) — a curse on the real
|
||||||
|
/// life total. bp of target `max_vigor`. Does **not** go through fill
|
||||||
|
/// mitigation; con effectiveness still gates whether it lands (§7).
|
||||||
|
CeilingDamage { magnitude_bp: i64 },
|
||||||
|
/// Lower the target's fatigue (raise their cap), bp of target `max_vigor`.
|
||||||
|
/// The inverse of `CeilingDamage`; friendly.
|
||||||
|
Recovery { magnitude_bp: i64 },
|
||||||
|
/// Reduce the target's regen by `reduce_bp` for `duration` ticks.
|
||||||
|
RegenSabotage { reduce_bp: i64, duration: u32 },
|
||||||
|
/// Multiply the target's fatigue-per-spend by `(1 + amp_bp)` for `duration`.
|
||||||
|
FatigueAmp { amp_bp: i64, duration: u32 },
|
||||||
|
/// Drain fill over time: `per_tick_bp` of target `max_vigor` each tick for
|
||||||
|
/// `duration` ticks. Each tick routes through mitigation.
|
||||||
|
Dot { per_tick_bp: i64, duration: u32 },
|
||||||
|
/// Restore fill over time: `per_tick_bp` of target `max_vigor` per tick.
|
||||||
|
Hot { per_tick_bp: i64, duration: u32 },
|
||||||
|
/// Add mitigation at `stage` for `duration` ticks (friendly). A `block`
|
||||||
|
/// ability is a short, strong `ActiveDefense` buff — defenses are just timed
|
||||||
|
/// mitigation, so the reactive-timing meta-game needs no separate concept.
|
||||||
|
MitigationBuff { stage: MitigationStage, amount_bp: i64, duration: u32 },
|
||||||
|
/// Strip mitigation at `stage` for `duration` ticks (hostile). Negative
|
||||||
|
/// reduction at the `Buff` stage reads as a vulnerability/sunder.
|
||||||
|
MitigationDebuff { stage: MitigationStage, amount_bp: i64, duration: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectKind {
|
||||||
|
/// Who this effect is meant for. Drives target selection in the engine.
|
||||||
|
pub fn valence(&self) -> Valence {
|
||||||
|
match self {
|
||||||
|
EffectKind::Recovery { .. }
|
||||||
|
| EffectKind::Hot { .. }
|
||||||
|
| EffectKind::MitigationBuff { .. } => Valence::Friendly,
|
||||||
|
EffectKind::FillDamage { .. }
|
||||||
|
| EffectKind::CeilingDamage { .. }
|
||||||
|
| EffectKind::RegenSabotage { .. }
|
||||||
|
| EffectKind::FatigueAmp { .. }
|
||||||
|
| EffectKind::Dot { .. }
|
||||||
|
| EffectKind::MitigationDebuff { .. } => Valence::Hostile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this effect resolves instantly (vs. installing a timed status).
|
||||||
|
pub fn is_instant(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
EffectKind::FillDamage { .. }
|
||||||
|
| EffectKind::CeilingDamage { .. }
|
||||||
|
| EffectKind::Recovery { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authored effect: what it does (`kind`) plus what it adds to its ability's
|
||||||
|
/// composed fill cost (`cost_fill`, in displayed units — "more effects ⇒ more
|
||||||
|
/// expensive", §6).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EffectSpec {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub kind: EffectKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost_fill: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectSpec {
|
||||||
|
pub fn new(kind: EffectKind, cost_fill: i64) -> Self {
|
||||||
|
Self { kind, cost_fill }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valence(&self) -> Valence {
|
||||||
|
self.kind.valence()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valence_is_derived_from_kind() {
|
||||||
|
assert_eq!(EffectKind::FillDamage { magnitude_bp: 1000 }.valence(), Valence::Hostile);
|
||||||
|
assert_eq!(EffectKind::Recovery { magnitude_bp: 500 }.valence(), Valence::Friendly);
|
||||||
|
assert_eq!(
|
||||||
|
EffectKind::MitigationBuff {
|
||||||
|
stage: MitigationStage::ActiveDefense,
|
||||||
|
amount_bp: 8000,
|
||||||
|
duration: 2
|
||||||
|
}
|
||||||
|
.valence(),
|
||||||
|
Valence::Friendly
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instant_vs_timed_classification() {
|
||||||
|
assert!(EffectKind::FillDamage { magnitude_bp: 1000 }.is_instant());
|
||||||
|
assert!(!EffectKind::Dot { per_tick_bp: 100, duration: 5 }.is_instant());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effect_spec_roundtrips_json() {
|
||||||
|
let e = EffectSpec::new(EffectKind::FillDamage { magnitude_bp: 1000 }, 5);
|
||||||
|
let j = serde_json::to_string(&e).unwrap();
|
||||||
|
let back: EffectSpec = serde_json::from_str(&j).unwrap();
|
||||||
|
assert_eq!(e, back);
|
||||||
|
// flattened tag means the json is one flat object.
|
||||||
|
assert!(j.contains("\"kind\":\"fill_damage\""));
|
||||||
|
assert!(j.contains("\"magnitude_bp\":1000"));
|
||||||
|
}
|
||||||
|
}
|
||||||
803
combat/combat-core/src/engine.rs
Normal file
803
combat/combat-core/src/engine.rs
Normal file
|
|
@ -0,0 +1,803 @@
|
||||||
|
//! The engine (spec §10): a fixed-tick, deterministic [`Encounter`] loop that
|
||||||
|
//! enforces the **canonical within-tick resolution order** — itself a determinism
|
||||||
|
//! invariant, because reordering changes outcomes (regen-before-damage yields
|
||||||
|
//! fewer staggers; sequential hits differ from batched).
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 0. Controllers decide (ascending actor-id) → pay cost, open cooldowns/windows,
|
||||||
|
//! enqueue instant hits / start casts.
|
||||||
|
//! 1. Expire finishing statuses, dazes, and defense windows (inclusive of tick t).
|
||||||
|
//! 2. Resolve casts completing this tick → enqueue their hits / install statuses.
|
||||||
|
//! 3. Apply queued hits sequentially in ascending source-id order — each followed
|
||||||
|
//! immediately by its stagger check and fatigue add.
|
||||||
|
//! 4. Tick DoT/HoT and other per-tick effects.
|
||||||
|
//! 5. Apply regen (clamped to cap).
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! A fight is a pure function of `(rules, seed, actors, controllers)`. Every roll
|
||||||
|
//! is a `root_rng.fork("actor{id}:{kind}:tick{t}")`, so adding an effect to one
|
||||||
|
//! ability never reshuffles another actor's or tick's stream (§10).
|
||||||
|
|
||||||
|
use crate::actor::{Actor, ActorId, HitOutcome, Status, StatusKind};
|
||||||
|
use crate::ability::Ability;
|
||||||
|
use crate::config::Rules;
|
||||||
|
use crate::controller::{Action, Controller, View};
|
||||||
|
use crate::effect::{EffectKind, EffectSpec, Valence};
|
||||||
|
use crate::event::{Event, FightMetrics, FightRecorder, Outcome, BlockedReason};
|
||||||
|
use crate::fixed::{apply_bp, Milli};
|
||||||
|
use crate::mitigation::{self, MitigationInputs, MitigationStage};
|
||||||
|
use crate::rng::Rng;
|
||||||
|
|
||||||
|
/// Knobs for a single fight that aren't balance (those live in [`Rules`]).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct EncounterOptions {
|
||||||
|
/// Hard cap on ticks; reaching it is a `Timeout` outcome.
|
||||||
|
pub max_ticks: u64,
|
||||||
|
/// Retain the raw event log (for inspecting one fight; off for sweeps).
|
||||||
|
pub log_events: bool,
|
||||||
|
/// Sample cap/fill time series every N ticks (0 = off).
|
||||||
|
pub sample_every: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EncounterOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { max_ticks: 6_000, log_events: false, sample_every: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resolved instant magnitude waiting for sequential application at step 3.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PendingHit {
|
||||||
|
source: ActorId,
|
||||||
|
source_eff_level: i32,
|
||||||
|
target: ActorId,
|
||||||
|
ability_id: String,
|
||||||
|
seq: u64,
|
||||||
|
payload: HitPayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum HitPayload {
|
||||||
|
/// Fill damage, pre-con/pre-mitigation (potency already folded).
|
||||||
|
Fill { pre_mitigation: Milli },
|
||||||
|
/// Direct fatigue (a curse), pre-con-resist.
|
||||||
|
Ceiling { magnitude: Milli },
|
||||||
|
/// Fatigue healed (friendly, no resist).
|
||||||
|
Recovery { magnitude: Milli },
|
||||||
|
/// Instant fill restore (friendly).
|
||||||
|
Heal { magnitude: Milli },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fight.
|
||||||
|
pub struct Encounter {
|
||||||
|
pub actors: Vec<Actor>,
|
||||||
|
controllers: Vec<Box<dyn Controller>>,
|
||||||
|
rules: Rules,
|
||||||
|
root_rng: Rng,
|
||||||
|
tick: u64,
|
||||||
|
opts: EncounterOptions,
|
||||||
|
recorder: FightRecorder,
|
||||||
|
pending: Vec<PendingHit>,
|
||||||
|
seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encounter {
|
||||||
|
/// Build an encounter. `actors` must carry dense ids `0..n` matching their
|
||||||
|
/// index, paired with a controller each.
|
||||||
|
pub fn new(
|
||||||
|
actors: Vec<Actor>,
|
||||||
|
controllers: Vec<Box<dyn Controller>>,
|
||||||
|
rules: Rules,
|
||||||
|
seed: u64,
|
||||||
|
opts: EncounterOptions,
|
||||||
|
) -> Self {
|
||||||
|
assert_eq!(actors.len(), controllers.len(), "one controller per actor");
|
||||||
|
for (i, a) in actors.iter().enumerate() {
|
||||||
|
assert_eq!(a.id as usize, i, "actor ids must be dense 0..n in index order");
|
||||||
|
}
|
||||||
|
let recorder = FightRecorder::new(&actors, seed, rules.tick_rate, opts.log_events, opts.sample_every);
|
||||||
|
Self {
|
||||||
|
actors,
|
||||||
|
controllers,
|
||||||
|
rules,
|
||||||
|
root_rng: Rng::from_seed(seed),
|
||||||
|
tick: 0,
|
||||||
|
opts,
|
||||||
|
recorder,
|
||||||
|
pending: Vec::new(),
|
||||||
|
seq: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run to resolution and return the rolled-up metrics.
|
||||||
|
pub fn run(mut self) -> FightMetrics {
|
||||||
|
loop {
|
||||||
|
self.step();
|
||||||
|
if let Some(outcome) = self.resolution() {
|
||||||
|
return self.recorder.finish(self.tick, outcome, &self.actors);
|
||||||
|
}
|
||||||
|
if self.tick >= self.opts.max_ticks {
|
||||||
|
return self.recorder.finish(self.tick, Outcome::Timeout, &self.actors);
|
||||||
|
}
|
||||||
|
self.tick += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run and also return the raw event log (only populated if `log_events`).
|
||||||
|
pub fn run_with_events(mut self) -> (FightMetrics, Vec<Event>) {
|
||||||
|
let metrics = loop {
|
||||||
|
self.step();
|
||||||
|
if let Some(outcome) = self.resolution() {
|
||||||
|
break self.recorder.clone().finish(self.tick, outcome, &self.actors);
|
||||||
|
}
|
||||||
|
if self.tick >= self.opts.max_ticks {
|
||||||
|
break self.recorder.clone().finish(self.tick, Outcome::Timeout, &self.actors);
|
||||||
|
}
|
||||||
|
self.tick += 1;
|
||||||
|
};
|
||||||
|
let events = self.recorder.into_events();
|
||||||
|
(metrics, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One full tick through the canonical order.
|
||||||
|
fn step(&mut self) {
|
||||||
|
self.pending.clear();
|
||||||
|
self.decide_and_commit(); // step 0
|
||||||
|
self.expire(); // step 1
|
||||||
|
self.resolve_casts(); // step 2
|
||||||
|
self.apply_hits(); // step 3
|
||||||
|
self.tick_over_time(); // step 4
|
||||||
|
self.apply_regen(); // step 5
|
||||||
|
self.sample();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- step 0 ------------------------------------------------------------
|
||||||
|
|
||||||
|
fn decide_and_commit(&mut self) {
|
||||||
|
let n = self.actors.len();
|
||||||
|
|
||||||
|
// Pass 1: ALL controllers decide against the *same* start-of-tick snapshot
|
||||||
|
// (no commits happen yet). Within one 0.1s tick combatants act
|
||||||
|
// simultaneously — a higher-id actor must not get sub-tick reaction to a
|
||||||
|
// lower-id actor's spend, or a perfect mirror match would be unfair.
|
||||||
|
let mut decisions: Vec<(ActorId, Action)> = Vec::with_capacity(n);
|
||||||
|
for id in 0..n as ActorId {
|
||||||
|
let idx = id as usize;
|
||||||
|
if !self.actors[idx].is_active() {
|
||||||
|
continue; // unconscious/dead: out of the fight
|
||||||
|
}
|
||||||
|
if !self.actors[idx].can_act() {
|
||||||
|
// Dazed or mid-cast: no fresh decision. Dazed = blocked; casting
|
||||||
|
// proceeds toward step 2.
|
||||||
|
if matches!(self.actors[idx].stagger, crate::actor::StaggerState::Dazed { .. }) {
|
||||||
|
self.recorder.blocked(self.tick, id, "", BlockedReason::Dazed);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let action = {
|
||||||
|
let view = View {
|
||||||
|
me: &self.actors[idx],
|
||||||
|
actors: &self.actors,
|
||||||
|
tick: self.tick,
|
||||||
|
rules: &self.rules,
|
||||||
|
};
|
||||||
|
let mut rng = self.root_rng.fork(&format!("actor{id}:decide:tick{}", self.tick));
|
||||||
|
self.controllers[idx].decide(&view, &mut rng)
|
||||||
|
};
|
||||||
|
decisions.push((id, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: commit in ascending id order (payment + cast-start are still
|
||||||
|
// ordered, the §10 step-0 invariant — only the *decision* is simultaneous).
|
||||||
|
for (id, action) in decisions {
|
||||||
|
match action {
|
||||||
|
Action::Idle => self.recorder.idle(id),
|
||||||
|
Action::Use { ability_id, targets } => self.commit_use(id, &ability_id, targets),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_use(&mut self, source: ActorId, ability_id: &str, targets: Vec<ActorId>) {
|
||||||
|
let idx = source as usize;
|
||||||
|
let Some(ability) = self.actors[idx].ability(ability_id).cloned() else {
|
||||||
|
// Controller named an ability not in the loadout — treat as a no-target
|
||||||
|
// misfire so it's visible rather than silent.
|
||||||
|
self.recorder.blocked(self.tick, source, ability_id, BlockedReason::NoTarget);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.actors[idx].on_cooldown(&ability.id, self.tick) {
|
||||||
|
self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::OnCooldown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.actors[idx].can_afford(ability.cost) {
|
||||||
|
// The §6 affordability gate: heavy hitters fall away as the ceiling
|
||||||
|
// grinds down. This is a real "wanted to act but couldn't" signal.
|
||||||
|
self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::CantAfford);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay at cast-start (§6): fizzle/interrupt later = paid for nothing.
|
||||||
|
let fill_before = self.actors[idx].vigor;
|
||||||
|
self.actors[idx].pay(ability.cost);
|
||||||
|
self.recorder.spent(source, ability.cost.fill);
|
||||||
|
if ability.cooldown > 0 {
|
||||||
|
self.actors[idx]
|
||||||
|
.cooldowns
|
||||||
|
.insert(ability.id.clone(), self.tick + ability.cooldown as u64);
|
||||||
|
}
|
||||||
|
self.recorder.action(self.tick, &self.actors[idx], &ability.id, fill_before);
|
||||||
|
self.classify_action(source, &ability);
|
||||||
|
|
||||||
|
if ability.cast_time == 0 {
|
||||||
|
// Instant: resolve now (step 0). Self-buffs install immediately so a
|
||||||
|
// block protects this very tick; hits queue for step 3.
|
||||||
|
self.resolve_ability(source, &ability, &targets);
|
||||||
|
} else {
|
||||||
|
let completes_at = self.tick + ability.cast_time as u64;
|
||||||
|
self.actors[idx].casting = Some(crate::actor::Cast {
|
||||||
|
ability_id: ability.id.clone(),
|
||||||
|
targets,
|
||||||
|
completes_at,
|
||||||
|
});
|
||||||
|
self.recorder.cast_started(self.tick, source, &ability.id, completes_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_action(&mut self, source: ActorId, ability: &Ability) {
|
||||||
|
if ability.is_support() {
|
||||||
|
self.recorder.recovery_used(source);
|
||||||
|
} else if ability.cost.fill == 0 && ability.is_offensive() {
|
||||||
|
self.recorder.auto_attack(source);
|
||||||
|
} else {
|
||||||
|
self.recorder.ability_used(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- step 1 ------------------------------------------------------------
|
||||||
|
|
||||||
|
fn expire(&mut self) {
|
||||||
|
let tick = self.tick;
|
||||||
|
for a in &mut self.actors {
|
||||||
|
a.expire_statuses(tick);
|
||||||
|
a.expire_daze(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- step 2 ------------------------------------------------------------
|
||||||
|
|
||||||
|
fn resolve_casts(&mut self) {
|
||||||
|
let n = self.actors.len();
|
||||||
|
for id in 0..n as ActorId {
|
||||||
|
let idx = id as usize;
|
||||||
|
let due = self.actors[idx]
|
||||||
|
.casting
|
||||||
|
.as_ref()
|
||||||
|
.filter(|c| c.completes_at == self.tick)
|
||||||
|
.map(|c| (c.ability_id.clone(), c.targets.clone()));
|
||||||
|
if let Some((ability_id, targets)) = due {
|
||||||
|
self.actors[idx].casting = None;
|
||||||
|
if let Some(ability) = self.actors[idx].ability(&ability_id).cloned() {
|
||||||
|
self.resolve_ability(id, &ability, &targets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll fizzle, then route every effect: install timed statuses immediately,
|
||||||
|
/// queue instant magnitudes for step 3.
|
||||||
|
fn resolve_ability(&mut self, source: ActorId, ability: &Ability, targets: &[ActorId]) {
|
||||||
|
// Fizzle (§9): competency vs difficulty. Paid for nothing on failure.
|
||||||
|
let comp = self.actors[source as usize].competency_in(&ability.school);
|
||||||
|
let chance = self.rules.fizzle_chance_bp(comp, ability.difficulty);
|
||||||
|
if chance > 0 {
|
||||||
|
let mut rng = self
|
||||||
|
.root_rng
|
||||||
|
.fork(&format!("actor{source}:fizzle:tick{}", self.tick));
|
||||||
|
if rng.chance_bp(chance) {
|
||||||
|
self.recorder.fizzle(self.tick, source, &ability.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let eff_level = self.actors[source as usize]
|
||||||
|
.effective_level(&ability.school, self.rules.competency_level_per_point_bp);
|
||||||
|
|
||||||
|
for (order, effect) in ability.effects.iter().enumerate() {
|
||||||
|
let chosen = self.select_targets(source, ability, effect, targets);
|
||||||
|
for target in chosen {
|
||||||
|
self.route_effect(source, eff_level, target, ability, effect, order as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick targets for one effect by valence, honoring the controller's request
|
||||||
|
/// and topping up to `ability.targets` from the appropriate side.
|
||||||
|
fn select_targets(
|
||||||
|
&self,
|
||||||
|
source: ActorId,
|
||||||
|
ability: &Ability,
|
||||||
|
effect: &EffectSpec,
|
||||||
|
requested: &[ActorId],
|
||||||
|
) -> Vec<ActorId> {
|
||||||
|
let want = ability.targets.max(1) as usize;
|
||||||
|
let me = &self.actors[source as usize];
|
||||||
|
let mut out: Vec<ActorId> = Vec::new();
|
||||||
|
|
||||||
|
match effect.valence() {
|
||||||
|
Valence::Friendly => {
|
||||||
|
// Self first, then requested allies, then nearest allies.
|
||||||
|
let push = |id: ActorId, out: &mut Vec<ActorId>| {
|
||||||
|
if out.len() < want && !out.contains(&id) {
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Honor explicit friendly requests (e.g. heal an ally), else self.
|
||||||
|
for &r in requested {
|
||||||
|
if let Some(t) = self.actors.get(r as usize) {
|
||||||
|
if t.team == me.team && t.is_alive() {
|
||||||
|
push(r, &mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push(source, &mut out);
|
||||||
|
for a in &self.actors {
|
||||||
|
if a.team == me.team && a.is_alive() {
|
||||||
|
push(a.id, &mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Valence::Hostile => {
|
||||||
|
let push = |id: ActorId, out: &mut Vec<ActorId>| {
|
||||||
|
if out.len() < want && !out.contains(&id) {
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for &r in requested {
|
||||||
|
if let Some(t) = self.actors.get(r as usize) {
|
||||||
|
if t.team != me.team && t.is_active() {
|
||||||
|
push(r, &mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Top up with the most-vulnerable enemies (lowest cap).
|
||||||
|
let mut enemies: Vec<&Actor> = self
|
||||||
|
.actors
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.team != me.team && a.is_active())
|
||||||
|
.collect();
|
||||||
|
enemies.sort_by_key(|a| (a.cap(), a.vigor, a.id));
|
||||||
|
for a in enemies {
|
||||||
|
push(a.id, &mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a status immediately or queue an instant magnitude.
|
||||||
|
fn route_effect(
|
||||||
|
&mut self,
|
||||||
|
source: ActorId,
|
||||||
|
source_eff_level: i32,
|
||||||
|
target: ActorId,
|
||||||
|
ability: &Ability,
|
||||||
|
effect: &EffectSpec,
|
||||||
|
order: u64,
|
||||||
|
) {
|
||||||
|
let potency = ability.potency_bp;
|
||||||
|
let tmax = self.actors[target as usize].max_vigor;
|
||||||
|
let scaled = |bp: i64| apply_bp(tmax, apply_bp(bp, potency));
|
||||||
|
let _ = order;
|
||||||
|
|
||||||
|
match effect.kind {
|
||||||
|
EffectKind::FillDamage { magnitude_bp } => {
|
||||||
|
let payload = HitPayload::Fill { pre_mitigation: scaled(magnitude_bp) };
|
||||||
|
let seq = self.next_seq();
|
||||||
|
self.queue(PendingHit {
|
||||||
|
source,
|
||||||
|
source_eff_level,
|
||||||
|
target,
|
||||||
|
ability_id: ability.id.clone(),
|
||||||
|
seq,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
EffectKind::CeilingDamage { magnitude_bp } => {
|
||||||
|
let payload = HitPayload::Ceiling { magnitude: scaled(magnitude_bp) };
|
||||||
|
let seq = self.next_seq();
|
||||||
|
self.queue(PendingHit {
|
||||||
|
source,
|
||||||
|
source_eff_level,
|
||||||
|
target,
|
||||||
|
ability_id: ability.id.clone(),
|
||||||
|
seq,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
EffectKind::Recovery { magnitude_bp } => {
|
||||||
|
let payload = HitPayload::Recovery { magnitude: scaled(magnitude_bp) };
|
||||||
|
let seq = self.next_seq();
|
||||||
|
self.queue(PendingHit {
|
||||||
|
source,
|
||||||
|
source_eff_level,
|
||||||
|
target,
|
||||||
|
ability_id: ability.id.clone(),
|
||||||
|
seq,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
EffectKind::Hot { per_tick_bp, duration: 0 } => {
|
||||||
|
let payload = HitPayload::Heal { magnitude: scaled(per_tick_bp) };
|
||||||
|
let seq = self.next_seq();
|
||||||
|
self.queue(PendingHit {
|
||||||
|
source,
|
||||||
|
source_eff_level,
|
||||||
|
target,
|
||||||
|
ability_id: ability.id.clone(),
|
||||||
|
seq,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// --- timed statuses: install now, tick later ---
|
||||||
|
EffectKind::Dot { per_tick_bp, duration } => self.install(
|
||||||
|
target,
|
||||||
|
StatusKind::Dot { per_tick_bp: apply_bp(per_tick_bp, potency), source_eff_level },
|
||||||
|
duration,
|
||||||
|
source,
|
||||||
|
),
|
||||||
|
EffectKind::Hot { per_tick_bp, duration } => self.install(
|
||||||
|
target,
|
||||||
|
StatusKind::Hot { per_tick_bp: apply_bp(per_tick_bp, potency) },
|
||||||
|
duration,
|
||||||
|
source,
|
||||||
|
),
|
||||||
|
EffectKind::RegenSabotage { reduce_bp, duration } => {
|
||||||
|
self.install(target, StatusKind::RegenSabotage { reduce_bp }, duration, source)
|
||||||
|
}
|
||||||
|
EffectKind::FatigueAmp { amp_bp, duration } => {
|
||||||
|
self.install(target, StatusKind::FatigueAmp { amp_bp }, duration, source)
|
||||||
|
}
|
||||||
|
EffectKind::MitigationBuff { stage, amount_bp, duration } => self.install(
|
||||||
|
target,
|
||||||
|
StatusKind::Mitigation { stage, amount_bp: apply_bp(amount_bp, potency) },
|
||||||
|
duration,
|
||||||
|
source,
|
||||||
|
),
|
||||||
|
EffectKind::MitigationDebuff { stage, amount_bp, duration } => self.install(
|
||||||
|
target,
|
||||||
|
// A debuff strips mitigation → stored negative so the pipeline
|
||||||
|
// amplifies the hit (a vulnerability/sunder).
|
||||||
|
StatusKind::Mitigation { stage, amount_bp: -apply_bp(amount_bp, potency) },
|
||||||
|
duration,
|
||||||
|
source,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&mut self, target: ActorId, kind: StatusKind, duration: u32, source: ActorId) {
|
||||||
|
if duration == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let expires_at = self.tick + duration as u64 - 1;
|
||||||
|
self.actors[target as usize].add_status(Status { kind, expires_at, source });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue(&mut self, hit: PendingHit) {
|
||||||
|
self.pending.push(hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_seq(&mut self) -> u64 {
|
||||||
|
self.seq += 1;
|
||||||
|
self.seq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply deterministic ± damage variance to a magnitude. Returns the input
|
||||||
|
/// unchanged when variance is disabled. Keyed by `(source, target, seq, tick)`
|
||||||
|
/// so it never reshuffles another hit's stream (§10).
|
||||||
|
fn varied(&self, amount: Milli, source: ActorId, target: ActorId, seq: u64) -> Milli {
|
||||||
|
let var = self.rules.damage_variance_bp;
|
||||||
|
if var <= 0 {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
let mut rng = self.root_rng.fork(&format!(
|
||||||
|
"hit:src{source}:tgt{target}:seq{seq}:tick{}",
|
||||||
|
self.tick
|
||||||
|
));
|
||||||
|
// Uniform factor in [BP_ONE - var, BP_ONE + var].
|
||||||
|
let factor = (crate::fixed::BP_ONE - var) + rng.range(0, (2 * var + 1) as i32) as i64;
|
||||||
|
apply_bp(amount, factor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- step 3 ------------------------------------------------------------
|
||||||
|
|
||||||
|
fn apply_hits(&mut self) {
|
||||||
|
// Sequential in ascending source-id order, stable within a source: hit 1
|
||||||
|
// lowers fill before hit 2's stagger check (§10).
|
||||||
|
let mut pending = std::mem::take(&mut self.pending);
|
||||||
|
pending.sort_by_key(|h| (h.source, h.seq));
|
||||||
|
for h in pending {
|
||||||
|
self.apply_one(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_one(&mut self, h: PendingHit) {
|
||||||
|
let tidx = h.target as usize;
|
||||||
|
if self.actors[tidx].stagger.is_dead() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match h.payload {
|
||||||
|
HitPayload::Fill { pre_mitigation } => {
|
||||||
|
// Per-hit damage variance (deterministic, keyed by the hit). The
|
||||||
|
// genre's damage range; also what desyncs a mirror match.
|
||||||
|
let pre_mitigation = self.varied(pre_mitigation, h.source, h.target, h.seq);
|
||||||
|
let con = self
|
||||||
|
.rules
|
||||||
|
.con_mult_bp(h.source_eff_level, self.actors[tidx].level);
|
||||||
|
let inp = MitigationInputs {
|
||||||
|
armor_reduce_bp: self.actors[tidx].armor_bp,
|
||||||
|
con_mult_bp: con,
|
||||||
|
active_defense_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::ActiveDefense),
|
||||||
|
stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance),
|
||||||
|
buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff),
|
||||||
|
};
|
||||||
|
let mit = mitigation::run(pre_mitigation, inp);
|
||||||
|
|
||||||
|
// Capture cast-in-progress to detect interruption.
|
||||||
|
let was_casting = self.actors[tidx]
|
||||||
|
.casting
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.ability_id.clone());
|
||||||
|
|
||||||
|
let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger);
|
||||||
|
self.recorder
|
||||||
|
.hit(self.tick, h.source, &h.ability_id, &self.actors[tidx], mit, outcome);
|
||||||
|
|
||||||
|
if matches!(outcome, HitOutcome::Dazed | HitOutcome::Unconscious | HitOutcome::Killed) {
|
||||||
|
if let Some(cast_id) = was_casting {
|
||||||
|
if self.actors[tidx].casting.is_none() {
|
||||||
|
self.recorder.interrupted(self.tick, h.target, &cast_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(outcome, HitOutcome::Killed) {
|
||||||
|
self.recorder.death(self.tick, h.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HitPayload::Ceiling { magnitude } => {
|
||||||
|
// Con effectiveness gates how much of the curse lands (§7).
|
||||||
|
let con = self
|
||||||
|
.rules
|
||||||
|
.con_mult_bp(h.source_eff_level, self.actors[tidx].level);
|
||||||
|
let landed = apply_bp(magnitude, con);
|
||||||
|
let before_down = !self.actors[tidx].is_active();
|
||||||
|
self.actors[tidx].add_fatigue(landed);
|
||||||
|
self.recorder
|
||||||
|
.ceiling_damage(self.tick, h.source, h.target, &h.ability_id, landed);
|
||||||
|
if !before_down && !self.actors[tidx].is_active() {
|
||||||
|
// Collapse from the curse counts as a death-adjacent event only
|
||||||
|
// if it actually killed; unconscious is logged via state.
|
||||||
|
if self.actors[tidx].stagger.is_dead() {
|
||||||
|
self.recorder.death(self.tick, h.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HitPayload::Recovery { magnitude } => {
|
||||||
|
let was_down = !self.actors[tidx].is_active();
|
||||||
|
self.actors[tidx]
|
||||||
|
.recover_fatigue(magnitude, self.tick, self.rules.revive_daze);
|
||||||
|
let revived = was_down && self.actors[tidx].is_active();
|
||||||
|
self.recorder
|
||||||
|
.recovery(self.tick, h.source, h.target, &h.ability_id, magnitude, revived);
|
||||||
|
}
|
||||||
|
HitPayload::Heal { magnitude } => {
|
||||||
|
let was_down = !self.actors[tidx].is_active();
|
||||||
|
self.actors[tidx]
|
||||||
|
.restore_fill(magnitude, self.tick, self.rules.revive_daze);
|
||||||
|
let revived = was_down && self.actors[tidx].is_active();
|
||||||
|
self.recorder.fill_restored(h.target, magnitude);
|
||||||
|
if revived {
|
||||||
|
self.recorder
|
||||||
|
.recovery(self.tick, h.source, h.target, &h.ability_id, 0, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- step 4 ------------------------------------------------------------
|
||||||
|
|
||||||
|
fn tick_over_time(&mut self) {
|
||||||
|
let n = self.actors.len();
|
||||||
|
for tidx in 0..n {
|
||||||
|
if self.actors[tidx].stagger.is_dead() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Snapshot the over-time statuses to apply (avoid borrow conflicts).
|
||||||
|
let tmax = self.actors[tidx].max_vigor;
|
||||||
|
let tlevel = self.actors[tidx].level;
|
||||||
|
let mut dots: Vec<(Milli, i32, ActorId)> = Vec::new();
|
||||||
|
let mut hots: Vec<Milli> = Vec::new();
|
||||||
|
for s in &self.actors[tidx].statuses {
|
||||||
|
match s.kind {
|
||||||
|
StatusKind::Dot { per_tick_bp, source_eff_level } => {
|
||||||
|
dots.push((apply_bp(tmax, per_tick_bp), source_eff_level, s.source));
|
||||||
|
}
|
||||||
|
StatusKind::Hot { per_tick_bp } => {
|
||||||
|
hots.push(apply_bp(tmax, per_tick_bp));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (pre, src_level, src) in dots {
|
||||||
|
let con = self.rules.con_mult_bp(src_level, tlevel);
|
||||||
|
let inp = MitigationInputs {
|
||||||
|
armor_reduce_bp: self.actors[tidx].armor_bp,
|
||||||
|
con_mult_bp: con,
|
||||||
|
active_defense_reduce_bp: 0, // a poison isn't parried
|
||||||
|
stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance),
|
||||||
|
buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff),
|
||||||
|
};
|
||||||
|
let mit = mitigation::run(pre, inp);
|
||||||
|
let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger);
|
||||||
|
self.recorder
|
||||||
|
.hit(self.tick, src, "dot", &self.actors[tidx], mit, outcome);
|
||||||
|
if matches!(outcome, HitOutcome::Killed) {
|
||||||
|
self.recorder.death(self.tick, self.actors[tidx].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for amount in hots {
|
||||||
|
self.actors[tidx]
|
||||||
|
.restore_fill(amount, self.tick, self.rules.revive_daze);
|
||||||
|
self.recorder.fill_restored(self.actors[tidx].id, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- step 5 ------------------------------------------------------------
|
||||||
|
|
||||||
|
fn apply_regen(&mut self) {
|
||||||
|
for a in &mut self.actors {
|
||||||
|
let before = a.vigor;
|
||||||
|
a.regen_tick(true, &self.rules.regen);
|
||||||
|
let gained = a.vigor - before;
|
||||||
|
if gained > 0 {
|
||||||
|
self.recorder.regen(a.id, gained);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample(&mut self) {
|
||||||
|
let tick = self.tick;
|
||||||
|
// Snapshot ids first to avoid borrowing actors while recorder mutates.
|
||||||
|
for i in 0..self.actors.len() {
|
||||||
|
let snapshot = self.actors[i].clone();
|
||||||
|
self.recorder.sample(tick, &snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolution --------------------------------------------------------
|
||||||
|
|
||||||
|
/// `Some(outcome)` once the fight is decided: one team holds the only
|
||||||
|
/// conscious actor(s) (win), or none are conscious (draw).
|
||||||
|
fn resolution(&self) -> Option<Outcome> {
|
||||||
|
let mut teams_up: Vec<u8> = Vec::new();
|
||||||
|
for a in &self.actors {
|
||||||
|
if a.is_active() && !teams_up.contains(&a.team) {
|
||||||
|
teams_up.push(a.team);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match teams_up.len() {
|
||||||
|
0 => Some(Outcome::Draw),
|
||||||
|
1 => Some(Outcome::Win { team: teams_up[0] }),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ability::{AbilitySpec, Delivery, RawCost};
|
||||||
|
use crate::actor::{compile_actor, StatBlock};
|
||||||
|
use crate::config::CombatConfig;
|
||||||
|
use crate::effect::EffectSpec;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn swing_spec() -> AbilitySpec {
|
||||||
|
AbilitySpec {
|
||||||
|
id: "swing".into(),
|
||||||
|
name: "Swing".into(),
|
||||||
|
school: "martial".into(),
|
||||||
|
delivery: Delivery::Melee,
|
||||||
|
effects: vec![EffectSpec::new(EffectKind::FillDamage { magnitude_bp: 1_000 }, 0)],
|
||||||
|
potency_bp: 10_000,
|
||||||
|
targets: 1,
|
||||||
|
cast_time: 0,
|
||||||
|
cooldown: 10,
|
||||||
|
cost_override: Some(RawCost { fill: 0, fatigue: 1 }),
|
||||||
|
fatigue_units: None,
|
||||||
|
difficulty: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fighter(name: &str, team: u8, level: i32) -> StatBlock {
|
||||||
|
StatBlock {
|
||||||
|
name: name.into(),
|
||||||
|
team,
|
||||||
|
level,
|
||||||
|
max_vigor: 100,
|
||||||
|
regen_idle_per_sec: 10,
|
||||||
|
regen_combat_per_sec: 5,
|
||||||
|
armor_bp: 0,
|
||||||
|
competency: BTreeMap::new(),
|
||||||
|
loadout: vec!["swing".into()],
|
||||||
|
start_vigor_bp: 10_000,
|
||||||
|
start_fatigue_bp: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> (Vec<Actor>, Rules) {
|
||||||
|
let rules = cfg.compile();
|
||||||
|
let actors = blocks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, sb)| {
|
||||||
|
let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
|
||||||
|
assert!(missing.is_empty(), "missing abilities: {missing:?}");
|
||||||
|
compile_actor(i as ActorId, sb, loadout, rules.tick_rate)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(actors, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_even_fight_resolves() {
|
||||||
|
let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
|
||||||
|
let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 10)];
|
||||||
|
let (actors, rules) = build(&cfg, &blocks);
|
||||||
|
|
||||||
|
let controllers: Vec<Box<dyn Controller>> = vec![
|
||||||
|
Box::new(crate::controller::SwingOnCooldown),
|
||||||
|
Box::new(crate::controller::SwingOnCooldown),
|
||||||
|
];
|
||||||
|
let enc = Encounter::new(actors, controllers, rules, 42, EncounterOptions::default());
|
||||||
|
let m = enc.run();
|
||||||
|
// Two identical swingers: it ends (someone goes down) and doesn't time out.
|
||||||
|
assert!(matches!(m.outcome, Outcome::Win { .. } | Outcome::Draw));
|
||||||
|
assert!(m.ticks < 6_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_seed_same_outcome() {
|
||||||
|
let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
|
||||||
|
let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 12)];
|
||||||
|
|
||||||
|
let run_once = |seed: u64| {
|
||||||
|
let (actors, rules) = build(&cfg, &blocks);
|
||||||
|
let controllers: Vec<Box<dyn Controller>> = vec![
|
||||||
|
Box::new(crate::controller::SwingOnCooldown),
|
||||||
|
Box::new(crate::controller::SwingOnCooldown),
|
||||||
|
];
|
||||||
|
Encounter::new(actors, controllers, rules, seed, EncounterOptions::default()).run()
|
||||||
|
};
|
||||||
|
let a = run_once(7);
|
||||||
|
let b = run_once(7);
|
||||||
|
assert_eq!(a.ticks, b.ticks);
|
||||||
|
assert_eq!(format!("{:?}", a.outcome), format!("{:?}", b.outcome));
|
||||||
|
assert_eq!(a.actors[0].fill_damage_dealt, b.actors[0].fill_damage_dealt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn higher_con_attacker_wins() {
|
||||||
|
let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
|
||||||
|
// +5 levels of con advantage should win reliably.
|
||||||
|
let blocks = vec![fighter("Strong", 0, 15), fighter("Weak", 1, 10)];
|
||||||
|
let (actors, rules) = build(&cfg, &blocks);
|
||||||
|
let controllers: Vec<Box<dyn Controller>> = vec![
|
||||||
|
Box::new(crate::controller::SwingOnCooldown),
|
||||||
|
Box::new(crate::controller::SwingOnCooldown),
|
||||||
|
];
|
||||||
|
let m = Encounter::new(actors, controllers, rules, 1, EncounterOptions::default()).run();
|
||||||
|
assert_eq!(m.outcome, Outcome::Win { team: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
524
combat/combat-core/src/event.rs
Normal file
524
combat/combat-core/src/event.rs
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
//! Instrumentation (spec §12): a structured event stream plus a recorder that
|
||||||
|
//! rolls it into per-fight metrics. Outcomes alone can't tell you *why* a config
|
||||||
|
//! feels bad, so the schema records **causes** — pre-mitigation damage and each
|
||||||
|
//! mitigation stage's contribution (you can't tune the con curve from `landed`
|
||||||
|
//! alone), `blocked` reasons (a "wanted to act but couldn't" signal that makes
|
||||||
|
//! soft-lock/turtle modes visible), and a fizzle-vs-interrupt split.
|
||||||
|
|
||||||
|
use crate::actor::{Actor, ActorId, HitOutcome, StaggerState};
|
||||||
|
use crate::fixed::Milli;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Why an attempted action didn't happen (§12 `action_blocked_reason`).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BlockedReason {
|
||||||
|
CantAfford,
|
||||||
|
OnCooldown,
|
||||||
|
Dazed,
|
||||||
|
NoTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a committed ability produced nothing (§12 `fail_reason`). Kept separate so
|
||||||
|
/// "competency too low" and "wind-ups too long" never collapse into one signal.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FailReason {
|
||||||
|
CompetencyFizzle,
|
||||||
|
Interrupted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a fight ended.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Outcome {
|
||||||
|
/// A single team had the last conscious actor(s) standing.
|
||||||
|
Win { team: u8 },
|
||||||
|
/// No conscious actors on any team (mutual collapse).
|
||||||
|
Draw,
|
||||||
|
/// Tick budget exhausted with multiple teams still up.
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw event stream (opt-in; not stored for million-fight sweeps). A tagged
|
||||||
|
/// enum so it serializes cleanly to JSON for inspecting a single fight.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "event", rename_all = "snake_case")]
|
||||||
|
pub enum Event {
|
||||||
|
Action {
|
||||||
|
tick: u64,
|
||||||
|
actor: ActorId,
|
||||||
|
ability: String,
|
||||||
|
fill_before: Milli,
|
||||||
|
fill_after: Milli,
|
||||||
|
fatigue: Milli,
|
||||||
|
cap: Milli,
|
||||||
|
},
|
||||||
|
CastStarted {
|
||||||
|
tick: u64,
|
||||||
|
actor: ActorId,
|
||||||
|
ability: String,
|
||||||
|
completes_at: u64,
|
||||||
|
},
|
||||||
|
Blocked {
|
||||||
|
tick: u64,
|
||||||
|
actor: ActorId,
|
||||||
|
ability: String,
|
||||||
|
reason: BlockedReason,
|
||||||
|
},
|
||||||
|
Fail {
|
||||||
|
tick: u64,
|
||||||
|
actor: ActorId,
|
||||||
|
ability: String,
|
||||||
|
reason: FailReason,
|
||||||
|
},
|
||||||
|
Hit {
|
||||||
|
tick: u64,
|
||||||
|
source: ActorId,
|
||||||
|
target: ActorId,
|
||||||
|
ability: String,
|
||||||
|
pre_mitigation: Milli,
|
||||||
|
armor: Milli,
|
||||||
|
con: Milli,
|
||||||
|
defense: Milli,
|
||||||
|
stance: Milli,
|
||||||
|
buff: Milli,
|
||||||
|
landed: Milli,
|
||||||
|
outcome: HitKind,
|
||||||
|
target_fill_after: Milli,
|
||||||
|
target_cap_after: Milli,
|
||||||
|
},
|
||||||
|
Ceiling {
|
||||||
|
tick: u64,
|
||||||
|
source: ActorId,
|
||||||
|
target: ActorId,
|
||||||
|
ability: String,
|
||||||
|
fatigue_added: Milli,
|
||||||
|
},
|
||||||
|
Recovery {
|
||||||
|
tick: u64,
|
||||||
|
source: ActorId,
|
||||||
|
target: ActorId,
|
||||||
|
ability: String,
|
||||||
|
fatigue_healed: Milli,
|
||||||
|
revived: bool,
|
||||||
|
},
|
||||||
|
Death {
|
||||||
|
tick: u64,
|
||||||
|
actor: ActorId,
|
||||||
|
},
|
||||||
|
End {
|
||||||
|
tick: u64,
|
||||||
|
outcome: Outcome,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable mirror of [`HitOutcome`].
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum HitKind {
|
||||||
|
Absorbed,
|
||||||
|
Dazed,
|
||||||
|
Unconscious,
|
||||||
|
Killed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HitOutcome> for HitKind {
|
||||||
|
fn from(o: HitOutcome) -> Self {
|
||||||
|
match o {
|
||||||
|
HitOutcome::Absorbed => HitKind::Absorbed,
|
||||||
|
HitOutcome::Dazed => HitKind::Dazed,
|
||||||
|
HitOutcome::Unconscious => HitKind::Unconscious,
|
||||||
|
HitOutcome::Killed => HitKind::Killed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable final state of an actor.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FinalState {
|
||||||
|
Alive,
|
||||||
|
Dazed,
|
||||||
|
Unconscious,
|
||||||
|
Dead,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StaggerState> for FinalState {
|
||||||
|
fn from(s: StaggerState) -> Self {
|
||||||
|
match s {
|
||||||
|
StaggerState::Normal => FinalState::Alive,
|
||||||
|
StaggerState::Dazed { .. } => FinalState::Dazed,
|
||||||
|
StaggerState::Unconscious => FinalState::Unconscious,
|
||||||
|
StaggerState::Dead => FinalState::Dead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-actor rolled-up metrics for one fight (§12).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ActorMetrics {
|
||||||
|
pub id: ActorId,
|
||||||
|
pub name: String,
|
||||||
|
pub team: u8,
|
||||||
|
pub level: i32,
|
||||||
|
|
||||||
|
// Throughput.
|
||||||
|
pub fill_damage_dealt: Milli,
|
||||||
|
pub fill_damage_taken: Milli,
|
||||||
|
pub ceiling_damage_dealt: Milli,
|
||||||
|
pub ceiling_damage_taken: Milli,
|
||||||
|
|
||||||
|
// Fill-flow accounting (§12 make-or-break co-tuning): where every point went.
|
||||||
|
pub fill_gained_regen: Milli,
|
||||||
|
pub fill_spent_actions: Milli,
|
||||||
|
pub fill_lost_damage: Milli,
|
||||||
|
pub fill_restored: Milli,
|
||||||
|
|
||||||
|
// Stagger ledger.
|
||||||
|
pub dazes_inflicted: u32,
|
||||||
|
pub unconscious_inflicted: u32,
|
||||||
|
pub dazes_taken: u32,
|
||||||
|
pub unconscious_taken: u32,
|
||||||
|
|
||||||
|
// Reliability.
|
||||||
|
pub fizzles: u32,
|
||||||
|
pub interruptions_suffered: u32,
|
||||||
|
|
||||||
|
// Blocked-action breakdown.
|
||||||
|
pub blocked_cant_afford: u32,
|
||||||
|
pub blocked_on_cooldown: u32,
|
||||||
|
pub blocked_dazed: u32,
|
||||||
|
pub blocked_no_target: u32,
|
||||||
|
|
||||||
|
// Action mix — feeds the auto-attack-only guardrail (§12).
|
||||||
|
pub auto_attacks: u32,
|
||||||
|
pub abilities_used: u32,
|
||||||
|
pub recoveries_used: u32,
|
||||||
|
pub idle_ticks: u32,
|
||||||
|
|
||||||
|
pub near_death_recoveries: u32,
|
||||||
|
|
||||||
|
// Lows over the fight.
|
||||||
|
pub min_fill: Milli,
|
||||||
|
pub min_cap: Milli,
|
||||||
|
|
||||||
|
pub final_state: FinalState,
|
||||||
|
|
||||||
|
/// Sampled `cap` over time (the ceiling-decay curve). Empty unless sampling
|
||||||
|
/// is enabled.
|
||||||
|
pub cap_series: Vec<Milli>,
|
||||||
|
/// Sampled `vigor` over time. Empty unless sampling is enabled.
|
||||||
|
pub fill_series: Vec<Milli>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorMetrics {
|
||||||
|
fn new(a: &Actor) -> Self {
|
||||||
|
Self {
|
||||||
|
id: a.id,
|
||||||
|
name: a.name.clone(),
|
||||||
|
team: a.team,
|
||||||
|
level: a.level,
|
||||||
|
fill_damage_dealt: 0,
|
||||||
|
fill_damage_taken: 0,
|
||||||
|
ceiling_damage_dealt: 0,
|
||||||
|
ceiling_damage_taken: 0,
|
||||||
|
fill_gained_regen: 0,
|
||||||
|
fill_spent_actions: 0,
|
||||||
|
fill_lost_damage: 0,
|
||||||
|
fill_restored: 0,
|
||||||
|
dazes_inflicted: 0,
|
||||||
|
unconscious_inflicted: 0,
|
||||||
|
dazes_taken: 0,
|
||||||
|
unconscious_taken: 0,
|
||||||
|
fizzles: 0,
|
||||||
|
interruptions_suffered: 0,
|
||||||
|
blocked_cant_afford: 0,
|
||||||
|
blocked_on_cooldown: 0,
|
||||||
|
blocked_dazed: 0,
|
||||||
|
blocked_no_target: 0,
|
||||||
|
auto_attacks: 0,
|
||||||
|
abilities_used: 0,
|
||||||
|
recoveries_used: 0,
|
||||||
|
idle_ticks: 0,
|
||||||
|
near_death_recoveries: 0,
|
||||||
|
min_fill: a.vigor,
|
||||||
|
min_cap: a.cap(),
|
||||||
|
final_state: FinalState::Alive,
|
||||||
|
cap_series: Vec::new(),
|
||||||
|
fill_series: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whole-fight metrics (§12). The sim exports one row per fight from this.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FightMetrics {
|
||||||
|
pub seed: u64,
|
||||||
|
pub ticks: u64,
|
||||||
|
pub tick_rate: u32,
|
||||||
|
pub outcome: Outcome,
|
||||||
|
pub actors: Vec<ActorMetrics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FightMetrics {
|
||||||
|
/// Fight duration in seconds (derived from ticks and rate).
|
||||||
|
pub fn seconds(&self) -> f64 {
|
||||||
|
self.ticks as f64 / self.tick_rate.max(1) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up an actor's metrics by id.
|
||||||
|
pub fn actor(&self, id: ActorId) -> Option<&ActorMetrics> {
|
||||||
|
self.actors.iter().find(|m| m.id == id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates events into [`FightMetrics`]. Always tracks the rollups; only
|
||||||
|
/// retains the raw [`Event`] log when `log_events` is set (cheap for a single
|
||||||
|
/// inspected fight, skipped for million-fight sweeps). Sampling `cap`/`fill`
|
||||||
|
/// series is gated by `sample_every` (0 = off).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FightRecorder {
|
||||||
|
metrics: Vec<ActorMetrics>,
|
||||||
|
events: Vec<Event>,
|
||||||
|
log_events: bool,
|
||||||
|
sample_every: u32,
|
||||||
|
tick_rate: u32,
|
||||||
|
seed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FightRecorder {
|
||||||
|
pub fn new(actors: &[Actor], seed: u64, tick_rate: u32, log_events: bool, sample_every: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
metrics: actors.iter().map(ActorMetrics::new).collect(),
|
||||||
|
events: Vec::new(),
|
||||||
|
log_events,
|
||||||
|
sample_every,
|
||||||
|
tick_rate,
|
||||||
|
seed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn m(&mut self, id: ActorId) -> &mut ActorMetrics {
|
||||||
|
// Actor ids are dense 0..n in encounters; fall back to a search if not.
|
||||||
|
let idx = self
|
||||||
|
.metrics
|
||||||
|
.iter()
|
||||||
|
.position(|x| x.id == id)
|
||||||
|
.expect("metrics exist for every actor");
|
||||||
|
&mut self.metrics[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, e: Event) {
|
||||||
|
if self.log_events {
|
||||||
|
self.events.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- engine-facing recording API --------------------------------------
|
||||||
|
|
||||||
|
pub fn action(&mut self, tick: u64, a: &Actor, ability: &str, fill_before: Milli) {
|
||||||
|
self.push(Event::Action {
|
||||||
|
tick,
|
||||||
|
actor: a.id,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
fill_before,
|
||||||
|
fill_after: a.vigor,
|
||||||
|
fatigue: a.fatigue,
|
||||||
|
cap: a.cap(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cast_started(&mut self, tick: u64, actor: ActorId, ability: &str, completes_at: u64) {
|
||||||
|
self.push(Event::CastStarted {
|
||||||
|
tick,
|
||||||
|
actor,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
completes_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn idle(&mut self, actor: ActorId) {
|
||||||
|
self.m(actor).idle_ticks += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auto_attack(&mut self, actor: ActorId) {
|
||||||
|
self.m(actor).auto_attacks += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ability_used(&mut self, actor: ActorId) {
|
||||||
|
self.m(actor).abilities_used += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recovery_used(&mut self, actor: ActorId) {
|
||||||
|
self.m(actor).recoveries_used += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spent(&mut self, actor: ActorId, fill: Milli) {
|
||||||
|
self.m(actor).fill_spent_actions += fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blocked(&mut self, tick: u64, actor: ActorId, ability: &str, reason: BlockedReason) {
|
||||||
|
{
|
||||||
|
let m = self.m(actor);
|
||||||
|
match reason {
|
||||||
|
BlockedReason::CantAfford => m.blocked_cant_afford += 1,
|
||||||
|
BlockedReason::OnCooldown => m.blocked_on_cooldown += 1,
|
||||||
|
BlockedReason::Dazed => m.blocked_dazed += 1,
|
||||||
|
BlockedReason::NoTarget => m.blocked_no_target += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.push(Event::Blocked {
|
||||||
|
tick,
|
||||||
|
actor,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fizzle(&mut self, tick: u64, actor: ActorId, ability: &str) {
|
||||||
|
self.m(actor).fizzles += 1;
|
||||||
|
self.push(Event::Fail {
|
||||||
|
tick,
|
||||||
|
actor,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
reason: FailReason::CompetencyFizzle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interrupted(&mut self, tick: u64, actor: ActorId, ability: &str) {
|
||||||
|
self.m(actor).interruptions_suffered += 1;
|
||||||
|
self.push(Event::Fail {
|
||||||
|
tick,
|
||||||
|
actor,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
reason: FailReason::Interrupted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a resolved hit with its full mitigation breakdown.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn hit(
|
||||||
|
&mut self,
|
||||||
|
tick: u64,
|
||||||
|
source: ActorId,
|
||||||
|
ability: &str,
|
||||||
|
target: &Actor,
|
||||||
|
mit: crate::mitigation::MitigationResult,
|
||||||
|
outcome: HitOutcome,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let m = self.m(source);
|
||||||
|
m.fill_damage_dealt += mit.landed;
|
||||||
|
match outcome {
|
||||||
|
HitOutcome::Dazed => m.dazes_inflicted += 1,
|
||||||
|
HitOutcome::Unconscious | HitOutcome::Killed => m.unconscious_inflicted += 1,
|
||||||
|
HitOutcome::Absorbed => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let m = self.m(target.id);
|
||||||
|
m.fill_damage_taken += mit.landed;
|
||||||
|
m.fill_lost_damage += mit.landed;
|
||||||
|
match outcome {
|
||||||
|
HitOutcome::Dazed => m.dazes_taken += 1,
|
||||||
|
HitOutcome::Unconscious => m.unconscious_taken += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.push(Event::Hit {
|
||||||
|
tick,
|
||||||
|
source,
|
||||||
|
target: target.id,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
pre_mitigation: mit.pre_mitigation,
|
||||||
|
armor: mit.armor_delta,
|
||||||
|
con: mit.con_delta,
|
||||||
|
defense: mit.defense_delta,
|
||||||
|
stance: mit.stance_delta,
|
||||||
|
buff: mit.buff_delta,
|
||||||
|
landed: mit.landed,
|
||||||
|
outcome: outcome.into(),
|
||||||
|
target_fill_after: target.vigor,
|
||||||
|
target_cap_after: target.cap(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ceiling_damage(&mut self, tick: u64, source: ActorId, target: ActorId, ability: &str, fatigue: Milli) {
|
||||||
|
self.m(source).ceiling_damage_dealt += fatigue;
|
||||||
|
self.m(target).ceiling_damage_taken += fatigue;
|
||||||
|
self.push(Event::Ceiling {
|
||||||
|
tick,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
fatigue_added: fatigue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recovery(
|
||||||
|
&mut self,
|
||||||
|
tick: u64,
|
||||||
|
source: ActorId,
|
||||||
|
target: ActorId,
|
||||||
|
ability: &str,
|
||||||
|
fatigue_healed: Milli,
|
||||||
|
revived: bool,
|
||||||
|
) {
|
||||||
|
if revived {
|
||||||
|
self.m(target).near_death_recoveries += 1;
|
||||||
|
}
|
||||||
|
self.push(Event::Recovery {
|
||||||
|
tick,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
ability: ability.to_string(),
|
||||||
|
fatigue_healed,
|
||||||
|
revived,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fill_restored(&mut self, target: ActorId, amount: Milli) {
|
||||||
|
self.m(target).fill_restored += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn regen(&mut self, actor: ActorId, amount: Milli) {
|
||||||
|
self.m(actor).fill_gained_regen += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn death(&mut self, tick: u64, actor: ActorId) {
|
||||||
|
self.push(Event::Death { tick, actor });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick sampling of lows + (optional) time series.
|
||||||
|
pub fn sample(&mut self, tick: u64, a: &Actor) {
|
||||||
|
let store_series = self.sample_every > 0 && tick.is_multiple_of(self.sample_every as u64);
|
||||||
|
let m = self.m(a.id);
|
||||||
|
m.min_fill = m.min_fill.min(a.vigor);
|
||||||
|
m.min_cap = m.min_cap.min(a.cap());
|
||||||
|
if store_series {
|
||||||
|
m.cap_series.push(a.cap());
|
||||||
|
m.fill_series.push(a.vigor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalize into [`FightMetrics`], stamping each actor's terminal state.
|
||||||
|
pub fn finish(mut self, tick: u64, outcome: Outcome, actors: &[Actor]) -> FightMetrics {
|
||||||
|
for a in actors {
|
||||||
|
self.m(a.id).final_state = a.stagger.into();
|
||||||
|
}
|
||||||
|
self.push(Event::End { tick, outcome });
|
||||||
|
FightMetrics {
|
||||||
|
seed: self.seed,
|
||||||
|
ticks: tick,
|
||||||
|
tick_rate: self.tick_rate,
|
||||||
|
outcome,
|
||||||
|
actors: self.metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw event log (empty unless `log_events` was set).
|
||||||
|
pub fn into_events(self) -> Vec<Event> {
|
||||||
|
self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
138
combat/combat-core/src/fixed.rs
Normal file
138
combat/combat-core/src/fixed.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
//! Integer fixed-point arithmetic — the *only* place magnitude math rounds.
|
||||||
|
//!
|
||||||
|
//! Determinism (spec §10) requires bit-exact results across compilers, opt
|
||||||
|
//! levels, and machines. IEEE-754 `f64` multiply *chains* do **not** give that,
|
||||||
|
//! so every balance-relevant magnitude is an integer and every formula routes
|
||||||
|
//! through the helpers here.
|
||||||
|
//!
|
||||||
|
//! ## Units
|
||||||
|
//! - **Magnitudes** (vigor, cap, fatigue, damage, regen) are **milli-units**:
|
||||||
|
//! one displayed "unit" of Vigor = [`UNIT`] (`1000`) internal points. The ×1000
|
||||||
|
//! headroom means per-tick regen and small percentages don't round to zero.
|
||||||
|
//! - **Ratios / percentages / multipliers** are **basis points**: [`BP_ONE`]
|
||||||
|
//! (`10_000`) == `1.0`, so `2_500` == `25%`, `12_457` == `1.2457×`.
|
||||||
|
//!
|
||||||
|
//! ## Rounding
|
||||||
|
//! Every conversion rounds **half to even** (banker's rounding). Always-floor
|
||||||
|
//! or always-up accumulates a systematic skew over a long fight — e.g.
|
||||||
|
//! always-floor on mitigation quietly favors the defender (spec §15). Half-to-
|
||||||
|
//! even is symmetric around zero and unbiased on a stream of ties, so it is the
|
||||||
|
//! single documented rule for *every* pipeline stage.
|
||||||
|
|
||||||
|
/// A magnitude in milli-units (vigor, fatigue, damage, …). Signed so that
|
||||||
|
/// recovery (negative fatigue cost) and over-spend are representable.
|
||||||
|
pub type Milli = i64;
|
||||||
|
|
||||||
|
/// One displayed Vigor unit, in milli-units.
|
||||||
|
pub const UNIT: Milli = 1_000;
|
||||||
|
|
||||||
|
/// `1.0` expressed in basis points.
|
||||||
|
pub const BP_ONE: i64 = 10_000;
|
||||||
|
|
||||||
|
/// Multiply a magnitude by a basis-point ratio, rounding half-to-even.
|
||||||
|
///
|
||||||
|
/// `apply_bp(20_000, 5_000)` = `20_000 × 0.5` = `10_000`.
|
||||||
|
/// `apply_bp(1_000, 12_457)` = `1_000 × 1.2457` ≈ `1_246`.
|
||||||
|
#[inline]
|
||||||
|
pub fn apply_bp(amount: Milli, ratio_bp: i64) -> Milli {
|
||||||
|
mul_div_round(amount, ratio_bp, BP_ONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert displayed units to internal milli-units.
|
||||||
|
#[inline]
|
||||||
|
pub fn units(n: i64) -> Milli {
|
||||||
|
n * UNIT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a * b / den`, computed in `i128` to avoid overflow, rounded half-to-even.
|
||||||
|
/// `den` must be positive.
|
||||||
|
#[inline]
|
||||||
|
pub fn mul_div_round(a: i64, b: i64, den: i64) -> i64 {
|
||||||
|
debug_assert!(den > 0, "mul_div_round denominator must be positive");
|
||||||
|
let num = (a as i128) * (b as i128);
|
||||||
|
div_round_even(num, den as i128) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integer division `num / den` rounding half **to even**. `den > 0`.
|
||||||
|
///
|
||||||
|
/// Built on Euclidean division so the floor base and non-negative remainder are
|
||||||
|
/// well-defined for negative `num` (recovery / over-spend produce negatives).
|
||||||
|
#[inline]
|
||||||
|
fn div_round_even(num: i128, den: i128) -> i128 {
|
||||||
|
let q = num.div_euclid(den); // floor(num/den)
|
||||||
|
let r = num.rem_euclid(den); // 0 <= r < den
|
||||||
|
let twice = r * 2;
|
||||||
|
if twice < den {
|
||||||
|
q
|
||||||
|
} else if twice > den {
|
||||||
|
q + 1
|
||||||
|
} else {
|
||||||
|
// Exactly halfway: round toward the even neighbor.
|
||||||
|
if q % 2 == 0 {
|
||||||
|
q
|
||||||
|
} else {
|
||||||
|
q + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp `x` into `[lo, hi]`. Panics in debug if `lo > hi`.
|
||||||
|
#[inline]
|
||||||
|
pub fn clamp(x: Milli, lo: Milli, hi: Milli) -> Milli {
|
||||||
|
debug_assert!(lo <= hi);
|
||||||
|
x.max(lo).min(hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_bp_basic() {
|
||||||
|
assert_eq!(apply_bp(20_000, 5_000), 10_000); // 50%
|
||||||
|
assert_eq!(apply_bp(20_000, 10_000), 20_000); // 100%
|
||||||
|
assert_eq!(apply_bp(20_000, 0), 0);
|
||||||
|
assert_eq!(apply_bp(1_000, 12_457), 1_246); // 1.2457×, rounds 1245.7 -> 1246
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounds_half_to_even() {
|
||||||
|
// 2.5 -> 2 (even), 3.5 -> 4 (even), 1.5 -> 2, 0.5 -> 0.
|
||||||
|
assert_eq!(mul_div_round(5, 1, 2), 2);
|
||||||
|
assert_eq!(mul_div_round(7, 1, 2), 4);
|
||||||
|
assert_eq!(mul_div_round(3, 1, 2), 2);
|
||||||
|
assert_eq!(mul_div_round(1, 1, 2), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounds_negatives_symmetrically() {
|
||||||
|
// -2.5 -> -2 (even), -3.5 -> -4, -0.5 -> 0, -1.5 -> -2.
|
||||||
|
assert_eq!(mul_div_round(-5, 1, 2), -2);
|
||||||
|
assert_eq!(mul_div_round(-7, 1, 2), -4);
|
||||||
|
assert_eq!(mul_div_round(-1, 1, 2), 0);
|
||||||
|
assert_eq!(mul_div_round(-3, 1, 2), -2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Over a symmetric stream of half-ties the even rule introduces no net
|
||||||
|
/// drift — the property that protects long-fight balance (spec §15).
|
||||||
|
#[test]
|
||||||
|
fn no_systematic_drift_on_ties() {
|
||||||
|
// Sum of round(k + 0.5) for k in -100..100 should equal the sum of the
|
||||||
|
// exact values (the +0.5 ties cancel under round-half-to-even).
|
||||||
|
let mut rounded = 0i128;
|
||||||
|
let mut exact = 0i128;
|
||||||
|
for k in -100..100 {
|
||||||
|
// value = (2k+1)/2 -> numerator 2k+1, den 2
|
||||||
|
rounded += div_round_even((2 * k + 1) as i128, 2);
|
||||||
|
exact += (2 * k + 1) as i128; // times 2; compare scaled below
|
||||||
|
}
|
||||||
|
// exact average is sum/2; rounded should equal that with no bias.
|
||||||
|
assert_eq!(rounded * 2, exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mul_div_no_overflow_near_limits() {
|
||||||
|
// Large but realistic: 10^9 milli * 30000 bp / 10000 = 3 * 10^9.
|
||||||
|
assert_eq!(mul_div_round(1_000_000_000, 30_000, 10_000), 3_000_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
combat/combat-core/src/lib.rs
Normal file
54
combat/combat-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
//! # combat-core
|
||||||
|
//!
|
||||||
|
//! A pure, deterministic, headless **combined-pool** combat engine. Every
|
||||||
|
//! combatant has a single `Vigor` pool that replaces HP / mana / stamina:
|
||||||
|
//! everything you do *and* everything done to you draws on the same bar, so
|
||||||
|
//! offense literally spends your survival.
|
||||||
|
//!
|
||||||
|
//! The crate is the single source of truth for combat math. It has no I/O, no
|
||||||
|
//! rendering, no real-time — a fight is a pure function of `(rules, seed, actors,
|
||||||
|
//! controllers)`, reproducible bit-for-bit (see [`rng`] and [`fixed`]). A batch
|
||||||
|
//! sim harness and (later) a real-time front-end are both just consumers.
|
||||||
|
//!
|
||||||
|
//! ## The shape of a fight
|
||||||
|
//! 1. Author a [`config::CombatConfig`] (the tunable "table") and `compile()` it
|
||||||
|
//! to [`config::Rules`].
|
||||||
|
//! 2. Build [`actor::Actor`]s from [`actor::StatBlock`]s + resolved loadouts.
|
||||||
|
//! 3. Pair each with a [`controller::Controller`] (intent).
|
||||||
|
//! 4. Run an [`engine::Encounter`] → [`event::FightMetrics`].
|
||||||
|
//!
|
||||||
|
//! ## Module map
|
||||||
|
//! - [`fixed`] — integer fixed-point arithmetic; the only place magnitudes round.
|
||||||
|
//! - [`rng`] — vendored order-independent, version-stable deterministic RNG.
|
||||||
|
//! - [`con`] — the level-delta → multiplier con curve.
|
||||||
|
//! - [`mitigation`] — the ordered "how much lands" pipeline.
|
||||||
|
//! - [`effect`] / [`ability`] — the composable effect library and unified ability
|
||||||
|
//! model (skills == spells) with its two-axis cost composer.
|
||||||
|
//! - [`actor`] — the symmetric combatant: combined pool, stagger cascade, statuses.
|
||||||
|
//! - [`config`] — the full tunable surface and its compiled [`config::Rules`].
|
||||||
|
//! - [`controller`] — pluggable decision policies (dumb AI, scripted player,
|
||||||
|
//! hold-and-punish).
|
||||||
|
//! - [`event`] — the structured event stream + metrics recorder.
|
||||||
|
//! - [`engine`] — the fixed-tick loop and canonical within-tick resolution order.
|
||||||
|
|
||||||
|
pub mod ability;
|
||||||
|
pub mod actor;
|
||||||
|
pub mod con;
|
||||||
|
pub mod config;
|
||||||
|
pub mod controller;
|
||||||
|
pub mod effect;
|
||||||
|
pub mod engine;
|
||||||
|
pub mod event;
|
||||||
|
pub mod fixed;
|
||||||
|
pub mod mitigation;
|
||||||
|
pub mod rng;
|
||||||
|
|
||||||
|
// Convenience re-exports for the common surface.
|
||||||
|
pub use ability::{Ability, AbilitySpec, AbilityCost, Delivery};
|
||||||
|
pub use actor::{Actor, ActorId, StatBlock, StaggerState};
|
||||||
|
pub use config::{CombatConfig, Rules};
|
||||||
|
pub use controller::{Controller, HoldAndPunish, ScriptedPlayer, SwingOnCooldown};
|
||||||
|
pub use effect::{EffectKind, EffectSpec};
|
||||||
|
pub use engine::{Encounter, EncounterOptions};
|
||||||
|
pub use event::{ActorMetrics, FightMetrics, Outcome};
|
||||||
|
pub use rng::Rng;
|
||||||
184
combat/combat-core/src/mitigation.rs
Normal file
184
combat/combat-core/src/mitigation.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
//! The mitigation pipeline (spec §8): one ordered place where "how much of this
|
||||||
|
//! hit actually lands" is decided, so new sources are additive contributors and
|
||||||
|
//! never reworks.
|
||||||
|
//!
|
||||||
|
//! Order is fixed: **Armor → Con → Active defense → Stance → Buff/debuff**.
|
||||||
|
//! Armor / defenses / stances / buffs are *reductions* (a fraction passes
|
||||||
|
//! through). Con is a *multiplier* drawn from the con curve (§7) — it can
|
||||||
|
//! amplify (over-con) as well as shrink (under-con). The pipeline reports each
|
||||||
|
//! stage's contribution because you cannot tune the con curve, the balance
|
||||||
|
//! lever, from the final `landed` number alone (spec §12).
|
||||||
|
|
||||||
|
use crate::fixed::{apply_bp, Milli, BP_ONE};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The ordered stages of the pipeline. `ActiveDefense`, `Stance`, and `Buff` are
|
||||||
|
/// the stages that timed/temporary effects feed into.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MitigationStage {
|
||||||
|
Armor,
|
||||||
|
Con,
|
||||||
|
ActiveDefense,
|
||||||
|
Stance,
|
||||||
|
Buff,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-stage reduction/multiplier inputs for a single hit, gathered by the
|
||||||
|
/// engine from the defender's stat block + active statuses.
|
||||||
|
///
|
||||||
|
/// All `*_reduce_bp` are "fraction removed" in basis points (`10_000` == removes
|
||||||
|
/// everything). `con_mult_bp` is a straight multiplier (`10_000` == ×1.0,
|
||||||
|
/// `30_000` == ×3.0). `buff_reduce_bp` may be **negative** — a vulnerability
|
||||||
|
/// debuff that *amplifies* incoming damage.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct MitigationInputs {
|
||||||
|
pub armor_reduce_bp: i64,
|
||||||
|
pub con_mult_bp: i64,
|
||||||
|
pub active_defense_reduce_bp: i64,
|
||||||
|
pub stance_reduce_bp: i64,
|
||||||
|
pub buff_reduce_bp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The landed damage plus the per-stage breakdown (each is `before − after` at
|
||||||
|
/// that stage, so they sum — with sign — to `pre_mitigation − landed`). The con
|
||||||
|
/// delta is negative when over-con amplifies the hit.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct MitigationResult {
|
||||||
|
pub pre_mitigation: Milli,
|
||||||
|
pub landed: Milli,
|
||||||
|
pub armor_delta: Milli,
|
||||||
|
pub con_delta: Milli,
|
||||||
|
pub defense_delta: Milli,
|
||||||
|
pub stance_delta: Milli,
|
||||||
|
pub buff_delta: Milli,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a pre-mitigation magnitude through the full ordered pipeline.
|
||||||
|
///
|
||||||
|
/// Each reduction multiplies the running value by `(1 − reduce)`, clamped so a
|
||||||
|
/// single reduction stage can null a hit but never make it negative. Con
|
||||||
|
/// multiplies (and may amplify). The final `landed` is floored at zero.
|
||||||
|
pub fn run(pre_mitigation: Milli, inp: MitigationInputs) -> MitigationResult {
|
||||||
|
let mut cur = pre_mitigation;
|
||||||
|
|
||||||
|
// Armor — passive attrition reduction.
|
||||||
|
let after_armor = reduce(cur, inp.armor_reduce_bp);
|
||||||
|
let armor_delta = cur - after_armor;
|
||||||
|
cur = after_armor;
|
||||||
|
|
||||||
|
// Con — level-delta multiplier (the amplify-capable stage).
|
||||||
|
let after_con = apply_bp(cur, inp.con_mult_bp.max(0));
|
||||||
|
let con_delta = cur - after_con; // negative when amplified
|
||||||
|
cur = after_con;
|
||||||
|
|
||||||
|
// Active defense — block/parry/evade windows.
|
||||||
|
let after_def = reduce(cur, inp.active_defense_reduce_bp);
|
||||||
|
let defense_delta = cur - after_def;
|
||||||
|
cur = after_def;
|
||||||
|
|
||||||
|
// Stance — toggled mitigation trade.
|
||||||
|
let after_stance = reduce(cur, inp.stance_reduce_bp);
|
||||||
|
let stance_delta = cur - after_stance;
|
||||||
|
cur = after_stance;
|
||||||
|
|
||||||
|
// Buff/debuff — temporary contributors. Negative reduce amplifies.
|
||||||
|
let after_buff = reduce_signed(cur, inp.buff_reduce_bp);
|
||||||
|
let buff_delta = cur - after_buff;
|
||||||
|
cur = after_buff.max(0);
|
||||||
|
|
||||||
|
MitigationResult {
|
||||||
|
pre_mitigation,
|
||||||
|
landed: cur,
|
||||||
|
armor_delta,
|
||||||
|
con_delta,
|
||||||
|
defense_delta,
|
||||||
|
stance_delta,
|
||||||
|
buff_delta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a non-negative reduction (`reduce_bp` clamped to `[0, BP_ONE]`).
|
||||||
|
#[inline]
|
||||||
|
fn reduce(amount: Milli, reduce_bp: i64) -> Milli {
|
||||||
|
let keep = BP_ONE - reduce_bp.clamp(0, BP_ONE);
|
||||||
|
apply_bp(amount, keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a signed reduction: positive shrinks (clamped to full negation),
|
||||||
|
/// negative amplifies (a vulnerability debuff). `keep = 1 − reduce` is clamped
|
||||||
|
/// at the low end to 0 (can't go negative) but may exceed 1 to amplify.
|
||||||
|
#[inline]
|
||||||
|
fn reduce_signed(amount: Milli, reduce_bp: i64) -> Milli {
|
||||||
|
let keep = (BP_ONE - reduce_bp).max(0);
|
||||||
|
apply_bp(amount, keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn no_mit(con_mult_bp: i64) -> MitigationInputs {
|
||||||
|
MitigationInputs {
|
||||||
|
armor_reduce_bp: 0,
|
||||||
|
con_mult_bp,
|
||||||
|
active_defense_reduce_bp: 0,
|
||||||
|
stance_reduce_bp: 0,
|
||||||
|
buff_reduce_bp: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn even_con_no_mitigation_is_identity() {
|
||||||
|
let r = run(10_000, no_mit(BP_ONE));
|
||||||
|
assert_eq!(r.landed, 10_000);
|
||||||
|
assert_eq!(r.con_delta, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn over_con_amplifies_via_con_stage() {
|
||||||
|
let r = run(10_000, no_mit(30_000)); // ×3.0
|
||||||
|
assert_eq!(r.landed, 30_000);
|
||||||
|
assert_eq!(r.con_delta, -20_000); // amplification reads as negative mitigation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn armor_then_con_order_matters() {
|
||||||
|
// 50% armor, then ×2.0 con: 10000 -> 5000 -> 10000.
|
||||||
|
let inp = MitigationInputs { armor_reduce_bp: 5_000, ..no_mit(20_000) };
|
||||||
|
let r = run(10_000, inp);
|
||||||
|
assert_eq!(r.armor_delta, 5_000);
|
||||||
|
assert_eq!(r.landed, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_defense_can_negate() {
|
||||||
|
let inp = MitigationInputs { active_defense_reduce_bp: 10_000, ..no_mit(BP_ONE) };
|
||||||
|
let r = run(10_000, inp);
|
||||||
|
assert_eq!(r.landed, 0);
|
||||||
|
assert_eq!(r.defense_delta, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vulnerability_debuff_amplifies() {
|
||||||
|
// buff_reduce_bp = -5000 => keep 1.5x.
|
||||||
|
let inp = MitigationInputs { buff_reduce_bp: -5_000, ..no_mit(BP_ONE) };
|
||||||
|
let r = run(10_000, inp);
|
||||||
|
assert_eq!(r.landed, 15_000);
|
||||||
|
assert_eq!(r.buff_delta, -5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deltas_reconcile_with_landed() {
|
||||||
|
let inp = MitigationInputs {
|
||||||
|
armor_reduce_bp: 2_000,
|
||||||
|
con_mult_bp: 16_000,
|
||||||
|
active_defense_reduce_bp: 3_000,
|
||||||
|
stance_reduce_bp: 1_000,
|
||||||
|
buff_reduce_bp: -1_000,
|
||||||
|
};
|
||||||
|
let r = run(12_345, inp);
|
||||||
|
let sum = r.armor_delta + r.con_delta + r.defense_delta + r.stance_delta + r.buff_delta;
|
||||||
|
assert_eq!(r.pre_mitigation - r.landed, sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
282
combat/combat-core/src/rng.rs
Normal file
282
combat/combat-core/src/rng.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
//! Deterministic, reproducible RNG — **vendored verbatim** from
|
||||||
|
//! `reikhelm-core/src/rng.rs` (spec §10), with one additive local extension:
|
||||||
|
//! [`Rng::chance_bp`], an integer-threshold probability check so that *no*
|
||||||
|
//! `f64` ever touches the combat hot path.
|
||||||
|
//!
|
||||||
|
//! [`Rng`] wraps [`rand_chacha::ChaCha8Rng`] — a portable, byte-stable stream
|
||||||
|
//! cipher RNG. The same seed yields the same sequence on every machine and
|
||||||
|
//! every run; we never touch `thread_rng`/`rand::random()`.
|
||||||
|
//!
|
||||||
|
//! The keystone feature is [`Rng::fork`]: it derives an *independent* child
|
||||||
|
//! stream from the parent's **basis seed** plus a label. Forking does not draw
|
||||||
|
//! from or otherwise mutate the parent (`fork(&self)`), so a child stream is a
|
||||||
|
//! pure function of `(basis_seed, label)` — independent of how many values were
|
||||||
|
//! pulled from the parent first. The combat engine leans on this to key every
|
||||||
|
//! roll by `"actor{id}:{roll_kind}:tick{t}"`, which is what makes "change one
|
||||||
|
//! knob, observe the isolated delta" hold: adding an effect to one ability
|
||||||
|
//! never reshuffles another actor's or another tick's stream.
|
||||||
|
|
||||||
|
use rand_chacha::ChaCha8Rng;
|
||||||
|
// `rand_core` is not a direct dependency; reach its traits through the
|
||||||
|
// re-export on `rand_chacha` so the two stay version-locked. `Rng` carries
|
||||||
|
// `next_u64`; `SeedableRng` carries `seed_from_u64`.
|
||||||
|
use rand_chacha::rand_core::{Rng as _, SeedableRng};
|
||||||
|
|
||||||
|
/// A deterministic random number generator backed by ChaCha8.
|
||||||
|
///
|
||||||
|
/// Construct one with [`Rng::from_seed`], draw values with [`range`](Rng::range),
|
||||||
|
/// [`chance`](Rng::chance), [`chance_bp`](Rng::chance_bp), [`choose`](Rng::choose)
|
||||||
|
/// and [`shuffle`](Rng::shuffle), and derive isolated child streams with
|
||||||
|
/// [`fork`](Rng::fork).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Rng {
|
||||||
|
/// The stable basis used for all forks: the seed this `Rng` was built from.
|
||||||
|
/// Forking mixes *this* (never the live ChaCha state) with the label, so a
|
||||||
|
/// fork is independent of how many draws the parent has taken.
|
||||||
|
basis_seed: u64,
|
||||||
|
/// The live ChaCha8 stream that actually produces random words.
|
||||||
|
inner: ChaCha8Rng,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
/// Create an `Rng` from a 64-bit seed, storing the seed as the fork basis.
|
||||||
|
///
|
||||||
|
/// The seed is expanded into ChaCha8's 256-bit key by ChaCha8's own
|
||||||
|
/// portable `seed_from_u64`, so two `Rng`s built from the same `u64`
|
||||||
|
/// produce identical streams everywhere.
|
||||||
|
pub fn from_seed(seed: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
basis_seed: seed,
|
||||||
|
inner: ChaCha8Rng::seed_from_u64(seed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive an independent child `Rng` from a stable basis (this `Rng`'s
|
||||||
|
/// originating seed) combined with `label`.
|
||||||
|
///
|
||||||
|
/// This **does not** advance or otherwise mutate `self` (note `&self`), and
|
||||||
|
/// the result depends only on `(self.basis_seed, label)` — never on how many
|
||||||
|
/// values have already been drawn from `self`. Distinct labels (including
|
||||||
|
/// `"name#0"` vs `"name#1"`) yield distinct streams.
|
||||||
|
///
|
||||||
|
/// The label→seed mix is the explicit, fixed integer routine in
|
||||||
|
/// [`mix_seed`] (splitmix64 + FNV-1a), chosen because it is stable across
|
||||||
|
/// runs and crate versions. We deliberately avoid
|
||||||
|
/// `std::collections::hash_map::DefaultHasher`, whose output is
|
||||||
|
/// unspecified and version-unstable (spec §10).
|
||||||
|
pub fn fork(&self, label: &str) -> Rng {
|
||||||
|
Rng::from_seed(mix_seed(self.basis_seed, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a `u64` of raw randomness from the live stream.
|
||||||
|
fn next_u64(&mut self) -> u64 {
|
||||||
|
self.inner.next_u64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an integer in the **half-open** range `[lo, hi)` — `lo` inclusive,
|
||||||
|
/// `hi` exclusive (so `range(0, 3)` yields one of `0, 1, 2`).
|
||||||
|
///
|
||||||
|
/// If `hi <= lo` the range is empty; we return `lo` rather than panicking.
|
||||||
|
/// Uniformity is achieved with Lemire-style rejection sampling over the
|
||||||
|
/// `u64` span, so the result is unbiased across the whole range.
|
||||||
|
pub fn range(&mut self, lo: i32, hi: i32) -> i32 {
|
||||||
|
if hi <= lo {
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
// Width fits in u64: (hi - lo) where hi > lo and both are i32.
|
||||||
|
let span = (hi as i64 - lo as i64) as u64;
|
||||||
|
lo + self.below(span) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform integer in `[0, span)` for `span > 0`, via rejection sampling to
|
||||||
|
/// avoid modulo bias. `span` is assumed non-zero by callers.
|
||||||
|
fn below(&mut self, span: u64) -> u64 {
|
||||||
|
// Largest multiple of `span` that fits in u64; anything at or above the
|
||||||
|
// threshold would skew the distribution, so we reject and redraw.
|
||||||
|
let threshold = u64::MAX - (u64::MAX % span);
|
||||||
|
loop {
|
||||||
|
let x = self.next_u64();
|
||||||
|
if x < threshold {
|
||||||
|
return x % span;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `true` with probability `p`.
|
||||||
|
///
|
||||||
|
/// `p` is clamped to `[0.0, 1.0]`. The roll is a single `u64 → f64`
|
||||||
|
/// unit-interval divide-and-compare, which *is* IEEE-deterministic across
|
||||||
|
/// platforms (one division, one compare). Combat code prefers
|
||||||
|
/// [`chance_bp`](Rng::chance_bp) to keep the hot path integer-only, but this
|
||||||
|
/// remains available and identical to the upstream contract.
|
||||||
|
pub fn chance(&mut self, p: f64) -> bool {
|
||||||
|
if p <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if p >= 1.0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Map the next u64 into a uniform f64 in [0, 1) and compare.
|
||||||
|
// 53 bits gives the full mantissa precision of an f64.
|
||||||
|
let bits = self.next_u64() >> 11; // top 53 bits
|
||||||
|
let unit = bits as f64 / (1u64 << 53) as f64;
|
||||||
|
unit < p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Local extension (combat-core):** integer-only probability check.
|
||||||
|
///
|
||||||
|
/// Returns `true` with probability `p_bp / 10_000` (basis points). This is
|
||||||
|
/// the integer-threshold `chance` variant the spec sanctions (§10): it
|
||||||
|
/// compares a fresh `u64` against `p_bp` scaled across the full `u64` span,
|
||||||
|
/// so no `f64` is involved at all. `p_bp <= 0` is always `false`,
|
||||||
|
/// `p_bp >= 10_000` is always `true`.
|
||||||
|
///
|
||||||
|
/// Scaling: a draw is a "hit" when `draw < p_bp * (2^64 / 10_000)`. We use
|
||||||
|
/// `u128` for the threshold product so it cannot overflow, then truncate the
|
||||||
|
/// comparison back into `u64` space. The mapping is monotonic and exact at
|
||||||
|
/// the endpoints, which is all a fair Bernoulli trial needs.
|
||||||
|
pub fn chance_bp(&mut self, p_bp: i64) -> bool {
|
||||||
|
if p_bp <= 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if p_bp >= 10_000 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// threshold = floor(p_bp / 10_000 * 2^64), computed without overflow.
|
||||||
|
let span = 1u128 << 64;
|
||||||
|
let threshold = (span * p_bp as u128) / 10_000u128; // in [0, 2^64)
|
||||||
|
(self.next_u64() as u128) < threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a reference to a uniformly chosen element of `items`, or `None`
|
||||||
|
/// if the slice is empty.
|
||||||
|
pub fn choose<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let idx = self.below(items.len() as u64) as usize;
|
||||||
|
items.get(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shuffle `items` in place into a uniformly random permutation
|
||||||
|
/// (in-place Fisher–Yates over our own [`range`]). Slices of length < 2 are
|
||||||
|
/// left unchanged.
|
||||||
|
pub fn shuffle<T>(&mut self, items: &mut [T]) {
|
||||||
|
let len = items.len();
|
||||||
|
if len < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for i in (1..len).rev() {
|
||||||
|
let j = self.below((i + 1) as u64) as usize;
|
||||||
|
items.swap(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mix a 64-bit basis seed with a label into a fresh 64-bit seed for [`Rng::fork`].
|
||||||
|
///
|
||||||
|
/// This is the load-bearing determinism primitive (spec §10), spelled out
|
||||||
|
/// explicitly with fixed integer arithmetic — no `DefaultHasher`.
|
||||||
|
///
|
||||||
|
/// Recipe: (1) splitmix64-scramble the basis; (2) fold each label byte with the
|
||||||
|
/// FNV-1a step (xor then multiply by the 64-bit FNV prime), making the result
|
||||||
|
/// order-sensitive so `"name#0"`/`"name#1"` diverge; (3) one final splitmix64
|
||||||
|
/// finalizer to avalanche.
|
||||||
|
fn mix_seed(basis: u64, label: &str) -> u64 {
|
||||||
|
const FNV_PRIME: u64 = 0x0000_0100_0000_01B3;
|
||||||
|
|
||||||
|
let mut state = splitmix64(basis);
|
||||||
|
for &byte in label.as_bytes() {
|
||||||
|
state ^= byte as u64;
|
||||||
|
state = state.wrapping_mul(FNV_PRIME);
|
||||||
|
}
|
||||||
|
splitmix64(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round of the splitmix64 finalizer: a fixed, portable integer avalanche.
|
||||||
|
fn splitmix64(seed: u64) -> u64 {
|
||||||
|
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_seed_reproduces_sequence() {
|
||||||
|
let mut a = Rng::from_seed(0xABCD_1234);
|
||||||
|
let mut b = Rng::from_seed(0xABCD_1234);
|
||||||
|
let seq_a: Vec<i32> = (0..64).map(|_| a.range(0, 1000)).collect();
|
||||||
|
let seq_b: Vec<i32> = (0..64).map(|_| b.range(0, 1000)).collect();
|
||||||
|
assert_eq!(seq_a, seq_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fork_is_order_independent() {
|
||||||
|
let parent = Rng::from_seed(99);
|
||||||
|
let mut early = parent.fork("a");
|
||||||
|
let mut drained = parent.clone();
|
||||||
|
for _ in 0..50 {
|
||||||
|
let _ = drained.range(0, 100);
|
||||||
|
}
|
||||||
|
let mut late = drained.fork("a");
|
||||||
|
let early_seq: Vec<i32> = (0..32).map(|_| early.range(0, 10_000)).collect();
|
||||||
|
let late_seq: Vec<i32> = (0..32).map(|_| late.range(0, 10_000)).collect();
|
||||||
|
assert_eq!(early_seq, late_seq, "fork must depend only on basis seed + label");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fork_does_not_mutate_parent() {
|
||||||
|
let mut parent = Rng::from_seed(7);
|
||||||
|
let before: Vec<i32> = {
|
||||||
|
let mut probe = parent.clone();
|
||||||
|
(0..16).map(|_| probe.range(0, 1000)).collect()
|
||||||
|
};
|
||||||
|
let _child = parent.fork("whatever");
|
||||||
|
let after: Vec<i32> = (0..16).map(|_| parent.range(0, 1000)).collect();
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_labels_diverge() {
|
||||||
|
let parent = Rng::from_seed(42);
|
||||||
|
let mut a = parent.fork("a");
|
||||||
|
let mut b = parent.fork("b");
|
||||||
|
let seq_a: Vec<i32> = (0..32).map(|_| a.range(0, 1_000_000)).collect();
|
||||||
|
let seq_b: Vec<i32> = (0..32).map(|_| b.range(0, 1_000_000)).collect();
|
||||||
|
assert_ne!(seq_a, seq_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The integer-threshold `chance_bp` saturates at the endpoints and lands
|
||||||
|
/// near its nominal rate over many draws.
|
||||||
|
#[test]
|
||||||
|
fn chance_bp_saturates_and_is_fair() {
|
||||||
|
let mut rng = Rng::from_seed(9);
|
||||||
|
assert!(!rng.chance_bp(0));
|
||||||
|
assert!(!rng.chance_bp(-100));
|
||||||
|
assert!(rng.chance_bp(10_000));
|
||||||
|
assert!(rng.chance_bp(20_000));
|
||||||
|
|
||||||
|
let mut a = Rng::from_seed(2);
|
||||||
|
let hits = (0..10_000).filter(|_| a.chance_bp(5_000)).count();
|
||||||
|
assert!((4_700..5_300).contains(&hits), "0.5 chance_bp produced {hits}/10000");
|
||||||
|
|
||||||
|
// A low rate should be rare but non-zero over enough draws.
|
||||||
|
let mut b = Rng::from_seed(3);
|
||||||
|
let rare = (0..100_000).filter(|_| b.chance_bp(100)).count(); // 1%
|
||||||
|
assert!((700..1_300).contains(&rare), "1% chance_bp produced {rare}/100000");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `chance_bp` is reproducible under a fixed seed.
|
||||||
|
#[test]
|
||||||
|
fn chance_bp_reproduces() {
|
||||||
|
let mut a = Rng::from_seed(123);
|
||||||
|
let mut b = Rng::from_seed(123);
|
||||||
|
let sa: Vec<bool> = (0..200).map(|_| a.chance_bp(3_333)).collect();
|
||||||
|
let sb: Vec<bool> = (0..200).map(|_| b.chance_bp(3_333)).collect();
|
||||||
|
assert_eq!(sa, sb);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
combat/combat-core/tests/determinism.rs
Normal file
115
combat/combat-core/tests/determinism.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
//! End-to-end determinism + public-API smoke tests (spec §10).
|
||||||
|
//!
|
||||||
|
//! The whole analysis story rests on one guarantee: a fight is a bit-reproducible
|
||||||
|
//! function of `(rules, seed, actors, controllers)`. These tests drive the crate
|
||||||
|
//! exactly as `combat-sim` does and assert that guarantee at the event-log level.
|
||||||
|
|
||||||
|
use combat_core::actor::{compile_actor, StatBlock};
|
||||||
|
use combat_core::config::CombatConfig;
|
||||||
|
use combat_core::controller::{Controller, HoldAndPunish, ScriptedPlayer};
|
||||||
|
use combat_core::engine::{Encounter, EncounterOptions};
|
||||||
|
use combat_core::{Actor, ActorId};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn duelist(name: &str, team: u8, level: i32) -> StatBlock {
|
||||||
|
let mut competency = BTreeMap::new();
|
||||||
|
competency.insert("martial".to_string(), 50);
|
||||||
|
competency.insert("restoration".to_string(), 30);
|
||||||
|
StatBlock {
|
||||||
|
name: name.into(),
|
||||||
|
team,
|
||||||
|
level,
|
||||||
|
max_vigor: 120,
|
||||||
|
regen_idle_per_sec: 12,
|
||||||
|
regen_combat_per_sec: 6,
|
||||||
|
armor_bp: 1500,
|
||||||
|
competency,
|
||||||
|
loadout: vec![
|
||||||
|
"auto_attack".into(),
|
||||||
|
"quick_jab".into(),
|
||||||
|
"heavy_strike".into(),
|
||||||
|
"second_wind".into(),
|
||||||
|
"guard".into(),
|
||||||
|
],
|
||||||
|
start_vigor_bp: 10_000,
|
||||||
|
start_fatigue_bp: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config() -> CombatConfig {
|
||||||
|
let json = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../configs/default.json"))
|
||||||
|
.expect("default config readable");
|
||||||
|
serde_json::from_str(&json).expect("default config parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> Vec<Actor> {
|
||||||
|
let rules = cfg.compile();
|
||||||
|
blocks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, sb)| {
|
||||||
|
let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
|
||||||
|
assert!(missing.is_empty(), "config loadout has unknown abilities: {missing:?}");
|
||||||
|
compile_actor(i as ActorId, sb, loadout, rules.tick_rate)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn controllers() -> Vec<Box<dyn Controller>> {
|
||||||
|
vec![Box::new(ScriptedPlayer::new(80)), Box::new(HoldAndPunish::default())]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_fight_event_log_is_bit_reproducible() {
|
||||||
|
let cfg = config();
|
||||||
|
let blocks = vec![duelist("P", 0, 10), duelist("E", 1, 12)];
|
||||||
|
let opts = EncounterOptions { max_ticks: 6_000, log_events: true, sample_every: 0 };
|
||||||
|
|
||||||
|
let run = || {
|
||||||
|
let actors = build(&cfg, &blocks);
|
||||||
|
Encounter::new(actors, controllers(), cfg.compile(), 12345, opts).run_with_events()
|
||||||
|
};
|
||||||
|
let (m1, e1) = run();
|
||||||
|
let (m2, e2) = run();
|
||||||
|
|
||||||
|
assert_eq!(m1.ticks, m2.ticks);
|
||||||
|
assert_eq!(format!("{:?}", m1.outcome), format!("{:?}", m2.outcome));
|
||||||
|
// Event logs must be identical event-for-event — the strongest determinism
|
||||||
|
// claim the crate makes.
|
||||||
|
let j1 = serde_json::to_string(&e1).unwrap();
|
||||||
|
let j2 = serde_json::to_string(&e2).unwrap();
|
||||||
|
assert_eq!(j1, j2, "event logs diverged under identical inputs");
|
||||||
|
assert!(!e1.is_empty(), "a real fight should emit events");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_seeds_can_diverge() {
|
||||||
|
let cfg = config();
|
||||||
|
let blocks = vec![duelist("P", 0, 10), duelist("E", 1, 10)];
|
||||||
|
let opts = EncounterOptions::default();
|
||||||
|
|
||||||
|
let outcome_for = |seed: u64| {
|
||||||
|
let actors = build(&cfg, &blocks);
|
||||||
|
let m = Encounter::new(actors, controllers(), cfg.compile(), seed, opts).run();
|
||||||
|
(m.ticks, format!("{:?}", m.outcome))
|
||||||
|
};
|
||||||
|
// Across many seeds an even-con fight produces a spread of durations (not a
|
||||||
|
// single fixed value), evidence the RNG actually drives variety.
|
||||||
|
let durations: std::collections::BTreeSet<u64> =
|
||||||
|
(0..40).map(|s| outcome_for(s).0).collect();
|
||||||
|
assert!(durations.len() > 3, "expected varied fight lengths across seeds");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_round_trips_and_compiles() {
|
||||||
|
let cfg = config();
|
||||||
|
let rules = cfg.compile();
|
||||||
|
// The shipped library resolves and the headline abilities exist.
|
||||||
|
for id in ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard", "fireball"] {
|
||||||
|
assert!(rules.abilities.contains_key(id), "missing ability {id}");
|
||||||
|
}
|
||||||
|
// Re-serialize and re-parse to confirm the config is stable through serde.
|
||||||
|
let j = serde_json::to_string(&cfg).unwrap();
|
||||||
|
let back: CombatConfig = serde_json::from_str(&j).unwrap();
|
||||||
|
assert_eq!(back.compile().abilities.len(), rules.abilities.len());
|
||||||
|
}
|
||||||
12
combat/combat-sim/Cargo.toml
Normal file
12
combat/combat-sim/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "combat-sim"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.96"
|
||||||
|
description = "Batch simulation harness over combat-core: sweeps, metrics, CSV/JSON export."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
combat-core = { path = "../combat-core" }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.150"
|
||||||
|
csv = "1.3"
|
||||||
476
combat/combat-sim/src/main.rs
Normal file
476
combat/combat-sim/src/main.rs
Normal file
|
|
@ -0,0 +1,476 @@
|
||||||
|
//! combat-sim: the batch harness over `combat-core` (spec §12).
|
||||||
|
//!
|
||||||
|
//! Loads the config "table", runs N fights across a matchup/parameter sweep, and
|
||||||
|
//! exports per-fight metrics so "feel" (fight duration, difficulty curve) is
|
||||||
|
//! *measured and tuned*, not eyeballed. The two headline sweeps:
|
||||||
|
//!
|
||||||
|
//! - **con** — player vs. an equal-kit enemy across level delta × player-skill,
|
||||||
|
//! with auto-attack-only included as one player policy so the §12 anti-turtle
|
||||||
|
//! guardrail (fraction of fights won by auto-only) falls out of the same table.
|
||||||
|
//! - **archetypes** — a skilled player against golem / rat / battlemage.
|
||||||
|
//!
|
||||||
|
//! Outputs (under `--out`, default `out/`): `fights.csv` (one row per fight),
|
||||||
|
//! `summary.csv` (aggregated per scenario cell), `sample_fight.json` (one fight's
|
||||||
|
//! full event log + metrics for plotting a single fight).
|
||||||
|
|
||||||
|
use combat_core::actor::{compile_actor, StatBlock};
|
||||||
|
use combat_core::config::Rules;
|
||||||
|
use combat_core::controller::{Controller, HoldAndPunish, ScriptedPlayer, SwingOnCooldown};
|
||||||
|
use combat_core::engine::{Encounter, EncounterOptions};
|
||||||
|
use combat_core::event::{FightMetrics, FinalState, Outcome};
|
||||||
|
use combat_core::{Actor, CombatConfig};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
/// `1000` milli-units == 1 displayed unit.
|
||||||
|
fn u(milli: i64) -> f64 {
|
||||||
|
milli as f64 / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which controller drives the player in a cell.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum PlayerCtrl {
|
||||||
|
/// The §12 guardrail policy: only ever auto-attacks.
|
||||||
|
AutoOnly,
|
||||||
|
/// Skill-tunable scripted "player".
|
||||||
|
Skill(i64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerCtrl {
|
||||||
|
fn label(&self) -> String {
|
||||||
|
match self {
|
||||||
|
PlayerCtrl::AutoOnly => "auto_only".to_string(),
|
||||||
|
PlayerCtrl::Skill(s) => format!("skill{s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn skill(&self) -> i64 {
|
||||||
|
match self {
|
||||||
|
PlayerCtrl::AutoOnly => 0,
|
||||||
|
PlayerCtrl::Skill(s) => *s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn make(&self) -> Box<dyn Controller> {
|
||||||
|
match self {
|
||||||
|
PlayerCtrl::AutoOnly => Box::new(SwingOnCooldown),
|
||||||
|
PlayerCtrl::Skill(s) => Box::new(ScriptedPlayer::new(*s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One flattened fight record (a `fights.csv` row).
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FightRow {
|
||||||
|
sweep: String,
|
||||||
|
matchup: String,
|
||||||
|
con_delta: i32,
|
||||||
|
player_ctrl: String,
|
||||||
|
player_skill: i64,
|
||||||
|
seed: u64,
|
||||||
|
|
||||||
|
player_won: u8,
|
||||||
|
outcome: String,
|
||||||
|
ticks: u64,
|
||||||
|
seconds: f64,
|
||||||
|
player_final: String,
|
||||||
|
enemy_final: String,
|
||||||
|
|
||||||
|
// Player-side throughput + flow accounting (§12).
|
||||||
|
p_fill_dmg_dealt: f64,
|
||||||
|
p_fill_dmg_taken: f64,
|
||||||
|
p_ceiling_dmg_dealt: f64,
|
||||||
|
p_ceiling_dmg_taken: f64,
|
||||||
|
p_regen_gained: f64,
|
||||||
|
p_spent_actions: f64,
|
||||||
|
p_lost_damage: f64,
|
||||||
|
p_restored: f64,
|
||||||
|
|
||||||
|
// Stagger ledger.
|
||||||
|
p_dazes_inflicted: u32,
|
||||||
|
p_unconscious_inflicted: u32,
|
||||||
|
p_dazes_taken: u32,
|
||||||
|
p_unconscious_taken: u32,
|
||||||
|
|
||||||
|
// Reliability.
|
||||||
|
p_fizzles: u32,
|
||||||
|
p_interruptions: u32,
|
||||||
|
|
||||||
|
// "Wanted to act but couldn't" (§12).
|
||||||
|
p_blocked_cant_afford: u32,
|
||||||
|
p_blocked_on_cooldown: u32,
|
||||||
|
p_blocked_dazed: u32,
|
||||||
|
|
||||||
|
// Action mix (auto-attack-only guardrail signal).
|
||||||
|
p_auto_attacks: u32,
|
||||||
|
p_abilities_used: u32,
|
||||||
|
p_recoveries_used: u32,
|
||||||
|
p_idle_ticks: u32,
|
||||||
|
|
||||||
|
p_near_death_recoveries: u32,
|
||||||
|
p_min_fill: f64,
|
||||||
|
p_min_cap: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated per-cell summary (a `summary.csv` row).
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryRow {
|
||||||
|
sweep: String,
|
||||||
|
matchup: String,
|
||||||
|
con_delta: i32,
|
||||||
|
player_ctrl: String,
|
||||||
|
fights: u32,
|
||||||
|
player_win_rate: f64,
|
||||||
|
draw_rate: f64,
|
||||||
|
timeout_rate: f64,
|
||||||
|
mean_seconds: f64,
|
||||||
|
median_seconds: f64,
|
||||||
|
mean_player_dazes_taken: f64,
|
||||||
|
mean_player_staggers_inflicted: f64,
|
||||||
|
mean_player_fizzles: f64,
|
||||||
|
mean_player_blocked_cant_afford: f64,
|
||||||
|
mean_player_min_cap: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
config: PathBuf,
|
||||||
|
out: PathBuf,
|
||||||
|
seeds: u64,
|
||||||
|
max_ticks: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Args {
|
||||||
|
let mut config = PathBuf::from("configs/default.json");
|
||||||
|
let mut out = PathBuf::from("out");
|
||||||
|
let mut seeds = 60u64;
|
||||||
|
let mut max_ticks = 6_000u64;
|
||||||
|
|
||||||
|
let mut it = std::env::args().skip(1);
|
||||||
|
while let Some(a) = it.next() {
|
||||||
|
match a.as_str() {
|
||||||
|
"--config" => config = PathBuf::from(it.next().unwrap_or_default()),
|
||||||
|
"--out" => out = PathBuf::from(it.next().unwrap_or_default()),
|
||||||
|
"--seeds" => seeds = it.next().and_then(|s| s.parse().ok()).unwrap_or(seeds),
|
||||||
|
"--max-ticks" => max_ticks = it.next().and_then(|s| s.parse().ok()).unwrap_or(max_ticks),
|
||||||
|
"--help" | "-h" => {
|
||||||
|
eprintln!(
|
||||||
|
"combat-sim [--config PATH] [--out DIR] [--seeds N] [--max-ticks N]\n\
|
||||||
|
Runs the con-tier and archetype sweeps and writes CSV + a sample fight."
|
||||||
|
);
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
other => eprintln!("warning: ignoring unknown arg {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Args { config, out, seeds, max_ticks }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args = parse_args();
|
||||||
|
|
||||||
|
let config = match std::fs::read_to_string(&args.config) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: cannot read config {:?}: {e}", args.config);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let config: CombatConfig = match serde_json::from_str(&config) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: invalid config JSON: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rules = config.compile();
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&args.out) {
|
||||||
|
eprintln!("error: cannot create out dir {:?}: {e}", args.out);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fights: Vec<FightRow> = Vec::new();
|
||||||
|
con_sweep(&config, &rules, args.seeds, args.max_ticks, &mut fights);
|
||||||
|
archetype_sweep(&config, &rules, args.seeds, args.max_ticks, &mut fights);
|
||||||
|
|
||||||
|
let summary = summarize(&fights);
|
||||||
|
|
||||||
|
if let Err(e) = write_csv(&args.out.join("fights.csv"), &fights) {
|
||||||
|
eprintln!("error writing fights.csv: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
if let Err(e) = write_csv(&args.out.join("summary.csv"), &summary) {
|
||||||
|
eprintln!("error writing summary.csv: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single inspectable fight (even-con, skilled player) with its full event log.
|
||||||
|
dump_sample_fight(&config, &rules, args.max_ticks, &args.out);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"combat-sim: {} fights across {} cells → {:?}",
|
||||||
|
fights.len(),
|
||||||
|
summary.len(),
|
||||||
|
args.out
|
||||||
|
);
|
||||||
|
println!(" fights.csv one row per fight");
|
||||||
|
println!(" summary.csv aggregated per scenario cell");
|
||||||
|
println!(" sample_fight.json one even-con fight, full event log");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a fighter from a named archetype, overriding team/level.
|
||||||
|
fn make_fighter(
|
||||||
|
config: &CombatConfig,
|
||||||
|
rules: &Rules,
|
||||||
|
archetype: &str,
|
||||||
|
id: u32,
|
||||||
|
team: u8,
|
||||||
|
level: i32,
|
||||||
|
) -> Actor {
|
||||||
|
let base = config
|
||||||
|
.stat_blocks
|
||||||
|
.get(archetype)
|
||||||
|
.unwrap_or_else(|| panic!("config missing stat_block {archetype:?}"));
|
||||||
|
let mut sb: StatBlock = base.clone();
|
||||||
|
sb.team = team;
|
||||||
|
sb.level = level;
|
||||||
|
let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
|
||||||
|
if !missing.is_empty() {
|
||||||
|
eprintln!("warning: {archetype} loadout missing abilities: {missing:?}");
|
||||||
|
}
|
||||||
|
compile_actor(id, &sb, loadout, rules.tick_rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one fight and flatten to a row.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn run_fight(
|
||||||
|
rules: &Rules,
|
||||||
|
sweep: &str,
|
||||||
|
matchup: &str,
|
||||||
|
con_delta: i32,
|
||||||
|
player_ctrl: PlayerCtrl,
|
||||||
|
player: Actor,
|
||||||
|
enemy: Actor,
|
||||||
|
enemy_ctrl: Box<dyn Controller>,
|
||||||
|
seed: u64,
|
||||||
|
max_ticks: u64,
|
||||||
|
) -> FightRow {
|
||||||
|
let controllers: Vec<Box<dyn Controller>> = vec![player_ctrl.make(), enemy_ctrl];
|
||||||
|
let opts = EncounterOptions { max_ticks, log_events: false, sample_every: 0 };
|
||||||
|
let m = Encounter::new(vec![player, enemy], controllers, rules.clone(), seed, opts).run();
|
||||||
|
flatten(&m, sweep, matchup, con_delta, player_ctrl, seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten(
|
||||||
|
m: &FightMetrics,
|
||||||
|
sweep: &str,
|
||||||
|
matchup: &str,
|
||||||
|
con_delta: i32,
|
||||||
|
player_ctrl: PlayerCtrl,
|
||||||
|
seed: u64,
|
||||||
|
) -> FightRow {
|
||||||
|
let p = m.actor(0).expect("player metrics");
|
||||||
|
let e = m.actor(1).expect("enemy metrics");
|
||||||
|
let (outcome, player_won) = match m.outcome {
|
||||||
|
Outcome::Win { team: 0 } => ("win_player".to_string(), 1),
|
||||||
|
Outcome::Win { team } => (format!("win_team{team}"), 0),
|
||||||
|
Outcome::Draw => ("draw".to_string(), 0),
|
||||||
|
Outcome::Timeout => ("timeout".to_string(), 0),
|
||||||
|
};
|
||||||
|
FightRow {
|
||||||
|
sweep: sweep.to_string(),
|
||||||
|
matchup: matchup.to_string(),
|
||||||
|
con_delta,
|
||||||
|
player_ctrl: player_ctrl.label(),
|
||||||
|
player_skill: player_ctrl.skill(),
|
||||||
|
seed,
|
||||||
|
player_won,
|
||||||
|
outcome,
|
||||||
|
ticks: m.ticks,
|
||||||
|
seconds: m.seconds(),
|
||||||
|
player_final: final_label(p.final_state),
|
||||||
|
enemy_final: final_label(e.final_state),
|
||||||
|
p_fill_dmg_dealt: u(p.fill_damage_dealt),
|
||||||
|
p_fill_dmg_taken: u(p.fill_damage_taken),
|
||||||
|
p_ceiling_dmg_dealt: u(p.ceiling_damage_dealt),
|
||||||
|
p_ceiling_dmg_taken: u(p.ceiling_damage_taken),
|
||||||
|
p_regen_gained: u(p.fill_gained_regen),
|
||||||
|
p_spent_actions: u(p.fill_spent_actions),
|
||||||
|
p_lost_damage: u(p.fill_lost_damage),
|
||||||
|
p_restored: u(p.fill_restored),
|
||||||
|
p_dazes_inflicted: p.dazes_inflicted,
|
||||||
|
p_unconscious_inflicted: p.unconscious_inflicted,
|
||||||
|
p_dazes_taken: p.dazes_taken,
|
||||||
|
p_unconscious_taken: p.unconscious_taken,
|
||||||
|
p_fizzles: p.fizzles,
|
||||||
|
p_interruptions: p.interruptions_suffered,
|
||||||
|
p_blocked_cant_afford: p.blocked_cant_afford,
|
||||||
|
p_blocked_on_cooldown: p.blocked_on_cooldown,
|
||||||
|
p_blocked_dazed: p.blocked_dazed,
|
||||||
|
p_auto_attacks: p.auto_attacks,
|
||||||
|
p_abilities_used: p.abilities_used,
|
||||||
|
p_recoveries_used: p.recoveries_used,
|
||||||
|
p_idle_ticks: p.idle_ticks,
|
||||||
|
p_near_death_recoveries: p.near_death_recoveries,
|
||||||
|
p_min_fill: u(p.min_fill),
|
||||||
|
p_min_cap: u(p.min_cap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_label(s: FinalState) -> String {
|
||||||
|
match s {
|
||||||
|
FinalState::Alive => "alive",
|
||||||
|
FinalState::Dazed => "dazed",
|
||||||
|
FinalState::Unconscious => "unconscious",
|
||||||
|
FinalState::Dead => "dead",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The headline sweep: player (team 0) vs. an equal-kit duelist enemy (team 1),
|
||||||
|
/// across level delta × player policy. Enemy plays hold-and-punish.
|
||||||
|
fn con_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec<FightRow>) {
|
||||||
|
let player_level = 10;
|
||||||
|
let player_ctrls = [
|
||||||
|
PlayerCtrl::AutoOnly,
|
||||||
|
PlayerCtrl::Skill(20),
|
||||||
|
PlayerCtrl::Skill(50),
|
||||||
|
PlayerCtrl::Skill(80),
|
||||||
|
PlayerCtrl::Skill(100),
|
||||||
|
];
|
||||||
|
// A fixed, competent benchmark opponent isolates the con + player-skill
|
||||||
|
// signal (vs. a different enemy AI, which would inject matchup asymmetry).
|
||||||
|
for delta in -8..=8 {
|
||||||
|
let enemy_level = player_level + delta;
|
||||||
|
for ctrl in player_ctrls {
|
||||||
|
for seed in 0..seeds {
|
||||||
|
let player = make_fighter(config, rules, "duelist", 0, 0, player_level);
|
||||||
|
let enemy = make_fighter(config, rules, "duelist", 1, 1, enemy_level);
|
||||||
|
out.push(run_fight(
|
||||||
|
rules,
|
||||||
|
"con",
|
||||||
|
"duelist_vs_duelist",
|
||||||
|
delta,
|
||||||
|
ctrl,
|
||||||
|
player,
|
||||||
|
enemy,
|
||||||
|
Box::new(ScriptedPlayer::new(80)),
|
||||||
|
seed,
|
||||||
|
max_ticks,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A skilled player against the three archetypes, to feel out asymmetric matchups.
|
||||||
|
fn archetype_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec<FightRow>) {
|
||||||
|
let player_level = 10;
|
||||||
|
let enemies = ["golem", "rat", "battlemage"];
|
||||||
|
let ctrl = PlayerCtrl::Skill(80);
|
||||||
|
for enemy_name in enemies {
|
||||||
|
let enemy_level = config.stat_blocks.get(enemy_name).map(|b| b.level).unwrap_or(player_level);
|
||||||
|
let delta = enemy_level - player_level;
|
||||||
|
for seed in 0..seeds {
|
||||||
|
let player = make_fighter(config, rules, "duelist", 0, 0, player_level);
|
||||||
|
let enemy = make_fighter(config, rules, enemy_name, 1, 1, enemy_level);
|
||||||
|
out.push(run_fight(
|
||||||
|
rules,
|
||||||
|
"archetypes",
|
||||||
|
enemy_name,
|
||||||
|
delta,
|
||||||
|
ctrl,
|
||||||
|
player,
|
||||||
|
enemy,
|
||||||
|
Box::new(HoldAndPunish::default()),
|
||||||
|
seed,
|
||||||
|
max_ticks,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll fights up into per-cell summary rows.
|
||||||
|
fn summarize(fights: &[FightRow]) -> Vec<SummaryRow> {
|
||||||
|
// Group by (sweep, matchup, con_delta, player_ctrl).
|
||||||
|
let mut groups: BTreeMap<(String, String, i32, String), Vec<&FightRow>> = BTreeMap::new();
|
||||||
|
for f in fights {
|
||||||
|
groups
|
||||||
|
.entry((f.sweep.clone(), f.matchup.clone(), f.con_delta, f.player_ctrl.clone()))
|
||||||
|
.or_default()
|
||||||
|
.push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for ((sweep, matchup, con_delta, player_ctrl), fs) in groups {
|
||||||
|
let n = fs.len() as f64;
|
||||||
|
let wins = fs.iter().filter(|f| f.player_won == 1).count() as f64;
|
||||||
|
let draws = fs.iter().filter(|f| f.outcome == "draw").count() as f64;
|
||||||
|
let timeouts = fs.iter().filter(|f| f.outcome == "timeout").count() as f64;
|
||||||
|
let mean = |xs: &[f64]| xs.iter().sum::<f64>() / xs.len().max(1) as f64;
|
||||||
|
let mut secs: Vec<f64> = fs.iter().map(|f| f.seconds).collect();
|
||||||
|
secs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let median = if secs.is_empty() { 0.0 } else { secs[secs.len() / 2] };
|
||||||
|
|
||||||
|
rows.push(SummaryRow {
|
||||||
|
sweep,
|
||||||
|
matchup,
|
||||||
|
con_delta,
|
||||||
|
player_ctrl,
|
||||||
|
fights: fs.len() as u32,
|
||||||
|
player_win_rate: wins / n,
|
||||||
|
draw_rate: draws / n,
|
||||||
|
timeout_rate: timeouts / n,
|
||||||
|
mean_seconds: mean(&secs),
|
||||||
|
median_seconds: median,
|
||||||
|
mean_player_dazes_taken: mean(&fs.iter().map(|f| f.p_dazes_taken as f64).collect::<Vec<_>>()),
|
||||||
|
mean_player_staggers_inflicted: mean(
|
||||||
|
&fs.iter()
|
||||||
|
.map(|f| (f.p_dazes_inflicted + f.p_unconscious_inflicted) as f64)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
mean_player_fizzles: mean(&fs.iter().map(|f| f.p_fizzles as f64).collect::<Vec<_>>()),
|
||||||
|
mean_player_blocked_cant_afford: mean(
|
||||||
|
&fs.iter().map(|f| f.p_blocked_cant_afford as f64).collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
mean_player_min_cap: mean(&fs.iter().map(|f| f.p_min_cap).collect::<Vec<_>>()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dump one even-con, skilled fight with its full event log for the notebook.
|
||||||
|
fn dump_sample_fight(config: &CombatConfig, rules: &Rules, max_ticks: u64, out: &std::path::Path) {
|
||||||
|
let player = make_fighter(config, rules, "duelist", 0, 0, 10);
|
||||||
|
let enemy = make_fighter(config, rules, "duelist", 1, 1, 10);
|
||||||
|
let controllers: Vec<Box<dyn Controller>> =
|
||||||
|
vec![Box::new(ScriptedPlayer::new(80)), Box::new(HoldAndPunish::default())];
|
||||||
|
let opts = EncounterOptions { max_ticks, log_events: true, sample_every: 1 };
|
||||||
|
let (metrics, events) = Encounter::new(vec![player, enemy], controllers, rules.clone(), 7, opts)
|
||||||
|
.run_with_events();
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Sample<'a> {
|
||||||
|
metrics: &'a FightMetrics,
|
||||||
|
events: &'a [combat_core::event::Event],
|
||||||
|
}
|
||||||
|
let sample = Sample { metrics: &metrics, events: &events };
|
||||||
|
match serde_json::to_string_pretty(&sample) {
|
||||||
|
Ok(j) => {
|
||||||
|
if let Err(e) = std::fs::write(out.join("sample_fight.json"), j) {
|
||||||
|
eprintln!("warning: cannot write sample_fight.json: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("warning: cannot serialize sample fight: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_csv<T: Serialize>(path: &std::path::Path, rows: &[T]) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut w = csv::Writer::from_path(path)?;
|
||||||
|
for r in rows {
|
||||||
|
w.serialize(r)?;
|
||||||
|
}
|
||||||
|
w.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
187
combat/configs/default.json
Normal file
187
combat/configs/default.json
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
{
|
||||||
|
"_comment": "The combat 'table' (spec §11). Magnitudes are displayed units; ratios/percentages are basis points (10000 = 1.0 = 100%); durations are ticks. Editing this file is the primary balancing act. tick_rate ticks = 1 second.",
|
||||||
|
|
||||||
|
"tick_rate": 10,
|
||||||
|
"damage_variance_bp": 1500,
|
||||||
|
|
||||||
|
"regen": {
|
||||||
|
"dazed_regen_bp": 5000,
|
||||||
|
"floor_per_sec": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"stagger": {
|
||||||
|
"daze_threshold_bp": 10000,
|
||||||
|
"unconscious_threshold_bp": 15000,
|
||||||
|
"daze_duration": 8,
|
||||||
|
"revive_daze": 12,
|
||||||
|
"fatigue_ratio_bp": 2000
|
||||||
|
},
|
||||||
|
|
||||||
|
"competency": {
|
||||||
|
"level_per_point_bp": 200
|
||||||
|
},
|
||||||
|
|
||||||
|
"fizzle": {
|
||||||
|
"base_bp": 200,
|
||||||
|
"per_gap_bp": 150,
|
||||||
|
"max_bp": 9000
|
||||||
|
},
|
||||||
|
|
||||||
|
"cost_model": {
|
||||||
|
"base_fill_self": 0,
|
||||||
|
"base_fill_touch": 1,
|
||||||
|
"base_fill_melee": 1,
|
||||||
|
"base_fill_projectile": 2,
|
||||||
|
"base_fill_beam": 3,
|
||||||
|
"base_fill_aoe": 4,
|
||||||
|
"fatigue_ratio_bp": 2000,
|
||||||
|
"difficulty_per_fill_bp": 10000
|
||||||
|
},
|
||||||
|
|
||||||
|
"con_curve": [
|
||||||
|
{ "delta": -8, "mult_bp": 4000 },
|
||||||
|
{ "delta": -5, "mult_bp": 6000 },
|
||||||
|
{ "delta": -2, "mult_bp": 8500 },
|
||||||
|
{ "delta": 0, "mult_bp": 10000 },
|
||||||
|
{ "delta": 2, "mult_bp": 11500 },
|
||||||
|
{ "delta": 5, "mult_bp": 15000 },
|
||||||
|
{ "delta": 8, "mult_bp": 19000 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"id": "auto_attack",
|
||||||
|
"name": "Auto-Attack",
|
||||||
|
"school": "martial",
|
||||||
|
"delivery": "melee",
|
||||||
|
"effects": [ { "kind": "fill_damage", "magnitude_bp": 800, "cost_fill": 0 } ],
|
||||||
|
"cooldown": 10,
|
||||||
|
"cost_override": { "fill": 0, "fatigue": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quick_jab",
|
||||||
|
"name": "Quick Jab",
|
||||||
|
"school": "martial",
|
||||||
|
"delivery": "melee",
|
||||||
|
"effects": [ { "kind": "fill_damage", "magnitude_bp": 1700, "cost_fill": 6 } ],
|
||||||
|
"cooldown": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "heavy_strike",
|
||||||
|
"name": "Heavy Strike",
|
||||||
|
"school": "martial",
|
||||||
|
"delivery": "melee",
|
||||||
|
"effects": [ { "kind": "fill_damage", "magnitude_bp": 4500, "cost_fill": 26 } ],
|
||||||
|
"cooldown": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fireball",
|
||||||
|
"name": "Fireball",
|
||||||
|
"school": "invocation",
|
||||||
|
"delivery": "projectile",
|
||||||
|
"effects": [ { "kind": "fill_damage", "magnitude_bp": 3500, "cost_fill": 24 } ],
|
||||||
|
"potency_bp": 10000,
|
||||||
|
"cast_time": 12,
|
||||||
|
"cooldown": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "curse_of_weariness",
|
||||||
|
"name": "Curse of Weariness",
|
||||||
|
"school": "affliction",
|
||||||
|
"delivery": "projectile",
|
||||||
|
"effects": [ { "kind": "ceiling_damage", "magnitude_bp": 1400, "cost_fill": 16 } ],
|
||||||
|
"cast_time": 8,
|
||||||
|
"cooldown": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "venom",
|
||||||
|
"name": "Venom",
|
||||||
|
"school": "affliction",
|
||||||
|
"delivery": "touch",
|
||||||
|
"effects": [ { "kind": "dot", "per_tick_bp": 250, "duration": 20, "cost_fill": 12 } ],
|
||||||
|
"cooldown": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "enervate",
|
||||||
|
"name": "Enervate",
|
||||||
|
"school": "affliction",
|
||||||
|
"delivery": "projectile",
|
||||||
|
"effects": [ { "kind": "regen_sabotage", "reduce_bp": 6000, "duration": 30, "cost_fill": 10 } ],
|
||||||
|
"cast_time": 6,
|
||||||
|
"cooldown": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "second_wind",
|
||||||
|
"name": "Second Wind",
|
||||||
|
"school": "restoration",
|
||||||
|
"delivery": "self_cast",
|
||||||
|
"effects": [ { "kind": "recovery", "magnitude_bp": 3000, "cost_fill": 3 } ],
|
||||||
|
"cooldown": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mend",
|
||||||
|
"name": "Mend",
|
||||||
|
"school": "restoration",
|
||||||
|
"delivery": "self_cast",
|
||||||
|
"effects": [ { "kind": "hot", "per_tick_bp": 200, "duration": 15, "cost_fill": 6 } ],
|
||||||
|
"cast_time": 10,
|
||||||
|
"cooldown": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "guard",
|
||||||
|
"name": "Guard",
|
||||||
|
"school": "martial",
|
||||||
|
"delivery": "self_cast",
|
||||||
|
"effects": [ { "kind": "mitigation_buff", "stage": "active_defense", "amount_bp": 8000, "duration": 3, "cost_fill": 0 } ],
|
||||||
|
"cooldown": 15,
|
||||||
|
"cost_override": { "fill": 0, "fatigue": 2 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"stat_blocks": {
|
||||||
|
"duelist": {
|
||||||
|
"name": "Duelist",
|
||||||
|
"team": 0,
|
||||||
|
"level": 10,
|
||||||
|
"max_vigor": 120,
|
||||||
|
"regen_idle_per_sec": 12,
|
||||||
|
"regen_combat_per_sec": 6,
|
||||||
|
"armor_bp": 1500,
|
||||||
|
"competency": { "martial": 50, "restoration": 30 },
|
||||||
|
"loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"]
|
||||||
|
},
|
||||||
|
"battlemage": {
|
||||||
|
"name": "Battlemage",
|
||||||
|
"team": 0,
|
||||||
|
"level": 10,
|
||||||
|
"max_vigor": 100,
|
||||||
|
"regen_idle_per_sec": 12,
|
||||||
|
"regen_combat_per_sec": 7,
|
||||||
|
"armor_bp": 500,
|
||||||
|
"competency": { "invocation": 50, "affliction": 40, "restoration": 25, "martial": 15 },
|
||||||
|
"loadout": ["auto_attack", "fireball", "curse_of_weariness", "second_wind", "guard"]
|
||||||
|
},
|
||||||
|
"golem": {
|
||||||
|
"name": "Golem",
|
||||||
|
"team": 1,
|
||||||
|
"level": 10,
|
||||||
|
"max_vigor": 260,
|
||||||
|
"regen_idle_per_sec": 6,
|
||||||
|
"regen_combat_per_sec": 3,
|
||||||
|
"armor_bp": 3500,
|
||||||
|
"competency": { "martial": 40 },
|
||||||
|
"loadout": ["auto_attack", "heavy_strike"]
|
||||||
|
},
|
||||||
|
"rat": {
|
||||||
|
"name": "Rat",
|
||||||
|
"team": 1,
|
||||||
|
"level": 8,
|
||||||
|
"max_vigor": 45,
|
||||||
|
"regen_idle_per_sec": 16,
|
||||||
|
"regen_combat_per_sec": 9,
|
||||||
|
"armor_bp": 0,
|
||||||
|
"competency": { "martial": 20 },
|
||||||
|
"loadout": ["auto_attack", "quick_jab"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue