reikhelm/combat/combat-core/src/mitigation.rs
Parley Hatch 5055feb4c3 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>
2026-06-03 12:13:04 -06:00

184 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}
}