1use wowlab_engine_domain::rotation::DenseBuffer;
2use wowlab_types::constants::HUNDRED;
3
4use super::{DamageFlags, DamageRequest};
5#[cfg(test)]
6use crate::state::DamageSource;
7use crate::{
8 context::{ActorView, CombatView},
9 state::{BuffEffect, BuffTotals, CombatState},
10 systems::{
11 aoe::{AoeConfig, read_spell_aoe_config},
12 buffs::{
13 accumulate_actor_buffs, accumulate_actor_only_buffs, active_spell_school_override,
14 },
15 mastery::MasteryCtx,
16 },
17};
18
19mod armor;
20mod passive_damage;
21pub(super) mod spell_modifiers;
22
23use passive_damage::passive_damage_adjustments;
24use spell_modifiers::{SpellModifierQuery, spell_scoped_mods_for};
25
26#[derive(Clone, Copy, Debug, Default)]
27pub(crate) struct DamageSnapshot {
28 pub ap: f64,
29 pub sp: f64,
30 pub crit_pct: f64,
31 pub vers_pct: f64,
32 pub mastery_mult: f64,
33 pub damage_mult: f64,
34}
35
36pub(super) struct DamageSetup {
37 pub(super) base: f64,
38 pub(super) armor: f64,
39 pub(super) armor_k: f64,
40 pub(super) combined_mult: f64,
41 pub(super) target_damage_mult: f64,
42 pub(super) power_coefficient_add: f64,
43 pub(super) auto_combined_mult: f64,
44 pub(super) auto_attack_buff_mult: f64,
45 pub(super) mastery_mult: f64,
46 pub(super) crit_pct_total: f64,
47 pub(super) crit_multiplier: f64,
48 pub(super) vers_pct_total: f64,
49 pub(super) school: wowlab_types::combat::DamageSchool,
50 pub(super) is_physical: bool,
51 pub(super) aoe: AoeConfig,
52}
53
54#[derive(Clone, Copy)]
55pub(super) struct SnapshotDamageContext<'a> {
56 state: &'a CombatState,
57 buf: &'a DenseBuffer,
58 source: wowlab_types::sim::ActorId,
59 target: wowlab_types::sim::EnemyIdx,
60 now: wowlab_types::sim::SimTime,
61}
62
63#[derive(Clone, Copy)]
64pub(super) struct SnapshotDamageEvent {
65 pub(super) source: wowlab_types::sim::ActorId,
66 pub(super) target: wowlab_types::sim::EnemyIdx,
67 pub(super) now: wowlab_types::sim::SimTime,
68}
69
70impl<'a> SnapshotDamageContext<'a> {
71 pub(super) const fn new(
72 state: &'a CombatState,
73 buf: &'a DenseBuffer,
74 event: SnapshotDamageEvent,
75 ) -> Self {
76 Self {
77 state,
78 buf,
79 source: event.source,
80 target: event.target,
81 now: event.now,
82 }
83 }
84}
85
86impl DamageSetup {
87 pub(super) fn from_snapshot_for(
88 context: SnapshotDamageContext<'_>,
89 effect: crate::state::DamageEffectRef,
90 flags: DamageFlags,
91 snap: &DamageSnapshot,
92 ) -> Self {
93 let spell_id = effect.spell_id;
94 let SnapshotDamageContext {
95 state,
96 buf,
97 source,
98 target,
99 now,
100 } = context;
101 let base = state
102 .spell_data(spell_id)
103 .map_or(0.0, |(_, s)| s.base_points);
104 let spell_idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
105 let passive_mult = state.config.game_data.passive_damage_mult(spell_idx, true);
106 let mods = spell_scoped_mods_for(
107 state,
108 buf,
109 source,
110 SpellModifierQuery::new(spell_id, effect, flags),
111 Some(target),
112 now,
113 );
114 let ignore_player = flags.contains(DamageFlags::IGNORE_PLAYER_MULTIPLIERS);
115 let ignore_target = flags.intersects(
116 DamageFlags::IGNORE_TARGET_MULTIPLIERS
117 | DamageFlags::IGNORE_POSITIVE_TARGET_MULTIPLIERS,
118 );
119 let target_mult = if ignore_target {
120 1.0
121 } else {
122 mods.target_damage_mult
123 };
124 let view = &CombatView::new(state, buf);
125 let (school, mitigation_flags) =
126 effective_school_and_flags(view, spell_id, spell_id, spell_id, flags);
127
128 Self {
129 base,
130 armor: if flags.contains(DamageFlags::IGNORE_TARGET_MULTIPLIERS) {
131 0.0
132 } else {
133 armor::for_target(
134 crate::HookView {
135 state,
136 buf,
137 target: Some(target),
138 now,
139 },
140 source,
141 mitigation_flags | DamageFlags::PERIODIC,
142 )
143 },
144 armor_k: state.enemy_definition(target).map_or(
145 0.0,
146 wowlab_engine_domain::encounter::EnemyActorDefinition::armor_constant,
147 ),
148 combined_mult: if ignore_player { 1.0 } else { snap.damage_mult }
149 * passive_mult
150 * target_mult,
151 target_damage_mult: target_mult,
152 power_coefficient_add: 0.0,
153 auto_combined_mult: if ignore_player { 1.0 } else { snap.damage_mult }
154 * passive_mult
155 * target_mult,
156 auto_attack_buff_mult: 1.0,
157 mastery_mult: snap.mastery_mult,
158 crit_pct_total: snap.crit_pct
159 + state.config.game_data.passive_crit_chance(spell_idx)
160 + crit_chance_versus_target_health(state, buf, source, target, now)
161 + guaranteed_crit_pct(state, spell_id),
162 crit_multiplier: {
163 let crit_scaled = crit_chance_scaled_mults(state, spell_idx, school, snap.crit_pct);
164
165 resolved_crit_multiplier(
166 flags,
167 wowlab_engine_domain::stats::crit_multiplier(&state.config.base_stats.stats)
168 * crit_scaled.total,
169 0.0,
170 state.config.game_data.passive_crit_damage_bonus(spell_idx),
171 mods.crit_damage_mult * crit_scaled.bonus,
172 )
173 },
174 vers_pct_total: if ignore_player { 0.0 } else { snap.vers_pct },
175 school,
176 is_physical: mitigation_flags.contains(DamageFlags::PHYSICAL),
177 aoe: AoeConfig::default(),
178 }
179 }
180
181 #[cfg(test)]
182 pub(super) fn from_snapshot_fixture(
183 view: &CombatView<'_>,
184 spell_id: u32,
185 flags: DamageFlags,
186 snap: &DamageSnapshot,
187 ) -> Self {
188 Self::from_snapshot_for(
189 SnapshotDamageContext::new(
190 view.state,
191 view.buf,
192 SnapshotDamageEvent {
193 source: wowlab_types::sim::ActorId::Player,
194 target: view
195 .state
196 .current_target()
197 .expect("snapshot fixture requires a selected target"),
198 now: wowlab_types::sim::SimTime::ZERO,
199 },
200 ),
201 crate::state::DamageEffectRef::new(spell_id, 1),
202 flags,
203 snap,
204 )
205 }
206}
207
208#[derive(Clone, Copy, Debug)]
210pub(super) struct CritChanceScaledMults {
211 total: f64,
213 bonus: f64,
215}
216
217fn crit_chance_scaled_mults(
225 state: &CombatState,
226 spell: wowlab_types::sim::SpellIdx,
227 school: wowlab_types::combat::DamageSchool,
228 player_crit_pct: f64,
229) -> CritChanceScaledMults {
230 let crit_chance = player_crit_pct / HUNDRED;
231 let hit_schools = wowlab_engine_domain::dbc::SpellSchoolMask::from(school);
232 let total = state
233 .config
234 .game_data
235 .crit_damage_per_crit_chance()
236 .iter()
237 .filter(|(schools, _)| {
238 hit_schools.intersects(wowlab_engine_domain::dbc::SpellSchoolMask::from_dbc(
239 *schools,
240 ))
241 })
242 .fold(1.0, |mult, (_, percent)| {
243 mult * (1.0 + percent / HUNDRED * crit_chance)
244 });
245
246 CritChanceScaledMults {
247 total,
248 bonus: 1.0
249 + state.config.game_data.crit_bonus_per_crit_chance(spell) / HUNDRED * crit_chance,
250 }
251}
252
253const BASE_CRIT_MULTIPLIER: f64 = 2.0;
254
255fn resolved_crit_multiplier(
256 flags: DamageFlags,
257 effective_base_crit: f64,
258 mastery_bonus_pct: f64,
259 passive_bonus_pct: f64,
260 buff_bonus_mult: f64,
261) -> f64 {
262 if flags.contains(DamageFlags::IGNORE_CRIT_DAMAGE_BONUSES) {
263 return BASE_CRIT_MULTIPLIER;
264 }
265
266 let bonus_mult =
267 (1.0 + mastery_bonus_pct / HUNDRED) * (1.0 + passive_bonus_pct / HUNDRED) * buff_bonus_mult;
268
269 1.0 + (effective_base_crit - 1.0) * bonus_mult
270}
271
272pub(crate) const GUARANTEED_CRIT_PCT: f64 = HUNDRED;
273
274fn guaranteed_crit_pct(state: &CombatState, spell_id: u32) -> f64 {
275 let guaranteed = state
276 .spell_data(spell_id)
277 .is_some_and(|(_, s)| s.damage_policy.guaranteed_crit);
278
279 if guaranteed { GUARANTEED_CRIT_PCT } else { 0.0 }
280}
281
282pub(crate) fn dynamic_base_powers(
283 state: &CombatState,
284 buf: &DenseBuffer,
285 totals: &BuffTotals,
286) -> (f64, f64) {
287 let p = buf.player();
288 let flat = totals.primary_stat * state.config.base_stats.stats.primary_stat_mult;
289 let bonus_stat = state
290 .config
291 .base_stats
292 .primary_attribute
293 .map_or(0.0, |attribute| {
294 positive_non_create_bonus(&state.config.base_stats.stats, attribute)
295 * bonus_stat_multiplier(totals, attribute, true)
296 });
297 let pct_mult = 1.0 + totals.primary_stat_pct / HUNDRED;
298 let base_attack_power = (p.attack_power + flat + bonus_stat) * pct_mult;
299 let base_spell_power = (p.spell_power + flat + bonus_stat) * pct_mult;
300 let attack_power = totals
301 .override_attack_power_by_spell_power_pct
302 .map_or(base_attack_power, |percent| {
303 base_spell_power * percent / HUNDRED
304 });
305 let spell_power = totals
306 .override_spell_power_by_attack_power_pct
307 .map_or(base_spell_power, |percent| {
308 base_attack_power * percent / HUNDRED
309 });
310
311 (attack_power, spell_power)
312}
313
314fn positive_non_create_bonus(
315 stats: &wowlab_engine_ports::CombatStats,
316 attribute: wowlab_types::game::Attribute,
317) -> f64 {
318 match attribute {
319 wowlab_types::game::Attribute::Strength => stats.positive_strength_bonus,
320 wowlab_types::game::Attribute::Agility => stats.positive_agility_bonus,
321 wowlab_types::game::Attribute::Stamina => stats.positive_stamina_bonus,
322 wowlab_types::game::Attribute::Intellect => stats.positive_intellect_bonus,
323 }
324 .max(0.0)
325}
326
327fn bonus_stat_multiplier(
328 totals: &BuffTotals,
329 attribute: wowlab_types::game::Attribute,
330 is_primary: bool,
331) -> f64 {
332 let index = wowlab_engine_domain::dbc::StatModifierKind::from_attribute(attribute)
333 .stat_index()
334 .expect("specific attributes always have a stat index");
335 let percent = totals.bonus_all_stat_pct
336 + if is_primary {
337 totals.bonus_primary_stat_pct
338 } else {
339 0.0
340 }
341 + totals.bonus_stat_pct.get(index).copied().unwrap_or(0.0);
342
343 percent / HUNDRED
344}
345
346pub(crate) fn dynamic_stamina(state: &CombatState, totals: &BuffTotals) -> f64 {
347 let combat_stats = &state.config.base_stats.stats;
348
349 combat_stats.stamina
350 + positive_non_create_bonus(combat_stats, wowlab_types::game::Attribute::Stamina)
351 * bonus_stat_multiplier(
352 totals,
353 wowlab_types::game::Attribute::Stamina,
354 state.config.base_stats.primary_attribute
355 == Some(wowlab_types::game::Attribute::Stamina),
356 )
357}
358
359pub(crate) fn school_of(state: &CombatState, spell_id: u32) -> wowlab_types::combat::DamageSchool {
360 wowlab_engine_domain::dbc::damage_school(
361 &state.config.game_data,
362 wowlab_types::sim::SpellIdx::from_raw(spell_id),
363 )
364 .unwrap_or(wowlab_types::combat::DamageSchool::Physical)
365}
366
367fn effective_school_and_flags(
368 view: &CombatView<'_>,
369 base_spell_id: u32,
370 profile_spell_id: u32,
371 effect_spell_id: u32,
372 flags: DamageFlags,
373) -> (wowlab_types::combat::DamageSchool, DamageFlags) {
374 let Some(school) = active_spell_school_override(view, profile_spell_id, effect_spell_id) else {
375 return (school_of(view.state, base_spell_id), flags);
376 };
377 let flags = if school.is_physical() {
378 flags | DamageFlags::PHYSICAL
379 } else {
380 flags & !DamageFlags::PHYSICAL
381 };
382
383 (school, flags)
384}
385
386fn actor_auto_attack_crit_pct(
387 request: &DamageRequest,
388 source: wowlab_types::sim::ActorId,
389 owner: &BuffTotals,
390 actor: Option<&BuffTotals>,
391) -> f64 {
392 if !request.weapon.is_auto_attack {
393 return 0.0;
394 }
395
396 if source == wowlab_types::sim::ActorId::Player {
397 owner.auto_attack_crit_pct
398 } else {
399 actor.map_or(0.0, |totals| totals.auto_attack_crit_pct)
400 }
401}
402
403fn crit_chance_versus_target_health(
404 state: &CombatState,
405 buf: &DenseBuffer,
406 source: wowlab_types::sim::ActorId,
407 target: wowlab_types::sim::EnemyIdx,
408 now: wowlab_types::sim::SimTime,
409) -> f64 {
410 let health_pct = state.enemy_health_fraction(target, now).unwrap_or(1.0) * HUNDRED;
411 let mut total = 0.0;
412
413 crate::systems::buffs::for_each_active_actor_aura_effect(
414 ActorView::new(state, buf, source),
415 |stacks, effect| {
416 if let BuffEffect::CritChanceVersusTargetHealth {
417 percent,
418 threshold_pct,
419 } = effect
420 && health_pct >= *threshold_pct
421 {
422 total += percent * stacks;
423 }
424 },
425 );
426
427 total
428}
429
430pub(super) fn prepare_damage_setup_for(
432 state: &CombatState,
433 buf: &DenseBuffer,
434 request: &DamageRequest,
435 source: wowlab_types::sim::ActorId,
436 target: wowlab_types::sim::EnemyIdx,
437 now: wowlab_types::sim::SimTime,
438) -> DamageSetup {
439 let combat_view = &CombatView::new(state, buf);
440 let spell_id = request.effect.spell_id;
441 let flags = request.flags;
442 let reflected = flags.contains(DamageFlags::REFLECTED);
443 let is_periodic = flags.contains(DamageFlags::PERIODIC);
444 let is_pet = flags.contains(DamageFlags::PET);
445 let is_companion = flags.intersects(DamageFlags::PET | DamageFlags::GUARDIAN);
446 let (crit, vers) = read_player_stats_for_damage(buf);
447 let ignore_player = flags.contains(DamageFlags::IGNORE_PLAYER_MULTIPLIERS);
448 let ignore_target = reflected
449 || flags.intersects(
450 DamageFlags::IGNORE_TARGET_MULTIPLIERS
451 | DamageFlags::IGNORE_POSITIVE_TARGET_MULTIPLIERS,
452 );
453 let (school, mitigation_flags) = effective_school_and_flags(
454 combat_view,
455 spell_id,
456 request.profile_spell_id,
457 request.effect.spell_id,
458 flags,
459 );
460 let armor = if reflected || flags.contains(DamageFlags::IGNORE_TARGET_MULTIPLIERS) {
461 0.0
462 } else {
463 armor::for_target(
464 crate::HookView {
465 state,
466 buf,
467 target: Some(target),
468 now,
469 },
470 source,
471 mitigation_flags,
472 )
473 };
474 let armor = crate::systems::buffs::active_spell_modifier_value(
475 ActorView::new(state, buf, source),
476 wowlab_types::sim::SpellIdx::from_raw(request.profile_spell_id),
477 wowlab_engine_domain::dbc::ModifierPropertyKind::TargetResistance,
478 armor,
479 )
480 .max(0.0);
481 let modifier_target = (!reflected).then_some(target);
482 let owner_totals = accumulate_actor_buffs(
483 combat_view.for_actor(wowlab_types::sim::ActorId::Player),
484 modifier_target,
485 );
486 let actor_totals = if source == wowlab_types::sim::ActorId::Player {
487 None
488 } else {
489 Some(accumulate_actor_only_buffs(
490 combat_view.for_actor(source),
491 modifier_target,
492 ))
493 };
494
495 let owner_school_mult = owner_totals
496 .school_damage_mult
497 .get(school as usize)
498 .copied()
499 .unwrap_or(1.0);
500 let actor_school_mult = actor_totals.as_ref().map_or(1.0, |totals| {
501 totals
502 .school_damage_mult
503 .get(school as usize)
504 .copied()
505 .unwrap_or(1.0)
506 });
507 let school_mult = owner_school_mult * actor_school_mult;
508 let mastery = crate::systems::buffs::current_mastery_points(combat_view);
509 let passive = passive_damage_adjustments(state, request, mastery);
510 let mods = spell_scoped_mods_for(
511 state,
512 buf,
513 source,
514 SpellModifierQuery::new(request.profile_spell_id, request.effect, flags),
515 modifier_target,
516 now,
517 );
518 let target_mult = if ignore_target {
519 1.0
520 } else {
521 mods.target_damage_mult
522 };
523 let auto_combined_mult = school_mult * passive.damage_mult * target_mult;
524 let pet_mult = if is_pet && !ignore_player {
525 owner_totals.pet_damage_mult
526 } else {
527 1.0
528 };
529 let player_mult = if ignore_player {
530 1.0
531 } else {
532 owner_totals.damage_mult
533 };
534 let actor_mult = actor_totals
535 .as_ref()
536 .map_or(1.0, |totals| totals.damage_mult);
537 let auto_attack_crit_pct =
538 actor_auto_attack_crit_pct(request, source, &owner_totals, actor_totals.as_ref());
539 let combined_mult = auto_combined_mult * player_mult * actor_mult * mods.damage_mult * pet_mult;
540
541 let mastery_ctx = MasteryCtx {
542 state,
543 buf,
544 spell_id,
545 school,
546 mastery,
547 player_crit_pct: crit + owner_totals.crit_pct,
548 mastery_spell: state.config.mastery_spell,
549 is_periodic,
550 is_pet,
551 source,
552 target: (!reflected).then_some(target),
553 now,
554 };
555 let mastery_mult = (state.config.mastery_hook)(&mastery_ctx);
556 let crit_scaled =
557 crit_chance_scaled_mults(state, passive.spell, school, mastery_ctx.player_crit_pct);
558 let crit_multiplier = resolved_crit_multiplier(
559 request.flags,
560 wowlab_engine_domain::stats::crit_multiplier(&state.config.base_stats.stats)
561 * crit_scaled.total,
562 (state.config.mastery_crit_damage)(&mastery_ctx),
563 state
564 .config
565 .game_data
566 .passive_crit_damage_bonus(passive.spell),
567 mods.crit_damage_mult * crit_scaled.bonus,
568 );
569
570 let base = request.base_points.unwrap_or_else(|| {
571 state
572 .spell_data(request.profile_spell_id)
573 .map_or(0.0, |(_, spell)| spell.base_points)
574 });
575 let base = crate::systems::buffs::active_effect_points(
576 ActorView::new(state, buf, source),
577 wowlab_types::sim::SpellIdx::from_raw(request.effect.spell_id),
578 request.effect.effect_index,
579 base,
580 ) + passive.direct_flat;
581
582 DamageSetup {
583 base,
584 armor,
585 armor_k: state.enemy_definition(target).map_or(
586 0.0,
587 wowlab_engine_domain::encounter::EnemyActorDefinition::armor_constant,
588 ),
589 combined_mult,
590 target_damage_mult: target_mult,
591 power_coefficient_add: passive.power_coefficient_add,
592 auto_combined_mult,
593 auto_attack_buff_mult: if is_companion {
594 1.0
595 } else {
596 owner_totals.auto_attack_damage_mult
597 } * actor_totals
598 .as_ref()
599 .map_or(1.0, |totals| totals.auto_attack_damage_mult),
600 mastery_mult,
601 crit_pct_total: (crit
602 + owner_totals.crit_pct
603 + auto_attack_crit_pct
604 + passive.crit_chance
605 + mods.crit_chance_pct
606 + if reflected {
607 0.0
608 } else {
609 crit_chance_versus_target_health(state, buf, source, target, now)
610 }
611 + guaranteed_crit_pct(state, request.profile_spell_id))
612 * passive.crit_chance_mult,
613 crit_multiplier,
614 vers_pct_total: if ignore_player {
615 0.0
616 } else {
617 vers + owner_totals.vers_pct
618 },
619 school,
620 is_physical: mitigation_flags.contains(DamageFlags::PHYSICAL),
621 aoe: read_spell_aoe_config(
622 state,
623 buf,
624 source,
625 request.profile_spell_id,
626 request.geometry_effect,
627 ),
628 }
629}
630
631#[cfg(test)]
632pub(super) fn prepare_damage_setup_fixture(
633 state: &CombatState,
634 buf: &DenseBuffer,
635 request: &DamageRequest,
636) -> DamageSetup {
637 prepare_damage_setup_for(
638 state,
639 buf,
640 request,
641 wowlab_types::sim::ActorId::Player,
642 state
643 .current_target()
644 .expect("damage setup fixture requires a selected target"),
645 wowlab_types::sim::SimTime::ZERO,
646 )
647}
648
649pub(crate) fn read_player_stats_for_damage(buf: &DenseBuffer) -> (f64, f64) {
650 let p = buf.player();
651
652 (p.crit, p.versatility)
653}
654
655#[cfg(test)]
656fn apply_test_aura_as(
657 state: &mut CombatState,
658 buf: &mut DenseBuffer,
659 aura_id: u32,
660 source: wowlab_types::sim::ActorId,
661) {
662 const TEST_RNG_ROLL: f64 = 0.5;
663
664 let mut rng = || TEST_RNG_ROLL;
665 let local = state.aura_local(aura_id).expect("aura registered");
666 let mut sink = wowlab_engine_telemetry::TelemetrySink::default();
667
668 crate::systems::auras::apply_aura(
669 &mut crate::HookCtx::new(
670 crate::context::HookCtxServices {
671 state,
672 buf,
673 sink: &mut sink,
674 rng: &mut rng,
675 },
676 crate::context::HookCtxRequest::for_target(
677 wowlab_types::sim::SimTime::ZERO,
678 wowlab_types::sim::EnemyIdx::PRIMARY,
679 )
680 .with_source(source),
681 ),
682 local,
683 );
684}
685
686#[cfg(test)]
687mod auto_attack_crit_tests;
688#[cfg(test)]
689mod passive_flat_tests;
690#[cfg(test)]
691mod school_override_tests;
692
693#[cfg(test)]
694#[path = "setup/tests.rs"]
695mod tests;