subtr_actor/stats/calculators/
center.rs1use super::*;
2
3const CENTER_MAX_DURATION_SECONDS: f32 = 3.0;
4const CENTER_MIN_BALL_TRAVEL_DISTANCE: f32 = 500.0;
5const CENTER_MIN_LATERAL_DISTANCE: f32 = 500.0;
6const CENTER_MIN_START_ABS_X: f32 = 1600.0;
7const CENTER_MAX_END_ABS_X: f32 = 1400.0;
8const CENTER_MIN_START_ATTACKING_Y: f32 = BOOST_PAD_MIDFIELD_TOLERANCE_Y;
9const CENTER_MIN_END_ATTACKING_Y: f32 = FIELD_ZONE_BOUNDARY_Y;
10
11#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
12#[ts(export)]
13pub struct CenterEvent {
14 pub time: f32,
15 pub frame: usize,
16 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
17 pub player: PlayerId,
18 pub is_team_0: bool,
19 pub start_time: f32,
20 pub start_frame: usize,
21 pub duration: f32,
22 pub start_ball_position: [f32; 3],
23 pub end_ball_position: [f32; 3],
24 pub ball_travel_distance: f32,
25 pub ball_advance_distance: f32,
26 pub lateral_centering_distance: f32,
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
30#[ts(export)]
31pub struct CenterPlayerStats {
32 pub count: u32,
33 pub total_ball_travel_distance: f32,
34 pub total_ball_advance_distance: f32,
35 pub total_lateral_centering_distance: f32,
36 pub longest_center_distance: f32,
37 pub is_last_center: bool,
38 pub last_center_time: Option<f32>,
39 pub last_center_frame: Option<usize>,
40 pub time_since_last_center: Option<f32>,
41 pub frames_since_last_center: Option<usize>,
42}
43
44impl CenterPlayerStats {
45 pub fn average_ball_travel_distance(&self) -> f32 {
46 if self.count == 0 {
47 0.0
48 } else {
49 self.total_ball_travel_distance / self.count as f32
50 }
51 }
52
53 pub fn average_ball_advance_distance(&self) -> f32 {
54 if self.count == 0 {
55 0.0
56 } else {
57 self.total_ball_advance_distance / self.count as f32
58 }
59 }
60
61 pub fn average_lateral_centering_distance(&self) -> f32 {
62 if self.count == 0 {
63 0.0
64 } else {
65 self.total_lateral_centering_distance / self.count as f32
66 }
67 }
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
71#[ts(export)]
72pub struct CenterTeamStats {
73 pub count: u32,
74 pub total_ball_travel_distance: f32,
75 pub total_ball_advance_distance: f32,
76 pub total_lateral_centering_distance: f32,
77 pub longest_center_distance: f32,
78}
79
80impl CenterTeamStats {
81 pub fn average_ball_travel_distance(&self) -> f32 {
82 if self.count == 0 {
83 0.0
84 } else {
85 self.total_ball_travel_distance / self.count as f32
86 }
87 }
88
89 pub fn average_ball_advance_distance(&self) -> f32 {
90 if self.count == 0 {
91 0.0
92 } else {
93 self.total_ball_advance_distance / self.count as f32
94 }
95 }
96
97 pub fn average_lateral_centering_distance(&self) -> f32 {
98 if self.count == 0 {
99 0.0
100 } else {
101 self.total_lateral_centering_distance / self.count as f32
102 }
103 }
104}
105
106#[derive(Debug, Clone)]
107struct PendingCenterTouch {
108 player: PlayerId,
109 is_team_0: bool,
110 time: f32,
111 frame: usize,
112 ball_position: glam::Vec3,
113}
114
115#[derive(Debug, Clone, Default)]
116pub struct CenterCalculator {
117 player_stats: HashMap<PlayerId, CenterPlayerStats>,
118 team_zero_stats: CenterTeamStats,
119 team_one_stats: CenterTeamStats,
120 events: Vec<CenterEvent>,
121 pending_touch: Option<PendingCenterTouch>,
122 current_last_center_player: Option<PlayerId>,
123}
124
125impl CenterCalculator {
126 pub fn new() -> Self {
127 Self::default()
128 }
129
130 pub fn player_stats(&self) -> &HashMap<PlayerId, CenterPlayerStats> {
131 &self.player_stats
132 }
133
134 pub fn team_zero_stats(&self) -> &CenterTeamStats {
135 &self.team_zero_stats
136 }
137
138 pub fn team_one_stats(&self) -> &CenterTeamStats {
139 &self.team_one_stats
140 }
141
142 pub fn events(&self) -> &[CenterEvent] {
143 &self.events
144 }
145
146 fn begin_sample(&mut self, frame: &FrameInfo) {
147 for stats in self.player_stats.values_mut() {
148 stats.is_last_center = false;
149 stats.time_since_last_center = stats
150 .last_center_time
151 .map(|time| (frame.time - time).max(0.0));
152 stats.frames_since_last_center = stats
153 .last_center_frame
154 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
155 }
156 }
157
158 fn center_event_for_position(
159 pending: &PendingCenterTouch,
160 frame: &FrameInfo,
161 ball_position: glam::Vec3,
162 ) -> Option<CenterEvent> {
163 let duration = frame.time - pending.time;
164 if !(0.0..=CENTER_MAX_DURATION_SECONDS).contains(&duration) {
165 return None;
166 }
167
168 let start_normalized_y = normalized_y(pending.is_team_0, pending.ball_position);
169 let end_normalized_y = normalized_y(pending.is_team_0, ball_position);
170 if start_normalized_y < CENTER_MIN_START_ATTACKING_Y
171 || end_normalized_y < CENTER_MIN_END_ATTACKING_Y
172 {
173 return None;
174 }
175
176 let start_abs_x = pending.ball_position.x.abs();
177 let end_abs_x = ball_position.x.abs();
178 let lateral_centering_distance = start_abs_x - end_abs_x;
179 if start_abs_x < CENTER_MIN_START_ABS_X
180 || end_abs_x > CENTER_MAX_END_ABS_X
181 || lateral_centering_distance < CENTER_MIN_LATERAL_DISTANCE
182 {
183 return None;
184 }
185
186 let ball_delta = ball_position - pending.ball_position;
187 let ball_travel_distance = ball_delta.length();
188 if ball_travel_distance < CENTER_MIN_BALL_TRAVEL_DISTANCE {
189 return None;
190 }
191
192 let team_forward_sign = if pending.is_team_0 { 1.0 } else { -1.0 };
193 Some(CenterEvent {
194 time: frame.time,
195 frame: frame.frame_number,
196 player: pending.player.clone(),
197 is_team_0: pending.is_team_0,
198 start_time: pending.time,
199 start_frame: pending.frame,
200 duration,
201 start_ball_position: pending.ball_position.to_array(),
202 end_ball_position: ball_position.to_array(),
203 ball_travel_distance,
204 ball_advance_distance: ball_delta.y * team_forward_sign,
205 lateral_centering_distance,
206 })
207 }
208
209 fn record_center(&mut self, frame: &FrameInfo, event: CenterEvent) {
210 let player_stats = self.player_stats.entry(event.player.clone()).or_default();
211 player_stats.count += 1;
212 player_stats.total_ball_travel_distance += event.ball_travel_distance;
213 player_stats.total_ball_advance_distance += event.ball_advance_distance;
214 player_stats.total_lateral_centering_distance += event.lateral_centering_distance;
215 player_stats.longest_center_distance = player_stats
216 .longest_center_distance
217 .max(event.ball_travel_distance);
218 player_stats.last_center_time = Some(event.time);
219 player_stats.last_center_frame = Some(event.frame);
220 player_stats.time_since_last_center = Some((frame.time - event.time).max(0.0));
221 player_stats.frames_since_last_center =
222 Some(frame.frame_number.saturating_sub(event.frame));
223
224 let team_stats = if event.is_team_0 {
225 &mut self.team_zero_stats
226 } else {
227 &mut self.team_one_stats
228 };
229 team_stats.count += 1;
230 team_stats.total_ball_travel_distance += event.ball_travel_distance;
231 team_stats.total_ball_advance_distance += event.ball_advance_distance;
232 team_stats.total_lateral_centering_distance += event.lateral_centering_distance;
233 team_stats.longest_center_distance = team_stats
234 .longest_center_distance
235 .max(event.ball_travel_distance);
236
237 self.current_last_center_player = Some(event.player.clone());
238 self.events.push(event);
239 self.pending_touch = None;
240 }
241
242 fn update_pending_center(&mut self, frame: &FrameInfo, ball_position: glam::Vec3) {
243 let Some(pending) = self.pending_touch.as_ref() else {
244 return;
245 };
246 let duration = frame.time - pending.time;
247 if duration > CENTER_MAX_DURATION_SECONDS {
248 self.pending_touch = None;
249 return;
250 }
251
252 if let Some(event) = Self::center_event_for_position(pending, frame, ball_position) {
253 self.record_center(frame, event);
254 }
255 }
256
257 fn player_has_disqualifying_event(
258 events: &FrameEventsState,
259 player: &PlayerId,
260 is_team_0: bool,
261 ) -> bool {
262 events.player_stat_events.iter().any(|event| {
263 event.kind == PlayerStatEventKind::Shot
264 && event.player == *player
265 && event.is_team_0 == is_team_0
266 }) || events
267 .goal_events
268 .iter()
269 .any(|event| match event.player.as_ref() {
270 Some(scorer) => scorer == player,
271 None => event.scoring_team_is_team_0 == is_team_0,
272 })
273 }
274
275 fn clear_disqualified_pending_center(&mut self, events: &FrameEventsState) {
276 let should_clear = self.pending_touch.as_ref().is_some_and(|pending| {
277 Self::player_has_disqualifying_event(events, &pending.player, pending.is_team_0)
278 });
279 if should_clear {
280 self.pending_touch = None;
281 }
282 }
283
284 pub fn update(
285 &mut self,
286 frame: &FrameInfo,
287 ball: &BallFrameState,
288 touch_state: &TouchState,
289 frame_events: &FrameEventsState,
290 live_play: bool,
291 ) -> SubtrActorResult<()> {
292 self.begin_sample(frame);
293 if !live_play {
294 self.pending_touch = None;
295 self.current_last_center_player = None;
296 return Ok(());
297 }
298
299 let Some(ball_position) = ball.position() else {
300 return Ok(());
301 };
302
303 self.clear_disqualified_pending_center(frame_events);
304 self.update_pending_center(frame, ball_position);
305
306 for touch in &touch_state.touch_events {
307 let Some(player) = touch.player.clone() else {
308 self.pending_touch = None;
309 continue;
310 };
311
312 if Self::player_has_disqualifying_event(frame_events, &player, touch.team_is_team_0) {
313 self.pending_touch = None;
314 continue;
315 }
316
317 self.pending_touch = Some(PendingCenterTouch {
318 player,
319 is_team_0: touch.team_is_team_0,
320 time: touch.time,
321 frame: touch.frame,
322 ball_position,
323 });
324 }
325
326 if let Some(player_id) = self.current_last_center_player.as_ref() {
327 if let Some(stats) = self.player_stats.get_mut(player_id) {
328 stats.is_last_center = true;
329 }
330 }
331
332 Ok(())
333 }
334}
335
336#[cfg(test)]
337#[path = "center_tests.rs"]
338mod tests;