Skip to main content

subtr_actor/stats/calculators/
positioning.rs

1use super::*;
2
3const GOAL_CAUGHT_AHEAD_MAX_BALL_Y: f32 = -1200.0;
4const GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y: f32 = -250.0;
5const GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y: f32 = 2200.0;
6const DEFAULT_LEVEL_BALL_DEPTH_MARGIN: f32 = 150.0;
7
8#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
9#[ts(export)]
10pub struct PositioningStats {
11    pub active_game_time: f32,
12    pub tracked_time: f32,
13    pub sum_distance_to_teammates: f32,
14    pub sum_distance_to_ball: f32,
15    pub sum_distance_to_ball_has_possession: f32,
16    pub time_has_possession: f32,
17    pub sum_distance_to_ball_no_possession: f32,
18    pub time_no_possession: f32,
19    pub time_demolished: f32,
20    pub time_no_teammates: f32,
21    pub time_most_back: f32,
22    pub time_most_forward: f32,
23    pub time_mid_role: f32,
24    pub time_other_role: f32,
25    #[serde(rename = "time_defensive_third")]
26    pub time_defensive_zone: f32,
27    #[serde(rename = "time_neutral_third")]
28    pub time_neutral_zone: f32,
29    #[serde(rename = "time_offensive_third")]
30    pub time_offensive_zone: f32,
31    pub time_defensive_half: f32,
32    pub time_offensive_half: f32,
33    pub time_closest_to_ball: f32,
34    pub time_farthest_from_ball: f32,
35    pub time_behind_ball: f32,
36    pub time_level_with_ball: f32,
37    pub time_in_front_of_ball: f32,
38    pub times_caught_ahead_of_play_on_conceded_goals: u32,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
42#[ts(export)]
43pub struct PositioningEvent {
44    pub time: f32,
45    pub frame: usize,
46    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
47    pub player: PlayerId,
48    pub is_team_0: bool,
49    pub active_game_time: f32,
50    pub tracked_time: f32,
51    pub sum_distance_to_teammates: f32,
52    pub sum_distance_to_ball: f32,
53    pub sum_distance_to_ball_has_possession: f32,
54    pub time_has_possession: f32,
55    pub sum_distance_to_ball_no_possession: f32,
56    pub time_no_possession: f32,
57    pub time_demolished: f32,
58    pub time_no_teammates: f32,
59    pub time_most_back: f32,
60    pub time_most_forward: f32,
61    pub time_mid_role: f32,
62    pub time_other_role: f32,
63    #[serde(rename = "time_defensive_third")]
64    pub time_defensive_zone: f32,
65    #[serde(rename = "time_neutral_third")]
66    pub time_neutral_zone: f32,
67    #[serde(rename = "time_offensive_third")]
68    pub time_offensive_zone: f32,
69    pub time_defensive_half: f32,
70    pub time_offensive_half: f32,
71    pub time_closest_to_ball: f32,
72    pub time_farthest_from_ball: f32,
73    pub time_behind_ball: f32,
74    pub time_level_with_ball: f32,
75    pub time_in_front_of_ball: f32,
76    pub times_caught_ahead_of_play_on_conceded_goals: u32,
77}
78
79impl PositioningEvent {
80    fn new(frame: &FrameInfo, player: PlayerId, is_team_0: bool) -> Self {
81        Self {
82            time: frame.time,
83            frame: frame.frame_number,
84            player,
85            is_team_0,
86            active_game_time: 0.0,
87            tracked_time: 0.0,
88            sum_distance_to_teammates: 0.0,
89            sum_distance_to_ball: 0.0,
90            sum_distance_to_ball_has_possession: 0.0,
91            time_has_possession: 0.0,
92            sum_distance_to_ball_no_possession: 0.0,
93            time_no_possession: 0.0,
94            time_demolished: 0.0,
95            time_no_teammates: 0.0,
96            time_most_back: 0.0,
97            time_most_forward: 0.0,
98            time_mid_role: 0.0,
99            time_other_role: 0.0,
100            time_defensive_zone: 0.0,
101            time_neutral_zone: 0.0,
102            time_offensive_zone: 0.0,
103            time_defensive_half: 0.0,
104            time_offensive_half: 0.0,
105            time_closest_to_ball: 0.0,
106            time_farthest_from_ball: 0.0,
107            time_behind_ball: 0.0,
108            time_level_with_ball: 0.0,
109            time_in_front_of_ball: 0.0,
110            times_caught_ahead_of_play_on_conceded_goals: 0,
111        }
112    }
113
114    fn has_delta(&self) -> bool {
115        self.active_game_time != 0.0
116            || self.tracked_time != 0.0
117            || self.sum_distance_to_teammates != 0.0
118            || self.sum_distance_to_ball != 0.0
119            || self.sum_distance_to_ball_has_possession != 0.0
120            || self.time_has_possession != 0.0
121            || self.sum_distance_to_ball_no_possession != 0.0
122            || self.time_no_possession != 0.0
123            || self.time_demolished != 0.0
124            || self.time_no_teammates != 0.0
125            || self.time_most_back != 0.0
126            || self.time_most_forward != 0.0
127            || self.time_mid_role != 0.0
128            || self.time_other_role != 0.0
129            || self.time_defensive_zone != 0.0
130            || self.time_neutral_zone != 0.0
131            || self.time_offensive_zone != 0.0
132            || self.time_defensive_half != 0.0
133            || self.time_offensive_half != 0.0
134            || self.time_closest_to_ball != 0.0
135            || self.time_farthest_from_ball != 0.0
136            || self.time_behind_ball != 0.0
137            || self.time_level_with_ball != 0.0
138            || self.time_in_front_of_ball != 0.0
139            || self.times_caught_ahead_of_play_on_conceded_goals != 0
140    }
141}
142
143impl PositioningStats {
144    pub fn average_distance_to_teammates(&self) -> f32 {
145        if self.tracked_time == 0.0 {
146            0.0
147        } else {
148            self.sum_distance_to_teammates / self.tracked_time
149        }
150    }
151
152    pub fn average_distance_to_ball(&self) -> f32 {
153        if self.tracked_time == 0.0 {
154            0.0
155        } else {
156            self.sum_distance_to_ball / self.tracked_time
157        }
158    }
159
160    pub fn average_distance_to_ball_has_possession(&self) -> f32 {
161        if self.time_has_possession == 0.0 {
162            0.0
163        } else {
164            self.sum_distance_to_ball_has_possession / self.time_has_possession
165        }
166    }
167
168    pub fn average_distance_to_ball_no_possession(&self) -> f32 {
169        if self.time_no_possession == 0.0 {
170            0.0
171        } else {
172            self.sum_distance_to_ball_no_possession / self.time_no_possession
173        }
174    }
175
176    fn pct(&self, value: f32) -> f32 {
177        if self.tracked_time == 0.0 {
178            0.0
179        } else {
180            value * 100.0 / self.tracked_time
181        }
182    }
183
184    pub fn most_back_pct(&self) -> f32 {
185        self.pct(self.time_most_back)
186    }
187
188    pub fn most_forward_pct(&self) -> f32 {
189        self.pct(self.time_most_forward)
190    }
191
192    pub fn mid_role_pct(&self) -> f32 {
193        self.pct(self.time_mid_role)
194    }
195
196    pub fn other_role_pct(&self) -> f32 {
197        self.pct(self.time_other_role)
198    }
199
200    pub fn defensive_third_pct(&self) -> f32 {
201        self.pct(self.time_defensive_zone)
202    }
203
204    pub fn neutral_third_pct(&self) -> f32 {
205        self.pct(self.time_neutral_zone)
206    }
207
208    pub fn offensive_third_pct(&self) -> f32 {
209        self.pct(self.time_offensive_zone)
210    }
211
212    pub fn defensive_zone_pct(&self) -> f32 {
213        self.defensive_third_pct()
214    }
215
216    pub fn neutral_zone_pct(&self) -> f32 {
217        self.neutral_third_pct()
218    }
219
220    pub fn offensive_zone_pct(&self) -> f32 {
221        self.offensive_third_pct()
222    }
223
224    pub fn defensive_half_pct(&self) -> f32 {
225        self.pct(self.time_defensive_half)
226    }
227
228    pub fn offensive_half_pct(&self) -> f32 {
229        self.pct(self.time_offensive_half)
230    }
231
232    pub fn closest_to_ball_pct(&self) -> f32 {
233        self.pct(self.time_closest_to_ball)
234    }
235
236    pub fn farthest_from_ball_pct(&self) -> f32 {
237        self.pct(self.time_farthest_from_ball)
238    }
239
240    pub fn behind_ball_pct(&self) -> f32 {
241        self.pct(self.time_behind_ball)
242    }
243
244    pub fn level_with_ball_pct(&self) -> f32 {
245        self.pct(self.time_level_with_ball)
246    }
247
248    pub fn in_front_of_ball_pct(&self) -> f32 {
249        self.pct(self.time_in_front_of_ball)
250    }
251}
252
253#[derive(Debug, Clone)]
254pub struct PositioningCalculatorConfig {
255    pub most_back_forward_threshold_y: f32,
256    pub level_ball_depth_margin: f32,
257}
258
259impl Default for PositioningCalculatorConfig {
260    fn default() -> Self {
261        Self {
262            most_back_forward_threshold_y: DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y,
263            level_ball_depth_margin: DEFAULT_LEVEL_BALL_DEPTH_MARGIN,
264        }
265    }
266}
267
268#[derive(Debug, Clone, Default)]
269pub struct PositioningCalculator {
270    config: PositioningCalculatorConfig,
271    player_stats: HashMap<PlayerId, PositioningStats>,
272    previous_ball_position: Option<glam::Vec3>,
273    previous_player_positions: HashMap<PlayerId, glam::Vec3>,
274    events: Vec<PositioningEvent>,
275}
276
277impl PositioningCalculator {
278    pub fn new() -> Self {
279        Self::default()
280    }
281
282    pub fn with_config(config: PositioningCalculatorConfig) -> Self {
283        Self {
284            config,
285            ..Self::default()
286        }
287    }
288
289    pub fn config(&self) -> &PositioningCalculatorConfig {
290        &self.config
291    }
292
293    pub fn player_stats(&self) -> &HashMap<PlayerId, PositioningStats> {
294        &self.player_stats
295    }
296
297    pub fn events(&self) -> &[PositioningEvent] {
298        &self.events
299    }
300
301    fn event_delta<'a>(
302        deltas: &'a mut HashMap<PlayerId, PositioningEvent>,
303        frame: &FrameInfo,
304        player_id: &PlayerId,
305        is_team_0: bool,
306    ) -> &'a mut PositioningEvent {
307        deltas
308            .entry(player_id.clone())
309            .or_insert_with(|| PositioningEvent::new(frame, player_id.clone(), is_team_0))
310    }
311
312    fn record_goal_positioning_events(
313        &mut self,
314        frame: &FrameInfo,
315        players: &PlayerFrameState,
316        events: &FrameEventsState,
317        ball_position: glam::Vec3,
318        event_deltas: &mut HashMap<PlayerId, PositioningEvent>,
319    ) {
320        for goal_event in &events.goal_events {
321            let defending_team_is_team_0 = !goal_event.scoring_team_is_team_0;
322            let normalized_ball_y = normalized_y(defending_team_is_team_0, ball_position);
323            if normalized_ball_y > GOAL_CAUGHT_AHEAD_MAX_BALL_Y {
324                continue;
325            }
326
327            for player in players
328                .players
329                .iter()
330                .filter(|player| player.is_team_0 == defending_team_is_team_0)
331            {
332                let Some(position) = player.position() else {
333                    continue;
334                };
335                let normalized_player_y = normalized_y(defending_team_is_team_0, position);
336                if normalized_player_y < GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y {
337                    continue;
338                }
339                if normalized_player_y - normalized_ball_y < GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y {
340                    continue;
341                }
342
343                self.player_stats
344                    .entry(player.player_id.clone())
345                    .or_default()
346                    .times_caught_ahead_of_play_on_conceded_goals += 1;
347                Self::event_delta(event_deltas, frame, &player.player_id, player.is_team_0)
348                    .times_caught_ahead_of_play_on_conceded_goals += 1;
349            }
350        }
351    }
352
353    #[allow(clippy::too_many_arguments)]
354    fn process_sample(
355        &mut self,
356        frame: &FrameInfo,
357        gameplay: &GameplayState,
358        ball: &BallFrameState,
359        players: &PlayerFrameState,
360        events: &FrameEventsState,
361        live_play: bool,
362        possession_player_before_sample: Option<&PlayerId>,
363    ) -> SubtrActorResult<()> {
364        if frame.dt == 0.0 {
365            if let Some(ball) = ball.sample() {
366                self.previous_ball_position = Some(ball.position());
367            }
368            for player in &players.players {
369                if let Some(position) = player.position() {
370                    self.previous_player_positions
371                        .insert(player.player_id.clone(), position);
372                }
373            }
374            return Ok(());
375        }
376
377        let Some(ball) = ball.sample() else {
378            return Ok(());
379        };
380        let ball_position = ball.position();
381        let mut event_deltas = HashMap::new();
382        if !events.goal_events.is_empty() {
383            self.record_goal_positioning_events(
384                frame,
385                players,
386                events,
387                ball_position,
388                &mut event_deltas,
389            );
390        }
391        let demoed_players: HashSet<_> = events
392            .active_demos
393            .iter()
394            .map(|demo| demo.victim.clone())
395            .collect();
396
397        for player in &players.players {
398            let is_demoed = demoed_players.contains(&player.player_id);
399            if live_play && is_demoed {
400                let stats = self
401                    .player_stats
402                    .entry(player.player_id.clone())
403                    .or_default();
404                stats.active_game_time += frame.dt;
405                stats.time_demolished += frame.dt;
406                let delta = Self::event_delta(
407                    &mut event_deltas,
408                    frame,
409                    &player.player_id,
410                    player.is_team_0,
411                );
412                delta.active_game_time += frame.dt;
413                delta.time_demolished += frame.dt;
414                continue;
415            }
416
417            let Some(position) = player.position() else {
418                continue;
419            };
420            let previous_position = self
421                .previous_player_positions
422                .get(&player.player_id)
423                .copied()
424                .unwrap_or(position);
425            let previous_ball_position = self.previous_ball_position.unwrap_or(ball_position);
426            let normalized_position_y = normalized_y(player.is_team_0, position);
427            let normalized_previous_position_y = normalized_y(player.is_team_0, previous_position);
428            let normalized_ball_y = normalized_y(player.is_team_0, ball_position);
429            let normalized_previous_ball_y = normalized_y(player.is_team_0, previous_ball_position);
430            let stats = self
431                .player_stats
432                .entry(player.player_id.clone())
433                .or_default();
434
435            if live_play {
436                stats.active_game_time += frame.dt;
437                stats.tracked_time += frame.dt;
438                let distance_to_ball = position.distance(ball_position);
439                stats.sum_distance_to_ball += distance_to_ball * frame.dt;
440                let delta = Self::event_delta(
441                    &mut event_deltas,
442                    frame,
443                    &player.player_id,
444                    player.is_team_0,
445                );
446                delta.active_game_time += frame.dt;
447                delta.tracked_time += frame.dt;
448                delta.sum_distance_to_ball += distance_to_ball * frame.dt;
449
450                if possession_player_before_sample == Some(&player.player_id) {
451                    stats.time_has_possession += frame.dt;
452                    stats.sum_distance_to_ball_has_possession += distance_to_ball * frame.dt;
453                    delta.time_has_possession += frame.dt;
454                    delta.sum_distance_to_ball_has_possession += distance_to_ball * frame.dt;
455                } else if possession_player_before_sample.is_some() {
456                    stats.time_no_possession += frame.dt;
457                    stats.sum_distance_to_ball_no_possession += distance_to_ball * frame.dt;
458                    delta.time_no_possession += frame.dt;
459                    delta.sum_distance_to_ball_no_possession += distance_to_ball * frame.dt;
460                }
461
462                let defensive_zone_fraction = interval_fraction_below_threshold(
463                    normalized_previous_position_y,
464                    normalized_position_y,
465                    -FIELD_ZONE_BOUNDARY_Y,
466                );
467                let offensive_zone_fraction = interval_fraction_above_threshold(
468                    normalized_previous_position_y,
469                    normalized_position_y,
470                    FIELD_ZONE_BOUNDARY_Y,
471                );
472                let neutral_zone_fraction = interval_fraction_in_scalar_range(
473                    normalized_previous_position_y,
474                    normalized_position_y,
475                    -FIELD_ZONE_BOUNDARY_Y,
476                    FIELD_ZONE_BOUNDARY_Y,
477                );
478                stats.time_defensive_zone += frame.dt * defensive_zone_fraction;
479                stats.time_neutral_zone += frame.dt * neutral_zone_fraction;
480                stats.time_offensive_zone += frame.dt * offensive_zone_fraction;
481                delta.time_defensive_zone += frame.dt * defensive_zone_fraction;
482                delta.time_neutral_zone += frame.dt * neutral_zone_fraction;
483                delta.time_offensive_zone += frame.dt * offensive_zone_fraction;
484
485                let defensive_half_fraction = interval_fraction_below_threshold(
486                    normalized_previous_position_y,
487                    normalized_position_y,
488                    0.0,
489                );
490                stats.time_defensive_half += frame.dt * defensive_half_fraction;
491                stats.time_offensive_half += frame.dt * (1.0 - defensive_half_fraction);
492                delta.time_defensive_half += frame.dt * defensive_half_fraction;
493                delta.time_offensive_half += frame.dt * (1.0 - defensive_half_fraction);
494
495                let previous_ball_delta =
496                    normalized_previous_position_y - normalized_previous_ball_y;
497                let current_ball_delta = normalized_position_y - normalized_ball_y;
498                let (behind_ball_fraction, level_ball_fraction, in_front_ball_fraction) =
499                    ball_depth_fractions(
500                        self.config.level_ball_depth_margin,
501                        previous_ball_delta,
502                        current_ball_delta,
503                    );
504                stats.time_behind_ball += frame.dt * behind_ball_fraction;
505                stats.time_level_with_ball += frame.dt * level_ball_fraction;
506                stats.time_in_front_of_ball += frame.dt * in_front_ball_fraction;
507                delta.time_behind_ball += frame.dt * behind_ball_fraction;
508                delta.time_level_with_ball += frame.dt * level_ball_fraction;
509                delta.time_in_front_of_ball += frame.dt * in_front_ball_fraction;
510            }
511        }
512
513        if live_play {
514            for is_team_0 in [true, false] {
515                let team_present_player_count = players
516                    .players
517                    .iter()
518                    .filter(|player| player.is_team_0 == is_team_0)
519                    .count();
520                let team_roster_count = gameplay.current_in_game_team_player_count(is_team_0).max(
521                    players
522                        .players
523                        .iter()
524                        .filter(|player| player.is_team_0 == is_team_0)
525                        .count(),
526                );
527                let team_players: Vec<_> = players
528                    .players
529                    .iter()
530                    .filter(|player| player.is_team_0 == is_team_0)
531                    .filter(|player| !demoed_players.contains(&player.player_id))
532                    .filter_map(|player| player.position().map(|position| (player, position)))
533                    .collect();
534
535                if team_players.is_empty() {
536                    continue;
537                }
538
539                for (player, position) in &team_players {
540                    let teammate_distance_sum: f32 = team_players
541                        .iter()
542                        .filter(|(other_player, _)| other_player.player_id != player.player_id)
543                        .map(|(_, other_position)| position.distance(*other_position))
544                        .sum();
545                    let teammate_count = team_players.len().saturating_sub(1);
546                    if teammate_count > 0 {
547                        let stats = self
548                            .player_stats
549                            .entry(player.player_id.clone())
550                            .or_default();
551                        stats.sum_distance_to_teammates +=
552                            teammate_distance_sum * frame.dt / teammate_count as f32;
553                        Self::event_delta(
554                            &mut event_deltas,
555                            frame,
556                            &player.player_id,
557                            player.is_team_0,
558                        )
559                        .sum_distance_to_teammates +=
560                            teammate_distance_sum * frame.dt / teammate_count as f32;
561                    }
562                }
563
564                if team_roster_count < 2
565                    || team_present_player_count < team_roster_count
566                    || team_players.len() < 2
567                {
568                    for (player, _) in &team_players {
569                        self.player_stats
570                            .entry(player.player_id.clone())
571                            .or_default()
572                            .time_no_teammates += frame.dt;
573                        Self::event_delta(
574                            &mut event_deltas,
575                            frame,
576                            &player.player_id,
577                            player.is_team_0,
578                        )
579                        .time_no_teammates += frame.dt;
580                    }
581                } else {
582                    let mut sorted_team: Vec<_> = team_players
583                        .iter()
584                        .map(|(info, pos)| (info.player_id.clone(), normalized_y(is_team_0, *pos)))
585                        .collect();
586                    sorted_team.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
587
588                    let team_spread = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0)
589                        - sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
590
591                    if team_spread <= self.config.most_back_forward_threshold_y {
592                        for (player_id, _) in &sorted_team {
593                            self.player_stats
594                                .entry(player_id.clone())
595                                .or_default()
596                                .time_other_role += frame.dt;
597                            Self::event_delta(&mut event_deltas, frame, player_id, is_team_0)
598                                .time_other_role += frame.dt;
599                        }
600                    } else {
601                        let min_y = sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
602                        let max_y = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0);
603                        let can_assign_mid_role = sorted_team.len() == 3;
604
605                        for (player_id, y) in &sorted_team {
606                            let near_back =
607                                (*y - min_y) <= self.config.most_back_forward_threshold_y;
608                            let near_front =
609                                (max_y - *y) <= self.config.most_back_forward_threshold_y;
610
611                            if near_back && !near_front {
612                                self.player_stats
613                                    .entry(player_id.clone())
614                                    .or_default()
615                                    .time_most_back += frame.dt;
616                                Self::event_delta(
617                                    &mut event_deltas,
618                                    frame,
619                                    player_id,
620                                    is_team_0,
621                                )
622                                .time_most_back += frame.dt;
623                            } else if near_front && !near_back {
624                                self.player_stats
625                                    .entry(player_id.clone())
626                                    .or_default()
627                                    .time_most_forward += frame.dt;
628                                Self::event_delta(
629                                    &mut event_deltas,
630                                    frame,
631                                    player_id,
632                                    is_team_0,
633                                )
634                                .time_most_forward += frame.dt;
635                            } else if can_assign_mid_role {
636                                self.player_stats
637                                    .entry(player_id.clone())
638                                    .or_default()
639                                    .time_mid_role += frame.dt;
640                                Self::event_delta(
641                                    &mut event_deltas,
642                                    frame,
643                                    player_id,
644                                    is_team_0,
645                                )
646                                .time_mid_role += frame.dt;
647                            } else {
648                                self.player_stats
649                                    .entry(player_id.clone())
650                                    .or_default()
651                                    .time_other_role += frame.dt;
652                                Self::event_delta(
653                                    &mut event_deltas,
654                                    frame,
655                                    player_id,
656                                    is_team_0,
657                                )
658                                .time_other_role += frame.dt;
659                            }
660                        }
661                    }
662                }
663
664                if let Some((closest_player, _)) = team_players.iter().min_by(|(_, a), (_, b)| {
665                    a.distance(ball_position)
666                        .partial_cmp(&b.distance(ball_position))
667                        .unwrap()
668                }) {
669                    self.player_stats
670                        .entry(closest_player.player_id.clone())
671                        .or_default()
672                        .time_closest_to_ball += frame.dt;
673                    Self::event_delta(
674                        &mut event_deltas,
675                        frame,
676                        &closest_player.player_id,
677                        closest_player.is_team_0,
678                    )
679                    .time_closest_to_ball += frame.dt;
680                }
681
682                if let Some((farthest_player, _)) = team_players.iter().max_by(|(_, a), (_, b)| {
683                    a.distance(ball_position)
684                        .partial_cmp(&b.distance(ball_position))
685                        .unwrap()
686                }) {
687                    self.player_stats
688                        .entry(farthest_player.player_id.clone())
689                        .or_default()
690                        .time_farthest_from_ball += frame.dt;
691                    Self::event_delta(
692                        &mut event_deltas,
693                        frame,
694                        &farthest_player.player_id,
695                        farthest_player.is_team_0,
696                    )
697                    .time_farthest_from_ball += frame.dt;
698                }
699            }
700        }
701
702        let mut frame_events: Vec<_> = event_deltas
703            .into_values()
704            .filter(PositioningEvent::has_delta)
705            .collect();
706        frame_events.sort_by(|left, right| {
707            format!("{:?}", left.player).cmp(&format!("{:?}", right.player))
708        });
709        self.events.extend(frame_events);
710
711        self.previous_ball_position = Some(ball_position);
712        for player in &players.players {
713            if let Some(position) = player.position() {
714                self.previous_player_positions
715                    .insert(player.player_id.clone(), position);
716            }
717        }
718
719        Ok(())
720    }
721
722    #[allow(clippy::too_many_arguments)]
723    pub fn update(
724        &mut self,
725        frame: &FrameInfo,
726        gameplay: &GameplayState,
727        ball: &BallFrameState,
728        players: &PlayerFrameState,
729        events: &FrameEventsState,
730        live_play: bool,
731        possession_player_before_sample: Option<&PlayerId>,
732    ) -> SubtrActorResult<()> {
733        self.process_sample(
734            frame,
735            gameplay,
736            ball,
737            players,
738            events,
739            live_play,
740            possession_player_before_sample,
741        )
742    }
743}
744
745fn ball_depth_fractions(level_margin: f32, start_delta: f32, end_delta: f32) -> (f32, f32, f32) {
746    let behind_fraction = interval_fraction_below_threshold(start_delta, end_delta, -level_margin);
747    let level_fraction =
748        interval_fraction_in_scalar_range(start_delta, end_delta, -level_margin, level_margin);
749    let in_front_fraction = (1.0 - behind_fraction - level_fraction).clamp(0.0, 1.0);
750    (behind_fraction, level_fraction, in_front_fraction)
751}
752
753#[cfg(test)]
754#[path = "positioning_tests.rs"]
755mod tests;