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
41impl PositioningStats {
42    pub fn average_distance_to_teammates(&self) -> f32 {
43        if self.tracked_time == 0.0 {
44            0.0
45        } else {
46            self.sum_distance_to_teammates / self.tracked_time
47        }
48    }
49
50    pub fn average_distance_to_ball(&self) -> f32 {
51        if self.tracked_time == 0.0 {
52            0.0
53        } else {
54            self.sum_distance_to_ball / self.tracked_time
55        }
56    }
57
58    pub fn average_distance_to_ball_has_possession(&self) -> f32 {
59        if self.time_has_possession == 0.0 {
60            0.0
61        } else {
62            self.sum_distance_to_ball_has_possession / self.time_has_possession
63        }
64    }
65
66    pub fn average_distance_to_ball_no_possession(&self) -> f32 {
67        if self.time_no_possession == 0.0 {
68            0.0
69        } else {
70            self.sum_distance_to_ball_no_possession / self.time_no_possession
71        }
72    }
73
74    fn pct(&self, value: f32) -> f32 {
75        if self.tracked_time == 0.0 {
76            0.0
77        } else {
78            value * 100.0 / self.tracked_time
79        }
80    }
81
82    pub fn most_back_pct(&self) -> f32 {
83        self.pct(self.time_most_back)
84    }
85
86    pub fn most_forward_pct(&self) -> f32 {
87        self.pct(self.time_most_forward)
88    }
89
90    pub fn mid_role_pct(&self) -> f32 {
91        self.pct(self.time_mid_role)
92    }
93
94    pub fn other_role_pct(&self) -> f32 {
95        self.pct(self.time_other_role)
96    }
97
98    pub fn defensive_third_pct(&self) -> f32 {
99        self.pct(self.time_defensive_zone)
100    }
101
102    pub fn neutral_third_pct(&self) -> f32 {
103        self.pct(self.time_neutral_zone)
104    }
105
106    pub fn offensive_third_pct(&self) -> f32 {
107        self.pct(self.time_offensive_zone)
108    }
109
110    pub fn defensive_zone_pct(&self) -> f32 {
111        self.defensive_third_pct()
112    }
113
114    pub fn neutral_zone_pct(&self) -> f32 {
115        self.neutral_third_pct()
116    }
117
118    pub fn offensive_zone_pct(&self) -> f32 {
119        self.offensive_third_pct()
120    }
121
122    pub fn defensive_half_pct(&self) -> f32 {
123        self.pct(self.time_defensive_half)
124    }
125
126    pub fn offensive_half_pct(&self) -> f32 {
127        self.pct(self.time_offensive_half)
128    }
129
130    pub fn closest_to_ball_pct(&self) -> f32 {
131        self.pct(self.time_closest_to_ball)
132    }
133
134    pub fn farthest_from_ball_pct(&self) -> f32 {
135        self.pct(self.time_farthest_from_ball)
136    }
137
138    pub fn behind_ball_pct(&self) -> f32 {
139        self.pct(self.time_behind_ball)
140    }
141
142    pub fn level_with_ball_pct(&self) -> f32 {
143        self.pct(self.time_level_with_ball)
144    }
145
146    pub fn in_front_of_ball_pct(&self) -> f32 {
147        self.pct(self.time_in_front_of_ball)
148    }
149}
150
151#[derive(Debug, Clone)]
152pub struct PositioningCalculatorConfig {
153    pub most_back_forward_threshold_y: f32,
154    pub level_ball_depth_margin: f32,
155}
156
157impl Default for PositioningCalculatorConfig {
158    fn default() -> Self {
159        Self {
160            most_back_forward_threshold_y: DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y,
161            level_ball_depth_margin: DEFAULT_LEVEL_BALL_DEPTH_MARGIN,
162        }
163    }
164}
165
166#[derive(Debug, Clone, Default)]
167pub struct PositioningCalculator {
168    config: PositioningCalculatorConfig,
169    player_stats: HashMap<PlayerId, PositioningStats>,
170    previous_ball_position: Option<glam::Vec3>,
171    previous_player_positions: HashMap<PlayerId, glam::Vec3>,
172}
173
174impl PositioningCalculator {
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    pub fn with_config(config: PositioningCalculatorConfig) -> Self {
180        Self {
181            config,
182            ..Self::default()
183        }
184    }
185
186    pub fn config(&self) -> &PositioningCalculatorConfig {
187        &self.config
188    }
189
190    pub fn player_stats(&self) -> &HashMap<PlayerId, PositioningStats> {
191        &self.player_stats
192    }
193
194    fn record_goal_positioning_events(
195        &mut self,
196        players: &PlayerFrameState,
197        events: &FrameEventsState,
198        ball_position: glam::Vec3,
199    ) {
200        for goal_event in &events.goal_events {
201            let defending_team_is_team_0 = !goal_event.scoring_team_is_team_0;
202            let normalized_ball_y = normalized_y(defending_team_is_team_0, ball_position);
203            if normalized_ball_y > GOAL_CAUGHT_AHEAD_MAX_BALL_Y {
204                continue;
205            }
206
207            for player in players
208                .players
209                .iter()
210                .filter(|player| player.is_team_0 == defending_team_is_team_0)
211            {
212                let Some(position) = player.position() else {
213                    continue;
214                };
215                let normalized_player_y = normalized_y(defending_team_is_team_0, position);
216                if normalized_player_y < GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y {
217                    continue;
218                }
219                if normalized_player_y - normalized_ball_y < GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y {
220                    continue;
221                }
222
223                self.player_stats
224                    .entry(player.player_id.clone())
225                    .or_default()
226                    .times_caught_ahead_of_play_on_conceded_goals += 1;
227            }
228        }
229    }
230
231    #[allow(clippy::too_many_arguments)]
232    fn process_sample(
233        &mut self,
234        frame: &FrameInfo,
235        gameplay: &GameplayState,
236        ball: &BallFrameState,
237        players: &PlayerFrameState,
238        events: &FrameEventsState,
239        live_play: bool,
240        possession_player_before_sample: Option<&PlayerId>,
241    ) -> SubtrActorResult<()> {
242        if frame.dt == 0.0 {
243            if let Some(ball) = ball.sample() {
244                self.previous_ball_position = Some(ball.position());
245            }
246            for player in &players.players {
247                if let Some(position) = player.position() {
248                    self.previous_player_positions
249                        .insert(player.player_id.clone(), position);
250                }
251            }
252            return Ok(());
253        }
254
255        let Some(ball) = ball.sample() else {
256            return Ok(());
257        };
258        let ball_position = ball.position();
259        if !events.goal_events.is_empty() {
260            self.record_goal_positioning_events(players, events, ball_position);
261        }
262        let demoed_players: HashSet<_> = events
263            .active_demos
264            .iter()
265            .map(|demo| demo.victim.clone())
266            .collect();
267
268        for player in &players.players {
269            let is_demoed = demoed_players.contains(&player.player_id);
270            if live_play && is_demoed {
271                let stats = self
272                    .player_stats
273                    .entry(player.player_id.clone())
274                    .or_default();
275                stats.active_game_time += frame.dt;
276                stats.time_demolished += frame.dt;
277                continue;
278            }
279
280            let Some(position) = player.position() else {
281                continue;
282            };
283            let previous_position = self
284                .previous_player_positions
285                .get(&player.player_id)
286                .copied()
287                .unwrap_or(position);
288            let previous_ball_position = self.previous_ball_position.unwrap_or(ball_position);
289            let normalized_position_y = normalized_y(player.is_team_0, position);
290            let normalized_previous_position_y = normalized_y(player.is_team_0, previous_position);
291            let normalized_ball_y = normalized_y(player.is_team_0, ball_position);
292            let normalized_previous_ball_y = normalized_y(player.is_team_0, previous_ball_position);
293            let stats = self
294                .player_stats
295                .entry(player.player_id.clone())
296                .or_default();
297
298            if live_play {
299                stats.active_game_time += frame.dt;
300                stats.tracked_time += frame.dt;
301                stats.sum_distance_to_ball += position.distance(ball_position) * frame.dt;
302
303                if possession_player_before_sample == Some(&player.player_id) {
304                    stats.time_has_possession += frame.dt;
305                    stats.sum_distance_to_ball_has_possession +=
306                        position.distance(ball_position) * frame.dt;
307                } else if possession_player_before_sample.is_some() {
308                    stats.time_no_possession += frame.dt;
309                    stats.sum_distance_to_ball_no_possession +=
310                        position.distance(ball_position) * frame.dt;
311                }
312
313                let defensive_zone_fraction = interval_fraction_below_threshold(
314                    normalized_previous_position_y,
315                    normalized_position_y,
316                    -FIELD_ZONE_BOUNDARY_Y,
317                );
318                let offensive_zone_fraction = interval_fraction_above_threshold(
319                    normalized_previous_position_y,
320                    normalized_position_y,
321                    FIELD_ZONE_BOUNDARY_Y,
322                );
323                let neutral_zone_fraction = interval_fraction_in_scalar_range(
324                    normalized_previous_position_y,
325                    normalized_position_y,
326                    -FIELD_ZONE_BOUNDARY_Y,
327                    FIELD_ZONE_BOUNDARY_Y,
328                );
329                stats.time_defensive_zone += frame.dt * defensive_zone_fraction;
330                stats.time_neutral_zone += frame.dt * neutral_zone_fraction;
331                stats.time_offensive_zone += frame.dt * offensive_zone_fraction;
332
333                let defensive_half_fraction = interval_fraction_below_threshold(
334                    normalized_previous_position_y,
335                    normalized_position_y,
336                    0.0,
337                );
338                stats.time_defensive_half += frame.dt * defensive_half_fraction;
339                stats.time_offensive_half += frame.dt * (1.0 - defensive_half_fraction);
340
341                let previous_ball_delta =
342                    normalized_previous_position_y - normalized_previous_ball_y;
343                let current_ball_delta = normalized_position_y - normalized_ball_y;
344                let (behind_ball_fraction, level_ball_fraction, in_front_ball_fraction) =
345                    ball_depth_fractions(
346                        self.config.level_ball_depth_margin,
347                        previous_ball_delta,
348                        current_ball_delta,
349                    );
350                stats.time_behind_ball += frame.dt * behind_ball_fraction;
351                stats.time_level_with_ball += frame.dt * level_ball_fraction;
352                stats.time_in_front_of_ball += frame.dt * in_front_ball_fraction;
353            }
354        }
355
356        if live_play {
357            for is_team_0 in [true, false] {
358                let team_present_player_count = players
359                    .players
360                    .iter()
361                    .filter(|player| player.is_team_0 == is_team_0)
362                    .count();
363                let team_roster_count = gameplay.current_in_game_team_player_count(is_team_0).max(
364                    players
365                        .players
366                        .iter()
367                        .filter(|player| player.is_team_0 == is_team_0)
368                        .count(),
369                );
370                let team_players: Vec<_> = players
371                    .players
372                    .iter()
373                    .filter(|player| player.is_team_0 == is_team_0)
374                    .filter(|player| !demoed_players.contains(&player.player_id))
375                    .filter_map(|player| player.position().map(|position| (player, position)))
376                    .collect();
377
378                if team_players.is_empty() {
379                    continue;
380                }
381
382                for (player, position) in &team_players {
383                    let teammate_distance_sum: f32 = team_players
384                        .iter()
385                        .filter(|(other_player, _)| other_player.player_id != player.player_id)
386                        .map(|(_, other_position)| position.distance(*other_position))
387                        .sum();
388                    let teammate_count = team_players.len().saturating_sub(1);
389                    if teammate_count > 0 {
390                        let stats = self
391                            .player_stats
392                            .entry(player.player_id.clone())
393                            .or_default();
394                        stats.sum_distance_to_teammates +=
395                            teammate_distance_sum * frame.dt / teammate_count as f32;
396                    }
397                }
398
399                if team_roster_count < 2
400                    || team_present_player_count < team_roster_count
401                    || team_players.len() < 2
402                {
403                    for (player, _) in &team_players {
404                        self.player_stats
405                            .entry(player.player_id.clone())
406                            .or_default()
407                            .time_no_teammates += frame.dt;
408                    }
409                } else {
410                    let mut sorted_team: Vec<_> = team_players
411                        .iter()
412                        .map(|(info, pos)| (info.player_id.clone(), normalized_y(is_team_0, *pos)))
413                        .collect();
414                    sorted_team.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
415
416                    let team_spread = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0)
417                        - sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
418
419                    if team_spread <= self.config.most_back_forward_threshold_y {
420                        for (player_id, _) in &sorted_team {
421                            self.player_stats
422                                .entry(player_id.clone())
423                                .or_default()
424                                .time_other_role += frame.dt;
425                        }
426                    } else {
427                        let min_y = sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
428                        let max_y = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0);
429                        let can_assign_mid_role = sorted_team.len() == 3;
430
431                        for (player_id, y) in &sorted_team {
432                            let near_back =
433                                (*y - min_y) <= self.config.most_back_forward_threshold_y;
434                            let near_front =
435                                (max_y - *y) <= self.config.most_back_forward_threshold_y;
436
437                            if near_back && !near_front {
438                                self.player_stats
439                                    .entry(player_id.clone())
440                                    .or_default()
441                                    .time_most_back += frame.dt;
442                            } else if near_front && !near_back {
443                                self.player_stats
444                                    .entry(player_id.clone())
445                                    .or_default()
446                                    .time_most_forward += frame.dt;
447                            } else if can_assign_mid_role {
448                                self.player_stats
449                                    .entry(player_id.clone())
450                                    .or_default()
451                                    .time_mid_role += frame.dt;
452                            } else {
453                                self.player_stats
454                                    .entry(player_id.clone())
455                                    .or_default()
456                                    .time_other_role += frame.dt;
457                            }
458                        }
459                    }
460                }
461
462                if let Some((closest_player, _)) = team_players.iter().min_by(|(_, a), (_, b)| {
463                    a.distance(ball_position)
464                        .partial_cmp(&b.distance(ball_position))
465                        .unwrap()
466                }) {
467                    self.player_stats
468                        .entry(closest_player.player_id.clone())
469                        .or_default()
470                        .time_closest_to_ball += frame.dt;
471                }
472
473                if let Some((farthest_player, _)) = team_players.iter().max_by(|(_, a), (_, b)| {
474                    a.distance(ball_position)
475                        .partial_cmp(&b.distance(ball_position))
476                        .unwrap()
477                }) {
478                    self.player_stats
479                        .entry(farthest_player.player_id.clone())
480                        .or_default()
481                        .time_farthest_from_ball += frame.dt;
482                }
483            }
484        }
485
486        self.previous_ball_position = Some(ball_position);
487        for player in &players.players {
488            if let Some(position) = player.position() {
489                self.previous_player_positions
490                    .insert(player.player_id.clone(), position);
491            }
492        }
493
494        Ok(())
495    }
496
497    #[allow(clippy::too_many_arguments)]
498    pub fn update(
499        &mut self,
500        frame: &FrameInfo,
501        gameplay: &GameplayState,
502        ball: &BallFrameState,
503        players: &PlayerFrameState,
504        events: &FrameEventsState,
505        live_play: bool,
506        possession_player_before_sample: Option<&PlayerId>,
507    ) -> SubtrActorResult<()> {
508        self.process_sample(
509            frame,
510            gameplay,
511            ball,
512            players,
513            events,
514            live_play,
515            possession_player_before_sample,
516        )
517    }
518}
519
520fn ball_depth_fractions(level_margin: f32, start_delta: f32, end_delta: f32) -> (f32, f32, f32) {
521    let behind_fraction = interval_fraction_below_threshold(start_delta, end_delta, -level_margin);
522    let level_fraction =
523        interval_fraction_in_scalar_range(start_delta, end_delta, -level_margin, level_margin);
524    let in_front_fraction = (1.0 - behind_fraction - level_fraction).clamp(0.0, 1.0);
525    (behind_fraction, level_fraction, in_front_fraction)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn ball_depth_fractions_treat_near_ball_band_as_level() {
534        let (behind, level, in_front) = ball_depth_fractions(150.0, -100.0, 100.0);
535        assert_eq!(behind, 0.0);
536        assert_eq!(level, 1.0);
537        assert_eq!(in_front, 0.0);
538    }
539
540    #[test]
541    fn ball_depth_fractions_split_crossing_time_across_all_three_buckets() {
542        let (behind, level, in_front) = ball_depth_fractions(150.0, -300.0, 300.0);
543        assert!((behind - 0.25).abs() < 1e-6);
544        assert!((level - 0.5).abs() < 1e-6);
545        assert!((in_front - 0.25).abs() < 1e-6);
546    }
547
548    #[test]
549    fn ball_depth_fractions_count_boundary_point_as_in_front_not_level() {
550        let (behind, level, in_front) = ball_depth_fractions(150.0, 150.0, 150.0);
551        assert_eq!(behind, 0.0);
552        assert_eq!(level, 0.0);
553        assert_eq!(in_front, 1.0);
554    }
555}