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 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
550pub 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
572fn 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 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 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}