subtr_actor/stats/calculators/
rush.rs1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use super::*;
6
7const 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)]
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#[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#[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;