Skip to main content

subtr_actor/stats/reducers/
match_stats.rs

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}