Skip to main content

wowlab_engine_combat/systems/auras/
application_state.rs

1// #t(file: rust_duplicate_strings) Aura binding invariant messages intentionally match the owning aura transaction diagnostics.
2
3//! Aura instance projection, snapshot, refresh, and rolling-periodic state.
4
5use wowlab_engine_domain::constants::AURA_PANDEMIC_THRESHOLD;
6use wowlab_engine_ports::Event;
7use wowlab_engine_telemetry::TelemetrySink;
8use wowlab_types::{
9    constants::MS_PER_SECOND,
10    sim::{ActorId, AuraIdx, AuraKey, AuraOn, EnemyIdx, PetIdx, SimTime},
11};
12
13use super::{MAX_AURA_APPLICATION_ACTORS, PERMANENT_AURA_EXPIRES_AT_S};
14#[cfg(test)]
15use crate::systems::{
16    refresh_maximum_charges_for, refresh_pet_actions_after_aura_change, refresh_pet_stat_snapshots,
17    refresh_primary_resource_projection, refresh_recharge_rates_for, refresh_spell_costs_for,
18    refresh_spell_gates_for, sync_player_health_from_stamina,
19};
20use crate::{
21    context::HookCtx,
22    state::{
23        AuraActivation, AuraMutation, AuraRefresh, AuraSchedule, AuraSnapshot, CombatInstance,
24        DamageEffectRef, LocalAuraIdx,
25    },
26    systems::{
27        buffs::{self, buff_totals},
28        damage_pipeline::{
29            DamageFlags, DamageSnapshot, SpellModifierEffect, SpellModifierProfile,
30            SpellModifierQuery, dynamic_base_powers, read_player_stats_for_damage,
31            spell_scoped_mods_for, typed_attack_power_with,
32        },
33        mastery::MasteryCtx,
34    },
35};
36
37#[cfg(test)]
38pub(super) fn pandemic_carry(base_duration: f64, remaining: f64) -> f64 {
39    remaining.min(base_duration * AURA_PANDEMIC_THRESHOLD)
40}
41
42pub(super) fn pandemic_carry_ms(base_duration_ms: u32, remaining_ms: u32) -> u32 {
43    let carry_limit = wowlab_types::numeric::f64_to_u32_saturating_trunc(
44        f64::from(base_duration_ms) * AURA_PANDEMIC_THRESHOLD,
45    );
46
47    remaining_ms.min(carry_limit)
48}
49
50#[derive(Clone, Copy)]
51pub(super) struct AuraSlotRequest<'a> {
52    pub aura: &'a crate::state::AuraData,
53    pub local: LocalAuraIdx,
54    pub key: AuraKey,
55    pub base_dur_ms: u32,
56    pub base_dur: SimTime,
57    pub is_permanent: bool,
58}
59
60#[inline]
61fn resolved_aura_key(aura: &crate::state::AuraData, source: ActorId, affected: ActorId) -> AuraKey {
62    AuraKey::new(AuraIdx(aura.aura_id), source, affected, aura.on)
63}
64
65#[inline]
66pub(crate) fn aura_key_for(
67    program: &crate::state::CombatProgram,
68    _state: &CombatInstance,
69    local: LocalAuraIdx,
70    source: ActorId,
71    target: Option<EnemyIdx>,
72) -> Option<AuraKey> {
73    let aura = program.auras.get(local.as_usize()).copied()?;
74    let affected = match aura.on {
75        AuraOn::Player => ActorId::Player,
76        AuraOn::Target => ActorId::Enemy(target?),
77        AuraOn::Pet => ActorId::Pet(match source {
78            ActorId::Player => PetIdx::PRIMARY,
79            ActorId::Pet(pet) => pet,
80            ActorId::External | ActorId::Enemy(_) => return None,
81        }),
82    };
83
84    Some(resolved_aura_key(&aura, source, affected))
85}
86
87pub(crate) fn aura_keys_for_application(
88    program: &crate::state::CombatProgram,
89    state: &CombatInstance,
90    local: LocalAuraIdx,
91    source: ActorId,
92    target: Option<EnemyIdx>,
93) -> [Option<AuraKey>; MAX_AURA_APPLICATION_ACTORS] {
94    let primary = aura_key_for(program, state, local, source, target);
95    let secondary = program
96        .auras
97        .get(local.as_usize())
98        .filter(|aura| aura.propagates_to_pet)
99        .map(|aura| {
100            AuraKey::new(
101                AuraIdx(aura.aura_id),
102                source,
103                ActorId::Pet(PetIdx::PRIMARY),
104                AuraOn::Pet,
105            )
106        });
107
108    [primary, secondary]
109}
110
111pub(crate) fn aura_keys_for_actor_application(
112    program: &crate::state::CombatProgram,
113    _state: &CombatInstance,
114    local: LocalAuraIdx,
115    source: ActorId,
116    affected: ActorId,
117) -> [Option<AuraKey>; MAX_AURA_APPLICATION_ACTORS] {
118    let Some(aura) = program.auras.get(local.as_usize()) else {
119        return [None, None];
120    };
121    let primary = Some(resolved_aura_key(aura, source, affected));
122    let secondary = aura.propagates_to_pet.then(|| {
123        AuraKey::new(
124            AuraIdx(aura.aura_id),
125            source,
126            ActorId::Pet(PetIdx::PRIMARY),
127            AuraOn::Pet,
128        )
129    });
130
131    [primary, secondary]
132}
133
134pub(super) fn aura_query_key_for(
135    program: &crate::state::CombatProgram,
136    state: &CombatInstance,
137    local: LocalAuraIdx,
138    source: ActorId,
139    target: Option<EnemyIdx>,
140) -> Option<AuraKey> {
141    let aura = program.auras.get(local.as_usize()).copied()?;
142    let query_source = if aura.on == AuraOn::Player && matches!(source, ActorId::Pet(_)) {
143        ActorId::Player
144    } else {
145        source
146    };
147
148    aura_key_for(program, state, local, query_source, target)
149}
150
151#[inline]
152pub(super) fn context_aura_key(ctx: &HookCtx<'_>, local: LocalAuraIdx) -> Option<AuraKey> {
153    if let Some(affected) = ctx.effect_target {
154        let aura = ctx.program.auras.get(local.as_usize())?;
155
156        return Some(resolved_aura_key(aura, ctx.source, affected));
157    }
158
159    aura_key_for(ctx.program, ctx.instance, local, ctx.source, ctx.target)
160}
161
162fn emit_aura_event(
163    program: &crate::state::CombatProgram,
164    state: &CombatInstance,
165    sink: &mut TelemetrySink,
166    key: AuraKey,
167    now: SimTime,
168    emit: impl FnOnce(&mut TelemetrySink, u32, wowlab_engine_telemetry::AuraTelemetryScope),
169) {
170    emit(
171        sink,
172        now.as_millis(),
173        program.aura_telemetry_scope(state, key),
174    );
175}
176
177// #t(fn: rust_large_fn_params) program and instance are intentionally explicit lifecycle borrows alongside the complete typed telemetry event.
178pub(crate) fn emit_aura_apply_event(
179    program: &crate::state::CombatProgram,
180    state: &CombatInstance,
181    sink: &mut TelemetrySink,
182    key: AuraKey,
183    aura_id: u32,
184    stacks: u8,
185    now: SimTime,
186) {
187    emit_aura_event(program, state, sink, key, now, |sink, at_ms, scope| {
188        sink.emit_aura_apply(aura_id, stacks, at_ms, scope);
189    });
190}
191
192// #t(fn: rust_large_fn_params) program and instance are intentionally explicit lifecycle borrows alongside the complete typed telemetry event.
193pub(crate) fn emit_aura_refresh_event(
194    program: &crate::state::CombatProgram,
195    state: &CombatInstance,
196    sink: &mut TelemetrySink,
197    key: AuraKey,
198    aura_id: u32,
199    stacks: u8,
200    now: SimTime,
201) {
202    emit_aura_event(program, state, sink, key, now, |sink, at_ms, scope| {
203        sink.emit_aura_refresh(aura_id, stacks, at_ms, scope);
204    });
205}
206
207pub(super) fn emit_aura_expire_event(
208    program: &crate::state::CombatProgram,
209    state: &CombatInstance,
210    sink: &mut TelemetrySink,
211    key: AuraKey,
212    aura_id: u32,
213    now: SimTime,
214) {
215    emit_aura_event(program, state, sink, key, now, |sink, at_ms, scope| {
216        sink.emit_aura_expire(aura_id, at_ms, scope);
217    });
218}
219
220fn refresh_projection(
221    program: &crate::state::CombatProgram,
222    instance: &mut CombatInstance,
223    dependency: crate::state::program::ProjectionDependency,
224) {
225    let source = program
226        .aura_dependencies
227        .projection_source(dependency, instance.current_target());
228    let exact = source.map_or_else(Default::default, |source| *instance.buffer.aura_at(source));
229
230    *instance
231        .buffer
232        .aura_projection_mut_at(dependency.destination) = exact;
233}
234
235pub(crate) fn refresh_planned_aura_projection(
236    program: &crate::state::CombatProgram,
237    instance: &mut CombatInstance,
238    index: crate::state::program::AuraProjectionDependencyIdx,
239) {
240    refresh_projection(
241        program,
242        instance,
243        program.aura_dependencies.projection(index),
244    );
245}
246
247/// Refresh all projections only for initialization or a selected-target transition.
248pub(crate) fn refresh_all_aura_projections(
249    program: &crate::state::CombatProgram,
250    instance: &mut CombatInstance,
251) {
252    for dependency in program.aura_dependencies.all_projections() {
253        refresh_projection(program, instance, *dependency);
254    }
255
256    instance.clear_aura_projection_dirty();
257}
258
259/// Independent full-consumer recomputation used only as a dispatcher differential oracle.
260#[cfg(test)]
261pub(crate) fn reference_recompute_all_aura_consumers(
262    program: &crate::state::CombatProgram,
263    instance: &mut CombatInstance,
264    now: SimTime,
265) {
266    wowlab_types::bench_metrics::record_scanned_aura_definitions(program.auras.len());
267    wowlab_types::bench_metrics::record_scanned_aura_spells(program.spells.len());
268    wowlab_types::bench_metrics::record_scanned_aura_projections(
269        program.aura_dependencies.all_projections().len(),
270    );
271
272    let spell_locals = (0..program.spells.len())
273        .map(|index| {
274            crate::state::LocalSpellIdx::new(u8::try_from(index).expect("spell count fits"))
275        })
276        .collect::<Vec<_>>();
277    let spell_consumers = spell_locals
278        .iter()
279        .copied()
280        .map(|local| crate::state::program::SpellConsumerBinding {
281            local,
282            handle: program.buffer_bindings.spell(local),
283        })
284        .collect::<Vec<_>>();
285    let aura_bindings = program
286        .buffer_bindings
287        .aura_bindings()
288        .filter(|binding| binding.key.affected() == ActorId::Player)
289        .collect::<Vec<_>>();
290
291    refresh_all_aura_projections(program, instance);
292    refresh_spell_gates_for(program, instance, &spell_consumers);
293    refresh_spell_costs_for(program, instance, &spell_consumers);
294    refresh_recharge_rates_for(program, instance, now, &spell_locals);
295    refresh_maximum_charges_for(program, instance, now, &spell_locals);
296    sync_player_health_from_stamina(program, instance);
297    refresh_primary_resource_projection(program, instance, now);
298    super::refresh_aura_time_rates_for(program, instance, now, &aura_bindings);
299    super::super::auto_attack::reproject_player_auto_attack_speeds(program, instance, now);
300    super::super::auto_attack::reproject_pet_auto_attack_speeds(program, instance, now);
301    super::super::guardians::reproject_guardian_action_speeds(program, instance, now);
302    refresh_pet_stat_snapshots(program, instance);
303    refresh_pet_actions_after_aura_change(program, instance, now);
304}
305
306pub(super) fn apply_exclusive_group_dependencies(
307    ctx: &mut HookCtx<'_>,
308    applied: crate::state::program::ConcreteAuraBinding,
309) {
310    let peer_count = ctx.program.aura_dependencies.exclusive_peers(applied).len();
311    let spell_count = ctx
312        .program
313        .aura_dependencies
314        .exclusive_spells(applied.local)
315        .len();
316
317    wowlab_types::bench_metrics::record_planned_aura_bindings(peer_count);
318    wowlab_types::bench_metrics::record_planned_aura_spell_bindings(spell_count);
319
320    for index in 0..peer_count {
321        // BOUNDS: index is bounded by this exact finalized peer slice's length.
322        let peer = ctx.program.aura_dependencies.exclusive_peers(applied)[index];
323
324        if ctx.instance.buffer.aura_at(peer.handle).is_occupied() {
325            super::expire_aura_instance(ctx, peer.local, peer.key);
326        }
327    }
328
329    for dependency in ctx
330        .program
331        .aura_dependencies
332        .exclusive_spells(applied.local)
333    {
334        ctx.instance
335            .buffer
336            .spell_mut_at(dependency.handle)
337            .is_enabled = i32::from(dependency.enabled);
338    }
339}
340
341#[inline]
342pub(super) fn refreshed_stacks(aura: &crate::state::AuraData, current: i32) -> i32 {
343    let max = i32::from(aura.max_stacks);
344
345    if aura.apply_at_max_stacks {
346        max
347    } else {
348        (current + i32::from(aura.doses)).min(max)
349    }
350}
351
352pub(crate) fn capture_snapshot_for(
353    program: &crate::state::CombatProgram,
354    state: &CombatInstance,
355    binding: crate::state::program::PeriodicDamageBinding,
356    source: ActorId,
357    target: Option<EnemyIdx>,
358    now: SimTime,
359) -> DamageSnapshot {
360    let effect_plan = program.damage.effect_at(binding.snapshot_effect);
361    let effect_action = program.damage.action_at(effect_plan.action);
362    let effect = DamageEffectRef::new(effect_action.spell.as_u32(), effect_plan.effect_index);
363    let spell_id = program
364        .damage
365        .action_at(binding.request.profile_action)
366        .spell
367        .as_u32();
368    let (crit, vers) = read_player_stats_for_damage(&state.buffer);
369    let totals = buff_totals(program, state, target);
370    let spell_mods = spell_scoped_mods_for(
371        program,
372        state,
373        source,
374        SpellModifierQuery::new(
375            SpellModifierProfile::new(spell_id, binding.request.profile_action),
376            SpellModifierEffect::new(effect, binding.snapshot_effect),
377            DamageFlags::PERIODIC,
378        ),
379        target,
380        now,
381    );
382    let mastery_mult = target.map_or(1.0, |target| {
383        (program.config.mastery_hook)(&MasteryCtx {
384            program,
385            instance: state,
386            spell_id,
387            spell_action: binding.request.profile_action,
388            school: program
389                .damage
390                .action_at(binding.request.profile_action)
391                .school,
392            mastery: buffs::current_mastery_points(&crate::CombatView::new(program, state)),
393            player_crit_pct: crit + totals.crit_pct,
394            mastery_spell: program.config.mastery_spell,
395            mastery_action: program.damage.mastery_action(),
396            is_periodic: true,
397            is_pet: matches!(source, ActorId::Pet(_)),
398            source,
399            target: Some(target),
400            now,
401        })
402    });
403
404    DamageSnapshot {
405        ap: typed_attack_power_with(
406            program,
407            state,
408            crate::state::WeaponApType::MainHand,
409            &totals,
410        ),
411        sp: dynamic_base_powers(program, state, &totals).1,
412        crit_pct: crit + totals.crit_pct + spell_mods.crit_chance_pct,
413        vers_pct: vers + totals.vers_pct,
414        mastery_mult,
415        damage_mult: totals.damage_mult * spell_mods.damage_mult,
416    }
417}
418
419#[cfg(test)]
420pub(crate) fn capture_snapshot_fixture(
421    view: crate::context::HookView<'_>,
422    spell_id: u32,
423    source: ActorId,
424) -> DamageSnapshot {
425    capture_snapshot_for(
426        view.program,
427        view.instance,
428        {
429            let effect = DamageEffectRef::new(spell_id, 0);
430            let request = view
431                .program
432                .damage
433                .diagnostic_bind_request(effect, effect, spell_id)
434                .expect("snapshot fixture spell is finalized");
435
436            crate::state::program::PeriodicDamageBinding {
437                request,
438                snapshot_effect: request.effect,
439            }
440        },
441        source,
442        view.target,
443        view.now,
444    )
445}
446
447pub(super) fn store_stronger_snapshot(
448    program: &crate::state::CombatProgram,
449    instance: &mut CombatInstance,
450    local: LocalAuraIdx,
451    key: AuraKey,
452    snapshot: DamageSnapshot,
453    now: SimTime,
454) -> bool {
455    let Some(binding) = program.aura_binding(local, key) else {
456        return false;
457    };
458    let aura = instance.buffer.aura_at(binding.handle);
459
460    if aura.snapshot_has != 0 && snapshot.damage_mult <= aura.snapshot_damage_mult {
461        return false;
462    }
463
464    program.mutate_aura(
465        instance,
466        binding,
467        AuraMutation::SetSnapshot(AuraSnapshot {
468            has_snapshot: true,
469            attack_power: snapshot.ap,
470            spell_power: snapshot.sp,
471            critical_chance: snapshot.crit_pct,
472            versatility: snapshot.vers_pct,
473            mastery_multiplier: snapshot.mastery_mult,
474            damage_multiplier: snapshot.damage_mult,
475        }),
476        now,
477    )
478}
479
480/// Refreshes a snapshot with a persistent damage multiplier without compounding it.
481// #t(fn: rust_cyclomatic_complexity) snapshot refresh is one ordered validation, capture, multiplier replacement, and write transaction.
482pub(crate) fn refresh_aura_snapshot_multiplier(
483    ctx: &mut HookCtx<'_>,
484    local: LocalAuraIdx,
485    multiplier: f64,
486) -> bool {
487    // BOUNDS: local indices are created from the registered aura definition table.
488    let aura = ctx.program.auras[local.as_usize()];
489
490    if aura.periodic.is_none() || multiplier <= 0.0 {
491        return false;
492    }
493
494    let Some(key) = context_aura_key(ctx, local) else {
495        return false;
496    };
497
498    if !ctx
499        .program
500        .buffer_aura(ctx.instance, local, key)
501        .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
502    {
503        return false;
504    }
505
506    if aura.is_snapshot {
507        let binding = ctx
508            .program
509            .damage
510            .periodic_request(local)
511            .expect("snapshot aura has a finalized periodic damage binding");
512        let mut snapshot = capture_snapshot_for(
513            ctx.program,
514            ctx.instance,
515            binding,
516            ctx.source,
517            ctx.target,
518            ctx.now,
519        );
520
521        snapshot.damage_mult *= multiplier;
522
523        return store_stronger_snapshot(ctx.program, ctx.instance, local, key, snapshot, ctx.now);
524    }
525
526    let Some(slot) = ctx.program.buffer_aura(ctx.instance, local, key) else {
527        return false;
528    };
529
530    if slot.snapshot_has != 0 && multiplier <= slot.snapshot_damage_mult {
531        return false;
532    }
533
534    let snapshot = AuraSnapshot {
535        has_snapshot: true,
536        attack_power: slot.snapshot_ap,
537        spell_power: slot.snapshot_sp,
538        critical_chance: slot.snapshot_crit,
539        versatility: slot.snapshot_vers,
540        mastery_multiplier: slot.snapshot_mastery_mult,
541        damage_multiplier: multiplier,
542    };
543
544    let Some(binding) = ctx.program.aura_binding(local, key) else {
545        return false;
546    };
547
548    ctx.program.mutate_aura(
549        ctx.instance,
550        binding,
551        AuraMutation::SetSnapshot(snapshot),
552        ctx.now,
553    )
554}
555
556pub(super) fn revive_tick_chain(
557    ctx: &mut HookCtx<'_>,
558    local: LocalAuraIdx,
559    aura: &crate::state::AuraData,
560    key: AuraKey,
561    previous_expiry: Option<f64>,
562) {
563    if aura.periodic.is_none() {
564        return;
565    }
566
567    let Some(a) = ctx.program.buffer_aura(ctx.instance, local, key) else {
568        return;
569    };
570
571    let terminal_at_previous_expiry = previous_expiry.is_some_and(|expiry| a.next_tick >= expiry);
572
573    if !a.is_occupied()
574        || (a.next_tick > ctx.now.as_secs_f64() && !terminal_at_previous_expiry)
575        || a.tick_interval <= 0.0
576    {
577        return;
578    }
579
580    let next_tick = SimTime::from_secs_f64(ctx.now.as_secs_f64() + a.tick_interval);
581
582    if next_tick.as_secs_f64() <= a.expires_at {
583        if let Some(binding) = ctx.program.aura_binding(local, key) {
584            ctx.program.mutate_aura(
585                ctx.instance,
586                binding,
587                AuraMutation::SetNextTick(next_tick.as_secs_f64()),
588                ctx.now,
589            );
590        }
591
592        ctx.instance.schedule(Event::AuraTick {
593            t: next_tick,
594            aura: ctx
595                .program
596                .aura_event(local, key, ctx.target)
597                .expect("periodic aura has a finalized event binding"),
598        });
599    }
600}
601
602pub(super) fn update_aura_slot(
603    ctx: &mut HookCtx<'_>,
604    request: AuraSlotRequest<'_>,
605) -> Option<bool> {
606    let AuraSlotRequest { local, key, .. } = request;
607    let is_fresh = !ctx
608        .program
609        .buffer_aura(ctx.instance, local, key)?
610        .is_occupied();
611
612    if is_fresh {
613        initialize_aura_slot(ctx, request);
614    } else {
615        refresh_aura_slot(ctx, request);
616    }
617
618    Some(is_fresh)
619}
620
621pub(super) fn initialize_aura_slot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
622    let AuraSlotRequest {
623        aura,
624        local,
625        key,
626        base_dur_ms,
627        base_dur,
628        is_permanent,
629    } = request;
630    let expires_at = if is_permanent {
631        PERMANENT_AURA_EXPIRES_AT_S
632    } else {
633        ctx.now.saturating_add(base_dur).as_secs_f64()
634    };
635    let stacks = if aura.apply_at_max_stacks {
636        i32::from(aura.max_stacks)
637    } else {
638        i32::from(aura.doses).min(i32::from(aura.max_stacks))
639    };
640
641    let Some(binding) = ctx.program.aura_binding(local, key) else {
642        return;
643    };
644
645    if !ctx.program.mutate_aura(
646        ctx.instance,
647        binding,
648        AuraMutation::Activate(AuraActivation {
649            expires_at,
650            base_duration: f64::from(base_dur_ms) / MS_PER_SECOND,
651            stacks,
652            max_stacks: i32::from(aura.max_stacks),
653        }),
654        ctx.now,
655    ) {
656        return;
657    }
658
659    let stacks = wowlab_types::numeric::i32_to_u8_saturating(stacks);
660
661    emit_aura_apply_event(
662        ctx.program,
663        ctx.instance,
664        ctx.sink,
665        key,
666        aura.aura_id,
667        stacks,
668        ctx.now,
669    );
670}
671
672pub(super) fn refresh_aura_slot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
673    let AuraSlotRequest {
674        aura,
675        local,
676        key,
677        base_dur_ms,
678        base_dur,
679        is_permanent,
680    } = request;
681    let Some(a) = ctx.program.buffer_aura(ctx.instance, local, key) else {
682        return;
683    };
684    let expires_at = (!is_permanent).then(|| {
685        let expires_ms =
686            wowlab_types::numeric::f64_to_u32_saturating_trunc(a.expires_at * MS_PER_SECOND);
687        let remaining_ms = expires_ms.saturating_sub(ctx.now.as_millis());
688        let carry_ms = match aura.refresh_behavior {
689            wowlab_types::data::RefreshBehavior::Pandemic => {
690                pandemic_carry_ms(base_dur_ms, remaining_ms)
691            }
692            wowlab_types::data::RefreshBehavior::Extend => remaining_ms,
693            wowlab_types::data::RefreshBehavior::Tick => {
694                let interval_ms = wowlab_types::numeric::f64_to_u32_saturating_round(
695                    a.tick_interval * MS_PER_SECOND,
696                );
697
698                if interval_ms == 0 {
699                    0
700                } else {
701                    remaining_ms % interval_ms
702                }
703            }
704            wowlab_types::data::RefreshBehavior::Clip
705            | wowlab_types::data::RefreshBehavior::None
706            | wowlab_types::data::RefreshBehavior::Duration => 0,
707        };
708        // docref:start auras-pandemic-carry
709        ctx.now
710            .saturating_add(base_dur)
711            .saturating_add(SimTime::from_millis(carry_ms))
712            .as_secs_f64()
713        // docref:end auras-pandemic-carry
714    });
715
716    let stacks = refreshed_stacks(aura, a.stacks);
717
718    if let Some(binding) = ctx.program.aura_binding(local, key) {
719        ctx.program.mutate_aura(
720            ctx.instance,
721            binding,
722            AuraMutation::Refresh(AuraRefresh { expires_at, stacks }),
723            ctx.now,
724        );
725    }
726
727    let stacks = wowlab_types::numeric::i32_to_u8_saturating(stacks);
728
729    emit_aura_refresh_event(
730        ctx.program,
731        ctx.instance,
732        ctx.sink,
733        key,
734        aura.aura_id,
735        stacks,
736        ctx.now,
737    );
738}
739
740pub(super) fn initialize_or_revive_periodic(
741    ctx: &mut HookCtx<'_>,
742    request: AuraSlotRequest<'_>,
743    is_fresh: bool,
744    previous_expiry: Option<f64>,
745) {
746    let AuraSlotRequest {
747        aura,
748        local,
749        key,
750        base_dur_ms,
751        ..
752    } = request;
753
754    let Some(periodic) = aura.periodic.as_ref() else {
755        return;
756    };
757
758    if !is_fresh {
759        revive_tick_chain(ctx, local, aura, key, previous_expiry);
760
761        return;
762    }
763
764    let effective_tick_ms =
765        buffs::effective_periodic_tick_ms(&ctx.view().combat(), local, periodic.tick_interval_ms);
766    let base_interval_s = f64::from(effective_tick_ms) / MS_PER_SECOND;
767    let tick_interval_ms = buffs::effective_periodic_interval_ms(
768        &ctx.view().combat(),
769        local,
770        periodic.tick_interval_ms,
771        periodic.hasted_ticks,
772    );
773    let tick_interval_now = f64::from(tick_interval_ms) / MS_PER_SECOND;
774
775    if let Some(a) = ctx.program.buffer_aura(ctx.instance, local, key) {
776        let time_rate = if a.time_rate_multiplier > 0.0 {
777            a.time_rate_multiplier
778        } else {
779            1.0
780        };
781
782        let next_tick = if periodic.tick_on_application {
783            ctx.now.as_secs_f64()
784        } else {
785            ctx.now.as_secs_f64() + tick_interval_now * time_rate
786        };
787
788        if let Some(binding) = ctx.program.aura_binding(local, key) {
789            ctx.program.mutate_aura(
790                ctx.instance,
791                binding,
792                AuraMutation::SetSchedule(AuraSchedule {
793                    base_tick_interval: base_interval_s,
794                    tick_interval: tick_interval_now * time_rate,
795                    next_tick,
796                    time_rate_multiplier: time_rate,
797                    remaining_ticks: wowlab_types::numeric::f64_to_i32_saturating_ceil(
798                        f64::from(base_dur_ms) / MS_PER_SECOND / tick_interval_now,
799                    ),
800                }),
801                ctx.now,
802            );
803        }
804    }
805
806    if let Some(a) = ctx.program.buffer_aura(ctx.instance, local, key) {
807        ctx.instance.schedule(Event::AuraTick {
808            t: SimTime::from_secs_f64(a.next_tick),
809            aura: ctx
810                .program
811                .aura_event(local, key, ctx.target)
812                .expect("periodic aura has a finalized event binding"),
813        });
814    }
815}
816
817pub(super) fn refresh_snapshot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
818    let AuraSlotRequest {
819        aura, local, key, ..
820    } = request;
821
822    if !aura.is_snapshot || aura.periodic.is_none() {
823        return;
824    }
825
826    let Some(binding) = ctx.program.damage.periodic_request(local) else {
827        return;
828    };
829    let snapshot = capture_snapshot_for(
830        ctx.program,
831        ctx.instance,
832        binding,
833        ctx.source,
834        ctx.target,
835        ctx.now,
836    );
837
838    store_stronger_snapshot(ctx.program, ctx.instance, local, key, snapshot, ctx.now);
839}
840
841pub(super) fn is_rolling_periodic(aura: &crate::state::AuraData) -> bool {
842    aura.periodic.is_some_and(|periodic| {
843        matches!(
844            periodic.effect,
845            crate::state::PeriodicKind::RollingDamageAp { .. }
846                | crate::state::PeriodicKind::RollingDamageSp { .. }
847        )
848    })
849}
850
851/// Ticks left on the aura slot at `now`; `0.0` when absent or expired.
852pub(super) fn rolling_ticks_left(ctx: &HookCtx<'_>, local: LocalAuraIdx, key: AuraKey) -> f64 {
853    ctx.program
854        .buffer_aura(ctx.instance, local, key)
855        .map_or(0.0, |slot| {
856            if slot.tick_interval <= 0.0 {
857                return 0.0;
858            }
859
860            ((slot.expires_at - ctx.now.as_secs_f64()) / slot.tick_interval).max(0.0)
861        })
862}
863
864/// Blends this application into the rolling per-tick multiplier so total damage is preserved without diluting tick value.
865pub(super) fn combine_rolling_multiplier(
866    ctx: &mut HookCtx<'_>,
867    request: AuraSlotRequest<'_>,
868    old_ticks_left: f64,
869    new_multiplier: f64,
870) {
871    let AuraSlotRequest {
872        aura,
873        local,
874        key,
875        base_dur_ms,
876        ..
877    } = request;
878
879    if !is_rolling_periodic(aura) {
880        return;
881    }
882    // The slot's tick_interval is the cadence ticks actually fire at (haste
883    // and cadence modifiers applied) — the same units rolling_ticks_left uses.
884
885    let Some(tick_interval) = ctx
886        .program
887        .buffer_aura(ctx.instance, local, key)
888        .map(|slot| slot.tick_interval)
889        .filter(|interval| *interval > 0.0)
890    else {
891        return;
892    };
893    let new_base_ticks = f64::from(base_dur_ms) / MS_PER_SECOND / tick_interval;
894    let new_ticks_left = rolling_ticks_left(ctx, local, key);
895    let Some(pool) = ctx.program.aura_pool_index(local, key) else {
896        return;
897    };
898
899    ctx.instance.runtime.pools.rolling_tick_mult.combine(
900        pool,
901        old_ticks_left,
902        new_base_ticks,
903        new_ticks_left,
904        new_multiplier,
905    );
906}
907
908pub(super) fn restore_active_shapeshift_form(ctx: &mut HookCtx<'_>) {
909    let restored = ctx
910        .program
911        .effects
912        .shapeshift_auras()
913        .iter()
914        .rev()
915        .find_map(|entry| {
916            let aura = ctx.program.aura(entry.local);
917
918            ctx.instance
919                .buffer
920                .aura_at(entry.handle)
921                .is_occupied()
922                .then_some(aura.shapeshift_form)
923        })
924        .unwrap_or(0);
925
926    ctx.instance.buffer.player_mut().shapeshift_form = restored;
927    tracing::trace!(form = restored, "restored active shapeshift form");
928}