wowlab_engine_combat/systems/
health_thresholds.rs1use wowlab_engine_ports::Event;
2use wowlab_types::sim::{ActorId, SimTime};
3
4use super::buffs::for_each_planned_actor_effect;
5use crate::state::{BuffEffect, CombatInstance, HealthThresholdDirection};
6
7const fn condition_met(direction: HealthThresholdDirection, health: f64, threshold: f64) -> bool {
8 match direction {
9 HealthThresholdDirection::Above => health > threshold,
10 HealthThresholdDirection::Below => health < threshold,
11 }
12}
13
14const fn crossed(
15 direction: HealthThresholdDirection,
16 before: f64,
17 after: f64,
18 threshold: f64,
19) -> bool {
20 match direction {
21 HealthThresholdDirection::Above => before <= threshold && after > threshold,
22 HealthThresholdDirection::Below => before >= threshold && after < threshold,
23 }
24}
25
26fn schedule_trigger(
27 state: &mut CombatInstance,
28 actor: ActorId,
29 spell: wowlab_engine_ports::ScheduledSpellBinding,
30 now: SimTime,
31) {
32 let target = match actor {
33 ActorId::Enemy(enemy) => Some(enemy),
34 ActorId::Player | ActorId::External | ActorId::Pet(_) => state.current_target(),
35 };
36
37 if let Some(target) = target {
38 state.schedule(Event::TriggeredSpell {
39 t: now,
40 spell,
41 source: actor,
42 target: ActorId::Enemy(target),
43 });
44 }
45}
46
47pub(crate) fn process_health_threshold_application(
48 program: &crate::state::CombatProgram,
49 state: &mut CombatInstance,
50 actor: ActorId,
51 effects: &[Option<BuffEffect>],
52 now: SimTime,
53) {
54 let Some(health_pct) = state
55 .actor_health_fraction(program, actor, now)
56 .map(|fraction| fraction * wowlab_types::constants::HUNDRED)
57 else {
58 return;
59 };
60
61 for spell_id in effects.iter().flatten().filter_map(|effect| match effect {
62 BuffEffect::HealthThresholdTrigger {
63 threshold_pct,
64 direction,
65 scheduled: Some(spell),
66 ..
67 } if condition_met(*direction, health_pct, *threshold_pct) => Some(*spell),
68 _ => None,
69 }) {
70 schedule_trigger(state, actor, spell_id, now);
71 }
72}
73
74pub(crate) fn process_actor_health_change(
75 program: &crate::state::CombatProgram,
76 state: &mut CombatInstance,
77 actor: ActorId,
78 before: f64,
79 after: f64,
80 now: SimTime,
81) {
82 #[cfg(feature = "rotation-optimizations")]
83 if actor == ActorId::Player && (after - before).abs() > f64::EPSILON {
84 let direction = if after > before {
85 crate::state::runtime::RotationMutationDirection::Rise
86 } else {
87 crate::state::runtime::RotationMutationDirection::Fall
88 };
89
90 state.runtime.rotation_mutations.record_semantic(
91 crate::state::runtime::RotationSemanticMutation::PlayerHealth,
92 direction,
93 now,
94 );
95 }
96
97 let before_pct = before * wowlab_types::constants::HUNDRED;
98 let after_pct = after * wowlab_types::constants::HUNDRED;
99
100 let mut triggered = std::mem::take(&mut state.runtime.scratch.health_threshold_triggers);
101
102 triggered.clear();
103
104 for_each_planned_actor_effect(
105 crate::context::ActorView::new(program, state, actor),
106 crate::state::EffectFamily::HEALTH_THRESHOLD_TRIGGER,
107 |_stacks, effect| {
108 let BuffEffect::HealthThresholdTrigger {
109 threshold_pct,
110 direction,
111 scheduled: Some(spell),
112 ..
113 } = effect
114 else {
115 return;
116 };
117
118 if crossed(*direction, before_pct, after_pct, *threshold_pct) {
119 debug_assert!(
120 triggered.len() < triggered.capacity(),
121 "health triggers fit the finalized aura-effect bound"
122 );
123 triggered.push(*spell);
124 }
125 },
126 );
127
128 for spell in triggered.iter().copied() {
129 schedule_trigger(state, actor, spell, now);
130 }
131
132 state.runtime.scratch.health_threshold_triggers = triggered;
133}
134
135#[cfg(test)]
136#[path = "health_thresholds/tests.rs"]
137mod tests;