Skip to main content

wowlab_engine_combat/handler/spec_handler/
casting.rs

1use wowlab_engine_event_authority::EngineScheduledSpellAccess as _;
2
3use super::{
4    ActorId, CombatCtxRequest, CombatHandler, EnemyIdx, Event, HookCtxRequest, SimContext, SimTime,
5    check_recharge, drain_deferred_work, process_channel_tick_for,
6};
7
8#[derive(Clone, Copy)]
9enum SpellTravelEvent {
10    Launch,
11    Impact,
12}
13
14impl CombatHandler {
15    // #t(fn: rust_cyclomatic_complexity) cast completion is one ordered identity-validation, cleanup, execution, and item-state transaction.
16    pub(super) fn on_cast_complete_impl(&mut self, event: Event, ctx: &mut SimContext) {
17        let Event::CastComplete {
18            spell,
19            empower_rank,
20            source,
21            target,
22            ..
23        } = event
24        else {
25            panic!("on_cast_complete received a non-cast-complete event");
26        };
27        let Some(spell_local) = self.program.validate_scheduled_spell(spell) else {
28            return;
29        };
30        let spell_id = spell.engine_coordinates();
31
32        if source != ActorId::Player {
33            return;
34        }
35
36        let now = ctx.state.current_time;
37        let active = self
38            .instance
39            .runtime
40            .casting
41            .active_casts
42            .get(source)
43            .copied();
44        let had_player_tracking = source == ActorId::Player
45            && (active.is_some()
46                || self.instance.runtime.casting.selected_candidate.is_some()
47                || self
48                    .instance
49                    .runtime
50                    .casting
51                    .active_player_candidate
52                    .is_some());
53        let hard_completion_matches = source == ActorId::Player
54            && active.is_some_and(|active| {
55                active.spell_id == spell_id
56                    && active.ends_at <= now
57                    && active.target == Some(target)
58            })
59            && self
60                .instance
61                .runtime
62                .casting
63                .active_player_candidate
64                .is_some_and(|candidate| {
65                    candidate.requested_local == spell_local && candidate.target == target
66                });
67        let mut candidate = if hard_completion_matches {
68            self.instance.runtime.casting.active_casts.remove(source);
69            self.instance.runtime.casting.selected_candidate = None;
70
71            self.instance.runtime.casting.active_player_candidate.take()
72        } else if source == ActorId::Player {
73            let instant_matches = self
74                .instance
75                .runtime
76                .casting
77                .selected_candidate
78                .is_some_and(|candidate| {
79                    candidate.requested_local == spell_local && candidate.target == target
80                });
81
82            instant_matches
83                .then(|| self.instance.runtime.casting.selected_candidate.take())
84                .flatten()
85        } else {
86            None
87        };
88
89        if had_player_tracking && candidate.is_none() {
90            return;
91        }
92
93        if candidate.is_none() {
94            candidate = Some(crate::handler::can_cast::CastCandidate::resolve_planned(
95                &self.instance,
96                self.program.rotation.local_binding(spell_local),
97                target,
98            ));
99        }
100
101        let is_off_gcd =
102            candidate.is_some_and(|candidate| candidate.effective(&self.program).behavior.off_gcd);
103
104        #[cfg(feature = "rotation-optimizations")]
105        let completes_into_gcd_lock = candidate.is_some_and(|candidate| {
106            candidate.resolved_cast_time_ms() == Some(0)
107                && !candidate.effective(&self.program).behavior.off_gcd
108                && candidate.effective(&self.program).gcd_ms > 0
109                && SimTime::from_secs_f64(self.instance.buffer.player().gcd_end) > now
110        });
111
112        if is_off_gcd {
113            self.instance.runtime.casting.instant_cast_pending_at = None;
114        }
115
116        if !self.instance.is_valid_target(target) {
117            self.instance.buffer.player_mut().cast_end = 0.0;
118            self.instance.schedule(Event::PlayerReady { t: now });
119
120            return;
121        }
122
123        self.with_combat_ctx(
124            &mut *ctx.telemetry,
125            CombatCtxRequest::new(now, source, target),
126            |combat| {
127                // docref:start cast-pipeline-lookup-guard
128                let Some(candidate) = candidate else {
129                    tracing::warn!(spell_id = spell_id.as_u32(), "UNKNOWN_SPELL_CAST");
130                    return;
131                };
132                // docref:end cast-pipeline-lookup-guard
133
134                crate::pipeline::process_selected_cast(combat, candidate, empower_rank);
135
136                if let Some(slot) = combat
137                    .program
138                    .weapon_imbue_by_spell
139                    .get(candidate.requested_local.as_usize())
140                    .copied()
141                    .flatten()
142                {
143                    let slot = combat.instance.buffer.weapon_imbue_mut_at(slot);
144
145                    slot.is_active = 1;
146                    slot.is_inactive = 0;
147
148                    #[cfg(feature = "rotation-optimizations")]
149                    combat.instance.runtime.rotation_mutations.record_semantic(
150                        crate::state::runtime::RotationSemanticMutation::EquipmentState,
151                        crate::state::runtime::RotationMutationDirection::Rise,
152                        now,
153                    );
154                }
155            },
156        );
157
158        #[cfg(feature = "rotation-optimizations")]
159        if completes_into_gcd_lock {
160            self.instance.runtime.casting.instant_cast_pending_at = None;
161            self.instance.schedule(Event::OffGcdReady { t: now });
162        }
163    }
164
165    pub(super) fn on_triggered_spell_impl(
166        &mut self,
167        spell: wowlab_engine_ports::ScheduledSpellBinding,
168        source: ActorId,
169        target: ActorId,
170        ctx: &mut SimContext,
171    ) {
172        let Some(execution) = self.program.scheduled_spell_execution(spell) else {
173            return;
174        };
175        let now = ctx.state.current_time;
176        let anchor = match target {
177            ActorId::Enemy(enemy) => {
178                if !self.instance.is_valid_target(enemy) {
179                    return;
180                }
181
182                enemy
183            }
184            ActorId::Player | ActorId::Pet(_) | ActorId::External => {
185                self.instance.current_target().unwrap_or(EnemyIdx::PRIMARY)
186            }
187        };
188
189        self.with_combat_ctx(
190            &mut *ctx.telemetry,
191            CombatCtxRequest::new(now, source, anchor),
192            |combat| match execution {
193                crate::state::program::ScheduledSpellExecution::Registered(spell_local) => {
194                    crate::pipeline::process_triggered_spell_local(combat, spell_local, target);
195                }
196                crate::state::program::ScheduledSpellExecution::TriggerOnly(action) => {
197                    crate::pipeline::process_trigger_only_action(combat, action);
198                }
199                crate::state::program::ScheduledSpellExecution::TriggerOnlyEmpty => {}
200            },
201        );
202        self.with_hook_ctx(
203            &mut *ctx.telemetry,
204            HookCtxRequest::for_optional_target(now, Some(anchor))
205                .with_source(source)
206                .with_effect_target(target),
207            drain_deferred_work,
208        );
209    }
210
211    pub(super) fn on_spell_impact_impl(
212        &mut self,
213        impact: wowlab_engine_ports::PendingImpactBinding,
214        ctx: &mut SimContext,
215    ) {
216        self.process_spell_travel_event(impact, SpellTravelEvent::Impact, ctx);
217    }
218
219    pub(super) fn on_spell_launch_impl(
220        &mut self,
221        impact: wowlab_engine_ports::PendingImpactBinding,
222        ctx: &mut SimContext,
223    ) {
224        self.process_spell_travel_event(impact, SpellTravelEvent::Launch, ctx);
225    }
226
227    pub(super) fn on_channel_tick_impl(
228        &mut self,
229        spell: wowlab_engine_ports::ScheduledSpellBinding,
230        source: ActorId,
231        target: EnemyIdx,
232        generation: u64,
233        ctx: &mut SimContext,
234    ) {
235        let Some(spell_local) = self.program.validate_scheduled_spell(spell) else {
236            return;
237        };
238        let now = ctx.state.current_time;
239
240        self.with_combat_ctx(
241            &mut *ctx.telemetry,
242            CombatCtxRequest::new(now, source, target),
243            |combat| {
244                process_channel_tick_for(
245                    combat,
246                    combat.program.spell(spell_local).spell_id,
247                    generation,
248                );
249            },
250        );
251    }
252
253    pub(super) fn on_actor_movement_impl(
254        &mut self,
255        actor: ActorId,
256        moving: bool,
257        generation: u64,
258        ctx: &mut SimContext,
259    ) {
260        crate::systems::apply_movement_event(
261            &self.program,
262            &mut self.instance,
263            actor,
264            moving,
265            generation,
266            ctx.state.current_time,
267        );
268    }
269
270    pub(super) fn on_cooldown_ready_impl(
271        &mut self,
272        spell: wowlab_engine_ports::ScheduledSpellBinding,
273        ctx: &mut SimContext,
274    ) {
275        let now = ctx.state.current_time;
276
277        if let Some(local) = self.program.validate_scheduled_spell(spell) {
278            check_recharge(&self.program, &mut self.instance, local, now);
279
280            #[cfg(feature = "rotation-optimizations")]
281            if self
282                .program
283                .buffer_cooldown(&self.instance, local)
284                .is_some_and(|cooldown| {
285                    cooldown.max_charges > 1 || SimTime::from_secs_f64(cooldown.ready_at) == now
286                })
287            {
288                self.instance.runtime.rotation_mutations.record_cooldown(
289                    local,
290                    crate::state::runtime::RotationMutationDirection::Rise,
291                    now,
292                );
293            }
294        }
295
296        #[cfg(not(feature = "rotation-optimizations"))]
297        self.instance.schedule(Event::PlayerReady { t: now });
298    }
299
300    pub(super) fn cast_time_ms_impl(
301        &self,
302        spell: wowlab_engine_ports::ScheduledSpellBinding,
303        empower_rank: u8,
304    ) -> u32 {
305        let Some(local) = self.program.validate_scheduled_spell(spell) else {
306            return 0;
307        };
308        let spell_id = spell.engine_coordinates();
309        // #t(rust_closure_dense_method_chain) exact active/selected identities precede the explicit raw introspection boundary.
310        let candidate = self
311            .instance
312            .runtime
313            .casting
314            .active_player_candidate
315            .filter(|candidate| candidate.requested_spell_id(&self.program) == spell_id.as_u32())
316            .or_else(|| {
317                self.instance
318                    .runtime
319                    .casting
320                    .selected_candidate
321                    .filter(|candidate| {
322                        candidate.requested_spell_id(&self.program) == spell_id.as_u32()
323                    })
324            })
325            .or_else(|| {
326                let target = self.instance.current_target()?;
327                let binding = self.program.rotation.local_binding(local);
328
329                Some(crate::handler::can_cast::CastCandidate::resolve_planned(
330                    &self.instance,
331                    binding,
332                    target,
333                ))
334            });
335        let Some(candidate) = candidate else {
336            return 0;
337        };
338
339        self.cast_time_ms_for_candidate(candidate, empower_rank)
340    }
341
342    pub(super) fn cast_time_ms_for_candidate(
343        &self,
344        candidate: crate::handler::can_cast::CastCandidate,
345        empower_rank: u8,
346    ) -> u32 {
347        if let Some(cast_time_ms) = candidate.resolved_cast_time_ms() {
348            return cast_time_ms;
349        }
350
351        let s = candidate.effective(&self.program);
352
353        if s.channel.behavior.is_channel {
354            return 0;
355        }
356
357        if candidate
358            .effective_profile(&self.program)
359            .instant_cast_aura
360            .is_some_and(|requirement| {
361                self.program
362                    .rotation
363                    .aura_active(&self.instance, requirement, candidate.target)
364            })
365        {
366            return 0;
367        }
368
369        let cast_time_ms = if s.empower_rank_count > 0 {
370            let rank = empower_rank.max(1);
371            let index = usize::try_from(s.empower_cast_time_offset)
372                .ok()
373                .and_then(|offset| offset.checked_add(usize::from(rank.saturating_sub(1))));
374
375            index
376                .and_then(|index| self.program.empower_cast_times_ms.get(index))
377                .copied()
378                .unwrap_or(0)
379        } else {
380            s.cast_time_ms
381        };
382
383        if cast_time_ms == 0 {
384            return 0;
385        }
386
387        crate::systems::effective_cast_time_ms(
388            &crate::context::CombatView::new(&self.program, &self.instance),
389            candidate.effective_local,
390            cast_time_ms,
391        )
392    }
393
394    pub(super) fn break_player_action_auras(
395        &mut self,
396        target: EnemyIdx,
397        now: SimTime,
398        telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
399    ) {
400        self.with_hook_ctx(
401            telemetry,
402            HookCtxRequest::for_target(now, target).with_source(ActorId::Player),
403            |ctx| {
404                crate::systems::break_auras_on_actor(
405                    ctx,
406                    ActorId::Player,
407                    crate::systems::AuraBreakTrigger::Action,
408                );
409            },
410        );
411    }
412
413    fn process_spell_travel_event(
414        &mut self,
415        impact: wowlab_engine_ports::PendingImpactBinding,
416        event: SpellTravelEvent,
417        ctx: &mut SimContext,
418    ) {
419        let now = ctx.state.current_time;
420
421        let Some((slot, generation)) = self.program.runtime_events.validate_pending_impact(impact)
422        else {
423            return;
424        };
425        let slot = slot as usize;
426
427        if self
428            .instance
429            .runtime
430            .pending
431            .spell_impact_generations
432            .get(slot)
433            .copied()
434            != Some(generation)
435        {
436            return;
437        }
438
439        let Some((source, target)) = self
440            .instance
441            .runtime
442            .pending
443            .pending_spell_impacts
444            .get(slot)
445            .and_then(Option::as_ref)
446            .map(|impact| (impact.source, impact.target))
447        else {
448            return;
449        };
450
451        self.with_combat_ctx(
452            &mut *ctx.telemetry,
453            CombatCtxRequest::new(now, source, target),
454            |combat| match event {
455                SpellTravelEvent::Launch => {
456                    crate::pipeline::process_spell_launch(combat, impact);
457                }
458                SpellTravelEvent::Impact => {
459                    crate::pipeline::process_spell_impact(combat, impact);
460                }
461            },
462        );
463    }
464}