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