1use wowlab_engine_domain::{
2 damage::{AttackTable, DamageCalc},
3 targeting::{CandidateFilter, ChainTargetOrigin, ResolvedTargetShape, SingleTargetSelector},
4};
5use wowlab_engine_telemetry::{DamageEvent, DamageEventFlags, DamageObservation};
6use wowlab_types::{
7 constants::HUNDRED,
8 sim::{ActorId, EnemyIdx},
9};
10
11#[cfg(test)]
12use super::DamageRequest;
13use super::{
14 BoundDamageRequest, DamageFlags, emit_damage_telemetry, finalize_damage, finalize_damage_with,
15 setup::{
16 DamageSetup, prepare_bound_damage_setup_for, prepare_damage_batch_setup,
17 prepare_damage_target_setup,
18 },
19};
20use crate::{
21 context::CombatCtx,
22 state::{DamageSource, ImpactEvent},
23 systems::{
24 aoe::{AoeConfig, active_damage_replication, aoe_falloff_multiplier, resolve_hit_count},
25 procs::{fire_impact_procs, fire_landed_impact_batch},
26 },
27 targeting::{SpellTargetRequest, resolve_spell_targets_into, resolve_targets_into},
28};
29
30#[derive(Clone, Copy)]
31struct ReplicationImpact {
32 physical: bool,
33 indirect: bool,
34}
35
36impl ReplicationImpact {
37 fn is_accepted_by(
38 self,
39 replication: &crate::state::program::FinalizedDamageReplication,
40 source: crate::state::program::LocalDamageActionIdx,
41 ) -> bool {
42 (self.physical || replication.definition.include_magic)
43 && (!self.indirect || replication.definition.include_indirect)
44 && replication.accepts_source(source)
45 }
46}
47
48fn damage_source(flags: DamageFlags) -> DamageSource {
49 if flags.contains(DamageFlags::GUARDIAN) {
50 DamageSource::Guardian
51 } else if flags.contains(DamageFlags::PET) {
52 DamageSource::Pet
53 } else {
54 DamageSource::Player
55 }
56}
57
58fn companion_passive_damage_mult(
59 pet_damage_mult: f64,
60 guardian_damage_mult: f64,
61 flags: DamageFlags,
62) -> f64 {
63 if flags.contains(DamageFlags::GUARDIAN) {
64 guardian_damage_mult
65 } else if flags.contains(DamageFlags::PET) {
66 pet_damage_mult
67 } else {
68 1.0
69 }
70}
71
72pub(super) fn auto_attack_damage_mult(
73 damage: &crate::state::program::DamagePlan,
74 flags: DamageFlags,
75 active_mult: f64,
76) -> f64 {
77 if flags.intersects(DamageFlags::PET | DamageFlags::GUARDIAN) {
78 active_mult
79 } else {
80 damage.auto_attack_damage_mult * active_mult
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85enum AttackKind {
86 Auto,
87 MeleeSpecial,
88 Other,
89}
90
91struct AttackTablePolicy<'a> {
92 kind: AttackKind,
93 attributes: &'a [i32],
94 position: crate::state::AttackPosition,
95 target_table: wowlab_engine_domain::encounter::EnemyAttackTable,
96 target_level: i32,
97 player_level: i32,
98 white_miss_chance: f64,
99 hit_chance_delta: f64,
100}
101
102fn attack_table(
103 ctx: &CombatCtx<'_>,
104 target: EnemyIdx,
105 request: &BoundDamageRequest,
106) -> AttackTable {
107 if request.flags.contains(DamageFlags::REFLECTED) {
108 return AttackTable::default();
109 }
110
111 let action = ctx.program.damage.action_at(request.binding.profile_action);
112 let attributes = action.attributes.as_ref();
113 let target_table = ctx.program.enemy_definition(target).map_or_else(
114 Default::default,
115 wowlab_engine_domain::encounter::EnemyActorDefinition::attack_table,
116 );
117 let target_level = ctx
118 .program
119 .enemy_definition(target)
120 .map_or(0, |enemy| i32::from(enemy.level()));
121 let player_level = ctx.program.damage.player_level;
122 let kind = if request.weapon.is_auto_attack {
123 AttackKind::Auto
124 } else if wowlab_engine_domain::dbc::DefenseType::try_from(action.defense_type).ok()
125 == Some(wowlab_engine_domain::dbc::DefenseType::Melee)
126 {
127 AttackKind::MeleeSpecial
128 } else {
129 AttackKind::Other
130 };
131 let modified_hit_chance = super::super::buffs::active_spell_modifier_value(
132 ctx.view().for_actor(ctx.source),
133 request.binding.profile_action,
134 wowlab_engine_domain::dbc::ModifierPropertyKind::HitChance,
135 HUNDRED,
136 );
137
138 configured_attack_table(&AttackTablePolicy {
139 kind,
140 attributes,
141 position: ctx.instance.attack_position(),
142 target_table,
143 target_level,
144 player_level,
145 white_miss_chance: request.weapon.miss_chance,
146 hit_chance_delta: (modified_hit_chance - HUNDRED) / HUNDRED,
147 })
148}
149
150fn configured_attack_table(policy: &AttackTablePolicy<'_>) -> AttackTable {
151 const GLANCE_LEVEL_THRESHOLD: i32 = 3;
152 const GLANCE_BASE_CHANCE: f64 = 0.10;
153 const GLANCE_PER_LEVEL_CHANCE: f64 = 0.10;
154
155 if policy.kind == AttackKind::Other {
156 return AttackTable::default();
157 }
158
159 let has = |kind| wowlab_engine_domain::dbc::spell_attribute_is(policy.attributes, kind);
160 let behind = policy.position == crate::state::AttackPosition::Behind;
161 let level_delta = policy.target_level.saturating_sub(policy.player_level);
162 let glance_chance = if policy.kind == AttackKind::Auto
163 && policy.player_level > 0
164 && level_delta >= GLANCE_LEVEL_THRESHOLD
165 {
166 GLANCE_BASE_CHANCE + GLANCE_PER_LEVEL_CHANCE * f64::from(level_delta)
167 } else {
168 0.0
169 };
170
171 AttackTable {
172 miss_chance: if has(wowlab_engine_domain::dbc::SpellAttributeKind::AlwaysHit)
173 || has(wowlab_engine_domain::dbc::SpellAttributeKind::CannotMiss)
174 {
175 0.0
176 } else if policy.kind == AttackKind::Auto {
177 (policy.white_miss_chance - policy.hit_chance_delta).max(0.0)
178 } else {
179 (-policy.hit_chance_delta).max(0.0)
180 },
181 dodge_chance: if behind || has(wowlab_engine_domain::dbc::SpellAttributeKind::CannotDodge) {
182 0.0
183 } else {
184 policy.target_table.dodge_chance
185 },
186 parry_chance: if behind || has(wowlab_engine_domain::dbc::SpellAttributeKind::CannotParry) {
187 0.0
188 } else {
189 policy.target_table.parry_chance
190 },
191 glance_chance,
192 block_chance: if behind || has(wowlab_engine_domain::dbc::SpellAttributeKind::CannotBlock) {
193 0.0
194 } else {
195 policy.target_table.block_chance
196 },
197 critical_block_chance: policy.target_table.critical_block_chance,
198 ..AttackTable::default()
199 }
200}
201
202pub(super) fn deal_damage(ctx: &mut CombatCtx<'_>, mut request: BoundDamageRequest) -> (f64, bool) {
205 if !ctx.instance.is_valid_target(ctx.target) {
206 return (0.0, false);
207 }
208
209 if !request.weapon.is_auto_attack {
210 let Some(flags) =
211 super::resolve_spell_impact_flags(ctx, request.binding.profile_action, request.flags)
212 else {
213 return (0.0, false);
214 };
215
216 request.flags = flags;
217 }
218
219 let batch = prepare_damage_batch_setup(ctx.program, ctx.instance, &request, ctx.source);
220 let setup = prepare_damage_target_setup(ctx.program, ctx.instance, &batch, ctx.target, ctx.now);
221
222 let run_aoe = setup.aoe.is_aoe && !request.flags.contains(DamageFlags::REFLECTED);
223
224 debug_assert!(
225 !(setup.aoe.split && setup.aoe.chain_targeting),
226 "AoE spell {} has both split and chain targeting; split wins but the manifest is ambiguous",
227 request.effect.spell_id,
228 );
229
230 if run_aoe {
231 let Some(mut target_workspace) = crate::targeting::take_target_workspace(ctx.instance)
232 else {
233 ctx.instance.record_runtime_error(
234 wowlab_engine_ports::SpecRuntimeError::damage_target_resolution(
235 request.effect.spell_id,
236 request.effect.effect_index,
237 crate::targeting::TARGET_WORKSPACE_EXHAUSTED.to_string(),
238 ),
239 );
240
241 return (0.0, false);
242 };
243
244 if let Err(error) = resolve_aoe_targets(ctx, &request, &setup.aoe, &mut target_workspace) {
245 crate::targeting::restore_target_workspace(ctx.instance, target_workspace);
246 ctx.instance.record_runtime_error(
247 wowlab_engine_ports::SpecRuntimeError::damage_target_resolution(
248 request.effect.spell_id,
249 request.effect.effect_index,
250 error.to_string(),
251 ),
252 );
253
254 return (0.0, false);
255 }
256
257 let hits = resolve_hit_count(target_workspace.targets().len(), &setup.aoe);
258
259 if hits == 0 {
260 crate::targeting::restore_target_workspace(ctx.instance, target_workspace);
261
262 return (0.0, false);
263 }
264
265 let mut first_result = (0.0, false);
266 let retain_landed_impacts = ctx.program.damage.retains_landed_impacts
267 && !ctx.instance.runtime.procs.landed_impact_procs_dispatching;
268 let mut landed_impacts = retain_landed_impacts.then(|| {
269 let mut impacts =
270 std::mem::take(&mut ctx.instance.runtime.scratch.landed_impact_events);
271
272 impacts.clear();
273 debug_assert!(
274 impacts.capacity() >= usize::from(hits),
275 "landed-impact event scratch must cover the bounded damage batch"
276 );
277
278 impacts
279 });
280
281 for k in 0..usize::from(hits) {
282 let Some(target) = target_workspace.targets().get(k).copied() else {
283 break;
284 };
285 let target_ordinal = u8::try_from(k).expect("hit count is bounded by u8");
286 let mult = aoe_falloff_multiplier(target_ordinal, hits, &setup.aoe);
287 let target_setup = if target == ctx.target {
288 setup
289 } else {
290 prepare_damage_target_setup(ctx.program, ctx.instance, &batch, target, ctx.now)
291 };
292 let hit = run_single_hit_collect(ctx, target, &target_setup, &request, mult);
293
294 replicate_resolved_damage(
295 ctx,
296 target,
297 &request,
298 ReplicationImpact {
299 physical: target_setup.is_physical,
300 indirect: true,
301 },
302 hit.amount,
303 );
304
305 if k == 0 {
306 first_result = hit.into_tuple();
307 }
308
309 if let (Some(impacts), Some(impact)) = (landed_impacts.as_mut(), hit.landed_impact()) {
310 impacts.push(impact);
311 }
312 }
313
314 if let Some(mut impacts) = landed_impacts {
315 fire_landed_impact_batch(ctx, &impacts);
316 impacts.clear();
317 ctx.instance.runtime.scratch.landed_impact_events = impacts;
318 }
319
320 crate::targeting::restore_target_workspace(ctx.instance, target_workspace);
321
322 first_result
323 } else {
324 let primary = run_single_hit(ctx, ctx.target, &setup, &request, 1.0);
325
326 replicate_resolved_damage(
327 ctx,
328 ctx.target,
329 &request,
330 ReplicationImpact {
331 physical: setup.is_physical,
332 indirect: false,
333 },
334 primary.0,
335 );
336
337 primary
338 }
339}
340
341fn replicate_resolved_damage(
344 ctx: &mut CombatCtx<'_>,
345 source_target: EnemyIdx,
346 request: &BoundDamageRequest,
347 impact: ReplicationImpact,
348 amount: f64,
349) {
350 if amount <= 0.0 || !ctx.program.damage.has_replication {
351 return;
352 }
353
354 let source = damage_source(request.flags);
355
356 let Some(replication) = active_damage_replication(ctx.program, ctx.instance, source) else {
357 tracing::trace!(?source, "no active damage replication for impact source");
358
359 return;
360 };
361
362 if !impact.is_accepted_by(replication, request.binding.effect_action()) {
363 return;
364 }
365
366 let binding = replication.request;
367 let action = ctx.program.damage.action_at(binding.effect_action());
368 let replicated_periodic = action.treat_as_periodic;
369 let passive = action.passive;
370 let replication_passive_mult = if replicated_periodic {
371 passive.periodic_damage_mult
372 } else {
373 passive.direct_damage_mult
374 };
375 let geometry_effect = binding.geometry_effect;
376 let replication_binding = binding;
377 let impact_action = binding.effect_action();
378 let target = binding.target;
379 let replication = replication.definition;
380 let Some(mut target_workspace) = crate::targeting::take_target_workspace(ctx.instance) else {
381 ctx.instance.record_runtime_error(
382 wowlab_engine_ports::SpecRuntimeError::damage_target_resolution(
383 replication.effect.spell_id,
384 replication.effect.effect_index,
385 crate::targeting::TARGET_WORKSPACE_EXHAUSTED.to_string(),
386 ),
387 );
388
389 return;
390 };
391 let resolution = resolve_spell_targets_into(
392 ctx.program,
393 ctx.instance,
394 SpellTargetRequest {
395 source: ctx.source,
396 source_position: None,
397 anchor: source_target,
398 geometry_effect,
399 impact_action,
400 target,
401 max_targets: None,
402 destination: None,
403 },
404 ctx.now,
405 Some(ctx.rng),
406 &mut target_workspace,
407 );
408
409 if let Err(error) = resolution {
410 crate::targeting::restore_target_workspace(ctx.instance, target_workspace);
411 ctx.instance.record_runtime_error(
412 wowlab_engine_ports::SpecRuntimeError::damage_target_resolution(
413 replication.effect.spell_id,
414 replication.effect.effect_index,
415 error.to_string(),
416 ),
417 );
418
419 return;
420 }
421
422 let max_targets = if replication.max_targets == 0 {
423 usize::MAX
424 } else {
425 usize::from(replication.max_targets)
426 };
427 let hits = u8::try_from(
428 target_workspace
429 .targets()
430 .iter()
431 .filter(|target| replication.include_primary || **target != source_target)
432 .take(max_targets)
433 .count(),
434 )
435 .unwrap_or(u8::MAX);
436
437 tracing::trace!(
438 source_spell_id = request.effect.spell_id,
439 replication_spell_id = replication.effect.spell_id,
440 fraction = replication.fraction,
441 replication_passive_mult,
442 target_count = hits,
443 "resolved damage replication targets"
444 );
445 let aoe = AoeConfig {
446 base_mult: 1.0,
447 reduced_targets: replication.reduced_targets,
448 ..AoeConfig::default()
449 };
450
451 let mut ordinal = 0_usize;
452
453 for index in 0..target_workspace.targets().len() {
454 let Some(target) = target_workspace.targets().get(index).copied() else {
455 continue;
456 };
457
458 if (!replication.include_primary && target == source_target) || ordinal >= max_targets {
459 continue;
460 }
461
462 let target_ordinal = u8::try_from(ordinal).unwrap_or(u8::MAX);
463
464 ordinal = ordinal.saturating_add(1);
465 let copied = amount
466 * replication.fraction
467 * replication_passive_mult
468 * aoe_falloff_multiplier(target_ordinal, hits, &aoe);
469
470 if copied <= 0.0 || !ctx.instance.is_valid_target(target) {
471 continue;
472 }
473
474 let Some(scope) = ctx.program.telemetry_scope(ctx.source, target) else {
475 continue;
476 };
477
478 finalize_damage(
479 ctx,
480 target,
481 replication_binding,
482 &DamageEvent::new(
483 DamageObservation {
484 spell_id: replication.effect.spell_id,
485 amount: copied,
486 flags: DamageEventFlags::default()
487 .with_pet(request.flags.contains(DamageFlags::PET)),
488 },
489 ctx.now.as_millis(),
490 scope,
491 ),
492 );
493 }
494
495 crate::targeting::restore_target_workspace(ctx.instance, target_workspace);
496}
497
498pub(super) fn deal_damage_to_target(
499 ctx: &mut CombatCtx<'_>,
500 target: EnemyIdx,
501 mut request: BoundDamageRequest,
502) -> (f64, bool) {
503 if !ctx.instance.is_valid_target(target) || ctx.instance.enemy_is_invulnerable(target, ctx.now)
504 {
505 return (0.0, false);
506 }
507
508 if !request.weapon.is_auto_attack {
509 let previous_target = std::mem::replace(&mut ctx.target, target);
510 let flags =
511 super::resolve_spell_impact_flags(ctx, request.binding.profile_action, request.flags);
512
513 ctx.target = previous_target;
514 let Some(flags) = flags else {
515 return (0.0, false);
516 };
517
518 request.flags = flags;
519 }
520
521 let setup = prepare_bound_damage_setup_for(
522 ctx.program,
523 ctx.instance,
524 &request,
525 ctx.source,
526 target,
527 ctx.now,
528 );
529 let result = run_single_hit(ctx, target, &setup, &request, 1.0);
530
531 replicate_resolved_damage(
532 ctx,
533 target,
534 &request,
535 ReplicationImpact {
536 physical: setup.is_physical,
537 indirect: true,
538 },
539 result.0,
540 );
541
542 result
543}
544
545fn resolve_aoe_targets(
546 ctx: &mut CombatCtx<'_>,
547 request: &BoundDamageRequest,
548 aoe: &AoeConfig,
549 workspace: &mut crate::state::TargetWorkspace,
550) -> Result<(), wowlab_engine_domain::targeting::TargetShapeError> {
551 const DEFAULT_MAGIC_CHAIN_TARGET_RANGE: f64 = 10.0;
552 let binding = request.binding;
553 let geometry_action = ctx
554 .program
555 .damage
556 .action_at(ctx.program.damage.effect_at(binding.geometry_effect).action);
557
558 if aoe.chain_targeting {
559 let jump_radius = crate::systems::active_targeting_value(
560 ctx.view().for_actor(ctx.source),
561 request.binding.geometry_effect_action(&ctx.program.damage),
562 wowlab_engine_domain::dbc::ModifierPropertyKind::ChainTargetRange,
563 Some(geometry_action.chain_target_range)
564 .filter(|range| *range > 0.0)
565 .unwrap_or(DEFAULT_MAGIC_CHAIN_TARGET_RANGE),
566 );
567 let origin = if wowlab_engine_domain::dbc::spell_attribute_is(
568 geometry_action.attributes.as_ref(),
569 wowlab_engine_domain::dbc::SpellAttributeKind::ChainFromCaster,
570 ) {
571 ChainTargetOrigin::Source
572 } else {
573 ChainTargetOrigin::PreviousTarget
574 };
575 let shape = ResolvedTargetShape::Chain {
576 anchor: SingleTargetSelector::Explicit(ctx.target),
577 origin,
578 jump_radius,
579 max_hits: if aoe.max_targets == 0 {
580 u8::MAX
581 } else {
582 aoe.max_targets
583 },
584 };
585
586 return resolve_targets_into(
587 ctx.program,
588 ctx.instance,
589 ctx.source,
590 &shape,
591 &CandidateFilter::default(),
592 crate::targeting::LineOfSightPolicy::from_ignores_line_of_sight(
593 ctx.program
594 .damage
595 .action_at(binding.profile_action)
596 .ignores_line_of_sight,
597 ),
598 ctx.now,
599 workspace,
600 );
601 }
602
603 resolve_spell_targets_into(
604 ctx.program,
605 ctx.instance,
606 SpellTargetRequest {
607 source: ctx.source,
608 source_position: request.source_position,
609 anchor: ctx.target,
610 geometry_effect: binding.geometry_effect,
611 impact_action: binding.profile_action,
612 target: binding.target,
613 max_targets: None,
614 destination: request.destination,
615 },
616 ctx.now,
617 Some(ctx.rng),
618 workspace,
619 )
620}
621
622#[derive(Clone, Copy)]
623struct SingleHitResult {
624 amount: f64,
625 is_crit: bool,
626 impact: Option<ImpactEvent>,
627}
628
629impl SingleHitResult {
630 const fn into_tuple(self) -> (f64, bool) {
631 (self.amount, self.is_crit)
632 }
633
634 fn landed_impact(self) -> Option<ImpactEvent> {
635 self.impact
636 .filter(|impact| is_landed_impact(impact.hit_mask))
637 }
638}
639
640fn is_landed_impact(hit_mask: crate::state::ProcHitMask) -> bool {
641 hit_mask.contains(crate::state::ProcHitMask::LANDED)
642}
643
644pub(super) fn run_single_hit(
645 ctx: &mut CombatCtx<'_>,
646 target: EnemyIdx,
647 setup: &DamageSetup,
648 request: &BoundDamageRequest,
649 aoe_falloff_mult: f64,
650) -> (f64, bool) {
651 let hit = run_single_hit_collect(ctx, target, setup, request, aoe_falloff_mult);
652
653 if ctx.program.damage.retains_landed_impacts
654 && let Some(impact) = hit.landed_impact()
655 {
656 fire_landed_impact_batch(ctx, std::slice::from_ref(&impact));
657 }
658
659 hit.into_tuple()
660}
661
662fn run_single_hit_collect(
663 ctx: &mut CombatCtx<'_>,
664 target: EnemyIdx,
665 setup: &DamageSetup,
666 request: &BoundDamageRequest,
667 aoe_falloff_mult: f64,
668) -> SingleHitResult {
669 run_single_hit_collect_with_emitter(
670 ctx,
671 target,
672 setup,
673 request,
674 aoe_falloff_mult,
675 emit_damage_telemetry,
676 )
677}
678
679#[cfg(test)]
680pub(super) fn run_single_hit_with_emitter(
681 ctx: &mut CombatCtx<'_>,
682 target: EnemyIdx,
683 setup: &DamageSetup,
684 request: &DamageRequest,
685 aoe_falloff_mult: f64,
686 emit: impl FnOnce(
687 ActorId,
688 EnemyIdx,
689 &DamageEvent,
690 &crate::state::CombatProgram,
691 &crate::state::CombatInstance,
692 &mut wowlab_engine_telemetry::TelemetrySink,
693 ),
694) -> (f64, bool) {
695 let binding = ctx
696 .program
697 .damage
698 .diagnostic_bind_request(
699 request.effect,
700 request.geometry_effect,
701 request.profile_spell_id,
702 )
703 .expect("single-hit fixture identities belong to its finalized program");
704 let request = BoundDamageRequest::new(*request, binding);
705 let hit =
706 run_single_hit_collect_with_emitter(ctx, target, setup, &request, aoe_falloff_mult, emit);
707
708 if ctx.program.damage.retains_landed_impacts
709 && let Some(impact) = hit.landed_impact()
710 {
711 fire_landed_impact_batch(ctx, std::slice::from_ref(&impact));
712 }
713
714 hit.into_tuple()
715}
716
717fn run_single_hit_collect_with_emitter(
719 ctx: &mut CombatCtx<'_>,
720 target: EnemyIdx,
721 setup: &DamageSetup,
722 request: &BoundDamageRequest,
723 aoe_falloff_mult: f64,
724 emit: impl FnOnce(
725 ActorId,
726 EnemyIdx,
727 &DamageEvent,
728 &crate::state::CombatProgram,
729 &crate::state::CombatInstance,
730 &mut wowlab_engine_telemetry::TelemetrySink,
731 ),
732) -> SingleHitResult {
733 wowlab_types::bench_metrics::record_damage_target();
734 let flags = request.flags;
735 let weapon = request.weapon;
736 let (base_mult, auto_attack_mult) = if weapon.is_auto_attack {
737 (
738 setup.auto_combined_mult,
739 auto_attack_damage_mult(&ctx.program.damage, flags, setup.auto_attack_buff_mult),
740 )
741 } else {
742 (setup.combined_mult, 1.0)
743 };
744 let pet_mult = companion_passive_damage_mult(
745 ctx.program.damage.pet_damage_mult,
746 ctx.program.damage.guardian_damage_mult,
747 flags,
748 );
749 let calc = DamageCalc {
750 base: setup.base,
751 coefficient: request.coef + setup.power_coefficient_add + weapon.ap_coefficient,
752 attack_power: request.power,
753 crit_chance: if flags.contains(DamageFlags::NO_CRIT) {
754 0.0
755 } else {
756 setup.crit_pct_total / HUNDRED
757 },
758 crit_multiplier: setup.crit_multiplier,
759 attack_table: attack_table(ctx, target, request),
760 versatility: setup.vers_pct_total,
761 target_armor: setup.armor,
762 armor_k: setup.armor_k,
763 damage_multiplier: base_mult * aoe_falloff_mult * auto_attack_mult * pet_mult,
764 mastery_mult: setup.mastery_mult,
765 weapon_min: weapon.min,
766 weapon_max: weapon.max,
767 weapon_multiplier: weapon.multiplier,
768 school: setup.school,
769 };
770
771 tracing::trace!(
772 spell_id = request.effect.spell_id,
773 coef = request.coef,
774 power = request.power,
775 base = setup.base,
776 combined_mult = setup.combined_mult,
777 auto_combined_mult = setup.auto_combined_mult,
778 target_damage_mult = setup.target_damage_mult,
779 mastery_mult = setup.mastery_mult,
780 crit_pct = setup.crit_pct_total,
781 crit_multiplier = setup.crit_multiplier,
782 versatility_pct = setup.vers_pct_total,
783 aoe_falloff_mult,
784 pet_mult,
785 is_periodic = flags.contains(DamageFlags::PERIODIC),
786 is_pet = flags.contains(DamageFlags::PET),
787 "damage calculation inputs"
788 );
789
790 let result = calc.calculate(ctx.rng);
791
792 if flags.contains(DamageFlags::REFLECTED) {
793 let _ = crate::systems::deal_incoming_damage(
794 ctx.program,
795 ctx.instance,
796 crate::systems::IncomingDamage {
797 source: ActorId::Enemy(target),
798 target: ctx.source,
799 amount: result.final_amount,
800 school: setup.school,
801 mechanic_mask: 0,
802 is_aoe: setup.aoe.is_aoe,
803 is_periodic: flags.contains(DamageFlags::PERIODIC),
804 at: ctx.now,
805 },
806 );
807
808 return SingleHitResult {
809 amount: 0.0,
810 is_crit: result.is_crit,
811 impact: None,
812 };
813 }
814
815 let resolved = super::resolve_outgoing_absorbs(
816 ctx.instance,
817 target,
818 setup.school,
819 result.final_amount,
820 result.hit_result,
821 );
822 let proc_event = ImpactEvent {
823 kind: crate::state::ProcEventKind::Spell,
824 phase: crate::state::ProcPhaseMask::HIT,
825 hit_mask: resolved.hit_mask,
826 spell_id: request.effect.spell_id,
827 action: Some(request.binding.effect_action()),
828 amount: resolved.amount,
829 is_periodic: flags.contains(DamageFlags::PERIODIC),
830 is_physical: setup.school == wowlab_types::combat::DamageSchool::Physical,
831 swing_hand: weapon.swing_hand,
832 swing_speed_ms: weapon.swing_speed_ms,
833 source: ctx.source,
834 target,
835 provenance: crate::state::ProcProvenance {
836 trigger_spell_id: request.profile_spell_id,
837 policy: crate::ProcPolicy::from_attributes(
838 ctx.program
839 .damage
840 .action_at(request.binding.profile_action)
841 .attributes
842 .as_ref(),
843 ),
844 kind: if flags.contains(DamageFlags::PROC) {
845 crate::state::ProcSourceKind::Proc
846 } else if weapon.swing_hand.is_some() {
847 crate::state::ProcSourceKind::Weapon
848 } else {
849 crate::state::ProcSourceKind::Action
850 },
851 },
852 };
853
854 if !result.hit_result.is_hit() {
855 if result.hit_result == wowlab_types::combat::HitResult::Parry {
856 ctx.instance
857 .apply_enemy_parry_haste(ctx.program, target, ctx.now);
858 }
859
860 fire_impact_procs(ctx, proc_event);
861
862 return SingleHitResult {
863 amount: 0.0,
864 is_crit: false,
865 impact: Some(proc_event),
866 };
867 }
868
869 if resolved.amount > f64::EPSILON {
870 let Some(scope) = ctx.program.telemetry_scope(ctx.source, target) else {
871 return SingleHitResult {
872 amount: 0.0,
873 is_crit: false,
874 impact: None,
875 };
876 };
877
878 finalize_damage_with(
879 ctx,
880 target,
881 request.binding,
882 &DamageEvent::new(
883 DamageObservation {
884 spell_id: super::reported_damage_spell_id(request),
885 amount: resolved.amount,
886 flags: DamageEventFlags::default()
887 .with_crit(result.is_crit)
888 .with_periodic(flags.contains(DamageFlags::PERIODIC))
889 .with_pet(flags.contains(DamageFlags::PET)),
890 },
891 ctx.now.as_millis(),
892 scope,
893 ),
894 emit,
895 );
896 }
897
898 if ctx.instance.has_runtime_error() {
899 return SingleHitResult {
900 amount: 0.0,
901 is_crit: false,
902 impact: None,
903 };
904 }
905
906 fire_impact_procs(ctx, proc_event);
907
908 if result.final_amount > f64::EPSILON {
909 crate::systems::break_auras_on_actor(
910 &mut ctx.hook_ctx(),
911 ActorId::Enemy(target),
912 crate::systems::AuraBreakTrigger::Damage {
913 periodic: flags.contains(DamageFlags::PERIODIC),
914 },
915 );
916 }
917
918 SingleHitResult {
919 amount: resolved.amount,
920 is_crit: result.is_crit,
921 impact: Some(proc_event),
922 }
923}
924
925#[cfg(test)]
926#[allow(
927 clippy::float_cmp,
928 reason = "companion multiplier tests assert exact authored passive factors"
929)]
930mod tests;