Skip to main content

wowlab_engine_domain/dbc/passives/
crit_scaling.rs

1//! Crit damage the game scales by a share of the caster's own critical strike chance.
2
3use super::{
4    AuraSubtypeKind, CLASS_FAMILY_FLAGS, ModifierFilter, ModifierOperation, ModifierPropertyKind,
5    SpellDataFlat, SpellEffect, aura_kind, aura_subtype_semantic, masks_overlap,
6    modifier_property_kind,
7};
8use crate::dbc::SpellSchoolMask;
9
10/// Which multiplier a crit-chance-scaled crit-damage term belongs on.
11///
12/// The two are numerically distinct.
13///   The player axis scales the whole crit multiplier including its `1.0` base.
14///   The spell axis scales only the bonus above `1.0`.
15#[derive(Clone, Copy, Debug, PartialEq)]
16// #t(rust_non_exhaustive_on_public) the two DBC crit-damage aura families are the whole domain
17pub enum CritChanceScaledAxis {
18    /// DBC aura 163 `Modify Crit Damage Done%`, filtered by the effect's school mask.
19    Player(SpellSchoolMask),
20    /// Modifier property 15 `Spell Critical Bonus Multiplier`, filtered by the effect's affect list.
21    Spell,
22}
23
24/// One crit-damage term a passive states as a share of the caster's critical strike chance.
25#[derive(Clone, Debug)]
26pub struct CritChanceScaledCritDamage {
27    /// The zero-valued crit-damage effect the share applies to.
28    pub effect: SpellEffect,
29    /// Class family set of the passive that declares the term, for affect-list matching.
30    pub spell_class_set: i32,
31    /// Percentage of the caster's critical strike chance, after rank scaling.
32    pub percent: f64,
33    /// Which crit multiplier the term belongs on.
34    pub axis: CritChanceScaledAxis,
35}
36
37/// Reads the crit-damage terms `spell` states as a share of the caster's critical strike chance.
38///
39/// `source_effect_index` is the 1-based effect the spell's own description names for the value.
40///   Every live spell of this shape parks the percentage on a dummy effect and ships the real
41///   crit-damage effect at base value **zero**, because the client computes it from the two.
42///   That zero is what makes the reading unambiguous: a crit-damage effect carrying its own
43///   non-zero value, such as the racial Might of the Mountain, is a flat bonus and is left alone.
44#[must_use]
45pub fn crit_chance_scaled_crit_damage(
46    spell: &SpellDataFlat,
47    source_effect_index: u8,
48    ranks: f64,
49) -> Vec<CritChanceScaledCritDamage> {
50    // Description effect references are 1-based like `SimC`'s `effectN`; DBC rows are 0-based.
51    let source_dbc_index = i32::from(source_effect_index) - 1;
52    let Some(source) = spell
53        .effects
54        .iter()
55        .find(|effect| effect.index == source_dbc_index)
56    else {
57        return Vec::new();
58    };
59    let percent = source.base_points * ranks;
60
61    if percent.abs() < f64::EPSILON {
62        return Vec::new();
63    }
64
65    spell
66        .effects
67        .iter()
68        .filter(|effect| effect.base_points.abs() < f64::EPSILON)
69        .filter_map(|effect| {
70            crit_damage_axis(effect).map(|axis| CritChanceScaledCritDamage {
71                effect: effect.clone(),
72                spell_class_set: spell.spell_class_set,
73                percent,
74                axis,
75            })
76        })
77        .collect()
78}
79
80/// Percentage of the caster's critical strike chance the terms add to one spell's crit bonus.
81///
82/// Only the spell axis is per-spell; the player axis is school-filtered at damage time instead.
83#[must_use]
84pub fn crit_chance_scaled_spell_percent(
85    terms: &[CritChanceScaledCritDamage],
86    target_class_set: i32,
87    target_class_masks: [i32; CLASS_FAMILY_FLAGS],
88    target_labels: &[i32],
89) -> f64 {
90    let mut percent = 0.0;
91
92    for term in terms {
93        if term.axis != CritChanceScaledAxis::Spell {
94            continue;
95        }
96
97        let matched = match aura_subtype_semantic(term.effect.aura)
98            .and_then(|semantic| semantic.modifier)
99            .map(|(_, filter)| filter)
100        {
101            Some(ModifierFilter::ClassFamilyMask) => {
102                term.spell_class_set == target_class_set
103                    && masks_overlap(&term.effect, target_class_masks)
104            }
105            Some(ModifierFilter::Label) => target_labels.contains(&term.effect.misc_value_1),
106            _ => false,
107        };
108
109        if matched {
110            percent += term.percent;
111        }
112    }
113
114    percent
115}
116
117fn crit_damage_axis(effect: &SpellEffect) -> Option<CritChanceScaledAxis> {
118    if aura_kind(effect) == Some(AuraSubtypeKind::ModCritDamageMultiplier) {
119        return Some(CritChanceScaledAxis::Player(SpellSchoolMask::from_dbc(
120            effect.misc_value_0,
121        )));
122    }
123
124    let is_percent_modifier = aura_subtype_semantic(effect.aura)
125        .and_then(|semantic| semantic.modifier)
126        .is_some_and(|(operation, _)| operation == ModifierOperation::Percent);
127
128    (is_percent_modifier
129        && modifier_property_kind(effect) == Some(ModifierPropertyKind::CritDamage))
130    .then_some(CritChanceScaledAxis::Spell)
131}
132
133#[cfg(test)]
134mod tests;