Skip to main content

wowlab_engine_combat/systems/damage_pipeline/
dispatch.rs

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