Skip to main content

wowlab_engine_combat/systems/
healing_pipeline.rs

1//! Healing pipeline mirroring damage setup, crit/versatility, target modifiers, and application.
2
3use wowlab_engine_domain::{dbc::SpellSchoolMask, rotation::DenseBuffer};
4use wowlab_engine_rng::proc_chance;
5use wowlab_types::{
6    combat::DamageSchool,
7    constants::HUNDRED,
8    sim::{ActorId, SimTime},
9};
10
11use crate::state::{AbsorbKind, BuffEffect, CombatState};
12
13/// Typed heal request before source/target modifiers and healing absorbs.
14#[derive(Clone, Copy, Debug)]
15pub(crate) struct HealRequest {
16    pub source: ActorId,
17    pub target: ActorId,
18    pub base: f64,
19    pub percent_of_max: f64,
20    pub school: DamageSchool,
21    pub periodic: bool,
22    pub may_crit: bool,
23    pub at: SimTime,
24}
25
26/// Result of one healing event.
27#[derive(Clone, Copy, Debug, Default, PartialEq)]
28pub(crate) struct HealResult {
29    pub requested: f64,
30    pub modified: f64,
31    pub absorbed: f64,
32    pub effective: f64,
33    pub overheal: f64,
34    pub is_crit: bool,
35}
36
37/// Resolves one direct, periodic, percentage, or leech heal.
38#[must_use]
39// #t(fn: rust_cyclomatic_complexity) ordered source, target, caster-scoped, crit, and absorb stages are intentionally explicit
40pub(crate) fn deal_heal(
41    state: &mut CombatState,
42    buf: &DenseBuffer,
43    rng: &mut dyn FnMut() -> f64,
44    heal: HealRequest,
45) -> HealResult {
46    if !state.is_friendly_actor_alive(heal.target) {
47        return HealResult::default();
48    }
49
50    let combat_stats = &state.config.base_stats.stats;
51    let maximum = state.friendly_actor_max_health(heal.target).unwrap_or(0.0);
52    let mut requested = heal.base.max(0.0) + maximum * heal.percent_of_max;
53    let schools = SpellSchoolMask::from(heal.school);
54    let mut done_multiplier = wowlab_engine_domain::stats::versatility_multiplier(combat_stats);
55    let mut received_multiplier = 1.0;
56    let mut crit_chance = combat_stats.crit_chance;
57
58    super::buffs::for_each_active_actor_aura_effect(
59        crate::context::ActorView::new(state, buf, heal.source),
60        |stacks, effect| match effect {
61            BuffEffect::HealingDoneFlat {
62                amount,
63                schools: effect_schools,
64            } if effect_schools.is_empty() || effect_schools.intersects(schools) => {
65                requested += amount * stacks;
66            }
67            BuffEffect::HealingDonePercent(percent) | BuffEffect::Versatility(percent) => {
68                done_multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
69            }
70            BuffEffect::Crit(percent) | BuffEffect::CritFromEffect { percent, .. } => {
71                crit_chance += percent * stacks;
72            }
73            BuffEffect::CritActive(percent) => crit_chance += percent,
74            _ => {}
75        },
76    );
77
78    super::buffs::for_each_active_actor_aura_effect(
79        crate::context::ActorView::new(state, buf, heal.target),
80        |stacks, effect| match effect {
81            BuffEffect::HealingReceivedPercent(percent) => {
82                received_multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
83            }
84            BuffEffect::PeriodicHealingPercent(percent) if heal.periodic => {
85                received_multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
86            }
87            _ => {}
88        },
89    );
90
91    for key in buf.aura_keys().filter(|key| {
92        key.affected() == heal.target
93            && key.source() == heal.source
94            && buf
95                .aura(*key)
96                .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
97    }) {
98        let Some(local) = state.aura_local_for_key(key) else {
99            continue;
100        };
101        let stacks = buf.aura(key).map_or(1.0, |slot| f64::from(slot.stacks));
102
103        // BOUNDS: aura_local_for_key returns an index from this definition table.
104        let Some(aura) = state.defs.auras.get(local.as_usize()) else {
105            continue;
106        };
107
108        for effect in aura.effects.iter().flatten() {
109            if let BuffEffect::HealingReceivedFromCasterPercent(percent) = effect {
110                received_multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
111            }
112        }
113    }
114
115    let is_crit = heal.may_crit && proc_chance(rng, (crit_chance / HUNDRED).clamp(0.0, 1.0));
116    let crit_multiplier = if is_crit {
117        wowlab_engine_domain::stats::crit_multiplier(combat_stats)
118    } else {
119        1.0
120    };
121    let modified = requested * done_multiplier * received_multiplier * crit_multiplier;
122    let absorbed =
123        super::absorbs::consume_absorbs(state, heal.target, AbsorbKind::Healing, schools, modified);
124    let available = (modified - absorbed).max(0.0);
125    let before = state
126        .actor_health_fraction(heal.target, heal.at)
127        .unwrap_or(1.0);
128    let effective = state
129        .apply_friendly_actor_heal(heal.target, available)
130        .unwrap_or(0.0);
131    let after = state
132        .actor_health_fraction(heal.target, heal.at)
133        .unwrap_or(before);
134
135    super::process_actor_health_change(state, buf, heal.target, before, after, heal.at);
136
137    HealResult {
138        requested,
139        modified,
140        absorbed,
141        effective,
142        overheal: (available - effective).max(0.0),
143        is_crit,
144    }
145}
146
147#[cfg(test)]
148mod tests;