1use std::collections::HashSet;
2
3use serde::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)]
15pub struct RushEvent {
16 pub start_time: f32,
17 pub start_frame: usize,
18 pub end_time: f32,
19 pub end_frame: usize,
20 pub is_team_0: bool,
21 pub attackers: usize,
22 pub defenders: usize,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26struct ActiveRush {
27 start_time: f32,
28 start_frame: usize,
29 last_time: f32,
30 last_frame: usize,
31 is_team_0: bool,
32 attackers: usize,
33 defenders: usize,
34 counted: bool,
35}
36
37impl ActiveRush {
38 fn retained_possession_time(&self) -> f32 {
39 (self.last_time - self.start_time).max(0.0)
40 }
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct RushReducerConfig {
45 pub max_start_y: f32,
46 pub attack_support_distance_y: f32,
47 pub defender_distance_y: f32,
48 pub min_possession_retained_seconds: f32,
49}
50
51impl Default for RushReducerConfig {
52 fn default() -> Self {
53 Self {
54 max_start_y: DEFAULT_RUSH_MAX_START_Y,
55 attack_support_distance_y: DEFAULT_RUSH_ATTACK_SUPPORT_DISTANCE_Y,
56 defender_distance_y: DEFAULT_RUSH_DEFENDER_DISTANCE_Y,
57 min_possession_retained_seconds: DEFAULT_RUSH_MIN_POSSESSION_RETAINED_SECONDS,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Serialize)]
63pub struct RushStats {
64 pub team_zero_count: u32,
65 pub team_zero_two_v_one_count: u32,
66 pub team_zero_two_v_two_count: u32,
67 pub team_zero_two_v_three_count: u32,
68 pub team_zero_three_v_one_count: u32,
69 pub team_zero_three_v_two_count: u32,
70 pub team_zero_three_v_three_count: u32,
71 pub team_one_count: u32,
72 pub team_one_two_v_one_count: u32,
73 pub team_one_two_v_two_count: u32,
74 pub team_one_two_v_three_count: u32,
75 pub team_one_three_v_one_count: u32,
76 pub team_one_three_v_two_count: u32,
77 pub team_one_three_v_three_count: u32,
78}
79
80impl RushStats {
81 fn record(&mut self, attacking_team_is_team_0: bool, attackers: usize, defenders: usize) {
82 if attacking_team_is_team_0 {
83 self.team_zero_count += 1;
84 match (attackers, defenders) {
85 (2, 1) => self.team_zero_two_v_one_count += 1,
86 (2, 2) => self.team_zero_two_v_two_count += 1,
87 (2, 3) => self.team_zero_two_v_three_count += 1,
88 (3, 1) => self.team_zero_three_v_one_count += 1,
89 (3, 2) => self.team_zero_three_v_two_count += 1,
90 (3, 3) => self.team_zero_three_v_three_count += 1,
91 _ => {}
92 }
93 } else {
94 self.team_one_count += 1;
95 match (attackers, defenders) {
96 (2, 1) => self.team_one_two_v_one_count += 1,
97 (2, 2) => self.team_one_two_v_two_count += 1,
98 (2, 3) => self.team_one_two_v_three_count += 1,
99 (3, 1) => self.team_one_three_v_one_count += 1,
100 (3, 2) => self.team_one_three_v_two_count += 1,
101 (3, 3) => self.team_one_three_v_three_count += 1,
102 _ => {}
103 }
104 }
105 }
106}
107
108#[derive(Debug, Clone, Default, PartialEq)]
109pub struct RushReducer {
110 config: RushReducerConfig,
111 stats: RushStats,
112 events: Vec<RushEvent>,
113 active_rush: Option<ActiveRush>,
114 live_play_tracker: LivePlayTracker,
115}
116
117impl RushReducer {
118 pub fn new() -> Self {
119 Self::with_config(RushReducerConfig::default())
120 }
121
122 pub fn with_config(config: RushReducerConfig) -> Self {
123 Self {
124 config,
125 ..Self::default()
126 }
127 }
128
129 pub fn config(&self) -> &RushReducerConfig {
130 &self.config
131 }
132
133 pub fn stats(&self) -> &RushStats {
134 &self.stats
135 }
136
137 pub fn events(&self) -> &[RushEvent] {
138 &self.events
139 }
140
141 fn record_active_rush(&mut self, active_rush: &mut ActiveRush) {
142 if active_rush.counted {
143 return;
144 }
145 if active_rush.retained_possession_time() < self.config.min_possession_retained_seconds {
146 return;
147 }
148
149 self.stats.record(
150 active_rush.is_team_0,
151 active_rush.attackers,
152 active_rush.defenders,
153 );
154 active_rush.counted = true;
155 }
156
157 fn rush_numbers(
158 &self,
159 sample: &StatsSample,
160 attacking_team_is_team_0: bool,
161 ) -> Option<(usize, usize)> {
162 let ball_position = sample.ball.as_ref()?.position();
163 let normalized_ball_y = normalized_y(attacking_team_is_team_0, ball_position);
164 if normalized_ball_y > self.config.max_start_y {
165 return None;
166 }
167
168 let demoed_players: HashSet<_> = sample
169 .active_demos
170 .iter()
171 .map(|demo| demo.victim.clone())
172 .collect();
173
174 let attackers = sample
175 .players
176 .iter()
177 .filter(|player| player.is_team_0 == attacking_team_is_team_0)
178 .filter(|player| !demoed_players.contains(&player.player_id))
179 .filter_map(PlayerSample::position)
180 .filter(|position| {
181 normalized_y(attacking_team_is_team_0, *position)
182 >= normalized_ball_y - self.config.attack_support_distance_y
183 })
184 .count()
185 .min(3);
186
187 let defenders = sample
188 .players
189 .iter()
190 .filter(|player| player.is_team_0 != attacking_team_is_team_0)
191 .filter(|player| !demoed_players.contains(&player.player_id))
192 .filter_map(PlayerSample::position)
193 .filter(|position| {
194 normalized_y(attacking_team_is_team_0, *position)
195 >= normalized_ball_y + self.config.defender_distance_y
196 })
197 .count()
198 .min(3);
199
200 if attackers < 2 || defenders == 0 {
201 return None;
202 }
203
204 Some((attackers, defenders))
205 }
206
207 fn finalize_active_rush(&mut self) {
208 let Some(mut active_rush) = self.active_rush.take() else {
209 return;
210 };
211 self.record_active_rush(&mut active_rush);
212 if !active_rush.counted {
213 return;
214 }
215 self.events.push(RushEvent {
216 start_time: active_rush.start_time,
217 start_frame: active_rush.start_frame,
218 end_time: active_rush.last_time,
219 end_frame: active_rush.last_frame,
220 is_team_0: active_rush.is_team_0,
221 attackers: active_rush.attackers,
222 defenders: active_rush.defenders,
223 });
224 }
225
226 fn update_active_rush(&mut self, sample: &StatsSample, current_team_is_team_0: Option<bool>) {
227 let Some(active_team_is_team_0) = self.active_rush.as_ref().map(|rush| rush.is_team_0)
228 else {
229 return;
230 };
231
232 let active_continues = current_team_is_team_0 == Some(active_team_is_team_0)
233 && self.rush_numbers(sample, active_team_is_team_0).is_some();
234 if active_continues {
235 if let Some(active_rush) = self.active_rush.as_mut() {
236 active_rush.last_time = sample.time;
237 active_rush.last_frame = sample.frame_number;
238 }
239 if let Some(mut active_rush) = self.active_rush.take() {
240 self.record_active_rush(&mut active_rush);
241 self.active_rush = Some(active_rush);
242 }
243 return;
244 }
245
246 self.finalize_active_rush();
247 }
248
249 fn maybe_start_rush(
250 &mut self,
251 sample: &StatsSample,
252 active_team_before_sample: Option<bool>,
253 current_team_is_team_0: Option<bool>,
254 ) {
255 let Some(attacking_team_is_team_0) = current_team_is_team_0 else {
256 return;
257 };
258 if active_team_before_sample == Some(attacking_team_is_team_0) {
259 return;
260 }
261
262 if let Some((attackers, defenders)) = self.rush_numbers(sample, attacking_team_is_team_0) {
263 self.active_rush = Some(ActiveRush {
264 start_time: sample.time,
265 start_frame: sample.frame_number,
266 last_time: sample.time,
267 last_frame: sample.frame_number,
268 is_team_0: attacking_team_is_team_0,
269 attackers,
270 defenders,
271 counted: false,
272 });
273 }
274 }
275
276 fn update_rush_state(
277 &mut self,
278 sample: &StatsSample,
279 active_team_before_sample: Option<bool>,
280 current_team_is_team_0: Option<bool>,
281 ) {
282 self.update_active_rush(sample, current_team_is_team_0);
283 if self.active_rush.is_none() {
284 self.maybe_start_rush(sample, active_team_before_sample, current_team_is_team_0);
285 }
286 }
287}
288
289impl StatsReducer for RushReducer {
290 fn on_sample_with_context(
291 &mut self,
292 sample: &StatsSample,
293 ctx: &AnalysisContext,
294 ) -> SubtrActorResult<()> {
295 if !self.live_play_tracker.is_live_play(sample)
296 || FiftyFiftyReducer::kickoff_phase_active(sample)
297 {
298 self.finalize_active_rush();
299 return Ok(());
300 }
301
302 let possession_state = ctx
303 .get::<PossessionState>(POSSESSION_STATE_SIGNAL_ID)
304 .cloned()
305 .unwrap_or_default();
306 self.update_rush_state(
307 sample,
308 possession_state.active_team_before_sample,
309 possession_state.current_team_is_team_0,
310 );
311
312 Ok(())
313 }
314
315 fn finish(&mut self) -> SubtrActorResult<()> {
316 self.finalize_active_rush();
317 Ok(())
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use boxcars::{Quaternion, RemoteId, RigidBody, Vector3f};
324
325 use super::*;
326
327 fn rigid_body(x: f32, y: f32) -> RigidBody {
328 RigidBody {
329 sleeping: false,
330 location: Vector3f { x, y, z: 17.0 },
331 rotation: Quaternion {
332 x: 0.0,
333 y: 0.0,
334 z: 0.0,
335 w: 1.0,
336 },
337 linear_velocity: Some(Vector3f {
338 x: 0.0,
339 y: 0.0,
340 z: 0.0,
341 }),
342 angular_velocity: Some(Vector3f {
343 x: 0.0,
344 y: 0.0,
345 z: 0.0,
346 }),
347 }
348 }
349
350 fn player(player_id: u64, is_team_0: bool, x: f32, y: f32) -> PlayerSample {
351 PlayerSample {
352 player_id: RemoteId::Steam(player_id),
353 is_team_0,
354 rigid_body: Some(rigid_body(x, y)),
355 boost_amount: Some(50.0),
356 last_boost_amount: Some(50.0),
357 boost_active: false,
358 dodge_active: false,
359 powerslide_active: false,
360 match_goals: None,
361 match_assists: None,
362 match_saves: None,
363 match_shots: None,
364 match_score: None,
365 }
366 }
367
368 fn sample_with_ball_y(players: Vec<PlayerSample>, ball_y: f32) -> StatsSample {
369 StatsSample {
370 frame_number: 10,
371 time: 5.0,
372 dt: 1.0 / 120.0,
373 seconds_remaining: None,
374 game_state: None,
375 ball_has_been_hit: Some(true),
376 kickoff_countdown_time: None,
377 team_zero_score: Some(0),
378 team_one_score: Some(0),
379 possession_team_is_team_0: Some(true),
380 scored_on_team_is_team_0: None,
381 current_in_game_team_player_counts: Some([3, 3]),
382 ball: Some(BallSample {
383 rigid_body: rigid_body(0.0, ball_y),
384 }),
385 players,
386 active_demos: Vec::new(),
387 demo_events: Vec::new(),
388 boost_pad_events: Vec::new(),
389 touch_events: Vec::new(),
390 dodge_refreshed_events: Vec::new(),
391 player_stat_events: Vec::new(),
392 goal_events: Vec::new(),
393 }
394 }
395
396 fn sample(players: Vec<PlayerSample>) -> StatsSample {
397 sample_with_ball_y(players, -200.0)
398 }
399
400 #[test]
401 fn classifies_two_v_one_from_turnover_shape() {
402 let reducer = RushReducer::new();
403 let sample = sample(vec![
404 player(1, true, 0.0, -500.0),
405 player(2, true, 300.0, 250.0),
406 player(3, true, -1500.0, -2600.0),
407 player(4, false, 0.0, 1800.0),
408 player(5, false, 800.0, -150.0),
409 player(6, false, -900.0, -1800.0),
410 ]);
411
412 assert_eq!(reducer.rush_numbers(&sample, true), Some((2, 1)));
413 }
414
415 #[test]
416 fn counts_rush_once_when_possession_changes() {
417 let start_sample = sample(vec![
418 player(1, true, 0.0, -500.0),
419 player(2, true, 300.0, 250.0),
420 player(3, true, -1500.0, -2600.0),
421 player(4, false, 0.0, 1800.0),
422 player(5, false, 800.0, -150.0),
423 player(6, false, -900.0, -1800.0),
424 ]);
425 let continue_sample = StatsSample {
426 frame_number: 11,
427 time: 5.1,
428 ..sample(vec![
429 player(1, true, 0.0, -450.0),
430 player(2, true, 300.0, 300.0),
431 player(3, true, -1500.0, -2200.0),
432 player(4, false, 0.0, 1700.0),
433 player(5, false, 800.0, -100.0),
434 player(6, false, -900.0, -1700.0),
435 ])
436 };
437
438 let mut reducer = RushReducer::with_config(RushReducerConfig {
439 min_possession_retained_seconds: 0.05,
440 ..RushReducerConfig::default()
441 });
442 reducer.update_rush_state(&start_sample, Some(false), Some(true));
443 assert_eq!(reducer.stats().team_zero_count, 0);
444 assert_eq!(reducer.stats().team_zero_two_v_one_count, 0);
445 assert_eq!(reducer.events().len(), 0);
446
447 reducer.update_rush_state(&continue_sample, Some(true), Some(true));
448 assert_eq!(reducer.stats().team_zero_count, 1);
449 assert_eq!(reducer.stats().team_zero_two_v_one_count, 1);
450 assert_eq!(reducer.events().len(), 0);
451
452 reducer.update_rush_state(&continue_sample, Some(true), Some(true));
453 assert_eq!(reducer.stats().team_zero_count, 1);
454 assert_eq!(reducer.stats().team_zero_two_v_one_count, 1);
455 }
456
457 #[test]
458 fn does_not_count_rush_when_turnover_starts_at_midfield() {
459 let reducer = RushReducer::new();
460 let sample = sample_with_ball_y(
461 vec![
462 player(1, true, 0.0, -500.0),
463 player(2, true, 300.0, 250.0),
464 player(3, true, -1500.0, -2600.0),
465 player(4, false, 0.0, 1800.0),
466 player(5, false, 800.0, -150.0),
467 player(6, false, -900.0, -1800.0),
468 ],
469 0.0,
470 );
471
472 assert_eq!(reducer.rush_numbers(&sample, true), None);
473 }
474
475 #[test]
476 fn records_rush_event_with_start_and_end_frames() {
477 let mut reducer = RushReducer::with_config(RushReducerConfig {
478 min_possession_retained_seconds: 0.05,
479 ..RushReducerConfig::default()
480 });
481 let start_sample = sample(vec![
482 player(1, true, 0.0, -500.0),
483 player(2, true, 300.0, 250.0),
484 player(3, true, -1500.0, -2600.0),
485 player(4, false, 0.0, 1800.0),
486 player(5, false, 800.0, -150.0),
487 player(6, false, -900.0, -1800.0),
488 ]);
489 let continue_sample = StatsSample {
490 frame_number: 11,
491 time: 5.1,
492 ..sample(vec![
493 player(1, true, 0.0, -450.0),
494 player(2, true, 300.0, 300.0),
495 player(3, true, -1500.0, -2200.0),
496 player(4, false, 0.0, 1700.0),
497 player(5, false, 800.0, -100.0),
498 player(6, false, -900.0, -1700.0),
499 ])
500 };
501 let end_sample = StatsSample {
502 frame_number: 12,
503 time: 5.2,
504 ..sample_with_ball_y(
505 vec![
506 player(1, true, 0.0, -200.0),
507 player(2, true, 300.0, 700.0),
508 player(3, true, -1500.0, -1800.0),
509 player(4, false, 0.0, 1800.0),
510 player(5, false, 800.0, 100.0),
511 player(6, false, -900.0, -1500.0),
512 ],
513 300.0,
514 )
515 };
516
517 reducer.update_rush_state(&start_sample, Some(false), Some(true));
518 reducer.update_rush_state(&continue_sample, Some(true), Some(true));
519 reducer.update_rush_state(&end_sample, Some(true), Some(true));
520
521 assert_eq!(reducer.stats().team_zero_count, 1);
522 assert_eq!(
523 reducer.events(),
524 &[RushEvent {
525 start_time: 5.0,
526 start_frame: 10,
527 end_time: 5.1,
528 end_frame: 11,
529 is_team_0: true,
530 attackers: 2,
531 defenders: 1,
532 }]
533 );
534 }
535
536 #[test]
537 fn does_not_count_short_lived_rush_before_retention_threshold() {
538 let mut reducer = RushReducer::with_config(RushReducerConfig {
539 min_possession_retained_seconds: 0.2,
540 ..RushReducerConfig::default()
541 });
542 let start_sample = sample(vec![
543 player(1, true, 0.0, -500.0),
544 player(2, true, 300.0, 250.0),
545 player(3, true, -1500.0, -2600.0),
546 player(4, false, 0.0, 1800.0),
547 player(5, false, 800.0, -150.0),
548 player(6, false, -900.0, -1800.0),
549 ]);
550 let brief_continue_sample = StatsSample {
551 frame_number: 11,
552 time: 5.05,
553 ..sample(vec![
554 player(1, true, 0.0, -450.0),
555 player(2, true, 300.0, 300.0),
556 player(3, true, -1500.0, -2200.0),
557 player(4, false, 0.0, 1700.0),
558 player(5, false, 800.0, -100.0),
559 player(6, false, -900.0, -1700.0),
560 ])
561 };
562 let end_sample = StatsSample {
563 frame_number: 12,
564 time: 5.1,
565 ..sample_with_ball_y(
566 vec![
567 player(1, true, 0.0, -200.0),
568 player(2, true, 300.0, 700.0),
569 player(3, true, -1500.0, -1800.0),
570 player(4, false, 0.0, 1800.0),
571 player(5, false, 800.0, 100.0),
572 player(6, false, -900.0, -1500.0),
573 ],
574 300.0,
575 )
576 };
577
578 reducer.update_rush_state(&start_sample, Some(false), Some(true));
579 reducer.update_rush_state(&brief_continue_sample, Some(true), Some(true));
580 reducer.update_rush_state(&end_sample, Some(true), Some(true));
581
582 assert_eq!(reducer.stats().team_zero_count, 0);
583 assert!(reducer.events().is_empty());
584 }
585}