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::SpatialEnvelope;
8use wowlab_types::sim::{
9    ActorId, EnemyIdx, GEOMETRY_EPSILON, Position2, SimTime, SpatialLayerId, SpatialTransform,
10};
11
12use crate::state::{CombatInstance, TargetWorkspace};
13
14mod chain;
15mod program;
16
17use chain::{radius_envelope, resolve_chain};
18pub(crate) use program::resolve_spell_targets_into;
19#[cfg(test)]
20use program::spell_target_shapes_with_resolved_geometry;
21#[cfg(test)]
22pub(crate) use program::{spell_target_shapes, spell_target_shapes_with_destination};
23
24const DIRECTIONAL_SUMMON_DEFAULT_DISTANCE: f64 = 3.0;
25pub(crate) const TARGET_WORKSPACE_EXHAUSTED: &str = "finalized target workspace depth exhausted";
26
27pub(crate) fn take_target_workspace(state: &mut CombatInstance) -> Option<TargetWorkspace> {
28    let mut workspace = state.runtime.scratch.target_workspaces.pop()?;
29
30    workspace.clear();
31
32    Some(workspace)
33}
34
35pub(crate) fn restore_target_workspace(state: &mut CombatInstance, mut workspace: TargetWorkspace) {
36    workspace.clear();
37    assert!(
38        state.runtime.scratch.target_workspaces.len()
39            < state.runtime.scratch.target_workspaces.capacity(),
40        "restoring target scratch exceeds the finalized workspace pool"
41    );
42    state.runtime.scratch.target_workspaces.push(workspace);
43}
44
45#[derive(Clone, Copy)]
46enum RuntimeTargetAnchor {
47    Source,
48    ExplicitTarget,
49    Location(SpatialTransform),
50}
51
52#[derive(Clone, Copy)]
53struct TargetProgramState {
54    source: RuntimeTargetAnchor,
55    destination: Option<RuntimeTargetAnchor>,
56}
57
58#[derive(Clone, Copy, Default)]
59struct TargetProgramPositions {
60    source: Option<SpatialTransform>,
61    explicit_target: Option<SpatialTransform>,
62}
63
64impl TargetProgramState {
65    const fn with_destination(destination: Option<SpatialTransform>) -> Self {
66        Self {
67            source: RuntimeTargetAnchor::Source,
68            destination: match destination {
69                Some(location) => Some(RuntimeTargetAnchor::Location(location)),
70                None => None,
71            },
72        }
73    }
74}
75
76impl Default for TargetProgramState {
77    fn default() -> Self {
78        Self {
79            source: RuntimeTargetAnchor::Source,
80            destination: None,
81        }
82    }
83}
84
85#[derive(Clone, Copy)]
86struct ResolutionContext<'a> {
87    program: &'a crate::state::CombatProgram,
88    filter: &'a CandidateFilter,
89    now: SimTime,
90    line_of_sight: LineOfSightPolicy,
91}
92
93#[derive(Clone, Copy)]
94struct ChainGeometry {
95    origin: ChainTargetOrigin,
96    jump_radius: f64,
97    max_hits: u8,
98}
99
100#[derive(Clone, Copy)]
101struct ConeGeometry {
102    range: f64,
103    half_angle: f64,
104    heading_offset: f64,
105}
106
107#[derive(Clone, Copy)]
108struct LineGeometry {
109    range: f64,
110    half_width: f64,
111    heading_offset: f64,
112}
113
114#[derive(Clone, Copy)]
115// #t(rust_similar_structs) mutable runtime geometry and finalized target execution metadata belong to different lifecycle stages
116struct EffectGeometry {
117    radius: f64,
118    range: f64,
119    cone_half_angle: f64,
120    face_explicit_target: bool,
121}
122
123#[derive(Clone, Copy)]
124struct EffectGeometryInput {
125    radius: f64,
126    range: f64,
127}
128
129#[derive(Clone, Copy)]
130struct SpellTargetShapeInput {
131    anchor: EnemyIdx,
132    destination: Option<SpatialTransform>,
133    positions: TargetProgramPositions,
134    geometry: EffectGeometryInput,
135}
136
137#[derive(Debug, Default, Eq, PartialEq)]
138struct ResolutionDiagnostics {
139    #[cfg(test)]
140    broad_phase_candidates: usize,
141    #[cfg(test)]
142    metadata_checks: usize,
143    #[cfg(test)]
144    exact_geometry_checks: usize,
145    #[cfg(test)]
146    line_of_sight_checks: usize,
147}
148
149impl ResolutionDiagnostics {
150    fn record_broad_phase(&mut self, count: usize) {
151        let _ = self;
152        let _ = count;
153
154        #[cfg(test)]
155        {
156            self.broad_phase_candidates += count;
157        }
158    }
159
160    fn record_metadata_check(&mut self) {
161        let _ = self;
162
163        #[cfg(test)]
164        {
165            self.metadata_checks += 1;
166        }
167    }
168
169    fn record_exact_geometry(&mut self, count: usize) {
170        let _ = self;
171        let _ = count;
172
173        #[cfg(test)]
174        {
175            self.exact_geometry_checks += count;
176        }
177    }
178
179    fn record_line_of_sight_check(&mut self) {
180        let _ = self;
181
182        #[cfg(test)]
183        {
184            self.line_of_sight_checks += 1;
185        }
186    }
187}
188
189/// Spell-specific line-of-sight policy applied after metadata and exact geometry.
190#[derive(Clone, Copy, Debug, Eq, PartialEq)]
191#[non_exhaustive]
192pub(crate) enum LineOfSightPolicy {
193    Required,
194    IgnoreObstructions,
195}
196
197#[derive(Clone, Copy, Debug)]
198pub(crate) struct SpellTargetRequest {
199    pub(crate) source: ActorId,
200    pub(crate) source_position: Option<SpatialTransform>,
201    pub(crate) anchor: EnemyIdx,
202    pub(crate) geometry_effect: crate::state::program::LocalDamageEffectIdx,
203    pub(crate) impact_action: crate::state::program::LocalDamageActionIdx,
204    pub(crate) target: crate::state::program::FinalizedTargetExecution,
205    pub(crate) max_targets: Option<u8>,
206    pub(crate) destination: Option<SpatialTransform>,
207}
208
209impl LineOfSightPolicy {
210    /// Resolve the DBC `IgnoreLineOfSight` attribute into target-selection behavior.
211    #[must_use]
212    pub(crate) const fn from_ignores_line_of_sight(ignores_line_of_sight: bool) -> Self {
213        if ignores_line_of_sight {
214            Self::IgnoreObstructions
215        } else {
216            Self::Required
217        }
218    }
219}
220
221/// Resolve a combat target shape into its deterministic hit order.
222///
223/// # Errors
224///
225/// Returns [`TargetShapeError`] when the shape, anchor, source, or spatial query is invalid.
226// #t(fn: rust_large_fn_params) the explicit program/instance lifecycle borrows accompany the independent typed targeting inputs.
227#[expect(
228    clippy::too_many_arguments,
229    reason = "the targeting boundary keeps independent typed query inputs explicit"
230)]
231pub(crate) fn resolve_targets_into(
232    program: &crate::state::CombatProgram,
233    state: &CombatInstance,
234    source: ActorId,
235    shape: &ResolvedTargetShape,
236    filter: &CandidateFilter,
237    line_of_sight: LineOfSightPolicy,
238    now: SimTime,
239    workspace: &mut TargetWorkspace,
240) -> Result<(), TargetShapeError> {
241    shape.validate(state.enemies().len())?;
242    let mut diagnostics = ResolutionDiagnostics::default();
243
244    workspace.clear();
245
246    resolve_targets_diagnosed(
247        state,
248        source,
249        shape,
250        ResolutionContext {
251            program,
252            filter,
253            now,
254            line_of_sight,
255        },
256        &mut diagnostics,
257        workspace,
258    );
259
260    Ok(())
261}
262
263fn resolve_targets_diagnosed(
264    state: &CombatInstance,
265    source: ActorId,
266    shape: &ResolvedTargetShape,
267    context: ResolutionContext<'_>,
268    diagnostics: &mut ResolutionDiagnostics,
269    workspace: &mut TargetWorkspace,
270) {
271    match shape {
272        ResolvedTargetShape::Single(selector) => {
273            if let Some(target) =
274                select_one(state, source, *selector, context, diagnostics, workspace)
275            {
276                workspace.push_target(target);
277            }
278        }
279        ResolvedTargetShape::RadiusFromSource { .. }
280        | ResolvedTargetShape::RadiusFromTarget { .. }
281        | ResolvedTargetShape::RadiusAtLocation { .. } => {
282            resolve_radius_shape(state, source, shape, context, diagnostics, workspace);
283        }
284        ResolvedTargetShape::Cone { .. }
285        | ResolvedTargetShape::ConeFromTarget { .. }
286        | ResolvedTargetShape::ConeAtLocation { .. }
287        | ResolvedTargetShape::ConeTowardTarget { .. } => {
288            resolve_cone_shape(state, source, shape, context, diagnostics, workspace);
289        }
290        ResolvedTargetShape::Line { .. }
291        | ResolvedTargetShape::LineTowardTarget { .. }
292        | ResolvedTargetShape::LineAtLocation { .. } => {
293            resolve_line_shape(state, source, shape, context, diagnostics, workspace);
294        }
295        ResolvedTargetShape::Chain {
296            anchor,
297            origin,
298            jump_radius,
299            max_hits,
300        } => resolve_chain(
301            state,
302            source,
303            *anchor,
304            ChainGeometry {
305                origin: *origin,
306                jump_radius: *jump_radius,
307                max_hits: *max_hits,
308            },
309            context,
310            diagnostics,
311            workspace,
312        ),
313        ResolvedTargetShape::Explicit(enemies) => {
314            for enemy in enemies.as_slice().iter().copied() {
315                if metadata_eligible_counted(
316                    context.program,
317                    state,
318                    enemy,
319                    context.filter,
320                    diagnostics,
321                ) && visible_counted(
322                    context.program,
323                    state,
324                    source,
325                    enemy,
326                    context.line_of_sight,
327                    diagnostics,
328                ) {
329                    workspace.push_target(enemy);
330                }
331            }
332        }
333    }
334}
335
336fn resolve_radius_shape(
337    state: &CombatInstance,
338    source: ActorId,
339    shape: &ResolvedTargetShape,
340    context: ResolutionContext<'_>,
341    diagnostics: &mut ResolutionDiagnostics,
342    workspace: &mut TargetWorkspace,
343) {
344    let (origin, radius) = match shape {
345        ResolvedTargetShape::RadiusFromSource { radius } => {
346            let Some(origin) = state.actor_transform(source) else {
347                return;
348            };
349
350            (origin, *radius)
351        }
352        ResolvedTargetShape::RadiusFromTarget { anchor, radius } => {
353            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics, workspace)
354            else {
355                return;
356            };
357            let Some(origin) = state.actor_transform(ActorId::Enemy(anchor)) else {
358                return;
359            };
360
361            (origin, *radius)
362        }
363        ResolvedTargetShape::RadiusAtLocation { location, radius } => (*location, *radius),
364        _ => return,
365    };
366
367    resolve_radius_at(
368        state,
369        source,
370        origin,
371        radius,
372        context,
373        diagnostics,
374        workspace,
375    );
376}
377
378fn resolve_cone_shape(
379    state: &CombatInstance,
380    source: ActorId,
381    shape: &ResolvedTargetShape,
382    context: ResolutionContext<'_>,
383    diagnostics: &mut ResolutionDiagnostics,
384    workspace: &mut TargetWorkspace,
385) {
386    let (origin, geometry) = match shape {
387        ResolvedTargetShape::Cone {
388            range,
389            half_angle,
390            heading_offset,
391        } => {
392            let Some(origin) = state.actor_transform(source) else {
393                return;
394            };
395
396            (
397                origin,
398                ConeGeometry {
399                    range: *range,
400                    half_angle: *half_angle,
401                    heading_offset: *heading_offset,
402                },
403            )
404        }
405        ResolvedTargetShape::ConeFromTarget {
406            anchor,
407            range,
408            half_angle,
409            heading_offset,
410        } => {
411            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics, workspace)
412            else {
413                return;
414            };
415            let Some(origin) = state.actor_transform(ActorId::Enemy(anchor)) else {
416                return;
417            };
418
419            (
420                origin,
421                ConeGeometry {
422                    range: *range,
423                    half_angle: *half_angle,
424                    heading_offset: *heading_offset,
425                },
426            )
427        }
428        ResolvedTargetShape::ConeAtLocation {
429            location,
430            range,
431            half_angle,
432            heading_offset,
433        } => (
434            *location,
435            ConeGeometry {
436                range: *range,
437                half_angle: *half_angle,
438                heading_offset: *heading_offset,
439            },
440        ),
441        ResolvedTargetShape::ConeTowardTarget {
442            anchor,
443            range,
444            half_angle,
445            heading_offset,
446        } => {
447            let Some(origin) = state.actor_transform(source) else {
448                return;
449            };
450            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics, workspace)
451            else {
452                return;
453            };
454            let Some(target) = state.actor_transform(ActorId::Enemy(anchor)) else {
455                return;
456            };
457
458            (
459                SpatialTransform {
460                    heading: heading_toward(origin.position, target.position),
461                    ..origin
462                },
463                ConeGeometry {
464                    range: *range,
465                    half_angle: *half_angle,
466                    heading_offset: *heading_offset,
467                },
468            )
469        }
470        _ => return,
471    };
472
473    resolve_cone_at(
474        state,
475        source,
476        origin,
477        geometry,
478        context,
479        diagnostics,
480        workspace,
481    );
482}
483
484fn resolve_line_shape(
485    state: &CombatInstance,
486    source: ActorId,
487    shape: &ResolvedTargetShape,
488    context: ResolutionContext<'_>,
489    diagnostics: &mut ResolutionDiagnostics,
490    workspace: &mut TargetWorkspace,
491) {
492    let (origin, geometry) = match shape {
493        ResolvedTargetShape::Line {
494            range,
495            half_width,
496            heading_offset,
497        } => {
498            let Some(origin) = state.actor_transform(source) else {
499                return;
500            };
501
502            (
503                origin,
504                LineGeometry {
505                    range: *range,
506                    half_width: *half_width,
507                    heading_offset: *heading_offset,
508                },
509            )
510        }
511        ResolvedTargetShape::LineTowardTarget {
512            anchor,
513            range,
514            half_width,
515            heading_offset,
516        } => {
517            let Some(origin) = state.actor_transform(source) else {
518                return;
519            };
520            let Some(anchor) = select_one(state, source, *anchor, context, diagnostics, workspace)
521            else {
522                return;
523            };
524            let Some(target) = state.actor_transform(ActorId::Enemy(anchor)) else {
525                return;
526            };
527
528            (
529                SpatialTransform {
530                    heading: heading_toward(origin.position, target.position),
531                    ..origin
532                },
533                LineGeometry {
534                    range: *range,
535                    half_width: *half_width,
536                    heading_offset: *heading_offset,
537                },
538            )
539        }
540        ResolvedTargetShape::LineAtLocation {
541            location,
542            range,
543            half_width,
544            heading_offset,
545        } => (
546            *location,
547            LineGeometry {
548                range: *range,
549                half_width: *half_width,
550                heading_offset: *heading_offset,
551            },
552        ),
553        _ => return,
554    };
555
556    resolve_line_at(
557        state,
558        source,
559        origin,
560        geometry,
561        context,
562        diagnostics,
563        workspace,
564    );
565}
566
567// #t(fn: rust_large_fn_params) target resolution keeps program, geometry, diagnostics, and caller-owned scratch as independent borrows.
568fn resolve_radius_at(
569    state: &CombatInstance,
570    source: ActorId,
571    origin: SpatialTransform,
572    radius: f64,
573    context: ResolutionContext<'_>,
574    diagnostics: &mut ResolutionDiagnostics,
575    workspace: &mut TargetWorkspace,
576) {
577    fill_spatial_candidates(
578        state,
579        origin.layer,
580        radius_envelope(origin.position, radius),
581        diagnostics,
582        workspace,
583    );
584    workspace.retain_candidates(|enemy| {
585        metadata_eligible_counted(context.program, state, enemy, context.filter, diagnostics)
586    });
587    diagnostics.record_exact_geometry(workspace.candidate_count());
588    workspace.retain_candidates(|enemy| {
589        state
590            .actor_transform(ActorId::Enemy(enemy))
591            .is_some_and(|target| {
592                target.layer == origin.layer
593                    && origin.position.distance(target.position) <= radius + GEOMETRY_EPSILON
594            })
595    });
596    append_visible_candidates(state, source, context, diagnostics, workspace);
597}
598
599// #t(fn: rust_large_fn_params) target resolution keeps program, geometry, diagnostics, and caller-owned scratch as independent borrows.
600fn resolve_cone_at(
601    state: &CombatInstance,
602    source: ActorId,
603    origin: SpatialTransform,
604    geometry: ConeGeometry,
605    context: ResolutionContext<'_>,
606    diagnostics: &mut ResolutionDiagnostics,
607    workspace: &mut TargetWorkspace,
608) {
609    let heading = origin.heading + geometry.heading_offset;
610
611    fill_spatial_candidates(
612        state,
613        origin.layer,
614        radius_envelope(origin.position, geometry.range),
615        diagnostics,
616        workspace,
617    );
618    workspace.retain_candidates(|enemy| {
619        metadata_eligible_counted(context.program, state, enemy, context.filter, diagnostics)
620    });
621    diagnostics.record_exact_geometry(workspace.candidate_count());
622    workspace.retain_candidates(|enemy| {
623        state
624            .actor_transform(ActorId::Enemy(enemy))
625            .is_some_and(|target| {
626                target.layer == origin.layer
627                    && point_in_cone(
628                        target.position,
629                        origin.position,
630                        heading,
631                        geometry.half_angle,
632                        geometry.range,
633                    )
634            })
635    });
636    append_visible_candidates(state, source, context, diagnostics, workspace);
637}
638
639// #t(fn: rust_large_fn_params) target resolution keeps program, geometry, diagnostics, and caller-owned scratch as independent borrows.
640fn resolve_line_at(
641    state: &CombatInstance,
642    source: ActorId,
643    origin: SpatialTransform,
644    geometry: LineGeometry,
645    context: ResolutionContext<'_>,
646    diagnostics: &mut ResolutionDiagnostics,
647    workspace: &mut TargetWorkspace,
648) {
649    let heading = origin.heading + geometry.heading_offset;
650
651    fill_spatial_candidates(
652        state,
653        origin.layer,
654        radius_envelope(origin.position, geometry.range),
655        diagnostics,
656        workspace,
657    );
658    workspace.retain_candidates(|enemy| {
659        metadata_eligible_counted(context.program, state, enemy, context.filter, diagnostics)
660    });
661    diagnostics.record_exact_geometry(workspace.candidate_count());
662    workspace.retain_candidates(|enemy| {
663        state
664            .actor_transform(ActorId::Enemy(enemy))
665            .is_some_and(|target| {
666                target.layer == origin.layer
667                    && point_in_line(
668                        target.position,
669                        origin.position,
670                        heading,
671                        geometry.range,
672                        geometry.half_width,
673                    )
674            })
675    });
676    append_visible_candidates(state, source, context, diagnostics, workspace);
677}
678
679fn heading_toward(origin: Position2, target: Position2) -> f64 {
680    (target.y - origin.y).atan2(target.x - origin.x)
681}
682
683fn direction_angle(
684    direction: ExecutableTargetDirection,
685    rng: Option<&mut dyn FnMut() -> f64>,
686) -> Result<f64, TargetShapeError> {
687    use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI, TAU};
688
689    const THREE_QUARTER_TURN: f64 = 3.0 * FRAC_PI_4;
690
691    Ok(match direction {
692        ExecutableTargetDirection::None | ExecutableTargetDirection::Front => 0.0,
693        ExecutableTargetDirection::Back => PI,
694        ExecutableTargetDirection::Right => -FRAC_PI_2,
695        ExecutableTargetDirection::Left => FRAC_PI_2,
696        ExecutableTargetDirection::FrontRight => -FRAC_PI_4,
697        ExecutableTargetDirection::BackRight => -THREE_QUARTER_TURN,
698        ExecutableTargetDirection::BackLeft => THREE_QUARTER_TURN,
699        ExecutableTargetDirection::FrontLeft => FRAC_PI_4,
700        ExecutableTargetDirection::Random => {
701            rng.ok_or_else(TargetShapeError::missing_direction_rng)?() * TAU
702        }
703    })
704}
705
706fn point_in_line(
707    point: Position2,
708    origin: Position2,
709    heading: f64,
710    range: f64,
711    half_width: f64,
712) -> bool {
713    let dx = point.x - origin.x;
714    let dy = point.y - origin.y;
715    let forward = dx * heading.cos() + dy * heading.sin();
716    let lateral = -dx * heading.sin() + dy * heading.cos();
717    let distance_sq = dx * dx + dy * dy;
718
719    forward >= -GEOMETRY_EPSILON
720        && distance_sq <= range * range + GEOMETRY_EPSILON
721        && lateral.abs() <= half_width + GEOMETRY_EPSILON
722}
723
724fn point_in_cone(
725    point: Position2,
726    apex: Position2,
727    heading: f64,
728    half_angle: f64,
729    range: f64,
730) -> bool {
731    let distance = apex.distance(point);
732
733    if distance > range + GEOMETRY_EPSILON {
734        return false;
735    }
736
737    if distance <= GEOMETRY_EPSILON {
738        return true;
739    }
740
741    let angle = (point.y - apex.y).atan2(point.x - apex.x);
742    let mut delta = (angle - heading) % std::f64::consts::TAU;
743
744    if delta > std::f64::consts::PI {
745        delta -= std::f64::consts::TAU;
746    }
747
748    if delta < -std::f64::consts::PI {
749        delta += std::f64::consts::TAU;
750    }
751
752    delta.abs() <= half_angle + GEOMETRY_EPSILON
753}
754
755// #t(fn: rust_max_fn_lines) exhaustive selector semantics and their deterministic tie-breaking remain auditable in one match.
756fn select_one(
757    state: &CombatInstance,
758    source: ActorId,
759    selector: SingleTargetSelector,
760    context: ResolutionContext<'_>,
761    diagnostics: &mut ResolutionDiagnostics,
762    workspace: &mut TargetWorkspace,
763) -> Option<EnemyIdx> {
764    match selector {
765        SingleTargetSelector::Current => state
766            .current_target()
767            .filter(|enemy| {
768                metadata_eligible_counted(
769                    context.program,
770                    state,
771                    *enemy,
772                    context.filter,
773                    diagnostics,
774                )
775            })
776            .filter(|enemy| {
777                visible_counted(
778                    context.program,
779                    state,
780                    source,
781                    *enemy,
782                    context.line_of_sight,
783                    diagnostics,
784                )
785            }),
786        SingleTargetSelector::Primary => if metadata_eligible_counted(
787            context.program,
788            state,
789            EnemyIdx::PRIMARY,
790            context.filter,
791            diagnostics,
792        ) {
793            {
794                visible_counted(
795                    context.program,
796                    state,
797                    source,
798                    EnemyIdx::PRIMARY,
799                    context.line_of_sight,
800                    diagnostics,
801                )
802            }
803        } else {
804            false
805        }
806        .then_some(EnemyIdx::PRIMARY),
807        SingleTargetSelector::Explicit(enemy) => {
808            if metadata_eligible_counted(context.program, state, enemy, context.filter, diagnostics)
809            {
810                visible_counted(
811                    context.program,
812                    state,
813                    source,
814                    enemy,
815                    context.line_of_sight,
816                    diagnostics,
817                )
818            } else {
819                false
820            }
821            .then_some(enemy)
822        }
823        SingleTargetSelector::Nearest => {
824            let origin = state.actor_transform(source)?;
825            let count = state.spatial_query().actors_by_distance_into(
826                origin.layer,
827                origin.position,
828                None,
829                workspace.candidate_storage(),
830            );
831
832            workspace.set_candidate_count(count);
833
834            (0..workspace.candidate_count()).find_map(|index| {
835                let enemy = workspace.candidate_at(index)?;
836
837                diagnostics.record_broad_phase(1);
838
839                if !metadata_eligible_counted(
840                    context.program,
841                    state,
842                    enemy,
843                    context.filter,
844                    diagnostics,
845                ) {
846                    return None;
847                }
848
849                diagnostics.record_exact_geometry(1);
850
851                visible_counted(
852                    context.program,
853                    state,
854                    source,
855                    enemy,
856                    context.line_of_sight,
857                    diagnostics,
858                )
859                .then_some(enemy)
860            })
861        }
862        SingleTargetSelector::Farthest => {
863            let origin = state.actor_transform(source)?;
864
865            fill_layer_candidates(state, origin.layer, diagnostics, workspace);
866            workspace.retain_candidates(|enemy| {
867                metadata_eligible_counted(
868                    context.program,
869                    state,
870                    enemy,
871                    context.filter,
872                    diagnostics,
873                )
874            });
875
876            diagnostics.record_exact_geometry(workspace.candidate_count());
877
878            workspace
879                .candidates()
880                .iter()
881                .copied()
882                .filter(|enemy| {
883                    visible_counted(
884                        context.program,
885                        state,
886                        source,
887                        *enemy,
888                        context.line_of_sight,
889                        diagnostics,
890                    )
891                })
892                .max_by(|left, right| {
893                    let left_distance = state
894                        .actor_transform(ActorId::Enemy(*left))
895                        .map(|transform| origin.position.distance(transform.position))
896                        .unwrap_or_default();
897                    let right_distance = state
898                        .actor_transform(ActorId::Enemy(*right))
899                        .map(|transform| origin.position.distance(transform.position))
900                        .unwrap_or_default();
901
902                    left_distance
903                        .total_cmp(&right_distance)
904                        .then_with(|| right.cmp(left))
905                })
906        }
907        SingleTargetSelector::LowestHealth => {
908            let origin = state.actor_transform(source)?;
909
910            fill_layer_candidates(state, origin.layer, diagnostics, workspace);
911            workspace.retain_candidates(|enemy| {
912                metadata_eligible_counted(
913                    context.program,
914                    state,
915                    enemy,
916                    context.filter,
917                    diagnostics,
918                )
919            });
920
921            workspace
922                .candidates()
923                .iter()
924                .copied()
925                .filter(|enemy| {
926                    visible_counted(
927                        context.program,
928                        state,
929                        source,
930                        *enemy,
931                        context.line_of_sight,
932                        diagnostics,
933                    )
934                })
935                .min_by(|left, right| {
936                    let left_health = state
937                        .enemy_health_fraction(context.program, *left, context.now)
938                        .unwrap_or(1.0);
939                    let right_health = state
940                        .enemy_health_fraction(context.program, *right, context.now)
941                        .unwrap_or(1.0);
942
943                    left_health
944                        .total_cmp(&right_health)
945                        .then_with(|| left.cmp(right))
946                })
947        }
948    }
949}
950
951fn metadata_eligible_counted(
952    program: &crate::state::CombatProgram,
953    state: &CombatInstance,
954    enemy: EnemyIdx,
955    filter: &CandidateFilter,
956    diagnostics: &mut ResolutionDiagnostics,
957) -> bool {
958    diagnostics.record_metadata_check();
959
960    metadata_eligible(program, state, enemy, filter)
961}
962
963fn metadata_eligible(
964    program: &crate::state::CombatProgram,
965    state: &CombatInstance,
966    enemy: EnemyIdx,
967    filter: &CandidateFilter,
968) -> bool {
969    if !state.is_valid_target(enemy) {
970        return false;
971    }
972
973    program.enemy_definition(enemy).is_some_and(|definition| {
974        filter.matches(
975            definition.group_id(),
976            definition.enemy_tags(),
977            definition.group_tags(),
978        )
979    })
980}
981
982fn visible_counted(
983    program: &crate::state::CombatProgram,
984    state: &CombatInstance,
985    source: ActorId,
986    enemy: EnemyIdx,
987    line_of_sight: LineOfSightPolicy,
988    diagnostics: &mut ResolutionDiagnostics,
989) -> bool {
990    match line_of_sight {
991        LineOfSightPolicy::Required => {
992            diagnostics.record_line_of_sight_check();
993
994            state.has_line_of_sight(program, source, enemy)
995        }
996        LineOfSightPolicy::IgnoreObstructions => state
997            .actor_transform(source)
998            .zip(state.actor_transform(ActorId::Enemy(enemy)))
999            .is_some_and(|(source, target)| source.layer == target.layer),
1000    }
1001}
1002
1003fn append_visible_candidates(
1004    state: &CombatInstance,
1005    source: ActorId,
1006    context: ResolutionContext<'_>,
1007    diagnostics: &mut ResolutionDiagnostics,
1008    workspace: &mut TargetWorkspace,
1009) {
1010    let count = workspace.candidate_count();
1011
1012    for index in 0..count {
1013        let Some(enemy) = workspace.candidate_at(index) else {
1014            continue;
1015        };
1016
1017        if visible_counted(
1018            context.program,
1019            state,
1020            source,
1021            enemy,
1022            context.line_of_sight,
1023            diagnostics,
1024        ) && !workspace.contains_target(enemy)
1025        {
1026            workspace.push_target(enemy);
1027        }
1028    }
1029}
1030
1031fn fill_layer_candidates(
1032    state: &CombatInstance,
1033    layer: SpatialLayerId,
1034    diagnostics: &mut ResolutionDiagnostics,
1035    workspace: &mut TargetWorkspace,
1036) {
1037    let count = state
1038        .spatial_query()
1039        .candidates_on_layer_into(layer, workspace.candidate_storage());
1040
1041    workspace.set_candidate_count(count);
1042    diagnostics.record_broad_phase(count);
1043}
1044
1045fn fill_spatial_candidates(
1046    state: &CombatInstance,
1047    layer: SpatialLayerId,
1048    envelope: SpatialEnvelope,
1049    diagnostics: &mut ResolutionDiagnostics,
1050    workspace: &mut TargetWorkspace,
1051) {
1052    let count = state.spatial_query().candidates_in_envelope_into(
1053        layer,
1054        envelope,
1055        workspace.candidate_storage(),
1056    );
1057
1058    workspace.set_candidate_count(count);
1059    diagnostics.record_broad_phase(count);
1060}
1061
1062#[cfg(test)]
1063mod tests;