Skip to main content

wowlab_engine_combat/systems/periodic/
tick.rs

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