Skip to main content

subtr_actor/stats/reducers/
rush.rs

1use std::collections::HashSet;
2
3use serde::Serialize;
4
5use super::*;
6
7// Require the turnover to occur at least slightly inside the new attacking
8// team's defensive half rather than anywhere around midfield.
9const DEFAULT_RUSH_MAX_START_Y: f32 = -BOOST_PAD_MIDFIELD_TOLERANCE_Y;
10const DEFAULT_RUSH_ATTACK_SUPPORT_DISTANCE_Y: f32 = 900.0;
11const DEFAULT_RUSH_DEFENDER_DISTANCE_Y: f32 = 150.0;
12const DEFAULT_RUSH_MIN_POSSESSION_RETAINED_SECONDS: f32 = 0.75;
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct RushEvent {
16    pub start_time: f32,
17    pub start_frame: usize,
18    pub end_time: f32,
19    pub end_frame: usize,
20    pub is_team_0: bool,
21    pub attackers: usize,
22    pub defenders: usize,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26struct ActiveRush {
27    start_time: f32,
28    start_frame: usize,
29    last_time: f32,
30    last_frame: usize,
31    is_team_0: bool,
32    attackers: usize,
33    defenders: usize,
34    counted: bool,
35}
36
37impl ActiveRush {
38    fn retained_possession_time(&self) -> f32 {
39        (self.last_time - self.start_time).max(0.0)
40    }
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct RushReducerConfig {
45    pub max_start_y: f32,
46    pub attack_support_distance_y: f32,
47    pub defender_distance_y: f32,
48    pub min_possession_retained_seconds: f32,
49}
50
51impl Default for RushReducerConfig {
52    fn default() -> Self {
53        Self {
54            max_start_y: DEFAULT_RUSH_MAX_START_Y,
55            attack_support_distance_y: DEFAULT_RUSH_ATTACK_SUPPORT_DISTANCE_Y,
56            defender_distance_y: DEFAULT_RUSH_DEFENDER_DISTANCE_Y,
57            min_possession_retained_seconds: DEFAULT_RUSH_MIN_POSSESSION_RETAINED_SECONDS,
58        }
59    }
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Serialize)]
63pub struct RushStats {
64    pub team_zero_count: u32,
65    pub team_zero_two_v_one_count: u32,
66    pub team_zero_two_v_two_count: u32,
67    pub team_zero_two_v_three_count: u32,
68    pub team_zero_three_v_one_count: u32,
69    pub team_zero_three_v_two_count: u32,
70    pub team_zero_three_v_three_count: u32,
71    pub team_one_count: u32,
72    pub team_one_two_v_one_count: u32,
73    pub team_one_two_v_two_count: u32,
74    pub team_one_two_v_three_count: u32,
75    pub team_one_three_v_one_count: u32,
76    pub team_one_three_v_two_count: u32,
77    pub team_one_three_v_three_count: u32,
78}
79
80impl RushStats {
81    fn record(&mut self, attacking_team_is_team_0: bool, attackers: usize, defenders: usize) {
82        if attacking_team_is_team_0 {
83            self.team_zero_count += 1;
84            match (attackers, defenders) {
85                (2, 1) => self.team_zero_two_v_one_count += 1,
86                (2, 2) => self.team_zero_two_v_two_count += 1,
87                (2, 3) => self.team_zero_two_v_three_count += 1,
88                (3, 1) => self.team_zero_three_v_one_count += 1,
89                (3, 2) => self.team_zero_three_v_two_count += 1,
90                (3, 3) => self.team_zero_three_v_three_count += 1,
91                _ => {}
92            }
93        } else {
94            self.team_one_count += 1;
95            match (attackers, defenders) {
96                (2, 1) => self.team_one_two_v_one_count += 1,
97                (2, 2) => self.team_one_two_v_two_count += 1,
98                (2, 3) => self.team_one_two_v_three_count += 1,
99                (3, 1) => self.team_one_three_v_one_count += 1,
100                (3, 2) => self.team_one_three_v_two_count += 1,
101                (3, 3) => self.team_one_three_v_three_count += 1,
102                _ => {}
103            }
104        }
105    }
106}
107
108#[derive(Debug, Clone, Default, PartialEq)]
109pub struct RushReducer {
110    config: RushReducerConfig,
111    stats: RushStats,
112    events: Vec<RushEvent>,
113    active_rush: Option<ActiveRush>,
114    live_play_tracker: LivePlayTracker,
115}
116
117impl RushReducer {
118    pub fn new() -> Self {
119        Self::with_config(RushReducerConfig::default())
120    }
121
122    pub fn with_config(config: RushReducerConfig) -> Self {
123        Self {
124            config,
125            ..Self::default()
126        }
127    }
128
129    pub fn config(&self) -> &RushReducerConfig {
130        &self.config
131    }
132
133    pub fn stats(&self) -> &RushStats {
134        &self.stats
135    }
136
137    pub fn events(&self) -> &[RushEvent] {
138        &self.events
139    }
140
141    fn record_active_rush(&mut self, active_rush: &mut ActiveRush) {
142        if active_rush.counted {
143            return;
144        }
145        if active_rush.retained_possession_time() < self.config.min_possession_retained_seconds {
146            return;
147        }
148
149        self.stats.record(
150            active_rush.is_team_0,
151            active_rush.attackers,
152            active_rush.defenders,
153        );
154        active_rush.counted = true;
155    }
156
157    fn rush_numbers(
158        &self,
159        sample: &StatsSample,
160        attacking_team_is_team_0: bool,
161    ) -> Option<(usize, usize)> {
162        let ball_position = sample.ball.as_ref()?.position();
163        let normalized_ball_y = normalized_y(attacking_team_is_team_0, ball_position);
164        if normalized_ball_y > self.config.max_start_y {
165            return None;
166        }
167
168        let demoed_players: HashSet<_> = sample
169            .active_demos
170            .iter()
171            .map(|demo| demo.victim.clone())
172            .collect();
173
174        let attackers = sample
175            .players
176            .iter()
177            .filter(|player| player.is_team_0 == attacking_team_is_team_0)
178            .filter(|player| !demoed_players.contains(&player.player_id))
179            .filter_map(PlayerSample::position)
180            .filter(|position| {
181                normalized_y(attacking_team_is_team_0, *position)
182                    >= normalized_ball_y - self.config.attack_support_distance_y
183            })
184            .count()
185            .min(3);
186
187        let defenders = sample
188            .players
189            .iter()
190            .filter(|player| player.is_team_0 != attacking_team_is_team_0)
191            .filter(|player| !demoed_players.contains(&player.player_id))
192            .filter_map(PlayerSample::position)
193            .filter(|position| {
194                normalized_y(attacking_team_is_team_0, *position)
195                    >= normalized_ball_y + self.config.defender_distance_y
196            })
197            .count()
198            .min(3);
199
200        if attackers < 2 || defenders == 0 {
201            return None;
202        }
203
204        Some((attackers, defenders))
205    }
206
207    fn finalize_active_rush(&mut self) {
208        let Some(mut active_rush) = self.active_rush.take() else {
209            return;
210        };
211        self.record_active_rush(&mut active_rush);
212        if !active_rush.counted {
213            return;
214        }
215        self.events.push(RushEvent {
216            start_time: active_rush.start_time,
217            start_frame: active_rush.start_frame,
218            end_time: active_rush.last_time,
219            end_frame: active_rush.last_frame,
220            is_team_0: active_rush.is_team_0,
221            attackers: active_rush.attackers,
222            defenders: active_rush.defenders,
223        });
224    }
225
226    fn update_active_rush(&mut self, sample: &StatsSample, current_team_is_team_0: Option<bool>) {
227        let Some(active_team_is_team_0) = self.active_rush.as_ref().map(|rush| rush.is_team_0)
228        else {
229            return;
230        };
231
232        let active_continues = current_team_is_team_0 == Some(active_team_is_team_0)
233            && self.rush_numbers(sample, active_team_is_team_0).is_some();
234        if active_continues {
235            if let Some(active_rush) = self.active_rush.as_mut() {
236                active_rush.last_time = sample.time;
237                active_rush.last_frame = sample.frame_number;
238            }
239            if let Some(mut active_rush) = self.active_rush.take() {
240                self.record_active_rush(&mut active_rush);
241                self.active_rush = Some(active_rush);
242            }
243            return;
244        }
245
246        self.finalize_active_rush();
247    }
248
249    fn maybe_start_rush(
250        &mut self,
251        sample: &StatsSample,
252        active_team_before_sample: Option<bool>,
253        current_team_is_team_0: Option<bool>,
254    ) {
255        let Some(attacking_team_is_team_0) = current_team_is_team_0 else {
256            return;
257        };
258        if active_team_before_sample == Some(attacking_team_is_team_0) {
259            return;
260        }
261
262        if let Some((attackers, defenders)) = self.rush_numbers(sample, attacking_team_is_team_0) {
263            self.active_rush = Some(ActiveRush {
264                start_time: sample.time,
265                start_frame: sample.frame_number,
266                last_time: sample.time,
267                last_frame: sample.frame_number,
268                is_team_0: attacking_team_is_team_0,
269                attackers,
270                defenders,
271                counted: false,
272            });
273        }
274    }
275
276    fn update_rush_state(
277        &mut self,
278        sample: &StatsSample,
279        active_team_before_sample: Option<bool>,
280        current_team_is_team_0: Option<bool>,
281    ) {
282        self.update_active_rush(sample, current_team_is_team_0);
283        if self.active_rush.is_none() {
284            self.maybe_start_rush(sample, active_team_before_sample, current_team_is_team_0);
285        }
286    }
287}
288
289impl StatsReducer for RushReducer {
290    fn on_sample_with_context(
291        &mut self,
292        sample: &StatsSample,
293        ctx: &AnalysisContext,
294    ) -> SubtrActorResult<()> {
295        if !self.live_play_tracker.is_live_play(sample)
296            || FiftyFiftyReducer::kickoff_phase_active(sample)
297        {
298            self.finalize_active_rush();
299            return Ok(());
300        }
301
302        let possession_state = ctx
303            .get::<PossessionState>(POSSESSION_STATE_SIGNAL_ID)
304            .cloned()
305            .unwrap_or_default();
306        self.update_rush_state(
307            sample,
308            possession_state.active_team_before_sample,
309            possession_state.current_team_is_team_0,
310        );
311
312        Ok(())
313    }
314
315    fn finish(&mut self) -> SubtrActorResult<()> {
316        self.finalize_active_rush();
317        Ok(())
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use boxcars::{Quaternion, RemoteId, RigidBody, Vector3f};
324
325    use super::*;
326
327    fn rigid_body(x: f32, y: f32) -> RigidBody {
328        RigidBody {
329            sleeping: false,
330            location: Vector3f { x, y, z: 17.0 },
331            rotation: Quaternion {
332                x: 0.0,
333                y: 0.0,
334                z: 0.0,
335                w: 1.0,
336            },
337            linear_velocity: Some(Vector3f {
338                x: 0.0,
339                y: 0.0,
340                z: 0.0,
341            }),
342            angular_velocity: Some(Vector3f {
343                x: 0.0,
344                y: 0.0,
345                z: 0.0,
346            }),
347        }
348    }
349
350    fn player(player_id: u64, is_team_0: bool, x: f32, y: f32) -> PlayerSample {
351        PlayerSample {
352            player_id: RemoteId::Steam(player_id),
353            is_team_0,
354            rigid_body: Some(rigid_body(x, y)),
355            boost_amount: Some(50.0),
356            last_boost_amount: Some(50.0),
357            boost_active: false,
358            dodge_active: false,
359            powerslide_active: false,
360            match_goals: None,
361            match_assists: None,
362            match_saves: None,
363            match_shots: None,
364            match_score: None,
365        }
366    }
367
368    fn sample_with_ball_y(players: Vec<PlayerSample>, ball_y: f32) -> StatsSample {
369        StatsSample {
370            frame_number: 10,
371            time: 5.0,
372            dt: 1.0 / 120.0,
373            seconds_remaining: None,
374            game_state: None,
375            ball_has_been_hit: Some(true),
376            kickoff_countdown_time: None,
377            team_zero_score: Some(0),
378            team_one_score: Some(0),
379            possession_team_is_team_0: Some(true),
380            scored_on_team_is_team_0: None,
381            current_in_game_team_player_counts: Some([3, 3]),
382            ball: Some(BallSample {
383                rigid_body: rigid_body(0.0, ball_y),
384            }),
385            players,
386            active_demos: Vec::new(),
387            demo_events: Vec::new(),
388            boost_pad_events: Vec::new(),
389            touch_events: Vec::new(),
390            dodge_refreshed_events: Vec::new(),
391            player_stat_events: Vec::new(),
392            goal_events: Vec::new(),
393        }
394    }
395
396    fn sample(players: Vec<PlayerSample>) -> StatsSample {
397        sample_with_ball_y(players, -200.0)
398    }
399
400    #[test]
401    fn classifies_two_v_one_from_turnover_shape() {
402        let reducer = RushReducer::new();
403        let sample = sample(vec![
404            player(1, true, 0.0, -500.0),
405            player(2, true, 300.0, 250.0),
406            player(3, true, -1500.0, -2600.0),
407            player(4, false, 0.0, 1800.0),
408            player(5, false, 800.0, -150.0),
409            player(6, false, -900.0, -1800.0),
410        ]);
411
412        assert_eq!(reducer.rush_numbers(&sample, true), Some((2, 1)));
413    }
414
415    #[test]
416    fn counts_rush_once_when_possession_changes() {
417        let start_sample = sample(vec![
418            player(1, true, 0.0, -500.0),
419            player(2, true, 300.0, 250.0),
420            player(3, true, -1500.0, -2600.0),
421            player(4, false, 0.0, 1800.0),
422            player(5, false, 800.0, -150.0),
423            player(6, false, -900.0, -1800.0),
424        ]);
425        let continue_sample = StatsSample {
426            frame_number: 11,
427            time: 5.1,
428            ..sample(vec![
429                player(1, true, 0.0, -450.0),
430                player(2, true, 300.0, 300.0),
431                player(3, true, -1500.0, -2200.0),
432                player(4, false, 0.0, 1700.0),
433                player(5, false, 800.0, -100.0),
434                player(6, false, -900.0, -1700.0),
435            ])
436        };
437
438        let mut reducer = RushReducer::with_config(RushReducerConfig {
439            min_possession_retained_seconds: 0.05,
440            ..RushReducerConfig::default()
441        });
442        reducer.update_rush_state(&start_sample, Some(false), Some(true));
443        assert_eq!(reducer.stats().team_zero_count, 0);
444        assert_eq!(reducer.stats().team_zero_two_v_one_count, 0);
445        assert_eq!(reducer.events().len(), 0);
446
447        reducer.update_rush_state(&continue_sample, Some(true), Some(true));
448        assert_eq!(reducer.stats().team_zero_count, 1);
449        assert_eq!(reducer.stats().team_zero_two_v_one_count, 1);
450        assert_eq!(reducer.events().len(), 0);
451
452        reducer.update_rush_state(&continue_sample, Some(true), Some(true));
453        assert_eq!(reducer.stats().team_zero_count, 1);
454        assert_eq!(reducer.stats().team_zero_two_v_one_count, 1);
455    }
456
457    #[test]
458    fn does_not_count_rush_when_turnover_starts_at_midfield() {
459        let reducer = RushReducer::new();
460        let sample = sample_with_ball_y(
461            vec![
462                player(1, true, 0.0, -500.0),
463                player(2, true, 300.0, 250.0),
464                player(3, true, -1500.0, -2600.0),
465                player(4, false, 0.0, 1800.0),
466                player(5, false, 800.0, -150.0),
467                player(6, false, -900.0, -1800.0),
468            ],
469            0.0,
470        );
471
472        assert_eq!(reducer.rush_numbers(&sample, true), None);
473    }
474
475    #[test]
476    fn records_rush_event_with_start_and_end_frames() {
477        let mut reducer = RushReducer::with_config(RushReducerConfig {
478            min_possession_retained_seconds: 0.05,
479            ..RushReducerConfig::default()
480        });
481        let start_sample = sample(vec![
482            player(1, true, 0.0, -500.0),
483            player(2, true, 300.0, 250.0),
484            player(3, true, -1500.0, -2600.0),
485            player(4, false, 0.0, 1800.0),
486            player(5, false, 800.0, -150.0),
487            player(6, false, -900.0, -1800.0),
488        ]);
489        let continue_sample = StatsSample {
490            frame_number: 11,
491            time: 5.1,
492            ..sample(vec![
493                player(1, true, 0.0, -450.0),
494                player(2, true, 300.0, 300.0),
495                player(3, true, -1500.0, -2200.0),
496                player(4, false, 0.0, 1700.0),
497                player(5, false, 800.0, -100.0),
498                player(6, false, -900.0, -1700.0),
499            ])
500        };
501        let end_sample = StatsSample {
502            frame_number: 12,
503            time: 5.2,
504            ..sample_with_ball_y(
505                vec![
506                    player(1, true, 0.0, -200.0),
507                    player(2, true, 300.0, 700.0),
508                    player(3, true, -1500.0, -1800.0),
509                    player(4, false, 0.0, 1800.0),
510                    player(5, false, 800.0, 100.0),
511                    player(6, false, -900.0, -1500.0),
512                ],
513                300.0,
514            )
515        };
516
517        reducer.update_rush_state(&start_sample, Some(false), Some(true));
518        reducer.update_rush_state(&continue_sample, Some(true), Some(true));
519        reducer.update_rush_state(&end_sample, Some(true), Some(true));
520
521        assert_eq!(reducer.stats().team_zero_count, 1);
522        assert_eq!(
523            reducer.events(),
524            &[RushEvent {
525                start_time: 5.0,
526                start_frame: 10,
527                end_time: 5.1,
528                end_frame: 11,
529                is_team_0: true,
530                attackers: 2,
531                defenders: 1,
532            }]
533        );
534    }
535
536    #[test]
537    fn does_not_count_short_lived_rush_before_retention_threshold() {
538        let mut reducer = RushReducer::with_config(RushReducerConfig {
539            min_possession_retained_seconds: 0.2,
540            ..RushReducerConfig::default()
541        });
542        let start_sample = sample(vec![
543            player(1, true, 0.0, -500.0),
544            player(2, true, 300.0, 250.0),
545            player(3, true, -1500.0, -2600.0),
546            player(4, false, 0.0, 1800.0),
547            player(5, false, 800.0, -150.0),
548            player(6, false, -900.0, -1800.0),
549        ]);
550        let brief_continue_sample = StatsSample {
551            frame_number: 11,
552            time: 5.05,
553            ..sample(vec![
554                player(1, true, 0.0, -450.0),
555                player(2, true, 300.0, 300.0),
556                player(3, true, -1500.0, -2200.0),
557                player(4, false, 0.0, 1700.0),
558                player(5, false, 800.0, -100.0),
559                player(6, false, -900.0, -1700.0),
560            ])
561        };
562        let end_sample = StatsSample {
563            frame_number: 12,
564            time: 5.1,
565            ..sample_with_ball_y(
566                vec![
567                    player(1, true, 0.0, -200.0),
568                    player(2, true, 300.0, 700.0),
569                    player(3, true, -1500.0, -1800.0),
570                    player(4, false, 0.0, 1800.0),
571                    player(5, false, 800.0, 100.0),
572                    player(6, false, -900.0, -1500.0),
573                ],
574                300.0,
575            )
576        };
577
578        reducer.update_rush_state(&start_sample, Some(false), Some(true));
579        reducer.update_rush_state(&brief_continue_sample, Some(true), Some(true));
580        reducer.update_rush_state(&end_sample, Some(true), Some(true));
581
582        assert_eq!(reducer.stats().team_zero_count, 0);
583        assert!(reducer.events().is_empty());
584    }
585}