wowlab_engine_combat/systems/
casting.rs1use wowlab_engine_ports::Event;
2#[cfg(test)]
3use wowlab_types::sim::SpellIdx;
4use wowlab_types::{
5 constants::HUNDRED,
6 sim::{ActorId, SimTime},
7};
8
9use crate::{
10 context::{ActorView, CombatView},
11 state::{BuffEffect, CombatInstance, MovementState, SchoolLockout},
12};
13
14const CAST_PUSHBACK_MS: u32 = 500;
15const CHANNEL_PUSHBACK_DIVISOR: u32 = 4;
16const MAX_PUSHBACKS_PER_CAST: u8 = 2;
17
18#[cfg(test)]
19pub(crate) fn set_gcd(
20 program: &crate::state::CombatProgram,
21 state: &mut CombatInstance,
22 spell_id: u32,
23 now: SimTime,
24) {
25 let Some(target) = state.current_target() else {
26 return;
27 };
28 let Some(local) = program.spell_local(spell_id) else {
29 return;
30 };
31 let candidate = crate::handler::can_cast::CastCandidate::resolve_planned(
32 state,
33 program.rotation.local_binding(local),
34 target,
35 );
36
37 set_gcd_for_candidate(program, state, candidate, now);
38}
39
40pub(crate) fn set_gcd_for_candidate(
41 program: &crate::state::CombatProgram,
42 state: &mut CombatInstance,
43 candidate: crate::handler::can_cast::CastCandidate,
44 now: SimTime,
45) {
46 let spell = candidate.effective(program);
47
48 if spell.channel.behavior.is_channel && !spell.channel.completion.gcd_on_start {
49 return;
50 }
51
52 let effective = super::effective_gcd_ms(
53 &CombatView::new(program, state),
54 candidate.effective_local,
55 spell.base_gcd_ms(),
56 );
57 let gcd_end = now.saturating_add(SimTime::from_millis(effective));
58
59 if spell.cooldown.start_recovery_category.is_some() {
60 let owner = program
61 .start_recovery_category_owners
62 .get(candidate.effective_local.as_usize())
63 .copied()
64 .unwrap_or(candidate.effective_local);
65
66 state
67 .runtime
68 .pools
69 .start_recovery_ready_at
70 .start(owner, gcd_end);
71 }
72
73 if !spell.behavior.off_gcd {
74 state.buffer.player_mut().gcd_end = gcd_end.as_secs_f64();
75 }
76}
77
78#[must_use]
79pub(crate) fn can_cast_while_moving(
80 view: &CombatView<'_>,
81 spell_local: crate::state::LocalSpellIdx,
82 inherently_usable: bool,
83) -> bool {
84 if inherently_usable {
85 return true;
86 }
87
88 let mut allowed = false;
89 let target = view.program.damage.spell_action(spell_local);
90
91 super::buffs::for_each_active_scoped_spell_modifier_for_action(
92 ActorView::new(view.program, view.instance, ActorId::Player),
93 target,
94 |_, effect, binding| {
95 let BuffEffect::CastWhileMovingFromEffect { .. } = effect else {
96 return;
97 };
98
99 allowed |= binding
100 .source_effect
101 .is_some_and(|source| view.program.damage.effect_affects_action(source, target));
102 },
103 );
104
105 allowed
106}
107
108#[must_use]
109pub(crate) fn interrupt_cast(
110 program: &crate::state::CombatProgram,
111 state: &mut CombatInstance,
112 actor: ActorId,
113 now: SimTime,
114 lockout_ms: u32,
115) -> bool {
116 let Some(active) = state.runtime.casting.active_casts.get(actor).copied() else {
117 return false;
118 };
119
120 if !active.interruptible || active.ends_at <= now {
121 return false;
122 }
123
124 state.runtime.casting.active_casts.remove(actor);
125
126 if lockout_ms > 0 {
127 let expires_at = now.saturating_add(SimTime::from_millis(lockout_ms));
128
129 state.runtime.control.school_lockouts.insert(
130 actor,
131 SchoolLockout {
132 schools: active.school,
133 expires_at,
134 },
135 );
136
137 if actor == ActorId::Player {
138 #[cfg(feature = "rotation-optimizations")]
139 state.runtime.rotation_mutations.record_semantic(
140 crate::state::runtime::RotationSemanticMutation::ActorState,
141 crate::state::runtime::RotationMutationDirection::Fall,
142 now,
143 );
144 }
145 }
146
147 if actor == ActorId::Player {
148 state.runtime.casting.active_player_candidate = None;
149 state.runtime.casting.selected_candidate = None;
150 state.buffer.player_mut().cast_end = now.as_secs_f64();
151 end_channel(program, state, now);
152 state.schedule(Event::PlayerReady { t: now });
153 }
154
155 true
156}
157
158pub(crate) fn apply_movement_event(
159 program: &crate::state::CombatProgram,
160 state: &mut CombatInstance,
161 actor: ActorId,
162 moving: bool,
163 generation: u64,
164 now: SimTime,
165) {
166 if state.runtime.control.movement_generation.value(actor) != generation {
167 return;
168 }
169
170 #[cfg(feature = "rotation-optimizations")]
171 let before = state.runtime.control.movement.value(actor);
172 let after = if moving {
173 MovementState::Moving
174 } else {
175 MovementState::Stationary
176 };
177
178 state.runtime.control.movement.set(actor, after);
179
180 #[cfg(feature = "rotation-optimizations")]
181 if actor == ActorId::Player && before != after {
182 state.runtime.rotation_mutations.record_semantic(
183 crate::state::runtime::RotationSemanticMutation::ActorState,
184 if moving {
185 crate::state::runtime::RotationMutationDirection::Fall
186 } else {
187 crate::state::runtime::RotationMutationDirection::Rise
188 },
189 now,
190 );
191 }
192
193 if !moving || actor != ActorId::Player {
194 return;
195 }
196
197 let hard_cast_breaks = state
198 .runtime
199 .casting
200 .active_player_candidate
201 .is_some_and(|candidate| {
202 !can_cast_while_moving(
203 &CombatView::new(program, state),
204 candidate.effective_local,
205 candidate
206 .effective(program)
207 .requirements
208 .availability
209 .usable_while_moving,
210 )
211 });
212
213 if hard_cast_breaks {
214 let _ = interrupt_cast(program, state, actor, now, 0);
215 }
216
217 let channel_breaks = state.runtime.casting.active_channel.is_some_and(|channel| {
218 let spell = program.spell(channel.local);
219
220 !can_cast_while_moving(
221 &CombatView::new(program, state),
222 channel.local,
223 spell.requirements.availability.usable_while_moving,
224 )
225 });
226
227 if channel_breaks {
228 end_channel(program, state, now);
229 state.schedule(Event::PlayerReady { t: now });
230 }
231}
232
233pub(crate) fn apply_cast_pushback(
235 program: &crate::state::CombatProgram,
236 state: &mut CombatInstance,
237 actor: ActorId,
238 now: SimTime,
239) -> u32 {
240 let Some(active) = state.runtime.casting.active_casts.get(actor).copied() else {
241 return apply_channel_pushback(program, state, actor, now);
242 };
243
244 if active.ends_at <= now || active.pushback_count >= MAX_PUSHBACKS_PER_CAST {
245 return 0;
246 }
247
248 if !active.damage_pushback {
249 return 0;
250 }
251
252 let Some(action) = active.action else {
253 return 0;
254 };
255 let resistance = pushback_resistance(ActorView::new(program, state, actor), action);
256 let delay = wowlab_types::numeric::f64_to_u32_saturating_round(
257 f64::from(CAST_PUSHBACK_MS) * (1.0 - resistance / HUNDRED),
258 );
259
260 if delay == 0 {
261 return 0;
262 }
263
264 let new_end = active.ends_at.saturating_add(SimTime::from_millis(delay));
265
266 if let Some(cast) = state.runtime.casting.active_casts.get_mut(actor) {
267 cast.ends_at = new_end;
268 cast.pushback_count = cast.pushback_count.saturating_add(1);
269 }
270
271 if actor == ActorId::Player
272 && let Some(target) = active.target.or_else(|| state.current_target())
273 && let Some(candidate) = state.runtime.casting.active_player_candidate
274 {
275 state.buffer.player_mut().cast_end = new_end.as_secs_f64();
276 state.schedule(Event::CastComplete {
277 t: new_end,
278 spell: program.scheduled_spell(candidate.requested_local),
279 empower_rank: active.empower_rank,
280 source: actor,
281 target,
282 });
283 }
284
285 delay
286}
287
288fn apply_channel_pushback(
289 program: &crate::state::CombatProgram,
290 state: &mut CombatInstance,
291 actor: ActorId,
292 now: SimTime,
293) -> u32 {
294 if actor != ActorId::Player
295 || state.runtime.casting.active_channel_pushback_count >= MAX_PUSHBACKS_PER_CAST
296 {
297 return 0;
298 }
299
300 let Some(active_channel) = state.runtime.casting.active_channel else {
301 return 0;
302 };
303 let spell = program.spell(active_channel.local);
304
305 if !program
306 .rotation
307 .cast_profile(active_channel.local)
308 .channel_duration_pushback
309 {
310 return 0;
311 }
312
313 let spell = *spell;
314 let old_end = SimTime::from_secs_f64(state.buffer.player().channel_end);
315
316 if old_end <= now {
317 return 0;
318 }
319
320 let resistance = pushback_resistance(
321 ActorView::new(program, state, actor),
322 program.damage.spell_action(active_channel.local),
323 );
324 let base_reduction = spell.cast_time_ms / CHANNEL_PUSHBACK_DIVISOR;
325 let reduction = wowlab_types::numeric::f64_to_u32_saturating_round(
326 f64::from(base_reduction) * (1.0 - resistance / HUNDRED),
327 )
328 .min(old_end.saturating_sub(now).as_millis());
329
330 if reduction == 0 {
331 return 0;
332 }
333
334 let new_end = old_end.saturating_sub(SimTime::from_millis(reduction));
335 let new_end_secs = new_end.as_secs_f64();
336
337 state.runtime.casting.active_channel_pushback_count = state
338 .runtime
339 .casting
340 .active_channel_pushback_count
341 .saturating_add(1);
342 state.buffer.player_mut().channel_end = new_end_secs;
343
344 state.buffer.spell_mut_at(active_channel.handle).channel_end = new_end_secs;
345
346 let gcd_end = SimTime::from_secs_f64(state.buffer.player().gcd_end);
347
348 state.schedule(Event::PlayerReady {
349 t: gcd_end.max(new_end).saturating_add(SimTime::from_millis(
350 u32::from(spell.channel.completion.apply_lag) * program.config.cast_latency.channel_ms,
351 )),
352 });
353
354 reduction
355}
356
357fn pushback_resistance(
358 view: ActorView<'_>,
359 spell: crate::state::program::LocalDamageActionIdx,
360) -> f64 {
361 let Some(owner) = super::buffs::spell_modifier_owner(view.actor) else {
362 return 0.0;
363 };
364 let (flat, percent) = super::buffs::data_driven_actor_timing_modifiers(
365 view.combat().for_actor(owner),
366 spell,
367 wowlab_engine_domain::dbc::ModifierPropertyKind::ResistPushback,
368 |effect| match effect {
369 BuffEffect::ResistPushbackFromEffect {
370 value,
371 source_spell_id,
372 effect_index,
373 operation,
374 } => Some((*value, *source_spell_id, *effect_index, *operation)),
375 _ => None,
376 },
377 );
378
379 (flat + percent).clamp(0.0, HUNDRED)
380}
381
382pub(crate) fn end_channel(
383 program: &crate::state::CombatProgram,
384 state: &mut CombatInstance,
385 now: SimTime,
386) {
387 state.runtime.casting.channel_generation =
388 state.runtime.casting.channel_generation.wrapping_add(1);
389 state.runtime.casting.active_channel_target = None;
390 let active_channel = state.runtime.casting.active_channel.take();
391
392 state.runtime.casting.active_channel_pushback_count = 0;
393 state.runtime.casting.active_channel_ticks_remaining = 0;
394 state.runtime.casting.active_channel_on_last_tick = false;
395 state.buffer.player_mut().channel_end = now.as_secs_f64();
396
397 if let Some(active) = active_channel {
398 debug_assert_eq!(
399 program.spell(active.local).spell_id,
400 active.spell.as_u32(),
401 "active channel identity must match its finalized local spell"
402 );
403 let slot = state.buffer.spell_mut_at(active.handle);
404
405 slot.is_channeling = 0;
406 slot.channel_end = now.as_secs_f64();
407
408 #[cfg(feature = "rotation-optimizations")]
409 state.runtime.rotation_mutations.record_semantic(
410 crate::state::runtime::RotationSemanticMutation::SpellState,
411 crate::state::runtime::RotationMutationDirection::Fall,
412 now,
413 );
414 }
415}
416
417#[cfg(test)]
418mod tests;