Skip to main content

wowlab_engine_combat/context/hook/
ground_effects.rs

1use wowlab_engine_ports::Event;
2use wowlab_types::sim::{ActorId, EnemyIdx, SpellIdx};
3
4use super::SchedulingOps;
5use crate::{
6    context::{FreeActionSource, HookCtx},
7    systems,
8};
9
10impl HookCtx<'_> {
11    pub(crate) fn schedule_periodic_driver(
12        &mut self,
13        delay_ms: u32,
14        driver_index: usize,
15        expires_at: Option<wowlab_types::sim::SimTime>,
16        target: Option<EnemyIdx>,
17    ) {
18        let pending = crate::state::PendingHookTimer {
19            action: crate::state::PendingTimerAction::PeriodicDriver {
20                index: driver_index,
21                expires_at,
22            },
23            source: ActorId::Player,
24            target,
25            source_damage_flags: crate::DamageFlags::empty(),
26            source_npc_id: None,
27        };
28
29        self.schedule_pending_timer(delay_ms, pending);
30    }
31
32    pub(crate) fn schedule_effect_program(
33        &mut self,
34        delay_ms: u32,
35        effects: crate::state::SpellEffectRange,
36        profile_spell_id: u32,
37        flags: crate::DamageFlags,
38        originating_proc_driver_spell_id: u32,
39    ) {
40        let target = self.target.or_else(|| self.state.current_target());
41        let pending = crate::state::PendingHookTimer {
42            action: crate::state::PendingTimerAction::Effect(crate::state::PendingEffectProgram {
43                effects,
44                profile_spell_id,
45                repeat: 1,
46                source: self.source,
47                target,
48                effect_target: self.effect_target,
49                flags,
50                originating_proc_driver_spell_id,
51            }),
52            source: self.source,
53            target,
54            source_damage_flags: flags,
55            source_npc_id: self.source_npc_id,
56        };
57
58        tracing::trace!(
59            delay_ms,
60            profile_spell_id,
61            effects_start = effects.start,
62            effects_len = effects.len,
63            "scheduled typed effect program"
64        );
65        self.schedule_pending_timer(delay_ms, pending);
66    }
67}
68
69impl SchedulingOps for HookCtx<'_> {
70    /// Execute a registered spell as a cost-free child action on this hook's locked target.
71    ///
72    /// The child retains its payload, aura/effects, gains, telemetry, and spell hook.
73    /// It bypasses player costs, cooldowns, GCD/history, and global player-cast hooks.
74    fn dispatch_free_action(&mut self, spell_id: u32, source: FreeActionSource) -> bool {
75        let Some(target) = self.target else {
76            return false;
77        };
78        let mut combat = self.combat_ctx(target);
79
80        crate::pipeline::process_free_action(&mut combat, spell_id, source)
81    }
82
83    /// Execute a registered spell spawned by a proc without player action costs.
84    ///
85    /// The originating driver is retained for proc provenance and self-loop suppression.
86    fn dispatch_proc_spell(
87        &mut self,
88        spell_id: u32,
89        proc_driver_spell_id: u32,
90        affected: ActorId,
91    ) -> bool {
92        if proc_driver_spell_id == 0 || self.state.spell_data(spell_id).is_none() {
93            return false;
94        }
95
96        let target = match affected {
97            ActorId::Enemy(target) => target,
98            ActorId::Player | ActorId::Pet(_) | ActorId::External => self
99                .target
100                .or_else(|| self.state.current_target())
101                .unwrap_or(EnemyIdx::PRIMARY),
102        };
103        let mut combat = self.combat_ctx(target);
104
105        crate::pipeline::process_proc_triggered_spell(
106            &mut combat,
107            spell_id,
108            affected,
109            proc_driver_spell_id,
110        );
111
112        true
113    }
114
115    fn schedule_event(&mut self, event: Event) {
116        self.state.schedule(event);
117    }
118
119    /// Schedule a hook for delayed execution; a captured target binds the callback and cancels it if that enemy is missing, inactive, or dead when the timer fires.
120    fn schedule_hook(&mut self, delay_ms: u32, hook: crate::state::CastHookFn) {
121        let pending = crate::state::PendingHookTimer {
122            action: crate::state::PendingTimerAction::Hook(hook),
123            source: self.source,
124            target: self.target,
125            source_damage_flags: self.source_damage_flags,
126            source_npc_id: self.source_npc_id,
127        };
128
129        self.schedule_pending_timer(delay_ms, pending);
130    }
131
132    /// Activate a finite periodic driver by its real spell/passive identity.
133    fn trigger_periodic_driver(&mut self, driver_spell_id: u32) -> bool {
134        let Some(duration_ms) = self
135            .state
136            .defs
137            .periodic_drivers
138            .iter()
139            .find(|driver| driver.driver_spell_id == driver_spell_id)
140            .and_then(|driver| driver.triggered_duration_ms)
141        else {
142            return false;
143        };
144
145        self.trigger_periodic_driver_for(driver_spell_id, duration_ms.get())
146    }
147
148    /// Activate a finite periodic driver with a runtime-resolved lifetime.
149    ///
150    /// The driver must be registered as triggered.
151    /// This overrides the activation lifetime for mechanics whose duration varies per cast.
152    fn trigger_periodic_driver_for(&mut self, driver_spell_id: u32, duration_ms: u32) -> bool {
153        let Some(duration_ms) = std::num::NonZeroU32::new(duration_ms) else {
154            return false;
155        };
156        let Some((index, driver)) = self
157            .state
158            .defs
159            .periodic_drivers
160            .iter()
161            .copied()
162            .enumerate()
163            .find(|(_, driver)| driver.driver_spell_id == driver_spell_id)
164        else {
165            return false;
166        };
167
168        if driver.triggered_duration_ms.is_none() {
169            return false;
170        }
171
172        let interval_ms = systems::effective_periodic_interval_ms(
173            &self.view().combat(),
174            driver.driver_spell_id,
175            driver.tick_interval_ms.get(),
176            driver.hasted_ticks,
177        );
178        let expires_at = self
179            .now
180            .saturating_add(wowlab_types::sim::SimTime::from_millis(duration_ms.get()));
181
182        if self
183            .now
184            .saturating_add(wowlab_types::sim::SimTime::from_millis(interval_ms))
185            > expires_at
186        {
187            return false;
188        }
189
190        self.schedule_periodic_driver(interval_ms, index, Some(expires_at), self.target);
191
192        true
193    }
194
195    /// Schedule a location-locked sequence of spatial damage pulses.
196    fn schedule_ground_effect(&mut self, spec: crate::state::GroundEffectSpec) {
197        if spec.pulse_count == 0 {
198            return;
199        }
200
201        let Some(destination) = self
202            .target
203            .and_then(|target| self.state.actor_transform(ActorId::Enemy(target)))
204        else {
205            tracing::warn!(
206                spell_id = spec.damage_effect.spell_id,
207                "ground effect requires a cast destination"
208            );
209
210            return;
211        };
212        let Some(damage) = systems::resolve_ground_effect_damage(
213            &self.state.config.game_data,
214            spec.damage_effect,
215            spec.multiplier,
216        ) else {
217            tracing::error!(
218                spell_id = spec.damage_effect.spell_id,
219                effect_index = spec.damage_effect.effect_index,
220                "ground effect has no resolvable damage payload"
221            );
222
223            return;
224        };
225        let flags = self.source_flags(spec.flags);
226
227        for pulse in 0..spec.pulse_count {
228            let delay_ms = spec
229                .first_pulse_delay_ms
230                .saturating_add(spec.pulse_period_ms.saturating_mul(u32::from(pulse)));
231
232            self.schedule_pending_timer(
233                delay_ms,
234                crate::state::PendingHookTimer {
235                    action: crate::state::PendingTimerAction::GroundPulse(
236                        crate::state::PendingGroundPulse {
237                            geometry_effect: spec.geometry_effect,
238                            damage_effect: spec.damage_effect,
239                            profile_spell_id: spec.profile_spell_id,
240                            damage,
241                            destination,
242                            flags,
243                        },
244                    ),
245                    source: self.source,
246                    target: None,
247                    source_damage_flags: self.source_damage_flags,
248                    source_npc_id: self.source_npc_id,
249                },
250            );
251        }
252    }
253
254    /// Resolve a driver's authored duration and pulse period, then schedule its ground payload.
255    ///
256    /// # Panics
257    ///
258    /// Panics if the clamped pulse count does not fit in `u8`.
259    fn schedule_ground_effect_from_data(
260        &mut self,
261        driver_spell_id: u32,
262        timing_effect_index: u8,
263        geometry_effect: (u32, u8),
264        damage_effect: (u32, u8),
265        flags: systems::DamageFlags,
266    ) -> bool {
267        let driver = SpellIdx::from_raw(driver_spell_id);
268        let data = &self.state.config.game_data;
269        let Some(base_duration_ms) = data.aura_duration_ms(driver) else {
270            tracing::error!(
271                driver_spell_id,
272                "ground-effect driver is missing duration data"
273            );
274
275            return false;
276        };
277        let period = data.period(driver, timing_effect_index);
278
279        if !period.is_finite() || period <= 0.0 || period > f64::from(u32::MAX) {
280            tracing::error!(
281                driver_spell_id,
282                timing_effect_index,
283                period,
284                "ground-effect timing effect has an invalid pulse period"
285            );
286
287            return false;
288        }
289
290        let base_period_ms = wowlab_types::numeric::f64_to_u32_saturating_round(period);
291
292        if base_duration_ms == 0 || base_period_ms == 0 {
293            tracing::error!(
294                driver_spell_id,
295                base_duration_ms,
296                base_period_ms,
297                "ground-effect timing must be positive"
298            );
299
300            return false;
301        }
302
303        let haste = systems::current_haste_mult(&self.view().combat());
304        let duration_ms = if data.duration_hasted(driver).unwrap_or(false) {
305            wowlab_types::numeric::f64_to_u32_saturating_round(f64::from(base_duration_ms) / haste)
306        } else {
307            base_duration_ms
308        };
309        let period_ms = if data.hasted_ticks(driver).unwrap_or(false) {
310            wowlab_types::numeric::f64_to_u32_saturating_round(f64::from(base_period_ms) / haste)
311        } else {
312            base_period_ms
313        }
314        .max(1);
315        let pulse_count = duration_ms.div_ceil(period_ms).min(u32::from(u8::MAX));
316        // BOUNDS: clamped to u8::MAX above.
317        let pulse_count = u8::try_from(pulse_count).expect("ground pulse count is clamped");
318
319        self.schedule_ground_effect(
320            crate::state::GroundEffectSpec::new(geometry_effect, damage_effect)
321                .pulses(pulse_count, period_ms)
322                .flags(flags),
323        );
324
325        true
326    }
327}