Skip to main content

wowlab_engine_combat/systems/
casting.rs

1use 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
233// #t(fn: rust_cyclomatic_complexity) pushback validates actor, finalized candidate, resistance, limits, and typed rescheduling as one atomic cast-state transition.
234pub(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;