1use super::*;
2
3const GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS: f32 = 10.0;
4const GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS: f32 = 20.0;
5const GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS: f32 = 40.0;
6const GOAL_BUILDUP_LOOKBACK_SECONDS: f32 = 12.0;
7const COUNTER_ATTACK_MAX_ATTACK_SECONDS: f32 = 4.0;
8const COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS: f32 = 6.0;
9const COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS: f32 = 2.5;
10const SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS: f32 = 6.0;
11const SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS: f32 = 7.0;
12const SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS: f32 = 3.5;
13const DOUBLE_TAP_TOUCH_WINDOW_SECONDS: f32 = 2.5;
14
15#[derive(Debug, Clone, Default, PartialEq, Serialize)]
16pub struct GoalAfterKickoffStats {
17 pub kickoff_goal_count: u32,
18 pub short_goal_count: u32,
19 pub medium_goal_count: u32,
20 pub long_goal_count: u32,
21 #[serde(skip)]
22 goal_times: Vec<f32>,
23}
24
25impl GoalAfterKickoffStats {
26 pub fn record_goal(&mut self, time_after_kickoff: f32) {
27 let clamped_time = time_after_kickoff.max(0.0);
28 self.goal_times.push(clamped_time);
29 if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS {
30 self.kickoff_goal_count += 1;
31 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS {
32 self.short_goal_count += 1;
33 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS {
34 self.medium_goal_count += 1;
35 } else {
36 self.long_goal_count += 1;
37 }
38 }
39
40 pub fn average_goal_time_after_kickoff(&self) -> f32 {
41 if self.goal_times.is_empty() {
42 0.0
43 } else {
44 self.goal_times.iter().sum::<f32>() / self.goal_times.len() as f32
45 }
46 }
47
48 pub fn median_goal_time_after_kickoff(&self) -> f32 {
49 if self.goal_times.is_empty() {
50 return 0.0;
51 }
52
53 let mut sorted_times = self.goal_times.clone();
54 sorted_times.sort_by(|a, b| a.total_cmp(b));
55 let midpoint = sorted_times.len() / 2;
56 if sorted_times.len().is_multiple_of(2) {
57 (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
58 } else {
59 sorted_times[midpoint]
60 }
61 }
62
63 fn merge(&mut self, other: &Self) {
64 self.kickoff_goal_count += other.kickoff_goal_count;
65 self.short_goal_count += other.short_goal_count;
66 self.medium_goal_count += other.medium_goal_count;
67 self.long_goal_count += other.long_goal_count;
68 self.goal_times.extend(other.goal_times.iter().copied());
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum GoalBuildupKind {
74 CounterAttack,
75 SustainedPressure,
76 Other,
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Serialize)]
80pub struct GoalBuildupStats {
81 pub counter_attack_goal_count: u32,
82 pub sustained_pressure_goal_count: u32,
83 pub other_buildup_goal_count: u32,
84}
85
86impl GoalBuildupStats {
87 fn record(&mut self, kind: GoalBuildupKind) {
88 match kind {
89 GoalBuildupKind::CounterAttack => self.counter_attack_goal_count += 1,
90 GoalBuildupKind::SustainedPressure => self.sustained_pressure_goal_count += 1,
91 GoalBuildupKind::Other => self.other_buildup_goal_count += 1,
92 }
93 }
94
95 fn merge(&mut self, other: &Self) {
96 self.counter_attack_goal_count += other.counter_attack_goal_count;
97 self.sustained_pressure_goal_count += other.sustained_pressure_goal_count;
98 self.other_buildup_goal_count += other.other_buildup_goal_count;
99 }
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Serialize)]
103pub struct CorePlayerStats {
104 pub score: i32,
105 pub goals: i32,
106 pub assists: i32,
107 pub saves: i32,
108 pub shots: i32,
109 pub attacking_backboard_hit_count: u32,
110 pub double_tap_count: u32,
111 pub goals_conceded_while_last_defender: u32,
112 #[serde(flatten)]
113 pub goal_after_kickoff: GoalAfterKickoffStats,
114 #[serde(flatten)]
115 pub goal_buildup: GoalBuildupStats,
116}
117
118impl CorePlayerStats {
119 pub fn shooting_percentage(&self) -> f32 {
120 if self.shots == 0 {
121 0.0
122 } else {
123 self.goals as f32 * 100.0 / self.shots as f32
124 }
125 }
126
127 pub fn average_goal_time_after_kickoff(&self) -> f32 {
128 self.goal_after_kickoff.average_goal_time_after_kickoff()
129 }
130
131 pub fn median_goal_time_after_kickoff(&self) -> f32 {
132 self.goal_after_kickoff.median_goal_time_after_kickoff()
133 }
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Serialize)]
137pub struct CoreTeamStats {
138 pub score: i32,
139 pub goals: i32,
140 pub assists: i32,
141 pub saves: i32,
142 pub shots: i32,
143 pub attacking_backboard_hit_count: u32,
144 pub double_tap_count: u32,
145 #[serde(flatten)]
146 pub goal_after_kickoff: GoalAfterKickoffStats,
147 #[serde(flatten)]
148 pub goal_buildup: GoalBuildupStats,
149}
150
151impl CoreTeamStats {
152 pub fn shooting_percentage(&self) -> f32 {
153 if self.shots == 0 {
154 0.0
155 } else {
156 self.goals as f32 * 100.0 / self.shots as f32
157 }
158 }
159
160 pub fn average_goal_time_after_kickoff(&self) -> f32 {
161 self.goal_after_kickoff.average_goal_time_after_kickoff()
162 }
163
164 pub fn median_goal_time_after_kickoff(&self) -> f32 {
165 self.goal_after_kickoff.median_goal_time_after_kickoff()
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
170pub enum TimelineEventKind {
171 Goal,
172 Shot,
173 Save,
174 Assist,
175 Kill,
176 Death,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize)]
180pub struct TimelineEvent {
181 pub time: f32,
182 pub kind: TimelineEventKind,
183 pub player_id: Option<PlayerId>,
184 pub is_team_0: Option<bool>,
185}
186
187#[derive(Debug, Clone)]
188struct PendingGoalEvent {
189 event: GoalEvent,
190 time_after_kickoff: Option<f32>,
191}
192
193#[derive(Debug, Clone)]
194struct GoalBuildupSample {
195 time: f32,
196 dt: f32,
197 ball_y: f32,
198}
199
200#[derive(Debug, Clone)]
201struct PendingBackboardBounce {
202 player_id: PlayerId,
203 is_team_0: bool,
204 time: f32,
205}
206
207#[derive(Debug, Clone, Default)]
208pub struct MatchStatsReducer {
209 player_stats: HashMap<PlayerId, CorePlayerStats>,
210 player_teams: HashMap<PlayerId, bool>,
211 previous_player_stats: HashMap<PlayerId, CorePlayerStats>,
212 timeline: Vec<TimelineEvent>,
213 pending_goal_events: Vec<PendingGoalEvent>,
214 previous_team_scores: Option<(i32, i32)>,
215 kickoff_waiting_for_first_touch: bool,
216 active_kickoff_touch_time: Option<f32>,
217 goal_buildup_samples: Vec<GoalBuildupSample>,
218 pending_backboard_bounces: Vec<PendingBackboardBounce>,
219 live_play_tracker: LivePlayTracker,
220}
221
222impl MatchStatsReducer {
223 pub fn new() -> Self {
224 Self::default()
225 }
226
227 pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
228 &self.player_stats
229 }
230
231 pub fn timeline(&self) -> &[TimelineEvent] {
232 &self.timeline
233 }
234
235 pub fn team_zero_stats(&self) -> CoreTeamStats {
236 self.team_stats_for_side(true)
237 }
238
239 pub fn team_one_stats(&self) -> CoreTeamStats {
240 self.team_stats_for_side(false)
241 }
242
243 fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
244 self.player_stats
245 .iter()
246 .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
247 .fold(CoreTeamStats::default(), |mut stats, (_, player_stats)| {
248 stats.score += player_stats.score;
249 stats.goals += player_stats.goals;
250 stats.assists += player_stats.assists;
251 stats.saves += player_stats.saves;
252 stats.shots += player_stats.shots;
253 stats.attacking_backboard_hit_count += player_stats.attacking_backboard_hit_count;
254 stats.double_tap_count += player_stats.double_tap_count;
255 stats
256 .goal_after_kickoff
257 .merge(&player_stats.goal_after_kickoff);
258 stats.goal_buildup.merge(&player_stats.goal_buildup);
259 stats
260 })
261 }
262
263 fn emit_timeline_events(
264 &mut self,
265 time: f32,
266 kind: TimelineEventKind,
267 player_id: &PlayerId,
268 is_team_0: bool,
269 delta: i32,
270 ) {
271 for _ in 0..delta.max(0) {
272 self.timeline.push(TimelineEvent {
273 time,
274 kind,
275 player_id: Some(player_id.clone()),
276 is_team_0: Some(is_team_0),
277 });
278 }
279 }
280
281 fn kickoff_phase_active(sample: &StatsSample) -> bool {
282 sample.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
283 || sample.kickoff_countdown_time.is_some_and(|time| time > 0)
284 || sample.ball_has_been_hit == Some(false)
285 }
286
287 fn update_kickoff_reference(&mut self, sample: &StatsSample) {
288 if let Some(first_touch_time) = sample
289 .touch_events
290 .iter()
291 .map(|event| event.time)
292 .min_by(|a, b| a.total_cmp(b))
293 {
294 self.active_kickoff_touch_time = Some(first_touch_time);
295 self.kickoff_waiting_for_first_touch = false;
296 return;
297 }
298
299 if Self::kickoff_phase_active(sample) {
300 self.kickoff_waiting_for_first_touch = true;
301 self.active_kickoff_touch_time = None;
302 }
303 }
304
305 fn take_pending_goal_event(
306 &mut self,
307 player_id: &PlayerId,
308 is_team_0: bool,
309 ) -> Option<PendingGoalEvent> {
310 if let Some(index) = self.pending_goal_events.iter().position(|event| {
311 event.event.scoring_team_is_team_0 == is_team_0
312 && event.event.player.as_ref() == Some(player_id)
313 }) {
314 return Some(self.pending_goal_events.remove(index));
315 }
316
317 self.pending_goal_events
318 .iter()
319 .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
320 .map(|index| self.pending_goal_events.remove(index))
321 }
322
323 fn last_defender(
324 &self,
325 sample: &StatsSample,
326 defending_team_is_team_0: bool,
327 ) -> Option<PlayerId> {
328 sample
329 .players
330 .iter()
331 .filter(|player| player.is_team_0 == defending_team_is_team_0)
332 .filter_map(|player| {
333 player
334 .position()
335 .map(|position| (player.player_id.clone(), position.y))
336 })
337 .reduce(|current, candidate| {
338 if defending_team_is_team_0 {
339 if candidate.1 < current.1 {
340 candidate
341 } else {
342 current
343 }
344 } else if candidate.1 > current.1 {
345 candidate
346 } else {
347 current
348 }
349 })
350 .map(|(player_id, _)| player_id)
351 }
352
353 fn prune_goal_buildup_samples(&mut self, current_time: f32) {
354 self.goal_buildup_samples
355 .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
356 }
357
358 fn prune_pending_backboard_bounces(&mut self, current_time: f32) {
359 self.pending_backboard_bounces
360 .retain(|entry| current_time - entry.time <= DOUBLE_TAP_TOUCH_WINDOW_SECONDS);
361 }
362
363 fn record_backboard_bounces(&mut self, state: &BackboardBounceState) {
364 for event in &state.bounce_events {
365 self.player_stats
366 .entry(event.player.clone())
367 .or_default()
368 .attacking_backboard_hit_count += 1;
369 if let Some(existing) = self
370 .pending_backboard_bounces
371 .iter_mut()
372 .find(|pending| pending.player_id == event.player)
373 {
374 *existing = PendingBackboardBounce {
375 player_id: event.player.clone(),
376 is_team_0: event.is_team_0,
377 time: event.time,
378 };
379 } else {
380 self.pending_backboard_bounces.push(PendingBackboardBounce {
381 player_id: event.player.clone(),
382 is_team_0: event.is_team_0,
383 time: event.time,
384 });
385 }
386 }
387 }
388
389 fn resolve_double_tap_touches(&mut self, sample: &StatsSample) {
390 if sample.touch_events.is_empty() || self.pending_backboard_bounces.is_empty() {
391 return;
392 }
393
394 let mut completed_players = Vec::new();
395 self.pending_backboard_bounces.retain(|pending| {
396 if sample.time <= pending.time {
397 return true;
398 }
399
400 let matching_touch = sample.touch_events.iter().any(|touch| {
401 touch.team_is_team_0 == pending.is_team_0
402 && touch.player.as_ref() == Some(&pending.player_id)
403 });
404 let conflicting_touch = sample
405 .touch_events
406 .iter()
407 .any(|touch| touch.player.as_ref() != Some(&pending.player_id));
408
409 if matching_touch
410 && !conflicting_touch
411 && Self::followup_touch_is_goal_directed(sample, pending.is_team_0)
412 {
413 completed_players.push(pending.player_id.clone());
414 }
415 false
416 });
417
418 for player_id in completed_players {
419 self.player_stats
420 .entry(player_id)
421 .or_default()
422 .double_tap_count += 1;
423 }
424 }
425
426 fn followup_touch_is_goal_directed(sample: &StatsSample, is_team_0: bool) -> bool {
427 const GOAL_CENTER_Y: f32 = 5120.0;
428 const MIN_GOAL_ALIGNMENT_COSINE: f32 = 0.6;
429
430 let Some(ball) = sample.ball.as_ref() else {
431 return false;
432 };
433
434 let ball_position = ball.position();
435 let normalized_position_y = if is_team_0 {
436 ball_position.y
437 } else {
438 -ball_position.y
439 };
440 let normalized_velocity = ball.velocity();
441 let normalized_velocity = glam::Vec2::new(
442 normalized_velocity.x,
443 if is_team_0 {
444 normalized_velocity.y
445 } else {
446 -normalized_velocity.y
447 },
448 );
449 let goal_direction =
450 glam::Vec2::new(-ball_position.x, GOAL_CENTER_Y - normalized_position_y);
451
452 let Some(shot_direction) = normalized_velocity.try_normalize() else {
453 return false;
454 };
455 let Some(goal_direction) = goal_direction.try_normalize() else {
456 return false;
457 };
458
459 shot_direction.dot(goal_direction) >= MIN_GOAL_ALIGNMENT_COSINE
460 }
461
462 fn record_goal_buildup_sample(&mut self, sample: &StatsSample) {
463 let Some(ball) = sample.ball.as_ref() else {
464 return;
465 };
466 if sample.dt <= 0.0 {
467 return;
468 }
469 self.goal_buildup_samples.push(GoalBuildupSample {
470 time: sample.time,
471 dt: sample.dt,
472 ball_y: ball.position().y,
473 });
474 }
475
476 fn classify_goal_buildup(
477 &self,
478 goal_time: f32,
479 scoring_team_is_team_0: bool,
480 ) -> GoalBuildupKind {
481 let relevant_samples: Vec<_> = self
482 .goal_buildup_samples
483 .iter()
484 .filter(|entry| goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS)
485 .collect();
486 if relevant_samples.is_empty() {
487 return GoalBuildupKind::Other;
488 }
489
490 let mut defensive_half_time = 0.0;
491 let mut defensive_third_time = 0.0;
492 let mut offensive_half_time = 0.0;
493 let mut offensive_third_time = 0.0;
494 let mut current_attack_time = 0.0;
495
496 for entry in &relevant_samples {
497 let normalized_ball_y = if scoring_team_is_team_0 {
498 entry.ball_y
499 } else {
500 -entry.ball_y
501 };
502 if normalized_ball_y < 0.0 {
503 defensive_half_time += entry.dt;
504 } else {
505 offensive_half_time += entry.dt;
506 }
507 if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
508 defensive_third_time += entry.dt;
509 }
510 if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
511 offensive_third_time += entry.dt;
512 }
513 }
514
515 for entry in relevant_samples.iter().rev() {
516 let normalized_ball_y = if scoring_team_is_team_0 {
517 entry.ball_y
518 } else {
519 -entry.ball_y
520 };
521 if normalized_ball_y > 0.0 {
522 current_attack_time += entry.dt;
523 } else {
524 break;
525 }
526 }
527
528 if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS
529 && defensive_half_time >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
530 && defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
531 {
532 GoalBuildupKind::CounterAttack
533 } else if current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
534 && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
535 && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
536 {
537 GoalBuildupKind::SustainedPressure
538 } else {
539 GoalBuildupKind::Other
540 }
541 }
542}
543
544impl MatchStatsReducer {
545 fn on_sample_internal(
546 &mut self,
547 sample: &StatsSample,
548 backboard_bounce_state: &BackboardBounceState,
549 ) -> SubtrActorResult<()> {
550 self.update_kickoff_reference(sample);
551 let live_play = self.live_play_tracker.is_live_play(sample);
552 if !sample.is_live_play() {
553 self.pending_backboard_bounces.clear();
554 }
555 self.prune_goal_buildup_samples(sample.time);
556 self.prune_pending_backboard_bounces(sample.time);
557 self.record_backboard_bounces(backboard_bounce_state);
558 self.resolve_double_tap_touches(sample);
559 if live_play {
560 self.record_goal_buildup_sample(sample);
561 }
562 self.pending_goal_events
563 .extend(sample.goal_events.iter().cloned().map(|event| {
564 PendingGoalEvent {
565 time_after_kickoff: self
566 .active_kickoff_touch_time
567 .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
568 event,
569 }
570 }));
571 let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
572 HashMap::new();
573 for event in &sample.player_stat_events {
574 let kind = match event.kind {
575 PlayerStatEventKind::Shot => TimelineEventKind::Shot,
576 PlayerStatEventKind::Save => TimelineEventKind::Save,
577 PlayerStatEventKind::Assist => TimelineEventKind::Assist,
578 };
579 self.timeline.push(TimelineEvent {
580 time: event.time,
581 kind,
582 player_id: Some(event.player.clone()),
583 is_team_0: Some(event.is_team_0),
584 });
585 *processor_event_counts
586 .entry((event.player.clone(), kind))
587 .or_default() += 1;
588 }
589
590 for player in &sample.players {
591 self.player_teams
592 .insert(player.player_id.clone(), player.is_team_0);
593 let mut current_stats = CorePlayerStats {
594 score: player.match_score.unwrap_or(0),
595 goals: player.match_goals.unwrap_or(0),
596 assists: player.match_assists.unwrap_or(0),
597 saves: player.match_saves.unwrap_or(0),
598 shots: player.match_shots.unwrap_or(0),
599 attacking_backboard_hit_count: self
600 .player_stats
601 .get(&player.player_id)
602 .map(|stats| stats.attacking_backboard_hit_count)
603 .unwrap_or(0),
604 double_tap_count: self
605 .player_stats
606 .get(&player.player_id)
607 .map(|stats| stats.double_tap_count)
608 .unwrap_or(0),
609 goals_conceded_while_last_defender: self
610 .player_stats
611 .get(&player.player_id)
612 .map(|stats| stats.goals_conceded_while_last_defender)
613 .unwrap_or(0),
614 goal_after_kickoff: self
615 .player_stats
616 .get(&player.player_id)
617 .map(|stats| stats.goal_after_kickoff.clone())
618 .unwrap_or_default(),
619 goal_buildup: self
620 .player_stats
621 .get(&player.player_id)
622 .map(|stats| stats.goal_buildup.clone())
623 .unwrap_or_default(),
624 };
625
626 let previous_stats = self
627 .previous_player_stats
628 .get(&player.player_id)
629 .cloned()
630 .unwrap_or_default();
631
632 let shot_delta = current_stats.shots - previous_stats.shots;
633 let save_delta = current_stats.saves - previous_stats.saves;
634 let assist_delta = current_stats.assists - previous_stats.assists;
635 let goal_delta = current_stats.goals - previous_stats.goals;
636 let shot_fallback_delta = shot_delta
637 - processor_event_counts
638 .get(&(player.player_id.clone(), TimelineEventKind::Shot))
639 .copied()
640 .unwrap_or(0);
641 let save_fallback_delta = save_delta
642 - processor_event_counts
643 .get(&(player.player_id.clone(), TimelineEventKind::Save))
644 .copied()
645 .unwrap_or(0);
646 let assist_fallback_delta = assist_delta
647 - processor_event_counts
648 .get(&(player.player_id.clone(), TimelineEventKind::Assist))
649 .copied()
650 .unwrap_or(0);
651
652 if shot_fallback_delta > 0 {
653 self.emit_timeline_events(
654 sample.time,
655 TimelineEventKind::Shot,
656 &player.player_id,
657 player.is_team_0,
658 shot_fallback_delta,
659 );
660 }
661 if save_fallback_delta > 0 {
662 self.emit_timeline_events(
663 sample.time,
664 TimelineEventKind::Save,
665 &player.player_id,
666 player.is_team_0,
667 save_fallback_delta,
668 );
669 }
670 if assist_fallback_delta > 0 {
671 self.emit_timeline_events(
672 sample.time,
673 TimelineEventKind::Assist,
674 &player.player_id,
675 player.is_team_0,
676 assist_fallback_delta,
677 );
678 }
679 if goal_delta > 0 {
680 for _ in 0..goal_delta.max(0) {
681 let pending_goal_event =
682 self.take_pending_goal_event(&player.player_id, player.is_team_0);
683 let goal_time = pending_goal_event
684 .as_ref()
685 .map(|event| event.event.time)
686 .unwrap_or(sample.time);
687 let time_after_kickoff = pending_goal_event
688 .and_then(|event| event.time_after_kickoff)
689 .or_else(|| {
690 self.active_kickoff_touch_time
691 .map(|kickoff_touch_time| (goal_time - kickoff_touch_time).max(0.0))
692 });
693 if let Some(time_after_kickoff) = time_after_kickoff {
694 current_stats
695 .goal_after_kickoff
696 .record_goal(time_after_kickoff);
697 }
698 current_stats
699 .goal_buildup
700 .record(self.classify_goal_buildup(goal_time, player.is_team_0));
701 self.timeline.push(TimelineEvent {
702 time: goal_time,
703 kind: TimelineEventKind::Goal,
704 player_id: Some(player.player_id.clone()),
705 is_team_0: Some(player.is_team_0),
706 });
707 }
708 }
709
710 self.previous_player_stats
711 .insert(player.player_id.clone(), current_stats.clone());
712 self.player_stats
713 .insert(player.player_id.clone(), current_stats);
714 }
715
716 if let (Some(team_zero_score), Some(team_one_score)) =
717 (sample.team_zero_score, sample.team_one_score)
718 {
719 if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
720 let team_zero_delta = team_zero_score - prev_team_zero_score;
721 let team_one_delta = team_one_score - prev_team_one_score;
722
723 if team_zero_delta > 0 {
724 if let Some(last_defender) = self.last_defender(sample, false) {
725 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
726 stats.goals_conceded_while_last_defender += team_zero_delta as u32;
727 }
728 }
729 }
730
731 if team_one_delta > 0 {
732 if let Some(last_defender) = self.last_defender(sample, true) {
733 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
734 stats.goals_conceded_while_last_defender += team_one_delta as u32;
735 }
736 }
737 }
738 }
739
740 self.previous_team_scores = Some((team_zero_score, team_one_score));
741 }
742
743 self.timeline.sort_by(|a, b| {
744 a.time
745 .partial_cmp(&b.time)
746 .unwrap_or(std::cmp::Ordering::Equal)
747 });
748
749 Ok(())
750 }
751}
752
753impl StatsReducer for MatchStatsReducer {
754 fn on_sample(&mut self, sample: &StatsSample) -> SubtrActorResult<()> {
755 self.on_sample_internal(sample, &BackboardBounceState::default())
756 }
757
758 fn on_sample_with_context(
759 &mut self,
760 sample: &StatsSample,
761 ctx: &AnalysisContext,
762 ) -> SubtrActorResult<()> {
763 let backboard_bounce_state = ctx
764 .get::<BackboardBounceState>(BACKBOARD_BOUNCE_STATE_SIGNAL_ID)
765 .cloned()
766 .unwrap_or_default();
767 self.on_sample_internal(sample, &backboard_bounce_state)
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use boxcars::{Quaternion, RemoteId, RigidBody, Vector3f};
774
775 use super::*;
776
777 fn rigid_body(y: f32) -> RigidBody {
778 RigidBody {
779 sleeping: false,
780 location: Vector3f { x: 0.0, y, z: 17.0 },
781 rotation: Quaternion {
782 x: 0.0,
783 y: 0.0,
784 z: 0.0,
785 w: 1.0,
786 },
787 linear_velocity: Some(Vector3f {
788 x: 0.0,
789 y: 0.0,
790 z: 0.0,
791 }),
792 angular_velocity: Some(Vector3f {
793 x: 0.0,
794 y: 0.0,
795 z: 0.0,
796 }),
797 }
798 }
799
800 fn ball_rigid_body(x: f32, y: f32, z: f32, vx: f32, vy: f32) -> RigidBody {
801 RigidBody {
802 sleeping: false,
803 location: Vector3f { x, y, z },
804 rotation: Quaternion {
805 x: 0.0,
806 y: 0.0,
807 z: 0.0,
808 w: 1.0,
809 },
810 linear_velocity: Some(Vector3f {
811 x: vx,
812 y: vy,
813 z: 0.0,
814 }),
815 angular_velocity: Some(Vector3f {
816 x: 0.0,
817 y: 0.0,
818 z: 0.0,
819 }),
820 }
821 }
822
823 fn player(player_id: u64, is_team_0: bool, match_goals: i32) -> PlayerSample {
824 PlayerSample {
825 player_id: RemoteId::Steam(player_id),
826 is_team_0,
827 rigid_body: Some(rigid_body(if is_team_0 { -1000.0 } else { 1000.0 })),
828 boost_amount: None,
829 last_boost_amount: None,
830 boost_active: false,
831 dodge_active: false,
832 powerslide_active: false,
833 match_goals: Some(match_goals),
834 match_assists: Some(0),
835 match_saves: Some(0),
836 match_shots: Some(match_goals.max(1)),
837 match_score: Some(match_goals * 100),
838 }
839 }
840
841 fn sample(
842 frame_number: usize,
843 time: f32,
844 dt: f32,
845 ball_y: f32,
846 team_zero_goals: i32,
847 goal_event: Option<GoalEvent>,
848 ) -> StatsSample {
849 StatsSample {
850 frame_number,
851 time,
852 dt,
853 seconds_remaining: None,
854 game_state: None,
855 ball_has_been_hit: Some(true),
856 kickoff_countdown_time: None,
857 team_zero_score: Some(team_zero_goals),
858 team_one_score: Some(0),
859 possession_team_is_team_0: Some(true),
860 scored_on_team_is_team_0: goal_event
861 .as_ref()
862 .map(|event| !event.scoring_team_is_team_0),
863 current_in_game_team_player_counts: Some([1, 1]),
864 ball: Some(BallSample {
865 rigid_body: rigid_body(ball_y),
866 }),
867 players: vec![player(1, true, team_zero_goals), player(2, false, 0)],
868 active_demos: Vec::new(),
869 demo_events: Vec::new(),
870 boost_pad_events: Vec::new(),
871 touch_events: Vec::new(),
872 dodge_refreshed_events: Vec::new(),
873 player_stat_events: Vec::new(),
874 goal_events: goal_event.into_iter().collect(),
875 }
876 }
877
878 fn touch_event(frame: usize, time: f32, player_id: u64, is_team_0: bool) -> TouchEvent {
879 TouchEvent {
880 time,
881 frame,
882 team_is_team_0: is_team_0,
883 player: Some(RemoteId::Steam(player_id)),
884 closest_approach_distance: Some(40.0),
885 }
886 }
887
888 #[test]
889 fn classifies_counter_attack_goals_from_recent_defensive_pressure() {
890 let mut reducer = MatchStatsReducer::new();
891
892 for (index, ball_y) in [-4200.0, -4000.0, -3600.0, -3200.0, -2600.0, -1800.0, 1200.0]
893 .into_iter()
894 .enumerate()
895 {
896 reducer
897 .on_sample(&sample(index, index as f32 + 1.0, 1.0, ball_y, 0, None))
898 .unwrap();
899 }
900
901 reducer
902 .on_sample(&sample(
903 8,
904 8.0,
905 1.0,
906 4800.0,
907 1,
908 Some(GoalEvent {
909 time: 8.0,
910 frame: 8,
911 scoring_team_is_team_0: true,
912 player: Some(RemoteId::Steam(1)),
913 team_zero_score: Some(1),
914 team_one_score: Some(0),
915 }),
916 ))
917 .unwrap();
918
919 let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
920 assert_eq!(stats.goal_buildup.counter_attack_goal_count, 1);
921 assert_eq!(stats.goal_buildup.sustained_pressure_goal_count, 0);
922 assert_eq!(stats.goal_buildup.other_buildup_goal_count, 0);
923 }
924
925 #[test]
926 fn classifies_sustained_pressure_goals_after_long_attacking_spell() {
927 let mut reducer = MatchStatsReducer::new();
928
929 for (index, ball_y) in [
930 800.0, 1400.0, 2200.0, 2800.0, 3200.0, 3600.0, 4100.0, 4600.0,
931 ]
932 .into_iter()
933 .enumerate()
934 {
935 reducer
936 .on_sample(&sample(index, index as f32 + 1.0, 1.0, ball_y, 0, None))
937 .unwrap();
938 }
939
940 reducer
941 .on_sample(&sample(
942 9,
943 9.0,
944 1.0,
945 5000.0,
946 1,
947 Some(GoalEvent {
948 time: 9.0,
949 frame: 9,
950 scoring_team_is_team_0: true,
951 player: Some(RemoteId::Steam(1)),
952 team_zero_score: Some(1),
953 team_one_score: Some(0),
954 }),
955 ))
956 .unwrap();
957
958 let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
959 assert_eq!(stats.goal_buildup.counter_attack_goal_count, 0);
960 assert_eq!(stats.goal_buildup.sustained_pressure_goal_count, 1);
961 assert_eq!(stats.goal_buildup.other_buildup_goal_count, 0);
962 }
963
964 #[test]
965 fn counts_double_taps_from_backboard_bounces_before_the_followup_touch() {
966 let mut reducer = MatchStatsReducer::new();
967
968 let mut setup_touch = sample(1, 1.0, 1.0, 4300.0, 0, None);
969 setup_touch.ball = Some(BallSample {
970 rigid_body: ball_rigid_body(0.0, 4300.0, 700.0, 0.0, 900.0),
971 });
972 setup_touch.touch_events = vec![touch_event(1, 1.0, 1, true)];
973 reducer.on_sample(&setup_touch).unwrap();
974
975 let bounce_sample = sample(2, 1.2, 0.2, 4800.0, 0, None);
976 reducer
977 .on_sample_internal(
978 &bounce_sample,
979 &BackboardBounceState {
980 bounce_events: vec![BackboardBounceEvent {
981 time: 1.2,
982 frame: 2,
983 player: RemoteId::Steam(1),
984 is_team_0: true,
985 }],
986 last_bounce_event: Some(BackboardBounceEvent {
987 time: 1.2,
988 frame: 2,
989 player: RemoteId::Steam(1),
990 is_team_0: true,
991 }),
992 },
993 )
994 .unwrap();
995
996 let mut followup_touch = sample(3, 1.6, 0.4, 4400.0, 0, None);
997 followup_touch.ball = Some(BallSample {
998 rigid_body: ball_rigid_body(0.0, 4400.0, 700.0, 0.0, 900.0),
999 });
1000 followup_touch.touch_events = vec![touch_event(3, 1.6, 1, true)];
1001 reducer.on_sample(&followup_touch).unwrap();
1002
1003 let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
1004 assert_eq!(stats.attacking_backboard_hit_count, 1);
1005 assert_eq!(stats.double_tap_count, 1);
1006 assert_eq!(reducer.team_zero_stats().attacking_backboard_hit_count, 1);
1007 assert_eq!(reducer.team_zero_stats().double_tap_count, 1);
1008 }
1009
1010 #[test]
1011 fn does_not_count_double_taps_when_another_player_touches_first() {
1012 let mut reducer = MatchStatsReducer::new();
1013
1014 let mut setup_touch = sample(1, 1.0, 1.0, 4300.0, 0, None);
1015 setup_touch.ball = Some(BallSample {
1016 rigid_body: ball_rigid_body(0.0, 4300.0, 700.0, 0.0, 900.0),
1017 });
1018 setup_touch.touch_events = vec![touch_event(1, 1.0, 1, true)];
1019 reducer.on_sample(&setup_touch).unwrap();
1020
1021 let bounce_sample = sample(2, 1.2, 0.2, 4800.0, 0, None);
1022 reducer
1023 .on_sample_internal(
1024 &bounce_sample,
1025 &BackboardBounceState {
1026 bounce_events: vec![BackboardBounceEvent {
1027 time: 1.2,
1028 frame: 2,
1029 player: RemoteId::Steam(1),
1030 is_team_0: true,
1031 }],
1032 last_bounce_event: Some(BackboardBounceEvent {
1033 time: 1.2,
1034 frame: 2,
1035 player: RemoteId::Steam(1),
1036 is_team_0: true,
1037 }),
1038 },
1039 )
1040 .unwrap();
1041
1042 let mut opponent_touch = sample(3, 1.4, 0.2, 4300.0, 0, None);
1043 opponent_touch.touch_events = vec![touch_event(3, 1.4, 2, false)];
1044 reducer.on_sample(&opponent_touch).unwrap();
1045
1046 let mut late_followup = sample(4, 1.8, 0.4, 4200.0, 0, None);
1047 late_followup.touch_events = vec![touch_event(4, 1.8, 1, true)];
1048 reducer.on_sample(&late_followup).unwrap();
1049
1050 let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
1051 assert_eq!(stats.attacking_backboard_hit_count, 1);
1052 assert_eq!(stats.double_tap_count, 0);
1053 }
1054
1055 #[test]
1056 fn does_not_count_double_taps_when_followup_touch_is_not_goal_directed() {
1057 let mut reducer = MatchStatsReducer::new();
1058
1059 let mut setup_touch = sample(1, 1.0, 1.0, 4300.0, 0, None);
1060 setup_touch.ball = Some(BallSample {
1061 rigid_body: ball_rigid_body(0.0, 4300.0, 700.0, 0.0, 900.0),
1062 });
1063 setup_touch.touch_events = vec![touch_event(1, 1.0, 1, true)];
1064 reducer.on_sample(&setup_touch).unwrap();
1065
1066 let bounce_sample = sample(2, 1.2, 0.2, 4800.0, 0, None);
1067 reducer
1068 .on_sample_internal(
1069 &bounce_sample,
1070 &BackboardBounceState {
1071 bounce_events: vec![BackboardBounceEvent {
1072 time: 1.2,
1073 frame: 2,
1074 player: RemoteId::Steam(1),
1075 is_team_0: true,
1076 }],
1077 last_bounce_event: Some(BackboardBounceEvent {
1078 time: 1.2,
1079 frame: 2,
1080 player: RemoteId::Steam(1),
1081 is_team_0: true,
1082 }),
1083 },
1084 )
1085 .unwrap();
1086
1087 let mut wide_followup = sample(3, 1.6, 0.4, 4400.0, 0, None);
1088 wide_followup.ball = Some(BallSample {
1089 rigid_body: ball_rigid_body(1400.0, 4400.0, 700.0, 1200.0, 150.0),
1090 });
1091 wide_followup.touch_events = vec![touch_event(3, 1.6, 1, true)];
1092 reducer.on_sample(&wide_followup).unwrap();
1093
1094 let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
1095 assert_eq!(stats.attacking_backboard_hit_count, 1);
1096 assert_eq!(stats.double_tap_count, 0);
1097 }
1098}