Skip to main content

subtr_actor/stats/calculators/
pass.rs

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