Skip to main content

wowlab_engine_combat/systems/damage_pipeline/setup/
spell_modifiers.rs

1use wowlab_engine_domain::dbc::{
2    ModifierOperation, ModifierPropertyKind, aura_subtype_modifier, modifier_property_is,
3};
4use wowlab_types::constants::HUNDRED;
5
6#[cfg(feature = "rotation-optimizations")]
7use crate::systems::buffs::for_each_active_damage_modifier_for_actions;
8#[cfg(not(feature = "rotation-optimizations"))]
9use crate::{
10    state::EffectFamily,
11    systems::buffs::{
12        for_each_active_damage_modifier_for_action, for_each_planned_actor_effect_damage_binding,
13    },
14};
15use crate::{
16    state::{BuffEffect, CombatInstance, DamageEffectRef, DamageSource},
17    systems::{
18        buffs::{
19            dbc_effect_points, for_each_active_spell_modifier_for_action, spell_modifier_owner,
20            target_aura_mechanic_mask,
21        },
22        damage_pipeline::DamageFlags,
23    },
24};
25
26#[derive(Clone, Copy, Debug, PartialEq)]
27// #t(rust_similar_structs) Spell-scoped aura totals and driver callback results are separate pipeline stages with different ownership.
28pub(crate) struct SpellScopedMods {
29    pub(crate) damage_mult: f64,
30    pub(crate) target_damage_mult: f64,
31    pub(crate) crit_chance_pct: f64,
32    pub(crate) crit_damage_mult: f64,
33}
34
35impl Default for SpellScopedMods {
36    fn default() -> Self {
37        Self {
38            damage_mult: 1.0,
39            target_damage_mult: 1.0,
40            crit_chance_pct: 0.0,
41            crit_damage_mult: 1.0,
42        }
43    }
44}
45
46#[derive(Clone, Copy, Debug)]
47pub(crate) struct SpellModifierProfile {
48    external: u32,
49    local: crate::state::program::LocalDamageActionIdx,
50}
51
52impl SpellModifierProfile {
53    pub(crate) const fn new(
54        external: u32,
55        local: crate::state::program::LocalDamageActionIdx,
56    ) -> Self {
57        Self { external, local }
58    }
59}
60
61#[derive(Clone, Copy, Debug)]
62pub(crate) struct SpellModifierEffect {
63    external: DamageEffectRef,
64    local: crate::state::program::LocalDamageEffectIdx,
65}
66
67impl SpellModifierEffect {
68    pub(crate) const fn new(
69        external: DamageEffectRef,
70        local: crate::state::program::LocalDamageEffectIdx,
71    ) -> Self {
72        Self { external, local }
73    }
74}
75
76#[derive(Clone, Copy, Debug)]
77pub(crate) struct SpellModifierQuery {
78    profile: SpellModifierProfile,
79    effect: SpellModifierEffect,
80    flags: DamageFlags,
81}
82
83impl SpellModifierQuery {
84    pub(crate) const fn new(
85        profile: SpellModifierProfile,
86        effect: SpellModifierEffect,
87        flags: DamageFlags,
88    ) -> Self {
89        Self {
90            profile,
91            effect,
92            flags,
93        }
94    }
95
96    fn listed(
97        self,
98        program: &crate::state::CombatProgram,
99        binding: &crate::state::program::FinalizedBuffDamageBinding,
100    ) -> bool {
101        let effect_action = program.damage.effect_at(self.effect.local).action;
102
103        binding.lists_action(self.profile.local) || binding.lists_action(effect_action)
104    }
105
106    fn affected(
107        self,
108        program: &crate::state::CombatProgram,
109        source: crate::state::program::LocalDamageEffectIdx,
110    ) -> bool {
111        let profile_affected = program
112            .damage
113            .effect_affects_action(source, self.profile.local);
114        let effect_action = program.damage.effect_at(self.effect.local).action;
115        let effect_affected = self.profile.external != self.effect.external.spell_id
116            && program.damage.effect_affects_action(source, effect_action);
117
118        profile_affected || effect_affected
119    }
120
121    fn mechanic_mask(
122        self,
123        program: &crate::state::CombatProgram,
124    ) -> wowlab_engine_domain::dbc::MechanicMask {
125        let effect = program.damage.effect_at(self.effect.local).mechanic_mask;
126
127        if effect.is_empty() {
128            program.damage.action_at(self.profile.local).mechanic_mask
129        } else {
130            effect
131        }
132    }
133
134    fn school_mask(
135        self,
136        program: &crate::state::CombatProgram,
137    ) -> wowlab_engine_domain::dbc::SpellSchoolMask {
138        program
139            .damage
140            .action_at(program.damage.effect_at(self.effect.local).action)
141            .school_mask
142    }
143}
144
145struct SpellModifierContext<'a> {
146    program: &'a crate::state::CombatProgram,
147    state: &'a CombatInstance,
148    query: SpellModifierQuery,
149    target: Option<wowlab_types::sim::EnemyIdx>,
150    now: wowlab_types::sim::SimTime,
151}
152
153#[derive(Clone, Copy)]
154struct ActiveEffectPointModifier {
155    stacks: f64,
156    value: f64,
157    source: crate::state::program::LocalDamageEffectIdx,
158    operation: ModifierOperation,
159}
160
161impl ActiveEffectPointModifier {
162    fn from_buff(
163        stacks: f64,
164        effect: BuffEffect,
165        binding: &crate::state::program::FinalizedBuffDamageBinding,
166    ) -> Option<Self> {
167        let BuffEffect::SpellModifierFromEffect {
168            value,
169            property: ModifierPropertyKind::Points,
170            operation,
171            ..
172        } = effect
173        else {
174            return None;
175        };
176
177        Some(Self {
178            stacks,
179            value,
180            source: binding.source_effect?,
181            operation,
182        })
183    }
184}
185
186impl SpellModifierContext<'_> {
187    fn apply(
188        &self,
189        stacks: f64,
190        effect: &BuffEffect,
191        binding: &crate::state::program::FinalizedBuffDamageBinding,
192        mods: &mut SpellScopedMods,
193    ) {
194        let before = *mods;
195
196        self.apply_damage(stacks, effect, binding, mods);
197        self.apply_target(stacks, effect, binding, mods);
198        self.apply_critical(stacks, effect, binding, mods);
199
200        if *mods != before {
201            tracing::trace!(
202                profile_spell_id = self.query.profile.external,
203                effect_spell_id = self.query.effect.external.spell_id,
204                stacks,
205                ?effect,
206                damage_mult = mods.damage_mult,
207                target_damage_mult = mods.target_damage_mult,
208                crit_chance_pct = mods.crit_chance_pct,
209                crit_damage_mult = mods.crit_damage_mult,
210                "spell-scoped aura modifier applied"
211            );
212        }
213    }
214
215    fn apply_damage(
216        &self,
217        stacks: f64,
218        effect: &BuffEffect,
219        binding: &crate::state::program::FinalizedBuffDamageBinding,
220        mods: &mut SpellScopedMods,
221    ) {
222        match effect {
223            BuffEffect::DamageMultSpells(value, _) if self.query.listed(self.program, binding) => {
224                mods.damage_mult *= value.powf(stacks);
225            }
226            BuffEffect::DamageMultSpellsActive(value, _)
227                if self.query.listed(self.program, binding) =>
228            {
229                mods.damage_mult *= value;
230            }
231            BuffEffect::SpellDamageFromEffect {
232                percent,
233                source_spell_id,
234                effect_index,
235                periodic,
236            } if *periodic == self.query.flags.contains(DamageFlags::PERIODIC)
237                && binding
238                    .source_effect
239                    .is_some_and(|source| self.query.affected(self.program, source)) =>
240            {
241                let points = dbc_effect_points(
242                    self.program,
243                    self.state,
244                    binding
245                        .source_effect
246                        .expect("matched source binding exists"),
247                    stacks,
248                );
249
250                mods.damage_mult *= 1.0 + percent * points / HUNDRED;
251            }
252            BuffEffect::DamageMultSpellsLinear(value, _)
253                if self.query.listed(self.program, binding) =>
254            {
255                mods.damage_mult *= 1.0 + value / HUNDRED * stacks;
256            }
257            BuffEffect::DynamicDamageMultSpells(resolve, _)
258                if self.query.listed(self.program, binding) =>
259            {
260                mods.damage_mult *=
261                    resolve(self.program, self.state, self.target, self.now).powf(stacks);
262            }
263            BuffEffect::DamageToTargetAuraMechanicPercent {
264                percent,
265                mechanic_mask,
266            } if self.target.is_some_and(|target| {
267                target_aura_mechanic_mask(self.program, self.state, target, self.now)
268                    .intersects(*mechanic_mask)
269            }) =>
270            {
271                mods.damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
272            }
273            _ => {}
274        }
275    }
276
277    /// Applies an aura-87 (`ModDamageTakenPercent`) effect carried by an aura on the *victim*.
278    ///
279    /// The player-aura form of the same effect is the defensive half, owned by `incoming_damage`.
280    /// Only a debuff on the target scales outgoing damage, so this runs over the target-aura walk.
281    fn apply_incoming_damage(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
282        if let BuffEffect::IncomingDamagePercent { percent, schools } = effect
283            && schools.intersects(self.query.school_mask(self.program))
284        {
285            mods.target_damage_mult *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
286        }
287    }
288
289    fn apply_target(
290        &self,
291        stacks: f64,
292        effect: &BuffEffect,
293        binding: &crate::state::program::FinalizedBuffDamageBinding,
294        mods: &mut SpellScopedMods,
295    ) {
296        match effect {
297            BuffEffect::DamageTakenFromSource(percent, source)
298                if damage_source_matches(*source, self.query.flags) =>
299            {
300                mods.target_damage_mult *= 1.0 + percent / HUNDRED;
301            }
302            BuffEffect::DamageTakenFromSourceSpells {
303                percent,
304                source_spell_id,
305                effect_index,
306                source,
307            } if damage_source_matches(*source, self.query.flags)
308                && binding
309                    .source_effect
310                    .is_some_and(|source| self.query.affected(self.program, source)) =>
311            {
312                let points = dbc_effect_points(
313                    self.program,
314                    self.state,
315                    binding
316                        .source_effect
317                        .expect("matched source binding exists"),
318                    stacks,
319                );
320
321                mods.target_damage_mult *= 1.0 + percent / HUNDRED * points;
322            }
323            BuffEffect::MechanicDamageTakenPercent {
324                percent,
325                mechanic_mask,
326            } if self
327                .query
328                .mechanic_mask(self.program)
329                .intersects(*mechanic_mask) =>
330            {
331                mods.target_damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
332            }
333            BuffEffect::CreatureAoeDamageAvoidance { percent, schools }
334                if self.query.flags.contains(DamageFlags::AOE)
335                    && schools.intersects(self.query.school_mask(self.program)) =>
336            {
337                mods.target_damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
338            }
339            _ => {}
340        }
341    }
342
343    fn apply_critical(
344        &self,
345        stacks: f64,
346        effect: &BuffEffect,
347        binding: &crate::state::program::FinalizedBuffDamageBinding,
348        mods: &mut SpellScopedMods,
349    ) {
350        match effect {
351            BuffEffect::SpellCritChanceFromEffect {
352                percent,
353                source_spell_id,
354                effect_index,
355            } if binding
356                .source_effect
357                .is_some_and(|source| self.query.affected(self.program, source)) =>
358            {
359                let points = dbc_effect_points(
360                    self.program,
361                    self.state,
362                    binding
363                        .source_effect
364                        .expect("matched source binding exists"),
365                    stacks,
366                );
367
368                mods.crit_chance_pct += percent * points;
369            }
370            BuffEffect::SpellCritDamageFromEffect {
371                percent,
372                source_spell_id,
373                effect_index,
374            } if binding
375                .source_effect
376                .is_some_and(|source| self.query.affected(self.program, source)) =>
377            {
378                let points = dbc_effect_points(
379                    self.program,
380                    self.state,
381                    binding
382                        .source_effect
383                        .expect("matched source binding exists"),
384                    stacks,
385                );
386
387                mods.crit_damage_mult *= 1.0 + percent * points / HUNDRED;
388            }
389            BuffEffect::CritChanceSpells(value, _) if self.query.listed(self.program, binding) => {
390                mods.crit_chance_pct += value * stacks;
391            }
392            BuffEffect::CritDamageSpells(value, _) if self.query.listed(self.program, binding) => {
393                mods.crit_damage_mult *= (1.0 + value / HUNDRED).powf(stacks);
394            }
395            _ => {}
396        }
397    }
398
399    fn apply_driver_spell_modifiers(&self, mods: &mut SpellScopedMods) {
400        #[cfg(not(feature = "rotation-optimizations"))]
401        for (index, modifier) in self.program.driver_spell_modifiers.iter().enumerate() {
402            let effect_action = self
403                .program
404                .damage
405                .effect_at(self.query.effect.local)
406                .action;
407
408            if !self
409                .program
410                .damage
411                .driver_modifier_lists_action(index, self.query.profile.local)
412                && !self
413                    .program
414                    .damage
415                    .driver_modifier_lists_action(index, effect_action)
416            {
417                continue;
418            }
419
420            self.apply_driver_spell_modifier(modifier, mods);
421        }
422
423        #[cfg(feature = "rotation-optimizations")]
424        {
425            let effect_action = self
426                .program
427                .damage
428                .effect_at(self.query.effect.local)
429                .action;
430            let profile_modifiers = self
431                .program
432                .damage
433                .driver_modifiers_for_action(self.query.profile.local);
434            let effect_modifiers = self
435                .program
436                .damage
437                .driver_modifiers_for_action(effect_action);
438
439            for_each_sorted_union(profile_modifiers, effect_modifiers, |index| {
440                let modifier = self
441                    .program
442                    .driver_spell_modifiers
443                    .get(index)
444                    .expect("driver adjacency references a finalized spell modifier");
445
446                self.apply_driver_spell_modifier(modifier, mods);
447            });
448        }
449    }
450
451    fn apply_driver_spell_modifier(
452        &self,
453        modifier: &crate::state::DriverSpellModifier,
454        mods: &mut SpellScopedMods,
455    ) {
456        let resolved = (modifier.resolve)(crate::HookView {
457            program: self.program,
458            instance: self.state,
459            target: self.target,
460            now: self.now,
461        });
462
463        mods.damage_mult *= resolved.damage_mult;
464        mods.target_damage_mult *= resolved.target_damage_mult;
465        mods.crit_chance_pct += resolved.crit_chance_pct;
466        mods.crit_damage_mult *= resolved.crit_damage_mult;
467
468        // #t(rust_log_in_loop) Each applied driver identity is required for modifier attribution in trace diagnostics.
469        tracing::trace!(
470            driver_spell_id = modifier.driver_spell_id,
471            profile_spell_id = self.query.profile.external,
472            effect_spell_id = self.query.effect.external.spell_id,
473            ?resolved,
474            "driver-owned spell modifier applied"
475        );
476    }
477
478    fn apply_effect_point_damage_modifiers(
479        &self,
480        owner: wowlab_types::sim::ActorId,
481        mods: &mut SpellScopedMods,
482    ) {
483        #[cfg(not(feature = "rotation-optimizations"))]
484        for (coordinate, _) in self.program.damage.folded_passive_effects() {
485            self.apply_effect_point_damage_modifier(owner, coordinate, mods);
486        }
487
488        #[cfg(feature = "rotation-optimizations")]
489        {
490            let effect_action = self
491                .program
492                .damage
493                .effect_at(self.query.effect.local)
494                .action;
495            let profile_effects = self
496                .program
497                .damage
498                .folded_passive_effects_for_action(self.query.profile.local);
499            let effect_effects = self
500                .program
501                .damage
502                .folded_passive_effects_for_action(effect_action);
503
504            for_each_sorted_union(profile_effects, effect_effects, |coordinate| {
505                self.apply_effect_point_damage_modifier(owner, coordinate, mods);
506            });
507        }
508    }
509
510    fn apply_effect_point_damage_modifier(
511        &self,
512        owner: wowlab_types::sim::ActorId,
513        coordinate: crate::state::program::LocalDamageEffectIdx,
514        mods: &mut SpellScopedMods,
515    ) {
516        let passive_effect = self.program.damage.effect_at(coordinate);
517        let hit_is_periodic = self.query.flags.contains(DamageFlags::PERIODIC);
518        let property = passive_effect.modifier_property;
519        let effect_is_periodic =
520            if modifier_property_is(property, ModifierPropertyKind::GenericDamage) {
521                false
522            } else if modifier_property_is(property, ModifierPropertyKind::PeriodicAmount) {
523                true
524            } else {
525                return;
526            };
527
528        if effect_is_periodic != hit_is_periodic
529            || !self.query.affected(self.program, coordinate)
530            || aura_subtype_modifier(passive_effect.aura_subtype)
531                .is_none_or(|(operation, _)| operation != ModifierOperation::Percent)
532        {
533            return;
534        }
535
536        let initial = passive_effect.base_points;
537        let mut flat = 0.0;
538        let mut percent = 1.0;
539        let target = passive_effect.action;
540
541        for_each_active_spell_modifier_for_action(
542            crate::context::ActorView::new(self.program, self.state, owner),
543            target,
544            ModifierPropertyKind::Points,
545            |stacks, effect, binding| {
546                let Some(modifier) = ActiveEffectPointModifier::from_buff(stacks, *effect, binding)
547                else {
548                    return;
549                };
550
551                let affected = self
552                    .program
553                    .damage
554                    .effect_affects_action(modifier.source, target);
555
556                if !affected {
557                    return;
558                }
559
560                let points =
561                    dbc_effect_points(self.program, self.state, modifier.source, modifier.stacks);
562
563                match modifier.operation {
564                    ModifierOperation::Flat => flat += modifier.value * points,
565                    ModifierOperation::Percent => {
566                        percent *= 1.0 + modifier.value * points / HUNDRED;
567                    }
568                    _ => {}
569                }
570            },
571        );
572
573        let original_factor = 1.0 + initial / HUNDRED;
574
575        if original_factor.abs() <= f64::EPSILON {
576            return;
577        }
578
579        let modified_factor = 1.0 + (initial + flat) * percent / HUNDRED;
580
581        mods.damage_mult *= modified_factor / original_factor;
582    }
583}
584
585#[cfg(feature = "rotation-optimizations")]
586fn for_each_sorted_union<T>(left: &[T], right: &[T], mut visit: impl FnMut(T))
587where
588    T: Copy + Ord,
589{
590    let mut left_index = 0;
591    let mut right_index = 0;
592
593    while left_index < left.len() || right_index < right.len() {
594        let effect = match (left.get(left_index), right.get(right_index)) {
595            (Some(&left_effect), Some(&right_effect)) if left_effect < right_effect => {
596                left_index += 1;
597
598                left_effect
599            }
600            (Some(&left_effect), Some(&right_effect)) if right_effect < left_effect => {
601                right_index += 1;
602
603                right_effect
604            }
605            (Some(&effect), Some(_)) => {
606                left_index += 1;
607                right_index += 1;
608
609                effect
610            }
611            (Some(&effect), None) => {
612                left_index += 1;
613
614                effect
615            }
616            (None, Some(&effect)) => {
617                right_index += 1;
618
619                effect
620            }
621            (None, None) => break,
622        };
623
624        visit(effect);
625    }
626}
627
628fn damage_source_matches(source: DamageSource, flags: DamageFlags) -> bool {
629    match source {
630        DamageSource::Player => !flags.intersects(DamageFlags::PET | DamageFlags::GUARDIAN),
631        DamageSource::Pet => {
632            flags.contains(DamageFlags::PET) && !flags.contains(DamageFlags::GUARDIAN)
633        }
634        DamageSource::Guardian => flags.contains(DamageFlags::GUARDIAN),
635    }
636}
637
638fn target_aoe_damage_mult(
639    program: &crate::state::CombatProgram,
640    query: SpellModifierQuery,
641    target: Option<wowlab_types::sim::EnemyIdx>,
642) -> f64 {
643    if !query.flags.contains(DamageFlags::AOE) {
644        return 1.0;
645    }
646
647    target
648        .and_then(|target| program.enemy_definition(target))
649        .map_or(1.0, |enemy| {
650            1.0 - enemy.creature_aoe_avoidance_pct().clamp(0.0, HUNDRED) / HUNDRED
651        })
652}
653
654pub(crate) fn spell_scoped_mods_for(
655    program: &crate::state::CombatProgram,
656    state: &CombatInstance,
657    source: wowlab_types::sim::ActorId,
658    query: SpellModifierQuery,
659    target: Option<wowlab_types::sim::EnemyIdx>,
660    now: wowlab_types::sim::SimTime,
661) -> SpellScopedMods {
662    if !program.damage.has_spell_modifiers {
663        return SpellScopedMods::default();
664    }
665
666    let mut mods = SpellScopedMods::default();
667    let context = SpellModifierContext {
668        program,
669        state,
670        query,
671        target,
672        now,
673    };
674
675    mods.target_damage_mult *= target_aoe_damage_mult(program, query, target);
676
677    let owner = spell_modifier_owner(source);
678    let effect_action = program.damage.effect_at(query.effect.local).action;
679
680    if let Some(owner) = owner {
681        #[cfg(feature = "rotation-optimizations")]
682        for_each_active_damage_modifier_for_actions(
683            crate::context::ActorView::new(program, state, owner),
684            query.profile.local,
685            effect_action,
686            |stacks, effect, binding| context.apply(stacks, effect, binding, &mut mods),
687        );
688
689        #[cfg(not(feature = "rotation-optimizations"))]
690        if query.profile.local == effect_action {
691            for_each_active_damage_modifier_for_action(
692                crate::context::ActorView::new(program, state, owner),
693                query.profile.local,
694                |stacks, effect, binding| context.apply(stacks, effect, binding, &mut mods),
695            );
696        } else {
697            for_each_planned_actor_effect_damage_binding(
698                crate::context::ActorView::new(program, state, owner),
699                EffectFamily::SPELL_MODIFIER,
700                |stacks, effect, binding| context.apply(stacks, effect, binding, &mut mods),
701            );
702        }
703    }
704
705    if let Some(target) = target {
706        let target_view = crate::context::ActorView::new(
707            program,
708            state,
709            wowlab_types::sim::ActorId::Enemy(target),
710        );
711
712        #[cfg(feature = "rotation-optimizations")]
713        for_each_active_damage_modifier_for_actions(
714            target_view,
715            query.profile.local,
716            effect_action,
717            |stacks, effect, binding| {
718                context.apply(stacks, effect, binding, &mut mods);
719                context.apply_incoming_damage(stacks, effect, &mut mods);
720            },
721        );
722
723        #[cfg(not(feature = "rotation-optimizations"))]
724        if query.profile.local == effect_action {
725            for_each_active_damage_modifier_for_action(
726                target_view,
727                query.profile.local,
728                |stacks, effect, binding| {
729                    context.apply(stacks, effect, binding, &mut mods);
730                    context.apply_incoming_damage(stacks, effect, &mut mods);
731                },
732            );
733        } else {
734            for_each_planned_actor_effect_damage_binding(
735                target_view,
736                EffectFamily::SPELL_MODIFIER,
737                |stacks, effect, binding| {
738                    context.apply(stacks, effect, binding, &mut mods);
739                    context.apply_incoming_damage(stacks, effect, &mut mods);
740                },
741            );
742        }
743    }
744
745    if let Some(owner) = owner {
746        context.apply_driver_spell_modifiers(&mut mods);
747        context.apply_effect_point_damage_modifiers(owner, &mut mods);
748    }
749
750    mods
751}
752
753#[cfg(test)]
754pub(crate) fn spell_scoped_mods_fixture(
755    view: &crate::context::CombatView<'_>,
756    spell_id: u32,
757    flags: DamageFlags,
758) -> SpellScopedMods {
759    let effect = DamageEffectRef::new(spell_id, 0);
760    let profile_action = view
761        .program
762        .damage
763        .diagnostic_bind_action(spell_id)
764        .expect("spell modifier fixture profile belongs to the finalized program");
765    let effect_binding = view
766        .program
767        .damage
768        .diagnostic_bind_effect(effect)
769        .expect("spell modifier fixture effect belongs to the finalized program");
770
771    spell_scoped_mods_for(
772        view.program,
773        view.instance,
774        wowlab_types::sim::ActorId::Player,
775        SpellModifierQuery::new(
776            SpellModifierProfile::new(spell_id, profile_action),
777            SpellModifierEffect::new(effect, effect_binding),
778            flags,
779        ),
780        view.instance.current_target(),
781        wowlab_types::sim::SimTime::ZERO,
782    )
783}