1use 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#[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 #[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
202pub(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;