1use 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
177pub(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
192pub(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
247pub(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#[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 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
480pub(crate) fn refresh_aura_snapshot_multiplier(
483 ctx: &mut HookCtx<'_>,
484 local: LocalAuraIdx,
485 multiplier: f64,
486) -> bool {
487 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 ctx.now
710 .saturating_add(base_dur)
711 .saturating_add(SimTime::from_millis(carry_ms))
712 .as_secs_f64()
713 });
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
851pub(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
864pub(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 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}