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/// The kind of pass.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
8#[serde(rename_all = "snake_case")]
9#[ts(export)]
10pub enum PassKind {
11    Direct,
12    Backboard,
13    FiftyFifty,
14    FiftyFiftyBackboard,
15}
16
17/// A same-team touch sequence where one player sends the ball to a different teammate.
18#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
19#[ts(export)]
20pub struct PassEvent {
21    pub time: f32,
22    pub frame: usize,
23    pub sample_time: f32,
24    pub sample_frame: usize,
25    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
26    pub passer: PlayerId,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub passer_position: Option<[f32; 3]>,
29    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
30    pub receiver: PlayerId,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub receiver_position: Option<[f32; 3]>,
33    pub is_team_0: bool,
34    pub start_time: f32,
35    pub start_frame: usize,
36    pub duration: f32,
37    pub ball_travel_distance: f32,
38    pub ball_advance_distance: f32,
39    pub pass_kind: PassKind,
40}
41
42#[derive(Debug, Clone)]
43struct PendingPassTouch {
44    player: PlayerId,
45    player_position: Option<[f32; 3]>,
46    is_team_0: bool,
47    time: f32,
48    frame: usize,
49    ball_position: glam::Vec3,
50    from_fifty_fifty: bool,
51}
52
53/// Detects passes between teammates.
54#[derive(Debug, Clone, Default)]
55pub struct PassCalculator {
56    events: EventStream<PassEvent>,
57    last_touch: Option<PendingPassTouch>,
58}
59
60impl PassCalculator {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    pub fn events(&self) -> &[PassEvent] {
66        self.events.all()
67    }
68
69    pub fn new_events(&self) -> &[PassEvent] {
70        self.events.new_events()
71    }
72
73    fn pass_event_for_touch(
74        &self,
75        touch: &TouchEvent,
76        receiver: &PlayerId,
77        ball_position: glam::Vec3,
78        backboard_bounce_state: &BackboardBounceState,
79    ) -> Option<PassEvent> {
80        let previous = self.last_touch.as_ref()?;
81        if previous.player == *receiver || previous.is_team_0 != touch.team_is_team_0 {
82            return None;
83        }
84
85        let duration = touch.time - previous.time;
86        if !(0.0..=PASS_MAX_DURATION_SECONDS).contains(&duration) {
87            return None;
88        }
89
90        let ball_delta = ball_position - previous.ball_position;
91        let ball_travel_distance = ball_delta.length();
92        if ball_travel_distance < PASS_MIN_BALL_TRAVEL_DISTANCE {
93            return None;
94        }
95
96        let team_forward_sign = if touch.team_is_team_0 { 1.0 } else { -1.0 };
97        let went_off_backboard = Self::has_backboard_bounce_between(
98            previous,
99            touch,
100            backboard_bounce_state.last_bounce_event.as_ref(),
101        );
102        Some(PassEvent {
103            time: touch.time,
104            frame: touch.frame,
105            sample_time: touch.time,
106            sample_frame: touch.frame,
107            passer: previous.player.clone(),
108            passer_position: previous.player_position,
109            receiver: receiver.clone(),
110            receiver_position: touch
111                .player_position
112                .map(|position| vec_to_glam(&position).to_array()),
113            is_team_0: touch.team_is_team_0,
114            start_time: previous.time,
115            start_frame: previous.frame,
116            duration,
117            ball_travel_distance,
118            ball_advance_distance: ball_delta.y * team_forward_sign,
119            pass_kind: Self::pass_kind(previous.from_fifty_fifty, went_off_backboard),
120        })
121    }
122
123    fn pass_kind(from_fifty_fifty: bool, went_off_backboard: bool) -> PassKind {
124        match (from_fifty_fifty, went_off_backboard) {
125            (true, true) => PassKind::FiftyFiftyBackboard,
126            (true, false) => PassKind::FiftyFifty,
127            (false, true) => PassKind::Backboard,
128            (false, false) => PassKind::Direct,
129        }
130    }
131
132    fn has_backboard_bounce_between(
133        previous: &PendingPassTouch,
134        touch: &TouchEvent,
135        bounce_event: Option<&BackboardBounceEvent>,
136    ) -> bool {
137        bounce_event.is_some_and(|event| {
138            event.player == previous.player
139                && event.is_team_0 == previous.is_team_0
140                && event.time >= previous.time
141                && event.time <= touch.time
142        })
143    }
144
145    fn touch_from_fifty_fifty(touch: &TouchEvent, fifty_fifty_state: &FiftyFiftyState) -> bool {
146        fifty_fifty_state
147            .active_event
148            .as_ref()
149            .is_some_and(|event| {
150                Self::fifty_fifty_involves_touch(
151                    event.start_time,
152                    event.last_touch_time,
153                    event.team_zero_player.as_ref(),
154                    event.team_one_player.as_ref(),
155                    touch,
156                )
157            })
158            || fifty_fifty_state
159                .last_resolved_event
160                .as_ref()
161                .is_some_and(|event| {
162                    Self::fifty_fifty_involves_touch(
163                        event.start_time,
164                        event.resolve_time,
165                        event.team_zero_player.as_ref(),
166                        event.team_one_player.as_ref(),
167                        touch,
168                    )
169                })
170    }
171
172    fn fifty_fifty_involves_touch(
173        start_time: f32,
174        end_time: f32,
175        team_zero_player: Option<&PlayerId>,
176        team_one_player: Option<&PlayerId>,
177        touch: &TouchEvent,
178    ) -> bool {
179        if touch.time < start_time || touch.time > end_time {
180            return false;
181        }
182
183        match (touch.team_is_team_0, touch.player.as_ref()) {
184            (true, Some(player)) => team_zero_player == Some(player),
185            (false, Some(player)) => team_one_player == Some(player),
186            _ => false,
187        }
188    }
189
190    fn record_pass(&mut self, frame: &FrameInfo, mut event: PassEvent) {
191        event.sample_time = frame.time;
192        event.sample_frame = frame.frame_number;
193        self.events.push(event);
194    }
195
196    pub fn update(
197        &mut self,
198        frame: &FrameInfo,
199        ball: &BallFrameState,
200        touch_state: &TouchState,
201        backboard_bounce_state: &BackboardBounceState,
202        fifty_fifty_state: &FiftyFiftyState,
203        live_play_state: &LivePlayState,
204    ) -> SubtrActorResult<()> {
205        self.events.begin_update();
206        if !live_play_state.is_live_play {
207            self.last_touch = None;
208            return Ok(());
209        }
210
211        let Some(ball_position) = ball.position() else {
212            return Ok(());
213        };
214
215        for touch in sequential_touch_events(&touch_state.touch_events) {
216            let Some(player) = touch.player.clone() else {
217                self.last_touch = None;
218                continue;
219            };
220
221            if let Some(pass_event) =
222                self.pass_event_for_touch(touch, &player, ball_position, backboard_bounce_state)
223            {
224                self.record_pass(frame, pass_event);
225            }
226
227            self.last_touch = Some(PendingPassTouch {
228                player,
229                player_position: touch
230                    .player_position
231                    .map(|position| vec_to_glam(&position).to_array()),
232                is_team_0: touch.team_is_team_0,
233                time: touch.time,
234                frame: touch.frame,
235                ball_position,
236                from_fifty_fifty: Self::touch_from_fifty_fifty(touch, fifty_fifty_state),
237            });
238        }
239        Ok(())
240    }
241}
242
243#[cfg(test)]
244#[path = "pass_tests.rs"]
245mod tests;