1use 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
99pub(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
171pub(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 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 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
526pub(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;