Skip to main content

wowlab_engine_domain/
targeting.rs

1//! Resolved combat targeting vocabulary independent of encounter serialization.
2
3use wowlab_types::sim::{EnemyIdx, GroupId, SpatialTransform};
4
5mod overlay;
6mod plan;
7mod support;
8
9pub use overlay::{
10    TargetEffectCoordinate, TargetGeometryOverlay, TargetOverlayMetadata, TargetPlanOverlay,
11    registered_target_geometry_overlay, registered_target_plan_overlay,
12};
13pub use plan::{
14    TargetPlan, TargetPlanError, TargetPlanInput, TargetPlanOperation, TargetPlanOperations,
15    TargetPlanProvenance, TargetSelector, compile_dbc_target_plan, compile_target_plan,
16};
17pub use support::{
18    ExecutableCandidateDomain, ExecutableLocation, ExecutableLocationAlgorithm,
19    ExecutableSelection, ExecutableTargetAlgorithm, ExecutableTargetDirection,
20    ExecutableTargetFallback, ExecutableTargetOperation, ExecutableTargetOperations,
21    ExecutableTargetPlan, ExecutableTargetReference, TargetPlanAxis, TargetPlanGap,
22    lower_target_plan, target_plan_is_multi_target,
23};
24
25/// Shared deterministic single-target selectors.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27// #t(rust_non_exhaustive_on_public) selector variants are the fixed Phase 6 contract
28pub enum SingleTargetSelector {
29    Current,
30    Primary,
31    Explicit(EnemyIdx),
32    Nearest,
33    Farthest,
34    LowestHealth,
35}
36
37/// Spatial source used to choose every additional target after a chain's explicit first hit.
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39// #t(rust_non_exhaustive_on_public) chain origin variants are the exhaustive runtime algebra
40pub enum ChainTargetOrigin {
41    PreviousTarget,
42    Source,
43}
44
45/// Eligibility filter applied before target-shape geometry.
46#[derive(Clone, Debug, Default, Eq, PartialEq)]
47pub struct CandidateFilter {
48    pub groups: Vec<GroupId>,
49    pub tags: Vec<String>,
50}
51
52impl CandidateFilter {
53    /// Match OR-group membership and every required tag against enemy and group tags.
54    #[must_use]
55    pub fn matches(
56        &self,
57        group: GroupId,
58        enemy_tags: &[Box<str>],
59        group_tags: &[Box<str>],
60    ) -> bool {
61        (self.groups.is_empty() || self.groups.contains(&group))
62            && self.tags.iter().all(|required| {
63                enemy_tags.iter().any(|tag| tag.as_ref() == required)
64                    || group_tags.iter().any(|tag| tag.as_ref() == required)
65            })
66    }
67}
68
69/// Target shape after DBC/manifest semantics have been resolved.
70#[derive(Clone, Debug, PartialEq)]
71// #t(rust_non_exhaustive_on_public) shape variants are the fixed Phase 6 contract
72pub enum ResolvedTargetShape {
73    Single(SingleTargetSelector),
74    RadiusFromSource {
75        radius: f64,
76    },
77    RadiusFromTarget {
78        anchor: SingleTargetSelector,
79        radius: f64,
80    },
81    RadiusAtLocation {
82        location: SpatialTransform,
83        radius: f64,
84    },
85    Cone {
86        range: f64,
87        half_angle: f64,
88        heading_offset: f64,
89    },
90    ConeFromTarget {
91        anchor: SingleTargetSelector,
92        range: f64,
93        half_angle: f64,
94        heading_offset: f64,
95    },
96    ConeAtLocation {
97        location: SpatialTransform,
98        range: f64,
99        half_angle: f64,
100        heading_offset: f64,
101    },
102    ConeTowardTarget {
103        anchor: SingleTargetSelector,
104        range: f64,
105        half_angle: f64,
106        heading_offset: f64,
107    },
108    Line {
109        range: f64,
110        half_width: f64,
111        heading_offset: f64,
112    },
113    LineTowardTarget {
114        anchor: SingleTargetSelector,
115        range: f64,
116        half_width: f64,
117        heading_offset: f64,
118    },
119    LineAtLocation {
120        location: SpatialTransform,
121        range: f64,
122        half_width: f64,
123        heading_offset: f64,
124    },
125    Chain {
126        anchor: SingleTargetSelector,
127        origin: ChainTargetOrigin,
128        jump_radius: f64,
129        max_hits: u8,
130    },
131    Explicit(ExplicitTargets),
132}
133
134/// Validated explicit target identities in deterministic first-occurrence order.
135#[derive(Clone, Debug, Eq, PartialEq)]
136pub struct ExplicitTargets(Vec<EnemyIdx>);
137
138impl ExplicitTargets {
139    /// Borrow the validated identities in their authored order.
140    #[must_use]
141    pub fn as_slice(&self) -> &[EnemyIdx] {
142        &self.0
143    }
144
145    fn validate(&self, enemy_count: usize) -> Result<(), TargetShapeError> {
146        validate_explicit_targets(self.0.iter().copied(), enemy_count)
147    }
148}
149
150wowlab_engine_macros::define_error! {
151/// Invalid resolved target-shape construction.
152#[derive(Clone, Copy, Debug, Eq, PartialEq)]
153pub struct TargetShapeError {
154    #[source]
155    kind: TargetShapeErrorKind,
156}
157
158#[derive(Clone, Copy, Debug, thiserror::Error, Eq, PartialEq)]
159enum TargetShapeErrorKind {
160    #[error("explicit target {enemy:?} does not exist")]
161    MissingExplicitEnemy { enemy: EnemyIdx },
162    #[error(transparent)]
163    InvalidTargetPlan(#[from] TargetPlanError),
164    #[error(transparent)]
165    UnsupportedTargetPlan(#[from] TargetPlanGap),
166    #[error("target selector {selector:?} does not resolve hostile enemy identities")]
167    NonHostileSelection { selector: TargetSelector },
168    #[error("target plan requires an authored cast destination")]
169    MissingCastDestination,
170    #[error("directional target plan requires a spatial position for its {reference:?} reference")]
171    MissingReferencePosition {
172        reference: ExecutableTargetReference,
173    },
174    #[error("random target direction requires the simulation RNG")]
175    MissingDirectionRng,
176}
177}
178
179impl TargetShapeError {
180    /// Reports an explicit enemy outside the encounter.
181    #[must_use]
182    pub const fn missing_explicit_enemy(enemy: EnemyIdx) -> Self {
183        Self {
184            kind: TargetShapeErrorKind::MissingExplicitEnemy { enemy },
185        }
186    }
187
188    /// Reports a selector that does not produce hostile enemies.
189    #[must_use]
190    pub const fn non_hostile_selection(selector: TargetSelector) -> Self {
191        Self {
192            kind: TargetShapeErrorKind::NonHostileSelection { selector },
193        }
194    }
195
196    /// Reports an absent authored cast destination.
197    #[must_use]
198    pub const fn missing_cast_destination() -> Self {
199        Self {
200            kind: TargetShapeErrorKind::MissingCastDestination,
201        }
202    }
203
204    /// Reports an absent spatial position for a directional reference.
205    #[must_use]
206    pub const fn missing_reference_position(reference: ExecutableTargetReference) -> Self {
207        Self {
208            kind: TargetShapeErrorKind::MissingReferencePosition { reference },
209        }
210    }
211
212    /// Reports random direction resolution without an RNG.
213    #[must_use]
214    pub const fn missing_direction_rng() -> Self {
215        Self {
216            kind: TargetShapeErrorKind::MissingDirectionRng,
217        }
218    }
219}
220
221impl From<TargetPlanError> for TargetShapeError {
222    fn from(error: TargetPlanError) -> Self {
223        Self {
224            kind: TargetShapeErrorKind::InvalidTargetPlan(error),
225        }
226    }
227}
228
229impl From<TargetPlanGap> for TargetShapeError {
230    fn from(error: TargetPlanGap) -> Self {
231        Self {
232            kind: TargetShapeErrorKind::UnsupportedTargetPlan(error),
233        }
234    }
235}
236
237impl ResolvedTargetShape {
238    /// Validate explicit identities and deduplicate while preserving the first occurrence.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error when any target lies outside the encounter.
243    pub fn explicit(
244        targets: impl IntoIterator<Item = EnemyIdx>,
245        enemy_count: usize,
246    ) -> Result<Self, TargetShapeError> {
247        let resolved = deduplicate_explicit_targets(targets, enemy_count)?;
248
249        Ok(Self::Explicit(ExplicitTargets(resolved)))
250    }
251
252    /// Borrow explicit identities when this is an explicit shape.
253    #[must_use]
254    pub fn explicit_targets(&self) -> Option<&[EnemyIdx]> {
255        match self {
256            Self::Explicit(targets) => Some(targets.as_slice()),
257            _ => None,
258        }
259    }
260
261    /// Revalidate target identities against the encounter that will consume this shape.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error when any referenced target lies outside the encounter.
266    pub fn validate(&self, enemy_count: usize) -> Result<(), TargetShapeError> {
267        match self {
268            Self::Single(selector) => validate_selector(*selector, enemy_count),
269            Self::RadiusFromTarget { anchor, .. }
270            | Self::ConeFromTarget { anchor, .. }
271            | Self::ConeTowardTarget { anchor, .. }
272            | Self::LineTowardTarget { anchor, .. }
273            | Self::Chain { anchor, .. } => validate_selector(*anchor, enemy_count),
274            Self::Explicit(targets) => targets.validate(enemy_count),
275            Self::RadiusFromSource { .. }
276            | Self::RadiusAtLocation { .. }
277            | Self::Cone { .. }
278            | Self::ConeAtLocation { .. }
279            | Self::Line { .. }
280            | Self::LineAtLocation { .. } => Ok(()),
281        }
282    }
283}
284
285fn validate_selector(
286    selector: SingleTargetSelector,
287    enemy_count: usize,
288) -> Result<(), TargetShapeError> {
289    if let SingleTargetSelector::Explicit(enemy) = selector {
290        validate_explicit_targets([enemy], enemy_count)?;
291    }
292
293    Ok(())
294}
295
296fn validate_explicit_targets(
297    targets: impl IntoIterator<Item = EnemyIdx>,
298    enemy_count: usize,
299) -> Result<(), TargetShapeError> {
300    for enemy in targets {
301        if enemy.as_usize() >= enemy_count {
302            return Err(TargetShapeError::missing_explicit_enemy(enemy));
303        }
304    }
305
306    Ok(())
307}
308
309fn deduplicate_explicit_targets(
310    targets: impl IntoIterator<Item = EnemyIdx>,
311    enemy_count: usize,
312) -> Result<Vec<EnemyIdx>, TargetShapeError> {
313    let mut resolved = Vec::new();
314
315    for enemy in targets {
316        validate_explicit_targets([enemy], enemy_count)?;
317
318        if !resolved.contains(&enemy) {
319            resolved.push(enemy);
320        }
321    }
322
323    Ok(resolved)
324}
325
326#[cfg(test)]
327mod tests {
328    use googletest::prelude::*;
329
330    use super::*;
331
332    #[gtest]
333    fn candidate_filter_uses_or_groups_and_all_tags_from_union() {
334        let filter = CandidateFilter {
335            groups: vec![GroupId(1), GroupId(3)],
336            tags: vec!["caster".into(), "priority".into()],
337        };
338
339        expect_that!(
340            filter.matches(GroupId(3), &["caster".into()], &["priority".into()]),
341            is_true()
342        );
343        expect_that!(
344            !filter.matches(GroupId(2), &["caster".into()], &["priority".into()]),
345            is_true()
346        );
347        expect_that!(
348            !filter.matches(GroupId(3), &["caster".into()], &[]),
349            is_true()
350        );
351    }
352
353    #[gtest]
354    fn explicit_shape_deduplicates_in_first_occurrence_order() {
355        let shape =
356            ResolvedTargetShape::explicit([EnemyIdx(2), EnemyIdx(1), EnemyIdx(2), EnemyIdx(0)], 3)
357                .expect("valid explicit identities");
358
359        expect_that!(
360            shape.explicit_targets(),
361            eq(Some(&[EnemyIdx(2), EnemyIdx(1), EnemyIdx(0)][..]))
362        );
363        expect_that!(
364            ResolvedTargetShape::explicit([EnemyIdx(3)], 3),
365            eq(&Err(TargetShapeError {
366                kind: TargetShapeErrorKind::MissingExplicitEnemy { enemy: EnemyIdx(3) }
367            }))
368        );
369    }
370}