docs(combat): harden spec after two critic passes

Adversarial review found no blockers but several serious issues; fixed:
- determinism: require vendoring reikhelm-core's order-independent, version-
  stable Rng::fork (not DefaultHasher) + integer fixed-point internal state
  with documented per-stage rounding; carve out chance()'s IEEE-deterministic
  f64 compare as the sole hot-path exception
- pin a canonical within-tick resolution order (incl. step-0 controller
  decisions in actor-id order) as a determinism invariant
- close internal-consistency dead-ends: fatigue clamp [0,max_vigor], cap=0 as
  the explicit attrition-collapse trigger, regen floor + no-soft-lock rule,
  fix inverted ceiling-damage verb
- enrich instrumentation (pre-mitigation damage, per-stage mitigation, blocked-
  action + fail-reason, fill-flow accounting) + anti-turtle guardrail metric

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-06-02 23:51:12 -06:00
parent 35c544e931
commit e112ef5ee9

View file

@ -69,15 +69,22 @@ a table.** This is both the design elegance and the core requirement (§1012)
## 3. The Resource Model ## 3. The Resource Model
### Vigor, Cap, Fatigue ### Vigor, Cap, Fatigue
- `vigor` (current fill), `max_vigor` (level/stat-derived constant), `fatigue` (≥0, starts 0). - `vigor` (current fill), `max_vigor` (level/stat-derived constant), `fatigue` (starts 0).
- `cap = max_vigor fatigue`. `vigor` is clamped to `[0, cap]`. - `fatigue` is clamped to `[0, max_vigor]`; `cap = max_vigor fatigue` (so `cap ∈ [0,
max_vigor]`). `vigor` is clamped to `[0, cap]`.
- **Regen**: each tick, `vigor += regen_per_tick`, clamped to `cap`. Regen restores *fill toward - **Regen**: each tick, `vigor += regen_per_tick`, clamped to `cap`. Regen restores *fill toward
the ceiling only* — it never touches `fatigue`. (Optional config: reduced regen rate while in the ceiling only* — it never touches `fatigue`. Config carries an idle and a (lower) in-combat
combat vs. idle.) rate, and a **non-zero floor**: regen is reduced when dazed and by regen-sabotage effects but
never reaches 0 *except when unconscious* — so an actor can always claw back toward affording an
action (no soft-lock; see §6).
- **Fatigue ("spend leaves a mark")**: any point that *leaves* the pool generates fatigue. - **Fatigue ("spend leaves a mark")**: any point that *leaves* the pool generates fatigue.
Default `fatigue += fatigue_ratio × amount_spent` for damage taken and unlabeled spend; Default `fatigue += fatigue_ratio × amount_spent` for damage taken and unlabeled spend;
abilities may override with an explicit authored `fatigue_cost`. Fatigue only decreases via abilities may override with an explicit authored `fatigue_cost`. Fatigue only decreases via
**recovery abilities** (in combat) or **rest** (out of combat). It does *not* regen. **recovery abilities** (in combat) or **rest** (out of combat). It does *not* regen.
- **Ceiling collapse is the real loss condition.** Because every incoming hit adds fatigue,
even pure defense erodes your ceiling over time — *turtling is not viable*. When `fatigue`
reaches `max_vigor`, `cap = 0`, capacity is 0, and the actor collapses (→ unconscious → dead per
§4). Attrition kills by collapsing the ceiling, not by a single drain.
### Damage in ### Damage in
- An attack's landed damage is **a percentage of the target's `max_vigor`**, set by the **con - An attack's landed damage is **a percentage of the target's `max_vigor`**, set by the **con
@ -107,6 +114,15 @@ to capacity *at the moment it lands*:
Once unconscious, regen can't save you and follow-up hits grind you to 0 → **dead**. The Once unconscious, regen can't save you and follow-up hits grind you to 0 → **dead**. The
thresholds (`1.0×`, `1.5×`) and the dazed regen penalty are config. thresholds (`1.0×`, `1.5×`) and the dazed regen penalty are config.
**Reconciling with "no instant death":** "no instant `vigor = 0` → dead" means a single *fill*
subtraction never kills you outright. You are lost only when (a) a burst **overruns capacity**
(the cascade above) or (b) **attrition collapses your ceiling** (§3: `fatigue → max_vigor`, so
`cap → 0`). When `cap = 0`, capacity is 0 and the next hit trivially clears `1.5 × 0`
unconscious — that *is* the intended attrition-death path, fully consistent with the rule.
**Reaching `cap = 0` is itself the collapse trigger** (treated as an instantaneous self-overrun →
unconscious), so an actor who self-fatigues to zero in a lull collapses immediately without waiting
for an incoming hit — the §3 state-threshold and this §4 mechanism describe the same moment.
**Consequence that makes it sing:** spending offense lowers your fill, which lowers your **Consequence that makes it sing:** spending offense lowers your fill, which lowers your
capacity, which raises your stagger risk *on the very next hit*. "Fire the kick at 60%, eat an capacity, which raises your stagger risk *on the very next hit*. "Fire the kick at 60%, eat an
add's swing, get staggered" is a real mechanical cascade, not flavor. add's swing, get staggered" is a real mechanical cascade, not flavor.
@ -157,6 +173,11 @@ cascade. **Power is paid in vulnerability.**
progressively **lose access** to your big hitters. progressively **lose access** to your big hitters.
- **Pay at cast-start; fizzle or interruption = paid for nothing.** One rule covers both fizzle - **Pay at cast-start; fizzle or interruption = paid for nothing.** One rule covers both fizzle
risk (§9) and getting staggered mid-cast: you spent the cost and the ability never fired. risk (§9) and getting staggered mid-cast: you spent the cost and the ability never fired.
- **No soft-lock.** Auto-attack is always affordable (`0` fill), so an actor is never fully
action-locked — at worst reduced to auto-attack-and-regen (a *losing* position, not a frozen
one). Recovery abilities are authored **cheap (low or zero `fill_cost`)** so they stay reachable
when you most need them; together with the regen floor (§3), pay-at-start/fizzle can put you in
danger but cannot strand you in a permanently unrecoverable state.
### Cast time ### Cast time
- The only structural axis separating "skill" from "spell": skills are mostly instant; spells - The only structural axis separating "skill" from "spell": skills are mostly instant; spells
@ -168,7 +189,8 @@ Effects target *either* fill *or* ceiling — and the ceiling-attacking ones are
content the system can have: content the system can have:
- **Fill damage** — % of target `max_vigor` (con + mitigation applied). - **Fill damage** — % of target `max_vigor` (con + mitigation applied).
- **Ceiling damage / curse** — drains target `fatigue` directly (attacks the real life total). - **Ceiling damage / curse***raises* target `fatigue` directly, lowering their cap (attacks the
real life total). The inverse of Recovery, below.
- **Regen sabotage** — reduces target regen for a duration. - **Regen sabotage** — reduces target regen for a duration.
- **Fatigue amplification** — raises target's fatigue-per-spend for a duration. - **Fatigue amplification** — raises target's fatigue-per-spend for a duration.
- **DoT / HoT** — fill drain / restore over ticks. - **DoT / HoT** — fill drain / restore over ticks.
@ -194,8 +216,16 @@ directions:
(caster_effective_level folds in competency, §9) → fraction of the effect that lands. (caster_effective_level folds in competency, §9) → fraction of the effect that lands.
Anchors (config, illustrative): even-con baseline; ≈3× at +5; ≈0.2× at 5; roughly monotonic. Anchors (config, illustrative): even-con baseline; ≈3× at +5; ≈0.2× at 5; roughly monotonic.
Represented as a parametric function or a small lookup table — TBD in tuning, but it is *one* Represented as a parametric function or a small lookup table — TBD in tuning.
curve doing double duty.
**Caveat on the "double duty":** the curve takes a **delta**, but its two callers feed
differently-shaped domains — incoming damage uses raw `attacker_level defender_level` (bounded
by the level range), while outgoing effectiveness uses a *competency-inflated* effective-level
delta (potentially wider). Keep the curve's input domain explicit and range-check the
competency-derived delta against it; if competency inflation pushes the effective range past where
the damage-tuned curve behaves, the two callers may need to **split into separate curves**. v1 has
no competency (§14), so this won't surface until the progression layer lands — write the curve call
so a later split is cheap, not a rewrite.
--- ---
@ -265,10 +295,54 @@ hook, not the leveling curve.
- **Fixed tick** (default 10 ticks/s, config). *Every* duration — attack delay, cast time, regen, - **Fixed tick** (default 10 ticks/s, config). *Every* duration — attack delay, cast time, regen,
status durations, active windows — is expressed in **ticks**. Headless: ticks advance as fast as status durations, active windows — is expressed in **ticks**. Headless: ticks advance as fast as
the CPU allows. Real-time front-end: ticks advance on a wall clock. the CPU allows. Real-time front-end: ticks advance on a wall clock.
- **Determinism:** seeded **ChaCha8** RNG with per-roll sub-streams (mirrors `reikhelm-core`), so
tweaking one parameter doesn't reshuffle unrelated rolls. A fight = ### Reproducibility (load-bearing for all analysis)
`(config, seed, loadout_A, loadout_B)` → a *reproducible* outcome + full event log. This A fight = `(config, seed, loadout_A, loadout_B)` → a *bit-reproducible* outcome + event log.
reproducibility is what makes the analysis valid: change one knob, observe the isolated delta. "Change one knob, observe the isolated delta" only holds if unrelated rolls don't reshuffle and
arithmetic doesn't drift. Two hard requirements — neither is satisfied by "ChaCha8" alone:
- **Order-independent, version-stable RNG forking.** Reuse `reikhelm-core`'s `Rng::fork` contract
*verbatim* (vendor `reikhelm-core/src/rng.rs` as a module): a hand-rolled integer mix (splitmix64
+ FNV-1a), **`&self` (non-mutating) and order-independent** — explicitly **not**
`std::collections::hash_map::DefaultHasher`, whose output is version-unstable. Sub-streams are
keyed by a stable string, e.g. `fork("actor{id}:{roll_kind}:tick{t}")`, so adding an effect to
one ability never shifts another actor's or another tick's stream. *This property* — not
ChaCha8 itself — is what makes the isolated-delta guarantee true.
- **Integer fixed-point internal state.** `vigor`, `cap`, `fatigue`, and all magnitudes are stored
as scaled integers (e.g. milli-units); every formula (con %, potency multiply, `fatigue_ratio ×
amount`, each mitigation stage) is evaluated in integer arithmetic with **documented rounding at
each pipeline stage**. This mirrors reikhelm-core's integer-space discipline and gives bit-exact
results across compilers / opt-levels / machines — IEEE-754 `f64` *multiply chains* do **not**
(ChaCha8 gives a portable random *stream*, not portable *arithmetic*). All **magnitude**
arithmetic stays integer; `f64` is otherwise confined to config parsing. **One deliberate
exception:** the vendored `Rng::chance(p: f64)` does its roll as a `u64 → f64` unit-interval
divide-and-compare (`rng.rs:106-117`) — a single division + compare, which *is* IEEE-deterministic
across platforms — so probabilistic checks (fizzle, §9) may use it. That single compare is the
only f64 allowed in the hot path; if even that's unwanted, add an integer-threshold `chance`
variant (compare the raw `u64` bits against `p` scaled to `u64`). Either way, no f64 ever touches
a magnitude. Avoid iteration-order-dependent containers (`HashMap`) in the hot path; use
ordered/`BTreeMap` or `Vec` keyed by actor-id.
### Canonical within-tick resolution order (a determinism invariant)
Several things resolve on one tick and the order changes outcomes (regen-before-damage yields
fewer staggers than regen-after; sequential hits differ from batched), so it is **fixed**, not an
implementation detail:
0. **Controllers decide**, in ascending actor-id order, *before* anything else resolves. Starting a
cast or auto-attack pays its cost **here** (cast-start payment, §6) and opens cooldowns/windows;
instant abilities enqueue their effects for step 2. Pinning this first, by actor-id, is what
makes the affordability gate and stagger checks see a deterministic state.
1. **Expire** finishing statuses and active-defense windows (a window covering tick *t* is
*inclusive* of hits landing on tick *t*).
2. **Resolve completing casts** → enqueue their hits/effects for steps 34.
3. **Apply incoming hits sequentially in ascending actor-id order** (sequential, not batched: hit 1
lowers `vigor`, raising hit 2's stagger odds), each followed immediately by its **stagger check**
(§4) and its **fatigue add** (§3).
4. **Tick** DoT/HoT and other per-tick effects.
5. **Apply regen** (clamped to `cap`).
Determinism is part of the contract — any reordering (including controller-decision timing, step 0)
is a breaking change to recorded data.
--- ---
@ -293,15 +367,35 @@ rule changes are the only thing requiring a recompile.
## 12. Instrumentation, Metrics & Balancing ## 12. Instrumentation, Metrics & Balancing
### Event stream ### Event stream
The engine emits a structured event per meaningful tick: `{tick, actor, action, fill_before, The engine emits a structured event per meaningful tick. Outcomes alone can't tell you *why* a
fill_after, ceiling, fatigue, stagger_state, fizzle?, target, landed, outcome?}`. A **recorder** config feels bad, so the schema records **causes**, not just results:
rolls events into per-fight summary metrics. `{tick, actor, action, fill_before, fill_after, ceiling, fatigue, stagger_state, target,
damage_pre_mitigation, mitigation_by_stage[armor, con, defense, buff], landed,
fail_reason ∈ {none, competency_fizzle, interrupted}, action_blocked_reason?, outcome?}`.
A **recorder** rolls events into per-fight summary metrics. Each added field is load-bearing for
tuning:
- **Pre-mitigation damage + per-stage mitigation deltas** — you cannot tune the con curve (§7, the
balance lever) from the final `landed` number alone; you need each stage's contribution.
- **`action_blocked_reason`** (`cant_afford` / `on_cooldown` / `dazed` / …) — a "wanted to act but
couldn't" signal. Without it, a fight where an actor spent 40% of ticks unable to afford anything
is indistinguishable from one where it chose to wait, and the soft-lock/turtle modes are invisible.
- **`fail_reason` splits competency-fizzle from interruption** — otherwise "competency too low" and
"wind-ups too long" collapse into one signal you can't separate.
### Per-fight metrics (export to CSV/JSON) ### Per-fight metrics (export to CSV/JSON)
Time-to-resolution (ticks & seconds); outcome (win/loss/draw/timeout); #staggers, #dazes, Time-to-resolution (ticks & seconds); outcome (win/loss/draw/timeout); #staggers, #dazes,
#unconscious; #fizzles; total fill damage dealt/taken; total ceiling damage; min fill reached; #unconscious; #competency_fizzles, #interruptions; blocked-actions by reason; total fill damage
ceiling-decay curve; near-death recoveries; effective DPS; vigor/ceiling time series (optional, dealt/taken; total ceiling damage; min fill reached; ceiling-decay curve; near-death recoveries;
sampled). effective DPS; **fill-flow accounting (gained-by-regen vs. spent-on-actions vs. lost-to-damage)**
§15 names regen-vs-drain-vs-spend as the make-or-break co-tuning, so the sums must be visible;
vigor/ceiling time series (optional, sampled).
### Anti-turtle guardrail
Absorbed hits still erode the ceiling (§3), so passive-forever loses — but **auto-attack-only** can
still dominate if abilities aren't *efficient enough* (favorable damage-per-resource, or utility
auto-attack lacks). Track it explicitly: **fraction of fights won by an auto-attack-only policy.**
If that's high, the interesting verbs are a tax players opt out of — contradicting the thesis — and
the fix is a damage-per-resource/utility edge, not a tuning nudge.
### Batch harness & "feel" as acceptance criteria ### Batch harness & "feel" as acceptance criteria
A harness runs **N fights across a matchup/parameter sweep** (level-delta × armor tier × loadout × A harness runs **N fights across a matchup/parameter sweep** (level-delta × armor tier × loadout ×
@ -389,4 +483,10 @@ matters more than visuals.
- **Scripted "player skill" policies** for the sim are themselves a modeling choice — the - **Scripted "player skill" policies** for the sim are themselves a modeling choice — the
difficulty curve is only as meaningful as the policies are representative. Document the policies difficulty curve is only as meaningful as the policies are representative. Document the policies
used for any published balance numbers. used for any published balance numbers.
- **Auto-attack-only dominance** (see §12 guardrail) — the deepest balance risk: if abilities don't
beat auto-attack on damage-per-resource or bring utility it lacks, the optimal policy is to never
use the interesting verbs. Structural, not a number; watch the guardrail metric from day one.
- **Fixed-point rounding bias** (§10) — integer rounding at each pipeline stage can accumulate a
systematic skew (e.g. always-floor quietly favors the defender). Pick a rounding rule
deliberately and confirm it isn't biasing outcomes over long fights.
``` ```