Skip to main content

subtr_actor/stats/accumulators/
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;
6
7/// Stats on goals scored shortly after a kickoff.
8#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
9#[ts(export)]
10pub struct GoalAfterKickoffStats {
11    pub kickoff_goal_count: u32,
12    pub short_goal_count: u32,
13    pub medium_goal_count: u32,
14    pub long_goal_count: u32,
15    #[serde(default, skip_serializing)]
16    pub(crate) goal_times: Vec<f32>,
17}
18
19impl GoalAfterKickoffStats {
20    pub fn goal_times(&self) -> &[f32] {
21        &self.goal_times
22    }
23
24    pub fn record_goal(&mut self, time_after_kickoff: f32) {
25        let clamped_time = time_after_kickoff.max(0.0);
26        self.goal_times.push(clamped_time);
27        self.goal_times.sort_by(|left, right| left.total_cmp(right));
28        if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS {
29            self.kickoff_goal_count += 1;
30        } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS {
31            self.short_goal_count += 1;
32        } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS {
33            self.medium_goal_count += 1;
34        } else {
35            self.long_goal_count += 1;
36        }
37    }
38
39    pub fn average_goal_time_after_kickoff(&self) -> f32 {
40        if self.goal_times.is_empty() {
41            0.0
42        } else {
43            self.goal_times.iter().sum::<f32>() / self.goal_times.len() as f32
44        }
45    }
46
47    pub fn median_goal_time_after_kickoff(&self) -> f32 {
48        if self.goal_times.is_empty() {
49            return 0.0;
50        }
51
52        let mut sorted_times = self.goal_times.clone();
53        sorted_times.sort_by(|a, b| a.total_cmp(b));
54        let midpoint = sorted_times.len() / 2;
55        if sorted_times.len().is_multiple_of(2) {
56            (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
57        } else {
58            sorted_times[midpoint]
59        }
60    }
61
62    pub(crate) fn merge(&mut self, other: &Self) {
63        self.kickoff_goal_count += other.kickoff_goal_count;
64        self.short_goal_count += other.short_goal_count;
65        self.medium_goal_count += other.medium_goal_count;
66        self.long_goal_count += other.long_goal_count;
67        self.goal_times.extend(other.goal_times.iter().copied());
68        self.goal_times.sort_by(|left, right| left.total_cmp(right));
69    }
70}
71
72/// Stats on ball air-time leading into goals.
73#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
74#[ts(export)]
75pub struct GoalBallAirTimeStats {
76    pub goal_ball_air_time_sample_count: u32,
77    pub cumulative_goal_ball_air_time: f32,
78    pub last_goal_ball_air_time: Option<f32>,
79    #[serde(default, skip_serializing)]
80    pub(crate) goal_ball_air_times: Vec<f32>,
81}
82
83impl GoalBallAirTimeStats {
84    pub fn goal_ball_air_times(&self) -> &[f32] {
85        &self.goal_ball_air_times
86    }
87
88    pub fn record_goal(&mut self, ball_air_time: f32) {
89        let clamped_time = ball_air_time.max(0.0);
90        self.goal_ball_air_time_sample_count += 1;
91        self.cumulative_goal_ball_air_time += clamped_time;
92        self.last_goal_ball_air_time = Some(clamped_time);
93        self.goal_ball_air_times.push(clamped_time);
94        self.goal_ball_air_times
95            .sort_by(|left, right| left.total_cmp(right));
96    }
97
98    pub fn average_goal_ball_air_time(&self) -> f32 {
99        if self.goal_ball_air_time_sample_count == 0 {
100            0.0
101        } else {
102            self.cumulative_goal_ball_air_time / self.goal_ball_air_time_sample_count as f32
103        }
104    }
105
106    pub fn median_goal_ball_air_time(&self) -> f32 {
107        if self.goal_ball_air_times.is_empty() {
108            return 0.0;
109        }
110
111        let mut sorted_times = self.goal_ball_air_times.clone();
112        sorted_times.sort_by(|a, b| a.total_cmp(b));
113        let midpoint = sorted_times.len() / 2;
114        if sorted_times.len().is_multiple_of(2) {
115            (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
116        } else {
117            sorted_times[midpoint]
118        }
119    }
120
121    pub(crate) fn merge(&mut self, other: &Self) {
122        self.goal_ball_air_time_sample_count += other.goal_ball_air_time_sample_count;
123        self.cumulative_goal_ball_air_time += other.cumulative_goal_ball_air_time;
124        self.last_goal_ball_air_time = other
125            .last_goal_ball_air_time
126            .or(self.last_goal_ball_air_time);
127        self.goal_ball_air_times
128            .extend(other.goal_ball_air_times.iter().copied());
129        self.goal_ball_air_times
130            .sort_by(|left, right| left.total_cmp(right));
131    }
132}
133
134/// Stats classifying how goals were built up.
135#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
136#[ts(export)]
137pub struct GoalBuildupStats {
138    pub counter_attack_goal_count: u32,
139    pub sustained_pressure_goal_count: u32,
140    pub other_buildup_goal_count: u32,
141}
142
143impl GoalBuildupStats {
144    pub(crate) fn record(&mut self, kind: GoalBuildupKind) {
145        match kind {
146            GoalBuildupKind::CounterAttack => self.counter_attack_goal_count += 1,
147            GoalBuildupKind::SustainedPressure => self.sustained_pressure_goal_count += 1,
148            GoalBuildupKind::Other => self.other_buildup_goal_count += 1,
149        }
150    }
151
152    pub(crate) fn merge(&mut self, other: &Self) {
153        self.counter_attack_goal_count += other.counter_attack_goal_count;
154        self.sustained_pressure_goal_count += other.sustained_pressure_goal_count;
155        self.other_buildup_goal_count += other.other_buildup_goal_count;
156    }
157}
158
159/// Per-player scoring-context stats around goals.
160#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
161#[ts(export)]
162pub struct PlayerScoringContextStats {
163    pub goals_conceded_while_last_defender: u32,
164    pub goals_for_while_most_back: u32,
165    pub goals_against_while_most_back: u32,
166    pub caught_ahead_of_play_on_conceded_goals: u32,
167    pub goal_against_boost_sample_count: u32,
168    pub cumulative_boost_on_goals_against: f32,
169    pub last_boost_on_goal_against: Option<f32>,
170    pub goal_against_boost_leadup_sample_count: u32,
171    pub cumulative_average_boost_in_goal_against_leadup: f32,
172    pub cumulative_min_boost_in_goal_against_leadup: f32,
173    pub last_average_boost_in_goal_against_leadup: Option<f32>,
174    pub last_min_boost_in_goal_against_leadup: Option<f32>,
175    pub goal_against_position_sample_count: u32,
176    pub cumulative_goal_against_position_x: f32,
177    pub cumulative_goal_against_position_y: f32,
178    pub cumulative_goal_against_position_z: f32,
179    pub last_goal_against_position: Option<GoalContextPosition>,
180    pub scoring_goal_last_touch_position_sample_count: u32,
181    pub cumulative_scoring_goal_last_touch_position_x: f32,
182    pub cumulative_scoring_goal_last_touch_position_y: f32,
183    pub cumulative_scoring_goal_last_touch_position_z: f32,
184    pub last_scoring_goal_last_touch_position: Option<GoalContextPosition>,
185    #[serde(flatten)]
186    pub goal_after_kickoff: GoalAfterKickoffStats,
187    #[serde(flatten)]
188    pub goal_buildup: GoalBuildupStats,
189    #[serde(default, flatten)]
190    pub goal_ball_air_time: GoalBallAirTimeStats,
191}
192
193impl PlayerScoringContextStats {
194    pub(crate) fn record_goal_against_snapshot(
195        &mut self,
196        boost_amount: Option<f32>,
197        position: Option<GoalContextPosition>,
198        boost_leadup: Option<(f32, f32)>,
199    ) {
200        if let Some(boost_amount) = boost_amount {
201            self.goal_against_boost_sample_count += 1;
202            self.cumulative_boost_on_goals_against += boost_amount;
203            self.last_boost_on_goal_against = Some(boost_amount);
204        }
205
206        if let Some((average_boost, min_boost)) = boost_leadup {
207            self.goal_against_boost_leadup_sample_count += 1;
208            self.cumulative_average_boost_in_goal_against_leadup += average_boost;
209            self.cumulative_min_boost_in_goal_against_leadup += min_boost;
210            self.last_average_boost_in_goal_against_leadup = Some(average_boost);
211            self.last_min_boost_in_goal_against_leadup = Some(min_boost);
212        }
213
214        if let Some(position) = position {
215            self.goal_against_position_sample_count += 1;
216            self.cumulative_goal_against_position_x += position.x;
217            self.cumulative_goal_against_position_y += position.y;
218            self.cumulative_goal_against_position_z += position.z;
219            self.last_goal_against_position = Some(position);
220        }
221    }
222
223    pub(crate) fn record_scoring_goal_last_touch_position(
224        &mut self,
225        position: GoalContextPosition,
226    ) {
227        self.scoring_goal_last_touch_position_sample_count += 1;
228        self.cumulative_scoring_goal_last_touch_position_x += position.x;
229        self.cumulative_scoring_goal_last_touch_position_y += position.y;
230        self.cumulative_scoring_goal_last_touch_position_z += position.z;
231        self.last_scoring_goal_last_touch_position = Some(position);
232    }
233
234    pub(crate) fn record_goal_ball_air_time(&mut self, ball_air_time: f32) {
235        self.goal_ball_air_time.record_goal(ball_air_time);
236    }
237
238    fn average_boost_on_goals_against(&self) -> f32 {
239        if self.goal_against_boost_sample_count == 0 {
240            0.0
241        } else {
242            self.cumulative_boost_on_goals_against / self.goal_against_boost_sample_count as f32
243        }
244    }
245
246    fn average_boost_in_goal_against_leadup(&self) -> f32 {
247        if self.goal_against_boost_leadup_sample_count == 0 {
248            0.0
249        } else {
250            self.cumulative_average_boost_in_goal_against_leadup
251                / self.goal_against_boost_leadup_sample_count as f32
252        }
253    }
254
255    fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
256        if self.goal_against_boost_leadup_sample_count == 0 {
257            0.0
258        } else {
259            self.cumulative_min_boost_in_goal_against_leadup
260                / self.goal_against_boost_leadup_sample_count as f32
261        }
262    }
263
264    fn average_goal_against_position_x(&self) -> f32 {
265        if self.goal_against_position_sample_count == 0 {
266            0.0
267        } else {
268            self.cumulative_goal_against_position_x / self.goal_against_position_sample_count as f32
269        }
270    }
271
272    fn average_goal_against_position_y(&self) -> f32 {
273        if self.goal_against_position_sample_count == 0 {
274            0.0
275        } else {
276            self.cumulative_goal_against_position_y / self.goal_against_position_sample_count as f32
277        }
278    }
279
280    fn average_goal_against_position_z(&self) -> f32 {
281        if self.goal_against_position_sample_count == 0 {
282            0.0
283        } else {
284            self.cumulative_goal_against_position_z / self.goal_against_position_sample_count as f32
285        }
286    }
287
288    fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
289        if self.scoring_goal_last_touch_position_sample_count == 0 {
290            0.0
291        } else {
292            self.cumulative_scoring_goal_last_touch_position_x
293                / self.scoring_goal_last_touch_position_sample_count as f32
294        }
295    }
296
297    fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
298        if self.scoring_goal_last_touch_position_sample_count == 0 {
299            0.0
300        } else {
301            self.cumulative_scoring_goal_last_touch_position_y
302                / self.scoring_goal_last_touch_position_sample_count as f32
303        }
304    }
305
306    fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
307        if self.scoring_goal_last_touch_position_sample_count == 0 {
308            0.0
309        } else {
310            self.cumulative_scoring_goal_last_touch_position_z
311                / self.scoring_goal_last_touch_position_sample_count as f32
312        }
313    }
314}
315
316/// Per-player core scoreboard stats (goals, assists, saves, shots, etc.).
317#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
318#[ts(export)]
319pub struct CorePlayerStats {
320    pub score: i32,
321    pub goals: i32,
322    pub assists: i32,
323    pub saves: i32,
324    pub shots: i32,
325    #[serde(flatten)]
326    pub scoring_context: PlayerScoringContextStats,
327}
328
329impl CorePlayerStats {
330    pub fn shooting_percentage(&self) -> f32 {
331        if self.shots == 0 {
332            0.0
333        } else {
334            self.goals as f32 * 100.0 / self.shots as f32
335        }
336    }
337
338    pub fn average_goal_time_after_kickoff(&self) -> f32 {
339        self.scoring_context
340            .goal_after_kickoff
341            .average_goal_time_after_kickoff()
342    }
343
344    pub fn median_goal_time_after_kickoff(&self) -> f32 {
345        self.scoring_context
346            .goal_after_kickoff
347            .median_goal_time_after_kickoff()
348    }
349
350    pub fn average_boost_on_goals_against(&self) -> f32 {
351        self.scoring_context.average_boost_on_goals_against()
352    }
353
354    pub fn average_boost_in_goal_against_leadup(&self) -> f32 {
355        self.scoring_context.average_boost_in_goal_against_leadup()
356    }
357
358    pub fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
359        self.scoring_context
360            .average_min_boost_in_goal_against_leadup()
361    }
362
363    pub fn average_goal_against_position_x(&self) -> f32 {
364        self.scoring_context.average_goal_against_position_x()
365    }
366
367    pub fn average_goal_against_position_y(&self) -> f32 {
368        self.scoring_context.average_goal_against_position_y()
369    }
370
371    pub fn average_goal_against_position_z(&self) -> f32 {
372        self.scoring_context.average_goal_against_position_z()
373    }
374
375    pub fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
376        self.scoring_context
377            .average_scoring_goal_last_touch_position_x()
378    }
379
380    pub fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
381        self.scoring_context
382            .average_scoring_goal_last_touch_position_y()
383    }
384
385    pub fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
386        self.scoring_context
387            .average_scoring_goal_last_touch_position_z()
388    }
389
390    pub fn average_goal_ball_air_time(&self) -> f32 {
391        self.scoring_context
392            .goal_ball_air_time
393            .average_goal_ball_air_time()
394    }
395
396    pub fn median_goal_ball_air_time(&self) -> f32 {
397        self.scoring_context
398            .goal_ball_air_time
399            .median_goal_ball_air_time()
400    }
401}
402
403/// Per-team scoring-context stats around goals.
404#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
405#[ts(export)]
406pub struct TeamScoringContextStats {
407    #[serde(flatten)]
408    pub goal_after_kickoff: GoalAfterKickoffStats,
409    #[serde(flatten)]
410    pub goal_buildup: GoalBuildupStats,
411    #[serde(default, flatten)]
412    pub goal_ball_air_time: GoalBallAirTimeStats,
413}
414
415/// Per-team core scoreboard stats.
416#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
417#[ts(export)]
418pub struct CoreTeamStats {
419    pub score: i32,
420    pub goals: i32,
421    pub assists: i32,
422    pub saves: i32,
423    pub shots: i32,
424    #[serde(flatten)]
425    pub scoring_context: TeamScoringContextStats,
426}
427
428impl CoreTeamStats {
429    pub fn shooting_percentage(&self) -> f32 {
430        if self.shots == 0 {
431            0.0
432        } else {
433            self.goals as f32 * 100.0 / self.shots as f32
434        }
435    }
436
437    pub fn average_goal_time_after_kickoff(&self) -> f32 {
438        self.scoring_context
439            .goal_after_kickoff
440            .average_goal_time_after_kickoff()
441    }
442
443    pub fn median_goal_time_after_kickoff(&self) -> f32 {
444        self.scoring_context
445            .goal_after_kickoff
446            .median_goal_time_after_kickoff()
447    }
448
449    pub fn average_goal_ball_air_time(&self) -> f32 {
450        self.scoring_context
451            .goal_ball_air_time
452            .average_goal_ball_air_time()
453    }
454
455    pub fn median_goal_ball_air_time(&self) -> f32 {
456        self.scoring_context
457            .goal_ball_air_time
458            .median_goal_ball_air_time()
459    }
460}
461
462pub(crate) fn player_id_sort_key(player_id: &PlayerId) -> String {
463    match player_id {
464        boxcars::RemoteId::PlayStation(id) => {
465            format!("playstation:{}:{}:{:?}", id.online_id, id.name, id.unknown1)
466        }
467        boxcars::RemoteId::PsyNet(id) => format!("psynet:{}:{:?}", id.online_id, id.unknown1),
468        boxcars::RemoteId::SplitScreen(id) => format!("splitscreen:{id}"),
469        boxcars::RemoteId::Steam(id) => format!("steam:{id}"),
470        boxcars::RemoteId::Switch(id) => format!("switch:{}:{:?}", id.online_id, id.unknown1),
471        boxcars::RemoteId::Xbox(id) => format!("xbox:{id}"),
472        boxcars::RemoteId::QQ(id) => format!("qq:{id}"),
473        boxcars::RemoteId::Epic(id) => format!("epic:{id}"),
474    }
475}
476
477/// Accumulates core scoreboard and goal-context stats over the replay.
478#[derive(Debug, Clone, Default)]
479pub struct CoreStatsAccumulator {
480    player_stats: HashMap<PlayerId, CorePlayerStats>,
481    player_teams: HashMap<PlayerId, bool>,
482}
483
484impl CoreStatsAccumulator {
485    pub fn new() -> Self {
486        Self::default()
487    }
488
489    pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
490        &self.player_stats
491    }
492
493    pub fn player_stats_for(&self, player_id: &PlayerId) -> CorePlayerStats {
494        self.player_stats
495            .get(player_id)
496            .cloned()
497            .unwrap_or_default()
498    }
499
500    pub fn ensure_player(&mut self, player_id: PlayerId, is_team_0: bool) {
501        self.player_teams.insert(player_id.clone(), is_team_0);
502        self.player_stats.entry(player_id).or_default();
503    }
504
505    pub fn team_zero_stats(&self) -> CoreTeamStats {
506        self.team_stats_for_side(true)
507    }
508
509    pub fn team_one_stats(&self) -> CoreTeamStats {
510        self.team_stats_for_side(false)
511    }
512
513    pub fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
514        let mut player_stats: Vec<_> = self
515            .player_stats
516            .iter()
517            .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
518            .collect();
519        player_stats.sort_by_cached_key(|(player_id, _)| player_id_sort_key(player_id));
520
521        let mut stats = player_stats.into_iter().fold(
522            CoreTeamStats::default(),
523            |mut stats, (_, player_stats)| {
524                stats.score += player_stats.score;
525                stats.goals += player_stats.goals;
526                stats.assists += player_stats.assists;
527                stats.saves += player_stats.saves;
528                stats.shots += player_stats.shots;
529                stats
530                    .scoring_context
531                    .goal_after_kickoff
532                    .merge(&player_stats.scoring_context.goal_after_kickoff);
533                stats
534                    .scoring_context
535                    .goal_buildup
536                    .merge(&player_stats.scoring_context.goal_buildup);
537                stats
538                    .scoring_context
539                    .goal_ball_air_time
540                    .merge(&player_stats.scoring_context.goal_ball_air_time);
541                stats
542            },
543        );
544        stats
545            .scoring_context
546            .goal_after_kickoff
547            .goal_times
548            .sort_by(|left, right| left.total_cmp(right));
549        stats
550            .scoring_context
551            .goal_ball_air_time
552            .goal_ball_air_times
553            .sort_by(|left, right| left.total_cmp(right));
554        stats
555    }
556
557    pub fn apply_scoreboard_event(&mut self, event: &CorePlayerScoreboardEvent) {
558        self.player_teams
559            .insert(event.player.clone(), event.is_team_0);
560        let stats = self.player_stats.entry(event.player.clone()).or_default();
561        stats.score += event.score_delta;
562        stats.goals += event.goals_delta;
563        stats.assists += event.assists_delta;
564        stats.saves += event.saves_delta;
565        stats.shots += event.shots_delta;
566    }
567
568    pub fn apply_goal_context_event(&mut self, event: &CorePlayerGoalContextEvent) {
569        self.player_teams
570            .insert(event.player.clone(), event.is_team_0);
571        let stats = self.player_stats.entry(event.player.clone()).or_default();
572        let scoring_context = &mut stats.scoring_context;
573        if event.goals_conceded_while_last_defender {
574            scoring_context.goals_conceded_while_last_defender += 1;
575        }
576        if event.goals_for_while_most_back {
577            scoring_context.goals_for_while_most_back += 1;
578        }
579        if event.goals_against_while_most_back {
580            scoring_context.goals_against_while_most_back += 1;
581        }
582        if event.caught_ahead_of_play_on_conceded_goal {
583            scoring_context.caught_ahead_of_play_on_conceded_goals += 1;
584        }
585        scoring_context.record_goal_against_snapshot(
586            event.goal_against_boost_amount,
587            event.goal_against_position,
588            match (
589                event.goal_against_average_boost_in_leadup,
590                event.goal_against_min_boost_in_leadup,
591            ) {
592                (Some(average), Some(minimum)) => Some((average, minimum)),
593                _ => None,
594            },
595        );
596        if let Some(position) = event.scoring_goal_last_touch_position {
597            scoring_context.record_scoring_goal_last_touch_position(position);
598        }
599        if let Some(time_after_kickoff) = event.time_after_kickoff {
600            scoring_context
601                .goal_after_kickoff
602                .record_goal(time_after_kickoff);
603        }
604        if let Some(goal_buildup) = event.goal_buildup {
605            scoring_context.goal_buildup.record(goal_buildup);
606        }
607        if let Some(ball_air_time_before_goal) = event.ball_air_time_before_goal {
608            scoring_context.record_goal_ball_air_time(ball_air_time_before_goal);
609        }
610    }
611}