Skip to main content

wowlab_engine_combat/systems/damage_pipeline/
setup.rs

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/// The two crit multipliers a passive can scale by the caster's own critical strike chance.
209#[derive(Clone, Copy, Debug)]
210pub(super) struct CritChanceScaledMults {
211    /// Scales the whole crit multiplier, including its `1.0` base.
212    total: f64,
213    /// Scales only the bonus above `1.0`.
214    bonus: f64,
215}
216
217/// Reads the crit-damage terms the game states as a share of the caster's own crit chance.
218///
219/// `SimC` splits one DBC shape across two multipliers.
220///   Aura 163 lands on `composite_player_critical_damage_multiplier`, which
221///   `action_t::total_crit_bonus` applies to `(1 + base_crit_bonus)` before subtracting one
222///   (`action/action.cpp:1397-1418`); modifier property 15 lands on
223///   `composite_crit_damage_bonus_multiplier`, which it applies to the bonus alone.
224fn 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
430// #t(fn: rust_max_fn_lines) Damage setup is an ordered snapshot pipeline shared by every damage path.
431pub(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;