wowlab_engine_combat/context/hook/
ground_effects.rs1use 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 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 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 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 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 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 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 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 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}