Skip to main content

wowlab_engine_combat/systems/
auto_attack.rs

1//! Auto-attack swing scheduling, damage, and earliest-swing queries.
2
3use wowlab_engine_domain::{constants::OFF_HAND_DAMAGE_MULT, damage::WEAPON_POWER_COEFFICIENT};
4use wowlab_engine_ports::Event;
5#[cfg(test)]
6use wowlab_engine_telemetry::TelemetrySink;
7#[cfg(test)]
8use wowlab_types::sim::SpellIdx;
9use wowlab_types::{
10    constants::MS_PER_SECOND,
11    sim::{ActorId, EnemyIdx, SimTime},
12};
13
14use super::{
15    auras::drain_deferred_work,
16    buffs::{
17        active_auto_attack_replacement, active_form_combat_round_time_ms,
18        current_auto_attack_mult_for,
19    },
20    cooldowns::reduce_cooldown,
21    damage_pipeline::{
22        ProfiledDamageDef, WeaponDamageInput, deal_bound_profiled_damage_def,
23        deal_damage_ap_with_weapon,
24    },
25};
26use crate::{
27    DamageFlags,
28    context::{CombatCtx, HookCtx},
29    state::{CombatInstance, RuntimeDamageDef, SwingEvent, SwingHand},
30};
31
32const DUAL_WIELD_WHITE_MISS_CHANCE: f64 = 0.19;
33const RAGE_PER_SWING_SECOND: f64 = 1.75;
34
35fn set_player_auto_attacks_active(
36    program: &crate::state::CombatProgram,
37    state: &mut CombatInstance,
38    active: bool,
39    now: SimTime,
40) -> bool {
41    let mut changed = false;
42    let form_swing_ms = active_form_combat_round_time_ms(program, state);
43
44    for (index, attack) in program.auto_attacks.iter().copied().enumerate() {
45        if attack.is_pet {
46            continue;
47        }
48
49        let weapon_swing_ms = program
50            .damage
51            .auto_attack_profile(index)
52            .and_then(|profile| profile.weapon)
53            .map(|weapon| weapon.speed_ms);
54
55        let swing_ms = form_swing_ms
56            .or(weapon_swing_ms)
57            .filter(|speed| *speed > 0)
58            .unwrap_or(attack.swing_ms);
59        let is_scheduled = program
60            .buffer_swing(state, index)
61            .is_some_and(|swing| swing.next_swing_at.is_finite());
62
63        if active {
64            if !is_scheduled {
65                reschedule_swing(program, state, index, swing_ms, ActorId::Player, now);
66                changed = true;
67            }
68        } else if is_scheduled && let Some(swing) = program.buffer_swing_mut(state, index) {
69            swing.next_swing_at = f64::INFINITY;
70            changed = true;
71        }
72    }
73
74    changed
75}
76
77/// Apply DBC action attributes that start or cancel the player's shared swing scheduler.
78pub(crate) fn apply_auto_attack_action_lifecycle(
79    program: &crate::state::CombatProgram,
80    state: &mut CombatInstance,
81    action: crate::state::program::LocalDamageActionIdx,
82    source: ActorId,
83    target: EnemyIdx,
84    now: SimTime,
85) {
86    if source != ActorId::Player {
87        return;
88    }
89
90    let action = program.damage.action_at(action);
91    let attributes = action.attributes.as_ref();
92    let cancels = wowlab_engine_domain::dbc::spell_attribute_is(
93        attributes,
94        wowlab_engine_domain::dbc::SpellAttributeKind::CancelsAutoAttack,
95    );
96
97    if cancels {
98        set_player_auto_attacks_active(program, state, false, now);
99
100        return;
101    }
102
103    let initiates = wowlab_engine_domain::dbc::spell_attribute_is(
104        attributes,
105        wowlab_engine_domain::dbc::SpellAttributeKind::InitiatesAutoAttack,
106    ) || action.has_attack_effect;
107
108    if initiates
109        && set_player_auto_attacks_active(program, state, true, now)
110        && let Some(t) = earliest_auto_attack_time(program, state)
111    {
112        state.schedule(Event::AutoAttack { t, source, target });
113    }
114}
115
116fn replacement_damage(
117    program: &crate::state::CombatProgram,
118    _state: &CombatInstance,
119    binding: crate::state::program::DamageRequestBinding,
120    damage_multiplier: f64,
121) -> Option<(u32, Option<f64>, RuntimeDamageDef)> {
122    let action = program.damage.action_at(binding.effect_action());
123    let spell_id = action.spell.as_u32();
124
125    let (base_points, damage) = program
126        .damage
127        .resolved_action_damage(binding.effect_action())?;
128    let base_points = Some(base_points * damage_multiplier);
129
130    Some((spell_id, base_points, damage.scaled(damage_multiplier)))
131}
132
133// #t(fn: rust_unchecked_indexing) i bounded by auto_attacks.len(); target_local from on_crit_reduce_charge validated at build time
134// #t(fn: rust_cyclomatic_complexity) inherent auto-attack branching: pet vs MH vs OH, armed vs unarmed, crit procs
135// #t(fn: rust_max_fn_lines) the swing pipeline is a single ordered state transition.
136pub(crate) fn phase_auto_attacks_for(ctx: &mut CombatCtx<'_>) {
137    let source = ctx.source;
138    let target = ctx.target;
139    let now = ctx.now;
140    let program = ctx.program;
141    let state = &mut *ctx.instance;
142    let sink = &mut *ctx.sink;
143    let rng = &mut *ctx.rng;
144    let now_secs = now.as_secs_f64();
145
146    for i in 0..program.auto_attacks.len() {
147        let aa = program.auto_attacks[i];
148        let Some(auto_profile) = program.damage.auto_attack_profile(i) else {
149            continue;
150        };
151        let next_at_secs = match program.buffer_swing(state, i) {
152            Some(sw) => sw.next_swing_at,
153            None => continue,
154        };
155
156        if now_secs < next_at_secs {
157            continue;
158        }
159
160        let weapon_slot = auto_profile.weapon;
161        let hand = auto_profile.hand;
162        let hand_mult = if hand == SwingHand::OffHand {
163            OFF_HAND_DAMAGE_MULT
164        } else {
165            1.0
166        };
167        let auto_binding = auto_profile.request;
168        let weapon_multiplier = auto_profile.weapon_multiplier;
169        let normalized = auto_profile.normalized;
170
171        // A shifted form replaces the weapon with a beast weapon: its DBC combat round time becomes
172        // the swing period, and the weapon's damage range is renormalized to it.
173        let form_swing_ms = (!aa.is_pet)
174            .then(|| active_form_combat_round_time_ms(program, state))
175            .flatten();
176
177        let (mut weapon_input, swing_ms, mut ap_coef) = match weapon_slot {
178            Some(w) if w.speed_ms > 0 && w.max_damage > 0.0 => {
179                let multiplier = hand_mult * weapon_multiplier;
180                let swing_ms = form_swing_ms.unwrap_or(w.speed_ms);
181                let normalizing_factor = f64::from(swing_ms) / f64::from(w.speed_ms);
182                let speed_s = match form_swing_ms {
183                    Some(round_time_ms) => f64::from(round_time_ms) / MS_PER_SECOND,
184                    None if normalized => w.normalized_speed_s,
185                    None => f64::from(w.speed_ms) / MS_PER_SECOND,
186                };
187
188                (
189                    WeaponDamageInput {
190                        min: w.min_damage * normalizing_factor,
191                        max: w.max_damage * normalizing_factor,
192                        multiplier,
193                        ap_coefficient: speed_s / WEAPON_POWER_COEFFICIENT * multiplier,
194                        is_auto_attack: true,
195                        swing_hand: Some(hand),
196                        swing_speed_ms: swing_ms,
197                        miss_chance: 0.0,
198                    },
199                    swing_ms,
200                    0.0,
201                )
202            }
203            Some(w) if w.speed_ms > 0 => (
204                WeaponDamageInput::none(),
205                form_swing_ms.unwrap_or(w.speed_ms),
206                aa.ap_coef,
207            ),
208            Some(_) => {
209                continue;
210            }
211            None if aa.is_pet => (WeaponDamageInput::none(), aa.swing_ms, aa.ap_coef),
212            None => continue,
213        };
214
215        weapon_input.is_auto_attack = true;
216        weapon_input.swing_hand = Some(hand);
217        weapon_input.swing_speed_ms = swing_ms;
218
219        let miss_chance = aa.miss_chance_hook.and_then(|hook| {
220            let hook_ctx = HookCtx::new(
221                crate::context::HookCtxServices {
222                    program,
223                    instance: state,
224                    sink,
225                    rng,
226                },
227                crate::context::HookCtxRequest::for_target(now, target).with_source(source),
228            );
229
230            hook(&hook_ctx, hand)
231        });
232
233        weapon_input.miss_chance = if !aa.is_pet && auto_profile.dual_wield {
234            miss_chance.unwrap_or(DUAL_WIELD_WHITE_MISS_CHANCE)
235        } else {
236            miss_chance.unwrap_or(0.0)
237        };
238
239        let is_physical = auto_profile.is_physical;
240        let mut flags = if is_physical {
241            DamageFlags::PHYSICAL
242        } else {
243            DamageFlags::empty()
244        };
245
246        if aa.is_pet {
247            flags |= DamageFlags::PET;
248
249            ap_coef *= if aa.uses_spell_power {
250                auto_profile.stat_inheritance.spell_power
251            } else {
252                auto_profile.stat_inheritance.attack_power
253            };
254        }
255
256        let aura_replacement = (!aa.is_pet && hand == SwingHand::MainHand)
257            .then(|| active_auto_attack_replacement(program, state))
258            .flatten()
259            .map(|binding| (binding, 1.0));
260        let selected_replacement = aura_replacement.or_else(|| {
261            let hook = aa.replacement_hook?;
262
263            let replacement = hook(
264                &mut HookCtx::new(
265                    crate::context::HookCtxServices {
266                        program,
267                        instance: state,
268                        sink,
269                        rng,
270                    },
271                    crate::context::HookCtxRequest::for_target(now, target).with_source(source),
272                ),
273                hand,
274            )?;
275            let binding = program.damage.action_id_request(replacement.action)?;
276
277            Some((binding, replacement.damage_multiplier))
278        });
279        let replacement = selected_replacement.and_then(|(binding, multiplier)| {
280            replacement_damage(program, state, binding, multiplier).map(|damage| (binding, damage))
281        });
282        let impact_source = if aa.is_pet {
283            ActorId::Pet(wowlab_types::sim::PetIdx::PRIMARY)
284        } else {
285            source
286        };
287        let rage_gain = (!aa.is_pet
288            && program.config.base_stats.resource_type
289                == Some(wowlab_types::combat::ResourceType::Rage))
290        .then(|| {
291            f64::from(swing_ms) / MS_PER_SECOND
292                * RAGE_PER_SWING_SECOND
293                * hand_mult
294                * program.config.base_stats.swing_resource_gain_mult
295        });
296        let mut damage_ctx = CombatCtx {
297            program,
298            instance: state,
299            sink,
300            now,
301            rng,
302            source: impact_source,
303            target,
304        };
305        let (final_dmg, is_crit) =
306            if let Some((binding, (replacement_id, base_points, damage))) = replacement {
307                let effect = crate::state::DamageEffectRef::new(replacement_id, 1);
308
309                deal_bound_profiled_damage_def(
310                    &mut damage_ctx,
311                    ProfiledDamageDef {
312                        binding,
313                        effect,
314                        geometry_effect: effect,
315                        profile_spell_id: replacement_id,
316                        base_points,
317                        def: damage,
318                        flags: DamageFlags::empty(),
319                        source_position: None,
320                        destination: None,
321                    },
322                )
323            } else if aa.uses_spell_power {
324                super::deal_bound_damage_sp(
325                    &mut damage_ctx,
326                    auto_binding,
327                    super::DamagePayload::new(aa.spell_id, ap_coef, flags),
328                )
329            } else {
330                deal_damage_ap_with_weapon(
331                    &mut damage_ctx,
332                    auto_binding,
333                    super::DamagePayload::new(aa.spell_id, ap_coef, flags),
334                    weapon_input,
335                )
336            };
337
338        if final_dmg <= 0.0 {
339            reschedule_swing(program, state, i, swing_ms, impact_source, now);
340            continue;
341        }
342
343        if let Some(rage) = rage_gain {
344            // Haste changes swing frequency, not rage per swing.
345            super::gain_resource_with_procs(
346                &mut damage_ctx,
347                crate::state::ResourceGain::modified(
348                    rage,
349                    crate::state::ResourceGainSource::AutoAttack,
350                ),
351                false,
352            );
353        }
354
355        if let Some(hook_fn) = aa.on_swing {
356            let source_flags = if aa.is_pet {
357                DamageFlags::PET
358            } else {
359                DamageFlags::empty()
360            };
361            let mut hook_ctx = HookCtx::new(
362                crate::context::HookCtxServices {
363                    program,
364                    instance: state,
365                    sink,
366                    rng,
367                },
368                crate::context::HookCtxRequest::for_target(now, target).with_source(impact_source),
369            )
370            .with_driver_spell(aa.spell_id)
371            .with_source_damage_flags(source_flags)
372            .with_source_npc_id(aa.npc_id)
373            .with_pet_stat_inheritance(auto_profile.stat_inheritance);
374
375            hook_fn(&mut hook_ctx, SwingEvent { hand, is_crit });
376        }
377
378        if is_crit
379            && let Some(reduction) = aa.on_crit_reduce_charge
380            && wowlab_engine_rng::proc_chance(rng, reduction.chance)
381        {
382            reduce_cooldown(
383                program,
384                state,
385                reduction.target,
386                program.spells[reduction.target.as_usize()]
387                    .cooldown
388                    .recharge_ms,
389                now,
390            );
391        }
392
393        reschedule_swing(program, state, i, swing_ms, impact_source, now);
394    }
395
396    drain_deferred_work(&mut HookCtx::new(
397        crate::context::HookCtxServices {
398            program,
399            instance: state,
400            sink,
401            rng,
402        },
403        crate::context::HookCtxRequest::for_target(now, target).with_source(source),
404    ));
405}
406
407#[cfg(test)]
408fn phase_auto_attacks_fixture(
409    program: &crate::state::CombatProgram,
410    state: &mut CombatInstance,
411    now: SimTime,
412    rng: &mut dyn FnMut() -> f64,
413    sink: &mut TelemetrySink,
414) {
415    let Some(target) = state.current_target() else {
416        return;
417    };
418    let mut ctx = CombatCtx {
419        program,
420        instance: state,
421        sink,
422        now,
423        rng,
424        source: ActorId::Player,
425        target,
426    };
427
428    phase_auto_attacks_for(&mut ctx);
429}
430
431fn reschedule_swing(
432    program: &crate::state::CombatProgram,
433    state: &mut CombatInstance,
434    index: usize,
435    swing_ms: u32,
436    source: ActorId,
437    now: SimTime,
438) {
439    let swing_mult =
440        current_auto_attack_mult_for(crate::context::ActorView::new(program, state, source));
441    let scaled_swing =
442        wowlab_types::numeric::f64_to_u32_saturating_trunc(f64::from(swing_ms) / swing_mult);
443    let next_swing = now.saturating_add(SimTime::from_millis(scaled_swing));
444
445    if let Some(sw) = program.buffer_swing_mut(state, index) {
446        sw.next_swing_at = next_swing.as_secs_f64();
447        sw.speed = f64::from(scaled_swing) / MS_PER_SECOND;
448        sw.swing_ms = f64::from(swing_ms);
449    }
450}
451
452/// Preserves elapsed swing progress when live haste, attack speed, or form speed changes.
453pub(crate) fn reproject_player_auto_attack_speeds(
454    program: &crate::state::CombatProgram,
455    state: &mut CombatInstance,
456    now: SimTime,
457) {
458    let speed_multiplier = current_auto_attack_mult_for(crate::context::ActorView::new(
459        program,
460        state,
461        ActorId::Player,
462    ));
463
464    if speed_multiplier <= 0.0 {
465        return;
466    }
467
468    let form_swing_ms = active_form_combat_round_time_ms(program, state);
469    let now_secs = now.as_secs_f64();
470    let mut changed = false;
471
472    for (index, attack) in program.auto_attacks.iter().enumerate() {
473        if attack.is_pet {
474            continue;
475        }
476
477        let weapon_swing_ms = program
478            .damage
479            .auto_attack_profile(index)
480            .and_then(|profile| profile.weapon)
481            .map(|weapon| weapon.speed_ms);
482
483        let Some(base_swing_ms) = form_swing_ms.or(weapon_swing_ms).filter(|speed| *speed > 0)
484        else {
485            continue;
486        };
487        let desired_speed = f64::from(base_swing_ms) / MS_PER_SECOND / speed_multiplier;
488        let Some(swing) = program.buffer_swing_mut(state, index) else {
489            continue;
490        };
491        let previous_speed = if swing.speed > 0.0 {
492            swing.speed
493        } else {
494            desired_speed
495        };
496
497        if swing.next_swing_at.is_finite()
498            && swing.next_swing_at > now_secs
499            && (desired_speed - previous_speed).abs() > f64::EPSILON
500        {
501            let remaining = swing.next_swing_at - now_secs;
502            let projected_remaining_ms = wowlab_types::numeric::f64_to_u32_saturating_round(
503                remaining * desired_speed / previous_speed * MS_PER_SECOND,
504            )
505            .max(1);
506
507            swing.next_swing_at = now
508                .saturating_add(SimTime::from_millis(projected_remaining_ms))
509                .as_secs_f64();
510            changed = true;
511        }
512
513        swing.speed = desired_speed;
514        swing.swing_ms = f64::from(base_swing_ms);
515    }
516
517    if changed
518        && let (Some(next), Some(target)) = (
519            earliest_auto_attack_time(program, state),
520            state.current_target(),
521        )
522    {
523        state.schedule(Event::AutoAttack {
524            t: next,
525            source: ActorId::Player,
526            target,
527        });
528    }
529}
530
531pub(crate) fn reproject_pet_auto_attack_speeds(
532    program: &crate::state::CombatProgram,
533    state: &mut CombatInstance,
534    now: SimTime,
535) {
536    let source = ActorId::Pet(wowlab_types::sim::PetIdx::PRIMARY);
537    let multiplier =
538        current_auto_attack_mult_for(crate::context::ActorView::new(program, state, source));
539
540    if multiplier <= 0.0 {
541        return;
542    }
543
544    let now_secs = now.as_secs_f64();
545    let mut earliest = None::<SimTime>;
546
547    for index in program.pet_auto_attacks.iter().copied().map(usize::from) {
548        // BOUNDS: finalization derives this slice from the same auto-attack plan.
549        let attack = &program.auto_attacks[index];
550
551        let desired = f64::from(attack.swing_ms) / MS_PER_SECOND / multiplier;
552        let Some(swing) = program.buffer_swing_mut(state, index) else {
553            continue;
554        };
555        let previous = if swing.speed > 0.0 {
556            swing.speed
557        } else {
558            desired
559        };
560
561        if swing.next_swing_at.is_finite()
562            && swing.next_swing_at > now_secs
563            && (desired - previous).abs() > f64::EPSILON
564        {
565            let remaining = swing.next_swing_at - now_secs;
566            let remaining_ms = wowlab_types::numeric::f64_to_u32_saturating_round(
567                remaining * desired / previous * MS_PER_SECOND,
568            )
569            .max(1);
570
571            swing.next_swing_at = now
572                .saturating_add(SimTime::from_millis(remaining_ms))
573                .as_secs_f64();
574        }
575
576        swing.speed = desired;
577        swing.swing_ms = f64::from(attack.swing_ms);
578
579        if swing.next_swing_at.is_finite() {
580            let next = SimTime::from_secs_f64(swing.next_swing_at);
581
582            earliest = Some(earliest.map_or(next, |current| current.min(next)));
583        }
584    }
585
586    if let (Some(t), Some(target)) = (earliest, state.current_target()) {
587        state.schedule(Event::AutoAttack { t, source, target });
588    }
589}
590
591pub(crate) fn earliest_auto_attack_time(
592    program: &crate::state::CombatProgram,
593    state: &CombatInstance,
594) -> Option<SimTime> {
595    if program.auto_attacks.is_empty() {
596        return None;
597    }
598
599    let mut min_time: Option<SimTime> = None;
600
601    for i in 0..program.auto_attacks.len() {
602        if let Some(sw) = program.buffer_swing(state, i) {
603            if !sw.next_swing_at.is_finite() {
604                continue;
605            }
606
607            let t = SimTime::from_secs_f64(sw.next_swing_at);
608
609            min_time = Some(match min_time {
610                Some(prev) => prev.min(t),
611                None => t,
612            });
613        }
614    }
615
616    min_time
617}
618
619#[cfg(test)]
620#[path = "auto_attack/tests.rs"]
621mod tests;