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