Skip to main content

wowlab_engine_combat/systems/damage_pipeline/setup/
spell_modifiers.rs

1use wowlab_engine_domain::{
2    dbc::{
3        ModifierOperation, ModifierPropertyKind, ResolvedGameDataSemanticExt as _,
4        aura_subtype_modifier, modifier_property_is,
5    },
6    rotation::DenseBuffer,
7};
8use wowlab_types::constants::HUNDRED;
9
10use crate::{
11    state::{BuffEffect, CombatState, DamageEffectRef, DamageSource},
12    systems::{
13        buffs::{
14            dbc_effect_points, for_each_active_actor_aura_effect,
15            for_each_active_target_aura_effect, spell_modifier_owner, target_aura_mechanic_mask,
16        },
17        damage_pipeline::DamageFlags,
18    },
19};
20
21#[derive(Clone, Copy, Debug, PartialEq)]
22// #t(rust_similar_structs) Spell-scoped aura totals and driver callback results are separate pipeline stages with different ownership.
23pub(crate) struct SpellScopedMods {
24    pub(crate) damage_mult: f64,
25    pub(crate) target_damage_mult: f64,
26    pub(crate) crit_chance_pct: f64,
27    pub(crate) crit_damage_mult: f64,
28}
29
30impl Default for SpellScopedMods {
31    fn default() -> Self {
32        Self {
33            damage_mult: 1.0,
34            target_damage_mult: 1.0,
35            crit_chance_pct: 0.0,
36            crit_damage_mult: 1.0,
37        }
38    }
39}
40
41#[derive(Clone, Copy, Debug)]
42pub(crate) struct SpellModifierQuery {
43    profile_spell_id: u32,
44    effect: DamageEffectRef,
45    flags: DamageFlags,
46}
47
48impl SpellModifierQuery {
49    pub(crate) const fn new(
50        profile_spell_id: u32,
51        effect: DamageEffectRef,
52        flags: DamageFlags,
53    ) -> Self {
54        Self {
55            profile_spell_id,
56            effect,
57            flags,
58        }
59    }
60
61    fn listed(self, spells: &[u32]) -> bool {
62        spells.contains(&self.profile_spell_id) || spells.contains(&self.effect.spell_id)
63    }
64
65    fn affected(self, state: &CombatState, source_spell_id: u32, effect_index: u8) -> bool {
66        [self.profile_spell_id, self.effect.spell_id]
67            .into_iter()
68            .any(|spell_id| {
69                state.config.game_data.effect_affects_spell(
70                    wowlab_types::sim::SpellIdx::from_raw(source_spell_id),
71                    effect_index,
72                    wowlab_types::sim::SpellIdx::from_raw(spell_id),
73                )
74            })
75    }
76
77    fn mechanic_mask(self, state: &CombatState) -> wowlab_engine_domain::dbc::MechanicMask {
78        let data = &state.config.game_data;
79        let effect_spell = wowlab_types::sim::SpellIdx::from_raw(self.effect.spell_id);
80        let effect = data.effect_mechanic_mask(effect_spell, self.effect.effect_index);
81
82        if effect.is_empty() {
83            data.spell_mechanic_mask(wowlab_types::sim::SpellIdx::from_raw(self.profile_spell_id))
84        } else {
85            effect
86        }
87    }
88
89    fn school_mask(self, state: &CombatState) -> wowlab_engine_domain::dbc::SpellSchoolMask {
90        let data = &state.config.game_data;
91        let raw = data
92            .school_mask(wowlab_types::sim::SpellIdx::from_raw(self.effect.spell_id))
93            .or_else(|| {
94                data.school_mask(wowlab_types::sim::SpellIdx::from_raw(self.profile_spell_id))
95            })
96            .unwrap_or_default();
97
98        wowlab_engine_domain::dbc::SpellSchoolMask::from_dbc(raw)
99    }
100}
101
102struct SpellModifierContext<'a> {
103    state: &'a CombatState,
104    buf: &'a DenseBuffer,
105    query: SpellModifierQuery,
106    target: Option<wowlab_types::sim::EnemyIdx>,
107    now: wowlab_types::sim::SimTime,
108}
109
110#[derive(Clone, Copy)]
111struct ActiveEffectPointModifier {
112    stacks: f64,
113    value: f64,
114    source_spell_id: u32,
115    effect_index: u8,
116    operation: ModifierOperation,
117}
118
119impl ActiveEffectPointModifier {
120    fn from_buff(stacks: f64, effect: BuffEffect) -> Option<Self> {
121        let BuffEffect::SpellModifierFromEffect {
122            value,
123            source_spell_id,
124            effect_index,
125            property: ModifierPropertyKind::Points,
126            operation,
127        } = effect
128        else {
129            return None;
130        };
131
132        Some(Self {
133            stacks,
134            value,
135            source_spell_id,
136            effect_index,
137            operation,
138        })
139    }
140}
141
142impl SpellModifierContext<'_> {
143    fn apply(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
144        let before = *mods;
145
146        self.apply_damage(stacks, effect, mods);
147        self.apply_target(stacks, effect, mods);
148        self.apply_critical(stacks, effect, mods);
149
150        if *mods != before {
151            tracing::trace!(
152                profile_spell_id = self.query.profile_spell_id,
153                effect_spell_id = self.query.effect.spell_id,
154                stacks,
155                ?effect,
156                damage_mult = mods.damage_mult,
157                target_damage_mult = mods.target_damage_mult,
158                crit_chance_pct = mods.crit_chance_pct,
159                crit_damage_mult = mods.crit_damage_mult,
160                "spell-scoped aura modifier applied"
161            );
162        }
163    }
164
165    fn apply_damage(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
166        match effect {
167            BuffEffect::DamageMultSpells(value, spells) if self.query.listed(spells) => {
168                mods.damage_mult *= value.powf(stacks);
169            }
170            BuffEffect::DamageMultSpellsActive(value, spells) if self.query.listed(spells) => {
171                mods.damage_mult *= value;
172            }
173            BuffEffect::SpellDamageFromEffect {
174                percent,
175                source_spell_id,
176                effect_index,
177                periodic,
178            } if *periodic == self.query.flags.contains(DamageFlags::PERIODIC)
179                && self
180                    .query
181                    .affected(self.state, *source_spell_id, *effect_index) =>
182            {
183                let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
184
185                mods.damage_mult *= 1.0 + percent * points / HUNDRED;
186            }
187            BuffEffect::DamageMultSpellsLinear(value, spells) if self.query.listed(spells) => {
188                mods.damage_mult *= 1.0 + value / HUNDRED * stacks;
189            }
190            BuffEffect::DynamicDamageMultSpells(resolve, spells) if self.query.listed(spells) => {
191                mods.damage_mult *=
192                    resolve(self.state, self.buf, self.target, self.now).powf(stacks);
193            }
194            BuffEffect::DamageToTargetAuraMechanicPercent {
195                percent,
196                mechanic_mask,
197            } if self.target.is_some_and(|target| {
198                target_aura_mechanic_mask(self.state, self.buf, target, self.now)
199                    .intersects(*mechanic_mask)
200            }) =>
201            {
202                mods.damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
203            }
204            _ => {}
205        }
206    }
207
208    /// Applies an aura-87 (`ModDamageTakenPercent`) effect carried by an aura on the *victim*.
209    ///
210    /// The player-aura form of the same effect is the defensive half, owned by `incoming_damage`.
211    /// Only a debuff on the target scales outgoing damage, so this runs over the target-aura walk.
212    fn apply_incoming_damage(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
213        if let BuffEffect::IncomingDamagePercent { percent, schools } = effect
214            && schools.intersects(self.query.school_mask(self.state))
215        {
216            mods.target_damage_mult *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
217        }
218    }
219
220    fn apply_target(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
221        match effect {
222            BuffEffect::DamageTakenFromSource(percent, source)
223                if damage_source_matches(*source, self.query.flags) =>
224            {
225                mods.target_damage_mult *= 1.0 + percent / HUNDRED;
226            }
227            BuffEffect::DamageTakenFromSourceSpells {
228                percent,
229                source_spell_id,
230                effect_index,
231                source,
232            } if damage_source_matches(*source, self.query.flags)
233                && self
234                    .query
235                    .affected(self.state, *source_spell_id, *effect_index) =>
236            {
237                let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
238
239                mods.target_damage_mult *= 1.0 + percent / HUNDRED * points;
240            }
241            BuffEffect::MechanicDamageTakenPercent {
242                percent,
243                mechanic_mask,
244            } if self
245                .query
246                .mechanic_mask(self.state)
247                .intersects(*mechanic_mask) =>
248            {
249                mods.target_damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
250            }
251            BuffEffect::CreatureAoeDamageAvoidance { percent, schools }
252                if self.query.flags.contains(DamageFlags::AOE)
253                    && schools.intersects(self.query.school_mask(self.state)) =>
254            {
255                mods.target_damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
256            }
257            _ => {}
258        }
259    }
260
261    fn apply_critical(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
262        match effect {
263            BuffEffect::SpellCritChanceFromEffect {
264                percent,
265                source_spell_id,
266                effect_index,
267            } if self
268                .query
269                .affected(self.state, *source_spell_id, *effect_index) =>
270            {
271                let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
272
273                mods.crit_chance_pct += percent * points;
274            }
275            BuffEffect::SpellCritDamageFromEffect {
276                percent,
277                source_spell_id,
278                effect_index,
279            } if self
280                .query
281                .affected(self.state, *source_spell_id, *effect_index) =>
282            {
283                let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
284
285                mods.crit_damage_mult *= 1.0 + percent * points / HUNDRED;
286            }
287            BuffEffect::CritChanceSpells(value, spells) if self.query.listed(spells) => {
288                mods.crit_chance_pct += value * stacks;
289            }
290            BuffEffect::CritDamageSpells(value, spells) if self.query.listed(spells) => {
291                mods.crit_damage_mult *= (1.0 + value / HUNDRED).powf(stacks);
292            }
293            _ => {}
294        }
295    }
296
297    fn apply_effect_point_damage_modifiers(
298        &self,
299        active: &[ActiveEffectPointModifier],
300        mods: &mut SpellScopedMods,
301    ) {
302        let data = &self.state.config.game_data;
303        let hit_is_periodic = self.query.flags.contains(DamageFlags::PERIODIC);
304
305        for passive_spell_id in data.folded_passive_spells() {
306            let passive = *passive_spell_id;
307
308            for passive_effect_index in 1..=data.max_effect_index(passive) {
309                let property = data.effect_misc_value_0(passive, passive_effect_index);
310                let effect_is_periodic =
311                    if modifier_property_is(property, ModifierPropertyKind::GenericDamage) {
312                        false
313                    } else if modifier_property_is(property, ModifierPropertyKind::PeriodicAmount) {
314                        true
315                    } else {
316                        continue;
317                    };
318
319                if effect_is_periodic != hit_is_periodic
320                    || !self.query.affected(
321                        self.state,
322                        passive_spell_id.as_u32(),
323                        passive_effect_index,
324                    )
325                    || aura_subtype_modifier(data.effect_aura(passive, passive_effect_index))
326                        .is_none_or(|(operation, _)| operation != ModifierOperation::Percent)
327                {
328                    continue;
329                }
330
331                let initial = data.base_points(passive, passive_effect_index);
332                let mut flat = 0.0;
333                let mut percent = 1.0;
334
335                for modifier in active.iter().filter(|modifier| {
336                    data.effect_affects_spell(
337                        wowlab_types::sim::SpellIdx::from_raw(modifier.source_spell_id),
338                        modifier.effect_index,
339                        passive,
340                    )
341                }) {
342                    let points = dbc_effect_points(
343                        self.state,
344                        modifier.source_spell_id,
345                        modifier.effect_index,
346                        modifier.stacks,
347                    );
348
349                    match modifier.operation {
350                        ModifierOperation::Flat => flat += modifier.value * points,
351                        ModifierOperation::Percent => {
352                            percent *= 1.0 + modifier.value * points / HUNDRED;
353                        }
354                        _ => {}
355                    }
356                }
357
358                let original_factor = 1.0 + initial / HUNDRED;
359
360                if original_factor.abs() <= f64::EPSILON {
361                    continue;
362                }
363
364                let modified_factor = 1.0 + (initial + flat) * percent / HUNDRED;
365
366                mods.damage_mult *= modified_factor / original_factor;
367            }
368        }
369    }
370}
371
372fn damage_source_matches(source: DamageSource, flags: DamageFlags) -> bool {
373    match source {
374        DamageSource::Player => !flags.intersects(DamageFlags::PET | DamageFlags::GUARDIAN),
375        DamageSource::Pet => {
376            flags.contains(DamageFlags::PET) && !flags.contains(DamageFlags::GUARDIAN)
377        }
378        DamageSource::Guardian => flags.contains(DamageFlags::GUARDIAN),
379    }
380}
381
382pub(crate) fn spell_scoped_mods_for(
383    state: &CombatState,
384    buf: &DenseBuffer,
385    source: wowlab_types::sim::ActorId,
386    query: SpellModifierQuery,
387    target: Option<wowlab_types::sim::EnemyIdx>,
388    now: wowlab_types::sim::SimTime,
389) -> SpellScopedMods {
390    let mut mods = SpellScopedMods::default();
391    let mut effect_point_modifiers = Vec::new();
392    let context = SpellModifierContext {
393        state,
394        buf,
395        query,
396        target,
397        now,
398    };
399
400    if query.flags.contains(DamageFlags::AOE)
401        && let Some(target) = target
402        && let Some(enemy) = state.enemy_definition(target)
403    {
404        mods.target_damage_mult *=
405            1.0 - enemy.creature_aoe_avoidance_pct().clamp(0.0, HUNDRED) / HUNDRED;
406    }
407
408    let mut apply_source_effect = |stacks, effect: &BuffEffect| {
409        context.apply(stacks, effect, &mut mods);
410
411        if let Some(modifier) = ActiveEffectPointModifier::from_buff(stacks, *effect) {
412            effect_point_modifiers.push(modifier);
413        }
414    };
415
416    let owner = spell_modifier_owner(source);
417
418    if let Some(owner) = owner {
419        for_each_active_actor_aura_effect(
420            crate::context::ActorView::new(state, buf, owner),
421            &mut apply_source_effect,
422        );
423    }
424
425    if let Some(target) = target {
426        for_each_active_target_aura_effect(state, buf, target, |stacks, effect| {
427            context.apply(stacks, effect, &mut mods);
428            context.apply_incoming_damage(stacks, effect, &mut mods);
429        });
430    }
431
432    if owner.is_some() {
433        for modifier in &state.defs.driver_spell_modifiers {
434            if !context.query.listed(modifier.spells) {
435                continue;
436            }
437
438            let resolved = (modifier.resolve)(crate::HookView {
439                state,
440                buf,
441                target,
442                now,
443            });
444
445            mods.damage_mult *= resolved.damage_mult;
446            mods.target_damage_mult *= resolved.target_damage_mult;
447            mods.crit_chance_pct += resolved.crit_chance_pct;
448            mods.crit_damage_mult *= resolved.crit_damage_mult;
449
450            // #t(rust_log_in_loop) Each applied driver identity is required for modifier attribution in trace diagnostics.
451            tracing::trace!(
452                driver_spell_id = modifier.driver_spell_id,
453                profile_spell_id = context.query.profile_spell_id,
454                effect_spell_id = context.query.effect.spell_id,
455                ?resolved,
456                "driver-owned spell modifier applied"
457            );
458        }
459
460        context.apply_effect_point_damage_modifiers(&effect_point_modifiers, &mut mods);
461    }
462
463    mods
464}
465
466#[cfg(test)]
467pub(crate) fn spell_scoped_mods_fixture(
468    view: &crate::context::CombatView<'_>,
469    spell_id: u32,
470    flags: DamageFlags,
471) -> SpellScopedMods {
472    spell_scoped_mods_for(
473        view.state,
474        view.buf,
475        wowlab_types::sim::ActorId::Player,
476        SpellModifierQuery::new(spell_id, DamageEffectRef::new(spell_id, 0), flags),
477        view.state.current_target(),
478        wowlab_types::sim::SimTime::ZERO,
479    )
480}