Skip to main content

wowlab_engine_combat/state/runtime/
enemies.rs

1use super::{
2    ActorId, BecameDead, CombatInstance, EnemyActorDefinition, EnemyDespawnOutcome, EnemyIdx,
3    EnemyState, Event, HealthModel, PullId, SimTime, SpatialActor, SpecRuntimeError,
4    parry_hasted_deadline,
5};
6#[cfg(feature = "rotation-optimizations")]
7use super::{RotationMutationDirection, RotationSemanticMutation};
8use crate::state::AttackPosition;
9
10impl CombatInstance {
11    #[must_use]
12    pub fn enemy(&self, enemy: EnemyIdx) -> Option<&EnemyState> {
13        self.runtime
14            .enemies
15            .get(enemy.as_usize())
16            .filter(|state| state.id() == enemy)
17    }
18
19    /// Canonical maximum health for an exact encounter enemy.
20    #[must_use]
21    pub fn enemy_max_health(
22        &self,
23        program: &crate::state::CombatProgram,
24        enemy: EnemyIdx,
25    ) -> Option<f64> {
26        program
27            .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(
34        &self,
35        program: &crate::state::CombatProgram,
36        enemy: EnemyIdx,
37        now: SimTime,
38    ) -> Option<f64> {
39        let definition = program.enemy_definition(enemy)?;
40        let state = self.enemy(enemy)?;
41
42        match definition.health_model() {
43            HealthModel::DamageDriven { .. } => Some(state.current_health()),
44            health_model @ HealthModel::ScriptedLinear { .. } => {
45                health_model.scripted_health_at(state.spawned_at()?, now)
46            }
47        }
48    }
49
50    /// Canonical health fraction for an exact encounter enemy at engine time `now`.
51    #[must_use]
52    pub fn enemy_health_fraction(
53        &self,
54        program: &crate::state::CombatProgram,
55        enemy: EnemyIdx,
56        now: SimTime,
57    ) -> Option<f64> {
58        let max_health = self.enemy_max_health(program, enemy)?;
59
60        (max_health > 0.0).then(|| {
61            self.enemy_health(program, enemy, now)
62                .map(|health| health / max_health)
63        })?
64    }
65
66    /// Canonical live health fraction for any actor addressable by combat effects.
67    #[must_use]
68    pub fn actor_health_fraction(
69        &self,
70        program: &crate::state::CombatProgram,
71        actor: ActorId,
72        now: SimTime,
73    ) -> Option<f64> {
74        match actor {
75            ActorId::External => None,
76            ActorId::Player => Some(self.runtime.health.player_health.fraction()),
77            ActorId::Pet(pet) => self
78                .runtime
79                .health
80                .pet_health
81                .get(pet.as_usize())
82                .and_then(Option::as_ref)
83                .map(|health| health.fraction()),
84            ActorId::Enemy(enemy) => self.enemy_health_fraction(program, enemy, now),
85        }
86    }
87
88    #[must_use]
89    pub fn enemies(&self) -> impl ExactSizeIterator<Item = &EnemyState> {
90        self.runtime.enemies.iter()
91    }
92
93    /// Returns the number of active enemies.
94    ///
95    /// # Panics
96    ///
97    /// Panics if a validated encounter exceeds the engine's supported enemy count.
98    #[must_use]
99    pub fn active_enemy_count(&self) -> i32 {
100        let count = self.enemies().filter(|enemy| enemy.is_active()).count();
101
102        i32::try_from(count).expect("validated encounter enemy count fits i32")
103    }
104
105    /// Returns the number of active, living enemies.
106    ///
107    /// # Panics
108    ///
109    /// Panics if a validated encounter exceeds the engine's supported enemy count.
110    #[must_use]
111    pub fn active_alive_enemy_count(&self) -> i32 {
112        let count = self
113            .enemies()
114            .filter(|enemy| enemy.is_active() && enemy.is_alive())
115            .count();
116
117        i32::try_from(count).expect("validated encounter enemy count fits i32")
118    }
119
120    /// Restores enemy and spatial state to the validated encounter definition.
121    ///
122    /// # Panics
123    ///
124    /// Panics if rebuilding a previously validated spatial scene fails.
125    pub fn reset_enemies(&mut self, program: &crate::state::CombatProgram) {
126        debug_assert_eq!(
127            self.runtime.enemies.len(),
128            program.enemies.len(),
129            "enemy runtime length must match the finalized encounter"
130        );
131
132        for index in 0..program.enemies.len() {
133            let current = self
134                .runtime
135                .enemies
136                .get(index)
137                .expect("bounded runtime enemy index");
138            let current_indexed = current.is_active() && current.is_alive();
139            let current_actor = SpatialActor {
140                id: current.id(),
141                transform: current.current_transform(),
142            };
143            let reset = EnemyState::from_definition(
144                program
145                    .enemies
146                    .get(index)
147                    .expect("bounded enemy definition index"),
148            );
149            let reset_indexed = reset.is_active() && reset.is_alive();
150            let reset_actor = SpatialActor {
151                id: reset.id(),
152                transform: reset.current_transform(),
153            };
154
155            match (current_indexed, reset_indexed) {
156                (true, true) if current_actor.transform != reset_actor.transform => self
157                    .spatial
158                    .relocate_actor(
159                        current_actor.id,
160                        current_actor.transform,
161                        reset_actor.transform,
162                    )
163                    .expect("validated encounter actor relocates during reset"),
164                (true, false) => self
165                    .spatial
166                    .remove_actor(current_actor)
167                    .expect("indexed encounter actor is removable during reset"),
168                (false, true) => {
169                    let _ = self.spatial.remove_actor(current_actor);
170
171                    self.spatial
172                        .insert_actor(reset_actor)
173                        .expect("validated encounter actor inserts during reset");
174                }
175                _ => {}
176            }
177
178            *self
179                .runtime
180                .enemies
181                .get_mut(index)
182                .expect("bounded runtime enemy index") = reset;
183        }
184
185        self.runtime.player_transform = program
186            .config
187            .encounter
188            .definition()
189            .initial_player_transform;
190        self.runtime.attack_position = AttackPosition::Behind;
191        self.runtime.health.enemy_swing_deadlines.fill(None);
192        self.runtime
193            .health
194            .enemy_invulnerable_until
195            .fill(SimTime::ZERO);
196        self.runtime
197            .encounter
198            .reset(program.config.encounter.definition());
199        self.retarget(program, SimTime::ZERO);
200    }
201
202    #[must_use]
203    pub fn is_valid_target(&self, enemy: EnemyIdx) -> bool {
204        self.enemy(enemy)
205            .is_some_and(|actor| actor.is_active() && actor.is_alive())
206    }
207
208    #[must_use]
209    pub const fn current_target(&self) -> Option<EnemyIdx> {
210        self.runtime.current_target
211    }
212
213    #[must_use]
214    pub const fn attack_position(&self) -> AttackPosition {
215        self.runtime.attack_position
216    }
217
218    pub fn set_attack_position(&mut self, position: AttackPosition, _at: SimTime) {
219        if self.runtime.attack_position == position {
220            return;
221        }
222
223        self.runtime.attack_position = position;
224
225        #[cfg(feature = "rotation-optimizations")]
226        {
227            self.runtime.rotation_mutations.record_semantic(
228                RotationSemanticMutation::Spatial,
229                RotationMutationDirection::Rise,
230                _at,
231            );
232            self.runtime.rotation_mutations.record_semantic(
233                RotationSemanticMutation::Spatial,
234                RotationMutationDirection::Fall,
235                _at,
236            );
237        }
238    }
239
240    /// Select the active pull's preferred enemy when eligible, otherwise its lowest eligible id.
241    pub fn retarget(
242        &mut self,
243        program: &crate::state::CombatProgram,
244        at: SimTime,
245    ) -> Option<EnemyIdx> {
246        let previous = self.runtime.current_target;
247        let active_pull = self.runtime.encounter.active_pull();
248        let pull = program
249            .config
250            .encounter
251            .definition()
252            .pulls
253            .get(active_pull.as_usize())
254            .filter(|pull| pull.id == active_pull);
255        let preferred = pull.map(|pull| pull.preferred_target);
256
257        self.runtime.current_target = preferred
258            .filter(|enemy| self.is_valid_target(*enemy))
259            .or_else(|| {
260                self.enemies()
261                    .find(|enemy| {
262                        enemy.is_active()
263                            && enemy.is_alive()
264                            && self.enemy_belongs_to_pull(program, enemy.id(), active_pull)
265                    })
266                    .map(EnemyState::id)
267            });
268
269        if self.runtime.current_target != previous {
270            self.mark_projection_dirty(
271                super::ProjectionDirty::TARGET_HEALTH
272                    | super::ProjectionDirty::TARGET_IDENTITY_RANGE_TRAVEL,
273                at,
274            );
275
276            #[cfg(feature = "rotation-optimizations")]
277            {
278                self.runtime.rotation_mutations.record_semantic(
279                    RotationSemanticMutation::TargetIdentity,
280                    RotationMutationDirection::Rise,
281                    at,
282                );
283                self.runtime.rotation_mutations.record_semantic(
284                    RotationSemanticMutation::TargetIdentity,
285                    RotationMutationDirection::Fall,
286                    at,
287                );
288            }
289        }
290
291        self.runtime.current_target
292    }
293
294    pub fn activate_enemy(&mut self, enemy: EnemyIdx, at: SimTime) -> bool {
295        let Some(actor) = self.enemy(enemy) else {
296            return false;
297        };
298
299        if actor.is_active() || !actor.is_alive() {
300            return false;
301        }
302
303        let transform = actor.current_transform();
304        let spatial_actor = SpatialActor {
305            id: enemy,
306            transform,
307        };
308
309        if self.spatial.insert_actor(spatial_actor).is_err() {
310            return false;
311        }
312
313        if !self
314            .enemy_mut(enemy)
315            .is_some_and(|actor| actor.activate(at))
316        {
317            let _ = self.spatial.remove_actor(spatial_actor);
318
319            return false;
320        }
321
322        self.mark_projection_dirty(super::ProjectionDirty::POPULATION, at);
323
324        #[cfg(feature = "rotation-optimizations")]
325        self.runtime.rotation_mutations.record_semantic(
326            RotationSemanticMutation::EncounterPopulation,
327            RotationMutationDirection::Rise,
328            at,
329        );
330
331        true
332    }
333
334    pub fn refresh_enemy_health(&mut self, enemy: EnemyIdx, now: SimTime) {
335        #[cfg(feature = "rotation-optimizations")]
336        let before = self.enemy(enemy).map(EnemyState::current_health);
337
338        if let Some(state) = self
339            .enemy_mut(enemy)
340            .filter(|state| state.is_active() && state.is_alive())
341        {
342            state.refresh_scripted_health(now);
343        }
344
345        #[cfg(feature = "rotation-optimizations")]
346        if let (Some(before), Some(after)) =
347            (before, self.enemy(enemy).map(EnemyState::current_health))
348        {
349            let direction = if after > before {
350                Some(RotationMutationDirection::Rise)
351            } else if after < before {
352                Some(RotationMutationDirection::Fall)
353            } else {
354                None
355            };
356
357            if let Some(direction) = direction {
358                self.runtime.rotation_mutations.record_semantic(
359                    RotationSemanticMutation::TargetHealth,
360                    direction,
361                    now,
362                );
363            }
364        }
365
366        if self.current_target() == Some(enemy) {
367            self.mark_projection_dirty(super::ProjectionDirty::TARGET_HEALTH, now);
368        }
369    }
370
371    #[must_use]
372    pub fn enemy_time_to_die(&self, enemy: EnemyIdx, now: SimTime) -> Option<f64> {
373        self.enemy(enemy)?.time_to_die(now)
374    }
375
376    /// Apply damage to one enemy and report whether the hit caused its death transition.
377    ///
378    /// # Errors
379    ///
380    /// Returns [`SpecRuntimeError`] when `enemy` is absent from the active encounter state.
381    pub fn apply_enemy_damage(
382        &mut self,
383        enemy: EnemyIdx,
384        amount: f64,
385        at: SimTime,
386    ) -> Result<Option<BecameDead>, SpecRuntimeError> {
387        let state = self
388            .runtime
389            .enemies
390            .get_mut(enemy.as_usize())
391            .filter(|state| state.id() == enemy)
392            .ok_or_else(|| SpecRuntimeError::missing_enemy(enemy))?;
393        let transform = state.current_transform();
394        #[cfg(feature = "rotation-optimizations")]
395        let health_before = state.current_health();
396        let remove_from_index = state.is_active();
397        let mut next_state = state.clone();
398        let outcome = next_state.apply_damage(amount, at);
399        #[cfg(feature = "rotation-optimizations")]
400        let health_after = next_state.current_health();
401
402        if outcome.is_some() && remove_from_index {
403            self.spatial
404                .remove_actor(SpatialActor {
405                    id: enemy,
406                    transform,
407                })
408                .map_err(|source| SpecRuntimeError::enemy_death_spatial_removal(enemy, source))?;
409        }
410
411        *state = next_state;
412
413        #[cfg(feature = "rotation-optimizations")]
414        if health_after < health_before {
415            self.runtime.rotation_mutations.record_semantic(
416                RotationSemanticMutation::TargetHealth,
417                RotationMutationDirection::Fall,
418                at,
419            );
420        }
421
422        #[cfg(feature = "rotation-optimizations")]
423        if outcome.is_some() {
424            self.runtime.rotation_mutations.record_semantic(
425                RotationSemanticMutation::EncounterPopulation,
426                RotationMutationDirection::Fall,
427                at,
428            );
429        }
430
431        let mut dirty = super::ProjectionDirty::empty();
432
433        if self.current_target() == Some(enemy) {
434            dirty |= super::ProjectionDirty::TARGET_HEALTH;
435        }
436
437        if outcome.is_some() {
438            dirty |= super::ProjectionDirty::POPULATION;
439        }
440
441        if !dirty.is_empty() {
442            self.mark_projection_dirty(dirty, at);
443        }
444
445        Ok(outcome)
446    }
447
448    pub(crate) fn schedule_enemy_auto_attack(
449        &mut self,
450        source: EnemyIdx,
451        target: ActorId,
452        at: SimTime,
453    ) {
454        let Some(deadline) = self
455            .runtime
456            .health
457            .enemy_swing_deadlines
458            .get_mut(source.as_usize())
459        else {
460            return;
461        };
462
463        *deadline = Some(at);
464        self.schedule(Event::EnemyAutoAttack {
465            t: at,
466            source,
467            target,
468        });
469    }
470
471    pub(crate) fn grant_enemy_invulnerability(&mut self, target: EnemyIdx, until: SimTime) {
472        if let Some(existing) = self
473            .runtime
474            .health
475            .enemy_invulnerable_until
476            .get_mut(target.as_usize())
477        {
478            *existing = (*existing).max(until);
479        }
480    }
481
482    #[must_use]
483    pub(crate) fn enemy_is_invulnerable(&self, target: EnemyIdx, now: SimTime) -> bool {
484        self.runtime
485            .health
486            .enemy_invulnerable_until
487            .get(target.as_usize())
488            .is_some_and(|until| now < *until)
489    }
490
491    pub(crate) fn enemy_auto_attack_is_current(&self, source: EnemyIdx, at: SimTime) -> bool {
492        self.runtime
493            .health
494            .enemy_swing_deadlines
495            .get(source.as_usize())
496            .is_some_and(|deadline| *deadline == Some(at))
497    }
498
499    pub(crate) fn apply_enemy_parry_haste(
500        &mut self,
501        program: &crate::state::CombatProgram,
502        source: EnemyIdx,
503        now: SimTime,
504    ) {
505        let Some(base_ms) = program
506            .enemy_definition(source)
507            .and_then(EnemyActorDefinition::auto_attack_swing_ms)
508        else {
509            return;
510        };
511        let Some(Some(current)) = self
512            .runtime
513            .health
514            .enemy_swing_deadlines
515            .get(source.as_usize())
516        else {
517            return;
518        };
519        let Some(accelerated_deadline) = parry_hasted_deadline(base_ms, *current, now) else {
520            return;
521        };
522
523        self.schedule_enemy_auto_attack(source, ActorId::Player, accelerated_deadline);
524    }
525
526    #[must_use]
527    #[expect(
528        clippy::unused_self,
529        reason = "pull membership is exposed through the enemy runtime query API"
530    )]
531    pub(crate) fn enemy_belongs_to_pull(
532        &self,
533        program: &crate::state::CombatProgram,
534        enemy: EnemyIdx,
535        pull: PullId,
536    ) -> bool {
537        let definition = program.config.encounter.definition();
538        let Some(actor) = definition.enemies.get(enemy.as_usize()) else {
539            return false;
540        };
541        let Some(group) = definition.groups.get(actor.group_id.as_usize()) else {
542            return false;
543        };
544
545        definition
546            .waves
547            .get(group.wave_id.as_usize())
548            .is_some_and(|wave| wave.pull_id == pull)
549    }
550
551    pub(crate) fn transition_enemy_despawn(
552        &mut self,
553        enemy: EnemyIdx,
554        at: SimTime,
555    ) -> EnemyDespawnOutcome {
556        let Some(actor) = self.enemy(enemy) else {
557            return EnemyDespawnOutcome::UnknownEnemy;
558        };
559
560        if !actor.is_active() {
561            return EnemyDespawnOutcome::AlreadyInactive;
562        }
563
564        let transform = actor.current_transform();
565        let spatial_actor = SpatialActor {
566            id: enemy,
567            transform,
568        };
569
570        if self.spatial.remove_actor(spatial_actor).is_err() {
571            return EnemyDespawnOutcome::AlreadyInactive;
572        }
573
574        if !self.enemy_mut(enemy).is_some_and(|actor| actor.despawn(at)) {
575            let _ = self.spatial.insert_actor(spatial_actor);
576
577            return EnemyDespawnOutcome::AlreadyInactive;
578        }
579
580        let mut dirty = super::ProjectionDirty::POPULATION;
581
582        if self.current_target() == Some(enemy) {
583            dirty |= super::ProjectionDirty::TARGET_HEALTH;
584        }
585
586        self.mark_projection_dirty(dirty, at);
587
588        #[cfg(feature = "rotation-optimizations")]
589        {
590            self.runtime.rotation_mutations.record_semantic(
591                RotationSemanticMutation::EncounterPopulation,
592                RotationMutationDirection::Fall,
593                at,
594            );
595            self.runtime.rotation_mutations.record_semantic(
596                RotationSemanticMutation::TargetHealth,
597                RotationMutationDirection::Fall,
598                at,
599            );
600        }
601
602        EnemyDespawnOutcome::Despawned
603    }
604
605    pub(crate) fn finalize_scripted_enemy_death(
606        &mut self,
607        enemy: EnemyIdx,
608        at: SimTime,
609    ) -> Result<bool, SpecRuntimeError> {
610        let state = self
611            .runtime
612            .enemies
613            .get_mut(enemy.as_usize())
614            .filter(|state| state.id() == enemy)
615            .ok_or_else(|| SpecRuntimeError::missing_enemy(enemy))?;
616
617        if !state.is_active() || !state.is_alive() {
618            return Ok(false);
619        }
620
621        let transform = state.current_transform();
622        let mut next_state = state.clone();
623
624        if !next_state.finalize_scripted_death(at) {
625            return Ok(false);
626        }
627
628        self.spatial
629            .remove_actor(SpatialActor {
630                id: enemy,
631                transform,
632            })
633            .map_err(|source| SpecRuntimeError::enemy_death_spatial_removal(enemy, source))?;
634        *state = next_state;
635
636        let mut dirty = super::ProjectionDirty::POPULATION;
637
638        if self.current_target() == Some(enemy) {
639            dirty |= super::ProjectionDirty::TARGET_HEALTH;
640        }
641
642        self.mark_projection_dirty(dirty, at);
643
644        #[cfg(feature = "rotation-optimizations")]
645        {
646            self.runtime.rotation_mutations.record_semantic(
647                RotationSemanticMutation::EncounterPopulation,
648                RotationMutationDirection::Fall,
649                at,
650            );
651            self.runtime.rotation_mutations.record_semantic(
652                RotationSemanticMutation::TargetHealth,
653                RotationMutationDirection::Fall,
654                at,
655            );
656        }
657
658        Ok(true)
659    }
660
661    pub(super) fn enemy_mut(&mut self, enemy: EnemyIdx) -> Option<&mut EnemyState> {
662        self.runtime
663            .enemies
664            .get_mut(enemy.as_usize())
665            .filter(|state| state.id() == enemy)
666    }
667}