Skip to main content

wowlab_engine_combat/state/
buff_effect.rs

1use wowlab_engine_domain::{dbc::ModifierOperation, rotation::DenseBuffer};
2use wowlab_types::{combat::DamageSchool, constants::HUNDRED};
3
4use super::{
5    AbsorbEffect, ActiveSpellGate, DamageReplication, DamageSource, DamageTransfer,
6    HealthThresholdDirection,
7};
8
9fn stacked_percent_multiplier(percent: f64, stacks: f64) -> f64 {
10    ((1.0 + percent / HUNDRED).powf(stacks) - 1.0) * HUNDRED
11}
12
13pub const MAX_AURA_BUFF_EFFECTS: usize = 16;
14
15/// Returned by [`try_push_effect`] when an aura's fixed effect array is already full.
16#[derive(Clone, Copy, Debug)]
17pub struct BuffEffectsFull;
18
19/// Push `effect` into the first empty slot of a fixed aura-effect array, erroring when full.
20///
21/// # Errors
22///
23/// Returns `BuffEffectsFull` when all effect slots are occupied.
24pub fn try_push_effect(
25    effects: &mut [Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS],
26    effect: BuffEffect,
27) -> Result<(), BuffEffectsFull> {
28    for slot in effects.iter_mut() {
29        if slot.is_none() {
30            *slot = Some(effect);
31
32            return Ok(());
33        }
34    }
35
36    Err(BuffEffectsFull)
37}
38
39/// A single per-stack buff contributed by an active aura.
40#[derive(Clone, Copy, Debug)]
41#[non_exhaustive]
42pub enum BuffEffect {
43    Haste(f64),
44    /// Multiplicative haste source whose per-stack percent points add (aura 193).
45    HasteMult(f64),
46    /// DBC-derived multiplicative haste source retaining its coordinate for content masking.
47    HasteMultFromEffect {
48        percent: f64,
49        source_spell_id: u32,
50        effect_index: u8,
51    },
52    /// Multiplicative spell cast-speed percent per stack (aura 355).
53    SpellCastSpeed(f64),
54    /// Percent points of auto-attack speed per stack (auras 319/342).
55    AttackSpeed(f64),
56    /// Percent points of extra auto-attack damage per stack (dynamic aura 344).
57    AutoAttackDamage(f64),
58    /// Percent points of extra auto-attack damage taken from the caster (aura 343).
59    AutoAttackDamageTaken(f64),
60    /// Additive critical-strike chance for this actor's white auto-attacks (aura 334).
61    AutoAttackCritChance(f64),
62    /// Critical chance while the hostile target is at or above the DBC threshold (aura 183).
63    CritChanceVersusTargetHealth {
64        percent: f64,
65        threshold_pct: f64,
66    },
67    /// Main-hand white swings execute this spell while the owning aura is active (aura 361).
68    AutoAttackReplacement(u32),
69    /// Cast a spell when the affected actor crosses a health threshold (aura 468).
70    HealthThresholdTrigger {
71        threshold_pct: f64,
72        direction: HealthThresholdDirection,
73        spell_id: u32,
74    },
75    /// Percent of the affected target's armor bypassed by this aura's caster (aura 345).
76    ArmorBypassFromCaster(f64),
77    /// Temporary spell-school override filtered through the aura effect's affect list (aura 220).
78    SpellSchoolFromEffect {
79        school: DamageSchool,
80        source_spell_id: u32,
81        effect_index: u8,
82    },
83    /// Wall-clock duration/cadence multiplier from aura 399 or label-scoped aura 470.
84    AuraTimeRateFromEffect {
85        percent: f64,
86        source_spell_id: u32,
87        effect_index: u8,
88    },
89    SpellReflection(wowlab_engine_domain::dbc::SpellSchoolMask),
90    SpellDeflectChance(f64),
91    /// Aura 213 resource-gain multiplier.
92    ResourceGainMult(f64),
93    /// Percent points of extra Rage gain while active (aura 213).
94    RageGainMult(f64),
95    /// Flat regeneration per five seconds for the named DBC power type (aura 85).
96    ResourceRegenPerFiveSeconds(f64, wowlab_types::combat::ResourceType),
97    /// Multiplicative incoming-damage percent filtered by DBC school mask (aura 87).
98    IncomingDamagePercent {
99        percent: f64,
100        schools: wowlab_engine_domain::dbc::SpellSchoolMask,
101    },
102    /// Multiplicative incoming area-damage percent filtered by DBC school mask (aura 229).
103    IncomingAoeDamagePercent {
104        percent: f64,
105        schools: wowlab_engine_domain::dbc::SpellSchoolMask,
106    },
107    /// Flat source-side healing bonus filtered by spell school (auras 115/135).
108    HealingDoneFlat {
109        amount: f64,
110        schools: wowlab_engine_domain::dbc::SpellSchoolMask,
111    },
112    /// Multiplicative source-side healing bonus (aura 136).
113    HealingDonePercent(f64),
114    /// Multiplicative target-side healing received bonus (aura 118).
115    HealingReceivedPercent(f64),
116    /// Healing received from this aura's caster only (aura 283).
117    HealingReceivedFromCasterPercent(f64),
118    /// Multiplicative target-side periodic-healing bonus (aura 259).
119    PeriodicHealingPercent(f64),
120    /// Damage-to-healing percentage (aura 443).
121    LeechPercent(f64),
122    /// Source-side absorb amount multiplier (aura 421).
123    AbsorbDonePercent(f64),
124    /// Target-side absorb amount multiplier (aura 422).
125    AbsorbTakenPercent(f64),
126    /// Mutable damage/healing/overkill absorb initialized when the aura is applied.
127    Absorb(AbsorbEffect),
128    /// Aura 81/300 target-side damage split/share.
129    DamageTransfer(DamageTransfer),
130    /// Complete incoming-damage immunity for the named schools (aura 39).
131    SchoolImmunity(wowlab_engine_domain::dbc::SpellSchoolMask),
132    /// Complete immunity for incoming effects carrying any named mechanic bit (auras 77/147).
133    MechanicImmunity(u64),
134    /// Marks power regeneration as interrupted (aura 94).
135    InterruptRegen,
136    /// Percent of normal regeneration retained while interrupted (aura 134).
137    InterruptedRegenPercent(f64),
138    /// DBC power-type bit mask whose regeneration is prevented (aura 294).
139    PreventResourceRegen(u64),
140    /// Additive resource-cost percent for the named DBC power type (aura 423).
141    ResourceCostPercent(f64, wowlab_types::combat::ResourceType),
142    Crit(f64),
143    /// DBC-derived critical-strike chance retaining its source coordinate for content masking.
144    CritFromEffect {
145        percent: f64,
146        source_spell_id: u32,
147        effect_index: u8,
148    },
149    /// Critical-strike chance whose magnitude is unchanged by aura charge stacks.
150    CritActive(f64),
151    Mastery(f64),
152    Versatility(f64),
153    PrimaryStat(f64),
154    PrimaryStatPercent(f64),
155    /// Aura 467 percentage applied to positive non-create stat gains.
156    BonusStatPercent {
157        percent: f64,
158        stat: wowlab_engine_domain::dbc::StatModifierKind,
159    },
160    /// Aura 366 spell-power replacement sourced from pre-override attack power.
161    OverrideSpellPowerByAttackPowerPercent(f64),
162    /// Aura 404 attack-power replacement sourced from pre-override spell power.
163    OverrideAttackPowerBySpellPowerPercent(f64),
164    AttackPowerPercent(f64),
165    DamageMult(f64),
166    PetDamageMult(f64),
167    DynamicDamageMult(
168        fn(&super::CombatState, &DenseBuffer, Option<wowlab_types::sim::EnemyIdx>) -> f64,
169    ),
170    DynamicDamageMultSpells(
171        fn(
172            &super::CombatState,
173            &DenseBuffer,
174            Option<wowlab_types::sim::EnemyIdx>,
175            wowlab_types::sim::SimTime,
176        ) -> f64,
177        &'static [u32],
178    ),
179    DynamicRegenPercent(fn(&super::CombatState, &DenseBuffer) -> f64),
180    DamageMultSchool(f64, DamageSchool),
181    DamageMultSpells(f64, &'static [u32]),
182    /// Spell-scoped multiplier whose magnitude is unchanged by aura charge stacks.
183    DamageMultSpellsActive(f64, &'static [u32]),
184    /// Aura 249: source multiplier while the target carries an aura tagged with `mechanic_mask`.
185    DamageToTargetAuraMechanicPercent {
186        percent: f64,
187        mechanic_mask: wowlab_engine_domain::dbc::MechanicMask,
188    },
189    /// Aura 255: target multiplier for damage spells tagged with `mechanic_mask`.
190    MechanicDamageTakenPercent {
191        percent: f64,
192        mechanic_mask: wowlab_engine_domain::dbc::MechanicMask,
193    },
194    /// Aura 310: target multiplier for area damage filtered by school.
195    CreatureAoeDamageAvoidance {
196        percent: f64,
197        schools: wowlab_engine_domain::dbc::SpellSchoolMask,
198    },
199    SpellDamageFromEffect {
200        percent: f64,
201        source_spell_id: u32,
202        effect_index: u8,
203        periodic: bool,
204    },
205    /// Active affected-spell modifier for runtime values outside the damage-point family.
206    SpellModifierFromEffect {
207        value: f64,
208        source_spell_id: u32,
209        effect_index: u8,
210        property: wowlab_engine_domain::dbc::ModifierPropertyKind,
211        operation: ModifierOperation,
212    },
213    SpellCritChanceFromEffect {
214        percent: f64,
215        source_spell_id: u32,
216        effect_index: u8,
217    },
218    SpellCritDamageFromEffect {
219        percent: f64,
220        source_spell_id: u32,
221        effect_index: u8,
222    },
223    SpellGateFromEffect {
224        source_spell_id: u32,
225        effect_index: u8,
226        gate: ActiveSpellGate,
227    },
228    /// Active aura-275 stance-mask exception filtered through its DBC affect list.
229    SpellStanceMaskFromEffect {
230        source_spell_id: u32,
231        effect_index: u8,
232    },
233    DamageTakenFromSource(f64, DamageSource),
234    DamageTakenFromSourceSpells {
235        percent: f64,
236        source_spell_id: u32,
237        effect_index: u8,
238        source: DamageSource,
239    },
240    DamageMultSpellsLinear(f64, &'static [u32]),
241    /// Buff-borne flat aura-107 `P_CRIT` (misc 7), e.g. Killing Machine (51124)
242    CritChanceSpells(f64, &'static [u32]),
243    /// Buff-borne aura-108 `P_CRIT_BONUS` (misc 15): Killing Machine e4 (+25%).
244    CritDamageSpells(f64, &'static [u32]),
245    SpellCostPercent(f64, &'static [u32], super::ResourcePool),
246    SpellCostFlat(f64, &'static [u32], super::ResourcePool),
247    SpellCostFromEffect {
248        value: f64,
249        source_spell_id: u32,
250        effect_index: u8,
251        cost_entry: u8,
252        operation: ModifierOperation,
253    },
254    /// Active aura-63 flat adjustment to an affected spell's optional power cost.
255    AdditionalPowerCostFromEffect {
256        value: f64,
257        source_spell_id: u32,
258        effect_index: u8,
259        resource_type: i32,
260    },
261    RechargeRatePercent(f64, &'static [u32]),
262    /// Active DBC aura-286 cooldown-recharge speed filtered through its affect list.
263    CooldownRechargeRateFromEffect {
264        percent: f64,
265        source_spell_id: u32,
266        effect_index: u8,
267    },
268    /// Active DBC aura-148 charge-recharge speed for one charge category.
269    ChargeRechargeRateCategory {
270        percent: f64,
271        category: i32,
272        source_spell_id: u32,
273        effect_index: u8,
274    },
275    /// Extra maximum charges for a charge category while active (aura 411).
276    MaxChargesCategory(f64, i32),
277    /// Casts do not consume charges from this category while active (aura 500).
278    IgnoreChargeCooldownCategory(i32),
279    CooldownDurationPercent {
280        percent: f64,
281        source_spell_id: u32,
282        effect_index: u8,
283    },
284    SpellCastTimeFromEffect {
285        value: f64,
286        source_spell_id: u32,
287        effect_index: u8,
288        operation: ModifierOperation,
289    },
290    SpellGcdFromEffect {
291        value: f64,
292        source_spell_id: u32,
293        effect_index: u8,
294        operation: ModifierOperation,
295    },
296    SpellDurationFromEffect {
297        value: f64,
298        source_spell_id: u32,
299        effect_index: u8,
300        operation: ModifierOperation,
301    },
302    SpellTickTimeFromEffect {
303        value: f64,
304        source_spell_id: u32,
305        effect_index: u8,
306        operation: ModifierOperation,
307    },
308    CastWhileMovingFromEffect {
309        source_spell_id: u32,
310        effect_index: u8,
311    },
312    ResistPushbackFromEffect {
313        value: f64,
314        source_spell_id: u32,
315        effect_index: u8,
316        operation: ModifierOperation,
317    },
318    SpellTargetingFromEffect {
319        value: f64,
320        source_spell_id: u32,
321        effect_index: u8,
322        property: wowlab_engine_domain::dbc::ModifierPropertyKind,
323        operation: ModifierOperation,
324    },
325    /// The magnitude is aura-wide rather than per-stack.
326    CastTimePercent(f64, &'static [u32]),
327    CastTimePercentPerStack(f64, &'static [u32]),
328    /// Like [`BuffEffect::CastTimePercent`], the magnitude does not scale with stacks.
329    GcdPercent(f64, &'static [u32]),
330    DamageReplication(DamageReplication),
331    DamageMultStacking {
332        initial: f64,
333        per_stack: f64,
334    },
335    RegenPercent(f64),
336    MaxResourceFlat(f64),
337}
338
339impl BuffEffect {
340    /// The DBC spell effect this row was derived from, when the shared resolution built it from data.
341    ///
342    /// Content-declared rows carry no DBC provenance and return `None`.
343    #[inline]
344    #[must_use]
345    pub fn dbc_source(&self) -> Option<(u32, u8)> {
346        match self {
347            Self::SpellSchoolFromEffect {
348                source_spell_id,
349                effect_index,
350                ..
351            }
352            | Self::AuraTimeRateFromEffect {
353                source_spell_id,
354                effect_index,
355                ..
356            }
357            | Self::HasteMultFromEffect {
358                source_spell_id,
359                effect_index,
360                ..
361            }
362            | Self::CritFromEffect {
363                source_spell_id,
364                effect_index,
365                ..
366            }
367            | Self::SpellDamageFromEffect {
368                source_spell_id,
369                effect_index,
370                ..
371            }
372            | Self::SpellModifierFromEffect {
373                source_spell_id,
374                effect_index,
375                ..
376            }
377            | Self::SpellCritChanceFromEffect {
378                source_spell_id,
379                effect_index,
380                ..
381            }
382            | Self::SpellCritDamageFromEffect {
383                source_spell_id,
384                effect_index,
385                ..
386            }
387            | Self::SpellGateFromEffect {
388                source_spell_id,
389                effect_index,
390                ..
391            }
392            | Self::SpellStanceMaskFromEffect {
393                source_spell_id,
394                effect_index,
395                ..
396            }
397            | Self::DamageTakenFromSourceSpells {
398                source_spell_id,
399                effect_index,
400                ..
401            }
402            | Self::SpellCostFromEffect {
403                source_spell_id,
404                effect_index,
405                ..
406            }
407            | Self::AdditionalPowerCostFromEffect {
408                source_spell_id,
409                effect_index,
410                ..
411            }
412            | Self::CooldownRechargeRateFromEffect {
413                source_spell_id,
414                effect_index,
415                ..
416            }
417            | Self::ChargeRechargeRateCategory {
418                source_spell_id,
419                effect_index,
420                ..
421            }
422            | Self::CooldownDurationPercent {
423                source_spell_id,
424                effect_index,
425                ..
426            }
427            | Self::SpellCastTimeFromEffect {
428                source_spell_id,
429                effect_index,
430                ..
431            }
432            | Self::SpellGcdFromEffect {
433                source_spell_id,
434                effect_index,
435                ..
436            }
437            | Self::SpellDurationFromEffect {
438                source_spell_id,
439                effect_index,
440                ..
441            }
442            | Self::SpellTickTimeFromEffect {
443                source_spell_id,
444                effect_index,
445                ..
446            }
447            | Self::CastWhileMovingFromEffect {
448                source_spell_id,
449                effect_index,
450                ..
451            }
452            | Self::ResistPushbackFromEffect {
453                source_spell_id,
454                effect_index,
455                ..
456            }
457            | Self::SpellTargetingFromEffect {
458                source_spell_id,
459                effect_index,
460                ..
461            } => Some((*source_spell_id, *effect_index)),
462            _ => None,
463        }
464    }
465
466    /// Resolve the effect at the active aura's stack count.
467    ///
468    /// # Panics
469    ///
470    /// Panics if `unscaled_dbc_effect` omits an effect variant classified as unscaled, indicating an internal dispatch bug.
471    // docref:start combat-formulas-buff-effect-scaled
472    #[inline]
473    // #t(fn: rust_cyclomatic_complexity) flat per-variant dispatch; grows with BuffEffect variants
474    // #t(fn: rust_max_fn_lines) flat exhaustive variant dispatch stays easier to audit as one match
475    #[must_use]
476    pub fn scaled(self, stacks: f64) -> Self {
477        if let Some(scaled) = self.unscaled_dbc_effect() {
478            return scaled;
479        }
480        match self {
481            Self::Haste(v) => Self::Haste(v * stacks),
482            Self::HasteMult(v) => Self::HasteMult(v * stacks),
483            Self::HasteMultFromEffect {
484                percent,
485                source_spell_id,
486                effect_index,
487            } => Self::HasteMultFromEffect {
488                percent: percent * stacks,
489                source_spell_id,
490                effect_index,
491            },
492            Self::SpellCastSpeed(v) => Self::SpellCastSpeed(stacked_percent_multiplier(v, stacks)),
493            Self::AttackSpeed(v) => Self::AttackSpeed(v * stacks),
494            Self::AutoAttackDamage(v) => Self::AutoAttackDamage(v * stacks),
495            Self::AutoAttackDamageTaken(v) => Self::AutoAttackDamageTaken(v * stacks),
496            Self::AutoAttackCritChance(v) => Self::AutoAttackCritChance(v * stacks),
497            Self::CritChanceVersusTargetHealth {
498                percent,
499                threshold_pct,
500            } => Self::CritChanceVersusTargetHealth {
501                percent: percent * stacks,
502                threshold_pct,
503            },
504            Self::ArmorBypassFromCaster(v) => Self::ArmorBypassFromCaster(v * stacks),
505            Self::ResourceGainMult(v) => Self::ResourceGainMult(v * stacks),
506            Self::RageGainMult(v) => Self::RageGainMult(v * stacks),
507            Self::ResourceRegenPerFiveSeconds(value, resource_type) => {
508                Self::ResourceRegenPerFiveSeconds(value * stacks, resource_type)
509            }
510            Self::IncomingDamagePercent { percent, schools } => Self::IncomingDamagePercent {
511                percent: percent * stacks,
512                schools,
513            },
514            Self::IncomingAoeDamagePercent { percent, schools } => Self::IncomingAoeDamagePercent {
515                percent: percent * stacks,
516                schools,
517            },
518            Self::HealingDoneFlat { amount, schools } => Self::HealingDoneFlat {
519                amount: amount * stacks,
520                schools,
521            },
522            Self::HealingDonePercent(v) => Self::HealingDonePercent(v * stacks),
523            Self::HealingReceivedPercent(v) => Self::HealingReceivedPercent(v * stacks),
524            Self::HealingReceivedFromCasterPercent(v) => {
525                Self::HealingReceivedFromCasterPercent(v * stacks)
526            }
527            Self::PeriodicHealingPercent(v) => Self::PeriodicHealingPercent(v * stacks),
528            Self::LeechPercent(v) => Self::LeechPercent(v * stacks),
529            Self::AbsorbDonePercent(v) => Self::AbsorbDonePercent(v * stacks),
530            Self::AbsorbTakenPercent(v) => Self::AbsorbTakenPercent(v * stacks),
531            Self::Absorb(effect) => Self::Absorb(AbsorbEffect {
532                amount: effect.amount * stacks,
533                ..effect
534            }),
535            Self::DamageTransfer(transfer) => Self::DamageTransfer(transfer),
536            Self::SchoolImmunity(schools) => Self::SchoolImmunity(schools),
537            Self::MechanicImmunity(mask) => Self::MechanicImmunity(mask),
538            Self::InterruptRegen => Self::InterruptRegen,
539            Self::InterruptedRegenPercent(percent) => {
540                Self::InterruptedRegenPercent(percent * stacks)
541            }
542            Self::PreventResourceRegen(mask) => Self::PreventResourceRegen(mask),
543            Self::ResourceCostPercent(value, resource_type) => {
544                Self::ResourceCostPercent(value * stacks, resource_type)
545            }
546            Self::Crit(v) => Self::Crit(v * stacks),
547            Self::CritFromEffect {
548                percent,
549                source_spell_id,
550                effect_index,
551            } => Self::CritFromEffect {
552                percent: percent * stacks,
553                source_spell_id,
554                effect_index,
555            },
556            Self::CritActive(v) => Self::CritActive(v),
557            Self::Mastery(v) => Self::Mastery(v * stacks),
558            Self::Versatility(v) => Self::Versatility(v * stacks),
559            Self::PrimaryStat(v) => Self::PrimaryStat(v * stacks),
560            Self::PrimaryStatPercent(v) => Self::PrimaryStatPercent(v * stacks),
561            Self::BonusStatPercent { percent, stat } => Self::BonusStatPercent {
562                percent: percent * stacks,
563                stat,
564            },
565            Self::OverrideSpellPowerByAttackPowerPercent(v) => {
566                Self::OverrideSpellPowerByAttackPowerPercent(v * stacks)
567            }
568            Self::OverrideAttackPowerBySpellPowerPercent(v) => {
569                Self::OverrideAttackPowerBySpellPowerPercent(v * stacks)
570            }
571            Self::AttackPowerPercent(v) => Self::AttackPowerPercent(v * stacks),
572            Self::DamageMult(v) => Self::DamageMult(v.powf(stacks)),
573            Self::PetDamageMult(v) => Self::PetDamageMult(v.powf(stacks)),
574            Self::DynamicDamageMult(resolve) => Self::DynamicDamageMult(resolve),
575            Self::DynamicDamageMultSpells(r, s) => Self::DynamicDamageMultSpells(r, s),
576            Self::DynamicRegenPercent(resolve) => Self::DynamicRegenPercent(resolve),
577            Self::DamageMultSchool(v, school) => Self::DamageMultSchool(v.powf(stacks), school),
578            Self::DamageMultSpells(v, spells) => Self::DamageMultSpells(v.powf(stacks), spells),
579            Self::DamageMultSpellsActive(v, spells) => Self::DamageMultSpellsActive(v, spells),
580            Self::DamageToTargetAuraMechanicPercent {
581                percent,
582                mechanic_mask,
583            } => Self::DamageToTargetAuraMechanicPercent {
584                percent: percent * stacks,
585                mechanic_mask,
586            },
587            Self::MechanicDamageTakenPercent {
588                percent,
589                mechanic_mask,
590            } => Self::MechanicDamageTakenPercent {
591                percent: percent * stacks,
592                mechanic_mask,
593            },
594            Self::CreatureAoeDamageAvoidance { percent, schools } => {
595                Self::CreatureAoeDamageAvoidance {
596                    percent: percent * stacks,
597                    schools,
598                }
599            }
600            Self::DamageTakenFromSource(v, source) => {
601                Self::DamageTakenFromSource(v * stacks, source)
602            }
603            Self::DamageMultSpellsLinear(v, spells) => {
604                Self::DamageMultSpellsLinear(v * stacks, spells)
605            }
606            Self::CritChanceSpells(v, spells) => Self::CritChanceSpells(v * stacks, spells),
607            Self::CritDamageSpells(v, spells) => Self::CritDamageSpells(v * stacks, spells),
608            Self::SpellCostPercent(v, spells, pool) => {
609                Self::SpellCostPercent(v * stacks, spells, pool)
610            }
611            Self::SpellCostFlat(v, spells, pool) => Self::SpellCostFlat(v * stacks, spells, pool),
612            Self::SpellCostFromEffect {
613                value,
614                source_spell_id,
615                effect_index,
616                cost_entry,
617                operation,
618            } => Self::SpellCostFromEffect {
619                value,
620                source_spell_id,
621                effect_index,
622                cost_entry,
623                operation,
624            },
625            Self::AdditionalPowerCostFromEffect {
626                value,
627                source_spell_id,
628                effect_index,
629                resource_type,
630            } => Self::AdditionalPowerCostFromEffect {
631                value,
632                source_spell_id,
633                effect_index,
634                resource_type,
635            },
636            Self::RechargeRatePercent(v, spells) => Self::RechargeRatePercent(v * stacks, spells),
637            Self::CooldownRechargeRateFromEffect {
638                percent,
639                source_spell_id,
640                effect_index,
641            } => Self::CooldownRechargeRateFromEffect {
642                percent,
643                source_spell_id,
644                effect_index,
645            },
646            Self::ChargeRechargeRateCategory {
647                percent,
648                category,
649                source_spell_id,
650                effect_index,
651            } => Self::ChargeRechargeRateCategory {
652                percent,
653                category,
654                source_spell_id,
655                effect_index,
656            },
657            Self::MaxChargesCategory(v, category) => Self::MaxChargesCategory(v * stacks, category),
658            Self::IgnoreChargeCooldownCategory(category) => {
659                Self::IgnoreChargeCooldownCategory(category)
660            }
661            Self::CooldownDurationPercent {
662                percent,
663                source_spell_id,
664                effect_index,
665            } => Self::CooldownDurationPercent {
666                percent,
667                source_spell_id,
668                effect_index,
669            },
670            Self::SpellCastTimeFromEffect { .. }
671            | Self::SpellGcdFromEffect { .. }
672            | Self::SpellDurationFromEffect { .. }
673            | Self::SpellTickTimeFromEffect { .. }
674            | Self::SpellModifierFromEffect { .. }
675            | Self::SpellTargetingFromEffect { .. }
676            | Self::SpellSchoolFromEffect { .. }
677            | Self::AuraTimeRateFromEffect { .. }
678            | Self::SpellReflection(..)
679            | Self::AutoAttackReplacement(..)
680            | Self::HealthThresholdTrigger { .. }
681            | Self::SpellDamageFromEffect { .. }
682            | Self::SpellCritChanceFromEffect { .. }
683            | Self::SpellCritDamageFromEffect { .. }
684            | Self::DamageTakenFromSourceSpells { .. }
685            | Self::SpellGateFromEffect { .. }
686            | Self::SpellStanceMaskFromEffect { .. }
687            | Self::CastWhileMovingFromEffect { .. }
688            | Self::ResistPushbackFromEffect { .. } => unreachable!("handled above"),
689            Self::CastTimePercent(v, spells) => Self::CastTimePercent(v, spells),
690            Self::CastTimePercentPerStack(v, spells) => {
691                Self::CastTimePercentPerStack(v * stacks, spells)
692            }
693            Self::GcdPercent(v, spells) => Self::GcdPercent(v, spells),
694            Self::DamageReplication(replication) => Self::DamageReplication(replication),
695            Self::DamageMultStacking { initial, per_stack } => {
696                Self::DamageMult(1.0 + initial + per_stack * (stacks - 1.0))
697            }
698            Self::RegenPercent(v) => Self::RegenPercent(v * stacks),
699            Self::MaxResourceFlat(v) => Self::MaxResourceFlat(v * stacks),
700            Self::SpellDeflectChance(v) => Self::SpellDeflectChance(v * stacks),
701        }
702    }
703    // #t(fn: rust_cyclomatic_complexity) exhaustive per-variant projection preserves which DBC-derived effects can be unscaled.
704    fn unscaled_dbc_effect(self) -> Option<Self> {
705        let scaled = match self {
706            Self::SpellCastTimeFromEffect {
707                value,
708                source_spell_id,
709                effect_index,
710                operation,
711            } => Self::SpellCastTimeFromEffect {
712                value,
713                source_spell_id,
714                effect_index,
715                operation,
716            },
717            Self::SpellGcdFromEffect {
718                value,
719                source_spell_id,
720                effect_index,
721                operation,
722            } => Self::SpellGcdFromEffect {
723                value,
724                source_spell_id,
725                effect_index,
726                operation,
727            },
728            Self::SpellDurationFromEffect {
729                value,
730                source_spell_id,
731                effect_index,
732                operation,
733            } => Self::SpellDurationFromEffect {
734                value,
735                source_spell_id,
736                effect_index,
737                operation,
738            },
739            Self::SpellTickTimeFromEffect {
740                value,
741                source_spell_id,
742                effect_index,
743                operation,
744            } => Self::SpellTickTimeFromEffect {
745                value,
746                source_spell_id,
747                effect_index,
748                operation,
749            },
750            Self::CastWhileMovingFromEffect {
751                source_spell_id,
752                effect_index,
753            } => Self::CastWhileMovingFromEffect {
754                source_spell_id,
755                effect_index,
756            },
757            Self::ResistPushbackFromEffect {
758                value,
759                source_spell_id,
760                effect_index,
761                operation,
762            } => Self::ResistPushbackFromEffect {
763                value,
764                source_spell_id,
765                effect_index,
766                operation,
767            },
768            Self::SpellGateFromEffect {
769                source_spell_id,
770                effect_index,
771                gate,
772            } => Self::SpellGateFromEffect {
773                source_spell_id,
774                effect_index,
775                gate,
776            },
777            Self::SpellStanceMaskFromEffect {
778                source_spell_id,
779                effect_index,
780            } => Self::SpellStanceMaskFromEffect {
781                source_spell_id,
782                effect_index,
783            },
784            Self::SpellModifierFromEffect {
785                value,
786                source_spell_id,
787                effect_index,
788                property,
789                operation,
790            } => Self::SpellModifierFromEffect {
791                value,
792                source_spell_id,
793                effect_index,
794                property,
795                operation,
796            },
797            Self::SpellTargetingFromEffect {
798                value,
799                source_spell_id,
800                effect_index,
801                property,
802                operation,
803            } => Self::SpellTargetingFromEffect {
804                value,
805                source_spell_id,
806                effect_index,
807                property,
808                operation,
809            },
810            Self::SpellSchoolFromEffect {
811                school,
812                source_spell_id,
813                effect_index,
814            } => Self::SpellSchoolFromEffect {
815                school,
816                source_spell_id,
817                effect_index,
818            },
819            Self::AuraTimeRateFromEffect {
820                percent,
821                source_spell_id,
822                effect_index,
823            } => Self::AuraTimeRateFromEffect {
824                percent,
825                source_spell_id,
826                effect_index,
827            },
828            Self::SpellReflection(schools) => Self::SpellReflection(schools),
829            Self::AutoAttackReplacement(spell_id) => Self::AutoAttackReplacement(spell_id),
830            Self::HealthThresholdTrigger {
831                threshold_pct,
832                direction,
833                spell_id,
834            } => Self::HealthThresholdTrigger {
835                threshold_pct,
836                direction,
837                spell_id,
838            },
839            effect @ (Self::SpellDamageFromEffect { .. }
840            | Self::SpellCritChanceFromEffect { .. }
841            | Self::SpellCritDamageFromEffect { .. }
842            | Self::DamageTakenFromSourceSpells { .. }) => effect,
843            _ => return None,
844        };
845        Some(scaled)
846    }
847    // docref:end combat-formulas-buff-effect-scaled
848}
849
850/// Number of [`DamageSchool`] variants; sized for indexing `school_damage_mult`.
851pub(crate) const DAMAGE_SCHOOL_COUNT: usize = DamageSchool::COUNT;
852const BONUS_STAT_COUNT: usize = 4;
853
854/// Accumulated buff totals from all active auras for one frame.
855#[derive(Clone, Copy, Debug)]
856pub struct BuffTotals {
857    pub haste_pct: f64,
858    pub haste_mult: f64,
859    pub spell_cast_speed_mult: f64,
860    pub attack_speed_mult: f64,
861    pub auto_attack_damage_mult: f64,
862    pub auto_attack_crit_pct: f64,
863    pub resource_gain_mult: f64,
864    pub rage_gain_mult: f64,
865    pub crit_pct: f64,
866    pub mastery_pct: f64,
867    pub vers_pct: f64,
868    pub primary_stat: f64,
869    pub primary_stat_pct: f64,
870    pub bonus_primary_stat_pct: f64,
871    pub bonus_all_stat_pct: f64,
872    pub bonus_stat_pct: [f64; BONUS_STAT_COUNT],
873    pub override_spell_power_by_attack_power_pct: Option<f64>,
874    pub override_attack_power_by_spell_power_pct: Option<f64>,
875    pub attack_power_pct: f64,
876    pub damage_mult: f64,
877    pub pet_damage_mult: f64,
878    pub school_damage_mult: [f64; DAMAGE_SCHOOL_COUNT],
879    pub regen_pct: f64,
880    pub max_resource_flat: f64,
881}
882
883impl BuffTotals {
884    // #t(fn: rust_cyclomatic_complexity) flat per-variant dispatch; grows with BuffEffect variants
885    #[expect(
886        clippy::match_same_arms,
887        reason = "separate no-op groups document which subsystem consumes each effect category"
888    )]
889    pub fn add(&mut self, effect: BuffEffect) {
890        match effect {
891            BuffEffect::Haste(v) => self.haste_pct += v,
892            BuffEffect::HasteMult(v) => self.haste_mult *= 1.0 + v / HUNDRED,
893            BuffEffect::HasteMultFromEffect { percent, .. } => {
894                self.haste_mult *= 1.0 + percent / HUNDRED;
895            }
896            BuffEffect::SpellCastSpeed(v) => {
897                self.spell_cast_speed_mult *= 1.0 + v / HUNDRED;
898            }
899            BuffEffect::AttackSpeed(v) => self.attack_speed_mult *= 1.0 + v / HUNDRED,
900            BuffEffect::AutoAttackDamage(v) | BuffEffect::AutoAttackDamageTaken(v) => {
901                self.auto_attack_damage_mult *= 1.0 + v / HUNDRED;
902            }
903            BuffEffect::AutoAttackCritChance(v) => self.auto_attack_crit_pct += v,
904            BuffEffect::CritChanceVersusTargetHealth { .. } => {}
905            BuffEffect::AutoAttackReplacement(..) => {}
906            BuffEffect::HealthThresholdTrigger { .. } => {}
907            BuffEffect::ArmorBypassFromCaster(..) => {}
908            BuffEffect::SpellSchoolFromEffect { .. } => {}
909            BuffEffect::AuraTimeRateFromEffect { .. } => {}
910            BuffEffect::SpellReflection(..) | BuffEffect::SpellDeflectChance(..) => {}
911            BuffEffect::ResourceGainMult(v) => self.resource_gain_mult *= 1.0 + v / HUNDRED,
912            BuffEffect::RageGainMult(v) => self.rage_gain_mult *= 1.0 + v / HUNDRED,
913            BuffEffect::Crit(v) => self.crit_pct += v,
914            BuffEffect::CritFromEffect { percent, .. } => self.crit_pct += percent,
915            BuffEffect::CritActive(v) => self.crit_pct += v,
916            BuffEffect::Mastery(v) => self.mastery_pct += v,
917            BuffEffect::Versatility(v) => self.vers_pct += v,
918            BuffEffect::PrimaryStat(v) => self.primary_stat += v,
919            BuffEffect::PrimaryStatPercent(v) => self.primary_stat_pct += v,
920            BuffEffect::BonusStatPercent { percent, stat } => match stat {
921                wowlab_engine_domain::dbc::StatModifierKind::Primary => {
922                    self.bonus_primary_stat_pct += percent;
923                }
924                wowlab_engine_domain::dbc::StatModifierKind::All => {
925                    self.bonus_all_stat_pct += percent;
926                }
927                _ => {
928                    if let Some(index) = stat.stat_index()
929                        && let Some(total) = self.bonus_stat_pct.get_mut(index)
930                    {
931                        *total += percent;
932                    }
933                }
934            },
935            BuffEffect::OverrideSpellPowerByAttackPowerPercent(v) => {
936                self.override_spell_power_by_attack_power_pct = Some(v);
937            }
938            BuffEffect::OverrideAttackPowerBySpellPowerPercent(v) => {
939                self.override_attack_power_by_spell_power_pct = Some(v);
940            }
941            BuffEffect::AttackPowerPercent(v) => self.attack_power_pct += v,
942            BuffEffect::DamageMult(v) => self.damage_mult *= v,
943            BuffEffect::PetDamageMult(v) => self.pet_damage_mult *= v,
944            // Requires combat state and is handled by `accumulate_buffs`.
945            BuffEffect::DynamicDamageMult(..)
946            | BuffEffect::DynamicDamageMultSpells(..)
947            | BuffEffect::DynamicRegenPercent(..) => {}
948            BuffEffect::DamageMultSchool(v, school) => {
949                // #t(rust_unchecked_indexing) school as u8 is bounded by DAMAGE_SCHOOL_COUNT
950                self.school_damage_mult[school as usize] *= v;
951            }
952            // Applied per damage event in `spell_scoped_mods`, not in spell-agnostic totals.
953            BuffEffect::DamageMultSpells(..)
954            | BuffEffect::DamageMultSpellsActive(..)
955            | BuffEffect::SpellDamageFromEffect { .. }
956            | BuffEffect::SpellModifierFromEffect { .. }
957            | BuffEffect::SpellCritChanceFromEffect { .. }
958            | BuffEffect::SpellCritDamageFromEffect { .. }
959            | BuffEffect::SpellGateFromEffect { .. }
960            | BuffEffect::SpellStanceMaskFromEffect { .. }
961            | BuffEffect::DamageTakenFromSource(..)
962            | BuffEffect::DamageTakenFromSourceSpells { .. }
963            | BuffEffect::DamageToTargetAuraMechanicPercent { .. }
964            | BuffEffect::MechanicDamageTakenPercent { .. }
965            | BuffEffect::CreatureAoeDamageAvoidance { .. }
966            | BuffEffect::DamageMultSpellsLinear(..)
967            | BuffEffect::CritChanceSpells(..)
968            | BuffEffect::CritDamageSpells(..) => {}
969            // Applied by `effective_spell_cost`, not spell-agnostic stat totals.
970            BuffEffect::SpellCostPercent(..)
971            | BuffEffect::SpellCostFlat(..)
972            | BuffEffect::SpellCostFromEffect { .. }
973            | BuffEffect::ResourceCostPercent(..)
974            | BuffEffect::AdditionalPowerCostFromEffect { .. }
975            | BuffEffect::RechargeRatePercent(..)
976            | BuffEffect::CooldownRechargeRateFromEffect { .. }
977            | BuffEffect::ChargeRechargeRateCategory { .. }
978            | BuffEffect::MaxChargesCategory(..)
979            | BuffEffect::IgnoreChargeCooldownCategory(..)
980            | BuffEffect::CooldownDurationPercent { .. }
981            | BuffEffect::SpellCastTimeFromEffect { .. }
982            | BuffEffect::SpellGcdFromEffect { .. }
983            | BuffEffect::SpellDurationFromEffect { .. }
984            | BuffEffect::SpellTickTimeFromEffect { .. }
985            | BuffEffect::CastWhileMovingFromEffect { .. }
986            | BuffEffect::ResistPushbackFromEffect { .. }
987            | BuffEffect::SpellTargetingFromEffect { .. }
988            | BuffEffect::CastTimePercent(..)
989            | BuffEffect::CastTimePercentPerStack(..)
990            | BuffEffect::GcdPercent(..) => {}
991            // Applied per damage event in `deal_damage`.
992            BuffEffect::DamageReplication(..) => {}
993            // `scaled()` converts this to `DamageMult` before `add` is reached.
994            BuffEffect::DamageMultStacking { .. } => {}
995            BuffEffect::RegenPercent(v) => self.regen_pct += v,
996            BuffEffect::ResourceRegenPerFiveSeconds(..) => {}
997            BuffEffect::IncomingDamagePercent { .. }
998            | BuffEffect::IncomingAoeDamagePercent { .. }
999            | BuffEffect::SchoolImmunity(..)
1000            | BuffEffect::MechanicImmunity(..)
1001            | BuffEffect::InterruptRegen
1002            | BuffEffect::InterruptedRegenPercent(..)
1003            | BuffEffect::PreventResourceRegen(..)
1004            | BuffEffect::HealingDoneFlat { .. }
1005            | BuffEffect::HealingDonePercent(..)
1006            | BuffEffect::HealingReceivedPercent(..)
1007            | BuffEffect::HealingReceivedFromCasterPercent(..)
1008            | BuffEffect::PeriodicHealingPercent(..)
1009            | BuffEffect::LeechPercent(..)
1010            | BuffEffect::AbsorbDonePercent(..)
1011            | BuffEffect::AbsorbTakenPercent(..)
1012            | BuffEffect::Absorb(..)
1013            | BuffEffect::DamageTransfer(..) => {}
1014            BuffEffect::MaxResourceFlat(v) => self.max_resource_flat += v,
1015        }
1016    }
1017}
1018
1019impl Default for BuffTotals {
1020    fn default() -> Self {
1021        Self {
1022            haste_pct: 0.0,
1023            haste_mult: 1.0,
1024            spell_cast_speed_mult: 1.0,
1025            attack_speed_mult: 1.0,
1026            auto_attack_damage_mult: 1.0,
1027            auto_attack_crit_pct: 0.0,
1028            resource_gain_mult: 1.0,
1029            rage_gain_mult: 1.0,
1030            crit_pct: 0.0,
1031            mastery_pct: 0.0,
1032            vers_pct: 0.0,
1033            primary_stat: 0.0,
1034            primary_stat_pct: 0.0,
1035            bonus_primary_stat_pct: 0.0,
1036            bonus_all_stat_pct: 0.0,
1037            bonus_stat_pct: [0.0; BONUS_STAT_COUNT],
1038            override_spell_power_by_attack_power_pct: None,
1039            override_attack_power_by_spell_power_pct: None,
1040            attack_power_pct: 0.0,
1041            damage_mult: 1.0,
1042            pet_damage_mult: 1.0,
1043            school_damage_mult: [1.0; DAMAGE_SCHOOL_COUNT],
1044            regen_pct: 0.0,
1045            max_resource_flat: 0.0,
1046        }
1047    }
1048}
1049
1050#[cfg(test)]
1051#[path = "buff_effect/tests.rs"]
1052mod tests;