Skip to main content

subtr_actor/stats/calculators/
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;
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
14#[ts(export)]
15pub struct GoalAfterKickoffStats {
16    pub kickoff_goal_count: u32,
17    pub short_goal_count: u32,
18    pub medium_goal_count: u32,
19    pub long_goal_count: u32,
20    #[serde(default, skip_serializing)]
21    goal_times: Vec<f32>,
22}
23
24impl GoalAfterKickoffStats {
25    pub fn goal_times(&self) -> &[f32] {
26        &self.goal_times
27    }
28
29    pub fn record_goal(&mut self, time_after_kickoff: f32) {
30        let clamped_time = time_after_kickoff.max(0.0);
31        self.goal_times.push(clamped_time);
32        if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS {
33            self.kickoff_goal_count += 1;
34        } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS {
35            self.short_goal_count += 1;
36        } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS {
37            self.medium_goal_count += 1;
38        } else {
39            self.long_goal_count += 1;
40        }
41    }
42
43    pub fn average_goal_time_after_kickoff(&self) -> f32 {
44        if self.goal_times.is_empty() {
45            0.0
46        } else {
47            self.goal_times.iter().sum::<f32>() / self.goal_times.len() as f32
48        }
49    }
50
51    pub fn median_goal_time_after_kickoff(&self) -> f32 {
52        if self.goal_times.is_empty() {
53            return 0.0;
54        }
55
56        let mut sorted_times = self.goal_times.clone();
57        sorted_times.sort_by(|a, b| a.total_cmp(b));
58        let midpoint = sorted_times.len() / 2;
59        if sorted_times.len().is_multiple_of(2) {
60            (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
61        } else {
62            sorted_times[midpoint]
63        }
64    }
65
66    fn merge(&mut self, other: &Self) {
67        self.kickoff_goal_count += other.kickoff_goal_count;
68        self.short_goal_count += other.short_goal_count;
69        self.medium_goal_count += other.medium_goal_count;
70        self.long_goal_count += other.long_goal_count;
71        self.goal_times.extend(other.goal_times.iter().copied());
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum GoalBuildupKind {
77    CounterAttack,
78    SustainedPressure,
79    Other,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
83#[ts(export)]
84pub struct GoalBuildupStats {
85    pub counter_attack_goal_count: u32,
86    pub sustained_pressure_goal_count: u32,
87    pub other_buildup_goal_count: u32,
88}
89
90impl GoalBuildupStats {
91    fn record(&mut self, kind: GoalBuildupKind) {
92        match kind {
93            GoalBuildupKind::CounterAttack => self.counter_attack_goal_count += 1,
94            GoalBuildupKind::SustainedPressure => self.sustained_pressure_goal_count += 1,
95            GoalBuildupKind::Other => self.other_buildup_goal_count += 1,
96        }
97    }
98
99    fn merge(&mut self, other: &Self) {
100        self.counter_attack_goal_count += other.counter_attack_goal_count;
101        self.sustained_pressure_goal_count += other.sustained_pressure_goal_count;
102        self.other_buildup_goal_count += other.other_buildup_goal_count;
103    }
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
107#[ts(export)]
108pub struct PlayerScoringContextStats {
109    pub goals_conceded_while_last_defender: u32,
110    #[serde(flatten)]
111    pub goal_after_kickoff: GoalAfterKickoffStats,
112    #[serde(flatten)]
113    pub goal_buildup: GoalBuildupStats,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
117#[ts(export)]
118pub struct CorePlayerStats {
119    pub score: i32,
120    pub goals: i32,
121    pub assists: i32,
122    pub saves: i32,
123    pub shots: i32,
124    #[serde(flatten)]
125    pub scoring_context: PlayerScoringContextStats,
126}
127
128impl CorePlayerStats {
129    pub fn shooting_percentage(&self) -> f32 {
130        if self.shots == 0 {
131            0.0
132        } else {
133            self.goals as f32 * 100.0 / self.shots as f32
134        }
135    }
136
137    pub fn average_goal_time_after_kickoff(&self) -> f32 {
138        self.scoring_context
139            .goal_after_kickoff
140            .average_goal_time_after_kickoff()
141    }
142
143    pub fn median_goal_time_after_kickoff(&self) -> f32 {
144        self.scoring_context
145            .goal_after_kickoff
146            .median_goal_time_after_kickoff()
147    }
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
151#[ts(export)]
152pub struct TeamScoringContextStats {
153    #[serde(flatten)]
154    pub goal_after_kickoff: GoalAfterKickoffStats,
155    #[serde(flatten)]
156    pub goal_buildup: GoalBuildupStats,
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
160#[ts(export)]
161pub struct CoreTeamStats {
162    pub score: i32,
163    pub goals: i32,
164    pub assists: i32,
165    pub saves: i32,
166    pub shots: i32,
167    #[serde(flatten)]
168    pub scoring_context: TeamScoringContextStats,
169}
170
171impl CoreTeamStats {
172    pub fn shooting_percentage(&self) -> f32 {
173        if self.shots == 0 {
174            0.0
175        } else {
176            self.goals as f32 * 100.0 / self.shots as f32
177        }
178    }
179
180    pub fn average_goal_time_after_kickoff(&self) -> f32 {
181        self.scoring_context
182            .goal_after_kickoff
183            .average_goal_time_after_kickoff()
184    }
185
186    pub fn median_goal_time_after_kickoff(&self) -> f32 {
187        self.scoring_context
188            .goal_after_kickoff
189            .median_goal_time_after_kickoff()
190    }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ts_rs::TS)]
194#[ts(export)]
195pub enum TimelineEventKind {
196    Goal,
197    Shot,
198    Save,
199    Assist,
200    Kill,
201    Death,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
205#[ts(export)]
206pub struct TimelineEvent {
207    pub time: f32,
208    pub kind: TimelineEventKind,
209    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
210    pub player_id: Option<PlayerId>,
211    pub is_team_0: Option<bool>,
212}
213
214#[derive(Debug, Clone)]
215struct PendingGoalEvent {
216    event: GoalEvent,
217    time_after_kickoff: Option<f32>,
218}
219
220#[derive(Debug, Clone)]
221struct GoalBuildupSample {
222    time: f32,
223    dt: f32,
224    ball_y: f32,
225}
226
227#[derive(Debug, Clone, Default)]
228pub struct MatchStatsCalculator {
229    player_stats: HashMap<PlayerId, CorePlayerStats>,
230    player_teams: HashMap<PlayerId, bool>,
231    previous_player_stats: HashMap<PlayerId, CorePlayerStats>,
232    timeline: Vec<TimelineEvent>,
233    pending_goal_events: Vec<PendingGoalEvent>,
234    previous_team_scores: Option<(i32, i32)>,
235    kickoff_waiting_for_first_touch: bool,
236    active_kickoff_touch_time: Option<f32>,
237    goal_buildup_samples: Vec<GoalBuildupSample>,
238}
239
240impl MatchStatsCalculator {
241    pub fn new() -> Self {
242        Self::default()
243    }
244
245    pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
246        &self.player_stats
247    }
248
249    pub fn timeline(&self) -> &[TimelineEvent] {
250        &self.timeline
251    }
252
253    pub fn team_zero_stats(&self) -> CoreTeamStats {
254        self.team_stats_for_side(true)
255    }
256
257    pub fn team_one_stats(&self) -> CoreTeamStats {
258        self.team_stats_for_side(false)
259    }
260
261    fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
262        let mut stats = self
263            .player_stats
264            .iter()
265            .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
266            .fold(CoreTeamStats::default(), |mut stats, (_, player_stats)| {
267                stats.score += player_stats.score;
268                stats.goals += player_stats.goals;
269                stats.assists += player_stats.assists;
270                stats.saves += player_stats.saves;
271                stats.shots += player_stats.shots;
272                stats
273                    .scoring_context
274                    .goal_after_kickoff
275                    .merge(&player_stats.scoring_context.goal_after_kickoff);
276                stats
277                    .scoring_context
278                    .goal_buildup
279                    .merge(&player_stats.scoring_context.goal_buildup);
280                stats
281            });
282        stats
283            .scoring_context
284            .goal_after_kickoff
285            .goal_times
286            .sort_by(|left, right| left.total_cmp(right));
287        stats
288    }
289
290    fn emit_timeline_events(
291        &mut self,
292        time: f32,
293        kind: TimelineEventKind,
294        player_id: &PlayerId,
295        is_team_0: bool,
296        delta: i32,
297    ) {
298        for _ in 0..delta.max(0) {
299            self.timeline.push(TimelineEvent {
300                time,
301                kind,
302                player_id: Some(player_id.clone()),
303                is_team_0: Some(is_team_0),
304            });
305        }
306    }
307
308    fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
309        gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
310            || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
311            || gameplay.ball_has_been_hit == Some(false)
312    }
313
314    fn update_kickoff_reference(&mut self, gameplay: &GameplayState, events: &FrameEventsState) {
315        if let Some(first_touch_time) = events
316            .touch_events
317            .iter()
318            .map(|event| event.time)
319            .min_by(|a, b| a.total_cmp(b))
320        {
321            self.active_kickoff_touch_time = Some(first_touch_time);
322            self.kickoff_waiting_for_first_touch = false;
323            return;
324        }
325
326        if Self::kickoff_phase_active(gameplay) {
327            self.kickoff_waiting_for_first_touch = true;
328            self.active_kickoff_touch_time = None;
329        }
330    }
331
332    fn take_pending_goal_event(
333        &mut self,
334        player_id: &PlayerId,
335        is_team_0: bool,
336    ) -> Option<PendingGoalEvent> {
337        if let Some(index) = self.pending_goal_events.iter().position(|event| {
338            event.event.scoring_team_is_team_0 == is_team_0
339                && event.event.player.as_ref() == Some(player_id)
340        }) {
341            return Some(self.pending_goal_events.remove(index));
342        }
343
344        self.pending_goal_events
345            .iter()
346            .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
347            .map(|index| self.pending_goal_events.remove(index))
348    }
349
350    fn last_defender(
351        &self,
352        players: &PlayerFrameState,
353        defending_team_is_team_0: bool,
354    ) -> Option<PlayerId> {
355        players
356            .players
357            .iter()
358            .filter(|player| player.is_team_0 == defending_team_is_team_0)
359            .filter_map(|player| {
360                player
361                    .position()
362                    .map(|position| (player.player_id.clone(), position.y))
363            })
364            .reduce(|current, candidate| {
365                if defending_team_is_team_0 {
366                    if candidate.1 < current.1 {
367                        candidate
368                    } else {
369                        current
370                    }
371                } else if candidate.1 > current.1 {
372                    candidate
373                } else {
374                    current
375                }
376            })
377            .map(|(player_id, _)| player_id)
378    }
379
380    fn prune_goal_buildup_samples(&mut self, current_time: f32) {
381        self.goal_buildup_samples
382            .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
383    }
384
385    fn record_goal_buildup_sample(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
386        let Some(ball) = ball.sample() else {
387            return;
388        };
389        if frame.dt <= 0.0 {
390            return;
391        }
392        self.goal_buildup_samples.push(GoalBuildupSample {
393            time: frame.time,
394            dt: frame.dt,
395            ball_y: ball.position().y,
396        });
397    }
398
399    fn classify_goal_buildup(
400        &self,
401        goal_time: f32,
402        scoring_team_is_team_0: bool,
403    ) -> GoalBuildupKind {
404        let relevant_samples: Vec<_> = self
405            .goal_buildup_samples
406            .iter()
407            .filter(|entry| goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS)
408            .collect();
409        if relevant_samples.is_empty() {
410            return GoalBuildupKind::Other;
411        }
412
413        let mut defensive_half_time = 0.0;
414        let mut defensive_third_time = 0.0;
415        let mut offensive_half_time = 0.0;
416        let mut offensive_third_time = 0.0;
417        let mut current_attack_time = 0.0;
418
419        for entry in &relevant_samples {
420            let normalized_ball_y = if scoring_team_is_team_0 {
421                entry.ball_y
422            } else {
423                -entry.ball_y
424            };
425            if normalized_ball_y < 0.0 {
426                defensive_half_time += entry.dt;
427            } else {
428                offensive_half_time += entry.dt;
429            }
430            if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
431                defensive_third_time += entry.dt;
432            }
433            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
434                offensive_third_time += entry.dt;
435            }
436        }
437
438        for entry in relevant_samples.iter().rev() {
439            let normalized_ball_y = if scoring_team_is_team_0 {
440                entry.ball_y
441            } else {
442                -entry.ball_y
443            };
444            if normalized_ball_y > 0.0 {
445                current_attack_time += entry.dt;
446            } else {
447                break;
448            }
449        }
450
451        if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS
452            && defensive_half_time >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
453            && defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
454        {
455            GoalBuildupKind::CounterAttack
456        } else if current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
457            && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
458            && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
459        {
460            GoalBuildupKind::SustainedPressure
461        } else {
462            GoalBuildupKind::Other
463        }
464    }
465}
466
467impl MatchStatsCalculator {
468    pub fn update_parts(
469        &mut self,
470        frame: &FrameInfo,
471        gameplay: &GameplayState,
472        ball: &BallFrameState,
473        players: &PlayerFrameState,
474        events: &FrameEventsState,
475        live_play_state: &LivePlayState,
476    ) -> SubtrActorResult<()> {
477        self.update_kickoff_reference(gameplay, events);
478        self.prune_goal_buildup_samples(frame.time);
479        if live_play_state.is_live_play {
480            self.record_goal_buildup_sample(frame, ball);
481        }
482        self.pending_goal_events
483            .extend(events.goal_events.iter().cloned().map(|event| {
484                PendingGoalEvent {
485                    time_after_kickoff: self
486                        .active_kickoff_touch_time
487                        .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
488                    event,
489                }
490            }));
491        let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
492            HashMap::new();
493        for event in &events.player_stat_events {
494            let kind = match event.kind {
495                PlayerStatEventKind::Shot => TimelineEventKind::Shot,
496                PlayerStatEventKind::Save => TimelineEventKind::Save,
497                PlayerStatEventKind::Assist => TimelineEventKind::Assist,
498            };
499            self.timeline.push(TimelineEvent {
500                time: event.time,
501                kind,
502                player_id: Some(event.player.clone()),
503                is_team_0: Some(event.is_team_0),
504            });
505            *processor_event_counts
506                .entry((event.player.clone(), kind))
507                .or_default() += 1;
508        }
509
510        for player in &players.players {
511            self.player_teams
512                .insert(player.player_id.clone(), player.is_team_0);
513            let mut current_stats = CorePlayerStats {
514                score: player.match_score.unwrap_or(0),
515                goals: player.match_goals.unwrap_or(0),
516                assists: player.match_assists.unwrap_or(0),
517                saves: player.match_saves.unwrap_or(0),
518                shots: player.match_shots.unwrap_or(0),
519                scoring_context: self
520                    .player_stats
521                    .get(&player.player_id)
522                    .map(|stats| stats.scoring_context.clone())
523                    .unwrap_or_default(),
524            };
525
526            let previous_stats = self
527                .previous_player_stats
528                .get(&player.player_id)
529                .cloned()
530                .unwrap_or_default();
531
532            let shot_delta = current_stats.shots - previous_stats.shots;
533            let save_delta = current_stats.saves - previous_stats.saves;
534            let assist_delta = current_stats.assists - previous_stats.assists;
535            let goal_delta = current_stats.goals - previous_stats.goals;
536            let shot_fallback_delta = shot_delta
537                - processor_event_counts
538                    .get(&(player.player_id.clone(), TimelineEventKind::Shot))
539                    .copied()
540                    .unwrap_or(0);
541            let save_fallback_delta = save_delta
542                - processor_event_counts
543                    .get(&(player.player_id.clone(), TimelineEventKind::Save))
544                    .copied()
545                    .unwrap_or(0);
546            let assist_fallback_delta = assist_delta
547                - processor_event_counts
548                    .get(&(player.player_id.clone(), TimelineEventKind::Assist))
549                    .copied()
550                    .unwrap_or(0);
551
552            if shot_fallback_delta > 0 {
553                self.emit_timeline_events(
554                    frame.time,
555                    TimelineEventKind::Shot,
556                    &player.player_id,
557                    player.is_team_0,
558                    shot_fallback_delta,
559                );
560            }
561            if save_fallback_delta > 0 {
562                self.emit_timeline_events(
563                    frame.time,
564                    TimelineEventKind::Save,
565                    &player.player_id,
566                    player.is_team_0,
567                    save_fallback_delta,
568                );
569            }
570            if assist_fallback_delta > 0 {
571                self.emit_timeline_events(
572                    frame.time,
573                    TimelineEventKind::Assist,
574                    &player.player_id,
575                    player.is_team_0,
576                    assist_fallback_delta,
577                );
578            }
579            if goal_delta > 0 {
580                for _ in 0..goal_delta.max(0) {
581                    let pending_goal_event =
582                        self.take_pending_goal_event(&player.player_id, player.is_team_0);
583                    let goal_time = pending_goal_event
584                        .as_ref()
585                        .map(|event| event.event.time)
586                        .unwrap_or(frame.time);
587                    let time_after_kickoff = pending_goal_event
588                        .and_then(|event| event.time_after_kickoff)
589                        .or_else(|| {
590                            self.active_kickoff_touch_time
591                                .map(|kickoff_touch_time| (goal_time - kickoff_touch_time).max(0.0))
592                        });
593                    if let Some(time_after_kickoff) = time_after_kickoff {
594                        current_stats
595                            .scoring_context
596                            .goal_after_kickoff
597                            .record_goal(time_after_kickoff);
598                    }
599                    current_stats
600                        .scoring_context
601                        .goal_buildup
602                        .record(self.classify_goal_buildup(goal_time, player.is_team_0));
603                    self.timeline.push(TimelineEvent {
604                        time: goal_time,
605                        kind: TimelineEventKind::Goal,
606                        player_id: Some(player.player_id.clone()),
607                        is_team_0: Some(player.is_team_0),
608                    });
609                }
610            }
611
612            self.previous_player_stats
613                .insert(player.player_id.clone(), current_stats.clone());
614            self.player_stats
615                .insert(player.player_id.clone(), current_stats);
616        }
617
618        if let (Some(team_zero_score), Some(team_one_score)) =
619            (gameplay.team_zero_score, gameplay.team_one_score)
620        {
621            if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
622                let team_zero_delta = team_zero_score - prev_team_zero_score;
623                let team_one_delta = team_one_score - prev_team_one_score;
624
625                if team_zero_delta > 0 {
626                    if let Some(last_defender) = self.last_defender(players, false) {
627                        if let Some(stats) = self.player_stats.get_mut(&last_defender) {
628                            stats.scoring_context.goals_conceded_while_last_defender +=
629                                team_zero_delta as u32;
630                        }
631                    }
632                }
633
634                if team_one_delta > 0 {
635                    if let Some(last_defender) = self.last_defender(players, true) {
636                        if let Some(stats) = self.player_stats.get_mut(&last_defender) {
637                            stats.scoring_context.goals_conceded_while_last_defender +=
638                                team_one_delta as u32;
639                        }
640                    }
641                }
642            }
643
644            self.previous_team_scores = Some((team_zero_score, team_one_score));
645        }
646
647        self.timeline.sort_by(|a, b| {
648            a.time
649                .partial_cmp(&b.time)
650                .unwrap_or(std::cmp::Ordering::Equal)
651        });
652
653        Ok(())
654    }
655}