Skip to main content

wowlab_engine_combat/systems/procs/
impact_effect.rs

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