Skip to main content

wowlab_engine_combat/systems/damage_pipeline/
incoming.rs

1use wowlab_engine_domain::{dbc::SpellSchoolMask, rotation::DenseBuffer};
2use wowlab_types::{
3    combat::DamageSchool,
4    constants::HUNDRED,
5    sim::{ActorId, SimTime},
6};
7
8use crate::{
9    context::ActorView,
10    state::{AbsorbKind, BuffEffect, CombatState, DamageTransfer, DamageTransferTiming},
11    systems::{absorbs, apply_cast_pushback, break_auras_without_telemetry, buffs},
12};
13
14/// Typed hostile damage request before target-side modifiers and health application.
15#[derive(Clone, Copy, Debug)]
16pub(crate) struct IncomingDamage {
17    pub source: ActorId,
18    pub target: ActorId,
19    pub amount: f64,
20    pub school: DamageSchool,
21    pub mechanic_mask: u64,
22    pub is_aoe: bool,
23    pub is_periodic: bool,
24    pub at: SimTime,
25}
26
27/// Result of the incoming-damage pipeline.
28#[derive(Clone, Copy, Debug, Default, PartialEq)]
29pub(crate) struct IncomingDamageResult {
30    pub requested: f64,
31    pub modified: f64,
32    pub absorbed: f64,
33    pub split: f64,
34    pub shared: f64,
35    pub applied: f64,
36    pub immune: bool,
37    pub killed: bool,
38}
39
40/// Applies immunity and multiplicative damage-taken effects before removing absolute health.
41#[must_use]
42pub(crate) fn deal_incoming_damage(
43    state: &mut CombatState,
44    buf: &mut DenseBuffer,
45    damage: IncomingDamage,
46) -> IncomingDamageResult {
47    let requested = damage.amount.max(0.0);
48
49    if requested <= f64::EPSILON || !state.is_friendly_actor_alive(damage.target) {
50        return IncomingDamageResult {
51            requested,
52            ..IncomingDamageResult::default()
53        };
54    }
55
56    let school = SpellSchoolMask::from(damage.school);
57    let mut split = 0.0;
58    let mut remaining = requested;
59
60    for (recipient, transfer) in active_transfers(
61        ActorView::new(state, buf, damage.target),
62        DamageTransferTiming::PreMitigationSplit,
63        school,
64    ) {
65        let amount = remaining * transfer.fraction.clamp(0.0, 1.0);
66
67        if amount <= f64::EPSILON {
68            continue;
69        }
70
71        remaining -= amount;
72        split += amount;
73        let _ = deal_incoming_damage_core(
74            state,
75            buf,
76            IncomingDamage {
77                target: recipient,
78                amount,
79                ..damage
80            },
81        );
82    }
83
84    let mut result = deal_incoming_damage_core(
85        state,
86        buf,
87        IncomingDamage {
88            amount: remaining,
89            ..damage
90        },
91    );
92    let mut shared = 0.0;
93
94    if result.applied > f64::EPSILON {
95        for (recipient, transfer) in active_transfers(
96            ActorView::new(state, buf, damage.target),
97            DamageTransferTiming::PostMitigationShare,
98            school,
99        ) {
100            let amount = result.applied * transfer.fraction.clamp(0.0, 1.0);
101
102            if amount <= f64::EPSILON {
103                continue;
104            }
105
106            shared += amount;
107            let _ = deal_incoming_damage_core(
108                state,
109                buf,
110                IncomingDamage {
111                    target: recipient,
112                    amount,
113                    ..damage
114                },
115            );
116        }
117    }
118
119    result.requested = requested;
120    result.split = split;
121    result.shared = shared;
122
123    if !result.immune {
124        break_auras_without_telemetry(state, buf, damage.target, damage.is_periodic);
125    }
126
127    if result.applied > f64::EPSILON
128        && matches!(damage.source, ActorId::Enemy(_))
129        && damage.source != damage.target
130        && damage.target == ActorId::Player
131        && !damage.is_periodic
132    {
133        let _ = apply_cast_pushback(state, buf, damage.target, damage.at);
134    }
135
136    result
137}
138
139// #t(fn: rust_cyclomatic_complexity) explicit immunity, modifier, absorb, overkill, and health stages mirror server ordering
140fn deal_incoming_damage_core(
141    state: &mut CombatState,
142    buf: &mut DenseBuffer,
143    damage: IncomingDamage,
144) -> IncomingDamageResult {
145    let requested = damage.amount.max(0.0);
146    let school = SpellSchoolMask::from(damage.school);
147    let mut multiplier = 1.0;
148    let mut immune = false;
149
150    state
151        .runtime
152        .health
153        .immunity_windows
154        .retain(|window| window.until > damage.at);
155    immune |= state.runtime.health.immunity_windows.iter().any(|window| {
156        window.actor == damage.target
157            && (window.schools.intersects(school)
158                || (damage.mechanic_mask != 0 && window.mechanic_mask & damage.mechanic_mask != 0))
159    });
160
161    buffs::for_each_active_actor_aura_effect(
162        ActorView::new(state, buf, damage.target),
163        |stacks, effect| match effect {
164            BuffEffect::IncomingDamagePercent { percent, schools }
165                if schools.intersects(school) =>
166            {
167                multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
168            }
169            BuffEffect::IncomingAoeDamagePercent { percent, schools }
170                if damage.is_aoe && schools.intersects(school) =>
171            {
172                multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
173            }
174            BuffEffect::SchoolImmunity(schools) if schools.intersects(school) => immune = true,
175            BuffEffect::MechanicImmunity(mask)
176                if damage.mechanic_mask != 0 && mask & damage.mechanic_mask != 0 =>
177            {
178                immune = true;
179            }
180            _ => {}
181        },
182    );
183
184    if immune {
185        return IncomingDamageResult {
186            requested,
187            immune: true,
188            ..IncomingDamageResult::default()
189        };
190    }
191
192    let modified = requested * multiplier;
193    let mut absorbed =
194        absorbs::consume_absorbs(state, damage.target, AbsorbKind::Damage, school, modified);
195    let mut after_absorb = (modified - absorbed).max(0.0);
196    let current_health = state.friendly_actor_health(damage.target).unwrap_or(0.0);
197    let mut grant_death_credit = false;
198
199    if after_absorb + f64::EPSILON >= current_health {
200        let maximum_health = state
201            .friendly_actor_max_health(damage.target)
202            .unwrap_or(current_health);
203        let overkill = absorbs::resolve_overkill_absorbs(
204            state,
205            &damage,
206            after_absorb,
207            current_health,
208            maximum_health,
209        );
210
211        absorbed += overkill.absorbed;
212        after_absorb = (after_absorb - overkill.absorbed).max(0.0);
213        grant_death_credit = overkill.absorbed > 0.0 && !overkill.death_fully_prevented;
214    }
215
216    let applied = state
217        .apply_friendly_actor_damage(damage.target, after_absorb)
218        .unwrap_or(0.0);
219    let mut killed = applied > 0.0 && !state.is_friendly_actor_alive(damage.target);
220
221    if grant_death_credit {
222        killed |= state.transition_friendly_actor_to_dead(damage.target);
223    }
224
225    // A dead player stops evaluating its rotation, so the iteration ends here instead of idling to
226    // the encounter endpoint and averaging its damage over time it was never alive for. This
227    // mirrors SimC's `player_t::demise()`, which freezes `iteration_fight_length` at the death.
228
229    if killed && damage.target == ActorId::Player {
230        state.runtime.encounter.terminate(damage.at);
231    }
232
233    IncomingDamageResult {
234        requested,
235        modified,
236        absorbed,
237        split: 0.0,
238        shared: 0.0,
239        applied,
240        immune: false,
241        killed,
242    }
243}
244
245fn active_transfers(
246    view: ActorView<'_>,
247    timing: DamageTransferTiming,
248    school: SpellSchoolMask,
249) -> Vec<(ActorId, DamageTransfer)> {
250    let ActorView {
251        state,
252        buf,
253        actor: target,
254    } = view;
255    let mut transfers = Vec::new();
256
257    for key in buf.aura_keys().filter(|key| key.affected() == target) {
258        if !buf
259            .aura(key)
260            .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
261        {
262            continue;
263        }
264
265        let Some(local) = state.aura_local_for_key(key) else {
266            continue;
267        };
268
269        // BOUNDS: aura_local_for_key returns an index from this definition table.
270        let Some(aura) = state.defs.auras.get(local.as_usize()) else {
271            continue;
272        };
273
274        for effect in aura.effects.iter().flatten() {
275            let BuffEffect::DamageTransfer(transfer) = effect else {
276                continue;
277            };
278
279            if transfer.timing == timing
280                && (transfer.schools.is_empty() || transfer.schools.intersects(school))
281                && !matches!(key.source(), ActorId::Enemy(_))
282                && key.source() != target
283            {
284                transfers.push((key.source(), *transfer));
285            }
286        }
287    }
288
289    transfers
290}
291
292#[cfg(test)]
293mod tests;