Skip to main content

subtr_actor/stats/calculators/
pass.rs

1use super::*;
2
3const PASS_MAX_DURATION_SECONDS: f32 = 3.0;
4const PASS_MIN_BALL_TRAVEL_DISTANCE: f32 = 500.0;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
7#[serde(rename_all = "snake_case")]
8#[ts(export)]
9pub enum PassKind {
10    Direct,
11    Backboard,
12    FiftyFifty,
13    FiftyFiftyBackboard,
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
17#[ts(export)]
18pub struct PassEvent {
19    pub time: f32,
20    pub frame: usize,
21    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
22    pub passer: PlayerId,
23    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
24    pub receiver: PlayerId,
25    pub is_team_0: bool,
26    pub start_time: f32,
27    pub start_frame: usize,
28    pub duration: f32,
29    pub ball_travel_distance: f32,
30    pub ball_advance_distance: f32,
31    pub pass_kind: PassKind,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
35#[ts(export)]
36pub struct PassPlayerStats {
37    pub completed_pass_count: u32,
38    pub received_pass_count: u32,
39    pub total_pass_distance: f32,
40    pub total_pass_advance: f32,
41    pub longest_pass_distance: f32,
42    pub is_last_completed_pass: bool,
43    pub last_completed_pass_time: Option<f32>,
44    pub last_completed_pass_frame: Option<usize>,
45    pub time_since_last_completed_pass: Option<f32>,
46    pub frames_since_last_completed_pass: Option<usize>,
47}
48
49impl PassPlayerStats {
50    pub fn average_pass_distance(&self) -> f32 {
51        if self.completed_pass_count == 0 {
52            0.0
53        } else {
54            self.total_pass_distance / self.completed_pass_count as f32
55        }
56    }
57
58    pub fn average_pass_advance(&self) -> f32 {
59        if self.completed_pass_count == 0 {
60            0.0
61        } else {
62            self.total_pass_advance / self.completed_pass_count as f32
63        }
64    }
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
68#[ts(export)]
69pub struct PassTeamStats {
70    pub completed_pass_count: u32,
71    pub total_pass_distance: f32,
72    pub total_pass_advance: f32,
73    pub longest_pass_distance: f32,
74}
75
76impl PassTeamStats {
77    pub fn average_pass_distance(&self) -> f32 {
78        if self.completed_pass_count == 0 {
79            0.0
80        } else {
81            self.total_pass_distance / self.completed_pass_count as f32
82        }
83    }
84
85    pub fn average_pass_advance(&self) -> f32 {
86        if self.completed_pass_count == 0 {
87            0.0
88        } else {
89            self.total_pass_advance / self.completed_pass_count as f32
90        }
91    }
92}
93
94#[derive(Debug, Clone)]
95struct PendingPassTouch {
96    player: PlayerId,
97    is_team_0: bool,
98    time: f32,
99    frame: usize,
100    ball_position: glam::Vec3,
101    from_fifty_fifty: bool,
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct PassCalculator {
106    player_stats: HashMap<PlayerId, PassPlayerStats>,
107    team_zero_stats: PassTeamStats,
108    team_one_stats: PassTeamStats,
109    events: Vec<PassEvent>,
110    last_touch: Option<PendingPassTouch>,
111    current_last_completed_pass_player: Option<PlayerId>,
112}
113
114impl PassCalculator {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    pub fn player_stats(&self) -> &HashMap<PlayerId, PassPlayerStats> {
120        &self.player_stats
121    }
122
123    pub fn team_zero_stats(&self) -> &PassTeamStats {
124        &self.team_zero_stats
125    }
126
127    pub fn team_one_stats(&self) -> &PassTeamStats {
128        &self.team_one_stats
129    }
130
131    pub fn events(&self) -> &[PassEvent] {
132        &self.events
133    }
134
135    fn begin_sample(&mut self, frame: &FrameInfo) {
136        for stats in self.player_stats.values_mut() {
137            stats.is_last_completed_pass = false;
138            stats.time_since_last_completed_pass = stats
139                .last_completed_pass_time
140                .map(|time| (frame.time - time).max(0.0));
141            stats.frames_since_last_completed_pass = stats
142                .last_completed_pass_frame
143                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
144        }
145    }
146
147    fn pass_event_for_touch(
148        &self,
149        touch: &TouchEvent,
150        receiver: &PlayerId,
151        ball_position: glam::Vec3,
152        backboard_bounce_state: &BackboardBounceState,
153    ) -> Option<PassEvent> {
154        let previous = self.last_touch.as_ref()?;
155        if previous.player == *receiver || previous.is_team_0 != touch.team_is_team_0 {
156            return None;
157        }
158
159        let duration = touch.time - previous.time;
160        if !(0.0..=PASS_MAX_DURATION_SECONDS).contains(&duration) {
161            return None;
162        }
163
164        let ball_delta = ball_position - previous.ball_position;
165        let ball_travel_distance = ball_delta.length();
166        if ball_travel_distance < PASS_MIN_BALL_TRAVEL_DISTANCE {
167            return None;
168        }
169
170        let team_forward_sign = if touch.team_is_team_0 { 1.0 } else { -1.0 };
171        let went_off_backboard = Self::has_backboard_bounce_between(
172            previous,
173            touch,
174            backboard_bounce_state.last_bounce_event.as_ref(),
175        );
176        Some(PassEvent {
177            time: touch.time,
178            frame: touch.frame,
179            passer: previous.player.clone(),
180            receiver: receiver.clone(),
181            is_team_0: touch.team_is_team_0,
182            start_time: previous.time,
183            start_frame: previous.frame,
184            duration,
185            ball_travel_distance,
186            ball_advance_distance: ball_delta.y * team_forward_sign,
187            pass_kind: Self::pass_kind(previous.from_fifty_fifty, went_off_backboard),
188        })
189    }
190
191    fn pass_kind(from_fifty_fifty: bool, went_off_backboard: bool) -> PassKind {
192        match (from_fifty_fifty, went_off_backboard) {
193            (true, true) => PassKind::FiftyFiftyBackboard,
194            (true, false) => PassKind::FiftyFifty,
195            (false, true) => PassKind::Backboard,
196            (false, false) => PassKind::Direct,
197        }
198    }
199
200    fn has_backboard_bounce_between(
201        previous: &PendingPassTouch,
202        touch: &TouchEvent,
203        bounce_event: Option<&BackboardBounceEvent>,
204    ) -> bool {
205        bounce_event.is_some_and(|event| {
206            event.player == previous.player
207                && event.is_team_0 == previous.is_team_0
208                && event.time >= previous.time
209                && event.time <= touch.time
210        })
211    }
212
213    fn touch_from_fifty_fifty(touch: &TouchEvent, fifty_fifty_state: &FiftyFiftyState) -> bool {
214        fifty_fifty_state
215            .active_event
216            .as_ref()
217            .is_some_and(|event| {
218                Self::fifty_fifty_involves_touch(
219                    event.start_time,
220                    event.last_touch_time,
221                    event.team_zero_player.as_ref(),
222                    event.team_one_player.as_ref(),
223                    touch,
224                )
225            })
226            || fifty_fifty_state
227                .last_resolved_event
228                .as_ref()
229                .is_some_and(|event| {
230                    Self::fifty_fifty_involves_touch(
231                        event.start_time,
232                        event.resolve_time,
233                        event.team_zero_player.as_ref(),
234                        event.team_one_player.as_ref(),
235                        touch,
236                    )
237                })
238    }
239
240    fn fifty_fifty_involves_touch(
241        start_time: f32,
242        end_time: f32,
243        team_zero_player: Option<&PlayerId>,
244        team_one_player: Option<&PlayerId>,
245        touch: &TouchEvent,
246    ) -> bool {
247        if touch.time < start_time || touch.time > end_time {
248            return false;
249        }
250
251        match (touch.team_is_team_0, touch.player.as_ref()) {
252            (true, Some(player)) => team_zero_player == Some(player),
253            (false, Some(player)) => team_one_player == Some(player),
254            _ => false,
255        }
256    }
257
258    fn record_pass(&mut self, frame: &FrameInfo, event: PassEvent) {
259        let passer_stats = self.player_stats.entry(event.passer.clone()).or_default();
260        passer_stats.completed_pass_count += 1;
261        passer_stats.total_pass_distance += event.ball_travel_distance;
262        passer_stats.total_pass_advance += event.ball_advance_distance;
263        passer_stats.longest_pass_distance = passer_stats
264            .longest_pass_distance
265            .max(event.ball_travel_distance);
266        passer_stats.last_completed_pass_time = Some(event.time);
267        passer_stats.last_completed_pass_frame = Some(event.frame);
268        passer_stats.time_since_last_completed_pass = Some((frame.time - event.time).max(0.0));
269        passer_stats.frames_since_last_completed_pass =
270            Some(frame.frame_number.saturating_sub(event.frame));
271
272        self.player_stats
273            .entry(event.receiver.clone())
274            .or_default()
275            .received_pass_count += 1;
276
277        let team_stats = if event.is_team_0 {
278            &mut self.team_zero_stats
279        } else {
280            &mut self.team_one_stats
281        };
282        team_stats.completed_pass_count += 1;
283        team_stats.total_pass_distance += event.ball_travel_distance;
284        team_stats.total_pass_advance += event.ball_advance_distance;
285        team_stats.longest_pass_distance = team_stats
286            .longest_pass_distance
287            .max(event.ball_travel_distance);
288
289        self.current_last_completed_pass_player = Some(event.passer.clone());
290        self.events.push(event);
291    }
292
293    pub fn update(
294        &mut self,
295        frame: &FrameInfo,
296        ball: &BallFrameState,
297        touch_state: &TouchState,
298        backboard_bounce_state: &BackboardBounceState,
299        fifty_fifty_state: &FiftyFiftyState,
300        live_play: bool,
301    ) -> SubtrActorResult<()> {
302        self.begin_sample(frame);
303        if !live_play {
304            self.last_touch = None;
305            self.current_last_completed_pass_player = None;
306            return Ok(());
307        }
308
309        let Some(ball_position) = ball.position() else {
310            return Ok(());
311        };
312
313        for touch in &touch_state.touch_events {
314            let Some(player) = touch.player.clone() else {
315                self.last_touch = None;
316                continue;
317            };
318
319            if let Some(pass_event) =
320                self.pass_event_for_touch(touch, &player, ball_position, backboard_bounce_state)
321            {
322                self.record_pass(frame, pass_event);
323            }
324
325            self.last_touch = Some(PendingPassTouch {
326                player,
327                is_team_0: touch.team_is_team_0,
328                time: touch.time,
329                frame: touch.frame,
330                ball_position,
331                from_fifty_fifty: Self::touch_from_fifty_fifty(touch, fifty_fifty_state),
332            });
333        }
334
335        if let Some(player_id) = self.current_last_completed_pass_player.as_ref() {
336            if let Some(stats) = self.player_stats.get_mut(player_id) {
337                stats.is_last_completed_pass = true;
338            }
339        }
340
341        Ok(())
342    }
343}
344
345#[cfg(test)]
346#[path = "pass_tests.rs"]
347mod tests;