Skip to main content

wowlab_engine_combat/systems/periodic/
tick.rs

1// #t(file: rust_duplicate_strings) Periodic binding failures intentionally match the owning aura transaction diagnostic.
2
3use super::{
4    ActorId, AuraKey, AuraOn, AuraTickBehavior, CombatCtx, CombatInstance, DamageFlags,
5    DamagePayload, DamageSnapshot, EffectExecution, EnemyIdx, Event, HUNDRED, HookCtx,
6    LocalAuraIdx, MIN_TICK_INTERVAL_S, MS_PER_SECOND, PeriodicKind, ResourceEventKind,
7    ResourceGain, ResourceGainSource, ResourceTelemetry, SimTime, SnapshotDamageInput,
8    SnapshotPower, TelemetrySink, apply_aura_system, aura_tick_due, deal_bound_damage_ap,
9    deal_bound_damage_sp, deal_damage_base, deal_damage_with_snapshot, deal_effect_damage_ap,
10    deal_effect_damage_base, deal_effect_damage_sp, drain_deferred_work,
11    effective_periodic_interval_ms, effective_periodic_tick_ms, emit_resource,
12    execute_effect_range, expire_aura, periodic_damage_snapshot, proc_chance, reapply_if_flagged,
13    spend_resource,
14};
15use crate::state::AuraMutation;
16
17struct PeriodicEffectContext<'a> {
18    program: &'a crate::state::CombatProgram,
19    instance: &'a mut CombatInstance,
20    sink: &'a mut TelemetrySink,
21    rng: &'a mut dyn FnMut() -> f64,
22    now: SimTime,
23    local: LocalAuraIdx,
24    spell_id: u32,
25    damage_binding: Option<crate::state::program::PeriodicDamageBinding>,
26    effect_ref: Option<crate::state::DamageEffectRef>,
27    may_crit: bool,
28    damage_is_periodic: bool,
29    key: AuraKey,
30    source: ActorId,
31    target: EnemyIdx,
32}
33
34impl PeriodicEffectContext<'_> {
35    fn aura_pool(&self) -> usize {
36        self.program
37            .aura_pool_index(self.local, self.key)
38            .expect("periodic aura has a finalized pool binding")
39    }
40    fn active_points(&self, initial: f64) -> f64 {
41        self.damage_binding.map_or(initial, |binding| {
42            crate::systems::buffs::active_effect_points(
43                crate::context::ActorView::new(self.program, self.instance, self.source),
44                binding.request.effect,
45                initial,
46            )
47        })
48    }
49
50    fn active_amplitude(&self, initial: f64) -> f64 {
51        self.damage_binding.map_or(initial, |binding| {
52            crate::systems::buffs::active_effect_amplitude(
53                crate::context::ActorView::new(self.program, self.instance, self.source),
54                binding.request.effect_action(),
55                initial,
56            )
57        })
58    }
59
60    fn damage_stack_multiplier(&self) -> f64 {
61        let Some(aura) = self.program.auras.get(self.local.as_usize()) else {
62            return 1.0;
63        };
64
65        if !aura.periodic_damage_scales_with_stacks {
66            return 1.0;
67        }
68
69        self.program
70            .buffer_aura(self.instance, self.local, self.key)
71            .map_or(1.0, |slot| f64::from(slot.stacks.max(1)))
72    }
73
74    fn combat_ctx(&mut self) -> CombatCtx<'_> {
75        CombatCtx {
76            program: self.program,
77            instance: self.instance,
78            sink: self.sink,
79            now: self.now,
80            rng: self.rng,
81            source: self.source,
82            target: self.target,
83        }
84    }
85
86    fn remaining_ticks(&self) -> f64 {
87        let (expires_at, tick_interval) = self
88            .program
89            .buffer_aura(self.instance, self.local, self.key)
90            .map_or((0.0, 0.0), |aura| (aura.expires_at, aura.tick_interval));
91
92        ((expires_at - self.now.as_secs_f64()) / tick_interval.max(MIN_TICK_INTERVAL_S))
93            .ceil()
94            .max(1.0)
95    }
96
97    fn classify_damage(&self, flags: DamageFlags) -> DamageFlags {
98        if self.damage_is_periodic {
99            flags | DamageFlags::PERIODIC
100        } else {
101            flags
102        }
103    }
104
105    fn damage_ap(
106        &mut self,
107        coef: f64,
108        flags: DamageFlags,
109        snapshot: Option<&DamageSnapshot>,
110    ) -> bool {
111        let spell_id = self.spell_id;
112        let effect = self.effect_ref;
113        let flags = if self.may_crit {
114            flags
115        } else {
116            flags | DamageFlags::NO_CRIT
117        };
118        let flags = self.classify_damage(flags);
119        let Some(binding) = self.damage_binding else {
120            return false;
121        };
122        let mut ctx = self.combat_ctx();
123
124        if let Some(snapshot) = snapshot {
125            deal_damage_with_snapshot(
126                &mut ctx,
127                SnapshotDamageInput {
128                    binding: binding.request,
129                    effect: effect
130                        .unwrap_or_else(|| crate::state::DamageEffectRef::new(spell_id, 1)),
131                    coefficient: coef,
132                    flags,
133                    power: SnapshotPower::Attack,
134                },
135                snapshot,
136            )
137            .1
138        } else if let Some(effect) = effect {
139            deal_effect_damage_ap(&mut ctx, binding.request, effect, coef, flags).1
140        } else {
141            deal_bound_damage_ap(
142                &mut ctx,
143                binding.request,
144                DamagePayload::new(spell_id, coef, flags),
145            )
146            .1
147        }
148    }
149
150    fn flat_damage(&mut self, amount: f64, is_physical: bool) -> bool {
151        let Some(binding) = self.damage_binding else {
152            return false;
153        };
154        let amount = amount * self.damage_stack_multiplier();
155        let spell_id = self.spell_id;
156        let mut flags = self.classify_damage(crate::systems::damage_flags_from_local(
157            self.program,
158            binding.request.effect_action(),
159            is_physical,
160        ));
161
162        if !self.may_crit {
163            flags |= DamageFlags::NO_CRIT;
164        }
165
166        if let Some(effect) = self.effect_ref {
167            deal_effect_damage_base(
168                &mut self.combat_ctx(),
169                binding.request,
170                effect,
171                spell_id,
172                amount,
173                flags,
174            )
175            .1
176        } else {
177            deal_damage_base(
178                &mut self.combat_ctx(),
179                binding.request,
180                spell_id,
181                amount,
182                flags,
183            )
184            .1
185        }
186    }
187
188    fn rolling_flat_damage(&mut self, amount: f64, is_physical: bool) -> bool {
189        let pool = self.aura_pool();
190        let multiplier = self.instance.runtime.pools.rolling_tick_mult.mult(pool);
191
192        self.flat_damage(amount * multiplier, is_physical)
193    }
194
195    fn rolling_damage_ap(&mut self, coef: f64, flags: DamageFlags) -> bool {
196        let pool = self.aura_pool();
197        let mult = self.instance.runtime.pools.rolling_tick_mult.mult(pool);
198
199        self.damage_ap(coef * mult, flags, None)
200    }
201
202    fn rolling_damage_sp(&mut self, coef: f64, flags: DamageFlags) -> bool {
203        let pool = self.aura_pool();
204        let mult = self.instance.runtime.pools.rolling_tick_mult.mult(pool);
205
206        self.damage_sp(coef * mult, flags, None)
207    }
208
209    fn damage_sp(
210        &mut self,
211        coef: f64,
212        flags: DamageFlags,
213        snapshot: Option<&DamageSnapshot>,
214    ) -> bool {
215        let spell_id = self.spell_id;
216        let effect = self.effect_ref;
217        let flags = if self.may_crit {
218            flags
219        } else {
220            flags | DamageFlags::NO_CRIT
221        };
222        let flags = self.classify_damage(flags);
223        let Some(binding) = self.damage_binding else {
224            return false;
225        };
226        let mut ctx = self.combat_ctx();
227
228        if let Some(snapshot) = snapshot {
229            deal_damage_with_snapshot(
230                &mut ctx,
231                SnapshotDamageInput {
232                    binding: binding.request,
233                    effect: effect
234                        .unwrap_or_else(|| crate::state::DamageEffectRef::new(spell_id, 1)),
235                    coefficient: coef,
236                    flags,
237                    power: SnapshotPower::Spell,
238                },
239                snapshot,
240            )
241            .1
242        } else if let Some(effect) = effect {
243            deal_effect_damage_sp(&mut ctx, binding.request, effect, coef, flags).1
244        } else {
245            deal_bound_damage_sp(
246                &mut ctx,
247                binding.request,
248                DamagePayload::new(spell_id, coef, flags),
249            )
250            .1
251        }
252    }
253
254    fn max_health_damage(&mut self, percent: f64, is_physical: bool) -> bool {
255        let Some(binding) = self.damage_binding else {
256            return false;
257        };
258        let Some(aura) = self.program.auras.get(self.local.as_usize()) else {
259            return false;
260        };
261
262        if aura.on != AuraOn::Target {
263            return false;
264        }
265
266        let Some(max_health) = self
267            .instance
268            .enemy_max_health(self.program, self.target)
269            .filter(|max_health| *max_health > 0.0)
270        else {
271            return false;
272        };
273        let amount =
274            max_health * self.active_points(percent) / HUNDRED * self.damage_stack_multiplier();
275        let mut flags = self.classify_damage(DamageFlags::empty());
276
277        if is_physical {
278            flags |= DamageFlags::PHYSICAL;
279        }
280
281        if !self.may_crit {
282            flags |= DamageFlags::NO_CRIT;
283        }
284
285        let spell_id = self.spell_id;
286
287        if let Some(effect) = self.effect_ref {
288            deal_effect_damage_base(
289                &mut self.combat_ctx(),
290                binding.request,
291                effect,
292                spell_id,
293                amount,
294                flags,
295            )
296            .1
297        } else {
298            deal_damage_base(
299                &mut self.combat_ctx(),
300                binding.request,
301                spell_id,
302                amount,
303                flags,
304            )
305            .1
306        }
307    }
308
309    fn heal(&mut self, base: f64, ap_coef: f64, sp_coef: f64, percent_of_max: f64) {
310        let base = self.active_points(base);
311        let percent_of_max = self.active_points(percent_of_max * HUNDRED) / HUNDRED;
312        let stats = &self.program.config.base_stats.stats;
313        let amount = base + ap_coef * stats.attack_power + sp_coef * stats.spell_power;
314        let target = match self.key.affected() {
315            ActorId::Enemy(_) => self.source,
316            actor => actor,
317        };
318        let _ = crate::systems::deal_heal(
319            self.program,
320            self.instance,
321            self.rng,
322            crate::systems::HealRequest {
323                source: self.source,
324                target,
325                base: amount,
326                percent_of_max,
327                school: wowlab_types::combat::DamageSchool::Holy,
328                periodic: true,
329                may_crit: self.may_crit,
330                at: self.now,
331            },
332        );
333    }
334
335    fn leech(
336        &mut self,
337        base: f64,
338        ap_coef: f64,
339        sp_coef: f64,
340        is_physical: bool,
341        heal_multiplier: f64,
342    ) -> bool {
343        let heal_multiplier = self.active_amplitude(heal_multiplier).max(0.0);
344        let mut flags = self.classify_damage(DamageFlags::empty());
345
346        if is_physical {
347            flags |= DamageFlags::PHYSICAL;
348        }
349
350        if !self.may_crit {
351            flags |= DamageFlags::NO_CRIT;
352        }
353
354        let spell_id = self.spell_id;
355        let Some(binding) = self.damage_binding else {
356            return false;
357        };
358        let (dealt, killed) = if ap_coef.abs() > f64::EPSILON {
359            deal_bound_damage_ap(
360                &mut self.combat_ctx(),
361                binding.request,
362                DamagePayload::new(spell_id, ap_coef, flags),
363            )
364        } else if sp_coef.abs() > f64::EPSILON {
365            deal_bound_damage_sp(
366                &mut self.combat_ctx(),
367                binding.request,
368                DamagePayload::new(spell_id, sp_coef, flags),
369            )
370        } else if let Some(effect) = self.effect_ref {
371            deal_effect_damage_base(
372                &mut self.combat_ctx(),
373                binding.request,
374                effect,
375                spell_id,
376                base,
377                flags,
378            )
379        } else {
380            deal_damage_base(
381                &mut self.combat_ctx(),
382                binding.request,
383                spell_id,
384                base,
385                flags,
386            )
387        };
388
389        self.heal(dealt * heal_multiplier, 0.0, 0.0, 0.0);
390
391        killed
392    }
393
394    fn gain_resource(
395        &mut self,
396        amount: f64,
397        resource_type: Option<wowlab_types::combat::ResourceType>,
398    ) {
399        let amount = self.active_points(amount);
400        let gain = ResourceGain::modified(amount, ResourceGainSource::Spell(self.spell_id));
401        let is_secondary = match resource_type {
402            Some(resource_type)
403                if Some(resource_type)
404                    == self.program.config.base_stats.secondary_resource_type =>
405            {
406                true
407            }
408            Some(resource_type)
409                if Some(resource_type) != self.program.config.base_stats.resource_type =>
410            {
411                tracing::trace!(
412                    ?resource_type,
413                    "periodic energize targets an unavailable resource"
414                );
415
416                return;
417            }
418            _ => false,
419        };
420
421        crate::systems::gain_resource_with_procs(&mut self.combat_ctx(), gain, is_secondary);
422    }
423
424    fn drain_resource(&mut self, amount: f64) -> bool {
425        let amount = self.active_points(amount);
426
427        if spend_resource(self.program, self.instance, amount, self.now) {
428            emit_resource(
429                self.program,
430                self.instance,
431                self.sink,
432                ResourceTelemetry {
433                    kind: ResourceEventKind::Spend,
434                    amount,
435                    secondary: false,
436                    now: self.now,
437                    source_spell_id: self.spell_id,
438                },
439            );
440
441            return true;
442        }
443
444        tracing::trace!(
445            aura_id = self.spell_id,
446            amount,
447            "aura expired: insufficient primary resource for drain tick"
448        );
449        expire_aura(
450            &mut HookCtx::new(
451                crate::context::HookCtxServices {
452                    program: self.program,
453                    instance: self.instance,
454                    sink: self.sink,
455                    rng: self.rng,
456                },
457                crate::context::HookCtxRequest::for_target(self.now, self.target)
458                    .with_source(self.source),
459            ),
460            self.local,
461        );
462
463        false
464    }
465
466    fn hook_ctx(&mut self) -> HookCtx<'_> {
467        HookCtx::new(
468            crate::context::HookCtxServices {
469                program: self.program,
470                instance: self.instance,
471                sink: self.sink,
472                rng: self.rng,
473            },
474            crate::context::HookCtxRequest::for_target(self.now, self.target)
475                .with_source(self.source),
476        )
477    }
478
479    fn apply_aura(&mut self, target_aura_local: LocalAuraIdx) {
480        apply_aura_system(&mut self.hook_ctx(), target_aura_local);
481    }
482
483    fn add_aura_stack(&mut self, target_aura_local: LocalAuraIdx) {
484        crate::systems::add_aura_stack(&mut self.hook_ctx(), target_aura_local);
485    }
486
487    fn deal_residual_damage(&mut self) {
488        let remaining_ticks = self.remaining_ticks();
489        let pool = self.aura_pool();
490        let amount = self
491            .instance
492            .runtime
493            .pools
494            .residual_damage_pools
495            .take_tick(pool, remaining_ticks);
496        let spell_id = self.spell_id;
497        let Some(binding) = self.damage_binding.map(|binding| binding.request) else {
498            return;
499        };
500
501        crate::systems::damage_pipeline::deal_residual_damage(
502            &mut self.combat_ctx(),
503            binding,
504            spell_id,
505            amount,
506        );
507    }
508
509    fn remove_stack(&mut self) -> bool {
510        let depleted = self
511            .program
512            .buffer_aura(self.instance, self.local, self.key)
513            .map(|aura| (aura.stacks - 1).max(0))
514            .is_some_and(|stacks| {
515                if let Some(binding) = self.program.aura_binding(self.local, self.key) {
516                    self.program.mutate_aura(
517                        self.instance,
518                        binding,
519                        AuraMutation::SetStacks(stacks),
520                        self.now,
521                    );
522                }
523
524                stacks == 0
525            });
526
527        if depleted {
528            let mut ctx = HookCtx::new(
529                crate::context::HookCtxServices {
530                    program: self.program,
531                    instance: self.instance,
532                    sink: self.sink,
533                    rng: self.rng,
534                },
535                crate::context::HookCtxRequest::for_target(self.now, self.target)
536                    .with_source(self.source),
537            );
538
539            expire_aura(&mut ctx, self.local);
540            reapply_if_flagged(&mut ctx, self.local);
541        }
542
543        !depleted
544    }
545}
546
547fn apply_periodic_effect(
548    ctx: &mut PeriodicEffectContext<'_>,
549    effect: PeriodicKind,
550    snapshot: Option<&DamageSnapshot>,
551) -> Option<bool> {
552    match effect {
553        PeriodicKind::FlatDamage {
554            amount,
555            is_physical,
556        } => Some(ctx.flat_damage(amount, is_physical)),
557        PeriodicKind::RollingFlatDamage {
558            amount,
559            is_physical,
560        } => Some(ctx.rolling_flat_damage(amount, is_physical)),
561        PeriodicKind::DamageAp { coef, is_physical } => {
562            let flags = periodic_damage_flags(is_physical);
563
564            Some(ctx.damage_ap(coef * ctx.damage_stack_multiplier(), flags, snapshot))
565        }
566        PeriodicKind::RollingDamageAp { coef, is_physical } => {
567            let flags = periodic_damage_flags(is_physical);
568
569            Some(ctx.rolling_damage_ap(coef * ctx.damage_stack_multiplier(), flags))
570        }
571        PeriodicKind::DamageSp { coef, is_physical } => {
572            let flags = periodic_damage_flags(is_physical);
573
574            Some(ctx.damage_sp(coef * ctx.damage_stack_multiplier(), flags, snapshot))
575        }
576        PeriodicKind::RollingDamageSp { coef, is_physical } => {
577            let flags = periodic_damage_flags(is_physical);
578
579            Some(ctx.rolling_damage_sp(coef * ctx.damage_stack_multiplier(), flags))
580        }
581        PeriodicKind::MaxHealthDamage {
582            percent,
583            is_physical,
584        } => Some(ctx.max_health_damage(percent, is_physical)),
585        PeriodicKind::Heal {
586            base,
587            ap_coef,
588            sp_coef,
589            percent_of_max,
590        } => {
591            ctx.heal(base, ap_coef, sp_coef, percent_of_max);
592
593            Some(false)
594        }
595        PeriodicKind::Leech {
596            base,
597            ap_coef,
598            sp_coef,
599            is_physical,
600            heal_multiplier,
601        } => Some(ctx.leech(base, ap_coef, sp_coef, is_physical, heal_multiplier)),
602        PeriodicKind::ResourceGain {
603            amount,
604            resource_type,
605        } => {
606            ctx.gain_resource(amount, resource_type);
607
608            Some(false)
609        }
610        PeriodicKind::ResourceDrain { amount } => ctx.drain_resource(amount).then_some(false),
611        PeriodicKind::ApplyAura { target_aura_local } => {
612            ctx.apply_aura(target_aura_local);
613
614            Some(false)
615        }
616        PeriodicKind::AddAuraStack { target_aura_local } => {
617            ctx.add_aura_stack(target_aura_local);
618
619            Some(false)
620        }
621        PeriodicKind::ResidualDamage => {
622            ctx.deal_residual_damage();
623
624            Some(false)
625        }
626        PeriodicKind::RemoveStack => ctx.remove_stack().then_some(false),
627        PeriodicKind::Hook => Some(false),
628    }
629}
630
631fn periodic_damage_flags(is_physical: bool) -> DamageFlags {
632    if is_physical {
633        DamageFlags::PHYSICAL
634    } else {
635        DamageFlags::empty()
636    }
637}
638
639pub(crate) fn periodic_tick_target(
640    program: &crate::state::CombatProgram,
641    state: &CombatInstance,
642    local: LocalAuraIdx,
643    key: AuraKey,
644    event_target: Option<EnemyIdx>,
645) -> Option<EnemyIdx> {
646    // #t(rust_unchecked_indexing) local is carried by the finalized aura event.
647    let aura = program.auras[local.as_usize()];
648    let event_target = event_target.or_else(|| match key.affected() {
649        ActorId::Enemy(enemy) => Some(enemy),
650        _ => None,
651    });
652
653    if aura.on == AuraOn::Player {
654        state.current_target()
655    } else {
656        event_target
657    }
658    .filter(|target| state.is_valid_target(*target))
659}
660
661/// Processes one periodic aura tick and queued expiry hooks.
662pub fn process_single_aura_tick_for(ctx: &mut CombatCtx<'_>, local: LocalAuraIdx, key: AuraKey) {
663    tick_single_aura(ctx, local, key);
664    let mut hook_ctx = ctx.hook_ctx_with_source(key.source());
665
666    drain_deferred_work(&mut hook_ctx);
667}
668
669#[cfg(test)]
670pub(crate) fn process_single_aura_tick_fixture(ctx: &mut CombatCtx<'_>, aura_id: u32) {
671    let Some(local) = ctx.program.aura_local(aura_id) else {
672        return;
673    };
674    let Some(key) = crate::systems::auras::aura_key_for(
675        ctx.program,
676        ctx.instance,
677        local,
678        ctx.source,
679        Some(ctx.target),
680    ) else {
681        return;
682    };
683
684    process_single_aura_tick_for(ctx, local, key);
685}
686
687// #t(fn: rust_cyclomatic_complexity) dispatch branches over channel ticks and each resolved periodic effect kind.
688// #t(fn: rust_max_fn_lines) tick resolution is one ordered transaction across payload, hook, resource, stack, and rescheduling stages.
689
690fn tick_single_aura(ctx: &mut CombatCtx<'_>, local: LocalAuraIdx, key: AuraKey) {
691    let now = ctx.now;
692    let target = ctx.target;
693    let program = ctx.program;
694    let state = &mut *ctx.instance;
695    let sink = &mut *ctx.sink;
696    let rng = &mut *ctx.rng;
697    // #t(rust_unchecked_indexing) local is carried by the finalized aura event.
698    let aura = program.auras[local.as_usize()];
699
700    if !aura_tick_due(program, state, local, key, now) {
701        return;
702    }
703
704    let Some(periodic) = aura.periodic else {
705        return;
706    };
707
708    let spell_id = periodic.damage_spell_id_or(aura.aura_id);
709
710    let snap = periodic_damage_snapshot(program, state, &aura, local, key, now);
711
712    let mut effect_ctx = PeriodicEffectContext {
713        program,
714        instance: state,
715        sink,
716        rng,
717        now,
718        local,
719        spell_id,
720        damage_binding: program.damage.periodic_request(local),
721        effect_ref: periodic.effect_ref,
722        may_crit: periodic.may_crit,
723        damage_is_periodic: periodic.damage_is_periodic,
724        key,
725        source: key.source(),
726        target,
727    };
728    let Some(crit) = apply_periodic_effect(&mut effect_ctx, periodic.effect, snap.as_ref()) else {
729        return;
730    };
731
732    if aura.tick_effects.len > 0 {
733        let profile_action = effect_ctx.program.damage.aura_tick_profile_action(local);
734
735        execute_effect_range(
736            &mut effect_ctx.combat_ctx(),
737            EffectExecution {
738                range: aura.tick_effects,
739                profile_spell_id: if aura.tick_profile_spell_id == 0 {
740                    aura.aura_id
741                } else {
742                    aura.tick_profile_spell_id
743                },
744                profile_action,
745                flags: DamageFlags::empty(),
746            },
747        );
748    }
749
750    // Re-read the slot after the hook because it may expire the aura.
751
752    if let Some(hook) = aura.on_tick {
753        let mut hook_ctx = HookCtx::new(
754            crate::context::HookCtxServices {
755                program,
756                instance: state,
757                sink,
758                rng,
759            },
760            crate::context::HookCtxRequest::for_target(now, target).with_source(key.source()),
761        )
762        .with_driver_spell(aura.aura_id);
763
764        hook(&mut hook_ctx);
765    }
766
767    let crit_gain = if crit
768        && periodic.crit_resource_gain > 0.0
769        && proc_chance(rng, periodic.crit_resource_chance)
770    {
771        periodic.crit_resource_gain
772    } else {
773        0.0
774    };
775    let resource_gain = periodic.resource_gain + crit_gain;
776
777    if resource_gain > 0.0 {
778        crate::systems::gain_resource_with_procs(
779            &mut CombatCtx {
780                program,
781                instance: state,
782                sink,
783                now,
784                rng,
785                source: key.source(),
786                target,
787            },
788            ResourceGain::modified(resource_gain, ResourceGainSource::Spell(spell_id)),
789            false,
790        );
791    }
792
793    if aura.tick_behavior != AuraTickBehavior::None && !aura.freeze_stacks {
794        let depleted = if let Some(slot) = program.buffer_aura(state, local, key) {
795            let change = i32::from(aura.tick_stack_change.max(1));
796
797            let stacks = if aura.reverse {
798                (slot.stacks - change).max(0)
799            } else {
800                (slot.stacks + change).min(slot.max_stacks)
801            };
802
803            if let Some(binding) = program.aura_binding(local, key) {
804                program.mutate_aura(state, binding, AuraMutation::SetStacks(stacks), now);
805            }
806
807            stacks == 0
808        } else {
809            false
810        };
811
812        if depleted {
813            expire_aura(
814                &mut HookCtx::new(
815                    crate::context::HookCtxServices {
816                        program,
817                        instance: state,
818                        sink,
819                        rng,
820                    },
821                    crate::context::HookCtxRequest::for_target(now, target)
822                        .with_source(key.source()),
823                ),
824                local,
825            );
826            let mut ctx = HookCtx::new(
827                crate::context::HookCtxServices {
828                    program,
829                    instance: state,
830                    sink,
831                    rng,
832                },
833                crate::context::HookCtxRequest::for_target(now, target).with_source(key.source()),
834            );
835
836            reapply_if_flagged(&mut ctx, local);
837
838            return;
839        }
840    }
841
842    let effective_tick_ms = effective_periodic_tick_ms(
843        &crate::context::CombatView::new(program, state),
844        local,
845        periodic.tick_interval_ms,
846    );
847    let base_s = f64::from(effective_tick_ms) / MS_PER_SECOND;
848    let next_interval_ms = effective_periodic_interval_ms(
849        &crate::context::CombatView::new(program, state),
850        local,
851        periodic.tick_interval_ms,
852        periodic.hasted_ticks,
853    );
854    let time_rate = program.buffer_aura(state, local, key).map_or(1.0, |slot| {
855        if slot.time_rate_multiplier > 0.0 {
856            slot.time_rate_multiplier
857        } else {
858            1.0
859        }
860    });
861    let interval_secs = f64::from(next_interval_ms) / MS_PER_SECOND * time_rate;
862
863    let next = SimTime::from_secs_f64(now.as_secs_f64() + interval_secs);
864    let next_time_s = next.as_secs_f64();
865    let expiry_s = program
866        .buffer_aura(state, local, key)
867        .map_or(0.0, |a| a.expires_at);
868
869    if next_time_s <= expiry_s {
870        if let Some(a) = program.buffer_aura(state, local, key).copied()
871            && let Some(binding) = program.aura_binding(local, key)
872        {
873            program.mutate_aura(
874                state,
875                binding,
876                AuraMutation::SetSchedule(crate::state::AuraSchedule {
877                    base_tick_interval: base_s,
878                    next_tick: next_time_s,
879                    tick_interval: interval_secs,
880                    time_rate_multiplier: a.time_rate_multiplier,
881                    remaining_ticks: a.remaining_ticks,
882                }),
883                now,
884            );
885        }
886
887        state.schedule(Event::AuraTick {
888            t: next,
889            aura: program
890                .aura_event(local, key, Some(target))
891                .expect("periodic aura has a finalized event binding"),
892        });
893    }
894}