1use 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)]
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;