Skip to main content

wowlab_engine_combat/systems/
effects.rs

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