Skip to main content

wowlab_engine_combat/builder/aura_builder/
periodic.rs

1//! Fluent periodic-effect configuration for aura drafts.
2
3use wowlab_engine_domain::dbc::EffectLookup;
4use wowlab_engine_gamedata::ResolvedGameData;
5use wowlab_types::{combat::PeriodicDamageMode, numeric::round_to_decimals};
6
7use super::{AuraDefinitionDraft, PeriodicDamageDataInput};
8use crate::{
9    BuilderError, DamageFlags, DamagePayload,
10    builder::def::{PeriodicDef, PeriodicEffectDef},
11    state::{CastHookFn, LocalAuraIdx},
12};
13
14fn damage_payload_is_periodic(lookup: EffectLookup<'_>) -> bool {
15    !wowlab_engine_domain::dbc::spell_effect_is_any(
16        lookup.effect_type(),
17        &[
18            wowlab_engine_domain::dbc::SpellEffectKind::SchoolDamage,
19            wowlab_engine_domain::dbc::SpellEffectKind::HealthLeech,
20        ],
21    )
22}
23
24#[expect(
25    clippy::missing_errors_doc,
26    reason = "fluent aura methods uniformly return BuilderError for invalid data"
27)]
28impl AuraDefinitionDraft {
29    /// Inherit periodic cadence, critical, refresh, and rolling behavior from spell data.
30    pub fn apply_periodic_behavior_from_data(
31        mut self,
32        data: &ResolvedGameData,
33        spell_id: u32,
34    ) -> Result<Self, BuilderError> {
35        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
36        let missing =
37            |field| crate::builder::BuilderErrorKind::MissingSpellData { spell_id, field };
38
39        self.refresh_behavior = data
40            .refresh_behavior(idx)
41            .ok_or_else(|| missing("refresh_behavior"))?;
42        self.pandemic = self.refresh_behavior == wowlab_types::data::RefreshBehavior::Pandemic;
43        let hasted = data
44            .hasted_ticks(idx)
45            .ok_or_else(|| missing("hasted_ticks"))?;
46        let rolling = data
47            .rolling_periodic(idx)
48            .ok_or_else(|| missing("rolling_periodic"))?;
49        let may_crit = data
50            .tick_may_crit(idx)
51            .ok_or_else(|| missing("tick_may_crit"))?;
52        let tick_on_application = data
53            .tick_on_application(idx)
54            .ok_or_else(|| missing("tick_on_application"))?;
55
56        if let Some(periodic) = self.periodic.as_mut() {
57            periodic.hasted = hasted;
58            periodic.may_crit = may_crit;
59            periodic.tick_on_application = tick_on_application;
60
61            if rolling {
62                periodic.effect = match periodic.effect {
63                    PeriodicEffectDef::FlatDamage {
64                        amount,
65                        is_physical,
66                    } => PeriodicEffectDef::RollingFlatDamage {
67                        amount,
68                        is_physical,
69                    },
70                    PeriodicEffectDef::Damage {
71                        ap_coef,
72                        is_physical,
73                    } => PeriodicEffectDef::RollingDamage {
74                        ap_coef,
75                        is_physical,
76                    },
77                    PeriodicEffectDef::SpDamage {
78                        sp_coef,
79                        is_physical,
80                    } => PeriodicEffectDef::RollingSpDamage {
81                        sp_coef,
82                        is_physical,
83                    },
84                    ref other => other.clone(),
85                };
86            }
87        }
88
89        Ok(self)
90    }
91
92    pub fn periodic_damage(mut self, tick_ms: u32, ap_coef: f64, flags: DamageFlags) -> Self {
93        self.periodic = Some(PeriodicDef::new(
94            tick_ms,
95            true,
96            PeriodicEffectDef::Damage {
97                ap_coef,
98                is_physical: flags.contains(DamageFlags::PHYSICAL),
99            },
100        ));
101
102        self
103    }
104
105    pub fn periodic_flat_damage_from_spell(mut self, tick_ms: u32, payload: DamagePayload) -> Self {
106        self.periodic = Some(PeriodicDef::new(
107            tick_ms,
108            true,
109            PeriodicEffectDef::FlatDamage {
110                amount: payload.coef,
111                is_physical: payload.flags.contains(DamageFlags::PHYSICAL),
112            },
113        ));
114
115        if let Some(periodic) = &mut self.periodic {
116            periodic.damage_spell_id = Some(payload.spell_id);
117        }
118
119        self
120    }
121
122    /// Configure one flat-damage periodic from its total damage over the aura's duration.
123    pub fn periodic_total_flat_damage_from_data(
124        self,
125        data: &ResolvedGameData,
126        tick_ms: u32,
127        spell_id: u32,
128        total: f64,
129    ) -> Result<Self, BuilderError> {
130        if self.duration_ms == 0 || tick_ms == 0 {
131            return Err(crate::builder::BuilderErrorKind::MissingSpellData {
132                spell_id,
133                field: "periodic_total_flat_damage_timing",
134            }
135            .into());
136        }
137
138        let total =
139            crate::builder::validated_nonnegative(total, spell_id, "periodic_total_flat_damage")?;
140        let tick_count = f64::from(self.duration_ms) / f64::from(tick_ms);
141        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
142        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
143            crate::builder::BuilderErrorKind::MissingSpellData {
144                spell_id,
145                field: "is_physical",
146            },
147        )?;
148        let flags = if is_physical {
149            DamageFlags::PHYSICAL
150        } else {
151            DamageFlags::empty()
152        };
153
154        Ok(self.periodic_flat_damage_from_spell(
155            tick_ms,
156            DamagePayload::new(spell_id, total / tick_count, flags),
157        ))
158    }
159
160    pub fn periodic_rolling_damage(
161        mut self,
162        tick_ms: u32,
163        ap_coef: f64,
164        flags: DamageFlags,
165    ) -> Self {
166        self.periodic = Some(PeriodicDef::new(
167            tick_ms,
168            true,
169            PeriodicEffectDef::RollingDamage {
170                ap_coef,
171                is_physical: flags.contains(DamageFlags::PHYSICAL),
172            },
173        ));
174
175        self
176    }
177
178    pub fn periodic_damage_from_spell(mut self, tick_ms: u32, payload: DamagePayload) -> Self {
179        self = self.periodic_damage(tick_ms, payload.coef, payload.flags);
180
181        if let Some(periodic) = &mut self.periodic {
182            periodic.damage_spell_id = Some(payload.spell_id);
183        }
184
185        self
186    }
187
188    pub fn periodic_damage_sp(mut self, tick_ms: u32, sp_coef: f64, flags: DamageFlags) -> Self {
189        self.periodic = Some(PeriodicDef::new(
190            tick_ms,
191            true,
192            PeriodicEffectDef::SpDamage {
193                sp_coef,
194                is_physical: flags.contains(DamageFlags::PHYSICAL),
195            },
196        ));
197
198        self
199    }
200
201    /// Classify scheduled aura damage as direct while retaining periodic cadence.
202    ///
203    /// Some buffs use a periodic scheduler to pulse child direct-damage actions.
204    ///
205    /// Their hits use direct spell modifiers and proc classification.
206    pub fn periodic_tick_direct_damage(mut self) -> Self {
207        if let Some(periodic) = &mut self.periodic {
208            periodic.damage_is_periodic = false;
209        }
210
211        self
212    }
213
214    pub fn periodic_damage_sp_from_spell(mut self, tick_ms: u32, payload: DamagePayload) -> Self {
215        self = self.periodic_damage_sp(tick_ms, payload.coef, payload.flags);
216
217        if let Some(periodic) = &mut self.periodic {
218            periodic.damage_spell_id = Some(payload.spell_id);
219        }
220
221        self
222    }
223
224    pub fn periodic_max_health_damage(
225        mut self,
226        tick_ms: u32,
227        percent: f64,
228        is_physical: bool,
229    ) -> Self {
230        self.periodic = Some(PeriodicDef::new(
231            tick_ms,
232            true,
233            PeriodicEffectDef::MaxHealthDamage {
234                percent,
235                is_physical,
236            },
237        ));
238
239        self
240    }
241
242    /// Configure AP periodic damage from an effect row, deriving coefficient, school, and attribution.
243    pub fn periodic_damage_ap_from_data(
244        self,
245        data: &ResolvedGameData,
246        input: PeriodicDamageDataInput,
247        mode: PeriodicDamageMode,
248    ) -> Result<Self, BuilderError> {
249        let idx = wowlab_types::sim::SpellIdx::from_raw(input.spell_id);
250        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
251            crate::builder::BuilderErrorKind::MissingSpellData {
252                spell_id: input.spell_id,
253                field: "is_physical",
254            },
255        )?;
256        let coefficient = data.ap_coef(idx, input.effect_index) * input.multiplier + input.offset;
257        let coefficient = input.round_decimals.map_or(coefficient, |decimals| {
258            round_to_decimals(coefficient, decimals)
259        });
260        let flags = if is_physical {
261            DamageFlags::PHYSICAL
262        } else {
263            DamageFlags::empty()
264        };
265        let mut builder = match mode {
266            PeriodicDamageMode::Standard => self.periodic_damage(input.tick_ms, coefficient, flags),
267            PeriodicDamageMode::Rolling => {
268                self.periodic_rolling_damage(input.tick_ms, coefficient, flags)
269            }
270        };
271
272        if let Some(periodic) = &mut builder.periodic {
273            periodic.damage_spell_id = Some(input.spell_id);
274            periodic.damage_is_periodic = damage_payload_is_periodic(EffectLookup::new(
275                data,
276                wowlab_types::sim::EffectRef::new(idx, input.effect_index),
277            ));
278        }
279
280        Ok(builder)
281    }
282
283    /// Configure SP periodic damage from an effect row, deriving coefficient, school, and attribution.
284    pub fn periodic_damage_sp_from_data(
285        self,
286        data: &ResolvedGameData,
287        input: PeriodicDamageDataInput,
288    ) -> Result<Self, BuilderError> {
289        let idx = wowlab_types::sim::SpellIdx::from_raw(input.spell_id);
290        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
291            crate::builder::BuilderErrorKind::MissingSpellData {
292                spell_id: input.spell_id,
293                field: "is_physical",
294            },
295        )?;
296        let coefficient = data.sp_coef(idx, input.effect_index) * input.multiplier + input.offset;
297        let coefficient = input.round_decimals.map_or(coefficient, |decimals| {
298            round_to_decimals(coefficient, decimals)
299        });
300        let flags = if is_physical {
301            DamageFlags::PHYSICAL
302        } else {
303            DamageFlags::empty()
304        };
305
306        let mut builder = self.periodic_damage_sp_from_spell(
307            input.tick_ms,
308            DamagePayload::new(input.spell_id, coefficient, flags),
309        );
310
311        if let Some(periodic) = &mut builder.periodic {
312            periodic.damage_is_periodic = damage_payload_is_periodic(EffectLookup::new(
313                data,
314                wowlab_types::sim::EffectRef::new(idx, input.effect_index),
315            ));
316        }
317
318        Ok(builder)
319    }
320
321    pub fn periodic_residual_damage(mut self, tick_ms: u32) -> Self {
322        self.periodic = Some(PeriodicDef::new(
323            tick_ms,
324            false,
325            PeriodicEffectDef::ResidualDamage,
326        ));
327
328        self
329    }
330
331    pub fn periodic_resource(mut self, tick_ms: u32, amount: f64) -> Self {
332        self.periodic = Some(PeriodicDef::new(
333            tick_ms,
334            true,
335            PeriodicEffectDef::ResourceGain {
336                amount,
337                resource_type: None,
338            },
339        ));
340
341        self
342    }
343
344    /// Add periodic primary-resource drain; the aura expires when a tick is unaffordable.
345    pub fn periodic_resource_drain(mut self, tick_ms: u32, amount: f64) -> Self {
346        self.periodic = Some(PeriodicDef::new(
347            tick_ms,
348            true,
349            PeriodicEffectDef::ResourceDrain { amount },
350        ));
351
352        self
353    }
354
355    /// Add a periodic that applies one stack of `target_aura_local` per tick.
356    pub fn periodic_apply_aura(mut self, tick_ms: u32, target_aura_local: u8) -> Self {
357        self.periodic = Some(PeriodicDef::new(
358            tick_ms,
359            true,
360            PeriodicEffectDef::ApplyAura {
361                target_aura_local: LocalAuraIdx::new(target_aura_local),
362            },
363        ));
364
365        self
366    }
367
368    /// Removes one stack per unhasted tick and expires at zero stacks.
369    pub fn periodic_remove_stack(mut self, tick_ms: u32) -> Self {
370        self.periodic = Some(PeriodicDef::new(
371            tick_ms,
372            false,
373            PeriodicEffectDef::RemoveStack,
374        ));
375
376        self
377    }
378
379    /// Register a hook-free periodic chain used solely for tick-driven stack behavior.
380    pub fn periodic_stack_tick(mut self, tick_ms: u32) -> Self {
381        self.periodic = Some(PeriodicDef::new(tick_ms, false, PeriodicEffectDef::Hook));
382
383        self
384    }
385
386    /// Grant primary resource in addition to a periodic damage tick.
387    pub fn periodic_damage_resource_gain(mut self, amount: f64) -> Self {
388        if let Some(periodic) = self.periodic.as_mut() {
389            periodic.resource_gain = amount;
390        }
391
392        self
393    }
394
395    /// Grant extra primary resource when a periodic damage tick crits and passes `chance`.
396    pub fn periodic_damage_crit_resource_gain(mut self, chance: f64, amount: f64) -> Self {
397        if let Some(periodic) = self.periodic.as_mut() {
398            periodic.crit_resource_chance = chance;
399            periodic.crit_resource_gain = amount;
400        }
401
402        self
403    }
404
405    /// Keep the registered periodic on wall-clock time instead of haste scaling.
406    pub fn unhasted_periodic(mut self) -> Self {
407        if let Some(periodic) = self.periodic.as_mut() {
408            periodic.hasted = false;
409        }
410
411        self
412    }
413
414    /// Scale the registered periodic interval with live spell haste.
415    pub fn hasted_periodic(mut self) -> Self {
416        if let Some(periodic) = self.periodic.as_mut() {
417            periodic.hasted = true;
418        }
419
420        self
421    }
422
423    /// Fires the tick hook once more at natural expiry after a partial interval.
424    pub fn partial_tick(mut self) -> Self {
425        if let Some(periodic) = self.periodic.as_mut() {
426            periodic.partial_tick = true;
427        }
428
429        self
430    }
431
432    /// Applies or refreshes at maximum stacks.
433    pub fn apply_at_max_stacks(mut self) -> Self {
434        self.apply_at_max_stacks = true;
435
436        self
437    }
438
439    /// Gives each stack an independent expiry, displacing the oldest at capacity.
440    pub fn async_stacks(mut self) -> Self {
441        self.async_stacks = true;
442
443        self
444    }
445
446    /// Multiplies periodic damage by the aura's current stack count.
447    pub fn periodic_damage_scales_with_stacks(mut self) -> Self {
448        self.periodic_damage_scales_with_stacks = true;
449
450        self
451    }
452
453    /// Ensures an unhasted periodic exists to drive the tick hook.
454    pub fn periodic_hook(mut self, tick_ms: u32) -> Self {
455        if self.periodic.is_none() {
456            self.periodic = Some(PeriodicDef::new(tick_ms, false, PeriodicEffectDef::Hook));
457        }
458
459        self
460    }
461
462    /// Sets the hook fired after each periodic tick.
463    pub fn tick_hook(mut self, hook: CastHookFn) -> Self {
464        self.on_tick = Some(hook);
465
466        self
467    }
468
469    pub(super) fn periodic_resource_type(
470        mut self,
471        tick_ms: u32,
472        amount: f64,
473        resource_type: wowlab_types::combat::ResourceType,
474    ) -> Self {
475        self.periodic = Some(PeriodicDef::new(
476            tick_ms,
477            true,
478            PeriodicEffectDef::ResourceGain {
479                amount,
480                resource_type: Some(resource_type),
481            },
482        ));
483
484        self
485    }
486}