Skip to main content

wowlab_engine_combat/state/combat_state/
enemies.rs

1use super::{
2    ActorId, AttackPosition, BecameDead, CombatState, EnemyActorDefinition, EnemyDespawnOutcome,
3    EnemyIdx, EnemyState, Event, HealthModel, PullId, SimTime, SpatialActor, SpatialQueryHandle,
4    SpecRuntimeError, parry_hasted_deadline,
5};
6
7impl CombatState {
8    #[must_use]
9    pub fn enemy_definition(&self, enemy: EnemyIdx) -> Option<&EnemyActorDefinition> {
10        self.defs
11            .enemies
12            .get(enemy.as_usize())
13            .filter(|definition| definition.id() == enemy)
14    }
15
16    #[must_use]
17    pub fn enemy(&self, enemy: EnemyIdx) -> Option<&EnemyState> {
18        self.runtime
19            .enemies
20            .get(enemy.as_usize())
21            .filter(|state| state.id() == enemy)
22    }
23
24    /// Canonical maximum health for an exact encounter enemy.
25    #[must_use]
26    pub fn enemy_max_health(&self, enemy: EnemyIdx) -> Option<f64> {
27        self.enemy_definition(enemy)
28            .map(|definition| definition.health_model().max_health())
29    }
30
31    /// Canonical health for an exact encounter enemy at engine time `now`.
32    #[must_use]
33    pub fn enemy_health(&self, enemy: EnemyIdx, now: SimTime) -> Option<f64> {
34        let definition = self.enemy_definition(enemy)?;
35        let state = self.enemy(enemy)?;
36
37        match definition.health_model() {
38            HealthModel::DamageDriven { .. } => Some(state.current_health()),
39            health_model @ HealthModel::ScriptedLinear { .. } => {
40                health_model.scripted_health_at(state.spawned_at()?, now)
41            }
42        }
43    }
44
45    /// Canonical health fraction for an exact encounter enemy at engine time `now`.
46    #[must_use]
47    pub fn enemy_health_fraction(&self, enemy: EnemyIdx, now: SimTime) -> Option<f64> {
48        let max_health = self.enemy_max_health(enemy)?;
49
50        (max_health > 0.0).then(|| {
51            self.enemy_health(enemy, now)
52                .map(|health| health / max_health)
53        })?
54    }
55
56    /// Canonical live health fraction for any actor addressable by combat effects.
57    #[must_use]
58    pub fn actor_health_fraction(&self, actor: ActorId, now: SimTime) -> Option<f64> {
59        match actor {
60            ActorId::External => None,
61            ActorId::Player => Some(self.runtime.health.player_health.fraction()),
62            ActorId::Pet(pet) => self
63                .runtime
64                .health
65                .pet_health
66                .get(pet.as_usize())
67                .and_then(Option::as_ref)
68                .map(|health| health.fraction()),
69            ActorId::Enemy(enemy) => self.enemy_health_fraction(enemy, now),
70        }
71    }
72
73    #[must_use]
74    pub fn enemy_definitions(&self) -> impl ExactSizeIterator<Item = &EnemyActorDefinition> {
75        self.defs.enemies.iter()
76    }
77
78    #[must_use]
79    pub fn enemies(&self) -> impl ExactSizeIterator<Item = &EnemyState> {
80        self.runtime.enemies.iter()
81    }
82
83    /// Returns the number of active enemies.
84    ///
85    /// # Panics
86    ///
87    /// Panics if a validated encounter exceeds the engine's supported enemy count.
88    #[must_use]
89    pub fn active_enemy_count(&self) -> i32 {
90        let count = self.enemies().filter(|enemy| enemy.is_active()).count();
91
92        i32::try_from(count).expect("validated encounter enemy count fits i32")
93    }
94
95    /// Returns the number of active, living enemies.
96    ///
97    /// # Panics
98    ///
99    /// Panics if a validated encounter exceeds the engine's supported enemy count.
100    #[must_use]
101    pub fn active_alive_enemy_count(&self) -> i32 {
102        let count = self
103            .enemies()
104            .filter(|enemy| enemy.is_active() && enemy.is_alive())
105            .count();
106
107        i32::try_from(count).expect("validated encounter enemy count fits i32")
108    }
109
110    /// Restores enemy and spatial state to the validated encounter definition.
111    ///
112    /// # Panics
113    ///
114    /// Panics if rebuilding a previously validated spatial scene fails.
115    pub fn reset_enemies(&mut self) {
116        self.runtime.enemies = self
117            .defs
118            .enemies
119            .iter()
120            .map(EnemyState::from_definition)
121            .collect();
122        self.runtime.player_transform = self.config.encounter.definition().initial_player_transform;
123        self.runtime.attack_position = AttackPosition::Behind;
124        self.runtime.health.enemy_swing_deadlines.fill(None);
125        self.runtime
126            .health
127            .enemy_invulnerable_until
128            .fill(SimTime::ZERO);
129        self.runtime
130            .encounter
131            .reset(self.config.encounter.definition());
132        let active: Vec<_> = self
133            .runtime
134            .enemies
135            .iter()
136            .filter(|enemy| enemy.is_active() && enemy.is_alive())
137            .map(|enemy| SpatialActor {
138                id: enemy.id(),
139                transform: enemy.current_transform(),
140            })
141            .collect();
142
143        self.spatial = SpatialQueryHandle(Box::new(
144            wowlab_engine_spatial::build_spatial_query(
145                &self.config.encounter.definition().spatial_scene,
146                &active,
147            )
148            .expect("validated encounter spatial scene rebuilds deterministically"),
149        ));
150        self.retarget();
151    }
152
153    #[must_use]
154    pub fn is_valid_target(&self, enemy: EnemyIdx) -> bool {
155        self.enemy(enemy)
156            .is_some_and(|actor| actor.is_active() && actor.is_alive())
157    }
158
159    #[must_use]
160    pub const fn current_target(&self) -> Option<EnemyIdx> {
161        self.runtime.current_target
162    }
163
164    #[must_use]
165    pub const fn attack_position(&self) -> AttackPosition {
166        self.runtime.attack_position
167    }
168
169    pub const fn set_attack_position(&mut self, position: AttackPosition) {
170        self.runtime.attack_position = position;
171    }
172
173    /// Select the active pull's preferred enemy when eligible, otherwise its lowest eligible id.
174    pub fn retarget(&mut self) -> Option<EnemyIdx> {
175        let active_pull = self.runtime.encounter.active_pull();
176        let pull = self
177            .config
178            .encounter
179            .definition()
180            .pulls
181            .get(active_pull.as_usize())
182            .filter(|pull| pull.id == active_pull);
183        let preferred = pull.map(|pull| pull.preferred_target);
184
185        self.runtime.current_target = preferred
186            .filter(|enemy| self.is_valid_target(*enemy))
187            .or_else(|| {
188                self.enemies()
189                    .find(|enemy| {
190                        enemy.is_active()
191                            && enemy.is_alive()
192                            && self.enemy_belongs_to_pull(enemy.id(), active_pull)
193                    })
194                    .map(EnemyState::id)
195            });
196
197        self.runtime.current_target
198    }
199
200    pub fn activate_enemy(&mut self, enemy: EnemyIdx, at: SimTime) -> bool {
201        let Some(actor) = self.enemy(enemy) else {
202            return false;
203        };
204
205        if actor.is_active() || !actor.is_alive() {
206            return false;
207        }
208
209        let transform = actor.current_transform();
210        let spatial_actor = SpatialActor {
211            id: enemy,
212            transform,
213        };
214
215        if self.spatial.insert_actor(spatial_actor).is_err() {
216            return false;
217        }
218
219        if !self
220            .enemy_mut(enemy)
221            .is_some_and(|actor| actor.activate(at))
222        {
223            let _ = self.spatial.remove_actor(spatial_actor);
224
225            return false;
226        }
227
228        true
229    }
230
231    pub fn refresh_enemy_health(&mut self, enemy: EnemyIdx, now: SimTime) {
232        if let Some(state) = self
233            .enemy_mut(enemy)
234            .filter(|state| state.is_active() && state.is_alive())
235        {
236            state.refresh_scripted_health(now);
237        }
238    }
239
240    #[must_use]
241    pub fn enemy_time_to_die(&self, enemy: EnemyIdx, now: SimTime) -> Option<f64> {
242        self.enemy(enemy)?.time_to_die(now)
243    }
244
245    /// Apply damage to one enemy and report whether the hit caused its death transition.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`SpecRuntimeError`] when `enemy` is absent from the active encounter state.
250    pub fn apply_enemy_damage(
251        &mut self,
252        enemy: EnemyIdx,
253        amount: f64,
254        at: SimTime,
255    ) -> Result<Option<BecameDead>, SpecRuntimeError> {
256        let state = self
257            .runtime
258            .enemies
259            .get_mut(enemy.as_usize())
260            .filter(|state| state.id() == enemy)
261            .ok_or_else(|| SpecRuntimeError::missing_enemy(enemy))?;
262        let transform = state.current_transform();
263        let remove_from_index = state.is_active();
264        let mut next_state = state.clone();
265        let outcome = next_state.apply_damage(amount, at);
266
267        if outcome.is_some() && remove_from_index {
268            self.spatial
269                .remove_actor(SpatialActor {
270                    id: enemy,
271                    transform,
272                })
273                .map_err(|source| SpecRuntimeError::enemy_death_spatial_removal(enemy, source))?;
274        }
275
276        *state = next_state;
277
278        Ok(outcome)
279    }
280
281    pub(crate) fn schedule_enemy_auto_attack(
282        &mut self,
283        source: EnemyIdx,
284        target: ActorId,
285        at: SimTime,
286    ) {
287        let Some(deadline) = self
288            .runtime
289            .health
290            .enemy_swing_deadlines
291            .get_mut(source.as_usize())
292        else {
293            return;
294        };
295
296        *deadline = Some(at);
297        self.schedule(Event::EnemyAutoAttack {
298            t: at,
299            source,
300            target,
301        });
302    }
303
304    pub(crate) fn grant_enemy_invulnerability(&mut self, target: EnemyIdx, until: SimTime) {
305        if let Some(existing) = self
306            .runtime
307            .health
308            .enemy_invulnerable_until
309            .get_mut(target.as_usize())
310        {
311            *existing = (*existing).max(until);
312        }
313    }
314
315    #[must_use]
316    pub(crate) fn enemy_is_invulnerable(&self, target: EnemyIdx, now: SimTime) -> bool {
317        self.runtime
318            .health
319            .enemy_invulnerable_until
320            .get(target.as_usize())
321            .is_some_and(|until| now < *until)
322    }
323
324    pub(crate) fn enemy_auto_attack_is_current(&self, source: EnemyIdx, at: SimTime) -> bool {
325        self.runtime
326            .health
327            .enemy_swing_deadlines
328            .get(source.as_usize())
329            .is_some_and(|deadline| *deadline == Some(at))
330    }
331
332    pub(crate) fn apply_enemy_parry_haste(&mut self, source: EnemyIdx, now: SimTime) {
333        let Some(base_ms) = self
334            .enemy_definition(source)
335            .and_then(EnemyActorDefinition::auto_attack_swing_ms)
336        else {
337            return;
338        };
339        let Some(Some(current)) = self
340            .runtime
341            .health
342            .enemy_swing_deadlines
343            .get(source.as_usize())
344        else {
345            return;
346        };
347        let Some(accelerated_deadline) = parry_hasted_deadline(base_ms, *current, now) else {
348            return;
349        };
350
351        self.schedule_enemy_auto_attack(source, ActorId::Player, accelerated_deadline);
352    }
353
354    #[must_use]
355    pub(crate) fn enemy_belongs_to_pull(&self, enemy: EnemyIdx, pull: PullId) -> bool {
356        let definition = self.config.encounter.definition();
357        let Some(actor) = definition.enemies.get(enemy.as_usize()) else {
358            return false;
359        };
360        let Some(group) = definition.groups.get(actor.group_id.as_usize()) else {
361            return false;
362        };
363
364        definition
365            .waves
366            .get(group.wave_id.as_usize())
367            .is_some_and(|wave| wave.pull_id == pull)
368    }
369
370    pub(crate) fn transition_enemy_despawn(
371        &mut self,
372        enemy: EnemyIdx,
373        at: SimTime,
374    ) -> EnemyDespawnOutcome {
375        let Some(actor) = self.enemy(enemy) else {
376            return EnemyDespawnOutcome::UnknownEnemy;
377        };
378
379        if !actor.is_active() {
380            return EnemyDespawnOutcome::AlreadyInactive;
381        }
382
383        let transform = actor.current_transform();
384        let spatial_actor = SpatialActor {
385            id: enemy,
386            transform,
387        };
388
389        if self.spatial.remove_actor(spatial_actor).is_err() {
390            return EnemyDespawnOutcome::AlreadyInactive;
391        }
392
393        if !self.enemy_mut(enemy).is_some_and(|actor| actor.despawn(at)) {
394            let _ = self.spatial.insert_actor(spatial_actor);
395
396            return EnemyDespawnOutcome::AlreadyInactive;
397        }
398
399        EnemyDespawnOutcome::Despawned
400    }
401
402    pub(crate) fn finalize_scripted_enemy_death(
403        &mut self,
404        enemy: EnemyIdx,
405        at: SimTime,
406    ) -> Result<bool, SpecRuntimeError> {
407        let state = self
408            .runtime
409            .enemies
410            .get_mut(enemy.as_usize())
411            .filter(|state| state.id() == enemy)
412            .ok_or_else(|| SpecRuntimeError::missing_enemy(enemy))?;
413
414        if !state.is_active() || !state.is_alive() {
415            return Ok(false);
416        }
417
418        let transform = state.current_transform();
419        let mut next_state = state.clone();
420
421        if !next_state.finalize_scripted_death(at) {
422            return Ok(false);
423        }
424
425        self.spatial
426            .remove_actor(SpatialActor {
427                id: enemy,
428                transform,
429            })
430            .map_err(|source| SpecRuntimeError::enemy_death_spatial_removal(enemy, source))?;
431        *state = next_state;
432
433        Ok(true)
434    }
435
436    pub(super) fn enemy_mut(&mut self, enemy: EnemyIdx) -> Option<&mut EnemyState> {
437        self.runtime
438            .enemies
439            .get_mut(enemy.as_usize())
440            .filter(|state| state.id() == enemy)
441    }
442}