Skip to main content

subtr_actor/stats/calculators/
rush.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, 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, Deserialize, ts_rs::TS)]
15#[ts(export)]
16pub struct RushEvent {
17    pub start_time: f32,
18    pub start_frame: usize,
19    pub end_time: f32,
20    pub end_frame: usize,
21    pub is_team_0: bool,
22    pub attackers: usize,
23    pub defenders: usize,
24}
25
26const RUSH_TEAM_LABELS: [StatLabel; 2] = [
27    StatLabel::new("team", "team_zero"),
28    StatLabel::new("team", "team_one"),
29];
30const RUSH_ATTACKER_LABELS: [StatLabel; 2] = [
31    StatLabel::new("attackers", "2"),
32    StatLabel::new("attackers", "3"),
33];
34const RUSH_DEFENDER_LABELS: [StatLabel; 3] = [
35    StatLabel::new("defenders", "1"),
36    StatLabel::new("defenders", "2"),
37    StatLabel::new("defenders", "3"),
38];
39
40impl RushEvent {
41    fn labels(&self) -> [StatLabel; 3] {
42        [
43            rush_team_label(self.is_team_0),
44            rush_attackers_label(self.attackers),
45            rush_defenders_label(self.defenders),
46        ]
47    }
48}
49
50#[derive(Debug, Clone, PartialEq)]
51struct ActiveRush {
52    start_time: f32,
53    start_frame: usize,
54    last_time: f32,
55    last_frame: usize,
56    is_team_0: bool,
57    attackers: usize,
58    defenders: usize,
59    counted: bool,
60}
61
62impl ActiveRush {
63    fn retained_possession_time(&self) -> f32 {
64        (self.last_time - self.start_time).max(0.0)
65    }
66}
67
68fn rush_team_label(is_team_0: bool) -> StatLabel {
69    if is_team_0 {
70        StatLabel::new("team", "team_zero")
71    } else {
72        StatLabel::new("team", "team_one")
73    }
74}
75
76fn rush_attackers_label(attackers: usize) -> StatLabel {
77    StatLabel::new(
78        "attackers",
79        match attackers {
80            2 => "2",
81            3 => "3",
82            _ => "other",
83        },
84    )
85}
86
87fn rush_defenders_label(defenders: usize) -> StatLabel {
88    StatLabel::new(
89        "defenders",
90        match defenders {
91            1 => "1",
92            2 => "2",
93            3 => "3",
94            _ => "other",
95        },
96    )
97}
98
99#[derive(Debug, Clone, PartialEq)]
100pub struct RushCalculatorConfig {
101    pub max_start_y: f32,
102    pub attack_support_distance_y: f32,
103    pub defender_distance_y: f32,
104    pub min_possession_retained_seconds: f32,
105}
106
107impl Default for RushCalculatorConfig {
108    fn default() -> Self {
109        Self {
110            max_start_y: DEFAULT_RUSH_MAX_START_Y,
111            attack_support_distance_y: DEFAULT_RUSH_ATTACK_SUPPORT_DISTANCE_Y,
112            defender_distance_y: DEFAULT_RUSH_DEFENDER_DISTANCE_Y,
113            min_possession_retained_seconds: DEFAULT_RUSH_MIN_POSSESSION_RETAINED_SECONDS,
114        }
115    }
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
119pub struct RushStats {
120    pub team_zero_count: u32,
121    pub team_zero_two_v_one_count: u32,
122    pub team_zero_two_v_two_count: u32,
123    pub team_zero_two_v_three_count: u32,
124    pub team_zero_three_v_one_count: u32,
125    pub team_zero_three_v_two_count: u32,
126    pub team_zero_three_v_three_count: u32,
127    pub team_one_count: u32,
128    pub team_one_two_v_one_count: u32,
129    pub team_one_two_v_two_count: u32,
130    pub team_one_two_v_three_count: u32,
131    pub team_one_three_v_one_count: u32,
132    pub team_one_three_v_two_count: u32,
133    pub team_one_three_v_three_count: u32,
134    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
135    pub labeled_rush_counts: LabeledCounts,
136}
137
138impl RushStats {
139    fn record(&mut self, event: &RushEvent) {
140        self.labeled_rush_counts.increment(event.labels());
141        self.sync_legacy_counts();
142    }
143
144    pub fn rush_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
145        self.labeled_rush_counts.count_matching(labels)
146    }
147
148    pub fn complete_labeled_rush_counts(&self) -> LabeledCounts {
149        LabeledCounts::complete_from_label_sets(
150            &[
151                &RUSH_TEAM_LABELS,
152                &RUSH_ATTACKER_LABELS,
153                &RUSH_DEFENDER_LABELS,
154            ],
155            &self.labeled_rush_counts,
156        )
157    }
158
159    pub fn with_complete_labeled_rush_counts(mut self) -> Self {
160        self.labeled_rush_counts = self.complete_labeled_rush_counts();
161        self
162    }
163
164    fn team_count(&self, is_team_zero: bool) -> u32 {
165        self.rush_count_with_labels(&[rush_team_label(is_team_zero)])
166    }
167
168    fn matchup_count(&self, is_team_zero: bool, attackers: usize, defenders: usize) -> u32 {
169        self.rush_count_with_labels(&[
170            rush_team_label(is_team_zero),
171            rush_attackers_label(attackers),
172            rush_defenders_label(defenders),
173        ])
174    }
175
176    fn sync_legacy_counts(&mut self) {
177        self.team_zero_count = self.team_count(true);
178        self.team_zero_two_v_one_count = self.matchup_count(true, 2, 1);
179        self.team_zero_two_v_two_count = self.matchup_count(true, 2, 2);
180        self.team_zero_two_v_three_count = self.matchup_count(true, 2, 3);
181        self.team_zero_three_v_one_count = self.matchup_count(true, 3, 1);
182        self.team_zero_three_v_two_count = self.matchup_count(true, 3, 2);
183        self.team_zero_three_v_three_count = self.matchup_count(true, 3, 3);
184        self.team_one_count = self.team_count(false);
185        self.team_one_two_v_one_count = self.matchup_count(false, 2, 1);
186        self.team_one_two_v_two_count = self.matchup_count(false, 2, 2);
187        self.team_one_two_v_three_count = self.matchup_count(false, 2, 3);
188        self.team_one_three_v_one_count = self.matchup_count(false, 3, 1);
189        self.team_one_three_v_two_count = self.matchup_count(false, 3, 2);
190        self.team_one_three_v_three_count = self.matchup_count(false, 3, 3);
191    }
192
193    pub fn for_team(&self, is_team_zero: bool) -> RushTeamStats {
194        if is_team_zero {
195            RushTeamStats {
196                count: self.team_zero_count,
197                two_v_one_count: self.team_zero_two_v_one_count,
198                two_v_two_count: self.team_zero_two_v_two_count,
199                two_v_three_count: self.team_zero_two_v_three_count,
200                three_v_one_count: self.team_zero_three_v_one_count,
201                three_v_two_count: self.team_zero_three_v_two_count,
202                three_v_three_count: self.team_zero_three_v_three_count,
203            }
204        } else {
205            RushTeamStats {
206                count: self.team_one_count,
207                two_v_one_count: self.team_one_two_v_one_count,
208                two_v_two_count: self.team_one_two_v_two_count,
209                two_v_three_count: self.team_one_two_v_three_count,
210                three_v_one_count: self.team_one_three_v_one_count,
211                three_v_two_count: self.team_one_three_v_two_count,
212                three_v_three_count: self.team_one_three_v_three_count,
213            }
214        }
215    }
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
219#[ts(export)]
220pub struct RushTeamStats {
221    pub count: u32,
222    pub two_v_one_count: u32,
223    pub two_v_two_count: u32,
224    pub two_v_three_count: u32,
225    pub three_v_one_count: u32,
226    pub three_v_two_count: u32,
227    pub three_v_three_count: u32,
228}
229
230#[derive(Debug, Clone, Default, PartialEq)]
231pub struct RushCalculator {
232    config: RushCalculatorConfig,
233    stats: RushStats,
234    events: Vec<RushEvent>,
235    active_rush: Option<ActiveRush>,
236}
237
238impl RushCalculator {
239    pub fn new() -> Self {
240        Self::with_config(RushCalculatorConfig::default())
241    }
242
243    pub fn with_config(config: RushCalculatorConfig) -> Self {
244        Self {
245            config,
246            ..Self::default()
247        }
248    }
249
250    pub fn config(&self) -> &RushCalculatorConfig {
251        &self.config
252    }
253
254    pub fn stats(&self) -> &RushStats {
255        &self.stats
256    }
257
258    pub fn events(&self) -> &[RushEvent] {
259        &self.events
260    }
261
262    fn record_active_rush(&mut self, active_rush: &mut ActiveRush) {
263        if active_rush.counted {
264            return;
265        }
266        if active_rush.retained_possession_time() < self.config.min_possession_retained_seconds {
267            return;
268        }
269
270        self.stats.record(&RushEvent {
271            start_time: active_rush.start_time,
272            start_frame: active_rush.start_frame,
273            end_time: active_rush.last_time,
274            end_frame: active_rush.last_frame,
275            is_team_0: active_rush.is_team_0,
276            attackers: active_rush.attackers,
277            defenders: active_rush.defenders,
278        });
279        active_rush.counted = true;
280    }
281
282    fn rush_numbers(
283        &self,
284        ball: &BallFrameState,
285        players: &PlayerFrameState,
286        events: &FrameEventsState,
287        attacking_team_is_team_0: bool,
288    ) -> Option<(usize, usize)> {
289        let ball_position = ball.position()?;
290        let normalized_ball_y = normalized_y(attacking_team_is_team_0, ball_position);
291        if normalized_ball_y > self.config.max_start_y {
292            return None;
293        }
294
295        let demoed_players: HashSet<_> = events
296            .active_demos
297            .iter()
298            .map(|demo| demo.victim.clone())
299            .collect();
300
301        let attackers = players
302            .players
303            .iter()
304            .filter(|player| player.is_team_0 == attacking_team_is_team_0)
305            .filter(|player| !demoed_players.contains(&player.player_id))
306            .filter_map(PlayerSample::position)
307            .filter(|position| {
308                normalized_y(attacking_team_is_team_0, *position)
309                    >= normalized_ball_y - self.config.attack_support_distance_y
310            })
311            .count()
312            .min(3);
313
314        let defenders = players
315            .players
316            .iter()
317            .filter(|player| player.is_team_0 != attacking_team_is_team_0)
318            .filter(|player| !demoed_players.contains(&player.player_id))
319            .filter_map(PlayerSample::position)
320            .filter(|position| {
321                normalized_y(attacking_team_is_team_0, *position)
322                    >= normalized_ball_y + self.config.defender_distance_y
323            })
324            .count()
325            .min(3);
326
327        if attackers < 2 || defenders == 0 {
328            return None;
329        }
330
331        Some((attackers, defenders))
332    }
333
334    fn finalize_active_rush(&mut self) {
335        let Some(mut active_rush) = self.active_rush.take() else {
336            return;
337        };
338        self.record_active_rush(&mut active_rush);
339        if !active_rush.counted {
340            return;
341        }
342        self.events.push(RushEvent {
343            start_time: active_rush.start_time,
344            start_frame: active_rush.start_frame,
345            end_time: active_rush.last_time,
346            end_frame: active_rush.last_frame,
347            is_team_0: active_rush.is_team_0,
348            attackers: active_rush.attackers,
349            defenders: active_rush.defenders,
350        });
351    }
352
353    fn update_active_rush(
354        &mut self,
355        frame: &FrameInfo,
356        ball: &BallFrameState,
357        players: &PlayerFrameState,
358        events: &FrameEventsState,
359        current_team_is_team_0: Option<bool>,
360    ) {
361        let Some(active_team_is_team_0) = self.active_rush.as_ref().map(|rush| rush.is_team_0)
362        else {
363            return;
364        };
365
366        let active_continues = current_team_is_team_0 == Some(active_team_is_team_0)
367            && self
368                .rush_numbers(ball, players, events, active_team_is_team_0)
369                .is_some();
370        if active_continues {
371            if let Some(active_rush) = self.active_rush.as_mut() {
372                active_rush.last_time = frame.time;
373                active_rush.last_frame = frame.frame_number;
374            }
375            if let Some(mut active_rush) = self.active_rush.take() {
376                self.record_active_rush(&mut active_rush);
377                self.active_rush = Some(active_rush);
378            }
379            return;
380        }
381
382        self.finalize_active_rush();
383    }
384
385    fn maybe_start_rush(
386        &mut self,
387        frame: &FrameInfo,
388        ball: &BallFrameState,
389        players: &PlayerFrameState,
390        events: &FrameEventsState,
391        active_team_before_sample: Option<bool>,
392        current_team_is_team_0: Option<bool>,
393    ) {
394        let Some(attacking_team_is_team_0) = current_team_is_team_0 else {
395            return;
396        };
397        if active_team_before_sample == Some(attacking_team_is_team_0) {
398            return;
399        }
400
401        if let Some((attackers, defenders)) =
402            self.rush_numbers(ball, players, events, attacking_team_is_team_0)
403        {
404            self.active_rush = Some(ActiveRush {
405                start_time: frame.time,
406                start_frame: frame.frame_number,
407                last_time: frame.time,
408                last_frame: frame.frame_number,
409                is_team_0: attacking_team_is_team_0,
410                attackers,
411                defenders,
412                counted: false,
413            });
414        }
415    }
416
417    fn update_rush_state(
418        &mut self,
419        frame: &FrameInfo,
420        ball: &BallFrameState,
421        players: &PlayerFrameState,
422        events: &FrameEventsState,
423        active_team_before_sample: Option<bool>,
424        current_team_is_team_0: Option<bool>,
425    ) {
426        self.update_active_rush(frame, ball, players, events, current_team_is_team_0);
427        if self.active_rush.is_none() {
428            self.maybe_start_rush(
429                frame,
430                ball,
431                players,
432                events,
433                active_team_before_sample,
434                current_team_is_team_0,
435            );
436        }
437    }
438
439    #[allow(clippy::too_many_arguments)]
440    pub fn update_parts(
441        &mut self,
442        frame: &FrameInfo,
443        gameplay: &GameplayState,
444        ball: &BallFrameState,
445        players: &PlayerFrameState,
446        events: &FrameEventsState,
447        possession_state: &PossessionState,
448        live_play_state: &LivePlayState,
449    ) -> SubtrActorResult<()> {
450        if !live_play_state.is_live_play || gameplay.kickoff_phase_active() {
451            self.finalize_active_rush();
452            return Ok(());
453        }
454
455        self.update_rush_state(
456            frame,
457            ball,
458            players,
459            events,
460            possession_state.active_team_before_sample,
461            possession_state.current_team_is_team_0,
462        );
463
464        Ok(())
465    }
466    pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
467        self.finalize_active_rush();
468        Ok(())
469    }
470}
471
472#[cfg(test)]
473#[path = "rush_tests.rs"]
474mod tests;