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
593fn 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;