1use wowlab_engine_domain::{
2 dbc::{
3 ModifierOperation, ModifierPropertyKind, ResolvedGameDataSemanticExt as _,
4 aura_subtype_modifier, modifier_property_is,
5 },
6 rotation::DenseBuffer,
7};
8use wowlab_types::constants::HUNDRED;
9
10use crate::{
11 state::{BuffEffect, CombatState, DamageEffectRef, DamageSource},
12 systems::{
13 buffs::{
14 dbc_effect_points, for_each_active_actor_aura_effect,
15 for_each_active_target_aura_effect, spell_modifier_owner, target_aura_mechanic_mask,
16 },
17 damage_pipeline::DamageFlags,
18 },
19};
20
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub(crate) struct SpellScopedMods {
24 pub(crate) damage_mult: f64,
25 pub(crate) target_damage_mult: f64,
26 pub(crate) crit_chance_pct: f64,
27 pub(crate) crit_damage_mult: f64,
28}
29
30impl Default for SpellScopedMods {
31 fn default() -> Self {
32 Self {
33 damage_mult: 1.0,
34 target_damage_mult: 1.0,
35 crit_chance_pct: 0.0,
36 crit_damage_mult: 1.0,
37 }
38 }
39}
40
41#[derive(Clone, Copy, Debug)]
42pub(crate) struct SpellModifierQuery {
43 profile_spell_id: u32,
44 effect: DamageEffectRef,
45 flags: DamageFlags,
46}
47
48impl SpellModifierQuery {
49 pub(crate) const fn new(
50 profile_spell_id: u32,
51 effect: DamageEffectRef,
52 flags: DamageFlags,
53 ) -> Self {
54 Self {
55 profile_spell_id,
56 effect,
57 flags,
58 }
59 }
60
61 fn listed(self, spells: &[u32]) -> bool {
62 spells.contains(&self.profile_spell_id) || spells.contains(&self.effect.spell_id)
63 }
64
65 fn affected(self, state: &CombatState, source_spell_id: u32, effect_index: u8) -> bool {
66 [self.profile_spell_id, self.effect.spell_id]
67 .into_iter()
68 .any(|spell_id| {
69 state.config.game_data.effect_affects_spell(
70 wowlab_types::sim::SpellIdx::from_raw(source_spell_id),
71 effect_index,
72 wowlab_types::sim::SpellIdx::from_raw(spell_id),
73 )
74 })
75 }
76
77 fn mechanic_mask(self, state: &CombatState) -> wowlab_engine_domain::dbc::MechanicMask {
78 let data = &state.config.game_data;
79 let effect_spell = wowlab_types::sim::SpellIdx::from_raw(self.effect.spell_id);
80 let effect = data.effect_mechanic_mask(effect_spell, self.effect.effect_index);
81
82 if effect.is_empty() {
83 data.spell_mechanic_mask(wowlab_types::sim::SpellIdx::from_raw(self.profile_spell_id))
84 } else {
85 effect
86 }
87 }
88
89 fn school_mask(self, state: &CombatState) -> wowlab_engine_domain::dbc::SpellSchoolMask {
90 let data = &state.config.game_data;
91 let raw = data
92 .school_mask(wowlab_types::sim::SpellIdx::from_raw(self.effect.spell_id))
93 .or_else(|| {
94 data.school_mask(wowlab_types::sim::SpellIdx::from_raw(self.profile_spell_id))
95 })
96 .unwrap_or_default();
97
98 wowlab_engine_domain::dbc::SpellSchoolMask::from_dbc(raw)
99 }
100}
101
102struct SpellModifierContext<'a> {
103 state: &'a CombatState,
104 buf: &'a DenseBuffer,
105 query: SpellModifierQuery,
106 target: Option<wowlab_types::sim::EnemyIdx>,
107 now: wowlab_types::sim::SimTime,
108}
109
110#[derive(Clone, Copy)]
111struct ActiveEffectPointModifier {
112 stacks: f64,
113 value: f64,
114 source_spell_id: u32,
115 effect_index: u8,
116 operation: ModifierOperation,
117}
118
119impl ActiveEffectPointModifier {
120 fn from_buff(stacks: f64, effect: BuffEffect) -> Option<Self> {
121 let BuffEffect::SpellModifierFromEffect {
122 value,
123 source_spell_id,
124 effect_index,
125 property: ModifierPropertyKind::Points,
126 operation,
127 } = effect
128 else {
129 return None;
130 };
131
132 Some(Self {
133 stacks,
134 value,
135 source_spell_id,
136 effect_index,
137 operation,
138 })
139 }
140}
141
142impl SpellModifierContext<'_> {
143 fn apply(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
144 let before = *mods;
145
146 self.apply_damage(stacks, effect, mods);
147 self.apply_target(stacks, effect, mods);
148 self.apply_critical(stacks, effect, mods);
149
150 if *mods != before {
151 tracing::trace!(
152 profile_spell_id = self.query.profile_spell_id,
153 effect_spell_id = self.query.effect.spell_id,
154 stacks,
155 ?effect,
156 damage_mult = mods.damage_mult,
157 target_damage_mult = mods.target_damage_mult,
158 crit_chance_pct = mods.crit_chance_pct,
159 crit_damage_mult = mods.crit_damage_mult,
160 "spell-scoped aura modifier applied"
161 );
162 }
163 }
164
165 fn apply_damage(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
166 match effect {
167 BuffEffect::DamageMultSpells(value, spells) if self.query.listed(spells) => {
168 mods.damage_mult *= value.powf(stacks);
169 }
170 BuffEffect::DamageMultSpellsActive(value, spells) if self.query.listed(spells) => {
171 mods.damage_mult *= value;
172 }
173 BuffEffect::SpellDamageFromEffect {
174 percent,
175 source_spell_id,
176 effect_index,
177 periodic,
178 } if *periodic == self.query.flags.contains(DamageFlags::PERIODIC)
179 && self
180 .query
181 .affected(self.state, *source_spell_id, *effect_index) =>
182 {
183 let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
184
185 mods.damage_mult *= 1.0 + percent * points / HUNDRED;
186 }
187 BuffEffect::DamageMultSpellsLinear(value, spells) if self.query.listed(spells) => {
188 mods.damage_mult *= 1.0 + value / HUNDRED * stacks;
189 }
190 BuffEffect::DynamicDamageMultSpells(resolve, spells) if self.query.listed(spells) => {
191 mods.damage_mult *=
192 resolve(self.state, self.buf, self.target, self.now).powf(stacks);
193 }
194 BuffEffect::DamageToTargetAuraMechanicPercent {
195 percent,
196 mechanic_mask,
197 } if self.target.is_some_and(|target| {
198 target_aura_mechanic_mask(self.state, self.buf, target, self.now)
199 .intersects(*mechanic_mask)
200 }) =>
201 {
202 mods.damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
203 }
204 _ => {}
205 }
206 }
207
208 fn apply_incoming_damage(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
213 if let BuffEffect::IncomingDamagePercent { percent, schools } = effect
214 && schools.intersects(self.query.school_mask(self.state))
215 {
216 mods.target_damage_mult *= (1.0 + percent / HUNDRED).max(0.0).powf(stacks);
217 }
218 }
219
220 fn apply_target(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
221 match effect {
222 BuffEffect::DamageTakenFromSource(percent, source)
223 if damage_source_matches(*source, self.query.flags) =>
224 {
225 mods.target_damage_mult *= 1.0 + percent / HUNDRED;
226 }
227 BuffEffect::DamageTakenFromSourceSpells {
228 percent,
229 source_spell_id,
230 effect_index,
231 source,
232 } if damage_source_matches(*source, self.query.flags)
233 && self
234 .query
235 .affected(self.state, *source_spell_id, *effect_index) =>
236 {
237 let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
238
239 mods.target_damage_mult *= 1.0 + percent / HUNDRED * points;
240 }
241 BuffEffect::MechanicDamageTakenPercent {
242 percent,
243 mechanic_mask,
244 } if self
245 .query
246 .mechanic_mask(self.state)
247 .intersects(*mechanic_mask) =>
248 {
249 mods.target_damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
250 }
251 BuffEffect::CreatureAoeDamageAvoidance { percent, schools }
252 if self.query.flags.contains(DamageFlags::AOE)
253 && schools.intersects(self.query.school_mask(self.state)) =>
254 {
255 mods.target_damage_mult *= (1.0 + percent / HUNDRED).powf(stacks);
256 }
257 _ => {}
258 }
259 }
260
261 fn apply_critical(&self, stacks: f64, effect: &BuffEffect, mods: &mut SpellScopedMods) {
262 match effect {
263 BuffEffect::SpellCritChanceFromEffect {
264 percent,
265 source_spell_id,
266 effect_index,
267 } if self
268 .query
269 .affected(self.state, *source_spell_id, *effect_index) =>
270 {
271 let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
272
273 mods.crit_chance_pct += percent * points;
274 }
275 BuffEffect::SpellCritDamageFromEffect {
276 percent,
277 source_spell_id,
278 effect_index,
279 } if self
280 .query
281 .affected(self.state, *source_spell_id, *effect_index) =>
282 {
283 let points = dbc_effect_points(self.state, *source_spell_id, *effect_index, stacks);
284
285 mods.crit_damage_mult *= 1.0 + percent * points / HUNDRED;
286 }
287 BuffEffect::CritChanceSpells(value, spells) if self.query.listed(spells) => {
288 mods.crit_chance_pct += value * stacks;
289 }
290 BuffEffect::CritDamageSpells(value, spells) if self.query.listed(spells) => {
291 mods.crit_damage_mult *= (1.0 + value / HUNDRED).powf(stacks);
292 }
293 _ => {}
294 }
295 }
296
297 fn apply_effect_point_damage_modifiers(
298 &self,
299 active: &[ActiveEffectPointModifier],
300 mods: &mut SpellScopedMods,
301 ) {
302 let data = &self.state.config.game_data;
303 let hit_is_periodic = self.query.flags.contains(DamageFlags::PERIODIC);
304
305 for passive_spell_id in data.folded_passive_spells() {
306 let passive = *passive_spell_id;
307
308 for passive_effect_index in 1..=data.max_effect_index(passive) {
309 let property = data.effect_misc_value_0(passive, passive_effect_index);
310 let effect_is_periodic =
311 if modifier_property_is(property, ModifierPropertyKind::GenericDamage) {
312 false
313 } else if modifier_property_is(property, ModifierPropertyKind::PeriodicAmount) {
314 true
315 } else {
316 continue;
317 };
318
319 if effect_is_periodic != hit_is_periodic
320 || !self.query.affected(
321 self.state,
322 passive_spell_id.as_u32(),
323 passive_effect_index,
324 )
325 || aura_subtype_modifier(data.effect_aura(passive, passive_effect_index))
326 .is_none_or(|(operation, _)| operation != ModifierOperation::Percent)
327 {
328 continue;
329 }
330
331 let initial = data.base_points(passive, passive_effect_index);
332 let mut flat = 0.0;
333 let mut percent = 1.0;
334
335 for modifier in active.iter().filter(|modifier| {
336 data.effect_affects_spell(
337 wowlab_types::sim::SpellIdx::from_raw(modifier.source_spell_id),
338 modifier.effect_index,
339 passive,
340 )
341 }) {
342 let points = dbc_effect_points(
343 self.state,
344 modifier.source_spell_id,
345 modifier.effect_index,
346 modifier.stacks,
347 );
348
349 match modifier.operation {
350 ModifierOperation::Flat => flat += modifier.value * points,
351 ModifierOperation::Percent => {
352 percent *= 1.0 + modifier.value * points / HUNDRED;
353 }
354 _ => {}
355 }
356 }
357
358 let original_factor = 1.0 + initial / HUNDRED;
359
360 if original_factor.abs() <= f64::EPSILON {
361 continue;
362 }
363
364 let modified_factor = 1.0 + (initial + flat) * percent / HUNDRED;
365
366 mods.damage_mult *= modified_factor / original_factor;
367 }
368 }
369 }
370}
371
372fn damage_source_matches(source: DamageSource, flags: DamageFlags) -> bool {
373 match source {
374 DamageSource::Player => !flags.intersects(DamageFlags::PET | DamageFlags::GUARDIAN),
375 DamageSource::Pet => {
376 flags.contains(DamageFlags::PET) && !flags.contains(DamageFlags::GUARDIAN)
377 }
378 DamageSource::Guardian => flags.contains(DamageFlags::GUARDIAN),
379 }
380}
381
382pub(crate) fn spell_scoped_mods_for(
383 state: &CombatState,
384 buf: &DenseBuffer,
385 source: wowlab_types::sim::ActorId,
386 query: SpellModifierQuery,
387 target: Option<wowlab_types::sim::EnemyIdx>,
388 now: wowlab_types::sim::SimTime,
389) -> SpellScopedMods {
390 let mut mods = SpellScopedMods::default();
391 let mut effect_point_modifiers = Vec::new();
392 let context = SpellModifierContext {
393 state,
394 buf,
395 query,
396 target,
397 now,
398 };
399
400 if query.flags.contains(DamageFlags::AOE)
401 && let Some(target) = target
402 && let Some(enemy) = state.enemy_definition(target)
403 {
404 mods.target_damage_mult *=
405 1.0 - enemy.creature_aoe_avoidance_pct().clamp(0.0, HUNDRED) / HUNDRED;
406 }
407
408 let mut apply_source_effect = |stacks, effect: &BuffEffect| {
409 context.apply(stacks, effect, &mut mods);
410
411 if let Some(modifier) = ActiveEffectPointModifier::from_buff(stacks, *effect) {
412 effect_point_modifiers.push(modifier);
413 }
414 };
415
416 let owner = spell_modifier_owner(source);
417
418 if let Some(owner) = owner {
419 for_each_active_actor_aura_effect(
420 crate::context::ActorView::new(state, buf, owner),
421 &mut apply_source_effect,
422 );
423 }
424
425 if let Some(target) = target {
426 for_each_active_target_aura_effect(state, buf, target, |stacks, effect| {
427 context.apply(stacks, effect, &mut mods);
428 context.apply_incoming_damage(stacks, effect, &mut mods);
429 });
430 }
431
432 if owner.is_some() {
433 for modifier in &state.defs.driver_spell_modifiers {
434 if !context.query.listed(modifier.spells) {
435 continue;
436 }
437
438 let resolved = (modifier.resolve)(crate::HookView {
439 state,
440 buf,
441 target,
442 now,
443 });
444
445 mods.damage_mult *= resolved.damage_mult;
446 mods.target_damage_mult *= resolved.target_damage_mult;
447 mods.crit_chance_pct += resolved.crit_chance_pct;
448 mods.crit_damage_mult *= resolved.crit_damage_mult;
449
450 tracing::trace!(
452 driver_spell_id = modifier.driver_spell_id,
453 profile_spell_id = context.query.profile_spell_id,
454 effect_spell_id = context.query.effect.spell_id,
455 ?resolved,
456 "driver-owned spell modifier applied"
457 );
458 }
459
460 context.apply_effect_point_damage_modifiers(&effect_point_modifiers, &mut mods);
461 }
462
463 mods
464}
465
466#[cfg(test)]
467pub(crate) fn spell_scoped_mods_fixture(
468 view: &crate::context::CombatView<'_>,
469 spell_id: u32,
470 flags: DamageFlags,
471) -> SpellScopedMods {
472 spell_scoped_mods_for(
473 view.state,
474 view.buf,
475 wowlab_types::sim::ActorId::Player,
476 SpellModifierQuery::new(spell_id, DamageEffectRef::new(spell_id, 0), flags),
477 view.state.current_target(),
478 wowlab_types::sim::SimTime::ZERO,
479 )
480}