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
26#[derive(Debug, Clone, PartialEq)]
27struct ActiveRush {
28    start_time: f32,
29    start_frame: usize,
30    last_time: f32,
31    last_frame: usize,
32    is_team_0: bool,
33    attackers: usize,
34    defenders: usize,
35    counted: bool,
36}
37
38impl ActiveRush {
39    fn retained_possession_time(&self) -> f32 {
40        (self.last_time - self.start_time).max(0.0)
41    }
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct RushCalculatorConfig {
46    pub max_start_y: f32,
47    pub attack_support_distance_y: f32,
48    pub defender_distance_y: f32,
49    pub min_possession_retained_seconds: f32,
50}
51
52impl Default for RushCalculatorConfig {
53    fn default() -> Self {
54        Self {
55            max_start_y: DEFAULT_RUSH_MAX_START_Y,
56            attack_support_distance_y: DEFAULT_RUSH_ATTACK_SUPPORT_DISTANCE_Y,
57            defender_distance_y: DEFAULT_RUSH_DEFENDER_DISTANCE_Y,
58            min_possession_retained_seconds: DEFAULT_RUSH_MIN_POSSESSION_RETAINED_SECONDS,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
64pub struct RushStats {
65    pub team_zero_count: u32,
66    pub team_zero_two_v_one_count: u32,
67    pub team_zero_two_v_two_count: u32,
68    pub team_zero_two_v_three_count: u32,
69    pub team_zero_three_v_one_count: u32,
70    pub team_zero_three_v_two_count: u32,
71    pub team_zero_three_v_three_count: u32,
72    pub team_one_count: u32,
73    pub team_one_two_v_one_count: u32,
74    pub team_one_two_v_two_count: u32,
75    pub team_one_two_v_three_count: u32,
76    pub team_one_three_v_one_count: u32,
77    pub team_one_three_v_two_count: u32,
78    pub team_one_three_v_three_count: u32,
79}
80
81impl RushStats {
82    fn record(&mut self, attacking_team_is_team_0: bool, attackers: usize, defenders: usize) {
83        if attacking_team_is_team_0 {
84            self.team_zero_count += 1;
85            match (attackers, defenders) {
86                (2, 1) => self.team_zero_two_v_one_count += 1,
87                (2, 2) => self.team_zero_two_v_two_count += 1,
88                (2, 3) => self.team_zero_two_v_three_count += 1,
89                (3, 1) => self.team_zero_three_v_one_count += 1,
90                (3, 2) => self.team_zero_three_v_two_count += 1,
91                (3, 3) => self.team_zero_three_v_three_count += 1,
92                _ => {}
93            }
94        } else {
95            self.team_one_count += 1;
96            match (attackers, defenders) {
97                (2, 1) => self.team_one_two_v_one_count += 1,
98                (2, 2) => self.team_one_two_v_two_count += 1,
99                (2, 3) => self.team_one_two_v_three_count += 1,
100                (3, 1) => self.team_one_three_v_one_count += 1,
101                (3, 2) => self.team_one_three_v_two_count += 1,
102                (3, 3) => self.team_one_three_v_three_count += 1,
103                _ => {}
104            }
105        }
106    }
107
108    pub fn for_team(&self, is_team_zero: bool) -> RushTeamStats {
109        if is_team_zero {
110            RushTeamStats {
111                count: self.team_zero_count,
112                two_v_one_count: self.team_zero_two_v_one_count,
113                two_v_two_count: self.team_zero_two_v_two_count,
114                two_v_three_count: self.team_zero_two_v_three_count,
115                three_v_one_count: self.team_zero_three_v_one_count,
116                three_v_two_count: self.team_zero_three_v_two_count,
117                three_v_three_count: self.team_zero_three_v_three_count,
118            }
119        } else {
120            RushTeamStats {
121                count: self.team_one_count,
122                two_v_one_count: self.team_one_two_v_one_count,
123                two_v_two_count: self.team_one_two_v_two_count,
124                two_v_three_count: self.team_one_two_v_three_count,
125                three_v_one_count: self.team_one_three_v_one_count,
126                three_v_two_count: self.team_one_three_v_two_count,
127                three_v_three_count: self.team_one_three_v_three_count,
128            }
129        }
130    }
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
134#[ts(export)]
135pub struct RushTeamStats {
136    pub count: u32,
137    pub two_v_one_count: u32,
138    pub two_v_two_count: u32,
139    pub two_v_three_count: u32,
140    pub three_v_one_count: u32,
141    pub three_v_two_count: u32,
142    pub three_v_three_count: u32,
143}
144
145#[derive(Debug, Clone, Default, PartialEq)]
146pub struct RushCalculator {
147    config: RushCalculatorConfig,
148    stats: RushStats,
149    events: Vec<RushEvent>,
150    active_rush: Option<ActiveRush>,
151}
152
153impl RushCalculator {
154    pub fn new() -> Self {
155        Self::with_config(RushCalculatorConfig::default())
156    }
157
158    pub fn with_config(config: RushCalculatorConfig) -> Self {
159        Self {
160            config,
161            ..Self::default()
162        }
163    }
164
165    pub fn config(&self) -> &RushCalculatorConfig {
166        &self.config
167    }
168
169    pub fn stats(&self) -> &RushStats {
170        &self.stats
171    }
172
173    pub fn events(&self) -> &[RushEvent] {
174        &self.events
175    }
176
177    fn record_active_rush(&mut self, active_rush: &mut ActiveRush) {
178        if active_rush.counted {
179            return;
180        }
181        if active_rush.retained_possession_time() < self.config.min_possession_retained_seconds {
182            return;
183        }
184
185        self.stats.record(
186            active_rush.is_team_0,
187            active_rush.attackers,
188            active_rush.defenders,
189        );
190        active_rush.counted = true;
191    }
192
193    fn rush_numbers(
194        &self,
195        ball: &BallFrameState,
196        players: &PlayerFrameState,
197        events: &FrameEventsState,
198        attacking_team_is_team_0: bool,
199    ) -> Option<(usize, usize)> {
200        let ball_position = ball.position()?;
201        let normalized_ball_y = normalized_y(attacking_team_is_team_0, ball_position);
202        if normalized_ball_y > self.config.max_start_y {
203            return None;
204        }
205
206        let demoed_players: HashSet<_> = events
207            .active_demos
208            .iter()
209            .map(|demo| demo.victim.clone())
210            .collect();
211
212        let attackers = players
213            .players
214            .iter()
215            .filter(|player| player.is_team_0 == attacking_team_is_team_0)
216            .filter(|player| !demoed_players.contains(&player.player_id))
217            .filter_map(PlayerSample::position)
218            .filter(|position| {
219                normalized_y(attacking_team_is_team_0, *position)
220                    >= normalized_ball_y - self.config.attack_support_distance_y
221            })
222            .count()
223            .min(3);
224
225        let defenders = players
226            .players
227            .iter()
228            .filter(|player| player.is_team_0 != attacking_team_is_team_0)
229            .filter(|player| !demoed_players.contains(&player.player_id))
230            .filter_map(PlayerSample::position)
231            .filter(|position| {
232                normalized_y(attacking_team_is_team_0, *position)
233                    >= normalized_ball_y + self.config.defender_distance_y
234            })
235            .count()
236            .min(3);
237
238        if attackers < 2 || defenders == 0 {
239            return None;
240        }
241
242        Some((attackers, defenders))
243    }
244
245    fn finalize_active_rush(&mut self) {
246        let Some(mut active_rush) = self.active_rush.take() else {
247            return;
248        };
249        self.record_active_rush(&mut active_rush);
250        if !active_rush.counted {
251            return;
252        }
253        self.events.push(RushEvent {
254            start_time: active_rush.start_time,
255            start_frame: active_rush.start_frame,
256            end_time: active_rush.last_time,
257            end_frame: active_rush.last_frame,
258            is_team_0: active_rush.is_team_0,
259            attackers: active_rush.attackers,
260            defenders: active_rush.defenders,
261        });
262    }
263
264    fn update_active_rush(
265        &mut self,
266        frame: &FrameInfo,
267        ball: &BallFrameState,
268        players: &PlayerFrameState,
269        events: &FrameEventsState,
270        current_team_is_team_0: Option<bool>,
271    ) {
272        let Some(active_team_is_team_0) = self.active_rush.as_ref().map(|rush| rush.is_team_0)
273        else {
274            return;
275        };
276
277        let active_continues = current_team_is_team_0 == Some(active_team_is_team_0)
278            && self
279                .rush_numbers(ball, players, events, active_team_is_team_0)
280                .is_some();
281        if active_continues {
282            if let Some(active_rush) = self.active_rush.as_mut() {
283                active_rush.last_time = frame.time;
284                active_rush.last_frame = frame.frame_number;
285            }
286            if let Some(mut active_rush) = self.active_rush.take() {
287                self.record_active_rush(&mut active_rush);
288                self.active_rush = Some(active_rush);
289            }
290            return;
291        }
292
293        self.finalize_active_rush();
294    }
295
296    fn maybe_start_rush(
297        &mut self,
298        frame: &FrameInfo,
299        ball: &BallFrameState,
300        players: &PlayerFrameState,
301        events: &FrameEventsState,
302        active_team_before_sample: Option<bool>,
303        current_team_is_team_0: Option<bool>,
304    ) {
305        let Some(attacking_team_is_team_0) = current_team_is_team_0 else {
306            return;
307        };
308        if active_team_before_sample == Some(attacking_team_is_team_0) {
309            return;
310        }
311
312        if let Some((attackers, defenders)) =
313            self.rush_numbers(ball, players, events, attacking_team_is_team_0)
314        {
315            self.active_rush = Some(ActiveRush {
316                start_time: frame.time,
317                start_frame: frame.frame_number,
318                last_time: frame.time,
319                last_frame: frame.frame_number,
320                is_team_0: attacking_team_is_team_0,
321                attackers,
322                defenders,
323                counted: false,
324            });
325        }
326    }
327
328    fn update_rush_state(
329        &mut self,
330        frame: &FrameInfo,
331        ball: &BallFrameState,
332        players: &PlayerFrameState,
333        events: &FrameEventsState,
334        active_team_before_sample: Option<bool>,
335        current_team_is_team_0: Option<bool>,
336    ) {
337        self.update_active_rush(frame, ball, players, events, current_team_is_team_0);
338        if self.active_rush.is_none() {
339            self.maybe_start_rush(
340                frame,
341                ball,
342                players,
343                events,
344                active_team_before_sample,
345                current_team_is_team_0,
346            );
347        }
348    }
349
350    #[allow(clippy::too_many_arguments)]
351    pub fn update_parts(
352        &mut self,
353        frame: &FrameInfo,
354        gameplay: &GameplayState,
355        ball: &BallFrameState,
356        players: &PlayerFrameState,
357        events: &FrameEventsState,
358        possession_state: &PossessionState,
359        live_play_state: &LivePlayState,
360    ) -> SubtrActorResult<()> {
361        if !live_play_state.is_live_play || gameplay.kickoff_phase_active() {
362            self.finalize_active_rush();
363            return Ok(());
364        }
365
366        self.update_rush_state(
367            frame,
368            ball,
369            players,
370            events,
371            possession_state.active_team_before_sample,
372            possession_state.current_team_is_team_0,
373        );
374
375        Ok(())
376    }
377    pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
378        self.finalize_active_rush();
379        Ok(())
380    }
381}