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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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}