Skip to main content

wowlab_engine_combat/systems/
casting.rs

1use 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;