Skip to main content

wowlab_engine_combat/state/combat_state/
spatial.rs

1#[cfg(test)]
2use super::SpatialActor;
3use super::{
4    ActorId, CombatState, EnemyIdx, EnemyState, PositionedActorRef, SpatialQuery,
5    SpatialQueryError, SpatialTransform, SpellIdx,
6};
7
8impl CombatState {
9    /// Live transform for a player/pet/enemy source. Pets use the owner's player transform.
10    #[must_use]
11    pub fn actor_transform(&self, actor: ActorId) -> Option<SpatialTransform> {
12        match actor {
13            ActorId::Player | ActorId::External | ActorId::Pet(_) => {
14                Some(self.runtime.player_transform)
15            }
16            ActorId::Enemy(enemy) => self.enemy(enemy).map(EnemyState::current_transform),
17        }
18    }
19
20    /// Visibility includes layer separation, explicit fixture blocks, and static obstacles.
21    #[must_use]
22    pub fn has_line_of_sight(&self, source: ActorId, target: EnemyIdx) -> bool {
23        let Some(source_transform) = self.actor_transform(source) else {
24            return false;
25        };
26        let Some(target_transform) = self.actor_transform(ActorId::Enemy(target)) else {
27            return false;
28        };
29
30        if source_transform.layer != target_transform.layer {
31            return false;
32        }
33
34        let source_ref = match source {
35            ActorId::Enemy(enemy) => PositionedActorRef::Enemy(enemy),
36            ActorId::Player | ActorId::External | ActorId::Pet(_) => PositionedActorRef::Player,
37        };
38        let target_ref = PositionedActorRef::Enemy(target);
39        let (a, b) = if source_ref <= target_ref {
40            (source_ref, target_ref)
41        } else {
42            (target_ref, source_ref)
43        };
44
45        if self
46            .config
47            .encounter
48            .definition()
49            .blocked_los_pairs
50            .iter()
51            .any(|pair| pair.a == a && pair.b == b)
52        {
53            return false;
54        }
55
56        self.spatial.segment_visible(
57            source_transform.layer,
58            source_transform.position,
59            target_transform.position,
60        )
61    }
62
63    /// Atomically update the validated canonical transform and spatial index.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`SpatialQueryError`] when the transform is invalid or the actor is unavailable.
68    pub fn relocate_actor(
69        &mut self,
70        actor: PositionedActorRef,
71        transform: SpatialTransform,
72    ) -> Result<(), SpatialQueryError> {
73        self.spatial.validate_transform(transform)?;
74
75        match actor {
76            PositionedActorRef::Player => {
77                self.runtime.player_transform = transform;
78
79                Ok(())
80            }
81            PositionedActorRef::Enemy(enemy) => {
82                let current = self
83                    .enemy(enemy)
84                    .filter(|enemy| enemy.is_active() && enemy.is_alive())
85                    .map(EnemyState::current_transform)
86                    .ok_or_else(|| SpatialQueryError::missing_actor(enemy))?;
87
88                self.spatial.relocate_actor(enemy, current, transform)?;
89                let Some(actor_state) = self.enemy_mut(enemy) else {
90                    let _ = self.spatial.relocate_actor(enemy, transform, current);
91
92                    return Err(SpatialQueryError::missing_actor(enemy));
93                };
94
95                actor_state.commit_relocation(transform);
96
97                Ok(())
98            }
99        }
100    }
101
102    #[must_use]
103    pub fn spatial_query(&self) -> &dyn SpatialQuery {
104        &*self.spatial
105    }
106
107    /// Shared inclusive hostile range/layer/LOS gate for casts and APL projection.
108    #[must_use]
109    pub fn spell_target_in_range(&self, spell_id: SpellIdx, target: EnemyIdx) -> bool {
110        self.spell_target_in_range_with_maximum(
111            spell_id,
112            target,
113            self.config
114                .game_data
115                .hostile_max_range(spell_id)
116                .unwrap_or(0.0),
117        )
118    }
119
120    #[cfg(test)]
121    pub(crate) fn remove_spatial_actor_for_test(
122        &mut self,
123        enemy: EnemyIdx,
124    ) -> Result<(), SpatialQueryError> {
125        let transform = self
126            .enemy(enemy)
127            .map(EnemyState::current_transform)
128            .ok_or_else(|| SpatialQueryError::missing_actor(enemy))?;
129
130        self.spatial.remove_actor(SpatialActor {
131            id: enemy,
132            transform,
133        })
134    }
135
136    pub(crate) fn spell_target_in_range_with_buffs(
137        &self,
138        buf: &wowlab_engine_domain::rotation::DenseBuffer,
139        spell_id: SpellIdx,
140        target: EnemyIdx,
141    ) -> bool {
142        let maximum = crate::systems::active_targeting_value(
143            crate::context::ActorView::new(self, buf, ActorId::Player),
144            spell_id,
145            wowlab_engine_domain::dbc::ModifierPropertyKind::Range,
146            self.config
147                .game_data
148                .hostile_max_range(spell_id)
149                .unwrap_or(0.0),
150        );
151
152        self.spell_target_in_range_with_maximum(spell_id, target, maximum)
153    }
154
155    fn spell_target_in_range_with_maximum(
156        &self,
157        spell_id: SpellIdx,
158        target: EnemyIdx,
159        maximum: f64,
160    ) -> bool {
161        if !self.is_valid_target(target) {
162            return false;
163        }
164
165        let Some(source) = self.actor_transform(ActorId::Player) else {
166            return false;
167        };
168        let Some(target_transform) = self.actor_transform(ActorId::Enemy(target)) else {
169            return false;
170        };
171
172        if source.layer != target_transform.layer {
173            return false;
174        }
175
176        let distance = source.position.distance(target_transform.position);
177        let minimum = self
178            .config
179            .game_data
180            .hostile_min_range(spell_id)
181            .unwrap_or(0.0);
182        let in_distance = distance + wowlab_types::sim::GEOMETRY_EPSILON >= minimum
183            && distance <= maximum + wowlab_types::sim::GEOMETRY_EPSILON;
184
185        in_distance
186            && (self
187                .config
188                .game_data
189                .ignores_line_of_sight(spell_id)
190                .unwrap_or(false)
191                || self.has_line_of_sight(ActorId::Player, target))
192    }
193}