1use wowlab_engine_domain::{constants::AURA_PANDEMIC_THRESHOLD, rotation::DenseBuffer};
4use wowlab_engine_ports::Event;
5use wowlab_engine_telemetry::TelemetrySink;
6use wowlab_types::{
7 constants::MS_PER_SECOND,
8 sim::{
9 ActorId, AuraIdx, AuraKey, AuraOn, AuraProjectionKey, EnemyIdx, PetIdx, SimTime, SpellIdx,
10 },
11};
12
13use super::{MAX_AURA_APPLICATION_ACTORS, PERMANENT_AURA_EXPIRES_AT_S, expire_aura};
14use crate::{
15 context::HookCtx,
16 state::{CombatState, DamageEffectRef, LocalAuraIdx, SpellGroup, SpellGroupRule},
17 systems::{
18 buffs::{self, accumulate_buffs},
19 damage_pipeline::{
20 DamageFlags, DamageSnapshot, SpellModifierQuery, dynamic_base_powers,
21 read_player_stats_for_damage, school_of, spell_scoped_mods_for,
22 typed_attack_power_with,
23 },
24 mastery::MasteryCtx,
25 sync_max_charges, sync_player_health_from_stamina, sync_recharge_rates, sync_spell_costs,
26 synchronize_active_spell_gates,
27 },
28};
29
30#[cfg(test)]
31pub(super) fn pandemic_carry(base_duration: f64, remaining: f64) -> f64 {
32 remaining.min(base_duration * AURA_PANDEMIC_THRESHOLD)
33}
34
35pub(super) fn pandemic_carry_ms(base_duration_ms: u32, remaining_ms: u32) -> u32 {
36 let carry_limit = wowlab_types::numeric::f64_to_u32_saturating_trunc(
37 f64::from(base_duration_ms) * AURA_PANDEMIC_THRESHOLD,
38 );
39
40 remaining_ms.min(carry_limit)
41}
42
43#[derive(Clone, Copy)]
44pub(super) struct AuraSlotRequest<'a> {
45 pub aura: &'a crate::state::AuraData,
46 pub key: AuraKey,
47 pub base_dur_ms: u32,
48 pub base_dur: SimTime,
49 pub is_permanent: bool,
50}
51
52#[inline]
53fn resolved_aura_key(aura: &crate::state::AuraData, source: ActorId, affected: ActorId) -> AuraKey {
54 AuraKey::new(AuraIdx(aura.aura_id), source, affected, aura.on)
55}
56
57#[inline]
58pub(crate) fn aura_key_for(
59 state: &CombatState,
60 local: LocalAuraIdx,
61 source: ActorId,
62 target: Option<EnemyIdx>,
63) -> Option<AuraKey> {
64 let aura = state.defs.auras.get(local.as_usize()).copied()?;
65 let affected = match aura.on {
66 AuraOn::Player => ActorId::Player,
67 AuraOn::Target => ActorId::Enemy(target?),
68 AuraOn::Pet => ActorId::Pet(match source {
69 ActorId::Player => PetIdx::PRIMARY,
70 ActorId::Pet(pet) => pet,
71 ActorId::External | ActorId::Enemy(_) => return None,
72 }),
73 };
74
75 Some(resolved_aura_key(&aura, source, affected))
76}
77
78pub(crate) fn aura_keys_for_application(
79 state: &CombatState,
80 local: LocalAuraIdx,
81 source: ActorId,
82 target: Option<EnemyIdx>,
83) -> [Option<AuraKey>; MAX_AURA_APPLICATION_ACTORS] {
84 let primary = aura_key_for(state, local, source, target);
85 let secondary = state
86 .defs
87 .auras
88 .get(local.as_usize())
89 .filter(|aura| aura.propagates_to_pet)
90 .map(|aura| {
91 AuraKey::new(
92 AuraIdx(aura.aura_id),
93 source,
94 ActorId::Pet(PetIdx::PRIMARY),
95 AuraOn::Pet,
96 )
97 });
98
99 [primary, secondary]
100}
101
102pub(crate) fn aura_keys_for_actor_application(
103 state: &CombatState,
104 local: LocalAuraIdx,
105 source: ActorId,
106 affected: ActorId,
107) -> [Option<AuraKey>; MAX_AURA_APPLICATION_ACTORS] {
108 let Some(aura) = state.defs.auras.get(local.as_usize()) else {
109 return [None, None];
110 };
111 let primary = Some(resolved_aura_key(aura, source, affected));
112 let secondary = aura.propagates_to_pet.then(|| {
113 AuraKey::new(
114 AuraIdx(aura.aura_id),
115 source,
116 ActorId::Pet(PetIdx::PRIMARY),
117 AuraOn::Pet,
118 )
119 });
120
121 [primary, secondary]
122}
123
124pub(super) fn aura_query_key_for(
125 state: &CombatState,
126 local: LocalAuraIdx,
127 source: ActorId,
128 target: Option<EnemyIdx>,
129) -> Option<AuraKey> {
130 let aura = state.defs.auras.get(local.as_usize()).copied()?;
131 let query_source = if aura.on == AuraOn::Player && matches!(source, ActorId::Pet(_)) {
132 ActorId::Player
133 } else {
134 source
135 };
136
137 aura_key_for(state, local, query_source, target)
138}
139
140#[inline]
141pub(super) fn context_aura_key(ctx: &HookCtx<'_>, local: LocalAuraIdx) -> Option<AuraKey> {
142 if let Some(affected) = ctx.effect_target {
143 let aura = ctx.state.defs.auras.get(local.as_usize())?;
144
145 return Some(resolved_aura_key(aura, ctx.source, affected));
146 }
147
148 aura_key_for(ctx.state, local, ctx.source, ctx.target)
149}
150
151fn emit_aura_event(
152 state: &CombatState,
153 sink: &mut TelemetrySink,
154 key: AuraKey,
155 now: SimTime,
156 emit: impl FnOnce(&mut TelemetrySink, u32, wowlab_engine_telemetry::AuraTelemetryScope),
157) {
158 emit(sink, now.as_millis(), state.aura_telemetry_scope(key));
159}
160
161pub(crate) fn emit_aura_apply_event(
162 state: &CombatState,
163 sink: &mut TelemetrySink,
164 key: AuraKey,
165 aura_id: u32,
166 stacks: u8,
167 now: SimTime,
168) {
169 emit_aura_event(state, sink, key, now, |sink, at_ms, scope| {
170 sink.emit_aura_apply(aura_id, stacks, at_ms, scope);
171 });
172}
173
174pub(crate) fn emit_aura_refresh_event(
175 state: &CombatState,
176 sink: &mut TelemetrySink,
177 key: AuraKey,
178 aura_id: u32,
179 stacks: u8,
180 now: SimTime,
181) {
182 emit_aura_event(state, sink, key, now, |sink, at_ms, scope| {
183 sink.emit_aura_refresh(aura_id, stacks, at_ms, scope);
184 });
185}
186
187pub(super) fn emit_aura_expire_event(
188 state: &CombatState,
189 sink: &mut TelemetrySink,
190 key: AuraKey,
191 aura_id: u32,
192 now: SimTime,
193) {
194 emit_aura_event(state, sink, key, now, |sink, at_ms, scope| {
195 sink.emit_aura_expire(aura_id, at_ms, scope);
196 });
197}
198
199pub(super) fn exact_key_for_projection(
200 projection: AuraProjectionKey,
201 current_target: Option<EnemyIdx>,
202) -> Option<AuraKey> {
203 let (source, affected) = match projection.on() {
204 AuraOn::Player => (ActorId::Player, ActorId::Player),
205 AuraOn::Target => (ActorId::Player, ActorId::Enemy(current_target?)),
206 AuraOn::Pet => {
207 let pet = ActorId::Pet(PetIdx::PRIMARY);
208
209 (pet, pet)
210 }
211 };
212
213 Some(AuraKey::new(
214 projection.aura(),
215 source,
216 affected,
217 projection.on(),
218 ))
219}
220
221pub(crate) fn refresh_aura_projections(buf: &mut DenseBuffer, current_target: Option<EnemyIdx>) {
223 let projections: Vec<_> = buf.aura_projection_keys().collect();
224
225 for projection in projections {
226 let exact = exact_key_for_projection(projection, current_target)
227 .and_then(|key| buf.aura(key).copied())
228 .unwrap_or_default();
229
230 if let Some(slot) = buf.aura_projection_mut(projection) {
231 *slot = exact;
232 }
233 }
234}
235
236pub(super) fn expire_conflicting_auras(ctx: &mut HookCtx<'_>, local: LocalAuraIdx, group_idx: u8) {
238 let mut to_expire: Vec<LocalAuraIdx> = Vec::with_capacity(ctx.state.defs.auras.len());
239
240 for i in 0..ctx.state.defs.auras.len() {
241 let i_local = LocalAuraIdx::new(u8::try_from(i).expect("aura count fits in u8"));
242
243 if i_local == local {
244 continue;
245 }
246
247 if let Some(group) = ctx.state.defs.auras[i].spell_group {
248 if group.group_idx == group_idx {
249 let Some(other_key) = context_aura_key(ctx, i_local) else {
250 continue;
251 };
252
253 if ctx
254 .buf
255 .aura(other_key)
256 .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
257 {
258 to_expire.push(i_local);
259 }
260 }
261 }
262 }
263
264 for idx in to_expire {
265 expire_aura(ctx, idx);
266 }
267}
268
269pub(super) fn sync_exclusive_group_enabled(
271 ctx: &mut HookCtx<'_>,
272 applied_aura_local: LocalAuraIdx,
273 group_idx: u8,
274) {
275 for spell in &ctx.state.defs.spells {
276 let start = spell.followup_effects.start as usize;
277 let len = usize::from(spell.followup_effects.len);
278 let followups = (start..start.saturating_add(len)).filter_map(|index| {
279 match ctx.state.defs.spell_effects.get(index) {
280 Some(crate::state::SpellEffectData::ApplyAura { aura_local }) => Some(*aura_local),
281 _ => None,
282 }
283 });
284
285 for aura_local in spell.applies_aura.into_iter().chain(followups) {
286 let aura = &ctx.state.defs.auras[aura_local.as_usize()];
287 let Some(SpellGroup {
288 group_idx: other_group,
289 rule: SpellGroupRule::Exclusive,
290 }) = aura.spell_group
291 else {
292 continue;
293 };
294
295 if other_group != group_idx {
296 continue;
297 }
298
299 let enabled = i32::from(aura_local == applied_aura_local);
300
301 if let Some(s) = ctx.buf.spell_mut(SpellIdx(spell.spell_id)) {
302 s.is_enabled = enabled;
303 }
304 }
305 }
306}
307
308#[inline]
309pub(super) fn refreshed_stacks(aura: &crate::state::AuraData, current: i32) -> i32 {
310 let max = i32::from(aura.max_stacks);
311
312 if aura.apply_at_max_stacks {
313 max
314 } else {
315 (current + i32::from(aura.doses)).min(max)
316 }
317}
318
319pub(crate) fn capture_snapshot_for(
320 state: &CombatState,
321 buf: &DenseBuffer,
322 effect: DamageEffectRef,
323 source: ActorId,
324 target: Option<EnemyIdx>,
325 now: SimTime,
326) -> DamageSnapshot {
327 let spell_id = effect.spell_id;
328 let (crit, vers) = read_player_stats_for_damage(buf);
329 let totals = accumulate_buffs(state, buf, target);
330 let spell_mods = spell_scoped_mods_for(
331 state,
332 buf,
333 source,
334 SpellModifierQuery::new(spell_id, effect, DamageFlags::PERIODIC),
335 target,
336 now,
337 );
338 let mastery_mult = target.map_or(1.0, |target| {
339 (state.config.mastery_hook)(&MasteryCtx {
340 state,
341 buf,
342 spell_id,
343 school: school_of(state, spell_id),
344 mastery: buffs::current_mastery_points(&crate::CombatView::new(state, buf)),
345 player_crit_pct: crit + totals.crit_pct,
346 mastery_spell: state.config.mastery_spell,
347 is_periodic: true,
348 is_pet: matches!(source, ActorId::Pet(_)),
349 source,
350 target: Some(target),
351 now,
352 })
353 });
354
355 DamageSnapshot {
356 ap: typed_attack_power_with(state, buf, crate::state::WeaponApType::MainHand, &totals),
357 sp: dynamic_base_powers(state, buf, &totals).1,
358 crit_pct: crit + totals.crit_pct + spell_mods.crit_chance_pct,
359 vers_pct: vers + totals.vers_pct,
360 mastery_mult,
361 damage_mult: totals.damage_mult * spell_mods.damage_mult,
362 }
363}
364
365#[cfg(test)]
366pub(crate) fn capture_snapshot_fixture(
367 view: crate::context::HookView<'_>,
368 spell_id: u32,
369 source: ActorId,
370) -> DamageSnapshot {
371 capture_snapshot_for(
372 view.state,
373 view.buf,
374 DamageEffectRef::new(spell_id, 0),
375 source,
376 view.target,
377 view.now,
378 )
379}
380
381pub(super) fn store_stronger_snapshot(
382 buf: &mut DenseBuffer,
383 key: AuraKey,
384 snapshot: DamageSnapshot,
385) -> bool {
386 let Some(aura) = buf.aura_mut(key) else {
387 return false;
388 };
389
390 if aura.snapshot_has != 0 && snapshot.damage_mult <= aura.snapshot_damage_mult {
391 return false;
392 }
393
394 aura.snapshot_has = 1;
395 aura.snapshot_ap = snapshot.ap;
396 aura.snapshot_sp = snapshot.sp;
397 aura.snapshot_crit = snapshot.crit_pct;
398 aura.snapshot_vers = snapshot.vers_pct;
399 aura.snapshot_mastery_mult = snapshot.mastery_mult;
400 aura.snapshot_damage_mult = snapshot.damage_mult;
401
402 true
403}
404
405pub(crate) fn refresh_aura_snapshot_multiplier(
407 ctx: &mut HookCtx<'_>,
408 local: LocalAuraIdx,
409 multiplier: f64,
410) -> bool {
411 let aura = ctx.state.defs.auras[local.as_usize()];
413
414 if aura.periodic.is_none() || multiplier <= 0.0 {
415 return false;
416 }
417
418 let Some(key) = context_aura_key(ctx, local) else {
419 return false;
420 };
421
422 if !ctx
423 .buf
424 .aura(key)
425 .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
426 {
427 return false;
428 }
429
430 if aura.is_snapshot {
431 let damage_spell_id = aura.periodic.map_or(aura.aura_id, |periodic| {
432 periodic.damage_spell_id_or(aura.aura_id)
433 });
434 let mut snapshot = capture_snapshot_for(
435 ctx.state,
436 ctx.buf,
437 aura.periodic
438 .and_then(|periodic| periodic.effect_ref)
439 .unwrap_or_else(|| DamageEffectRef::new(damage_spell_id, 0)),
440 ctx.source,
441 ctx.target,
442 ctx.now,
443 );
444
445 snapshot.damage_mult *= multiplier;
446 let changed = store_stronger_snapshot(ctx.buf, key, snapshot);
447
448 if changed {
449 refresh_aura_projections(ctx.buf, ctx.state.current_target());
450 }
451
452 return changed;
453 }
454
455 let Some(slot) = ctx.buf.aura_mut(key) else {
456 return false;
457 };
458
459 if slot.snapshot_has != 0 && multiplier <= slot.snapshot_damage_mult {
460 return false;
461 }
462
463 slot.snapshot_has = 1;
464 slot.snapshot_damage_mult = multiplier;
465 refresh_aura_projections(ctx.buf, ctx.state.current_target());
466
467 true
468}
469
470pub(super) fn revive_tick_chain(
471 ctx: &mut HookCtx<'_>,
472 aura: &crate::state::AuraData,
473 key: AuraKey,
474 previous_expiry: Option<f64>,
475) {
476 if aura.periodic.is_none() {
477 return;
478 }
479
480 let Some(a) = ctx.buf.aura_mut(key) else {
481 return;
482 };
483
484 let terminal_at_previous_expiry = previous_expiry.is_some_and(|expiry| a.next_tick >= expiry);
485
486 if !a.is_occupied()
487 || (a.next_tick > ctx.now.as_secs_f64() && !terminal_at_previous_expiry)
488 || a.tick_interval <= 0.0
489 {
490 return;
491 }
492
493 let next_tick = SimTime::from_secs_f64(ctx.now.as_secs_f64() + a.tick_interval);
494
495 if next_tick.as_secs_f64() <= a.expires_at {
496 a.next_tick = next_tick.as_secs_f64();
497 ctx.state.schedule(Event::AuraTick {
498 t: next_tick,
499 key,
500 target: ctx.target,
501 });
502 }
503}
504
505pub(super) fn update_aura_slot(
506 ctx: &mut HookCtx<'_>,
507 request: AuraSlotRequest<'_>,
508) -> Option<bool> {
509 let AuraSlotRequest { key, .. } = request;
510 let a = ctx.buf.aura_mut(key)?;
511 let is_fresh = !a.is_occupied();
512
513 if is_fresh {
514 initialize_aura_slot(ctx, request);
515 } else {
516 refresh_aura_slot(ctx, request);
517 }
518
519 Some(is_fresh)
520}
521
522pub(super) fn initialize_aura_slot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
523 let AuraSlotRequest {
524 aura,
525 key,
526 base_dur_ms,
527 base_dur,
528 is_permanent,
529 } = request;
530 let Some(a) = ctx.buf.aura_mut(key) else {
531 return;
532 };
533
534 a.expires_at = if is_permanent {
535 PERMANENT_AURA_EXPIRES_AT_S
536 } else {
537 ctx.now.saturating_add(base_dur).as_secs_f64()
538 };
539 a.stacks = if aura.apply_at_max_stacks {
540 i32::from(aura.max_stacks)
541 } else {
542 i32::from(aura.doses).min(i32::from(aura.max_stacks))
543 };
544 a.base_duration = f64::from(base_dur_ms) / MS_PER_SECOND;
545 a.max_stacks = i32::from(aura.max_stacks);
546 let stacks = wowlab_types::numeric::i32_to_u8_saturating(a.stacks);
547
548 emit_aura_apply_event(ctx.state, ctx.sink, key, aura.aura_id, stacks, ctx.now);
549}
550
551pub(super) fn refresh_aura_slot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
552 let AuraSlotRequest {
553 aura,
554 key,
555 base_dur_ms,
556 base_dur,
557 is_permanent,
558 } = request;
559 let Some(a) = ctx.buf.aura_mut(key) else {
560 return;
561 };
562
563 if !is_permanent {
564 let expires_ms =
565 wowlab_types::numeric::f64_to_u32_saturating_trunc(a.expires_at * MS_PER_SECOND);
566 let remaining_ms = expires_ms.saturating_sub(ctx.now.as_millis());
567 let carry_ms = match aura.refresh_behavior {
568 wowlab_types::data::RefreshBehavior::Pandemic => {
569 pandemic_carry_ms(base_dur_ms, remaining_ms)
570 }
571 wowlab_types::data::RefreshBehavior::Extend => remaining_ms,
572 wowlab_types::data::RefreshBehavior::Tick => {
573 let interval_ms = wowlab_types::numeric::f64_to_u32_saturating_round(
574 a.tick_interval * MS_PER_SECOND,
575 );
576
577 if interval_ms == 0 {
578 0
579 } else {
580 remaining_ms % interval_ms
581 }
582 }
583 wowlab_types::data::RefreshBehavior::Clip
584 | wowlab_types::data::RefreshBehavior::None
585 | wowlab_types::data::RefreshBehavior::Duration => 0,
586 };
587 a.expires_at = ctx
589 .now
590 .saturating_add(base_dur)
591 .saturating_add(SimTime::from_millis(carry_ms))
592 .as_secs_f64();
593 }
595
596 a.stacks = refreshed_stacks(aura, a.stacks);
597 let stacks = wowlab_types::numeric::i32_to_u8_saturating(a.stacks);
598
599 emit_aura_refresh_event(ctx.state, ctx.sink, key, aura.aura_id, stacks, ctx.now);
600}
601
602pub(super) fn initialize_or_revive_periodic(
603 ctx: &mut HookCtx<'_>,
604 request: AuraSlotRequest<'_>,
605 is_fresh: bool,
606 previous_expiry: Option<f64>,
607) {
608 let AuraSlotRequest {
609 aura,
610 key,
611 base_dur_ms,
612 ..
613 } = request;
614
615 let Some(periodic) = aura.periodic.as_ref() else {
616 return;
617 };
618
619 if !is_fresh {
620 revive_tick_chain(ctx, aura, key, previous_expiry);
621
622 return;
623 }
624
625 let effective_tick_ms = buffs::effective_periodic_tick_ms(
626 &ctx.view().combat(),
627 aura.aura_id,
628 periodic.tick_interval_ms,
629 );
630 let base_interval_s = f64::from(effective_tick_ms) / MS_PER_SECOND;
631 let tick_interval_ms = buffs::effective_periodic_interval_ms(
632 &ctx.view().combat(),
633 aura.aura_id,
634 periodic.tick_interval_ms,
635 periodic.hasted_ticks,
636 );
637 let tick_interval_now = f64::from(tick_interval_ms) / MS_PER_SECOND;
638
639 if let Some(a) = ctx.buf.aura_mut(key) {
640 let time_rate = if a.time_rate_multiplier > 0.0 {
641 a.time_rate_multiplier
642 } else {
643 1.0
644 };
645
646 a.base_tick_interval = base_interval_s;
647 a.tick_interval = tick_interval_now * time_rate;
648 a.next_tick = if periodic.tick_on_application {
649 ctx.now.as_secs_f64()
650 } else {
651 ctx.now.as_secs_f64() + tick_interval_now * time_rate
652 };
653 a.remaining_ticks = wowlab_types::numeric::f64_to_i32_saturating_ceil(
654 f64::from(base_dur_ms) / MS_PER_SECOND / tick_interval_now,
655 );
656 }
657
658 if let Some(a) = ctx.buf.aura(key) {
659 ctx.state.schedule(Event::AuraTick {
660 t: SimTime::from_secs_f64(a.next_tick),
661 key,
662 target: ctx.target,
663 });
664 }
665}
666
667pub(super) fn refresh_snapshot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
668 let AuraSlotRequest { aura, key, .. } = request;
669
670 if !aura.is_snapshot || aura.periodic.is_none() {
671 return;
672 }
673
674 let damage_spell_id = aura
675 .periodic
676 .map_or(aura.aura_id, |p| p.damage_spell_id_or(aura.aura_id));
677 let snapshot = capture_snapshot_for(
678 ctx.state,
679 ctx.buf,
680 aura.periodic
681 .and_then(|periodic| periodic.effect_ref)
682 .unwrap_or_else(|| DamageEffectRef::new(damage_spell_id, 0)),
683 ctx.source,
684 ctx.target,
685 ctx.now,
686 );
687
688 store_stronger_snapshot(ctx.buf, key, snapshot);
689}
690
691pub(super) fn is_rolling_periodic(aura: &crate::state::AuraData) -> bool {
692 aura.periodic.is_some_and(|periodic| {
693 matches!(
694 periodic.effect,
695 crate::state::PeriodicKind::RollingDamageAp { .. }
696 | crate::state::PeriodicKind::RollingDamageSp { .. }
697 )
698 })
699}
700
701pub(super) fn rolling_ticks_left(ctx: &HookCtx<'_>, key: AuraKey) -> f64 {
703 ctx.buf.aura(key).map_or(0.0, |slot| {
704 if slot.tick_interval <= 0.0 {
705 return 0.0;
706 }
707
708 ((slot.expires_at - ctx.now.as_secs_f64()) / slot.tick_interval).max(0.0)
709 })
710}
711
712pub(super) fn combine_rolling_multiplier(
714 ctx: &mut HookCtx<'_>,
715 request: AuraSlotRequest<'_>,
716 old_ticks_left: f64,
717 new_multiplier: f64,
718) {
719 let AuraSlotRequest {
720 aura,
721 key,
722 base_dur_ms,
723 ..
724 } = request;
725
726 if !is_rolling_periodic(aura) {
727 return;
728 }
729 let Some(tick_interval) = ctx
733 .buf
734 .aura(key)
735 .map(|slot| slot.tick_interval)
736 .filter(|interval| *interval > 0.0)
737 else {
738 return;
739 };
740 let new_base_ticks = f64::from(base_dur_ms) / MS_PER_SECOND / tick_interval;
741 let new_ticks_left = rolling_ticks_left(ctx, key);
742
743 ctx.state.runtime.pools.rolling_tick_mult.combine(
744 key,
745 old_ticks_left,
746 new_base_ticks,
747 new_ticks_left,
748 new_multiplier,
749 );
750}
751
752pub(super) fn synchronize_aura_dependents(ctx: &mut HookCtx<'_>) {
753 refresh_aura_projections(ctx.buf, ctx.state.current_target());
754 synchronize_active_spell_gates(ctx.state, ctx.buf);
755 sync_spell_costs(ctx.state, ctx.buf);
756 sync_player_health_from_stamina(ctx.state, ctx.buf);
757 sync_recharge_rates(ctx.state, ctx.buf, ctx.now);
758 sync_max_charges(ctx.state, ctx.buf, ctx.now);
759 super::reproject_aura_time_rates(ctx);
760 super::super::auto_attack::reproject_player_auto_attack_speeds(ctx.state, ctx.buf, ctx.now);
761}
762
763pub(super) fn restore_active_shapeshift_form(ctx: &mut HookCtx<'_>) {
764 let restored = ctx
765 .state
766 .defs
767 .auras
768 .iter()
769 .enumerate()
770 .rev()
771 .find_map(|(index, aura)| {
772 if aura.shapeshift_form <= 0 {
773 return None;
774 }
775
776 let local = LocalAuraIdx::new(u8::try_from(index).ok()?);
777 let key = aura_key_for(ctx.state, local, ActorId::Player, None)?;
778
779 ctx.buf
780 .aura(key)
781 .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
782 .then_some(aura.shapeshift_form)
783 })
784 .unwrap_or(0);
785
786 ctx.buf.player_mut().shapeshift_form = restored;
787 tracing::trace!(form = restored, "restored active shapeshift form");
788}