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