Skip to main content

wowlab_engine_combat/systems/damage_pipeline/
incoming.rs

1use wowlab_engine_domain::dbc::SpellSchoolMask;
2use wowlab_types::{
3    combat::DamageSchool,
4    constants::HUNDRED,
5    sim::{ActorId, SimTime},
6};
7
8use crate::{
9    context::ActorView,
10    state::{AbsorbKind, BuffEffect, CombatInstance, 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    program: &crate::state::CombatProgram,
44    state: &mut CombatInstance,
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    let transfer_scratch = std::mem::take(&mut state.runtime.scratch.active_damage_transfers);
61    let mut pre_mitigation = active_transfers(
62        ActorView::new(program, state, damage.target),
63        DamageTransferTiming::PreMitigationSplit,
64        school,
65        transfer_scratch,
66    );
67
68    for (recipient, transfer) in pre_mitigation.iter().copied() {
69        let amount = remaining * transfer.fraction.clamp(0.0, 1.0);
70
71        if amount <= f64::EPSILON {
72            continue;
73        }
74
75        remaining -= amount;
76        split += amount;
77        let _ = deal_incoming_damage_core(
78            program,
79            state,
80            IncomingDamage {
81                target: recipient,
82                amount,
83                ..damage
84            },
85        );
86    }
87
88    pre_mitigation.clear();
89    let nested_scratch = std::mem::replace(
90        &mut state.runtime.scratch.active_damage_transfers,
91        pre_mitigation,
92    );
93
94    debug_assert!(
95        nested_scratch.is_empty(),
96        "nested pre-mitigation damage calls restore an empty transfer scratch buffer"
97    );
98
99    let mut result = deal_incoming_damage_core(
100        program,
101        state,
102        IncomingDamage {
103            amount: remaining,
104            ..damage
105        },
106    );
107    let mut shared = 0.0;
108
109    if result.applied > f64::EPSILON {
110        let transfer_scratch = std::mem::take(&mut state.runtime.scratch.active_damage_transfers);
111        let mut post_mitigation = active_transfers(
112            ActorView::new(program, state, damage.target),
113            DamageTransferTiming::PostMitigationShare,
114            school,
115            transfer_scratch,
116        );
117
118        for (recipient, transfer) in post_mitigation.iter().copied() {
119            let amount = result.applied * transfer.fraction.clamp(0.0, 1.0);
120
121            if amount <= f64::EPSILON {
122                continue;
123            }
124
125            shared += amount;
126            let _ = deal_incoming_damage_core(
127                program,
128                state,
129                IncomingDamage {
130                    target: recipient,
131                    amount,
132                    ..damage
133                },
134            );
135        }
136
137        post_mitigation.clear();
138        let nested_scratch = std::mem::replace(
139            &mut state.runtime.scratch.active_damage_transfers,
140            post_mitigation,
141        );
142
143        debug_assert!(
144            nested_scratch.is_empty(),
145            "nested post-mitigation damage calls restore an empty transfer scratch buffer"
146        );
147    }
148
149    result.requested = requested;
150    result.split = split;
151    result.shared = shared;
152
153    if !result.immune {
154        break_auras_without_telemetry(program, state, damage.target, damage.is_periodic, damage.at);
155    }
156
157    if result.applied > f64::EPSILON
158        && matches!(damage.source, ActorId::Enemy(_))
159        && damage.source != damage.target
160        && damage.target == ActorId::Player
161        && !damage.is_periodic
162    {
163        let _ = apply_cast_pushback(program, state, damage.target, damage.at);
164    }
165
166    result
167}
168
169// #t(fn: rust_cyclomatic_complexity) explicit immunity, modifier, absorb, overkill, and health stages mirror server ordering
170fn deal_incoming_damage_core(
171    program: &crate::state::CombatProgram,
172    state: &mut CombatInstance,
173    damage: IncomingDamage,
174) -> IncomingDamageResult {
175    let requested = damage.amount.max(0.0);
176    let school = SpellSchoolMask::from(damage.school);
177    let mut multiplier = 1.0;
178    let mut immune = false;
179
180    state
181        .runtime
182        .health
183        .immunity_windows
184        .retain(|window| window.until > damage.at);
185    immune |= state.runtime.health.immunity_windows.iter().any(|window| {
186        window.actor == damage.target
187            && (window.schools.intersects(school)
188                || (damage.mechanic_mask != 0 && window.mechanic_mask & damage.mechanic_mask != 0))
189    });
190
191    buffs::for_each_planned_actor_effect(
192        ActorView::new(program, state, damage.target),
193        crate::state::EffectFamily::INCOMING_DAMAGE | crate::state::EffectFamily::IMMUNITY,
194        |stacks, effect| match effect {
195            BuffEffect::IncomingDamagePercent { percent, schools }
196                if schools.intersects(school) =>
197            {
198                multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
199            }
200            BuffEffect::IncomingAoeDamagePercent { percent, schools }
201                if damage.is_aoe && schools.intersects(school) =>
202            {
203                multiplier *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
204            }
205            BuffEffect::SchoolImmunity(schools) if schools.intersects(school) => immune = true,
206            BuffEffect::MechanicImmunity(mask)
207                if damage.mechanic_mask != 0 && mask & damage.mechanic_mask != 0 =>
208            {
209                immune = true;
210            }
211            _ => {}
212        },
213    );
214
215    if immune {
216        return IncomingDamageResult {
217            requested,
218            immune: true,
219            ..IncomingDamageResult::default()
220        };
221    }
222
223    let modified = requested * multiplier;
224    let mut absorbed =
225        absorbs::consume_absorbs(state, damage.target, AbsorbKind::Damage, school, modified);
226    let mut after_absorb = (modified - absorbed).max(0.0);
227    let current_health = state.friendly_actor_health(damage.target).unwrap_or(0.0);
228    let mut grant_death_credit = false;
229
230    if after_absorb + f64::EPSILON >= current_health {
231        let maximum_health = state
232            .friendly_actor_max_health(damage.target)
233            .unwrap_or(current_health);
234        let overkill = absorbs::resolve_overkill_absorbs(
235            state,
236            &damage,
237            after_absorb,
238            current_health,
239            maximum_health,
240        );
241
242        absorbed += overkill.absorbed;
243        after_absorb = (after_absorb - overkill.absorbed).max(0.0);
244        grant_death_credit = overkill.absorbed > 0.0 && !overkill.death_fully_prevented;
245    }
246
247    let applied = state
248        .apply_friendly_actor_damage(damage.target, after_absorb)
249        .unwrap_or(0.0);
250    let mut killed = applied > 0.0 && !state.is_friendly_actor_alive(damage.target);
251
252    if grant_death_credit {
253        killed |= state.transition_friendly_actor_to_dead(damage.target);
254    }
255
256    // A dead player stops evaluating its rotation, so the iteration ends here instead of idling to
257    // the encounter endpoint and averaging its damage over time it was never alive for.
258
259    if killed && damage.target == ActorId::Player {
260        state.runtime.encounter.terminate(damage.at);
261    }
262
263    IncomingDamageResult {
264        requested,
265        modified,
266        absorbed,
267        split: 0.0,
268        shared: 0.0,
269        applied,
270        immune: false,
271        killed,
272    }
273}
274
275fn active_transfers(
276    view: ActorView<'_>,
277    timing: DamageTransferTiming,
278    school: SpellSchoolMask,
279    mut transfers: Vec<(ActorId, DamageTransfer)>,
280) -> Vec<(ActorId, DamageTransfer)> {
281    let ActorView { actor: target, .. } = view;
282
283    transfers.clear();
284
285    buffs::for_each_planned_actor_aura_effect(
286        view,
287        crate::state::EffectFamily::DAMAGE_TRANSFER,
288        |key, _local, _stacks, effect| {
289            let BuffEffect::DamageTransfer(transfer) = effect else {
290                return;
291            };
292
293            if transfer.timing == timing
294                && (transfer.schools.is_empty() || transfer.schools.intersects(school))
295                && !matches!(key.source(), ActorId::Enemy(_))
296                && key.source() != target
297            {
298                transfers.push((key.source(), *transfer));
299            }
300        },
301    );
302
303    transfers
304}
305
306#[cfg(test)]
307mod tests;