wowlab_engine_combat/handler/spec_handler/
casting.rs1use 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 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 let Some(candidate) = candidate else {
129 tracing::warn!(spell_id = spell_id.as_u32(), "UNKNOWN_SPELL_CAST");
130 return;
131 };
132 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 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}