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:
parent
35c544e931
commit
e112ef5ee9
1 changed files with 117 additions and 17 deletions
|
|
@ -69,15 +69,22 @@ a table.** This is both the design elegance and the core requirement (§10–12)
|
||||||
## 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 3–4.
|
||||||
|
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.
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue