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/// A quick possession transition where the attacking team has numbers moving out of its defensive half.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
16#[ts(export)]
17pub struct RushEvent {
18    pub start_time: f32,
19    pub start_frame: usize,
20    pub end_time: f32,
21    pub end_frame: usize,
22    pub is_team_0: bool,
23    pub attackers: usize,
24    pub defenders: usize,
25}
26
27impl RushEvent {
28    pub(crate) fn labels(&self) -> [StatLabel; 3] {
29        [
30            rush_team_label(self.is_team_0),
31            rush_attackers_label(self.attackers),
32            rush_defenders_label(self.defenders),
33        ]
34    }
35}
36
37#[derive(Debug, Clone, PartialEq)]
38struct ActiveRush {
39    start_time: f32,
40    start_frame: usize,
41    last_time: f32,
42    last_frame: usize,
43    is_team_0: bool,
44    attackers: usize,
45    defenders: usize,
46    counted: bool,
47}
48
49impl ActiveRush {
50    fn retained_possession_time(&self) -> f32 {
51        (self.last_time - self.start_time).max(0.0)
52    }
53}
54
55pub(crate) fn rush_team_label(is_team_0: bool) -> StatLabel {
56    if is_team_0 {
57        StatLabel::new("team", "team_zero")
58    } else {
59        StatLabel::new("team", "team_one")
60    }
61}
62
63pub(crate) fn rush_attackers_label(attackers: usize) -> StatLabel {
64    StatLabel::new(
65        "attackers",
66        match attackers {
67            2 => "2",
68            3 => "3",
69            _ => "other",
70        },
71    )
72}
73
74pub(crate) fn rush_defenders_label(defenders: usize) -> StatLabel {
75    StatLabel::new(
76        "defenders",
77        match defenders {
78            1 => "1",
79            2 => "2",
80            3 => "3",
81            _ => "other",
82        },
83    )
84}
85
86/// Configuration thresholds for rush detection.
87#[derive(Debug, Clone, PartialEq)]
88pub struct RushCalculatorConfig {
89    pub max_start_y: f32,
90    pub attack_support_distance_y: f32,
91    pub defender_distance_y: f32,
92    pub min_possession_retained_seconds: f32,
93}
94
95impl Default for RushCalculatorConfig {
96    fn default() -> Self {
97        Self {
98            max_start_y: DEFAULT_RUSH_MAX_START_Y,
99            attack_support_distance_y: DEFAULT_RUSH_ATTACK_SUPPORT_DISTANCE_Y,
100            defender_distance_y: DEFAULT_RUSH_DEFENDER_DISTANCE_Y,
101            min_possession_retained_seconds: DEFAULT_RUSH_MIN_POSSESSION_RETAINED_SECONDS,
102        }
103    }
104}
105
106/// Detects rushes/over-commits during live play.
107#[derive(Debug, Clone, Default, PartialEq)]
108pub struct RushCalculator {
109    config: RushCalculatorConfig,
110    events: EventStream<RushEvent>,
111    active_rush: Option<ActiveRush>,
112}
113
114impl RushCalculator {
115    pub fn new() -> Self {
116        Self::with_config(RushCalculatorConfig::default())
117    }
118
119    pub fn with_config(config: RushCalculatorConfig) -> Self {
120        Self {
121            config,
122            ..Self::default()
123        }
124    }
125
126    pub fn config(&self) -> &RushCalculatorConfig {
127        &self.config
128    }
129
130    pub fn events(&self) -> &[RushEvent] {
131        self.events.all()
132    }
133
134    pub fn new_events(&self) -> &[RushEvent] {
135        self.events.new_events()
136    }
137
138    fn record_active_rush(&mut self, active_rush: &mut ActiveRush) {
139        if active_rush.counted {
140            return;
141        }
142        if active_rush.retained_possession_time() < self.config.min_possession_retained_seconds {
143            return;
144        }
145
146        let event = RushEvent {
147            start_time: active_rush.start_time,
148            start_frame: active_rush.start_frame,
149            end_time: active_rush.last_time,
150            end_frame: active_rush.last_frame,
151            is_team_0: active_rush.is_team_0,
152            attackers: active_rush.attackers,
153            defenders: active_rush.defenders,
154        };
155        self.events.push(event);
156        active_rush.counted = true;
157    }
158
159    fn rush_numbers(
160        &self,
161        ball: &BallFrameState,
162        players: &PlayerFrameState,
163        events: &FrameEventsState,
164        attacking_team_is_team_0: bool,
165    ) -> Option<(usize, usize)> {
166        let ball_position = ball.position()?;
167        let normalized_ball_y = normalized_y(attacking_team_is_team_0, ball_position);
168        if normalized_ball_y > self.config.max_start_y {
169            return None;
170        }
171
172        let demoed_players: HashSet<_> = events
173            .active_demos
174            .iter()
175            .map(|demo| demo.victim.clone())
176            .collect();
177
178        let attackers = players
179            .players
180            .iter()
181            .filter(|player| player.is_team_0 == attacking_team_is_team_0)
182            .filter(|player| !demoed_players.contains(&player.player_id))
183            .filter_map(PlayerSample::position)
184            .filter(|position| {
185                normalized_y(attacking_team_is_team_0, *position)
186                    >= normalized_ball_y - self.config.attack_support_distance_y
187            })
188            .count()
189            .min(3);
190
191        let defenders = players
192            .players
193            .iter()
194            .filter(|player| player.is_team_0 != attacking_team_is_team_0)
195            .filter(|player| !demoed_players.contains(&player.player_id))
196            .filter_map(PlayerSample::position)
197            .filter(|position| {
198                normalized_y(attacking_team_is_team_0, *position)
199                    >= normalized_ball_y + self.config.defender_distance_y
200            })
201            .count()
202            .min(3);
203
204        if attackers < 2 || defenders == 0 {
205            return None;
206        }
207
208        Some((attackers, defenders))
209    }
210
211    fn finalize_active_rush(&mut self) {
212        let Some(mut active_rush) = self.active_rush.take() else {
213            return;
214        };
215        self.record_active_rush(&mut active_rush);
216    }
217
218    fn update_active_rush(
219        &mut self,
220        frame: &FrameInfo,
221        ball: &BallFrameState,
222        players: &PlayerFrameState,
223        events: &FrameEventsState,
224        current_team_is_team_0: Option<bool>,
225    ) {
226        let Some(active_team_is_team_0) = self.active_rush.as_ref().map(|rush| rush.is_team_0)
227        else {
228            return;
229        };
230
231        let active_continues = current_team_is_team_0 == Some(active_team_is_team_0)
232            && self
233                .rush_numbers(ball, players, events, active_team_is_team_0)
234                .is_some();
235        if active_continues {
236            if let Some(active_rush) = self.active_rush.as_mut() {
237                active_rush.last_time = frame.time;
238                active_rush.last_frame = frame.frame_number;
239            }
240            if let Some(mut active_rush) = self.active_rush.take() {
241                self.record_active_rush(&mut active_rush);
242                self.active_rush = Some(active_rush);
243            }
244            return;
245        }
246
247        self.finalize_active_rush();
248    }
249
250    fn maybe_start_rush(
251        &mut self,
252        frame: &FrameInfo,
253        ball: &BallFrameState,
254        players: &PlayerFrameState,
255        events: &FrameEventsState,
256        active_team_before_sample: Option<bool>,
257        current_team_is_team_0: Option<bool>,
258    ) {
259        let Some(attacking_team_is_team_0) = current_team_is_team_0 else {
260            return;
261        };
262        if active_team_before_sample == Some(attacking_team_is_team_0) {
263            return;
264        }
265
266        if let Some((attackers, defenders)) =
267            self.rush_numbers(ball, players, events, attacking_team_is_team_0)
268        {
269            self.active_rush = Some(ActiveRush {
270                start_time: frame.time,
271                start_frame: frame.frame_number,
272                last_time: frame.time,
273                last_frame: frame.frame_number,
274                is_team_0: attacking_team_is_team_0,
275                attackers,
276                defenders,
277                counted: false,
278            });
279        }
280    }
281
282    fn update_rush_state(
283        &mut self,
284        frame: &FrameInfo,
285        ball: &BallFrameState,
286        players: &PlayerFrameState,
287        events: &FrameEventsState,
288        active_team_before_sample: Option<bool>,
289        current_team_is_team_0: Option<bool>,
290    ) {
291        self.update_active_rush(frame, ball, players, events, current_team_is_team_0);
292        if self.active_rush.is_none() {
293            self.maybe_start_rush(
294                frame,
295                ball,
296                players,
297                events,
298                active_team_before_sample,
299                current_team_is_team_0,
300            );
301        }
302    }
303
304    #[allow(clippy::too_many_arguments)]
305    pub fn update_parts(
306        &mut self,
307        frame: &FrameInfo,
308        gameplay: &GameplayState,
309        ball: &BallFrameState,
310        players: &PlayerFrameState,
311        events: &FrameEventsState,
312        possession_state: &PossessionState,
313        live_play_state: &LivePlayState,
314    ) -> SubtrActorResult<()> {
315        self.events.begin_update();
316        if !live_play_state.is_live_play || gameplay.kickoff_phase_active() {
317            self.finalize_active_rush();
318            return Ok(());
319        }
320
321        self.update_rush_state(
322            frame,
323            ball,
324            players,
325            events,
326            possession_state.active_team_before_sample,
327            possession_state.current_team_is_team_0,
328        );
329
330        Ok(())
331    }
332    pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
333        self.events.begin_update();
334        self.finalize_active_rush();
335        Ok(())
336    }
337}
338
339#[cfg(test)]
340#[path = "rush_tests.rs"]
341mod tests;