subtr_actor/stats/calculators/
positioning.rs1use super::*;
2
3const GOAL_CAUGHT_AHEAD_MAX_BALL_Y: f32 = -1200.0;
4const GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y: f32 = -250.0;
5const GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y: f32 = 2200.0;
6const DEFAULT_LEVEL_BALL_DEPTH_MARGIN: f32 = 150.0;
7
8#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
9#[ts(export)]
10pub struct PositioningStats {
11 pub active_game_time: f32,
12 pub tracked_time: f32,
13 pub sum_distance_to_teammates: f32,
14 pub sum_distance_to_ball: f32,
15 pub sum_distance_to_ball_has_possession: f32,
16 pub time_has_possession: f32,
17 pub sum_distance_to_ball_no_possession: f32,
18 pub time_no_possession: f32,
19 pub time_demolished: f32,
20 pub time_no_teammates: f32,
21 pub time_most_back: f32,
22 pub time_most_forward: f32,
23 pub time_mid_role: f32,
24 pub time_other_role: f32,
25 #[serde(rename = "time_defensive_third")]
26 pub time_defensive_zone: f32,
27 #[serde(rename = "time_neutral_third")]
28 pub time_neutral_zone: f32,
29 #[serde(rename = "time_offensive_third")]
30 pub time_offensive_zone: f32,
31 pub time_defensive_half: f32,
32 pub time_offensive_half: f32,
33 pub time_closest_to_ball: f32,
34 pub time_farthest_from_ball: f32,
35 pub time_behind_ball: f32,
36 pub time_level_with_ball: f32,
37 pub time_in_front_of_ball: f32,
38 pub times_caught_ahead_of_play_on_conceded_goals: u32,
39}
40
41impl PositioningStats {
42 pub fn average_distance_to_teammates(&self) -> f32 {
43 if self.tracked_time == 0.0 {
44 0.0
45 } else {
46 self.sum_distance_to_teammates / self.tracked_time
47 }
48 }
49
50 pub fn average_distance_to_ball(&self) -> f32 {
51 if self.tracked_time == 0.0 {
52 0.0
53 } else {
54 self.sum_distance_to_ball / self.tracked_time
55 }
56 }
57
58 pub fn average_distance_to_ball_has_possession(&self) -> f32 {
59 if self.time_has_possession == 0.0 {
60 0.0
61 } else {
62 self.sum_distance_to_ball_has_possession / self.time_has_possession
63 }
64 }
65
66 pub fn average_distance_to_ball_no_possession(&self) -> f32 {
67 if self.time_no_possession == 0.0 {
68 0.0
69 } else {
70 self.sum_distance_to_ball_no_possession / self.time_no_possession
71 }
72 }
73
74 fn pct(&self, value: f32) -> f32 {
75 if self.tracked_time == 0.0 {
76 0.0
77 } else {
78 value * 100.0 / self.tracked_time
79 }
80 }
81
82 pub fn most_back_pct(&self) -> f32 {
83 self.pct(self.time_most_back)
84 }
85
86 pub fn most_forward_pct(&self) -> f32 {
87 self.pct(self.time_most_forward)
88 }
89
90 pub fn mid_role_pct(&self) -> f32 {
91 self.pct(self.time_mid_role)
92 }
93
94 pub fn other_role_pct(&self) -> f32 {
95 self.pct(self.time_other_role)
96 }
97
98 pub fn defensive_third_pct(&self) -> f32 {
99 self.pct(self.time_defensive_zone)
100 }
101
102 pub fn neutral_third_pct(&self) -> f32 {
103 self.pct(self.time_neutral_zone)
104 }
105
106 pub fn offensive_third_pct(&self) -> f32 {
107 self.pct(self.time_offensive_zone)
108 }
109
110 pub fn defensive_zone_pct(&self) -> f32 {
111 self.defensive_third_pct()
112 }
113
114 pub fn neutral_zone_pct(&self) -> f32 {
115 self.neutral_third_pct()
116 }
117
118 pub fn offensive_zone_pct(&self) -> f32 {
119 self.offensive_third_pct()
120 }
121
122 pub fn defensive_half_pct(&self) -> f32 {
123 self.pct(self.time_defensive_half)
124 }
125
126 pub fn offensive_half_pct(&self) -> f32 {
127 self.pct(self.time_offensive_half)
128 }
129
130 pub fn closest_to_ball_pct(&self) -> f32 {
131 self.pct(self.time_closest_to_ball)
132 }
133
134 pub fn farthest_from_ball_pct(&self) -> f32 {
135 self.pct(self.time_farthest_from_ball)
136 }
137
138 pub fn behind_ball_pct(&self) -> f32 {
139 self.pct(self.time_behind_ball)
140 }
141
142 pub fn level_with_ball_pct(&self) -> f32 {
143 self.pct(self.time_level_with_ball)
144 }
145
146 pub fn in_front_of_ball_pct(&self) -> f32 {
147 self.pct(self.time_in_front_of_ball)
148 }
149}
150
151#[derive(Debug, Clone)]
152pub struct PositioningCalculatorConfig {
153 pub most_back_forward_threshold_y: f32,
154 pub level_ball_depth_margin: f32,
155}
156
157impl Default for PositioningCalculatorConfig {
158 fn default() -> Self {
159 Self {
160 most_back_forward_threshold_y: DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y,
161 level_ball_depth_margin: DEFAULT_LEVEL_BALL_DEPTH_MARGIN,
162 }
163 }
164}
165
166#[derive(Debug, Clone, Default)]
167pub struct PositioningCalculator {
168 config: PositioningCalculatorConfig,
169 player_stats: HashMap<PlayerId, PositioningStats>,
170 previous_ball_position: Option<glam::Vec3>,
171 previous_player_positions: HashMap<PlayerId, glam::Vec3>,
172}
173
174impl PositioningCalculator {
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn with_config(config: PositioningCalculatorConfig) -> Self {
180 Self {
181 config,
182 ..Self::default()
183 }
184 }
185
186 pub fn config(&self) -> &PositioningCalculatorConfig {
187 &self.config
188 }
189
190 pub fn player_stats(&self) -> &HashMap<PlayerId, PositioningStats> {
191 &self.player_stats
192 }
193
194 fn record_goal_positioning_events(
195 &mut self,
196 players: &PlayerFrameState,
197 events: &FrameEventsState,
198 ball_position: glam::Vec3,
199 ) {
200 for goal_event in &events.goal_events {
201 let defending_team_is_team_0 = !goal_event.scoring_team_is_team_0;
202 let normalized_ball_y = normalized_y(defending_team_is_team_0, ball_position);
203 if normalized_ball_y > GOAL_CAUGHT_AHEAD_MAX_BALL_Y {
204 continue;
205 }
206
207 for player in players
208 .players
209 .iter()
210 .filter(|player| player.is_team_0 == defending_team_is_team_0)
211 {
212 let Some(position) = player.position() else {
213 continue;
214 };
215 let normalized_player_y = normalized_y(defending_team_is_team_0, position);
216 if normalized_player_y < GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y {
217 continue;
218 }
219 if normalized_player_y - normalized_ball_y < GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y {
220 continue;
221 }
222
223 self.player_stats
224 .entry(player.player_id.clone())
225 .or_default()
226 .times_caught_ahead_of_play_on_conceded_goals += 1;
227 }
228 }
229 }
230
231 #[allow(clippy::too_many_arguments)]
232 fn process_sample(
233 &mut self,
234 frame: &FrameInfo,
235 gameplay: &GameplayState,
236 ball: &BallFrameState,
237 players: &PlayerFrameState,
238 events: &FrameEventsState,
239 live_play: bool,
240 possession_player_before_sample: Option<&PlayerId>,
241 ) -> SubtrActorResult<()> {
242 if frame.dt == 0.0 {
243 if let Some(ball) = ball.sample() {
244 self.previous_ball_position = Some(ball.position());
245 }
246 for player in &players.players {
247 if let Some(position) = player.position() {
248 self.previous_player_positions
249 .insert(player.player_id.clone(), position);
250 }
251 }
252 return Ok(());
253 }
254
255 let Some(ball) = ball.sample() else {
256 return Ok(());
257 };
258 let ball_position = ball.position();
259 if !events.goal_events.is_empty() {
260 self.record_goal_positioning_events(players, events, ball_position);
261 }
262 let demoed_players: HashSet<_> = events
263 .active_demos
264 .iter()
265 .map(|demo| demo.victim.clone())
266 .collect();
267
268 for player in &players.players {
269 let is_demoed = demoed_players.contains(&player.player_id);
270 if live_play && is_demoed {
271 let stats = self
272 .player_stats
273 .entry(player.player_id.clone())
274 .or_default();
275 stats.active_game_time += frame.dt;
276 stats.time_demolished += frame.dt;
277 continue;
278 }
279
280 let Some(position) = player.position() else {
281 continue;
282 };
283 let previous_position = self
284 .previous_player_positions
285 .get(&player.player_id)
286 .copied()
287 .unwrap_or(position);
288 let previous_ball_position = self.previous_ball_position.unwrap_or(ball_position);
289 let normalized_position_y = normalized_y(player.is_team_0, position);
290 let normalized_previous_position_y = normalized_y(player.is_team_0, previous_position);
291 let normalized_ball_y = normalized_y(player.is_team_0, ball_position);
292 let normalized_previous_ball_y = normalized_y(player.is_team_0, previous_ball_position);
293 let stats = self
294 .player_stats
295 .entry(player.player_id.clone())
296 .or_default();
297
298 if live_play {
299 stats.active_game_time += frame.dt;
300 stats.tracked_time += frame.dt;
301 stats.sum_distance_to_ball += position.distance(ball_position) * frame.dt;
302
303 if possession_player_before_sample == Some(&player.player_id) {
304 stats.time_has_possession += frame.dt;
305 stats.sum_distance_to_ball_has_possession +=
306 position.distance(ball_position) * frame.dt;
307 } else if possession_player_before_sample.is_some() {
308 stats.time_no_possession += frame.dt;
309 stats.sum_distance_to_ball_no_possession +=
310 position.distance(ball_position) * frame.dt;
311 }
312
313 let defensive_zone_fraction = interval_fraction_below_threshold(
314 normalized_previous_position_y,
315 normalized_position_y,
316 -FIELD_ZONE_BOUNDARY_Y,
317 );
318 let offensive_zone_fraction = interval_fraction_above_threshold(
319 normalized_previous_position_y,
320 normalized_position_y,
321 FIELD_ZONE_BOUNDARY_Y,
322 );
323 let neutral_zone_fraction = interval_fraction_in_scalar_range(
324 normalized_previous_position_y,
325 normalized_position_y,
326 -FIELD_ZONE_BOUNDARY_Y,
327 FIELD_ZONE_BOUNDARY_Y,
328 );
329 stats.time_defensive_zone += frame.dt * defensive_zone_fraction;
330 stats.time_neutral_zone += frame.dt * neutral_zone_fraction;
331 stats.time_offensive_zone += frame.dt * offensive_zone_fraction;
332
333 let defensive_half_fraction = interval_fraction_below_threshold(
334 normalized_previous_position_y,
335 normalized_position_y,
336 0.0,
337 );
338 stats.time_defensive_half += frame.dt * defensive_half_fraction;
339 stats.time_offensive_half += frame.dt * (1.0 - defensive_half_fraction);
340
341 let previous_ball_delta =
342 normalized_previous_position_y - normalized_previous_ball_y;
343 let current_ball_delta = normalized_position_y - normalized_ball_y;
344 let (behind_ball_fraction, level_ball_fraction, in_front_ball_fraction) =
345 ball_depth_fractions(
346 self.config.level_ball_depth_margin,
347 previous_ball_delta,
348 current_ball_delta,
349 );
350 stats.time_behind_ball += frame.dt * behind_ball_fraction;
351 stats.time_level_with_ball += frame.dt * level_ball_fraction;
352 stats.time_in_front_of_ball += frame.dt * in_front_ball_fraction;
353 }
354 }
355
356 if live_play {
357 for is_team_0 in [true, false] {
358 let team_present_player_count = players
359 .players
360 .iter()
361 .filter(|player| player.is_team_0 == is_team_0)
362 .count();
363 let team_roster_count = gameplay.current_in_game_team_player_count(is_team_0).max(
364 players
365 .players
366 .iter()
367 .filter(|player| player.is_team_0 == is_team_0)
368 .count(),
369 );
370 let team_players: Vec<_> = players
371 .players
372 .iter()
373 .filter(|player| player.is_team_0 == is_team_0)
374 .filter(|player| !demoed_players.contains(&player.player_id))
375 .filter_map(|player| player.position().map(|position| (player, position)))
376 .collect();
377
378 if team_players.is_empty() {
379 continue;
380 }
381
382 for (player, position) in &team_players {
383 let teammate_distance_sum: f32 = team_players
384 .iter()
385 .filter(|(other_player, _)| other_player.player_id != player.player_id)
386 .map(|(_, other_position)| position.distance(*other_position))
387 .sum();
388 let teammate_count = team_players.len().saturating_sub(1);
389 if teammate_count > 0 {
390 let stats = self
391 .player_stats
392 .entry(player.player_id.clone())
393 .or_default();
394 stats.sum_distance_to_teammates +=
395 teammate_distance_sum * frame.dt / teammate_count as f32;
396 }
397 }
398
399 if team_roster_count < 2
400 || team_present_player_count < team_roster_count
401 || team_players.len() < 2
402 {
403 for (player, _) in &team_players {
404 self.player_stats
405 .entry(player.player_id.clone())
406 .or_default()
407 .time_no_teammates += frame.dt;
408 }
409 } else {
410 let mut sorted_team: Vec<_> = team_players
411 .iter()
412 .map(|(info, pos)| (info.player_id.clone(), normalized_y(is_team_0, *pos)))
413 .collect();
414 sorted_team.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
415
416 let team_spread = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0)
417 - sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
418
419 if team_spread <= self.config.most_back_forward_threshold_y {
420 for (player_id, _) in &sorted_team {
421 self.player_stats
422 .entry(player_id.clone())
423 .or_default()
424 .time_other_role += frame.dt;
425 }
426 } else {
427 let min_y = sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
428 let max_y = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0);
429 let can_assign_mid_role = sorted_team.len() == 3;
430
431 for (player_id, y) in &sorted_team {
432 let near_back =
433 (*y - min_y) <= self.config.most_back_forward_threshold_y;
434 let near_front =
435 (max_y - *y) <= self.config.most_back_forward_threshold_y;
436
437 if near_back && !near_front {
438 self.player_stats
439 .entry(player_id.clone())
440 .or_default()
441 .time_most_back += frame.dt;
442 } else if near_front && !near_back {
443 self.player_stats
444 .entry(player_id.clone())
445 .or_default()
446 .time_most_forward += frame.dt;
447 } else if can_assign_mid_role {
448 self.player_stats
449 .entry(player_id.clone())
450 .or_default()
451 .time_mid_role += frame.dt;
452 } else {
453 self.player_stats
454 .entry(player_id.clone())
455 .or_default()
456 .time_other_role += frame.dt;
457 }
458 }
459 }
460 }
461
462 if let Some((closest_player, _)) = team_players.iter().min_by(|(_, a), (_, b)| {
463 a.distance(ball_position)
464 .partial_cmp(&b.distance(ball_position))
465 .unwrap()
466 }) {
467 self.player_stats
468 .entry(closest_player.player_id.clone())
469 .or_default()
470 .time_closest_to_ball += frame.dt;
471 }
472
473 if let Some((farthest_player, _)) = team_players.iter().max_by(|(_, a), (_, b)| {
474 a.distance(ball_position)
475 .partial_cmp(&b.distance(ball_position))
476 .unwrap()
477 }) {
478 self.player_stats
479 .entry(farthest_player.player_id.clone())
480 .or_default()
481 .time_farthest_from_ball += frame.dt;
482 }
483 }
484 }
485
486 self.previous_ball_position = Some(ball_position);
487 for player in &players.players {
488 if let Some(position) = player.position() {
489 self.previous_player_positions
490 .insert(player.player_id.clone(), position);
491 }
492 }
493
494 Ok(())
495 }
496
497 #[allow(clippy::too_many_arguments)]
498 pub fn update(
499 &mut self,
500 frame: &FrameInfo,
501 gameplay: &GameplayState,
502 ball: &BallFrameState,
503 players: &PlayerFrameState,
504 events: &FrameEventsState,
505 live_play: bool,
506 possession_player_before_sample: Option<&PlayerId>,
507 ) -> SubtrActorResult<()> {
508 self.process_sample(
509 frame,
510 gameplay,
511 ball,
512 players,
513 events,
514 live_play,
515 possession_player_before_sample,
516 )
517 }
518}
519
520fn ball_depth_fractions(level_margin: f32, start_delta: f32, end_delta: f32) -> (f32, f32, f32) {
521 let behind_fraction = interval_fraction_below_threshold(start_delta, end_delta, -level_margin);
522 let level_fraction =
523 interval_fraction_in_scalar_range(start_delta, end_delta, -level_margin, level_margin);
524 let in_front_fraction = (1.0 - behind_fraction - level_fraction).clamp(0.0, 1.0);
525 (behind_fraction, level_fraction, in_front_fraction)
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn ball_depth_fractions_treat_near_ball_band_as_level() {
534 let (behind, level, in_front) = ball_depth_fractions(150.0, -100.0, 100.0);
535 assert_eq!(behind, 0.0);
536 assert_eq!(level, 1.0);
537 assert_eq!(in_front, 0.0);
538 }
539
540 #[test]
541 fn ball_depth_fractions_split_crossing_time_across_all_three_buckets() {
542 let (behind, level, in_front) = ball_depth_fractions(150.0, -300.0, 300.0);
543 assert!((behind - 0.25).abs() < 1e-6);
544 assert!((level - 0.5).abs() < 1e-6);
545 assert!((in_front - 0.25).abs() < 1e-6);
546 }
547
548 #[test]
549 fn ball_depth_fractions_count_boundary_point_as_in_front_not_level() {
550 let (behind, level, in_front) = ball_depth_fractions(150.0, 150.0, 150.0);
551 assert_eq!(behind, 0.0);
552 assert_eq!(level, 0.0);
553 assert_eq!(in_front, 1.0);
554 }
555}