Skip to main content

wowlab_engine_combat/
targeting.rs

1//! Deterministic selector, filter, and spatial target-shape resolution.
2
3use wowlab_engine_domain::targeting::{
4    CandidateFilter, ChainTargetOrigin, ExecutableTargetDirection, ResolvedTargetShape,
5    SingleTargetSelector, TargetShapeError,
6};
7use wowlab_engine_ports::{ConeQuery, RadiusQuery, SpatialEnvelope};
8use wowlab_types::sim::{
9    ActorId, EnemyIdx, GEOMETRY_EPSILON, Position2, SimTime, SpatialLayerId, SpatialTransform,
10};
11
12use crate::state::CombatState;
13
14mod chain;
15mod program;
16
17use chain::{radius_envelope, resolve_chain};
18#[cfg(test)]
19use program::spell_target_shapes_with_resolved_geometry;
20pub(crate) use program::{
21    resolve_spell_targets, spell_chain_target_origin, spell_line_of_sight_policy,
22};
23#[cfg(test)]
24pub(crate) use program::{spell_target_shapes, spell_target_shapes_with_destination};
25
26const DIRECTIONAL_SUMMON_DEFAULT_DISTANCE: f64 = 3.0;
27
28#[derive(Clone, Copy)]
29enum RuntimeTargetAnchor {
30    Source,
31    ExplicitTarget,
32    Location(SpatialTransform),
33}
34
35#[derive(Clone, Copy)]
36struct TargetProgramState {
37    source: RuntimeTargetAnchor,
38    destination: Option<RuntimeTargetAnchor>,
39}
40
41#[derive(Clone, Copy, Default)]
42struct TargetProgramPositions {
43    source: Option<SpatialTransform>,
44    explicit_target: Option<SpatialTransform>,
45}
46
47impl TargetProgramState {
48    const fn with_destination(destination: Option<SpatialTransform>) -> Self {
49        Self {
50            source: RuntimeTargetAnchor::Source,
51            destination: match destination {
52                Some(location) => Some(RuntimeTargetAnchor::Location(location)),
53                None => None,
54            },
55        }
56    }
57}
58
59impl Default for TargetProgramState {
60    fn default() -> Self {
61        Self {
62            source: RuntimeTargetAnchor::Source,
63            destination: None,
64        }
65    }
66}
67
68#[derive(Clone, Copy)]
69struct ResolutionContext<'a> {
70    filter: &'a CandidateFilter,
71    now: SimTime,
72    line_of_sight: LineOfSightPolicy,
73}
74
75#[derive(Clone, Copy)]
76struct ChainGeometry {
77    origin: ChainTargetOrigin,
78    jump_radius: f64,
79    max_hits: u8,
80}
81
82#[derive(Clone, Copy)]
83struct ConeGeometry {
84    range: f64,
85    half_angle: f64,
86    heading_offset: f64,
87}
88
89#[derive(Clone, Copy)]
90struct LineGeometry {
91    range: f64,
92    half_width: f64,
93    heading_offset: f64,
94}
95
96#[derive(Clone, Copy)]
97struct EffectGeometry {
98    radius: f64,
99    range: f64,
100    cone_half_angle: f64,
101    face_explicit_target: bool,
102}
103
104#[derive(Clone, Copy)]
105struct EffectGeometryInput {
106    radius: f64,
107    range: f64,
108}
109
110#[derive(Clone, Copy)]
111struct SpellTargetShapeInput {
112    anchor: EnemyIdx,
113    destination: Option<SpatialTransform>,
114    positions: TargetProgramPositions,
115    geometry: EffectGeometryInput,
116}
117
118#[derive(Debug, Default, Eq, PartialEq)]
119struct ResolutionDiagnostics {
120    #[cfg(test)]
121    broad_phase_candidates: usize,
122    #[cfg(test)]
123    metadata_checks: usize,
124    #[cfg(test)]
125    exact_geometry_checks: usize,
126    #[cfg(test)]
127    line_of_sight_checks: usize,
128}
129
130impl ResolutionDiagnostics {
131    fn record_broad_phase(&mut self, count: usize) {
132        let _ = self;
133        let _ = count;
134
135        #[cfg(test)]
136        {
137            self.broad_phase_candidates += count;
138        }
139    }
140
141    fn record_metadata_check(&mut self) {
142        let _ = self;
143
144        #[cfg(test)]
145        {
146            self.metadata_checks += 1;
147        }
148    }
149
150    fn record_exact_geometry(&mut self, count: usize) {
151        let _ = self;
152        let _ = count;
153
154        #[cfg(test)]
155        {
156            self.exact_geometry_checks += count;
157        }
158    }
159
160    fn record_line_of_sight_check(&mut self) {
161        let _ = self;
162
163        #[cfg(test)]
164        {
165            self.line_of_sight_checks += 1;
166        }
167    }
168}
169
170/// Spell-specific line-of-sight policy applied after metadata and exact geometry.
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172#[non_exhaustive]
173pub(crate) enum LineOfSightPolicy {
174    Required,
175    IgnoreObstructions,
176}
177
178#[derive(Clone, Copy, Debug)]
179pub(crate) struct SpellTargetRequest {
180    pub(crate) source: ActorId,
181    pub(crate) source_position: Option<SpatialTransform>,
182    pub(crate) anchor: EnemyIdx,
183    pub(crate) geometry_spell: wowlab_types::sim::SpellIdx,
184    pub(crate) geometry_effect: u8,
185    pub(crate) impact_spell: wowlab_types::sim::SpellIdx,
186    pub(crate) max_targets: Option<u8>,
187    pub(crate) destination: Option<SpatialTransform>,
188}
189
190impl LineOfSightPolicy {
191    /// Resolve the DBC `IgnoreLineOfSight` attribute into target-selection behavior.
192    #[must_use]
193    pub(crate) const fn from_ignores_line_of_sight(ignores_line_of_sight: bool) -> Self {
194        if ignores_line_of_sight {
195            Self::IgnoreObstructions
196        } else {
197            Self::Required
198        }
199    }
200}
201
202/// Resolve a combat target shape into its deterministic hit order.
203///
204/// # Errors
205///
206/// Returns [`TargetShapeError`] when the shape, anchor, source, or spatial query is invalid.
207pub(crate) fn resolve_targets(
208    state: &CombatState,
209    source: ActorId,
210    shape: &ResolvedTargetShape,
211    filter: &CandidateFilter,
212    line_of_sight: LineOfSightPolicy,
213    now: SimTime,
214) -> Result<Vec<EnemyIdx>, TargetShapeError> {
215    shape.validate(state.enemies().len())?;
216    let mut diagnostics = ResolutionDiagnostics::default();
217
218    Ok(resolve_targets_diagnosed(
219        state,
220        source,
221        shape,
222        ResolutionContext {
223            filter,
224            now,
225            line_of_sight,
226        },
227        &mut diagnostics,
228    ))
229}
230
231fn resolve_targets_diagnosed(
232    state: &CombatState,
233    source: ActorId,
234    shape: &ResolvedTargetShape,
235    context: ResolutionContext<'_>,
236    diagnostics: &mut ResolutionDiagnostics,
237) -> Vec<EnemyIdx> {
238    match shape {
239        ResolvedTargetShape::Single(selector) => {
240            select_one(state, source, *selector, context, diagnostics)
241                .into_iter()
242                .collect()
243        }
244        ResolvedTargetShape::RadiusFromSource { .. }
245        | ResolvedTargetShape::RadiusFromTarget { .. }
246        | ResolvedTargetShape::RadiusAtLocation { .. } => {
247            resolve_radius_shape(state, source, shape, context, diagnostics)
248        }
249        ResolvedTargetShape::Cone { .. }
250        | ResolvedTargetShape::ConeFromTarget { .. }
251        | ResolvedTargetShape::ConeAtLocation { .. }
252        | ResolvedTargetShape::ConeTowardTarget { .. } => {
253            resolve_cone_shape(state, source, shape, context, diagnostics)
254        }
255        ResolvedTargetShape::Line { .. }
256        | ResolvedTargetShape::LineTowardTarget { .. }
257        | ResolvedTargetShape::LineAtLocation { .. } => {
258            resolve_line_shape(state, source, shape, context, diagnostics)
259        }
260        ResolvedTargetShape::Chain {
261            anchor,
262            origin,
263            jump_radius,
264            max_hits,
265        } => resolve_chain(
266            state,
267            source,
268            *anchor,
269            ChainGeometry {
270                origin: *origin,
271                jump_radius: *jump_radius,
272                max_hits: *max_hits,
273            },
274            context,
275            diagnostics,
276        ),
277        ResolvedTargetShape::Explicit(enemies) => enemies
278            .as_slice()
279            .iter()
280            .copied()
281            .filter(|enemy| {
282                metadata_eligible_counted(state, *enemy, context.filter, diagnostics)
283                    && visible_counted(state, source, *enemy, context.line_of_sight, diagnostics)
284            })
285            .collect(),
286    }
287}
288
289fn resolve_radius_shape(
290    state: &CombatState,
291    source: ActorId,
292    shape: &ResolvedTargetShape,
293    context: ResolutionContext<'_>,
294    diagnostics: &mut ResolutionDiagnostics,
295) -> Vec<EnemyIdx> {
296    let (origin, radius) = match shape {
297        ResolvedTargetShape::RadiusFromSource { radius } => {
298            let Some(origin) = state.actor_transform(source) else {
299                return Vec::new();
300            };
301
302            (origin, *radius)
303        }
304        ResolvedTargetShape::RadiusFromTarget { anchor, radius } => {
305            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics) else {
306                return Vec::new();
307            };
308            let Some(origin) = state.actor_transform(ActorId::Enemy(anchor)) else {
309                return Vec::new();
310            };
311
312            (origin, *radius)
313        }
314        ResolvedTargetShape::RadiusAtLocation { location, radius } => (*location, *radius),
315        _ => return Vec::new(),
316    };
317
318    resolve_radius_at(state, source, origin, radius, context, diagnostics)
319}
320
321fn resolve_cone_shape(
322    state: &CombatState,
323    source: ActorId,
324    shape: &ResolvedTargetShape,
325    context: ResolutionContext<'_>,
326    diagnostics: &mut ResolutionDiagnostics,
327) -> Vec<EnemyIdx> {
328    let (origin, geometry) = match shape {
329        ResolvedTargetShape::Cone {
330            range,
331            half_angle,
332            heading_offset,
333        } => {
334            let Some(origin) = state.actor_transform(source) else {
335                return Vec::new();
336            };
337
338            (
339                origin,
340                ConeGeometry {
341                    range: *range,
342                    half_angle: *half_angle,
343                    heading_offset: *heading_offset,
344                },
345            )
346        }
347        ResolvedTargetShape::ConeFromTarget {
348            anchor,
349            range,
350            half_angle,
351            heading_offset,
352        } => {
353            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics) else {
354                return Vec::new();
355            };
356            let Some(origin) = state.actor_transform(ActorId::Enemy(anchor)) else {
357                return Vec::new();
358            };
359
360            (
361                origin,
362                ConeGeometry {
363                    range: *range,
364                    half_angle: *half_angle,
365                    heading_offset: *heading_offset,
366                },
367            )
368        }
369        ResolvedTargetShape::ConeAtLocation {
370            location,
371            range,
372            half_angle,
373            heading_offset,
374        } => (
375            *location,
376            ConeGeometry {
377                range: *range,
378                half_angle: *half_angle,
379                heading_offset: *heading_offset,
380            },
381        ),
382        ResolvedTargetShape::ConeTowardTarget {
383            anchor,
384            range,
385            half_angle,
386            heading_offset,
387        } => {
388            let Some(origin) = state.actor_transform(source) else {
389                return Vec::new();
390            };
391            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics) else {
392                return Vec::new();
393            };
394            let Some(target) = state.actor_transform(ActorId::Enemy(anchor)) else {
395                return Vec::new();
396            };
397
398            (
399                SpatialTransform {
400                    heading: heading_toward(origin.position, target.position),
401                    ..origin
402                },
403                ConeGeometry {
404                    range: *range,
405                    half_angle: *half_angle,
406                    heading_offset: *heading_offset,
407                },
408            )
409        }
410        _ => return Vec::new(),
411    };
412
413    resolve_cone_at(state, source, origin, geometry, context, diagnostics)
414}
415
416fn resolve_line_shape(
417    state: &CombatState,
418    source: ActorId,
419    shape: &ResolvedTargetShape,
420    context: ResolutionContext<'_>,
421    diagnostics: &mut ResolutionDiagnostics,
422) -> Vec<EnemyIdx> {
423    let (origin, geometry) = match shape {
424        ResolvedTargetShape::Line {
425            range,
426            half_width,
427            heading_offset,
428        } => {
429            let Some(origin) = state.actor_transform(source) else {
430                return Vec::new();
431            };
432
433            (
434                origin,
435                LineGeometry {
436                    range: *range,
437                    half_width: *half_width,
438                    heading_offset: *heading_offset,
439                },
440            )
441        }
442        ResolvedTargetShape::LineTowardTarget {
443            anchor,
444            range,
445            half_width,
446            heading_offset,
447        } => {
448            let Some(origin) = state.actor_transform(source) else {
449                return Vec::new();
450            };
451            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics) else {
452                return Vec::new();
453            };
454            let Some(target) = state.actor_transform(ActorId::Enemy(anchor)) else {
455                return Vec::new();
456            };
457
458            (
459                SpatialTransform {
460                    heading: heading_toward(origin.position, target.position),
461                    ..origin
462                },
463                LineGeometry {
464                    range: *range,
465                    half_width: *half_width,
466                    heading_offset: *heading_offset,
467                },
468            )
469        }
470        ResolvedTargetShape::LineAtLocation {
471            location,
472            range,
473            half_width,
474            heading_offset,
475        } => (
476            *location,
477            LineGeometry {
478                range: *range,
479                half_width: *half_width,
480                heading_offset: *heading_offset,
481            },
482        ),
483        _ => return Vec::new(),
484    };
485
486    resolve_line_at(state, source, origin, geometry, context, diagnostics)
487}
488
489fn resolve_radius_at(
490    state: &CombatState,
491    source: ActorId,
492    origin: SpatialTransform,
493    radius: f64,
494    context: ResolutionContext<'_>,
495    diagnostics: &mut ResolutionDiagnostics,
496) -> Vec<EnemyIdx> {
497    let allowed = filtered_spatial_candidates(
498        state,
499        origin.layer,
500        radius_envelope(origin.position, radius),
501        context.filter,
502        diagnostics,
503    );
504
505    diagnostics.record_exact_geometry(allowed.len());
506    let targets = state.spatial_query().actors_in_radius(
507        RadiusQuery {
508            layer: origin.layer,
509            center: origin.position,
510            radius,
511        },
512        Some(&allowed),
513    );
514
515    visible_candidates(state, source, targets, context.line_of_sight, diagnostics)
516}
517
518fn resolve_cone_at(
519    state: &CombatState,
520    source: ActorId,
521    origin: SpatialTransform,
522    geometry: ConeGeometry,
523    context: ResolutionContext<'_>,
524    diagnostics: &mut ResolutionDiagnostics,
525) -> Vec<EnemyIdx> {
526    let allowed = filtered_spatial_candidates(
527        state,
528        origin.layer,
529        radius_envelope(origin.position, geometry.range),
530        context.filter,
531        diagnostics,
532    );
533
534    diagnostics.record_exact_geometry(allowed.len());
535    let targets = state.spatial_query().actors_in_cone(
536        ConeQuery {
537            layer: origin.layer,
538            apex: origin.position,
539            heading: origin.heading + geometry.heading_offset,
540            half_angle: geometry.half_angle,
541            range: geometry.range,
542        },
543        Some(&allowed),
544    );
545
546    visible_candidates(state, source, targets, context.line_of_sight, diagnostics)
547}
548
549fn resolve_line_at(
550    state: &CombatState,
551    source: ActorId,
552    origin: SpatialTransform,
553    geometry: LineGeometry,
554    context: ResolutionContext<'_>,
555    diagnostics: &mut ResolutionDiagnostics,
556) -> Vec<EnemyIdx> {
557    let heading = origin.heading + geometry.heading_offset;
558    let allowed = filtered_spatial_candidates(
559        state,
560        origin.layer,
561        radius_envelope(origin.position, geometry.range),
562        context.filter,
563        diagnostics,
564    );
565
566    diagnostics.record_exact_geometry(allowed.len());
567    let targets = allowed
568        .into_iter()
569        .filter(|enemy| {
570            state
571                .actor_transform(ActorId::Enemy(*enemy))
572                .is_some_and(|target| {
573                    target.layer == origin.layer
574                        && point_in_line(
575                            target.position,
576                            origin.position,
577                            heading,
578                            geometry.range,
579                            geometry.half_width,
580                        )
581                })
582        })
583        .collect();
584
585    visible_candidates(state, source, targets, context.line_of_sight, diagnostics)
586}
587
588fn heading_toward(origin: Position2, target: Position2) -> f64 {
589    (target.y - origin.y).atan2(target.x - origin.x)
590}
591
592fn direction_angle(
593    direction: ExecutableTargetDirection,
594    rng: Option<&mut dyn FnMut() -> f64>,
595) -> Result<f64, TargetShapeError> {
596    use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI, TAU};
597
598    const THREE_QUARTER_TURN: f64 = 3.0 * FRAC_PI_4;
599
600    Ok(match direction {
601        ExecutableTargetDirection::None | ExecutableTargetDirection::Front => 0.0,
602        ExecutableTargetDirection::Back => PI,
603        ExecutableTargetDirection::Right => -FRAC_PI_2,
604        ExecutableTargetDirection::Left => FRAC_PI_2,
605        ExecutableTargetDirection::FrontRight => -FRAC_PI_4,
606        ExecutableTargetDirection::BackRight => -THREE_QUARTER_TURN,
607        ExecutableTargetDirection::BackLeft => THREE_QUARTER_TURN,
608        ExecutableTargetDirection::FrontLeft => FRAC_PI_4,
609        ExecutableTargetDirection::Random => {
610            rng.ok_or_else(TargetShapeError::missing_direction_rng)?() * TAU
611        }
612    })
613}
614
615fn point_in_line(
616    point: Position2,
617    origin: Position2,
618    heading: f64,
619    range: f64,
620    half_width: f64,
621) -> bool {
622    let dx = point.x - origin.x;
623    let dy = point.y - origin.y;
624    let forward = dx * heading.cos() + dy * heading.sin();
625    let lateral = -dx * heading.sin() + dy * heading.cos();
626    let distance_sq = dx * dx + dy * dy;
627
628    forward >= -GEOMETRY_EPSILON
629        && distance_sq <= range * range + GEOMETRY_EPSILON
630        && lateral.abs() <= half_width + GEOMETRY_EPSILON
631}
632
633fn select_one(
634    state: &CombatState,
635    source: ActorId,
636    selector: SingleTargetSelector,
637    context: ResolutionContext<'_>,
638    diagnostics: &mut ResolutionDiagnostics,
639) -> Option<EnemyIdx> {
640    match selector {
641        SingleTargetSelector::Current => state
642            .current_target()
643            .filter(|enemy| metadata_eligible_counted(state, *enemy, context.filter, diagnostics))
644            .filter(|enemy| {
645                visible_counted(state, source, *enemy, context.line_of_sight, diagnostics)
646            }),
647        SingleTargetSelector::Primary => {
648            if metadata_eligible_counted(state, EnemyIdx::PRIMARY, context.filter, diagnostics) {
649                {
650                    visible_counted(
651                        state,
652                        source,
653                        EnemyIdx::PRIMARY,
654                        context.line_of_sight,
655                        diagnostics,
656                    )
657                }
658            } else {
659                false
660            }
661            .then_some(EnemyIdx::PRIMARY)
662        }
663        SingleTargetSelector::Explicit(enemy) => {
664            if metadata_eligible_counted(state, enemy, context.filter, diagnostics) {
665                visible_counted(state, source, enemy, context.line_of_sight, diagnostics)
666            } else {
667                false
668            }
669            .then_some(enemy)
670        }
671        SingleTargetSelector::Nearest => {
672            let origin = state.actor_transform(source)?;
673
674            state.spatial_query().nearest_actor_matching(
675                origin.layer,
676                origin.position,
677                &mut |enemy| {
678                    diagnostics.record_broad_phase(1);
679
680                    if !metadata_eligible_counted(state, enemy, context.filter, diagnostics) {
681                        return false;
682                    }
683
684                    diagnostics.record_exact_geometry(1);
685
686                    visible_counted(state, source, enemy, context.line_of_sight, diagnostics)
687                },
688            )
689        }
690        SingleTargetSelector::Farthest => {
691            let origin = state.actor_transform(source)?;
692            let allowed =
693                filtered_layer_candidates(state, origin.layer, context.filter, diagnostics);
694
695            diagnostics.record_exact_geometry(allowed.len());
696
697            state
698                .spatial_query()
699                .actors_by_distance(origin.layer, origin.position, Some(&allowed))
700                .into_iter()
701                .filter(|enemy| {
702                    visible_counted(state, source, *enemy, context.line_of_sight, diagnostics)
703                })
704                .max_by(|left, right| {
705                    let left_distance = state
706                        .actor_transform(ActorId::Enemy(*left))
707                        .map(|transform| origin.position.distance(transform.position))
708                        .unwrap_or_default();
709                    let right_distance = state
710                        .actor_transform(ActorId::Enemy(*right))
711                        .map(|transform| origin.position.distance(transform.position))
712                        .unwrap_or_default();
713
714                    left_distance
715                        .total_cmp(&right_distance)
716                        .then_with(|| right.cmp(left))
717                })
718        }
719        SingleTargetSelector::LowestHealth => {
720            let origin = state.actor_transform(source)?;
721
722            filtered_layer_candidates(state, origin.layer, context.filter, diagnostics)
723                .into_iter()
724                .filter(|enemy| {
725                    visible_counted(state, source, *enemy, context.line_of_sight, diagnostics)
726                })
727                .min_by(|left, right| {
728                    let left_health = state
729                        .enemy_health_fraction(*left, context.now)
730                        .unwrap_or(1.0);
731                    let right_health = state
732                        .enemy_health_fraction(*right, context.now)
733                        .unwrap_or(1.0);
734
735                    left_health
736                        .total_cmp(&right_health)
737                        .then_with(|| left.cmp(right))
738                })
739        }
740    }
741}
742
743fn metadata_eligible_counted(
744    state: &CombatState,
745    enemy: EnemyIdx,
746    filter: &CandidateFilter,
747    diagnostics: &mut ResolutionDiagnostics,
748) -> bool {
749    diagnostics.record_metadata_check();
750
751    metadata_eligible(state, enemy, filter)
752}
753
754fn metadata_eligible(state: &CombatState, enemy: EnemyIdx, filter: &CandidateFilter) -> bool {
755    if !state.is_valid_target(enemy) {
756        return false;
757    }
758
759    state.enemy_definition(enemy).is_some_and(|definition| {
760        filter.matches(
761            definition.group_id(),
762            definition.enemy_tags(),
763            definition.group_tags(),
764        )
765    })
766}
767
768fn visible_counted(
769    state: &CombatState,
770    source: ActorId,
771    enemy: EnemyIdx,
772    line_of_sight: LineOfSightPolicy,
773    diagnostics: &mut ResolutionDiagnostics,
774) -> bool {
775    match line_of_sight {
776        LineOfSightPolicy::Required => {
777            diagnostics.record_line_of_sight_check();
778
779            state.has_line_of_sight(source, enemy)
780        }
781        LineOfSightPolicy::IgnoreObstructions => state
782            .actor_transform(source)
783            .zip(state.actor_transform(ActorId::Enemy(enemy)))
784            .is_some_and(|(source, target)| source.layer == target.layer),
785    }
786}
787
788fn visible_candidates(
789    state: &CombatState,
790    source: ActorId,
791    targets: Vec<EnemyIdx>,
792    line_of_sight: LineOfSightPolicy,
793    diagnostics: &mut ResolutionDiagnostics,
794) -> Vec<EnemyIdx> {
795    targets
796        .into_iter()
797        .filter(|enemy| visible_counted(state, source, *enemy, line_of_sight, diagnostics))
798        .collect()
799}
800
801fn filtered_layer_candidates(
802    state: &CombatState,
803    layer: SpatialLayerId,
804    filter: &CandidateFilter,
805    diagnostics: &mut ResolutionDiagnostics,
806) -> Vec<EnemyIdx> {
807    let candidates = state.spatial_query().candidates_on_layer(layer);
808
809    diagnostics.record_broad_phase(candidates.len());
810
811    candidates
812        .into_iter()
813        .filter(|enemy| metadata_eligible_counted(state, *enemy, filter, diagnostics))
814        .collect()
815}
816
817fn filtered_spatial_candidates(
818    state: &CombatState,
819    layer: SpatialLayerId,
820    envelope: SpatialEnvelope,
821    filter: &CandidateFilter,
822    diagnostics: &mut ResolutionDiagnostics,
823) -> Vec<EnemyIdx> {
824    let candidates = state
825        .spatial_query()
826        .candidates_in_envelope(layer, envelope);
827
828    diagnostics.record_broad_phase(candidates.len());
829
830    candidates
831        .into_iter()
832        .filter(|enemy| metadata_eligible_counted(state, *enemy, filter, diagnostics))
833        .collect()
834}
835
836#[cfg(test)]
837mod tests;