Skip to main content

wowlab_engine_combat/systems/
health_thresholds.rs

1use 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;