1use 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
77pub(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
133pub(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 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 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
452pub(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 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;