1use 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;
6
7#[derive(Debug, Clone, Default, PartialEq, Serialize)]
8pub struct PositioningStats {
9 pub active_game_time: f32,
10 pub tracked_time: f32,
11 pub sum_distance_to_teammates: f32,
12 pub sum_distance_to_ball: f32,
13 pub sum_distance_to_ball_has_possession: f32,
14 pub time_has_possession: f32,
15 pub sum_distance_to_ball_no_possession: f32,
16 pub time_no_possession: f32,
17 pub time_demolished: f32,
18 pub time_no_teammates: f32,
19 pub time_most_back: f32,
20 pub time_most_forward: f32,
21 pub time_mid_role: f32,
22 pub time_other_role: f32,
23 #[serde(rename = "time_defensive_third")]
24 pub time_defensive_zone: f32,
25 #[serde(rename = "time_neutral_third")]
26 pub time_neutral_zone: f32,
27 #[serde(rename = "time_offensive_third")]
28 pub time_offensive_zone: f32,
29 pub time_defensive_half: f32,
30 pub time_offensive_half: f32,
31 pub time_closest_to_ball: f32,
32 pub time_farthest_from_ball: f32,
33 pub time_behind_ball: f32,
34 pub time_in_front_of_ball: f32,
35 pub times_caught_ahead_of_play_on_conceded_goals: u32,
36}
37
38impl PositioningStats {
39 pub fn average_distance_to_teammates(&self) -> f32 {
40 if self.tracked_time == 0.0 {
41 0.0
42 } else {
43 self.sum_distance_to_teammates / self.tracked_time
44 }
45 }
46
47 pub fn average_distance_to_ball(&self) -> f32 {
48 if self.tracked_time == 0.0 {
49 0.0
50 } else {
51 self.sum_distance_to_ball / self.tracked_time
52 }
53 }
54
55 pub fn average_distance_to_ball_has_possession(&self) -> f32 {
56 if self.time_has_possession == 0.0 {
57 0.0
58 } else {
59 self.sum_distance_to_ball_has_possession / self.time_has_possession
60 }
61 }
62
63 pub fn average_distance_to_ball_no_possession(&self) -> f32 {
64 if self.time_no_possession == 0.0 {
65 0.0
66 } else {
67 self.sum_distance_to_ball_no_possession / self.time_no_possession
68 }
69 }
70
71 fn pct(&self, value: f32) -> f32 {
72 if self.tracked_time == 0.0 {
73 0.0
74 } else {
75 value * 100.0 / self.tracked_time
76 }
77 }
78
79 pub fn most_back_pct(&self) -> f32 {
80 self.pct(self.time_most_back)
81 }
82
83 pub fn most_forward_pct(&self) -> f32 {
84 self.pct(self.time_most_forward)
85 }
86
87 pub fn mid_role_pct(&self) -> f32 {
88 self.pct(self.time_mid_role)
89 }
90
91 pub fn other_role_pct(&self) -> f32 {
92 self.pct(self.time_other_role)
93 }
94
95 pub fn defensive_third_pct(&self) -> f32 {
96 self.pct(self.time_defensive_zone)
97 }
98
99 pub fn neutral_third_pct(&self) -> f32 {
100 self.pct(self.time_neutral_zone)
101 }
102
103 pub fn offensive_third_pct(&self) -> f32 {
104 self.pct(self.time_offensive_zone)
105 }
106
107 pub fn defensive_zone_pct(&self) -> f32 {
108 self.defensive_third_pct()
109 }
110
111 pub fn neutral_zone_pct(&self) -> f32 {
112 self.neutral_third_pct()
113 }
114
115 pub fn offensive_zone_pct(&self) -> f32 {
116 self.offensive_third_pct()
117 }
118
119 pub fn defensive_half_pct(&self) -> f32 {
120 self.pct(self.time_defensive_half)
121 }
122
123 pub fn offensive_half_pct(&self) -> f32 {
124 self.pct(self.time_offensive_half)
125 }
126
127 pub fn closest_to_ball_pct(&self) -> f32 {
128 self.pct(self.time_closest_to_ball)
129 }
130
131 pub fn farthest_from_ball_pct(&self) -> f32 {
132 self.pct(self.time_farthest_from_ball)
133 }
134
135 pub fn behind_ball_pct(&self) -> f32 {
136 self.pct(self.time_behind_ball)
137 }
138
139 pub fn in_front_of_ball_pct(&self) -> f32 {
140 self.pct(self.time_in_front_of_ball)
141 }
142}
143
144#[derive(Debug, Clone)]
145pub struct PositioningReducerConfig {
146 pub most_back_forward_threshold_y: f32,
147}
148
149impl Default for PositioningReducerConfig {
150 fn default() -> Self {
151 Self {
152 most_back_forward_threshold_y: DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y,
153 }
154 }
155}
156
157#[derive(Debug, Clone, Default)]
158pub struct PositioningReducer {
159 config: PositioningReducerConfig,
160 player_stats: HashMap<PlayerId, PositioningStats>,
161 previous_ball_position: Option<glam::Vec3>,
162 previous_player_positions: HashMap<PlayerId, glam::Vec3>,
163 possession_tracker: PossessionTracker,
164 live_play_tracker: LivePlayTracker,
165}
166
167impl PositioningReducer {
168 pub fn new() -> Self {
169 Self::default()
170 }
171
172 pub fn with_config(config: PositioningReducerConfig) -> Self {
173 Self {
174 config,
175 ..Self::default()
176 }
177 }
178
179 pub fn config(&self) -> &PositioningReducerConfig {
180 &self.config
181 }
182
183 pub fn player_stats(&self) -> &HashMap<PlayerId, PositioningStats> {
184 &self.player_stats
185 }
186
187 fn record_goal_positioning_events(&mut self, sample: &StatsSample, ball_position: glam::Vec3) {
188 for goal_event in &sample.goal_events {
189 let defending_team_is_team_0 = !goal_event.scoring_team_is_team_0;
190 let normalized_ball_y = normalized_y(defending_team_is_team_0, ball_position);
191 if normalized_ball_y > GOAL_CAUGHT_AHEAD_MAX_BALL_Y {
192 continue;
193 }
194
195 for player in sample
196 .players
197 .iter()
198 .filter(|player| player.is_team_0 == defending_team_is_team_0)
199 {
200 let Some(position) = player.position() else {
201 continue;
202 };
203 let normalized_player_y = normalized_y(defending_team_is_team_0, position);
204 if normalized_player_y < GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y {
205 continue;
206 }
207 if normalized_player_y - normalized_ball_y < GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y {
208 continue;
209 }
210
211 self.player_stats
212 .entry(player.player_id.clone())
213 .or_default()
214 .times_caught_ahead_of_play_on_conceded_goals += 1;
215 }
216 }
217 }
218
219 fn process_sample(
220 &mut self,
221 sample: &StatsSample,
222 live_play: bool,
223 possession_player_before_sample: Option<&PlayerId>,
224 ) -> SubtrActorResult<()> {
225 if sample.dt == 0.0 {
226 if let Some(ball) = &sample.ball {
227 self.previous_ball_position = Some(ball.position());
228 }
229 for player in &sample.players {
230 if let Some(position) = player.position() {
231 self.previous_player_positions
232 .insert(player.player_id.clone(), position);
233 }
234 }
235 return Ok(());
236 }
237
238 let Some(ball) = &sample.ball else {
239 return Ok(());
240 };
241 let ball_position = ball.position();
242 if !sample.goal_events.is_empty() {
243 self.record_goal_positioning_events(sample, ball_position);
244 }
245 let demoed_players: HashSet<_> = sample
246 .active_demos
247 .iter()
248 .map(|demo| demo.victim.clone())
249 .collect();
250
251 for player in &sample.players {
252 let is_demoed = demoed_players.contains(&player.player_id);
253 if live_play && is_demoed {
254 let stats = self
255 .player_stats
256 .entry(player.player_id.clone())
257 .or_default();
258 stats.active_game_time += sample.dt;
259 stats.time_demolished += sample.dt;
260 continue;
261 }
262
263 let Some(position) = player.position() else {
264 continue;
265 };
266 let previous_position = self
267 .previous_player_positions
268 .get(&player.player_id)
269 .copied()
270 .unwrap_or(position);
271 let previous_ball_position = self.previous_ball_position.unwrap_or(ball_position);
272 let normalized_position_y = normalized_y(player.is_team_0, position);
273 let normalized_previous_position_y = normalized_y(player.is_team_0, previous_position);
274 let normalized_ball_y = normalized_y(player.is_team_0, ball_position);
275 let normalized_previous_ball_y = normalized_y(player.is_team_0, previous_ball_position);
276 let stats = self
277 .player_stats
278 .entry(player.player_id.clone())
279 .or_default();
280
281 if live_play {
282 stats.active_game_time += sample.dt;
283 stats.tracked_time += sample.dt;
284 stats.sum_distance_to_ball += position.distance(ball_position) * sample.dt;
285
286 if possession_player_before_sample == Some(&player.player_id) {
287 stats.time_has_possession += sample.dt;
288 stats.sum_distance_to_ball_has_possession +=
289 position.distance(ball_position) * sample.dt;
290 } else if possession_player_before_sample.is_some() {
291 stats.time_no_possession += sample.dt;
292 stats.sum_distance_to_ball_no_possession +=
293 position.distance(ball_position) * sample.dt;
294 }
295
296 let defensive_zone_fraction = interval_fraction_below_threshold(
297 normalized_previous_position_y,
298 normalized_position_y,
299 -FIELD_ZONE_BOUNDARY_Y,
300 );
301 let offensive_zone_fraction = interval_fraction_above_threshold(
302 normalized_previous_position_y,
303 normalized_position_y,
304 FIELD_ZONE_BOUNDARY_Y,
305 );
306 let neutral_zone_fraction = interval_fraction_in_scalar_range(
307 normalized_previous_position_y,
308 normalized_position_y,
309 -FIELD_ZONE_BOUNDARY_Y,
310 FIELD_ZONE_BOUNDARY_Y,
311 );
312 stats.time_defensive_zone += sample.dt * defensive_zone_fraction;
313 stats.time_neutral_zone += sample.dt * neutral_zone_fraction;
314 stats.time_offensive_zone += sample.dt * offensive_zone_fraction;
315
316 let defensive_half_fraction = interval_fraction_below_threshold(
317 normalized_previous_position_y,
318 normalized_position_y,
319 0.0,
320 );
321 stats.time_defensive_half += sample.dt * defensive_half_fraction;
322 stats.time_offensive_half += sample.dt * (1.0 - defensive_half_fraction);
323
324 let previous_ball_delta =
325 normalized_previous_position_y - normalized_previous_ball_y;
326 let current_ball_delta = normalized_position_y - normalized_ball_y;
327 let behind_ball_fraction =
328 interval_fraction_below_threshold(previous_ball_delta, current_ball_delta, 0.0);
329 stats.time_behind_ball += sample.dt * behind_ball_fraction;
330 stats.time_in_front_of_ball += sample.dt * (1.0 - behind_ball_fraction);
331 }
332 }
333
334 if live_play {
335 for is_team_0 in [true, false] {
336 let team_present_player_count = sample
337 .players
338 .iter()
339 .filter(|player| player.is_team_0 == is_team_0)
340 .count();
341 let team_roster_count = sample.current_in_game_team_player_count(is_team_0).max(
342 sample
343 .players
344 .iter()
345 .filter(|player| player.is_team_0 == is_team_0)
346 .count(),
347 );
348 let team_players: Vec<_> = sample
349 .players
350 .iter()
351 .filter(|player| player.is_team_0 == is_team_0)
352 .filter(|player| !demoed_players.contains(&player.player_id))
353 .filter_map(|player| player.position().map(|position| (player, position)))
354 .collect();
355
356 if team_players.is_empty() {
357 continue;
358 }
359
360 for (player, position) in &team_players {
361 let teammate_distance_sum: f32 = team_players
362 .iter()
363 .filter(|(other_player, _)| other_player.player_id != player.player_id)
364 .map(|(_, other_position)| position.distance(*other_position))
365 .sum();
366 let teammate_count = team_players.len().saturating_sub(1);
367 if teammate_count > 0 {
368 let stats = self
369 .player_stats
370 .entry(player.player_id.clone())
371 .or_default();
372 stats.sum_distance_to_teammates +=
373 teammate_distance_sum * sample.dt / teammate_count as f32;
374 }
375 }
376
377 if team_roster_count < 2
378 || team_present_player_count < team_roster_count
379 || team_players.len() < 2
380 {
381 for (player, _) in &team_players {
382 self.player_stats
383 .entry(player.player_id.clone())
384 .or_default()
385 .time_no_teammates += sample.dt;
386 }
387 } else {
388 let mut sorted_team: Vec<_> = team_players
389 .iter()
390 .map(|(info, pos)| (info.player_id.clone(), normalized_y(is_team_0, *pos)))
391 .collect();
392 sorted_team.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
393
394 let team_spread = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0)
395 - sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
396
397 if team_spread <= self.config.most_back_forward_threshold_y {
398 for (player_id, _) in &sorted_team {
399 self.player_stats
400 .entry(player_id.clone())
401 .or_default()
402 .time_other_role += sample.dt;
403 }
404 } else {
405 let min_y = sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
406 let max_y = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0);
407 let can_assign_mid_role = sorted_team.len() == 3;
408
409 for (player_id, y) in &sorted_team {
410 let near_back =
411 (*y - min_y) <= self.config.most_back_forward_threshold_y;
412 let near_front =
413 (max_y - *y) <= self.config.most_back_forward_threshold_y;
414
415 if near_back && !near_front {
416 self.player_stats
417 .entry(player_id.clone())
418 .or_default()
419 .time_most_back += sample.dt;
420 } else if near_front && !near_back {
421 self.player_stats
422 .entry(player_id.clone())
423 .or_default()
424 .time_most_forward += sample.dt;
425 } else if can_assign_mid_role {
426 self.player_stats
427 .entry(player_id.clone())
428 .or_default()
429 .time_mid_role += sample.dt;
430 } else {
431 self.player_stats
432 .entry(player_id.clone())
433 .or_default()
434 .time_other_role += sample.dt;
435 }
436 }
437 }
438 }
439
440 if let Some((closest_player, _)) = team_players.iter().min_by(|(_, a), (_, b)| {
441 a.distance(ball_position)
442 .partial_cmp(&b.distance(ball_position))
443 .unwrap()
444 }) {
445 self.player_stats
446 .entry(closest_player.player_id.clone())
447 .or_default()
448 .time_closest_to_ball += sample.dt;
449 }
450
451 if let Some((farthest_player, _)) = team_players.iter().max_by(|(_, a), (_, b)| {
452 a.distance(ball_position)
453 .partial_cmp(&b.distance(ball_position))
454 .unwrap()
455 }) {
456 self.player_stats
457 .entry(farthest_player.player_id.clone())
458 .or_default()
459 .time_farthest_from_ball += sample.dt;
460 }
461 }
462 }
463
464 self.previous_ball_position = Some(ball_position);
465 for player in &sample.players {
466 if let Some(position) = player.position() {
467 self.previous_player_positions
468 .insert(player.player_id.clone(), position);
469 }
470 }
471
472 Ok(())
473 }
474}
475
476impl StatsReducer for PositioningReducer {
477 fn on_sample(&mut self, sample: &StatsSample) -> SubtrActorResult<()> {
478 let live_play = self.live_play_tracker.is_live_play(sample);
479 let possession_player_before_sample = if live_play {
480 let possession_state = self.possession_tracker.update(sample, &sample.touch_events);
481 possession_state.active_player_before_sample
482 } else {
483 self.possession_tracker.reset();
484 None
485 };
486 self.process_sample(sample, live_play, possession_player_before_sample.as_ref())?;
487 Ok(())
488 }
489
490 fn on_sample_with_context(
491 &mut self,
492 sample: &StatsSample,
493 ctx: &AnalysisContext,
494 ) -> SubtrActorResult<()> {
495 let live_play = self.live_play_tracker.is_live_play(sample);
496 let possession_player_before_sample = ctx
497 .get::<PossessionState>(POSSESSION_STATE_SIGNAL_ID)
498 .and_then(|state| state.active_player_before_sample.as_ref());
499 self.process_sample(sample, live_play, possession_player_before_sample)?;
500 Ok(())
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use boxcars::{Quaternion, RemoteId, RigidBody, Vector3f};
507
508 use super::*;
509
510 fn rigid_body(y: f32) -> RigidBody {
511 RigidBody {
512 sleeping: false,
513 location: Vector3f { x: 0.0, y, z: 17.0 },
514 rotation: Quaternion {
515 x: 0.0,
516 y: 0.0,
517 z: 0.0,
518 w: 1.0,
519 },
520 linear_velocity: Some(Vector3f {
521 x: 0.0,
522 y: 0.0,
523 z: 0.0,
524 }),
525 angular_velocity: Some(Vector3f {
526 x: 0.0,
527 y: 0.0,
528 z: 0.0,
529 }),
530 }
531 }
532
533 fn player(player_id: u64, is_team_0: bool, y: f32) -> PlayerSample {
534 PlayerSample {
535 player_id: RemoteId::Steam(player_id),
536 is_team_0,
537 rigid_body: Some(rigid_body(y)),
538 boost_amount: None,
539 last_boost_amount: None,
540 boost_active: false,
541 powerslide_active: false,
542 match_goals: Some(0),
543 match_assists: Some(0),
544 match_saves: Some(0),
545 match_shots: Some(0),
546 match_score: Some(0),
547 }
548 }
549
550 fn sample(
551 frame_number: usize,
552 time: f32,
553 touch_players: &[(u64, bool)],
554 kickoff_phase_active: bool,
555 ) -> StatsSample {
556 StatsSample {
557 frame_number,
558 time,
559 dt: 1.0,
560 seconds_remaining: None,
561 game_state: kickoff_phase_active.then_some(55),
562 ball_has_been_hit: Some(!kickoff_phase_active),
563 kickoff_countdown_time: kickoff_phase_active.then_some(3),
564 team_zero_score: Some(0),
565 team_one_score: Some(0),
566 possession_team_is_team_0: None,
567 scored_on_team_is_team_0: None,
568 current_in_game_team_player_counts: Some([2, 1]),
569 ball: Some(BallSample {
570 rigid_body: rigid_body(0.0),
571 }),
572 players: vec![
573 player(1, true, -400.0),
574 player(2, true, -100.0),
575 player(3, false, 300.0),
576 ],
577 active_demos: Vec::new(),
578 demo_events: Vec::new(),
579 boost_pad_events: Vec::new(),
580 touch_events: touch_players
581 .iter()
582 .map(|(player_id, team_is_team_0)| TouchEvent {
583 time,
584 frame: frame_number,
585 player: Some(RemoteId::Steam(*player_id)),
586 team_is_team_0: *team_is_team_0,
587 closest_approach_distance: None,
588 })
589 .collect(),
590 dodge_refreshed_events: Vec::new(),
591 player_stat_events: Vec::new(),
592 goal_events: Vec::new(),
593 }
594 }
595
596 #[test]
597 fn counts_defenders_caught_ahead_of_play_on_goal_frames() {
598 let mut reducer = PositioningReducer::new();
599 let sample = StatsSample {
600 frame_number: 10,
601 time: 10.0,
602 dt: 1.0,
603 seconds_remaining: None,
604 game_state: None,
605 ball_has_been_hit: Some(true),
606 kickoff_countdown_time: None,
607 team_zero_score: Some(1),
608 team_one_score: Some(0),
609 possession_team_is_team_0: Some(true),
610 scored_on_team_is_team_0: Some(false),
611 current_in_game_team_player_counts: Some([1, 3]),
612 ball: Some(BallSample {
613 rigid_body: rigid_body(4800.0),
614 }),
615 players: vec![
616 player(1, true, 0.0),
617 player(2, false, -1800.0),
618 player(3, false, -700.0),
619 player(4, false, 3200.0),
620 ],
621 active_demos: Vec::new(),
622 demo_events: Vec::new(),
623 boost_pad_events: Vec::new(),
624 touch_events: Vec::new(),
625 dodge_refreshed_events: Vec::new(),
626 player_stat_events: Vec::new(),
627 goal_events: vec![GoalEvent {
628 time: 10.0,
629 frame: 10,
630 scoring_team_is_team_0: true,
631 player: Some(RemoteId::Steam(1)),
632 team_zero_score: Some(1),
633 team_one_score: Some(0),
634 }],
635 };
636
637 reducer.on_sample(&sample).unwrap();
638
639 assert_eq!(
640 reducer
641 .player_stats()
642 .get(&RemoteId::Steam(2))
643 .unwrap()
644 .times_caught_ahead_of_play_on_conceded_goals,
645 1
646 );
647 assert_eq!(
648 reducer
649 .player_stats()
650 .get(&RemoteId::Steam(3))
651 .unwrap()
652 .times_caught_ahead_of_play_on_conceded_goals,
653 1
654 );
655 assert_eq!(
656 reducer
657 .player_stats()
658 .get(&RemoteId::Steam(4))
659 .unwrap()
660 .times_caught_ahead_of_play_on_conceded_goals,
661 0
662 );
663 }
664
665 #[test]
666 fn player_possession_is_exclusive_and_resets_on_kickoff() {
667 let mut reducer = PositioningReducer::new();
668
669 reducer.on_sample(&sample(0, 0.0, &[], false)).unwrap();
670 reducer
671 .on_sample(&sample(1, 1.0, &[(1, true)], false))
672 .unwrap();
673 reducer.on_sample(&sample(2, 2.0, &[], false)).unwrap();
674 reducer
675 .on_sample(&sample(3, 3.0, &[(2, true)], false))
676 .unwrap();
677 reducer.on_sample(&sample(4, 4.0, &[], false)).unwrap();
678 reducer.on_sample(&sample(5, 5.0, &[], true)).unwrap();
679 reducer.on_sample(&sample(6, 6.0, &[], false)).unwrap();
680
681 let player_one = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
682 let player_two = reducer.player_stats().get(&RemoteId::Steam(2)).unwrap();
683 let player_three = reducer.player_stats().get(&RemoteId::Steam(3)).unwrap();
684
685 assert_eq!(player_one.time_has_possession, 2.0);
686 assert_eq!(player_two.time_has_possession, 1.0);
687 assert_eq!(player_three.time_has_possession, 0.0);
688 assert_eq!(
689 player_one.time_has_possession
690 + player_two.time_has_possession
691 + player_three.time_has_possession,
692 3.0
693 );
694 assert_eq!(player_one.time_no_possession, 1.0);
695 assert_eq!(player_two.time_no_possession, 2.0);
696 assert_eq!(player_three.time_no_possession, 3.0);
697 }
698}