Skip to main content

wowlab_engine_combat/state/runtime/
spatial.rs

1#[cfg(test)]
2use super::SpatialActor;
3#[cfg(test)]
4use super::SpellIdx;
5use super::{
6    ActorId, CombatInstance, EnemyIdx, EnemyState, PositionedActorRef, SimTime, SpatialQuery,
7    SpatialQueryError, SpatialTransform,
8};
9#[cfg(feature = "rotation-optimizations")]
10use super::{RotationMutationDirection, RotationSemanticMutation};
11use crate::state::LocalSpellIdx;
12
13impl CombatInstance {
14    /// Live transform for a player/pet/enemy source. Pets use the owner's player transform.
15    #[must_use]
16    pub fn actor_transform(&self, actor: ActorId) -> Option<SpatialTransform> {
17        match actor {
18            ActorId::Player | ActorId::External | ActorId::Pet(_) => {
19                Some(self.runtime.player_transform)
20            }
21            ActorId::Enemy(enemy) => self.enemy(enemy).map(EnemyState::current_transform),
22        }
23    }
24
25    /// Visibility includes layer separation, explicit fixture blocks, and static obstacles.
26    #[must_use]
27    pub fn has_line_of_sight(
28        &self,
29        program: &crate::state::CombatProgram,
30        source: ActorId,
31        target: EnemyIdx,
32    ) -> bool {
33        let Some(source_transform) = self.actor_transform(source) else {
34            return false;
35        };
36        let Some(target_transform) = self.actor_transform(ActorId::Enemy(target)) else {
37            return false;
38        };
39
40        if source_transform.layer != target_transform.layer {
41            return false;
42        }
43
44        let source_ref = match source {
45            ActorId::Enemy(enemy) => PositionedActorRef::Enemy(enemy),
46            ActorId::Player | ActorId::External | ActorId::Pet(_) => PositionedActorRef::Player,
47        };
48        let target_ref = PositionedActorRef::Enemy(target);
49        let (a, b) = if source_ref <= target_ref {
50            (source_ref, target_ref)
51        } else {
52            (target_ref, source_ref)
53        };
54
55        if program
56            .config
57            .encounter
58            .definition()
59            .blocked_los_pairs
60            .iter()
61            .any(|pair| pair.a == a && pair.b == b)
62        {
63            return false;
64        }
65
66        self.spatial.segment_visible(
67            source_transform.layer,
68            source_transform.position,
69            target_transform.position,
70        )
71    }
72
73    /// Atomically update the validated canonical transform and spatial index.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`SpatialQueryError`] when the transform is invalid or the actor is unavailable.
78    pub fn relocate_actor(
79        &mut self,
80        actor: PositionedActorRef,
81        transform: SpatialTransform,
82        at: SimTime,
83    ) -> Result<(), SpatialQueryError> {
84        self.spatial.validate_transform(transform)?;
85
86        #[cfg(feature = "rotation-optimizations")]
87        let before = self.actor_transform(match actor {
88            PositionedActorRef::Player => ActorId::Player,
89            PositionedActorRef::Enemy(enemy) => ActorId::Enemy(enemy),
90        });
91
92        let result = match actor {
93            PositionedActorRef::Player => {
94                self.runtime.player_transform = transform;
95                self.mark_projection_dirty(
96                    super::ProjectionDirty::TARGET_IDENTITY_RANGE_TRAVEL,
97                    at,
98                );
99
100                Ok(())
101            }
102            PositionedActorRef::Enemy(enemy) => {
103                let current = self
104                    .enemy(enemy)
105                    .filter(|enemy| enemy.is_active() && enemy.is_alive())
106                    .map(EnemyState::current_transform)
107                    .ok_or_else(|| SpatialQueryError::missing_actor(enemy))?;
108
109                self.spatial.relocate_actor(enemy, current, transform)?;
110                let Some(actor_state) = self.enemy_mut(enemy) else {
111                    let _ = self.spatial.relocate_actor(enemy, transform, current);
112
113                    return Err(SpatialQueryError::missing_actor(enemy));
114                };
115
116                actor_state.commit_relocation(transform);
117
118                if self.current_target() == Some(enemy) {
119                    self.mark_projection_dirty(
120                        super::ProjectionDirty::TARGET_IDENTITY_RANGE_TRAVEL,
121                        at,
122                    );
123                }
124
125                Ok(())
126            }
127        };
128
129        #[cfg(feature = "rotation-optimizations")]
130        if result.is_ok() && before != Some(transform) {
131            self.runtime.rotation_mutations.record_semantic(
132                RotationSemanticMutation::Spatial,
133                RotationMutationDirection::Rise,
134                at,
135            );
136            self.runtime.rotation_mutations.record_semantic(
137                RotationSemanticMutation::Spatial,
138                RotationMutationDirection::Fall,
139                at,
140            );
141        }
142
143        result
144    }
145
146    /// Shared inclusive hostile range/layer/LOS gate for casts and APL projection.
147    #[must_use]
148    pub fn spell_target_in_range(
149        &self,
150        program: &crate::state::CombatProgram,
151        spell: LocalSpellIdx,
152        target: EnemyIdx,
153    ) -> bool {
154        let Some(spell) = program.resolve_spell_identity(spell) else {
155            return false;
156        };
157
158        self.spell_target_in_range_profile(
159            program,
160            program.rotation.cast_profile_ref(spell),
161            target,
162        )
163    }
164
165    #[must_use]
166    pub(crate) fn spatial_query(&self) -> &dyn SpatialQuery {
167        &*self.spatial
168    }
169
170    #[cfg(test)]
171    pub(crate) fn remove_spatial_actor_for_test(
172        &mut self,
173        enemy: EnemyIdx,
174    ) -> Result<(), SpatialQueryError> {
175        let transform = self
176            .enemy(enemy)
177            .map(EnemyState::current_transform)
178            .ok_or_else(|| SpatialQueryError::missing_actor(enemy))?;
179
180        self.spatial.remove_actor(SpatialActor {
181            id: enemy,
182            transform,
183        })
184    }
185
186    #[cfg(test)]
187    pub(crate) fn spell_target_in_range_with_buffs(
188        &self,
189        program: &crate::state::CombatProgram,
190        spell_id: SpellIdx,
191        target: EnemyIdx,
192    ) -> bool {
193        let Some(local) = program.spell_local(spell_id.as_u32()) else {
194            return false;
195        };
196
197        self.spell_target_in_range_profile_with_buffs(
198            program,
199            program.rotation.cast_profile_ref(local),
200            target,
201        )
202    }
203
204    pub(crate) fn spell_target_in_range_profile(
205        &self,
206        program: &crate::state::CombatProgram,
207        profile: &crate::state::CastProfile,
208        target: EnemyIdx,
209    ) -> bool {
210        self.spell_target_in_range_profile_with_maximum(
211            program,
212            profile,
213            target,
214            profile.hostile_max_range,
215        )
216    }
217
218    pub(crate) fn spell_target_in_range_profile_with_buffs(
219        &self,
220        program: &crate::state::CombatProgram,
221        profile: &crate::state::CastProfile,
222        target: EnemyIdx,
223    ) -> bool {
224        let maximum = crate::systems::active_targeting_value(
225            crate::context::ActorView::new(program, self, ActorId::Player),
226            program.damage.spell_action(profile.local),
227            wowlab_engine_domain::dbc::ModifierPropertyKind::Range,
228            profile.hostile_max_range,
229        );
230
231        self.spell_target_in_range_profile_with_maximum(program, profile, target, maximum)
232    }
233
234    pub(crate) fn spell_target_in_range_profile_with_maximum(
235        &self,
236        program: &crate::state::CombatProgram,
237        profile: &crate::state::CastProfile,
238        target: EnemyIdx,
239        maximum: f64,
240    ) -> bool {
241        if !self.is_valid_target(target) {
242            return false;
243        }
244
245        let Some(source) = self.actor_transform(ActorId::Player) else {
246            return false;
247        };
248        let Some(target_transform) = self.actor_transform(ActorId::Enemy(target)) else {
249            return false;
250        };
251
252        if source.layer != target_transform.layer {
253            return false;
254        }
255
256        let distance = source.position.distance(target_transform.position);
257        let minimum = profile.hostile_min_range;
258        let in_distance = distance + wowlab_types::sim::GEOMETRY_EPSILON >= minimum
259            && distance <= maximum + wowlab_types::sim::GEOMETRY_EPSILON;
260
261        in_distance
262            && (profile.ignores_line_of_sight
263                || self.has_line_of_sight(program, ActorId::Player, target))
264    }
265}