Skip to main content

subtr_actor/stats/calculators/
positioning.rs

1use super::*;
2
3const DEFAULT_LEVEL_BALL_DEPTH_MARGIN: f32 = 150.0;
4const DEFAULT_CLOSEST_TO_BALL_SWITCH_MARGIN: f32 = 100.0;
5const DEFAULT_CLOSEST_TO_BALL_SWITCH_MIN_SECONDS: f32 = 0.2;
6const DEFAULT_SHADOW_DEFENSE_MAX_BALL_Y: f32 = 1024.0;
7const DEFAULT_SHADOW_DEFENSE_MIN_GOAL_SIDE_Y: f32 = 250.0;
8const DEFAULT_SHADOW_DEFENSE_MIN_GAP: f32 = 700.0;
9const DEFAULT_SHADOW_DEFENSE_MAX_GAP: f32 = 3200.0;
10const DEFAULT_SHADOW_DEFENSE_MAX_LATERAL_GAP: f32 = 1300.0;
11const DEFAULT_SHADOW_DEFENSE_MIN_RETREAT_SPEED: f32 = 100.0;
12const DEFAULT_SHADOW_DEFENSE_MAX_SPEED_DELTA: f32 = 900.0;
13const SHADOW_DEFENSE_OWN_GOAL_Y: f32 = 4240.0;
14
15/// Whether the player is participating in the frame: on the field being
16/// tracked, or waiting out a demolition.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
18#[serde(rename_all = "snake_case")]
19#[ts(export)]
20pub enum ActivityState {
21    Tracked,
22    Demolished,
23}
24
25/// Which third of the field (relative to the player's own goal) the player
26/// occupies.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
28#[serde(rename_all = "snake_case")]
29#[ts(export)]
30pub enum FieldThirdState {
31    Defensive,
32    Neutral,
33    Offensive,
34}
35
36/// Which half of the field (relative to the player's own goal) the player
37/// occupies.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
39#[serde(rename_all = "snake_case")]
40#[ts(export)]
41pub enum FieldHalfState {
42    Defensive,
43    Offensive,
44}
45
46/// The player's depth relative to the ball along the attacking axis.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
48#[serde(rename_all = "snake_case")]
49#[ts(export)]
50pub enum BallDepthState {
51    BehindBall,
52    LevelWithBall,
53    AheadOfBall,
54}
55
56/// The player's depth-ordered role within their team (most back / most
57/// forward / mid by normalized field depth).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
59#[serde(rename_all = "snake_case")]
60#[ts(export)]
61pub enum DepthRoleState {
62    NoTeammates,
63    MostBack,
64    MostForward,
65    Mid,
66    Other,
67}
68
69/// Ball-distance designations the player currently holds. These are not
70/// mutually exclusive (the absolute closest player is usually also their
71/// team's closest), so they ride one event as flags rather than separate
72/// per-designation streams.
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
74#[ts(export)]
75pub struct BallProximityState {
76    pub closest_to_ball_team: bool,
77    pub closest_to_ball_absolute: bool,
78    pub farthest_from_ball: bool,
79}
80
81impl BallProximityState {
82    fn any(self) -> bool {
83        self.closest_to_ball_team || self.closest_to_ball_absolute || self.farthest_from_ball
84    }
85}
86
87pub type PlayerActivityEvent = PlayerStateSpan<ActivityState>;
88pub type FieldThirdEvent = PlayerStateSpan<FieldThirdState>;
89pub type FieldHalfEvent = PlayerStateSpan<FieldHalfState>;
90pub type BallDepthEvent = PlayerStateSpan<BallDepthState>;
91pub type DepthRoleEvent = PlayerStateSpan<DepthRoleState>;
92pub type BallProximityEvent = PlayerStateSpan<BallProximityState>;
93
94/// Whether a defender is shadowing an opponent-controlled attack toward their
95/// own net: retreating goal-side of the threat while staying close enough to
96/// challenge or save.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
98#[serde(rename_all = "snake_case")]
99#[ts(export)]
100pub enum ShadowDefenseState {
101    Shadowing,
102}
103
104pub type ShadowDefenseEvent = PlayerStateSpan<ShadowDefenseState>;
105
106/// Possession context used in positioning classification.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
108#[serde(rename_all = "snake_case")]
109#[ts(export)]
110pub enum PositioningPossessionState {
111    HasPossession,
112    NoPossession,
113    #[default]
114    Neutral,
115}
116
117/// Cumulative per-player continuous totals.
118///
119/// Distance to the ball/teammates is a continuous magnitude, not a discrete occurrence, so
120/// it cannot be reconstructed from an event stream; the calculator accumulates these running
121/// totals as it processes frames and the timeline ships them directly. Possession is an input
122/// to positioning rather than a positioning facet of its own, so the possession-split distance
123/// sums and the possession *times* that denominate them both ride this continuous channel
124/// rather than being emitted as positioning events.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
126#[ts(export)]
127pub struct PositioningSignalSnapshot {
128    pub sum_distance_to_teammates: f32,
129    pub sum_distance_to_ball: f32,
130    pub sum_distance_to_ball_has_possession: f32,
131    pub time_has_possession: f32,
132    pub sum_distance_to_ball_no_possession: f32,
133    pub time_no_possession: f32,
134}
135
136impl PositioningSignalSnapshot {
137    fn accumulate(&mut self, facets: &PlayerFrameFacets, dt: f32) {
138        if let Some(distance) = facets.distance_to_teammates {
139            self.sum_distance_to_teammates += distance * dt;
140        }
141        let distance = facets.distance_to_ball;
142        if let Some(distance) = distance {
143            self.sum_distance_to_ball += distance * dt;
144        }
145        match facets.possession_state {
146            PositioningPossessionState::HasPossession => {
147                self.time_has_possession += dt;
148                if let Some(distance) = distance {
149                    self.sum_distance_to_ball_has_possession += distance * dt;
150                }
151            }
152            PositioningPossessionState::NoPossession => {
153                self.time_no_possession += dt;
154                if let Some(distance) = distance {
155                    self.sum_distance_to_ball_no_possession += distance * dt;
156                }
157            }
158            PositioningPossessionState::Neutral => {}
159        }
160    }
161}
162
163/// Configuration thresholds for positioning classification.
164#[derive(Debug, Clone)]
165pub struct PositioningCalculatorConfig {
166    pub most_back_forward_threshold_y: f32,
167    pub level_ball_depth_margin: f32,
168    pub closest_to_ball_switch_margin: f32,
169    pub closest_to_ball_switch_min_seconds: f32,
170    pub shadow_defense_max_ball_y: f32,
171    pub shadow_defense_min_goal_side_y: f32,
172    pub shadow_defense_min_gap: f32,
173    pub shadow_defense_max_gap: f32,
174    pub shadow_defense_max_lateral_gap: f32,
175    pub shadow_defense_min_retreat_speed: f32,
176    pub shadow_defense_max_speed_delta: f32,
177}
178
179impl Default for PositioningCalculatorConfig {
180    fn default() -> Self {
181        Self {
182            most_back_forward_threshold_y: DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y,
183            level_ball_depth_margin: DEFAULT_LEVEL_BALL_DEPTH_MARGIN,
184            closest_to_ball_switch_margin: DEFAULT_CLOSEST_TO_BALL_SWITCH_MARGIN,
185            closest_to_ball_switch_min_seconds: DEFAULT_CLOSEST_TO_BALL_SWITCH_MIN_SECONDS,
186            shadow_defense_max_ball_y: DEFAULT_SHADOW_DEFENSE_MAX_BALL_Y,
187            shadow_defense_min_goal_side_y: DEFAULT_SHADOW_DEFENSE_MIN_GOAL_SIDE_Y,
188            shadow_defense_min_gap: DEFAULT_SHADOW_DEFENSE_MIN_GAP,
189            shadow_defense_max_gap: DEFAULT_SHADOW_DEFENSE_MAX_GAP,
190            shadow_defense_max_lateral_gap: DEFAULT_SHADOW_DEFENSE_MAX_LATERAL_GAP,
191            shadow_defense_min_retreat_speed: DEFAULT_SHADOW_DEFENSE_MIN_RETREAT_SPEED,
192            shadow_defense_max_speed_delta: DEFAULT_SHADOW_DEFENSE_MAX_SPEED_DELTA,
193        }
194    }
195}
196
197#[derive(Debug, Clone)]
198struct ClosestToBallCandidate {
199    player_id: PlayerId,
200    distance: f32,
201}
202
203impl ClosestToBallCandidate {
204    fn from_player(player: &PlayerSample, position: glam::Vec3, ball_position: glam::Vec3) -> Self {
205        Self {
206            player_id: player.player_id.clone(),
207            distance: position.distance(ball_position),
208        }
209    }
210}
211
212#[derive(Debug, Clone, Default)]
213struct ClosestToBallDebouncer {
214    current_player: Option<PlayerId>,
215    pending_player: Option<PlayerId>,
216    pending_seconds: f32,
217}
218
219impl ClosestToBallDebouncer {
220    fn select(
221        &mut self,
222        candidates: &[ClosestToBallCandidate],
223        dt: f32,
224        switch_margin: f32,
225        switch_min_seconds: f32,
226    ) -> Option<ClosestToBallCandidate> {
227        let raw_closest = candidates
228            .iter()
229            .min_by(|left, right| left.distance.partial_cmp(&right.distance).unwrap())?;
230        let Some(current_player) = self.current_player.as_ref() else {
231            self.current_player = Some(raw_closest.player_id.clone());
232            self.pending_player = None;
233            self.pending_seconds = 0.0;
234            return Some(raw_closest.clone());
235        };
236        let Some(current) = candidates
237            .iter()
238            .find(|candidate| candidate.player_id == *current_player)
239        else {
240            self.current_player = Some(raw_closest.player_id.clone());
241            self.pending_player = None;
242            self.pending_seconds = 0.0;
243            return Some(raw_closest.clone());
244        };
245        if raw_closest.player_id == current.player_id {
246            self.pending_player = None;
247            self.pending_seconds = 0.0;
248            return Some(current.clone());
249        }
250        if raw_closest.distance + switch_margin >= current.distance {
251            self.pending_player = None;
252            self.pending_seconds = 0.0;
253            return Some(current.clone());
254        }
255        if self.pending_player.as_ref() == Some(&raw_closest.player_id) {
256            self.pending_seconds += dt;
257        } else {
258            self.pending_player = Some(raw_closest.player_id.clone());
259            self.pending_seconds = dt;
260        }
261        if self.pending_seconds >= switch_min_seconds.max(0.0) {
262            self.current_player = Some(raw_closest.player_id.clone());
263            self.pending_player = None;
264            self.pending_seconds = 0.0;
265            Some(raw_closest.clone())
266        } else {
267            Some(current.clone())
268        }
269    }
270
271    fn clear(&mut self) {
272        self.current_player = None;
273        self.pending_player = None;
274        self.pending_seconds = 0.0;
275    }
276}
277
278/// Everything one frame contributes for one player, gathered before any spans
279/// are recorded so emission can run in a deterministic player order.
280#[derive(Debug, Clone, Default)]
281struct PlayerFrameFacets {
282    player_position: Option<[f32; 3]>,
283    is_team_0: bool,
284    activity: Option<ActivityState>,
285    field_third_segments: Vec<(FieldThirdState, f32)>,
286    field_half_segments: Vec<(FieldHalfState, f32)>,
287    ball_depth_segments: Vec<(BallDepthState, f32)>,
288    depth_role: Option<DepthRoleState>,
289    proximity: BallProximityState,
290    shadow_defense: Option<ShadowDefenseState>,
291    distance_to_ball: Option<f32>,
292    distance_to_teammates: Option<f32>,
293    possession_state: PositioningPossessionState,
294}
295
296/// Tracks per-player field positioning over time.
297#[derive(Debug, Clone, Default)]
298pub struct PositioningCalculator {
299    config: PositioningCalculatorConfig,
300    previous_ball_position: Option<glam::Vec3>,
301    previous_player_positions: HashMap<PlayerId, glam::Vec3>,
302    absolute_closest_to_ball: ClosestToBallDebouncer,
303    team_zero_closest_to_ball: ClosestToBallDebouncer,
304    team_one_closest_to_ball: ClosestToBallDebouncer,
305    activity: PlayerSpanTracker<ActivityState>,
306    field_third: PlayerSpanTracker<FieldThirdState>,
307    field_half: PlayerSpanTracker<FieldHalfState>,
308    ball_depth: PlayerSpanTracker<BallDepthState>,
309    depth_role: PlayerSpanTracker<DepthRoleState>,
310    ball_proximity: PlayerSpanTracker<BallProximityState>,
311    shadow_defense: PlayerSpanTracker<ShadowDefenseState>,
312    signal: HashMap<PlayerId, PositioningSignalSnapshot>,
313}
314
315impl PositioningCalculator {
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    pub fn with_config(config: PositioningCalculatorConfig) -> Self {
321        Self {
322            config,
323            ..Self::default()
324        }
325    }
326
327    pub fn config(&self) -> &PositioningCalculatorConfig {
328        &self.config
329    }
330
331    pub fn activity_events(&self) -> Vec<PlayerActivityEvent> {
332        self.activity.projected_events()
333    }
334
335    pub fn field_third_events(&self) -> Vec<FieldThirdEvent> {
336        self.field_third.projected_events()
337    }
338
339    pub fn field_half_events(&self) -> Vec<FieldHalfEvent> {
340        self.field_half.projected_events()
341    }
342
343    pub fn ball_depth_events(&self) -> Vec<BallDepthEvent> {
344        self.ball_depth.projected_events()
345    }
346
347    pub fn depth_role_events(&self) -> Vec<DepthRoleEvent> {
348        self.depth_role.projected_events()
349    }
350
351    pub fn ball_proximity_events(&self) -> Vec<BallProximityEvent> {
352        self.ball_proximity.projected_events()
353    }
354
355    pub fn shadow_defense_events(&self) -> Vec<ShadowDefenseEvent> {
356        self.shadow_defense.projected_events()
357    }
358
359    /// Players with a span (any facet) closed during the current frame's update.
360    pub fn new_event_players(&self) -> Vec<PlayerId> {
361        let mut players: Vec<PlayerId> = self
362            .activity
363            .new_events()
364            .iter()
365            .map(|span| span.player.clone())
366            .chain(
367                self.field_third
368                    .new_events()
369                    .iter()
370                    .map(|span| span.player.clone()),
371            )
372            .chain(
373                self.field_half
374                    .new_events()
375                    .iter()
376                    .map(|span| span.player.clone()),
377            )
378            .chain(
379                self.ball_depth
380                    .new_events()
381                    .iter()
382                    .map(|span| span.player.clone()),
383            )
384            .chain(
385                self.depth_role
386                    .new_events()
387                    .iter()
388                    .map(|span| span.player.clone()),
389            )
390            .chain(
391                self.ball_proximity
392                    .new_events()
393                    .iter()
394                    .map(|span| span.player.clone()),
395            )
396            .chain(
397                self.shadow_defense
398                    .new_events()
399                    .iter()
400                    .map(|span| span.player.clone()),
401            )
402            .collect();
403        players.dedup();
404        players
405    }
406
407    /// Close every open span so the projected event streams are final.
408    pub fn flush_pending_events(&mut self) {
409        self.activity.close_all();
410        self.field_third.close_all();
411        self.field_half.close_all();
412        self.ball_depth.close_all();
413        self.depth_role.close_all();
414        self.ball_proximity.close_all();
415        self.shadow_defense.close_all();
416    }
417
418    fn close_all_spans(&mut self) {
419        self.flush_pending_events();
420    }
421
422    fn store_previous_positions(
423        &mut self,
424        ball_position: Option<glam::Vec3>,
425        players: &PlayerFrameState,
426    ) {
427        if let Some(ball_position) = ball_position {
428            self.previous_ball_position = Some(ball_position);
429        }
430        for player in &players.players {
431            if let Some(position) = player.position() {
432                self.previous_player_positions
433                    .insert(player.player_id.clone(), position);
434            }
435        }
436    }
437
438    #[allow(clippy::too_many_arguments)]
439    fn shadow_defense_state(
440        &self,
441        frame: &FrameInfo,
442        defender_position: glam::Vec3,
443        defender_previous_position: glam::Vec3,
444        ball_position: glam::Vec3,
445        previous_ball_position: glam::Vec3,
446        attacker_position: glam::Vec3,
447        attacker_previous_position: glam::Vec3,
448        defender_is_team_0: bool,
449    ) -> Option<ShadowDefenseState> {
450        if frame.dt <= 0.0 {
451            return None;
452        }
453
454        let defender = normalized_xy(defender_is_team_0, defender_position);
455        let previous_defender = normalized_xy(defender_is_team_0, defender_previous_position);
456        let ball = normalized_xy(defender_is_team_0, ball_position);
457        let previous_ball = normalized_xy(defender_is_team_0, previous_ball_position);
458        let attacker = normalized_xy(defender_is_team_0, attacker_position);
459        let previous_attacker = normalized_xy(defender_is_team_0, attacker_previous_position);
460
461        if ball.y > self.config.shadow_defense_max_ball_y {
462            return None;
463        }
464
465        let deepest_threat_y = ball.y.min(attacker.y);
466        if defender.y + self.config.shadow_defense_min_goal_side_y >= deepest_threat_y {
467            return None;
468        }
469
470        let threat = if defender.distance(ball) <= defender.distance(attacker) {
471            ball
472        } else {
473            attacker
474        };
475        let gap = defender.distance(threat);
476        if !(self.config.shadow_defense_min_gap..=self.config.shadow_defense_max_gap).contains(&gap)
477        {
478            return None;
479        }
480
481        let own_goal = glam::vec2(0.0, -SHADOW_DEFENSE_OWN_GOAL_Y);
482        if distance_to_segment_2d(defender, ball, own_goal)
483            > self.config.shadow_defense_max_lateral_gap
484        {
485            return None;
486        }
487
488        let defender_velocity_y = (defender.y - previous_defender.y) / frame.dt;
489        let ball_velocity_y = (ball.y - previous_ball.y) / frame.dt;
490        let attacker_velocity_y = (attacker.y - previous_attacker.y) / frame.dt;
491        let threat_velocity_y = ball_velocity_y.min(attacker_velocity_y);
492        if defender_velocity_y > -self.config.shadow_defense_min_retreat_speed {
493            return None;
494        }
495        if threat_velocity_y > -self.config.shadow_defense_min_retreat_speed {
496            return None;
497        }
498        if (defender_velocity_y - threat_velocity_y).abs()
499            > self.config.shadow_defense_max_speed_delta
500        {
501            return None;
502        }
503
504        Some(ShadowDefenseState::Shadowing)
505    }
506
507    #[allow(clippy::too_many_arguments)]
508    fn process_sample(
509        &mut self,
510        frame: &FrameInfo,
511        gameplay: &GameplayState,
512        ball: &BallFrameState,
513        players: &PlayerFrameState,
514        events: &FrameEventsState,
515        live_play_state: &LivePlayState,
516        possession_player_before_sample: Option<&PlayerId>,
517    ) -> SubtrActorResult<()> {
518        let live_play = live_play_state.is_live_play;
519        if frame.dt == 0.0 {
520            self.store_previous_positions(ball.sample().map(|ball| ball.position()), players);
521            return Ok(());
522        }
523
524        let Some(ball) = ball.sample() else {
525            self.close_all_spans();
526            return Ok(());
527        };
528        let ball_position = ball.position();
529
530        if !live_play {
531            self.absolute_closest_to_ball.clear();
532            self.team_zero_closest_to_ball.clear();
533            self.team_one_closest_to_ball.clear();
534            self.close_all_spans();
535            self.store_previous_positions(Some(ball_position), players);
536            return Ok(());
537        }
538
539        let mut facets: HashMap<PlayerId, PlayerFrameFacets> = HashMap::new();
540        let demoed_players: HashSet<_> = events
541            .active_demos
542            .iter()
543            .map(|demo| demo.victim.clone())
544            .collect();
545
546        for player in &players.players {
547            let is_demoed = demoed_players.contains(&player.player_id);
548            let entry = facets.entry(player.player_id.clone()).or_default();
549            entry.is_team_0 = player.is_team_0;
550            entry.player_position = player.position().map(|position| position.to_array());
551
552            if is_demoed {
553                entry.activity = Some(ActivityState::Demolished);
554                continue;
555            }
556
557            let Some(position) = player.position() else {
558                continue;
559            };
560            let previous_position = self
561                .previous_player_positions
562                .get(&player.player_id)
563                .copied()
564                .unwrap_or(position);
565            let previous_ball_position = self.previous_ball_position.unwrap_or(ball_position);
566            let normalized_position_y = normalized_y(player.is_team_0, position);
567            let normalized_previous_position_y = normalized_y(player.is_team_0, previous_position);
568            let normalized_ball_y = normalized_y(player.is_team_0, ball_position);
569            let normalized_previous_ball_y = normalized_y(player.is_team_0, previous_ball_position);
570
571            entry.activity = Some(ActivityState::Tracked);
572            entry.distance_to_ball = Some(position.distance(ball_position));
573
574            if possession_player_before_sample == Some(&player.player_id) {
575                entry.possession_state = PositioningPossessionState::HasPossession;
576            } else if possession_player_before_sample.is_some() {
577                entry.possession_state = PositioningPossessionState::NoPossession;
578            }
579
580            entry.field_third_segments = scalar_state_segments(
581                normalized_previous_position_y,
582                normalized_position_y,
583                &[-FIELD_ZONE_BOUNDARY_Y, FIELD_ZONE_BOUNDARY_Y],
584                &[
585                    FieldThirdState::Defensive,
586                    FieldThirdState::Neutral,
587                    FieldThirdState::Offensive,
588                ],
589            );
590            entry.field_half_segments = scalar_state_segments(
591                normalized_previous_position_y,
592                normalized_position_y,
593                &[0.0],
594                &[FieldHalfState::Defensive, FieldHalfState::Offensive],
595            );
596            entry.ball_depth_segments = scalar_state_segments(
597                normalized_previous_position_y - normalized_previous_ball_y,
598                normalized_position_y - normalized_ball_y,
599                &[
600                    -self.config.level_ball_depth_margin,
601                    self.config.level_ball_depth_margin,
602                ],
603                &[
604                    BallDepthState::BehindBall,
605                    BallDepthState::LevelWithBall,
606                    BallDepthState::AheadOfBall,
607                ],
608            );
609
610            if let Some(attacker_id) = possession_player_before_sample
611                && let Some(attacker) = players.player(attacker_id)
612                && attacker.is_team_0 != player.is_team_0
613                && let Some(attacker_position) = attacker.position()
614            {
615                let attacker_previous_position = self
616                    .previous_player_positions
617                    .get(&attacker.player_id)
618                    .copied()
619                    .unwrap_or(attacker_position);
620                entry.shadow_defense = self.shadow_defense_state(
621                    frame,
622                    position,
623                    previous_position,
624                    ball_position,
625                    previous_ball_position,
626                    attacker_position,
627                    attacker_previous_position,
628                    player.is_team_0,
629                );
630            }
631        }
632
633        let positioned_players: Vec<_> = players
634            .players
635            .iter()
636            .filter(|player| !demoed_players.contains(&player.player_id))
637            .filter_map(|player| {
638                player.position().map(|position| {
639                    ClosestToBallCandidate::from_player(player, position, ball_position)
640                })
641            })
642            .collect();
643        if let Some(closest_player) = self.absolute_closest_to_ball.select(
644            &positioned_players,
645            frame.dt,
646            self.config.closest_to_ball_switch_margin,
647            self.config.closest_to_ball_switch_min_seconds,
648        ) {
649            if let Some(entry) = facets.get_mut(&closest_player.player_id) {
650                entry.proximity.closest_to_ball_absolute = true;
651            }
652        } else {
653            self.absolute_closest_to_ball.clear();
654        }
655
656        for is_team_0 in [true, false] {
657            let team_present_player_count = players
658                .players
659                .iter()
660                .filter(|player| player.is_team_0 == is_team_0)
661                .count();
662            let team_roster_count = gameplay
663                .current_in_game_team_player_count(is_team_0)
664                .max(team_present_player_count);
665            let team_players: Vec<_> = players
666                .players
667                .iter()
668                .filter(|player| player.is_team_0 == is_team_0)
669                .filter(|player| !demoed_players.contains(&player.player_id))
670                .filter_map(|player| player.position().map(|position| (player, position)))
671                .collect();
672
673            if team_players.is_empty() {
674                if is_team_0 {
675                    self.team_zero_closest_to_ball.clear();
676                } else {
677                    self.team_one_closest_to_ball.clear();
678                }
679                continue;
680            }
681
682            for (player, position) in &team_players {
683                let teammate_distance_sum: f32 = team_players
684                    .iter()
685                    .filter(|(other_player, _)| other_player.player_id != player.player_id)
686                    .map(|(_, other_position)| position.distance(*other_position))
687                    .sum();
688                let teammate_count = team_players.len().saturating_sub(1);
689                if teammate_count > 0 {
690                    if let Some(entry) = facets.get_mut(&player.player_id) {
691                        entry.distance_to_teammates =
692                            Some(teammate_distance_sum / teammate_count as f32);
693                    }
694                }
695            }
696
697            if team_roster_count < 2
698                || team_present_player_count < team_roster_count
699                || team_players.len() < 2
700            {
701                for (player, _) in &team_players {
702                    if let Some(entry) = facets.get_mut(&player.player_id) {
703                        entry.depth_role = Some(DepthRoleState::NoTeammates);
704                    }
705                }
706            } else {
707                let mut sorted_team: Vec<_> = team_players
708                    .iter()
709                    .map(|(info, pos)| (info.player_id.clone(), normalized_y(is_team_0, *pos)))
710                    .collect();
711                sorted_team.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
712
713                let team_spread = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0)
714                    - sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
715
716                if team_spread <= self.config.most_back_forward_threshold_y {
717                    for (player_id, _) in &sorted_team {
718                        if let Some(entry) = facets.get_mut(player_id) {
719                            entry.depth_role = Some(DepthRoleState::Other);
720                        }
721                    }
722                } else {
723                    let min_y = sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
724                    let max_y = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0);
725                    let can_assign_mid_role = sorted_team.len() == 3;
726
727                    for (player_id, y) in &sorted_team {
728                        let near_back = (*y - min_y) <= self.config.most_back_forward_threshold_y;
729                        let near_front = (max_y - *y) <= self.config.most_back_forward_threshold_y;
730                        let role = if near_back && !near_front {
731                            DepthRoleState::MostBack
732                        } else if near_front && !near_back {
733                            DepthRoleState::MostForward
734                        } else if can_assign_mid_role {
735                            DepthRoleState::Mid
736                        } else {
737                            DepthRoleState::Other
738                        };
739                        if let Some(entry) = facets.get_mut(player_id) {
740                            entry.depth_role = Some(role);
741                        }
742                    }
743                }
744            }
745
746            let team_candidates: Vec<_> = team_players
747                .iter()
748                .map(|(player, position)| {
749                    ClosestToBallCandidate::from_player(player, *position, ball_position)
750                })
751                .collect();
752            let team_debouncer = if is_team_0 {
753                &mut self.team_zero_closest_to_ball
754            } else {
755                &mut self.team_one_closest_to_ball
756            };
757            if let Some(closest_player) = team_debouncer.select(
758                &team_candidates,
759                frame.dt,
760                self.config.closest_to_ball_switch_margin,
761                self.config.closest_to_ball_switch_min_seconds,
762            ) {
763                if let Some(entry) = facets.get_mut(&closest_player.player_id) {
764                    entry.proximity.closest_to_ball_team = true;
765                }
766            }
767
768            if let Some((farthest_player, _)) = team_players.iter().max_by(|(_, a), (_, b)| {
769                a.distance(ball_position)
770                    .partial_cmp(&b.distance(ball_position))
771                    .unwrap()
772            }) {
773                if let Some(entry) = facets.get_mut(&farthest_player.player_id) {
774                    entry.proximity.farthest_from_ball = true;
775                }
776            }
777        }
778
779        self.record_frame_facets(frame, &facets);
780        self.store_previous_positions(Some(ball_position), players);
781
782        Ok(())
783    }
784
785    /// Emit span contributions for the frame in a deterministic player order,
786    /// closing facets that stopped applying so spans never bridge gaps.
787    fn record_frame_facets(
788        &mut self,
789        frame: &FrameInfo,
790        facets: &HashMap<PlayerId, PlayerFrameFacets>,
791    ) {
792        let mut players: Vec<_> = facets.iter().collect();
793        players.sort_by_key(|(player, _)| format!("{player:?}"));
794
795        let frame_start = frame.time - frame.dt;
796        for (player, entry) in players {
797            let tracked = entry.activity == Some(ActivityState::Tracked);
798            if tracked {
799                self.signal
800                    .entry(player.clone())
801                    .or_default()
802                    .accumulate(entry, frame.dt);
803            }
804
805            match entry.activity {
806                Some(state) => self.activity.record(
807                    frame.frame_number,
808                    frame_start,
809                    frame.time,
810                    frame.dt,
811                    player,
812                    entry.player_position,
813                    entry.is_team_0,
814                    state,
815                ),
816                None => self.activity.close(player),
817            }
818
819            record_segments(
820                &mut self.field_third,
821                frame,
822                player,
823                entry,
824                &entry.field_third_segments,
825            );
826            record_segments(
827                &mut self.field_half,
828                frame,
829                player,
830                entry,
831                &entry.field_half_segments,
832            );
833            record_segments(
834                &mut self.ball_depth,
835                frame,
836                player,
837                entry,
838                &entry.ball_depth_segments,
839            );
840
841            match entry.depth_role {
842                Some(role) if tracked => self.depth_role.record(
843                    frame.frame_number,
844                    frame_start,
845                    frame.time,
846                    frame.dt,
847                    player,
848                    entry.player_position,
849                    entry.is_team_0,
850                    role,
851                ),
852                _ => self.depth_role.close(player),
853            }
854
855            if tracked && entry.proximity.any() {
856                self.ball_proximity.record(
857                    frame.frame_number,
858                    frame_start,
859                    frame.time,
860                    frame.dt,
861                    player,
862                    entry.player_position,
863                    entry.is_team_0,
864                    entry.proximity,
865                );
866            } else {
867                self.ball_proximity.close(player);
868            }
869
870            match entry.shadow_defense {
871                Some(state) if tracked => self.shadow_defense.record(
872                    frame.frame_number,
873                    frame_start,
874                    frame.time,
875                    frame.dt,
876                    player,
877                    entry.player_position,
878                    entry.is_team_0,
879                    state,
880                ),
881                _ => self.shadow_defense.close(player),
882            }
883        }
884    }
885
886    #[allow(clippy::too_many_arguments)]
887    pub fn update(
888        &mut self,
889        frame: &FrameInfo,
890        gameplay: &GameplayState,
891        ball: &BallFrameState,
892        players: &PlayerFrameState,
893        events: &FrameEventsState,
894        live_play_state: &LivePlayState,
895        possession_player_before_sample: Option<&PlayerId>,
896    ) -> SubtrActorResult<()> {
897        self.activity.begin_update();
898        self.field_third.begin_update();
899        self.field_half.begin_update();
900        self.ball_depth.begin_update();
901        self.depth_role.begin_update();
902        self.ball_proximity.begin_update();
903        self.shadow_defense.begin_update();
904        self.process_sample(
905            frame,
906            gameplay,
907            ball,
908            players,
909            events,
910            live_play_state,
911            possession_player_before_sample,
912        )
913    }
914
915    /// Cumulative distance/possession signal for `player` through the frames processed so
916    /// far. Continuous distance is shipped as this per-frame snapshot rather than as events.
917    pub fn player_signal(&self, player: &PlayerId) -> PositioningSignalSnapshot {
918        self.signal.get(player).copied().unwrap_or_default()
919    }
920
921    pub fn signals(&self) -> &HashMap<PlayerId, PositioningSignalSnapshot> {
922        &self.signal
923    }
924}
925
926fn normalized_xy(is_team_0: bool, position: glam::Vec3) -> glam::Vec2 {
927    glam::vec2(position.x, normalized_y(is_team_0, position))
928}
929
930fn distance_to_segment_2d(point: glam::Vec2, start: glam::Vec2, end: glam::Vec2) -> f32 {
931    let segment = end - start;
932    let length_squared = segment.length_squared();
933    if length_squared <= f32::EPSILON {
934        return point.distance(start);
935    }
936    let t = ((point - start).dot(segment) / length_squared).clamp(0.0, 1.0);
937    point.distance(start + segment * t)
938}
939
940/// Record a frame's ordered sub-frame segments for one fraction-derived facet,
941/// or close the player's span when the facet does not apply this frame.
942fn record_segments<S: Copy + PartialEq>(
943    tracker: &mut PlayerSpanTracker<S>,
944    frame: &FrameInfo,
945    player: &PlayerId,
946    entry: &PlayerFrameFacets,
947    segments: &[(S, f32)],
948) {
949    if segments.is_empty() {
950        tracker.close(player);
951        return;
952    }
953    let frame_start = frame.time - frame.dt;
954    let mut cumulative = 0.0f32;
955    for (state, fraction) in segments {
956        let start_time = frame_start + cumulative * frame.dt;
957        cumulative += fraction;
958        let end_time = if cumulative >= 1.0 {
959            frame.time
960        } else {
961            frame_start + cumulative * frame.dt
962        };
963        tracker.record(
964            frame.frame_number,
965            start_time,
966            end_time,
967            fraction * frame.dt,
968            player,
969            entry.player_position,
970            entry.is_team_0,
971            *state,
972        );
973    }
974}
975
976#[cfg(test)]
977#[path = "positioning_tests.rs"]
978mod tests;