1use 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)]
115struct 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#[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 #[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#[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
567fn 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
599fn 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
639fn 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
755fn 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;