subtr_actor/stats/calculators/
pass.rs1use 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)]
8#[serde(rename_all = "snake_case")]
9#[ts(export)]
10pub enum PassKind {
11 Direct,
12 Backboard,
13 FiftyFifty,
14 FiftyFiftyBackboard,
15}
16
17#[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#[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;