Skip to main content

wowlab_engine_domain/
damage.rs

1//! Damage calculation pipeline: base + coef * power, then crit, vers, armor, and multipliers.
2
3use wowlab_engine_rng::proc_chance;
4use wowlab_types::{
5    combat::{DamageSchool, HitResult},
6    constants::{HUNDRED, MS_PER_SECOND},
7};
8
9/// Converts weapon damage per second into attack power.
10pub const WEAPON_POWER_COEFFICIENT: f64 = 6.0;
11
12/// Returns the owner-power coefficient for a pet weapon swing.
13#[must_use]
14pub fn pet_swing_power_coefficient(
15    power_from_owner: f64,
16    weapon_speed_ms: u32,
17    weapon_multiplier: f64,
18) -> f64 {
19    power_from_owner * f64::from(weapon_speed_ms) / MS_PER_SECOND / WEAPON_POWER_COEFFICIENT
20        * weapon_multiplier
21}
22
23/// Attack-power coefficient for a normalized pet auto-attack swing.
24#[must_use]
25pub fn pet_swing_ap(swing_ms: u32) -> f64 {
26    pet_swing_power_coefficient(1.0, swing_ms, 1.0)
27}
28
29/// Result of a single damage calculation.
30#[derive(Clone, Debug)]
31#[must_use]
32pub struct DamageResult {
33    pub raw: f64,
34    pub is_crit: bool,
35    pub hit_result: HitResult,
36    pub final_amount: f64,
37}
38
39/// Ordered one-roll attack-table probabilities and landed-hit reductions.
40#[derive(Clone, Copy, Debug)]
41#[must_use]
42pub struct AttackTable {
43    pub miss_chance: f64,
44    pub dodge_chance: f64,
45    pub parry_chance: f64,
46    pub glance_chance: f64,
47    pub block_chance: f64,
48    pub critical_block_chance: f64,
49    pub glance_multiplier: f64,
50    pub block_multiplier: f64,
51    pub critical_block_multiplier: f64,
52}
53
54impl AttackTable {
55    const DEFAULT_BLOCK_MULTIPLIER: f64 = 0.7;
56    const DEFAULT_CRITICAL_BLOCK_MULTIPLIER: f64 = 0.4;
57    const DEFAULT_GLANCE_MULTIPLIER: f64 = 0.75;
58
59    // #t(fn: rust_cyclomatic_complexity) ordered attack-table thresholds and their neutral fast path are one semantic decision.
60    // #t(fn: rust_floating_point_eq) exact zero distinguishes the finalized neutral attack table from non-neutral probability mass.
61    fn resolve(self, crit_chance: f64, rng: &mut (impl FnMut() -> f64 + ?Sized)) -> HitResult {
62        let roll = rng();
63
64        if self.miss_chance == 0.0
65            && self.dodge_chance == 0.0
66            && self.parry_chance == 0.0
67            && self.glance_chance == 0.0
68            && self.block_chance == 0.0
69        {
70            return if roll < crit_chance.clamp(0.0, 1.0) {
71                HitResult::Crit
72            } else {
73                HitResult::Hit
74            };
75        }
76
77        let mut threshold = self.miss_chance.clamp(0.0, 1.0);
78
79        if roll < threshold {
80            return HitResult::Miss;
81        }
82
83        threshold = (threshold + self.dodge_chance.max(0.0)).min(1.0);
84
85        if roll < threshold {
86            return HitResult::Dodge;
87        }
88
89        threshold = (threshold + self.parry_chance.max(0.0)).min(1.0);
90
91        if roll < threshold {
92            return HitResult::Parry;
93        }
94
95        threshold = (threshold + self.glance_chance.max(0.0)).min(1.0);
96
97        if roll < threshold {
98            return HitResult::Glance;
99        }
100
101        threshold = (threshold + self.block_chance.max(0.0)).min(1.0);
102
103        if roll < threshold {
104            let mut critical_block_roll = || rng();
105
106            return if proc_chance(&mut critical_block_roll, self.critical_block_chance) {
107                HitResult::CritBlock
108            } else {
109                HitResult::Block
110            };
111        }
112
113        threshold = (threshold + crit_chance.clamp(0.0, 1.0)).min(1.0);
114
115        if roll < threshold {
116            HitResult::Crit
117        } else {
118            HitResult::Hit
119        }
120    }
121
122    const fn multiplier(self, result: HitResult, crit_multiplier: f64) -> f64 {
123        match result {
124            HitResult::Miss | HitResult::Dodge | HitResult::Parry => 0.0,
125            HitResult::Glance => self.glance_multiplier,
126            HitResult::Block => self.block_multiplier,
127            HitResult::CritBlock => self.critical_block_multiplier,
128            HitResult::Crit => crit_multiplier,
129            HitResult::Hit => 1.0,
130        }
131    }
132}
133
134impl Default for AttackTable {
135    fn default() -> Self {
136        Self {
137            miss_chance: 0.0,
138            dodge_chance: 0.0,
139            parry_chance: 0.0,
140            glance_chance: 0.0,
141            block_chance: 0.0,
142            critical_block_chance: 0.0,
143            glance_multiplier: Self::DEFAULT_GLANCE_MULTIPLIER,
144            block_multiplier: Self::DEFAULT_BLOCK_MULTIPLIER,
145            critical_block_multiplier: Self::DEFAULT_CRITICAL_BLOCK_MULTIPLIER,
146        }
147    }
148}
149
150/// All inputs needed to compute a damage hit.
151// docref:start combat-formulas-damage-calc-struct
152#[derive(Clone, Debug)]
153#[must_use]
154pub struct DamageCalc {
155    pub base: f64,
156    pub coefficient: f64,
157    pub attack_power: f64,
158    pub crit_chance: f64,
159    pub crit_multiplier: f64,
160    pub attack_table: AttackTable,
161    pub versatility: f64,
162    pub target_armor: f64,
163    pub armor_k: f64,
164    pub damage_multiplier: f64,
165    pub mastery_mult: f64,
166    pub weapon_min: f64,
167    pub weapon_max: f64,
168    pub weapon_multiplier: f64,
169    pub school: DamageSchool,
170}
171// docref:end combat-formulas-damage-calc-struct
172
173impl DamageCalc {
174    /// Compute the damage result, calling `rng` once for the weapon roll (if any) and once for crit.
175    pub fn calculate(&self, rng: &mut (impl FnMut() -> f64 + ?Sized)) -> DamageResult {
176        let weapon_roll = if self.weapon_max > 0.0 {
177            self.weapon_min + rng() * (self.weapon_max - self.weapon_min)
178        } else {
179            0.0
180        };
181        let raw =
182            self.base + weapon_roll * self.weapon_multiplier + self.coefficient * self.attack_power;
183
184        let hit_result = self.attack_table.resolve(self.crit_chance, rng);
185        let is_crit = hit_result.is_crit();
186        let crit_factor = self
187            .attack_table
188            .multiplier(hit_result, self.crit_multiplier);
189
190        let after_crit = raw * crit_factor;
191        let after_vers = after_crit * (1.0 + self.versatility / HUNDRED);
192        let mit = armor_mitigation(self.target_armor, self.armor_k);
193        let after_armor = after_vers * mit;
194        let final_amount = (after_armor * self.damage_multiplier * self.mastery_mult).max(0.0);
195
196        DamageResult {
197            raw,
198            is_crit,
199            hit_result,
200            final_amount,
201        }
202    }
203}
204
205/// Fraction of damage that bypasses armor (`1 - armor / (armor + K)`), in `[0, 1]`.
206// docref:start combat-formulas-armor-mitigation
207#[must_use]
208pub fn armor_mitigation(armor: f64, armor_k: f64) -> f64 {
209    if armor <= 0.0 {
210        return 1.0;
211    }
212    let mitigated = armor / (armor + armor_k);
213    (1.0 - mitigated.clamp(0.0, 1.0)).max(0.0)
214}
215// docref:end combat-formulas-armor-mitigation
216
217#[cfg(test)]
218#[path = "damage/tests.rs"]
219mod tests;