wowlab_engine_combat/systems/damage_pipeline/
incoming.rs1use 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#[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#[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#[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
169fn 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 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;