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;