Skip to main content

wowlab_engine_combat/systems/
effects.rs

1use super::{
2    add_aura_stack, apply_aura, deal_profiled_damage_def, expire_aura, extend_aura,
3    gain_resource_with_procs, mutate_cooldown,
4};
5use crate::{
6    DamageFlags,
7    context::CombatCtx,
8    state::{ResourceGain, ResourceGainSource, ResourcePool, SpellEffectData, SpellEffectRange},
9};
10
11fn execute_dispel(
12    ctx: &mut CombatCtx<'_>,
13    profile_spell_id: u32,
14    dispel_type: i32,
15    steal: bool,
16    target: wowlab_types::sim::ActorId,
17) {
18    let selected = ctx.buf.aura_keys().find(|key| {
19        if key.affected() != target {
20            return false;
21        }
22
23        let active = ctx
24            .buf
25            .aura(*key)
26            .is_some_and(|slot| slot.is_active(ctx.now.as_secs_f64()));
27
28        let matches_family = ctx
29            .state
30            .aura_local_for_key(*key)
31            .map(|local| ctx.state.aura(local).aura_id)
32            .is_some_and(|aura_id| {
33                ctx.state
34                    .config
35                    .game_data
36                    .dispel_type(wowlab_types::sim::SpellIdx::from_raw(aura_id))
37                    == dispel_type
38            });
39
40        active && matches_family
41    });
42    let Some(key) = selected else {
43        return;
44    };
45    let Some(local) = ctx.state.aura_local_for_key(key) else {
46        return;
47    };
48    let resistance = super::buffs::active_spell_modifier_value(
49        ctx.view().for_actor(key.source()),
50        wowlab_types::sim::SpellIdx::from_raw(ctx.state.aura(local).aura_id),
51        wowlab_engine_domain::dbc::ModifierPropertyKind::DispelResistance,
52        0.0,
53    )
54    .clamp(0.0, wowlab_types::constants::HUNDRED);
55
56    if (ctx.rng)() * wowlab_types::constants::HUNDRED < resistance {
57        return;
58    }
59
60    let stolen_slot = steal.then(|| ctx.buf.aura(key).copied()).flatten();
61
62    {
63        let mut aura_ctx = crate::HookCtx::new(
64            crate::context::HookCtxServices {
65                state: ctx.state,
66                buf: ctx.buf,
67                sink: ctx.sink,
68                rng: ctx.rng,
69            },
70            crate::context::HookCtxRequest::for_target(ctx.now, ctx.target)
71                .with_source(key.source()),
72        )
73        .with_effect_target(target);
74
75        expire_aura(&mut aura_ctx, local);
76    };
77
78    if let Some(slot) = stolen_slot {
79        let player_key = wowlab_types::sim::AuraKey::new(
80            key.aura(),
81            wowlab_types::sim::ActorId::Player,
82            wowlab_types::sim::ActorId::Player,
83            wowlab_types::sim::AuraOn::Player,
84        );
85
86        ctx.buf.ensure_aura_slot(player_key);
87
88        if let Some(destination) = ctx.buf.aura_mut(player_key) {
89            *destination = slot;
90        }
91
92        crate::systems::auras::emit_aura_apply_event(
93            ctx.state,
94            ctx.sink,
95            player_key,
96            key.aura().as_u32(),
97            wowlab_types::numeric::i32_to_u8_saturating(slot.stacks),
98            ctx.now,
99        );
100        ctx.state.schedule(wowlab_engine_ports::Event::AuraExpire {
101            t: wowlab_types::sim::SimTime::from_secs_f64(slot.expires_at),
102            key: player_key,
103            target: None,
104        });
105    }
106
107    super::procs::fire_dispel_procs(ctx, profile_spell_id);
108}
109
110#[derive(Clone, Copy, Debug)]
111pub(crate) struct EffectExecution {
112    pub range: SpellEffectRange,
113    pub profile_spell_id: u32,
114    pub flags: DamageFlags,
115}
116
117fn flags_for_child_profile(
118    state: &crate::state::CombatState,
119    profile_spell_id: u32,
120    inherited: DamageFlags,
121) -> DamageFlags {
122    let spell = wowlab_types::sim::SpellIdx::from_raw(profile_spell_id);
123    let physical = super::damage_pipeline::school_of(state, profile_spell_id).is_physical();
124    let profile_flags =
125        super::damage_pipeline::damage_flags_from_data(&state.config.game_data, spell, physical);
126
127    flags_with_profile_semantics(inherited, profile_flags)
128}
129
130// #t(fn: rust_const_fn_candidate) bitflags operators are not const-stable on this toolchain.
131fn flags_with_profile_semantics(inherited: DamageFlags, profile_flags: DamageFlags) -> DamageFlags {
132    let profile_bound = DamageFlags::PHYSICAL
133        | DamageFlags::PERIODIC
134        | DamageFlags::NO_CRIT
135        | DamageFlags::IGNORE_PLAYER_MULTIPLIERS
136        | DamageFlags::IGNORE_TARGET_MULTIPLIERS
137        | DamageFlags::IGNORE_POSITIVE_TARGET_MULTIPLIERS
138        | DamageFlags::AOE;
139
140    (inherited & !profile_bound) | profile_flags
141}
142
143struct EffectFrame {
144    execution: EffectExecution,
145    next: usize,
146    end: usize,
147    reflected: bool,
148}
149
150fn effect_is_hostile(
151    state: &crate::state::CombatState,
152    effect: SpellEffectData,
153    remaining_depth: u8,
154) -> bool {
155    match effect {
156        SpellEffectData::Damage { .. }
157        | SpellEffectData::InterruptCast { .. }
158        | SpellEffectData::Dispel { .. } => true,
159        SpellEffectData::ApplyAura { aura_local }
160        | SpellEffectData::AddAuraStack { aura_local }
161        | SpellEffectData::RemoveAura { aura_local }
162        | SpellEffectData::ExtendAura { aura_local, .. } => state
163            .defs
164            .auras
165            .get(aura_local.as_usize())
166            .is_some_and(|aura| aura.on == wowlab_types::sim::AuraOn::Target),
167        SpellEffectData::Conditional { .. }
168        | SpellEffectData::Heal { .. }
169        | SpellEffectData::Energize { .. }
170        | SpellEffectData::EnergizePercent { .. }
171        | SpellEffectData::MutateCooldown { .. } => false,
172        SpellEffectData::Delayed { effects, .. } => {
173            remaining_depth > 0
174                && effect_range_is_hostile_with_depth(state, effects, remaining_depth - 1)
175        }
176    }
177}
178
179fn effect_range_is_hostile_with_depth(
180    state: &crate::state::CombatState,
181    range: SpellEffectRange,
182    remaining_depth: u8,
183) -> bool {
184    let start = range.start as usize;
185    let end = start.saturating_add(usize::from(range.len));
186    let Some(effects) = state
187        .defs
188        .spell_effects
189        .get(start..end.min(state.defs.spell_effects.len()))
190    else {
191        return false;
192    };
193
194    effects
195        .iter()
196        .copied()
197        .any(|effect| effect_is_hostile(state, effect, remaining_depth))
198}
199
200pub(crate) fn effect_range_is_hostile(
201    state: &crate::state::CombatState,
202    range: SpellEffectRange,
203) -> bool {
204    const MAX_NESTED_EFFECT_DEPTH: u8 = 32;
205
206    effect_range_is_hostile_with_depth(state, range, MAX_NESTED_EFFECT_DEPTH)
207}
208
209fn effect_hook_ctx<'a>(
210    ctx: &'a mut CombatCtx<'_>,
211    reflected: bool,
212    effect_target: Option<wowlab_types::sim::ActorId>,
213) -> crate::HookCtx<'a> {
214    let source = ctx.source;
215    let hook = ctx.hook_ctx();
216
217    if reflected {
218        hook.with_effect_target(source)
219    } else if let Some(effect_target) = effect_target {
220        hook.with_effect_target(effect_target)
221    } else {
222        hook
223    }
224}
225
226fn hostile_effect_target(
227    ctx: &CombatCtx<'_>,
228    reflected: bool,
229    effect_target: Option<wowlab_types::sim::ActorId>,
230) -> wowlab_types::sim::ActorId {
231    if reflected {
232        ctx.source
233    } else {
234        match effect_target {
235            Some(target) => target,
236            None => wowlab_types::sim::ActorId::Enemy(ctx.target),
237        }
238    }
239}
240
241fn execute_percent_energize(
242    ctx: &mut CombatCtx<'_>,
243    profile_spell_id: u32,
244    resource_type: wowlab_types::combat::ResourceType,
245    percent: f64,
246    effect_index: u8,
247) {
248    let Some(resource) = ctx.buf.resource(resource_type) else {
249        return;
250    };
251    let percent = super::buffs::active_effect_points(
252        ctx.view().for_actor(ctx.source),
253        wowlab_types::sim::SpellIdx::from_raw(profile_spell_id),
254        effect_index,
255        percent,
256    );
257    let amount = resource.max * percent / wowlab_types::constants::HUNDRED;
258    let Some(is_secondary) = super::resources::resource_pool_for_type(ctx.state, resource_type)
259    else {
260        return;
261    };
262    let request = ResourceGain::modified(
263        amount,
264        ResourceGainSource::SpellEffect {
265            spell_id: profile_spell_id,
266            effect_index,
267        },
268    );
269
270    gain_resource_with_procs(ctx, request, is_secondary);
271}
272
273fn execute_damage_effect(
274    ctx: &mut CombatCtx<'_>,
275    execution: EffectExecution,
276    effect_data: SpellEffectData,
277) {
278    let SpellEffectData::Damage {
279        effect,
280        damage,
281        base_points,
282        may_crit,
283        attribute_flags,
284        requires_main_hand,
285        requires_off_hand,
286        equipped_item_requirement,
287    } = effect_data
288    else {
289        return;
290    };
291    let equipment = &ctx.state.config.game_data;
292    let missing_required_hand = (requires_main_hand && equipment.main_hand().speed_ms == 0)
293        || (requires_off_hand && equipment.off_hand().is_none());
294    let item_requirement_unmet = equipped_item_requirement.is_some_and(|requirement| {
295        let hand = if requires_off_hand {
296            wowlab_engine_gamedata::ResolvedDamageWeapon::OffHand
297        } else {
298            wowlab_engine_gamedata::ResolvedDamageWeapon::MainHand
299        };
300
301        !wowlab_engine_domain::dbc::equipped_weapon_matches(equipment, hand, requirement)
302    });
303
304    if missing_required_hand || item_requirement_unmet {
305        tracing::trace!(
306            spell_id = effect.spell_id,
307            effect_index = effect.effect_index,
308            requires_main_hand,
309            requires_off_hand,
310            ?equipped_item_requirement,
311            "SPELL_EFFECT_EQUIPMENT_REQUIREMENT_UNMET"
312        );
313
314        return;
315    }
316
317    let profile_flags = attribute_flags
318        | if may_crit {
319            DamageFlags::empty()
320        } else {
321            DamageFlags::NO_CRIT
322        };
323
324    deal_profiled_damage_def(
325        ctx,
326        effect,
327        execution.profile_spell_id,
328        Some(base_points),
329        damage,
330        flags_with_profile_semantics(execution.flags, profile_flags),
331    );
332}
333
334fn execute_heal_effect(
335    ctx: &mut CombatCtx<'_>,
336    effect_target: Option<wowlab_types::sim::ActorId>,
337    effect_data: SpellEffectData,
338) {
339    let SpellEffectData::Heal {
340        effect,
341        base,
342        ap_coef,
343        sp_coef,
344        percent_of_max,
345        may_crit,
346    } = effect_data
347    else {
348        return;
349    };
350    let stats = &ctx.state.config.base_stats.stats;
351    let (base, percent_of_max) = effect.map_or((base, percent_of_max), |effect| {
352        let spell = wowlab_types::sim::SpellIdx::from_raw(effect.spell_id);
353
354        (
355            super::buffs::active_effect_points(
356                ctx.view().for_actor(ctx.source),
357                spell,
358                effect.effect_index,
359                base,
360            ),
361            super::buffs::active_effect_points(
362                ctx.view().for_actor(ctx.source),
363                spell,
364                effect.effect_index,
365                percent_of_max * wowlab_types::constants::HUNDRED,
366            ) / wowlab_types::constants::HUNDRED,
367        )
368    });
369    let amount = base + ap_coef * stats.attack_power + sp_coef * stats.spell_power;
370
371    let _ = super::deal_heal(
372        ctx.state,
373        ctx.buf,
374        ctx.rng,
375        super::HealRequest {
376            source: ctx.source,
377            target: effect_target.unwrap_or(ctx.source),
378            base: amount,
379            percent_of_max,
380            school: wowlab_types::combat::DamageSchool::Holy,
381            periodic: false,
382            may_crit,
383            at: ctx.now,
384        },
385    );
386}
387
388fn execute_resource_effect(
389    ctx: &mut CombatCtx<'_>,
390    profile_spell_id: u32,
391    effect_data: SpellEffectData,
392) -> bool {
393    match effect_data {
394        SpellEffectData::Energize {
395            effect,
396            pool,
397            amount,
398        } => {
399            let amount = effect.map_or(amount, |effect| {
400                super::buffs::active_effect_points(
401                    ctx.view().for_actor(ctx.source),
402                    wowlab_types::sim::SpellIdx::from_raw(effect.spell_id),
403                    effect.effect_index,
404                    amount,
405                )
406            });
407            let is_secondary = matches!(pool, ResourcePool::Secondary);
408            let request =
409                ResourceGain::modified(amount, ResourceGainSource::Spell(profile_spell_id));
410
411            gain_resource_with_procs(ctx, request, is_secondary);
412
413            true
414        }
415        SpellEffectData::EnergizePercent {
416            resource_type,
417            percent,
418            effect_index,
419        } => {
420            execute_percent_energize(ctx, profile_spell_id, resource_type, percent, effect_index);
421
422            true
423        }
424        SpellEffectData::MutateCooldown {
425            spell_local,
426            operation,
427        } => {
428            mutate_cooldown(ctx.state, ctx.buf, spell_local, operation, ctx.now);
429
430            true
431        }
432        _ => false,
433    }
434}
435
436pub(crate) fn execute_effect_range(ctx: &mut CombatCtx<'_>, execution: EffectExecution) {
437    execute_effect_range_for_actor(ctx, execution, None);
438}
439
440fn effect_frame(ctx: &mut CombatCtx<'_>, execution: EffectExecution) -> Option<EffectFrame> {
441    let hostile = effect_range_is_hostile(ctx.state, execution.range);
442    let flags = if hostile {
443        super::damage_pipeline::resolve_spell_impact_flags(
444            ctx,
445            execution.profile_spell_id,
446            execution.flags,
447        )?
448    } else {
449        execution.flags
450    };
451    let execution = EffectExecution { flags, ..execution };
452    let next = execution.range.start as usize;
453    let end = next.saturating_add(usize::from(execution.range.len));
454
455    Some(EffectFrame {
456        execution,
457        next,
458        end,
459        reflected: flags.contains(DamageFlags::REFLECTED),
460    })
461}
462
463const fn effect_driver_spell_id(execution: EffectExecution, proc_driver_spell_id: u32) -> u32 {
464    if proc_driver_spell_id == 0 {
465        execution.profile_spell_id
466    } else {
467        proc_driver_spell_id
468    }
469}
470
471pub(crate) fn execute_effect_range_for_actor(
472    ctx: &mut CombatCtx<'_>,
473    execution: EffectExecution,
474    effect_target: Option<wowlab_types::sim::ActorId>,
475) {
476    execute_effect_range_for_actor_with_proc_driver(ctx, execution, effect_target, 0);
477}
478
479pub(crate) fn execute_proc_effect_range_for_actor(
480    ctx: &mut CombatCtx<'_>,
481    execution: EffectExecution,
482    effect_target: Option<wowlab_types::sim::ActorId>,
483    proc_driver_spell_id: u32,
484) {
485    execute_effect_range_for_actor_with_proc_driver(
486        ctx,
487        execution,
488        effect_target,
489        proc_driver_spell_id,
490    );
491}
492
493// #t(fn: rust_cyclomatic_complexity) one interpreter loop exhaustively dispatches the typed effect vocabulary
494fn execute_effect_range_for_actor_with_proc_driver(
495    ctx: &mut CombatCtx<'_>,
496    execution: EffectExecution,
497    effect_target: Option<wowlab_types::sim::ActorId>,
498    proc_driver_spell_id: u32,
499) {
500    let Some(frame) = effect_frame(ctx, execution) else {
501        return;
502    };
503
504    let mut frames = vec![frame];
505
506    while let Some(mut frame) = frames.pop() {
507        if frame.next >= frame.end {
508            continue;
509        }
510
511        let Some(effect) = ctx.state.defs.spell_effects.get(frame.next).copied() else {
512            continue;
513        };
514
515        frame.next = frame.next.saturating_add(1);
516        let execution = frame.execution;
517        let reflected = frame.reflected;
518
519        frames.push(frame);
520
521        if execute_resource_effect(ctx, execution.profile_spell_id, effect) {
522            continue;
523        }
524
525        match effect {
526            SpellEffectData::Conditional { predicate, effects } => {
527                let enabled = {
528                    let hook = effect_hook_ctx(ctx, reflected, effect_target);
529
530                    predicate(&hook)
531                };
532
533                if enabled {
534                    if let Some(frame) = effect_frame(
535                        ctx,
536                        EffectExecution {
537                            range: effects,
538                            ..execution
539                        },
540                    ) {
541                        frames.push(frame);
542                    }
543                }
544            }
545            SpellEffectData::ApplyAura { aura_local } => {
546                let mut hook = effect_hook_ctx(ctx, reflected, effect_target)
547                    .with_driver_spell(effect_driver_spell_id(execution, proc_driver_spell_id))
548                    .with_source_damage_flags(execution.flags);
549
550                apply_aura(&mut hook, aura_local);
551            }
552            SpellEffectData::AddAuraStack { aura_local } => {
553                let mut hook = effect_hook_ctx(ctx, reflected, effect_target)
554                    .with_driver_spell(effect_driver_spell_id(execution, proc_driver_spell_id))
555                    .with_source_damage_flags(execution.flags);
556
557                add_aura_stack(&mut hook, aura_local);
558            }
559            SpellEffectData::RemoveAura { aura_local } => {
560                expire_aura(
561                    &mut effect_hook_ctx(ctx, reflected, effect_target),
562                    aura_local,
563                );
564            }
565            SpellEffectData::InterruptCast { lockout_ms } => {
566                let _ = super::interrupt_cast(
567                    ctx.state,
568                    ctx.buf,
569                    hostile_effect_target(ctx, reflected, effect_target),
570                    ctx.now,
571                    lockout_ms,
572                );
573            }
574            SpellEffectData::Dispel { dispel_type, steal } => {
575                let target = hostile_effect_target(ctx, reflected, effect_target);
576
577                execute_dispel(ctx, execution.profile_spell_id, dispel_type, steal, target);
578            }
579            effect @ SpellEffectData::Damage { .. } => {
580                execute_damage_effect(ctx, execution, effect);
581            }
582            effect @ SpellEffectData::Heal { .. } => {
583                let target = if reflected {
584                    Some(ctx.source)
585                } else {
586                    effect_target
587                };
588
589                execute_heal_effect(ctx, target, effect);
590            }
591            SpellEffectData::Energize { .. }
592            | SpellEffectData::EnergizePercent { .. }
593            | SpellEffectData::MutateCooldown { .. } => {}
594            SpellEffectData::ExtendAura {
595                aura_local,
596                amount_ms,
597            } => extend_aura(
598                &mut effect_hook_ctx(ctx, reflected, effect_target),
599                aura_local,
600                amount_ms,
601            ),
602            SpellEffectData::Delayed {
603                delay_ms,
604                profile_spell_id,
605                effects,
606            } => {
607                let flags = flags_for_child_profile(ctx.state, profile_spell_id, execution.flags);
608
609                if delay_ms == 0 {
610                    if let Some(frame) = effect_frame(
611                        ctx,
612                        EffectExecution {
613                            range: effects,
614                            profile_spell_id,
615                            flags,
616                        },
617                    ) {
618                        frames.push(frame);
619                    }
620
621                    continue;
622                }
623
624                let source = ctx.source;
625                let hook = ctx.hook_ctx();
626                let mut hook = if reflected {
627                    hook.with_effect_target(source)
628                } else if let Some(effect_target) = effect_target {
629                    hook.with_effect_target(effect_target)
630                } else {
631                    hook
632                };
633
634                hook.schedule_effect_program(
635                    delay_ms,
636                    effects,
637                    profile_spell_id,
638                    flags,
639                    proc_driver_spell_id,
640                );
641            }
642        }
643    }
644}
645
646#[cfg(test)]
647#[path = "effects/tests.rs"]
648mod tests;