Skip to main content

wowlab_engine_combat/targeting/
chain.rs

1//! Deterministic chain-target traversal.
2
3use wowlab_engine_domain::targeting::{ChainTargetOrigin, SingleTargetSelector};
4use wowlab_engine_ports::{RadiusQuery, SpatialEnvelope};
5use wowlab_types::sim::{ActorId, EnemyIdx, GEOMETRY_EPSILON, Position2};
6
7use super::{
8    ChainGeometry, ResolutionContext, ResolutionDiagnostics, filtered_spatial_candidates,
9    select_one, visible_counted,
10};
11use crate::state::CombatState;
12
13pub(super) const fn radius_envelope(center: Position2, radius: f64) -> SpatialEnvelope {
14    SpatialEnvelope {
15        min: Position2 {
16            x: center.x - radius - GEOMETRY_EPSILON,
17            y: center.y - radius - GEOMETRY_EPSILON,
18        },
19        max: Position2 {
20            x: center.x + radius + GEOMETRY_EPSILON,
21            y: center.y + radius + GEOMETRY_EPSILON,
22        },
23    }
24}
25
26pub(super) fn resolve_chain(
27    state: &CombatState,
28    source: ActorId,
29    anchor: SingleTargetSelector,
30    geometry: ChainGeometry,
31    context: ResolutionContext<'_>,
32    diagnostics: &mut ResolutionDiagnostics,
33) -> Vec<EnemyIdx> {
34    if geometry.max_hits == 0 {
35        return Vec::new();
36    }
37
38    let Some(anchor) = select_one(state, source, anchor, context, diagnostics) else {
39        return Vec::new();
40    };
41    let mut targets = vec![anchor];
42
43    while targets.len() < usize::from(geometry.max_hits) {
44        let origin = match geometry.origin {
45            ChainTargetOrigin::PreviousTarget => {
46                let last = *targets.last().expect("chain always has an anchor");
47
48                state.actor_transform(ActorId::Enemy(last))
49            }
50            ChainTargetOrigin::Source => state.actor_transform(source),
51        };
52        let Some(origin) = origin else {
53            break;
54        };
55        let allowed = filtered_spatial_candidates(
56            state,
57            origin.layer,
58            radius_envelope(origin.position, geometry.jump_radius),
59            context.filter,
60            diagnostics,
61        );
62        let allowed: Vec<_> = allowed
63            .into_iter()
64            .filter(|enemy| !targets.contains(enemy))
65            .collect();
66
67        diagnostics.record_exact_geometry(allowed.len());
68        let next = state
69            .spatial_query()
70            .actors_in_radius(
71                RadiusQuery {
72                    layer: origin.layer,
73                    center: origin.position,
74                    radius: geometry.jump_radius,
75                },
76                Some(&allowed),
77            )
78            .into_iter()
79            .filter(|enemy| {
80                visible_counted(state, source, *enemy, context.line_of_sight, diagnostics)
81            })
82            .min_by(|left, right| {
83                let left_distance = state
84                    .actor_transform(ActorId::Enemy(*left))
85                    .map(|transform| origin.position.distance(transform.position))
86                    .unwrap_or_default();
87                let right_distance = state
88                    .actor_transform(ActorId::Enemy(*right))
89                    .map(|transform| origin.position.distance(transform.position))
90                    .unwrap_or_default();
91
92                left_distance
93                    .total_cmp(&right_distance)
94                    .then_with(|| left.cmp(right))
95            });
96        let Some(next) = next else {
97            break;
98        };
99
100        targets.push(next);
101    }
102
103    targets
104}