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