Skip to main content

wowlab_engine_combat/systems/damage_pipeline/
dispatch.rs

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
202// #t(fn: rust_cyclomatic_complexity) damage dispatch keeps targeting, hit resolution, proc batching, and replication in their required order.
203// #t(fn: rust_max_fn_lines) the single dispatch transaction preserves target order, hit batching, and scratch restoration on every exit.
204pub(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
341// #t(fn: rust_cyclomatic_complexity) replication keeps filtering, targeting, scaling, and telemetry in their required causal order.
342// #t(fn: rust_max_fn_lines) replication keeps filtering, targeting, scaling, and telemetry in their required causal order.
343fn 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
717// #t(fn: rust_max_fn_lines) one hit remains a causal transaction across defense, telemetry, proc, and aura-break stages.
718fn 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;