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>
184 lines
6.3 KiB
Rust
184 lines
6.3 KiB
Rust
//! 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);
|
||
}
|
||
}
|