Skip to main content

wowlab_engine_combat/builder/
aura_builder.rs

1use wowlab_engine_domain::dbc::{ResolvedGameDataSemanticExt as _, SpellAuraInterruptFlags};
2use wowlab_engine_gamedata::ResolvedGameData;
3use wowlab_types::constants::HUNDRED;
4
5use super::{
6    BuilderError,
7    def::{AuraOn, BuilderSpellEffect, PeriodicDef},
8};
9use crate::state::{
10    AuraTickBehavior, BuffEffect, CastHookFn, DynamicTickAction, LocalAuraIdx,
11    MAX_AURA_APPLICATION_EXCLUSIONS, MAX_AURA_BUFF_EFFECTS, SpellGroup, SpellGroupRule,
12};
13
14mod effects;
15mod finalize;
16mod from_data;
17mod health_threshold;
18mod input;
19mod numbers;
20mod periodic;
21use effects::{
22    apply_active_stat_effect, apply_actor_scope_from_data, apply_automatic_periodic_effect,
23    apply_critical_modifier_effect, apply_effect_points_modifier, apply_forwarded_value,
24    apply_non_passive_effect, apply_spell_modifier_effect, apply_timing_modifier_effect,
25};
26pub use input::PeriodicDamageDataInput;
27
28pub(crate) fn apply_forwarded_effect_value(
29    aura_id: u32,
30    effects: &mut [Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS],
31    data: &ResolvedGameData,
32    value: f64,
33) -> usize {
34    let spell_id = wowlab_types::sim::SpellIdx::from_raw(aura_id);
35    let mut patched = 0;
36
37    for effect_index in 1..=data.max_effect_index(spell_id) {
38        if data.base_points(spell_id, effect_index).abs() > f64::EPSILON {
39            continue;
40        }
41
42        let subtype = data.effect_aura(spell_id, effect_index);
43
44        if effects
45            .iter_mut()
46            .flatten()
47            .any(|effect| apply_forwarded_value(effect, aura_id, effect_index, subtype, value))
48        {
49            patched += 1;
50        }
51    }
52
53    patched
54}
55
56#[derive(Debug)]
57#[must_use]
58#[expect(
59    clippy::struct_excessive_bools,
60    reason = "aura behavior flags are independent DBC coordinates, not mutually exclusive states"
61)]
62pub struct AuraDefinitionDraft {
63    pub(crate) name: String,
64    pub(crate) aura_id: u32,
65    pub(crate) on: AuraOn,
66    pub(crate) propagates_to_pet: bool,
67    pub(crate) duration_ms: u32,
68    pub(crate) doses: u8,
69    pub(crate) max_stacks: u8,
70    pub(crate) pandemic: bool,
71    pub(crate) refresh_behavior: wowlab_types::data::RefreshBehavior,
72    pub(crate) duration_hasted: bool,
73    pub(crate) shapeshift_form: i32,
74    pub(crate) shapeshift_form_flags: i32,
75    pub(crate) shapeshift_combat_round_time_ms: u32,
76    pub(crate) periodic: Option<PeriodicDef>,
77    pub(crate) effects: [Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS],
78    pub(crate) spell_group: Option<SpellGroup>,
79    pub(crate) granted_aura_state: Option<crate::state::AuraState>,
80    pub(crate) crowd_control: Option<wowlab_engine_domain::dbc::CrowdControlSemantic>,
81    pub(crate) is_snapshot: bool,
82    pub(crate) dynamic_tick_action: DynamicTickAction,
83    pub(crate) apply_at_max_stacks: bool,
84    pub(crate) reverse: bool,
85    pub(crate) freeze_stacks: bool,
86    pub(crate) tick_behavior: AuraTickBehavior,
87    pub(crate) tick_stack_change: u8,
88    pub(crate) async_stacks: bool,
89    pub(crate) periodic_damage_scales_with_stacks: bool,
90    pub(crate) interrupt_flags: SpellAuraInterruptFlags,
91    pub(crate) reapply_on_expire: bool,
92    pub(crate) application_excluded_auras: [u32; MAX_AURA_APPLICATION_EXCLUSIONS],
93    pub(crate) application_followup_aura_id: u32,
94    pub(crate) apply_effects: Vec<BuilderSpellEffect>,
95    pub(crate) apply_profile_spell_id: u32,
96    pub(crate) on_consume: Vec<BuilderSpellEffect>,
97    pub(crate) tick_effects: Vec<BuilderSpellEffect>,
98    pub(crate) tick_profile_spell_id: u32,
99    pub(crate) expire_trigger_spell_id: u32,
100    pub(crate) expire_trigger_from_caster: bool,
101    pub(crate) on_expire: Option<CastHookFn>,
102    pub(crate) on_tick: Option<CastHookFn>,
103}
104
105#[expect(
106    clippy::missing_errors_doc,
107    reason = "fluent aura methods uniformly return BuilderError for invalid data or fixed-capacity effect overflow"
108)]
109impl AuraDefinitionDraft {
110    pub fn new(name: &str, id: u32) -> Self {
111        Self {
112            name: name.to_string(),
113            aura_id: id,
114            on: AuraOn::Player,
115            propagates_to_pet: false,
116            duration_ms: 0,
117            doses: 1,
118            max_stacks: 1,
119            pandemic: true,
120            refresh_behavior: wowlab_types::data::RefreshBehavior::Pandemic,
121            duration_hasted: false,
122            shapeshift_form: 0,
123            shapeshift_form_flags: 0,
124            shapeshift_combat_round_time_ms: 0,
125            periodic: None,
126            effects: [None; MAX_AURA_BUFF_EFFECTS],
127            spell_group: None,
128            granted_aura_state: None,
129            crowd_control: None,
130            is_snapshot: false,
131            dynamic_tick_action: DynamicTickAction::None,
132            apply_at_max_stacks: false,
133            reverse: false,
134            freeze_stacks: false,
135            tick_behavior: AuraTickBehavior::None,
136            tick_stack_change: 1,
137            async_stacks: false,
138            periodic_damage_scales_with_stacks: false,
139            interrupt_flags: SpellAuraInterruptFlags::empty(),
140            reapply_on_expire: false,
141            application_excluded_auras: [0; MAX_AURA_APPLICATION_EXCLUSIONS],
142            application_followup_aura_id: 0,
143            apply_effects: Vec::new(),
144            apply_profile_spell_id: 0,
145            on_consume: Vec::new(),
146            tick_effects: Vec::new(),
147            tick_profile_spell_id: 0,
148            expire_trigger_spell_id: 0,
149            expire_trigger_from_caster: false,
150            on_expire: None,
151            on_tick: None,
152        }
153    }
154
155    pub fn apply_base_from_data(
156        self,
157        data: &ResolvedGameData,
158        spell_id: u32,
159    ) -> Result<Self, BuilderError> {
160        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
161        let duration_ms = data.aura_duration_ms(idx).ok_or(
162            crate::builder::BuilderErrorKind::MissingSpellData {
163                spell_id,
164                field: "aura_duration_ms",
165            },
166        )?;
167        let folded_passive = data.is_folded_passive(idx);
168
169        if folded_passive {
170            tracing::debug!(
171                spell_id,
172                "statically folded passive registered as aura; skipping modifier semantics"
173            );
174        }
175
176        let mut aura = self
177            .duration_ms(duration_ms)
178            .doses(data.aura_doses(idx).unwrap_or(1))
179            .max_stacks(data.aura_max_stacks(idx).unwrap_or(1).max(1))
180            .apply_duration_behavior_from_data(data, spell_id)?;
181
182        if aura.granted_aura_state.is_none() {
183            aura.granted_aura_state = data.granted_aura_state(idx);
184        }
185
186        aura.interrupt_flags =
187            SpellAuraInterruptFlags::from_dbc(data.aura_interrupt_flags(idx).unwrap_or_default());
188
189        for effect_index in 1..=data.max_effect_index(idx) {
190            aura = from_data::apply_base_effect(
191                aura,
192                wowlab_engine_domain::dbc::EffectLookup::new(
193                    data,
194                    wowlab_types::sim::EffectRef::new(idx, effect_index),
195                ),
196                folded_passive,
197            )?;
198        }
199
200        // Each asynchronous periodic stack represents an independently applied copy of the
201        // effect. The shared tick chain reports them together, so its payload must scale by the
202        // number of copies that are still alive.
203
204        if aura.async_stacks && aura.periodic.is_some() {
205            aura.periodic_damage_scales_with_stacks = true;
206        }
207
208        Ok(aura)
209    }
210
211    /// Inherit periodic data while preserving authored duration and non-periodic effects.
212    pub fn apply_periodic_effect_from_data(
213        mut self,
214        data: &ResolvedGameData,
215        spell_id: u32,
216    ) -> Result<Self, BuilderError> {
217        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
218
219        for effect_index in 1..=data.max_effect_index(idx) {
220            self = apply_automatic_periodic_effect(
221                self,
222                wowlab_engine_domain::dbc::EffectLookup::new(
223                    data,
224                    wowlab_types::sim::EffectRef::new(idx, effect_index),
225                ),
226            )?;
227        }
228
229        if self.async_stacks && self.periodic.is_some() {
230            self.periodic_damage_scales_with_stacks = true;
231        }
232
233        Ok(self)
234    }
235
236    /// Apply cast-time, global-cooldown, duration, and tick-time modifiers from `spell_id`.
237    pub fn apply_timing_modifiers_from_data(
238        mut self,
239        data: &ResolvedGameData,
240        spell_id: u32,
241    ) -> Result<Self, BuilderError> {
242        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
243
244        for effect_index in 1..=data.max_effect_index(idx) {
245            self = apply_timing_modifier_effect(
246                self,
247                wowlab_engine_domain::dbc::EffectLookup::new(
248                    data,
249                    wowlab_types::sim::EffectRef::new(idx, effect_index),
250                ),
251            )?;
252        }
253
254        Ok(self)
255    }
256
257    pub fn on_player(mut self) -> Self {
258        self.on = AuraOn::Player;
259
260        self
261    }
262
263    pub fn on_target(mut self) -> Self {
264        self.on = AuraOn::Target;
265
266        self
267    }
268
269    pub fn on_pet(mut self) -> Self {
270        self.on = AuraOn::Pet;
271
272        self
273    }
274
275    /// Installs the per-hit script strategy for aura-316 effects.
276    pub fn overkill_absorb_script(mut self, script: crate::state::OverkillAbsorbScriptFn) -> Self {
277        for effect in self.effects.iter_mut().flatten() {
278            if let BuffEffect::Absorb(absorb) = effect
279                && absorb.kind == crate::state::AbsorbKind::Overkill
280            {
281                absorb.overkill_script = Some(script);
282            }
283        }
284
285        self
286    }
287
288    pub fn duration_ms(mut self, ms: u32) -> Self {
289        self.duration_ms = ms;
290
291        self
292    }
293
294    pub fn trigger_spell_on_expire(mut self, spell_id: u32, from_caster: bool) -> Self {
295        self.expire_trigger_spell_id = spell_id;
296        self.expire_trigger_from_caster = from_caster;
297
298        self
299    }
300
301    pub fn max_stacks(mut self, stacks: u8) -> Self {
302        self.max_stacks = stacks;
303
304        self
305    }
306
307    /// Start at maximum stacks and decrement on stack-changing ticks/triggers.
308    pub fn reverse(mut self) -> Self {
309        self.reverse = true;
310        self.apply_at_max_stacks = true;
311
312        self
313    }
314
315    /// Prevent periodic ticks from changing stacks.
316    pub fn freeze_stacks(mut self) -> Self {
317        self.freeze_stacks = true;
318
319        self
320    }
321
322    /// Change stacks after each tick and restart cadence when refreshed.
323    pub fn clip_tick_behavior(mut self, amount: u8) -> Self {
324        self.tick_behavior = AuraTickBehavior::Clip;
325        self.tick_stack_change = amount.max(1);
326
327        self
328    }
329
330    /// Change stacks after each tick while preserving cadence when refreshed.
331    pub fn refresh_tick_behavior(mut self, amount: u8) -> Self {
332        self.tick_behavior = AuraTickBehavior::Refresh;
333        self.tick_stack_change = amount.max(1);
334
335        self
336    }
337
338    /// Re-evaluate live actor state for every periodic tick.
339    pub fn dynamic_tick_update(mut self) -> Self {
340        self.dynamic_tick_action = DynamicTickAction::Update;
341
342        self
343    }
344
345    /// Capture a new one-tick snapshot for every periodic tick.
346    pub fn dynamic_tick_snapshot(mut self) -> Self {
347        self.dynamic_tick_action = DynamicTickAction::Snapshot;
348
349        self
350    }
351
352    /// Number of stacks added by one aura application.
353    pub fn doses(mut self, doses: u8) -> Self {
354        self.doses = doses.max(1);
355
356        self
357    }
358
359    pub fn no_pandemic(mut self) -> Self {
360        self.pandemic = false;
361        self.refresh_behavior = wowlab_types::data::RefreshBehavior::Duration;
362
363        self
364    }
365
366    /// Refresh to the base duration plus `SimC`'s pandemic carry window.
367    pub fn pandemic_on_refresh(mut self) -> Self {
368        self.pandemic = true;
369        self.refresh_behavior = wowlab_types::data::RefreshBehavior::Pandemic;
370
371        self
372    }
373
374    /// Extend the current remaining duration by the full base duration on refresh.
375    pub fn extend_on_refresh(mut self) -> Self {
376        self.pandemic = false;
377        self.refresh_behavior = wowlab_types::data::RefreshBehavior::Extend;
378
379        self
380    }
381
382    /// Refresh to the new duration while preserving the remainder of the current tick.
383    pub fn carry_tick_on_refresh(mut self) -> Self {
384        self.pandemic = false;
385        self.refresh_behavior = wowlab_types::data::RefreshBehavior::Tick;
386
387        self
388    }
389
390    /// Replace the duration and restart the periodic tick chain on refresh.
391    pub fn clip_on_refresh(mut self) -> Self {
392        self.pandemic = false;
393        self.refresh_behavior = wowlab_types::data::RefreshBehavior::Clip;
394
395        self
396    }
397
398    /// Ignore applications while an existing instance remains active.
399    pub fn never_refresh(mut self) -> Self {
400        self.pandemic = false;
401        self.refresh_behavior = wowlab_types::data::RefreshBehavior::None;
402
403        self
404    }
405
406    /// Scales the aura's wall-clock duration by spell haste at application time.
407    pub fn duration_hasted(mut self) -> Self {
408        self.duration_hasted = true;
409
410        self
411    }
412
413    /// Inherit duration flags from the spell supplying this aura's duration.
414    pub fn apply_duration_behavior_from_data(
415        mut self,
416        data: &ResolvedGameData,
417        spell_id: u32,
418    ) -> Result<Self, BuilderError> {
419        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
420        let duration_hasted = data.duration_hasted(idx).ok_or(
421            crate::builder::BuilderErrorKind::MissingSpellData {
422                spell_id,
423                field: "duration_hasted",
424            },
425        )?;
426
427        self.refresh_behavior = data.refresh_behavior(idx).ok_or(
428            crate::builder::BuilderErrorKind::MissingSpellData {
429                spell_id,
430                field: "refresh_behavior",
431            },
432        )?;
433        self.pandemic = self.refresh_behavior == wowlab_types::data::RefreshBehavior::Pandemic;
434        self.async_stacks = data.asynchronous_stacks(idx).ok_or(
435            crate::builder::BuilderErrorKind::MissingSpellData {
436                spell_id,
437                field: "asynchronous_stacks",
438            },
439        )?;
440
441        Ok(if duration_hasted {
442            self.duration_hasted()
443        } else {
444            self
445        })
446    }
447
448    /// Re-apply immediately when the aura expires (permanent decay/refill buffs).
449    pub fn reapply_on_expire(mut self) -> Self {
450        self.reapply_on_expire = true;
451
452        self
453    }
454
455    pub fn on_consume_extend(mut self, aura: LocalAuraIdx, amount_ms: u32) -> Self {
456        self.on_consume.push(BuilderSpellEffect::ExtendAura {
457            aura_local: aura,
458            amount_ms,
459        });
460
461        self
462    }
463
464    pub fn spell_cost_percent(
465        self,
466        percent: f64,
467        spells: &'static [u32],
468    ) -> Result<Self, BuilderError> {
469        self.push_effect(BuffEffect::SpellCostPercent(
470            percent,
471            spells,
472            crate::state::ResourcePool::Primary,
473        ))
474    }
475
476    pub fn spell_cost_flat(
477        self,
478        amount: f64,
479        spells: &'static [u32],
480    ) -> Result<Self, BuilderError> {
481        self.push_effect(BuffEffect::SpellCostFlat(
482            amount,
483            spells,
484            crate::state::ResourcePool::Primary,
485        ))
486    }
487
488    pub fn secondary_spell_cost_percent(
489        self,
490        percent: f64,
491        spells: &'static [u32],
492    ) -> Result<Self, BuilderError> {
493        self.push_effect(BuffEffect::SpellCostPercent(
494            percent,
495            spells,
496            crate::state::ResourcePool::Secondary,
497        ))
498    }
499
500    pub fn secondary_spell_cost_flat(
501        self,
502        amount: f64,
503        spells: &'static [u32],
504    ) -> Result<Self, BuilderError> {
505        self.push_effect(BuffEffect::SpellCostFlat(
506            amount,
507            spells,
508            crate::state::ResourcePool::Secondary,
509        ))
510    }
511
512    pub fn recharge_rate_percent(
513        self,
514        percent: f64,
515        spells: &'static [u32],
516    ) -> Result<Self, BuilderError> {
517        self.push_effect(BuffEffect::RechargeRatePercent(percent, spells))
518    }
519
520    pub fn cast_time_percent(
521        self,
522        percent: f64,
523        spells: &'static [u32],
524    ) -> Result<Self, BuilderError> {
525        self.push_effect(BuffEffect::CastTimePercent(percent, spells))
526    }
527
528    /// Modify cast time additively for every active stack of this aura.
529    pub fn cast_time_percent_per_stack(
530        self,
531        percent: f64,
532        spells: &'static [u32],
533    ) -> Result<Self, BuilderError> {
534        self.push_effect(BuffEffect::CastTimePercentPerStack(percent, spells))
535    }
536
537    /// Modify the global cooldown for the listed spells while this aura has any stacks.
538    pub fn gcd_percent(self, percent: f64, spells: &'static [u32]) -> Result<Self, BuilderError> {
539        self.push_effect(BuffEffect::GcdPercent(percent, spells))
540    }
541
542    /// Sets the hook fired when the active aura is removed.
543    pub fn on_expire(mut self, hook: CastHookFn) -> Self {
544        self.on_expire = Some(hook);
545
546        self
547    }
548
549    /// Add a per-stack combat-rating bonus, converted to percent points via the level's rating divisor and DR curve (`0` on empty introspection data).
550    pub fn rating_per_stack(
551        self,
552        data: &ResolvedGameData,
553        rating: wowlab_types::game::RatingType,
554        amount: f64,
555    ) -> Result<Self, BuilderError> {
556        let pct = wowlab_engine_domain::stats::rating_percent_points(data, rating, amount)
557            .map_err(|error| {
558                crate::builder::BuilderErrorKind::GameData(
559                    wowlab_engine_ports::EngineError::spec_construction(format!(
560                        "rating conversion failed: {error}"
561                    )),
562                )
563            })?;
564        let effect = match rating {
565            wowlab_types::game::RatingType::Crit => BuffEffect::Crit(pct),
566            wowlab_types::game::RatingType::Haste => BuffEffect::Haste(pct),
567            wowlab_types::game::RatingType::Mastery => BuffEffect::Mastery(pct),
568            wowlab_types::game::RatingType::Versatility => BuffEffect::Versatility(pct),
569            other => {
570                return Err(crate::builder::BuilderErrorKind::GameData(
571                    wowlab_engine_ports::EngineError::spec_construction(format!(
572                        "unmodelled rating {other:?} for aura {} ({})",
573                        self.name, self.aura_id
574                    )),
575                )
576                .into());
577            }
578        };
579
580        self.push_effect(effect)
581    }
582
583    pub fn haste_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
584        self.push_effect(BuffEffect::HasteMult(pct))
585    }
586
587    /// Percent points of auto-attack speed per stack (aura 319/342).
588    pub fn attack_speed_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
589        self.push_effect(BuffEffect::AttackSpeed(pct))
590    }
591
592    /// Percent points of extra auto-attack damage per stack (dynamic aura 344).
593    pub fn auto_attack_damage_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
594        self.push_effect(BuffEffect::AutoAttackDamage(pct))
595    }
596
597    /// Forward a resolved value into compatible zero-valued DBC effects on this aura.
598    ///
599    /// The carrier effects keep their own subtype, source coordinate, and affect mask.
600    pub fn forwarded_effect_value(
601        mut self,
602        data: &ResolvedGameData,
603        value: f64,
604    ) -> Result<Self, BuilderError> {
605        let patched = apply_forwarded_effect_value(self.aura_id, &mut self.effects, data, value);
606
607        if patched == 0
608            && data.max_effect_index(wowlab_types::sim::SpellIdx::from_raw(self.aura_id)) > 0
609        {
610            return Err(crate::builder::BuilderErrorKind::GameData(
611                wowlab_engine_ports::EngineError::spec_construction(format!(
612                    "aura {} ({}) declares forwarded_effect_value but has no compatible zero-valued DBC effect",
613                    self.name, self.aura_id
614                )),
615            )
616            .into());
617        }
618
619        Ok(self)
620    }
621
622    /// Percent points of extra primary-resource gain per stack (aura 213).
623    pub fn resource_gain_mult_per_stack(mut self, pct: f64) -> Result<Self, BuilderError> {
624        let mut replaced_narrow_gain = false;
625
626        for effect in self.effects.iter_mut().flatten() {
627            if matches!(effect, BuffEffect::RageGainMult(_)) {
628                *effect = BuffEffect::ResourceGainMult(pct);
629                replaced_narrow_gain = true;
630            }
631        }
632
633        if replaced_narrow_gain {
634            Ok(self)
635        } else {
636            self.push_effect(BuffEffect::ResourceGainMult(pct))
637        }
638    }
639
640    /// Percent points of extra primary-resource regen while active (e.g. Adrenaline Rush +75).
641    pub fn regen_percent_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
642        self.push_effect(BuffEffect::RegenPercent(pct))
643    }
644
645    /// Flat extra primary-resource maximum while active (e.g. Adrenaline Rush +50 energy).
646    pub fn max_resource_per_stack(self, amount: f64) -> Result<Self, BuilderError> {
647        self.push_effect(BuffEffect::MaxResourceFlat(amount))
648    }
649
650    pub fn crit_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
651        self.push_effect(BuffEffect::Crit(pct))
652    }
653
654    /// Overrides the percent on this aura's DBC `ModDamageTakenFromCasterSpells` (271) effect, whose base is zero for value-override talents.
655    ///
656    /// # Errors
657    ///
658    /// Returns [`BuilderError`] when resolved data describes the aura but carries no such effect.
659    pub fn damage_taken_from_caster_spells(
660        mut self,
661        data: &ResolvedGameData,
662        pct: f64,
663    ) -> Result<Self, BuilderError> {
664        let mut found = false;
665
666        for effect in self.effects.iter_mut().flatten() {
667            if let BuffEffect::DamageTakenFromSourceSpells { percent, .. } = effect {
668                *percent = pct;
669                found = true;
670            }
671        }
672
673        if !found && data.max_effect_index(wowlab_types::sim::SpellIdx::from_raw(self.aura_id)) > 0
674        {
675            return Err(crate::builder::BuilderErrorKind::GameData(
676                wowlab_engine_ports::EngineError::spec_construction(format!(
677                    "aura {} ({}) declares damage_taken_from_caster_spells but carries no aura-271 effect",
678                    self.name, self.aura_id
679                )),
680            )
681            .into());
682        }
683
684        Ok(self)
685    }
686
687    pub fn mastery_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
688        self.push_effect(BuffEffect::Mastery(pct))
689    }
690
691    pub fn vers_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
692        self.push_effect(BuffEffect::Versatility(pct))
693    }
694
695    /// Add a flat primary stat bonus per stack (raw value).
696    pub fn primary_per_stack(self, value: f64) -> Result<Self, BuilderError> {
697        self.push_effect(BuffEffect::PrimaryStat(value))
698    }
699
700    /// Add a percent primary-stat bonus per stack (e.g. `10.0` for +10%).
701    pub fn primary_pct_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
702        self.push_effect(BuffEffect::PrimaryStatPercent(pct))
703    }
704
705    /// Add a percent attack-power bonus per stack (e.g. `10.0` for +10%).
706    pub fn attack_power_pct_per_stack(self, pct: f64) -> Result<Self, BuilderError> {
707        self.push_effect(BuffEffect::AttackPowerPercent(pct))
708    }
709
710    /// Percent points of crit CHANCE per stack, only for the listed spells.
711    pub fn crit_chance_spells(
712        self,
713        pct: f64,
714        spells: &'static [u32],
715    ) -> Result<Self, BuilderError> {
716        self.push_effect(BuffEffect::CritChanceSpells(pct, spells))
717    }
718
719    /// Percent points of crit DAMAGE per stack, only for the listed spells.
720    pub fn crit_damage_spells(
721        self,
722        pct: f64,
723        spells: &'static [u32],
724    ) -> Result<Self, BuilderError> {
725        self.push_effect(BuffEffect::CritDamageSpells(pct, spells))
726    }
727
728    /// Add outgoing damage percent for the listed spells while this aura is active.
729    pub fn damage_percent_spells(
730        self,
731        pct: f64,
732        spells: &'static [u32],
733    ) -> Result<Self, BuilderError> {
734        self.push_effect(BuffEffect::DamageMultSpells(1.0 + pct / HUNDRED, spells))
735    }
736
737    /// Adds linear per-stack outgoing damage for the listed spells.
738    pub fn damage_percent_spells_linear(
739        self,
740        pct: f64,
741        spells: &'static [u32],
742    ) -> Result<Self, BuilderError> {
743        self.push_effect(BuffEffect::DamageMultSpellsLinear(pct, spells))
744    }
745
746    /// Add a multiplicative outgoing damage modifier (e.g. `1.10` for +10%).
747    pub fn damage_multiplier(self, mult: f64) -> Result<Self, BuilderError> {
748        self.push_effect(BuffEffect::DamageMult(mult))
749    }
750
751    /// Add a stacking damage modifier whose first stack differs from later stacks.
752    pub fn damage_multiplier_stacking(
753        self,
754        initial: f64,
755        per_stack: f64,
756    ) -> Result<Self, BuilderError> {
757        self.push_effect(BuffEffect::DamageMultStacking { initial, per_stack })
758    }
759
760    /// Add a multiplicative outgoing modifier for pet/guardian hits only.
761    pub fn pet_damage_multiplier(self, mult: f64) -> Result<Self, BuilderError> {
762        self.push_effect(BuffEffect::PetDamageMult(mult))
763    }
764
765    pub fn spell_group(mut self, group_idx: u8, rule: SpellGroupRule) -> Self {
766        self.spell_group = Some(SpellGroup { group_idx, rule });
767
768        self
769    }
770
771    /// Capture a source-side stat snapshot on periodic ticks; no-op without periodic damage.
772    pub fn snapshot(mut self) -> Self {
773        self.is_snapshot = true;
774
775        self
776    }
777
778    fn push_effect(mut self, effect: BuffEffect) -> Result<Self, BuilderError> {
779        if crate::state::try_push_effect(&mut self.effects, effect).is_err() {
780            return Err(crate::builder::BuilderErrorKind::AuraEffectsFull {
781                aura_name: self.name.clone(),
782                aura_id: self.aura_id,
783                slot_count: MAX_AURA_BUFF_EFFECTS,
784            }
785            .into());
786        }
787
788        Ok(self)
789    }
790}
791
792#[cfg(test)]
793#[path = "aura_builder/tests.rs"]
794mod tests;