Skip to main content

wowlab_engine_domain/encounter/
state.rs

1use wowlab_types::{
2    constants::HUNDRED,
3    sim::{EnemyIdx, SimTime, SpatialTransform},
4};
5
6use super::{EnemyActorDefinition, HealthModel};
7
8/// Current movement state.
9#[derive(Clone, Copy, Debug, PartialEq)]
10// #t(rust_non_exhaustive_on_public) exhaustive state is required for deterministic dense projection
11pub enum EnemyMovementState {
12    Stationary,
13    Moving { destination: SpatialTransform },
14}
15
16/// Mutable state for one immutable [`EnemyIdx`].
17#[derive(Clone, Debug, PartialEq)]
18pub struct EnemyState {
19    id: EnemyIdx,
20    health_model: HealthModel,
21    active: bool,
22    alive: bool,
23    current_health: f64,
24    current_transform: SpatialTransform,
25    movement: EnemyMovementState,
26    spawned_at: Option<SimTime>,
27    despawned_at: Option<SimTime>,
28    died_at: Option<SimTime>,
29    activation_generation: u64,
30}
31
32/// One-shot transition returned when damage-driven health first reaches zero.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub struct BecameDead {
35    pub enemy: EnemyIdx,
36    pub at: SimTime,
37}
38
39impl EnemyState {
40    #[must_use]
41    pub fn from_definition(definition: &EnemyActorDefinition) -> Self {
42        let active = definition.starts_active();
43        let health_model = definition.health_model();
44
45        Self {
46            id: definition.id(),
47            health_model,
48            active,
49            alive: true,
50            current_health: health_model.max_health(),
51            current_transform: definition.initial_transform(),
52            movement: EnemyMovementState::Stationary,
53            spawned_at: active.then_some(SimTime::ZERO),
54            despawned_at: None,
55            died_at: None,
56            activation_generation: u64::from(active),
57        }
58    }
59
60    #[must_use]
61    pub const fn id(&self) -> EnemyIdx {
62        self.id
63    }
64    #[must_use]
65    pub const fn is_active(&self) -> bool {
66        self.active
67    }
68    #[must_use]
69    pub const fn is_alive(&self) -> bool {
70        self.alive
71    }
72    #[must_use]
73    pub const fn current_health(&self) -> f64 {
74        self.current_health
75    }
76    #[must_use]
77    pub const fn current_transform(&self) -> SpatialTransform {
78        self.current_transform
79    }
80    #[must_use]
81    pub const fn movement(&self) -> EnemyMovementState {
82        self.movement
83    }
84    #[must_use]
85    pub const fn spawned_at(&self) -> Option<SimTime> {
86        self.spawned_at
87    }
88    #[must_use]
89    pub const fn despawned_at(&self) -> Option<SimTime> {
90        self.despawned_at
91    }
92    #[must_use]
93    pub const fn died_at(&self) -> Option<SimTime> {
94        self.died_at
95    }
96    #[must_use]
97    pub const fn activation_generation(&self) -> u64 {
98        self.activation_generation
99    }
100
101    pub fn activate(&mut self, at: SimTime) -> bool {
102        if self.active || !self.alive {
103            return false;
104        }
105
106        self.active = true;
107        self.current_health = self.health_model.max_health();
108        self.spawned_at = Some(at);
109        self.despawned_at = None;
110        self.activation_generation = self.activation_generation.wrapping_add(1);
111
112        true
113    }
114
115    pub fn despawn(&mut self, at: SimTime) -> bool {
116        if !self.active {
117            return false;
118        }
119
120        self.active = false;
121        self.despawned_at = Some(at);
122
123        true
124    }
125
126    pub fn commit_relocation(&mut self, transform: SpatialTransform) {
127        self.current_transform = transform;
128        self.movement = EnemyMovementState::Stationary;
129    }
130
131    pub fn apply_damage(&mut self, amount: f64, at: SimTime) -> Option<BecameDead> {
132        if !matches!(self.health_model, HealthModel::DamageDriven { .. })
133            || !self.alive
134            || amount <= 0.0
135        {
136            return None;
137        }
138
139        self.current_health = (self.current_health - amount).max(0.0);
140
141        if self.current_health > 0.0 {
142            return None;
143        }
144
145        self.alive = false;
146        self.active = false;
147        self.died_at = Some(at);
148
149        Some(BecameDead { enemy: self.id, at })
150    }
151
152    pub fn refresh_scripted_health(&mut self, now: SimTime) {
153        let Some(spawned_at) = self.spawned_at else {
154            return;
155        };
156        let Some(current_health) = self.health_model.scripted_health_at(spawned_at, now) else {
157            return;
158        };
159
160        self.current_health = current_health;
161
162        if self.current_health.abs() <= f64::EPSILON && self.alive {
163            let HealthModel::ScriptedLinear { death_at_s, .. } = self.health_model else {
164                return;
165            };
166
167            self.alive = false;
168            self.died_at = Some(spawned_at.saturating_add(SimTime::from_secs_f64(death_at_s)));
169        }
170    }
171
172    pub fn finalize_scripted_death(&mut self, at: SimTime) -> bool {
173        if !matches!(self.health_model, HealthModel::ScriptedLinear { .. }) || !self.alive {
174            return false;
175        }
176
177        self.current_health = 0.0;
178        self.alive = false;
179        self.active = false;
180        self.died_at = Some(at);
181
182        true
183    }
184
185    #[must_use]
186    pub fn time_to_die(&self, now: SimTime) -> Option<f64> {
187        let HealthModel::ScriptedLinear { death_at_s, .. } = self.health_model else {
188            return None;
189        };
190        let elapsed_s = now.saturating_sub(self.spawned_at?).as_secs_f64();
191
192        Some((death_at_s - elapsed_s).max(0.0))
193    }
194
195    /// Exact engine timestamp at which scripted linear health reaches `percent`.
196    #[must_use]
197    pub fn scripted_health_threshold_seconds(&self, percent: f64) -> Option<f64> {
198        let HealthModel::ScriptedLinear { death_at_s, .. } = self.health_model else {
199            return None;
200        };
201        let spawned_at = self.spawned_at?;
202        let threshold_fraction = (percent / HUNDRED).clamp(0.0, 1.0);
203        let elapsed_s = death_at_s * (1.0 - threshold_fraction);
204
205        Some(spawned_at.as_secs_f64() + elapsed_s)
206    }
207}