Skip to main content

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