Skip to main content

wowlab_engine_combat/systems/procs/
impact_effect.rs

1use super::{
2    CombatCtx, CombatInstance, EffectExecution, HookCtx, ImpactChanceScale, ImpactEffectProc,
3    ImpactEffectRngState, ImpactEvent, ImpactFilter, ImpactSource, PeriodicImpactFilter,
4    PeriodicImpactPolicy, ProcEventKind, ProcPhaseMask, SimTime,
5    execute_proc_effect_range_for_actor, impact_filter_matches, proc_policy_allows,
6    proc_type_mask_matches, with_scratch,
7};
8
9const FINALIZED_IMPACT_DRIVER: &str = "nonzero finalized impact proc driver has a dense action";
10
11fn impact_effect_source_matches(
12    ctx: &CombatCtx<'_>,
13    proc: ImpactEffectProc,
14    impact: ImpactEvent,
15) -> bool {
16    match proc.source {
17        ImpactSource::Spell(source_spell_id) => {
18            impact.kind == ProcEventKind::Spell
19                && impact.swing_hand.is_none()
20                && source_spell_id == impact.spell_id
21        }
22        ImpactSource::AutoAttack(source_hand) => {
23            impact.kind == ProcEventKind::Spell && impact.swing_hand == Some(source_hand)
24        }
25        ImpactSource::ProcTypeMask(mask) => {
26            matches!(
27                impact.kind,
28                ProcEventKind::Spell | ProcEventKind::AuraApplication
29            ) && proc_type_mask_matches(
30                impact.action.map_or(0, |action| {
31                    ctx.program.damage.action_at(action).defense_type
32                }),
33                mask,
34                impact,
35            )
36        }
37        ImpactSource::Heartbeat => impact.kind == ProcEventKind::Heartbeat,
38        ImpactSource::Kill => impact.kind == ProcEventKind::Kill,
39        ImpactSource::Dispel => impact.kind == ProcEventKind::Dispel,
40    }
41}
42
43fn impact_effect_matches(ctx: &CombatCtx<'_>, proc: ImpactEffectProc, impact: ImpactEvent) -> bool {
44    proc_policy_allows(impact, proc.driver)
45        && proc.phase_mask.contains(impact.phase)
46        && (impact.phase != ProcPhaseMask::HIT || proc.hit_mask.intersects(impact.hit_mask))
47        && impact_filter_matches(
48            ImpactFilter {
49                actor: proc.actor_filter,
50                spell: None,
51                periodic: PeriodicImpactFilter::from(PeriodicImpactPolicy {
52                    periodic_only: proc.periodic_only,
53                    skip_periodic: proc.skip_periodic,
54                }),
55                physical_only: proc.physical_only,
56                crit_only: proc.crit_only,
57            },
58            impact,
59        )
60        && impact_effect_source_matches(ctx, proc, impact)
61        && proc.charge_aura.is_none_or(|aura| {
62            crate::systems::is_aura_active(&ctx.view(), aura, impact.source, Some(impact.target))
63        })
64}
65
66fn impact_effect_runtime_ready(
67    ctx: &CombatCtx<'_>,
68    proc: ImpactEffectProc,
69    index: usize,
70    impact: ImpactEvent,
71) -> bool {
72    let charges = effective_proc_charges(ctx.program, ctx.instance, impact.source, proc, index);
73    let cooldown_ready = ctx
74        .instance
75        .runtime
76        .procs
77        .impact_effect_ready_at
78        .get(index)
79        .is_some_and(|ready_at| ctx.now.as_secs_f64() >= *ready_at);
80    let target_ready = proc.target_icd_ms == 0
81        || ctx
82            .instance
83            .runtime
84            .procs
85            .target_icd(index, impact.target)
86            .is_none_or(|ready_at| ctx.now >= ready_at);
87    let has_charges = if proc.uses_stacks_for_charges {
88        proc.charge_aura.is_some_and(|aura| {
89            crate::systems::aura_stacks(
90                ctx.program,
91                ctx.instance,
92                aura,
93                impact.source,
94                Some(impact.target),
95            ) > 0
96        })
97    } else {
98        charges == 0
99            || ctx
100                .instance
101                .runtime
102                .procs
103                .impact_effect_charges
104                .get(index)
105                .is_some_and(|remaining| *remaining > 0)
106    };
107
108    cooldown_ready && target_ready && has_charges
109}
110
111pub(in crate::systems) fn effective_proc_charges(
112    program: &crate::state::CombatProgram,
113    state: &CombatInstance,
114    source: wowlab_types::sim::ActorId,
115    proc: ImpactEffectProc,
116    index: usize,
117) -> u8 {
118    if proc.driver_spell_id == 0 {
119        return proc.charges;
120    }
121
122    let value = crate::systems::buffs::active_spell_modifier_value(
123        crate::context::ActorView::new(program, state, source),
124        program
125            .damage
126            .impact_effect_driver_action(index)
127            .expect(FINALIZED_IMPACT_DRIVER),
128        wowlab_engine_domain::dbc::ModifierPropertyKind::ProcCharges,
129        f64::from(proc.charges),
130    );
131
132    let clamped =
133        wowlab_types::numeric::f64_to_u32_saturating_round(value.max(0.0)).min(u32::from(u8::MAX));
134
135    u8::try_from(clamped).expect("proc charges are clamped to u8")
136}
137
138fn roll_rppm_impact_effect(
139    ctx: &mut CombatCtx<'_>,
140    source: wowlab_types::sim::ActorId,
141    proc: ImpactEffectProc,
142    tracker: crate::state::LocalRppmIdx,
143    index: usize,
144) -> bool {
145    let scaling = crate::systems::buffs::current_rppm_scaling(&ctx.view());
146    let base_rate = ctx
147        .instance
148        .runtime
149        .procs
150        .rppm_trackers
151        .get(tracker.as_usize())
152        .map_or(0.0, |tracker| tracker.rppm);
153    let rate = if proc.driver_spell_id == 0 {
154        base_rate
155    } else {
156        crate::systems::buffs::active_spell_modifier_value(
157            ctx.view().for_actor(source),
158            ctx.program
159                .damage
160                .impact_effect_driver_action(index)
161                .expect(FINALIZED_IMPACT_DRIVER),
162            wowlab_engine_domain::dbc::ModifierPropertyKind::ProcFrequency,
163            base_rate,
164        )
165        .max(0.0)
166    };
167    let Some(tracker) = ctx
168        .instance
169        .runtime
170        .procs
171        .rppm_trackers
172        .get_mut(tracker.as_usize())
173    else {
174        return false;
175    };
176    let base_rate = std::mem::replace(&mut tracker.rppm, rate);
177    let success = wowlab_engine_rng::roll_rppm(tracker, ctx.now.as_secs_f64(), scaling, ctx.rng);
178
179    tracker.rppm = base_rate;
180
181    success
182}
183
184fn roll_impact_effect(
185    ctx: &mut CombatCtx<'_>,
186    proc: ImpactEffectProc,
187    index: usize,
188    impact: ImpactEvent,
189) -> bool {
190    match proc.chance_scale {
191        ImpactChanceScale::Fixed => wowlab_engine_rng::proc_chance(ctx.rng, proc.chance),
192        ImpactChanceScale::WeaponSpeed { baseline_ms } => {
193            let chance = if baseline_ms == 0 {
194                0.0
195            } else {
196                proc.chance * f64::from(impact.swing_speed_ms) / f64::from(baseline_ms)
197            };
198
199            wowlab_engine_rng::proc_chance(ctx.rng, chance)
200        }
201        ImpactChanceScale::Rppm(tracker) => {
202            roll_rppm_impact_effect(ctx, impact.source, proc, tracker, index)
203        }
204        ImpactChanceScale::Shuffled { .. } => ctx
205            .instance
206            .runtime
207            .procs
208            .impact_effect_rng
209            .get_mut(index)
210            .is_some_and(|state| match state {
211                ImpactEffectRngState::Shuffled(tracker) => tracker.trigger(ctx.rng),
212                _ => false,
213            }),
214        ImpactChanceScale::Accumulated { .. } => ctx
215            .instance
216            .runtime
217            .procs
218            .impact_effect_rng
219            .get_mut(index)
220            .is_some_and(|state| match state {
221                ImpactEffectRngState::Accumulated(tracker) => !matches!(
222                    tracker.trigger(ctx.rng),
223                    wowlab_engine_rng::AccumulatedOutcome::Failed
224                ),
225                _ => false,
226            }),
227    }
228}
229
230fn record_impact_effect_success(
231    ctx: &mut CombatCtx<'_>,
232    proc: ImpactEffectProc,
233    index: usize,
234    impact: ImpactEvent,
235) -> bool {
236    let icd_ms = if proc.driver_spell_id == 0 {
237        proc.icd_ms
238    } else {
239        let value = crate::systems::buffs::active_spell_modifier_value(
240            ctx.view().for_actor(impact.source),
241            ctx.program
242                .damage
243                .impact_effect_driver_action(index)
244                .expect(FINALIZED_IMPACT_DRIVER),
245            wowlab_engine_domain::dbc::ModifierPropertyKind::ProcCooldown,
246            f64::from(proc.icd_ms),
247        );
248
249        wowlab_types::numeric::f64_to_u32_saturating_round(value.max(0.0))
250    };
251
252    if let Some(ready_at) = ctx
253        .instance
254        .runtime
255        .procs
256        .impact_effect_ready_at
257        .get_mut(index)
258    {
259        *ready_at = ctx
260            .now
261            .saturating_add(SimTime::from_millis(icd_ms))
262            .as_secs_f64();
263    }
264
265    if proc.target_icd_ms > 0 {
266        ctx.instance.runtime.procs.set_target_icd(
267            index,
268            impact.target,
269            ctx.now
270                .saturating_add(SimTime::from_millis(proc.target_icd_ms)),
271        );
272    }
273
274    if proc.uses_stacks_for_charges
275        || effective_proc_charges(ctx.program, ctx.instance, impact.source, proc, index) == 0
276    {
277        return false;
278    }
279
280    let Some(remaining) = ctx
281        .instance
282        .runtime
283        .procs
284        .impact_effect_charges
285        .get_mut(index)
286    else {
287        return false;
288    };
289
290    *remaining = remaining.saturating_sub(1);
291
292    *remaining == 0
293}
294
295fn consume_impact_effect_aura_stack(
296    ctx: &mut CombatCtx<'_>,
297    proc: ImpactEffectProc,
298    impact: ImpactEvent,
299) {
300    if !proc.uses_stacks_for_charges {
301        return;
302    }
303
304    let Some(aura) = proc.charge_aura else {
305        return;
306    };
307
308    let mut aura_ctx = HookCtx::new(
309        crate::context::HookCtxServices {
310            program: ctx.program,
311            instance: ctx.instance,
312            sink: ctx.sink,
313            rng: ctx.rng,
314        },
315        crate::context::HookCtxRequest::for_target(ctx.now, impact.target)
316            .with_source(impact.source),
317    );
318
319    crate::systems::consume_aura_stack(&mut aura_ctx, aura);
320}
321
322fn expire_depleted_impact_effect_aura(
323    ctx: &mut CombatCtx<'_>,
324    proc: ImpactEffectProc,
325    impact: ImpactEvent,
326) {
327    let Some(aura) = proc.charge_aura else {
328        return;
329    };
330    let mut aura_ctx = HookCtx::new(
331        crate::context::HookCtxServices {
332            program: ctx.program,
333            instance: ctx.instance,
334            sink: ctx.sink,
335            rng: ctx.rng,
336        },
337        crate::context::HookCtxRequest::for_target(ctx.now, impact.target)
338            .with_source(impact.source),
339    );
340
341    crate::systems::expire_aura(&mut aura_ctx, aura);
342}
343
344const fn impact_effect_driver_spell_id(proc: ImpactEffectProc, impact: ImpactEvent) -> u32 {
345    if proc.driver_spell_id == 0 {
346        impact.spell_id
347    } else {
348        proc.driver_spell_id
349    }
350}
351
352pub(super) fn fire_impact_effect_procs(
353    ctx: &mut CombatCtx<'_>,
354    impact: ImpactEvent,
355    excluded_driver_spell_id: Option<u32>,
356) {
357    if ctx.instance.runtime.procs.impact_effect_procs_dispatching {
358        return;
359    }
360
361    ctx.instance.runtime.procs.impact_effect_procs_dispatching = true;
362    let scratch = std::mem::take(&mut ctx.instance.runtime.scratch.impact_effects);
363    let scratch = with_scratch(&ctx.program.impact_effect_procs, scratch, |entries| {
364        for (index, proc) in entries.iter().copied().enumerate() {
365            // A proc may enable other proc drivers through CanProcFromProcs, but never
366            // schedules another attempt for itself from the aura it just applied.
367            if excluded_driver_spell_id == Some(proc.driver_spell_id)
368                || !impact_effect_matches(ctx, proc, impact)
369                || !impact_effect_runtime_ready(ctx, proc, index, impact)
370                || !roll_impact_effect(ctx, proc, index, impact)
371            {
372                continue;
373            }
374
375            let depleted = record_impact_effect_success(ctx, proc, index, impact);
376
377            consume_impact_effect_aura_stack(ctx, proc, impact);
378
379            if let Some(fire) = proc.fire {
380                let driver_spell_id = impact_effect_driver_spell_id(proc, impact);
381                let mut hook_ctx = HookCtx::new(
382                    crate::context::HookCtxServices {
383                        program: ctx.program,
384                        instance: ctx.instance,
385                        sink: ctx.sink,
386                        rng: ctx.rng,
387                    },
388                    crate::context::HookCtxRequest::for_target(ctx.now, impact.target)
389                        .with_source(impact.source),
390                )
391                .with_driver_spell(driver_spell_id)
392                .with_source_damage_flags(crate::DamageFlags::PROC);
393
394                fire(&mut hook_ctx, impact);
395            }
396
397            execute_proc_effect_range_for_actor(
398                ctx,
399                EffectExecution {
400                    range: proc.effects,
401                    profile_spell_id: if proc.profile_spell_id == 0 {
402                        impact_effect_driver_spell_id(proc, impact)
403                    } else {
404                        proc.profile_spell_id
405                    },
406                    profile_action: ctx
407                        .program
408                        .damage
409                        .impact_effect_profile_action(index)
410                        .or(impact.action)
411                        .expect("impact effect program carries a finalized profile action"),
412                    flags: crate::DamageFlags::PROC,
413                },
414                None,
415                impact_effect_driver_spell_id(proc, impact),
416            );
417
418            if depleted {
419                expire_depleted_impact_effect_aura(ctx, proc, impact);
420            }
421        }
422    });
423
424    ctx.instance.runtime.scratch.impact_effects = scratch;
425    ctx.instance.runtime.procs.impact_effect_procs_dispatching = false;
426}