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 = 4.0;
9const COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS: f32 = 1.0;
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 GOAL_CONTEXT_BOOST_LEADUP_SECONDS: f32 = 5.0;
14const BALL_GROUND_CONTACT_MAX_Z: f32 = BALL_RADIUS_Z + 5.0;
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
16#[ts(export)]
17pub struct GoalAfterKickoffStats {
18    pub kickoff_goal_count: u32,
19    pub short_goal_count: u32,
20    pub medium_goal_count: u32,
21    pub long_goal_count: u32,
22    #[serde(default, skip_serializing)]
23    goal_times: Vec<f32>,
24}
25
26impl GoalAfterKickoffStats {
27    pub fn goal_times(&self) -> &[f32] {
28        &self.goal_times
29    }
30
31    pub fn record_goal(&mut self, time_after_kickoff: f32) {
32        let clamped_time = time_after_kickoff.max(0.0);
33        self.goal_times.push(clamped_time);
34        if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS {
35            self.kickoff_goal_count += 1;
36        } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS {
37            self.short_goal_count += 1;
38        } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS {
39            self.medium_goal_count += 1;
40        } else {
41            self.long_goal_count += 1;
42        }
43    }
44
45    pub fn average_goal_time_after_kickoff(&self) -> f32 {
46        if self.goal_times.is_empty() {
47            0.0
48        } else {
49            self.goal_times.iter().sum::<f32>() / self.goal_times.len() as f32
50        }
51    }
52
53    pub fn median_goal_time_after_kickoff(&self) -> f32 {
54        if self.goal_times.is_empty() {
55            return 0.0;
56        }
57
58        let mut sorted_times = self.goal_times.clone();
59        sorted_times.sort_by(|a, b| a.total_cmp(b));
60        let midpoint = sorted_times.len() / 2;
61        if sorted_times.len().is_multiple_of(2) {
62            (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
63        } else {
64            sorted_times[midpoint]
65        }
66    }
67
68    fn merge(&mut self, other: &Self) {
69        self.kickoff_goal_count += other.kickoff_goal_count;
70        self.short_goal_count += other.short_goal_count;
71        self.medium_goal_count += other.medium_goal_count;
72        self.long_goal_count += other.long_goal_count;
73        self.goal_times.extend(other.goal_times.iter().copied());
74    }
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
78#[ts(export)]
79pub struct GoalBallAirTimeStats {
80    pub goal_ball_air_time_sample_count: u32,
81    pub cumulative_goal_ball_air_time: f32,
82    pub last_goal_ball_air_time: Option<f32>,
83    #[serde(default, skip_serializing)]
84    goal_ball_air_times: Vec<f32>,
85}
86
87impl GoalBallAirTimeStats {
88    pub fn goal_ball_air_times(&self) -> &[f32] {
89        &self.goal_ball_air_times
90    }
91
92    pub fn record_goal(&mut self, ball_air_time: f32) {
93        let clamped_time = ball_air_time.max(0.0);
94        self.goal_ball_air_time_sample_count += 1;
95        self.cumulative_goal_ball_air_time += clamped_time;
96        self.last_goal_ball_air_time = Some(clamped_time);
97        self.goal_ball_air_times.push(clamped_time);
98    }
99
100    pub fn average_goal_ball_air_time(&self) -> f32 {
101        if self.goal_ball_air_time_sample_count == 0 {
102            0.0
103        } else {
104            self.cumulative_goal_ball_air_time / self.goal_ball_air_time_sample_count as f32
105        }
106    }
107
108    pub fn median_goal_ball_air_time(&self) -> f32 {
109        if self.goal_ball_air_times.is_empty() {
110            return 0.0;
111        }
112
113        let mut sorted_times = self.goal_ball_air_times.clone();
114        sorted_times.sort_by(|a, b| a.total_cmp(b));
115        let midpoint = sorted_times.len() / 2;
116        if sorted_times.len().is_multiple_of(2) {
117            (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
118        } else {
119            sorted_times[midpoint]
120        }
121    }
122
123    fn merge(&mut self, other: &Self) {
124        self.goal_ball_air_time_sample_count += other.goal_ball_air_time_sample_count;
125        self.cumulative_goal_ball_air_time += other.cumulative_goal_ball_air_time;
126        self.last_goal_ball_air_time = other
127            .last_goal_ball_air_time
128            .or(self.last_goal_ball_air_time);
129        self.goal_ball_air_times
130            .extend(other.goal_ball_air_times.iter().copied());
131    }
132}
133
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
135#[serde(rename_all = "snake_case")]
136#[ts(export)]
137pub enum GoalBuildupKind {
138    CounterAttack,
139    SustainedPressure,
140    #[default]
141    Other,
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
145#[ts(export)]
146pub struct GoalBuildupStats {
147    pub counter_attack_goal_count: u32,
148    pub sustained_pressure_goal_count: u32,
149    pub other_buildup_goal_count: u32,
150}
151
152impl GoalBuildupStats {
153    fn record(&mut self, kind: GoalBuildupKind) {
154        match kind {
155            GoalBuildupKind::CounterAttack => self.counter_attack_goal_count += 1,
156            GoalBuildupKind::SustainedPressure => self.sustained_pressure_goal_count += 1,
157            GoalBuildupKind::Other => self.other_buildup_goal_count += 1,
158        }
159    }
160
161    fn merge(&mut self, other: &Self) {
162        self.counter_attack_goal_count += other.counter_attack_goal_count;
163        self.sustained_pressure_goal_count += other.sustained_pressure_goal_count;
164        self.other_buildup_goal_count += other.other_buildup_goal_count;
165    }
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
169#[ts(export)]
170pub struct PlayerScoringContextStats {
171    pub goals_conceded_while_last_defender: u32,
172    pub goals_for_while_most_back: u32,
173    pub goals_against_while_most_back: u32,
174    pub goal_against_boost_sample_count: u32,
175    pub cumulative_boost_on_goals_against: f32,
176    pub last_boost_on_goal_against: Option<f32>,
177    pub goal_against_boost_leadup_sample_count: u32,
178    pub cumulative_average_boost_in_goal_against_leadup: f32,
179    pub cumulative_min_boost_in_goal_against_leadup: f32,
180    pub last_average_boost_in_goal_against_leadup: Option<f32>,
181    pub last_min_boost_in_goal_against_leadup: Option<f32>,
182    pub goal_against_position_sample_count: u32,
183    pub cumulative_goal_against_position_x: f32,
184    pub cumulative_goal_against_position_y: f32,
185    pub cumulative_goal_against_position_z: f32,
186    pub last_goal_against_position: Option<GoalContextPosition>,
187    pub scoring_goal_last_touch_position_sample_count: u32,
188    pub cumulative_scoring_goal_last_touch_position_x: f32,
189    pub cumulative_scoring_goal_last_touch_position_y: f32,
190    pub cumulative_scoring_goal_last_touch_position_z: f32,
191    pub last_scoring_goal_last_touch_position: Option<GoalContextPosition>,
192    #[serde(flatten)]
193    pub goal_after_kickoff: GoalAfterKickoffStats,
194    #[serde(flatten)]
195    pub goal_buildup: GoalBuildupStats,
196    #[serde(default, flatten)]
197    pub goal_ball_air_time: GoalBallAirTimeStats,
198}
199
200impl PlayerScoringContextStats {
201    fn record_goal_against_snapshot(
202        &mut self,
203        boost_amount: Option<f32>,
204        position: Option<GoalContextPosition>,
205        boost_leadup: Option<BoostLeadupStats>,
206    ) {
207        if let Some(boost_amount) = boost_amount {
208            self.goal_against_boost_sample_count += 1;
209            self.cumulative_boost_on_goals_against += boost_amount;
210            self.last_boost_on_goal_against = Some(boost_amount);
211        }
212
213        if let Some(boost_leadup) = boost_leadup {
214            self.goal_against_boost_leadup_sample_count += 1;
215            self.cumulative_average_boost_in_goal_against_leadup += boost_leadup.average_boost;
216            self.cumulative_min_boost_in_goal_against_leadup += boost_leadup.min_boost;
217            self.last_average_boost_in_goal_against_leadup = Some(boost_leadup.average_boost);
218            self.last_min_boost_in_goal_against_leadup = Some(boost_leadup.min_boost);
219        }
220
221        if let Some(position) = position {
222            self.goal_against_position_sample_count += 1;
223            self.cumulative_goal_against_position_x += position.x;
224            self.cumulative_goal_against_position_y += position.y;
225            self.cumulative_goal_against_position_z += position.z;
226            self.last_goal_against_position = Some(position);
227        }
228    }
229
230    fn record_scoring_goal_last_touch_position(&mut self, position: GoalContextPosition) {
231        self.scoring_goal_last_touch_position_sample_count += 1;
232        self.cumulative_scoring_goal_last_touch_position_x += position.x;
233        self.cumulative_scoring_goal_last_touch_position_y += position.y;
234        self.cumulative_scoring_goal_last_touch_position_z += position.z;
235        self.last_scoring_goal_last_touch_position = Some(position);
236    }
237
238    fn record_goal_ball_air_time(&mut self, ball_air_time: f32) {
239        self.goal_ball_air_time.record_goal(ball_air_time);
240    }
241
242    fn average_boost_on_goals_against(&self) -> f32 {
243        if self.goal_against_boost_sample_count == 0 {
244            0.0
245        } else {
246            self.cumulative_boost_on_goals_against / self.goal_against_boost_sample_count as f32
247        }
248    }
249
250    fn average_boost_in_goal_against_leadup(&self) -> f32 {
251        if self.goal_against_boost_leadup_sample_count == 0 {
252            0.0
253        } else {
254            self.cumulative_average_boost_in_goal_against_leadup
255                / self.goal_against_boost_leadup_sample_count as f32
256        }
257    }
258
259    fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
260        if self.goal_against_boost_leadup_sample_count == 0 {
261            0.0
262        } else {
263            self.cumulative_min_boost_in_goal_against_leadup
264                / self.goal_against_boost_leadup_sample_count as f32
265        }
266    }
267
268    fn average_goal_against_position_x(&self) -> f32 {
269        if self.goal_against_position_sample_count == 0 {
270            0.0
271        } else {
272            self.cumulative_goal_against_position_x / self.goal_against_position_sample_count as f32
273        }
274    }
275
276    fn average_goal_against_position_y(&self) -> f32 {
277        if self.goal_against_position_sample_count == 0 {
278            0.0
279        } else {
280            self.cumulative_goal_against_position_y / self.goal_against_position_sample_count as f32
281        }
282    }
283
284    fn average_goal_against_position_z(&self) -> f32 {
285        if self.goal_against_position_sample_count == 0 {
286            0.0
287        } else {
288            self.cumulative_goal_against_position_z / self.goal_against_position_sample_count as f32
289        }
290    }
291
292    fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
293        if self.scoring_goal_last_touch_position_sample_count == 0 {
294            0.0
295        } else {
296            self.cumulative_scoring_goal_last_touch_position_x
297                / self.scoring_goal_last_touch_position_sample_count as f32
298        }
299    }
300
301    fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
302        if self.scoring_goal_last_touch_position_sample_count == 0 {
303            0.0
304        } else {
305            self.cumulative_scoring_goal_last_touch_position_y
306                / self.scoring_goal_last_touch_position_sample_count as f32
307        }
308    }
309
310    fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
311        if self.scoring_goal_last_touch_position_sample_count == 0 {
312            0.0
313        } else {
314            self.cumulative_scoring_goal_last_touch_position_z
315                / self.scoring_goal_last_touch_position_sample_count as f32
316        }
317    }
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
321#[ts(export)]
322pub struct CorePlayerStats {
323    pub score: i32,
324    pub goals: i32,
325    pub assists: i32,
326    pub saves: i32,
327    pub shots: i32,
328    #[serde(flatten)]
329    pub scoring_context: PlayerScoringContextStats,
330}
331
332impl CorePlayerStats {
333    pub fn shooting_percentage(&self) -> f32 {
334        if self.shots == 0 {
335            0.0
336        } else {
337            self.goals as f32 * 100.0 / self.shots as f32
338        }
339    }
340
341    pub fn average_goal_time_after_kickoff(&self) -> f32 {
342        self.scoring_context
343            .goal_after_kickoff
344            .average_goal_time_after_kickoff()
345    }
346
347    pub fn median_goal_time_after_kickoff(&self) -> f32 {
348        self.scoring_context
349            .goal_after_kickoff
350            .median_goal_time_after_kickoff()
351    }
352
353    pub fn average_boost_on_goals_against(&self) -> f32 {
354        self.scoring_context.average_boost_on_goals_against()
355    }
356
357    pub fn average_boost_in_goal_against_leadup(&self) -> f32 {
358        self.scoring_context.average_boost_in_goal_against_leadup()
359    }
360
361    pub fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
362        self.scoring_context
363            .average_min_boost_in_goal_against_leadup()
364    }
365
366    pub fn average_goal_against_position_x(&self) -> f32 {
367        self.scoring_context.average_goal_against_position_x()
368    }
369
370    pub fn average_goal_against_position_y(&self) -> f32 {
371        self.scoring_context.average_goal_against_position_y()
372    }
373
374    pub fn average_goal_against_position_z(&self) -> f32 {
375        self.scoring_context.average_goal_against_position_z()
376    }
377
378    pub fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
379        self.scoring_context
380            .average_scoring_goal_last_touch_position_x()
381    }
382
383    pub fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
384        self.scoring_context
385            .average_scoring_goal_last_touch_position_y()
386    }
387
388    pub fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
389        self.scoring_context
390            .average_scoring_goal_last_touch_position_z()
391    }
392
393    pub fn average_goal_ball_air_time(&self) -> f32 {
394        self.scoring_context
395            .goal_ball_air_time
396            .average_goal_ball_air_time()
397    }
398
399    pub fn median_goal_ball_air_time(&self) -> f32 {
400        self.scoring_context
401            .goal_ball_air_time
402            .median_goal_ball_air_time()
403    }
404}
405
406#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
407#[ts(export)]
408pub struct TeamScoringContextStats {
409    #[serde(flatten)]
410    pub goal_after_kickoff: GoalAfterKickoffStats,
411    #[serde(flatten)]
412    pub goal_buildup: GoalBuildupStats,
413    #[serde(default, flatten)]
414    pub goal_ball_air_time: GoalBallAirTimeStats,
415}
416
417#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
418#[ts(export)]
419pub struct CoreTeamStats {
420    pub score: i32,
421    pub goals: i32,
422    pub assists: i32,
423    pub saves: i32,
424    pub shots: i32,
425    #[serde(flatten)]
426    pub scoring_context: TeamScoringContextStats,
427}
428
429impl CoreTeamStats {
430    pub fn shooting_percentage(&self) -> f32 {
431        if self.shots == 0 {
432            0.0
433        } else {
434            self.goals as f32 * 100.0 / self.shots as f32
435        }
436    }
437
438    pub fn average_goal_time_after_kickoff(&self) -> f32 {
439        self.scoring_context
440            .goal_after_kickoff
441            .average_goal_time_after_kickoff()
442    }
443
444    pub fn median_goal_time_after_kickoff(&self) -> f32 {
445        self.scoring_context
446            .goal_after_kickoff
447            .median_goal_time_after_kickoff()
448    }
449
450    pub fn average_goal_ball_air_time(&self) -> f32 {
451        self.scoring_context
452            .goal_ball_air_time
453            .average_goal_ball_air_time()
454    }
455
456    pub fn median_goal_ball_air_time(&self) -> f32 {
457        self.scoring_context
458            .goal_ball_air_time
459            .median_goal_ball_air_time()
460    }
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ts_rs::TS)]
464#[ts(export)]
465pub enum TimelineEventKind {
466    Goal,
467    Shot,
468    Save,
469    Assist,
470    Kill,
471    Death,
472}
473
474#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
475#[ts(export)]
476pub struct TimelineEvent {
477    pub time: f32,
478    pub kind: TimelineEventKind,
479    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
480    pub player_id: Option<PlayerId>,
481    pub is_team_0: Option<bool>,
482}
483
484#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
485#[ts(export)]
486pub struct GoalContextPosition {
487    pub x: f32,
488    pub y: f32,
489    pub z: f32,
490}
491
492impl From<glam::Vec3> for GoalContextPosition {
493    fn from(position: glam::Vec3) -> Self {
494        Self {
495            x: position.x,
496            y: position.y,
497            z: position.z,
498        }
499    }
500}
501
502#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
503#[ts(export)]
504pub struct GoalPlayerContext {
505    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
506    pub player: PlayerId,
507    pub is_team_0: bool,
508    pub position: Option<GoalContextPosition>,
509    pub boost_amount: Option<f32>,
510    pub average_boost_in_leadup: Option<f32>,
511    pub min_boost_in_leadup: Option<f32>,
512    pub is_most_back: bool,
513}
514
515#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
516#[ts(export)]
517pub struct GoalTouchContext {
518    pub time: f32,
519    pub frame: usize,
520    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
521    pub player: PlayerId,
522    pub is_team_0: bool,
523    pub ball_position: Option<GoalContextPosition>,
524    pub player_position: Option<GoalContextPosition>,
525    pub players: Vec<GoalPlayerContext>,
526}
527
528#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
529#[ts(export)]
530pub struct GoalContextEvent {
531    pub time: f32,
532    pub frame: usize,
533    pub scoring_team_is_team_0: bool,
534    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
535    pub scorer: Option<PlayerId>,
536    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
537    pub scoring_team_most_back_player: Option<PlayerId>,
538    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
539    pub defending_team_most_back_player: Option<PlayerId>,
540    pub ball_position: Option<GoalContextPosition>,
541    pub ball_air_time_before_goal: Option<f32>,
542    #[serde(default)]
543    pub goal_buildup: GoalBuildupKind,
544    pub scorer_last_touch: Option<GoalTouchContext>,
545    pub players: Vec<GoalPlayerContext>,
546}
547
548#[derive(Debug, Clone)]
549struct PendingGoalEvent {
550    event: GoalEvent,
551    time_after_kickoff: Option<f32>,
552    goal_buildup: GoalBuildupKind,
553}
554
555#[derive(Debug, Clone)]
556struct GoalBuildupSample {
557    time: f32,
558    dt: f32,
559    ball_y: f32,
560}
561
562#[derive(Debug, Clone)]
563struct GoalBuildupPressureEvent {
564    time: f32,
565    is_team_0: bool,
566}
567
568#[derive(Debug, Clone, Copy)]
569struct BoostLeadupSample {
570    time: f32,
571    boost_amount: f32,
572}
573
574#[derive(Debug, Clone, Copy)]
575struct BoostLeadupStats {
576    average_boost: f32,
577    min_boost: f32,
578}
579
580#[derive(Debug, Clone, Default)]
581pub struct MatchStatsCalculator {
582    player_stats: HashMap<PlayerId, CorePlayerStats>,
583    player_teams: HashMap<PlayerId, bool>,
584    previous_player_stats: HashMap<PlayerId, CorePlayerStats>,
585    timeline: Vec<TimelineEvent>,
586    pending_goal_events: Vec<PendingGoalEvent>,
587    previous_team_scores: Option<(i32, i32)>,
588    kickoff_waiting_for_first_touch: bool,
589    active_kickoff_touch_time: Option<f32>,
590    goal_buildup_samples: Vec<GoalBuildupSample>,
591    goal_buildup_pressure_events: Vec<GoalBuildupPressureEvent>,
592    goal_context_events: Vec<GoalContextEvent>,
593    last_touch_context_by_player: HashMap<PlayerId, GoalTouchContext>,
594    boost_leadup_samples_by_player: HashMap<PlayerId, VecDeque<BoostLeadupSample>>,
595    last_ball_ground_contact_time: Option<f32>,
596}
597
598impl MatchStatsCalculator {
599    pub fn new() -> Self {
600        Self::default()
601    }
602
603    pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
604        &self.player_stats
605    }
606
607    pub fn timeline(&self) -> &[TimelineEvent] {
608        &self.timeline
609    }
610
611    pub fn goal_context_events(&self) -> &[GoalContextEvent] {
612        &self.goal_context_events
613    }
614
615    pub fn team_zero_stats(&self) -> CoreTeamStats {
616        self.team_stats_for_side(true)
617    }
618
619    pub fn team_one_stats(&self) -> CoreTeamStats {
620        self.team_stats_for_side(false)
621    }
622
623    fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
624        let mut stats = self
625            .player_stats
626            .iter()
627            .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
628            .fold(CoreTeamStats::default(), |mut stats, (_, player_stats)| {
629                stats.score += player_stats.score;
630                stats.goals += player_stats.goals;
631                stats.assists += player_stats.assists;
632                stats.saves += player_stats.saves;
633                stats.shots += player_stats.shots;
634                stats
635                    .scoring_context
636                    .goal_after_kickoff
637                    .merge(&player_stats.scoring_context.goal_after_kickoff);
638                stats
639                    .scoring_context
640                    .goal_buildup
641                    .merge(&player_stats.scoring_context.goal_buildup);
642                stats
643                    .scoring_context
644                    .goal_ball_air_time
645                    .merge(&player_stats.scoring_context.goal_ball_air_time);
646                stats
647            });
648        stats
649            .scoring_context
650            .goal_after_kickoff
651            .goal_times
652            .sort_by(|left, right| left.total_cmp(right));
653        stats
654            .scoring_context
655            .goal_ball_air_time
656            .goal_ball_air_times
657            .sort_by(|left, right| left.total_cmp(right));
658        stats
659    }
660
661    fn emit_timeline_events(
662        &mut self,
663        time: f32,
664        kind: TimelineEventKind,
665        player_id: &PlayerId,
666        is_team_0: bool,
667        delta: i32,
668    ) {
669        for _ in 0..delta.max(0) {
670            self.timeline.push(TimelineEvent {
671                time,
672                kind,
673                player_id: Some(player_id.clone()),
674                is_team_0: Some(is_team_0),
675            });
676        }
677    }
678
679    fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
680        gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
681            || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
682            || gameplay.ball_has_been_hit == Some(false)
683    }
684
685    fn update_kickoff_reference(&mut self, gameplay: &GameplayState, events: &FrameEventsState) {
686        if let Some(first_touch_time) = events
687            .touch_events
688            .iter()
689            .map(|event| event.time)
690            .min_by(|a, b| a.total_cmp(b))
691        {
692            self.active_kickoff_touch_time = Some(first_touch_time);
693            self.kickoff_waiting_for_first_touch = false;
694            return;
695        }
696
697        if Self::kickoff_phase_active(gameplay) {
698            self.kickoff_waiting_for_first_touch = true;
699            self.active_kickoff_touch_time = None;
700        }
701    }
702
703    fn take_pending_goal_event(
704        &mut self,
705        player_id: &PlayerId,
706        is_team_0: bool,
707    ) -> Option<PendingGoalEvent> {
708        if let Some(index) = self.pending_goal_events.iter().position(|event| {
709            event.event.scoring_team_is_team_0 == is_team_0
710                && event.event.player.as_ref() == Some(player_id)
711        }) {
712            return Some(self.pending_goal_events.remove(index));
713        }
714
715        self.pending_goal_events
716            .iter()
717            .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
718            .map(|index| self.pending_goal_events.remove(index))
719    }
720
721    fn last_defender(
722        &self,
723        players: &PlayerFrameState,
724        defending_team_is_team_0: bool,
725    ) -> Option<PlayerId> {
726        players
727            .players
728            .iter()
729            .filter(|player| player.is_team_0 == defending_team_is_team_0)
730            .filter_map(|player| {
731                player
732                    .position()
733                    .map(|position| (player.player_id.clone(), position.y))
734            })
735            .reduce(|current, candidate| {
736                if defending_team_is_team_0 {
737                    if candidate.1 < current.1 {
738                        candidate
739                    } else {
740                        current
741                    }
742                } else if candidate.1 > current.1 {
743                    candidate
744                } else {
745                    current
746                }
747            })
748            .map(|(player_id, _)| player_id)
749    }
750
751    fn most_back_player(players: &PlayerFrameState, team_is_team_0: bool) -> Option<PlayerId> {
752        players
753            .players
754            .iter()
755            .filter(|player| player.is_team_0 == team_is_team_0)
756            .filter_map(|player| {
757                player.position().map(|position| {
758                    (
759                        player.player_id.clone(),
760                        normalized_y(team_is_team_0, position),
761                    )
762                })
763            })
764            .min_by(|left, right| left.1.total_cmp(&right.1))
765            .map(|(player_id, _)| player_id)
766    }
767
768    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
769        players
770            .players
771            .iter()
772            .find(|player| &player.player_id == player_id)
773            .and_then(PlayerSample::position)
774    }
775
776    fn update_last_touch_contexts(
777        &mut self,
778        ball: &BallFrameState,
779        players: &PlayerFrameState,
780        touch_events: &[TouchEvent],
781    ) {
782        let ball_position = ball.position().map(GoalContextPosition::from);
783        for touch in touch_events {
784            let Some(player_id) = touch.player.clone() else {
785                continue;
786            };
787            let touch_team_most_back_player = Self::most_back_player(players, touch.team_is_team_0);
788            let other_team_most_back_player =
789                Self::most_back_player(players, !touch.team_is_team_0);
790            let touch_players = self.goal_player_contexts(
791                players,
792                touch.team_is_team_0,
793                touch_team_most_back_player.as_ref(),
794                other_team_most_back_player.as_ref(),
795            );
796            self.last_touch_context_by_player.insert(
797                player_id.clone(),
798                GoalTouchContext {
799                    time: touch.time,
800                    frame: touch.frame,
801                    player: player_id.clone(),
802                    is_team_0: touch.team_is_team_0,
803                    ball_position,
804                    player_position: Self::player_position(players, &player_id)
805                        .map(GoalContextPosition::from),
806                    players: touch_players,
807                },
808            );
809        }
810    }
811
812    fn update_boost_leadup_samples(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
813        let cutoff_time = frame.time - GOAL_CONTEXT_BOOST_LEADUP_SECONDS;
814        for player in &players.players {
815            let Some(boost_amount) = player.boost_amount.or(player.last_boost_amount) else {
816                continue;
817            };
818            let samples = self
819                .boost_leadup_samples_by_player
820                .entry(player.player_id.clone())
821                .or_default();
822            samples.push_back(BoostLeadupSample {
823                time: frame.time,
824                boost_amount,
825            });
826            while samples
827                .front()
828                .is_some_and(|sample| sample.time < cutoff_time)
829            {
830                samples.pop_front();
831            }
832        }
833
834        self.boost_leadup_samples_by_player
835            .retain(|_, samples| !samples.is_empty());
836    }
837
838    fn update_ball_ground_contact(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
839        if ball
840            .position()
841            .is_some_and(|position| position.z <= BALL_GROUND_CONTACT_MAX_Z)
842        {
843            self.last_ball_ground_contact_time = Some(frame.time);
844        }
845    }
846
847    fn ball_air_time_before_goal(&self, goal_time: f32) -> Option<f32> {
848        self.last_ball_ground_contact_time
849            .map(|ground_contact_time| (goal_time - ground_contact_time).max(0.0))
850    }
851
852    fn boost_leadup_for_player(&self, player_id: &PlayerId) -> Option<BoostLeadupStats> {
853        let samples = self.boost_leadup_samples_by_player.get(player_id)?;
854        if samples.is_empty() {
855            return None;
856        }
857
858        let mut sum = 0.0;
859        let mut min_boost = f32::INFINITY;
860        for sample in samples {
861            sum += sample.boost_amount;
862            min_boost = min_boost.min(sample.boost_amount);
863        }
864
865        Some(BoostLeadupStats {
866            average_boost: sum / samples.len() as f32,
867            min_boost,
868        })
869    }
870
871    fn goal_player_contexts(
872        &self,
873        players: &PlayerFrameState,
874        scoring_team_is_team_0: bool,
875        scoring_team_most_back_player: Option<&PlayerId>,
876        defending_team_most_back_player: Option<&PlayerId>,
877    ) -> Vec<GoalPlayerContext> {
878        players
879            .players
880            .iter()
881            .map(|player| {
882                let most_back_player = if player.is_team_0 == scoring_team_is_team_0 {
883                    scoring_team_most_back_player
884                } else {
885                    defending_team_most_back_player
886                };
887                let boost_leadup = self.boost_leadup_for_player(&player.player_id);
888                GoalPlayerContext {
889                    player: player.player_id.clone(),
890                    is_team_0: player.is_team_0,
891                    position: player.position().map(GoalContextPosition::from),
892                    boost_amount: player.boost_amount.or(player.last_boost_amount),
893                    average_boost_in_leadup: boost_leadup.map(|stats| stats.average_boost),
894                    min_boost_in_leadup: boost_leadup.map(|stats| stats.min_boost),
895                    is_most_back: most_back_player == Some(&player.player_id),
896                }
897            })
898            .collect()
899    }
900
901    fn record_goal_context_stats(
902        &mut self,
903        players: &PlayerFrameState,
904        goal_event: &GoalEvent,
905        scoring_team_most_back_player: Option<&PlayerId>,
906        defending_team_most_back_player: Option<&PlayerId>,
907        scorer_last_touch: Option<&GoalTouchContext>,
908        ball_air_time_before_goal: Option<f32>,
909    ) {
910        if let Some(player_id) = scoring_team_most_back_player {
911            self.player_stats
912                .entry(player_id.clone())
913                .or_default()
914                .scoring_context
915                .goals_for_while_most_back += 1;
916        }
917
918        if let Some(player_id) = defending_team_most_back_player {
919            self.player_stats
920                .entry(player_id.clone())
921                .or_default()
922                .scoring_context
923                .goals_against_while_most_back += 1;
924        }
925
926        for player in players
927            .players
928            .iter()
929            .filter(|player| player.is_team_0 != goal_event.scoring_team_is_team_0)
930        {
931            let boost_leadup = self.boost_leadup_for_player(&player.player_id);
932            self.player_stats
933                .entry(player.player_id.clone())
934                .or_default()
935                .scoring_context
936                .record_goal_against_snapshot(
937                    player.boost_amount.or(player.last_boost_amount),
938                    player.position().map(GoalContextPosition::from),
939                    boost_leadup,
940                );
941        }
942
943        if let Some(scorer) = goal_event.player.as_ref() {
944            if let Some(touch_position) = scorer_last_touch.and_then(|touch| touch.ball_position) {
945                self.player_stats
946                    .entry(scorer.clone())
947                    .or_default()
948                    .scoring_context
949                    .record_scoring_goal_last_touch_position(touch_position);
950            }
951            if let Some(ball_air_time_before_goal) = ball_air_time_before_goal {
952                self.player_stats
953                    .entry(scorer.clone())
954                    .or_default()
955                    .scoring_context
956                    .record_goal_ball_air_time(ball_air_time_before_goal);
957            }
958        }
959    }
960
961    fn record_goal_context_events(
962        &mut self,
963        ball: &BallFrameState,
964        players: &PlayerFrameState,
965        events: &FrameEventsState,
966    ) {
967        let ball_position = ball.position().map(GoalContextPosition::from);
968        for goal_event in &events.goal_events {
969            let scoring_team_most_back_player =
970                Self::most_back_player(players, goal_event.scoring_team_is_team_0);
971            let defending_team_most_back_player =
972                Self::most_back_player(players, !goal_event.scoring_team_is_team_0);
973            let scorer_last_touch = goal_event
974                .player
975                .as_ref()
976                .and_then(|player_id| self.last_touch_context_by_player.get(player_id))
977                .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
978                .cloned();
979            let ball_air_time_before_goal = self.ball_air_time_before_goal(goal_event.time);
980            let goal_buildup =
981                self.classify_goal_buildup(goal_event.time, goal_event.scoring_team_is_team_0);
982
983            self.record_goal_context_stats(
984                players,
985                goal_event,
986                scoring_team_most_back_player.as_ref(),
987                defending_team_most_back_player.as_ref(),
988                scorer_last_touch.as_ref(),
989                ball_air_time_before_goal,
990            );
991
992            self.goal_context_events.push(GoalContextEvent {
993                time: goal_event.time,
994                frame: goal_event.frame,
995                scoring_team_is_team_0: goal_event.scoring_team_is_team_0,
996                scorer: goal_event.player.clone(),
997                scoring_team_most_back_player: scoring_team_most_back_player.clone(),
998                defending_team_most_back_player: defending_team_most_back_player.clone(),
999                ball_position,
1000                ball_air_time_before_goal,
1001                goal_buildup,
1002                scorer_last_touch,
1003                players: self.goal_player_contexts(
1004                    players,
1005                    goal_event.scoring_team_is_team_0,
1006                    scoring_team_most_back_player.as_ref(),
1007                    defending_team_most_back_player.as_ref(),
1008                ),
1009            });
1010        }
1011    }
1012
1013    fn prune_goal_buildup_samples(&mut self, current_time: f32) {
1014        self.goal_buildup_samples
1015            .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1016        self.goal_buildup_pressure_events
1017            .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1018    }
1019
1020    fn record_goal_buildup_sample(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
1021        let Some(ball) = ball.sample() else {
1022            return;
1023        };
1024        if frame.dt <= 0.0 {
1025            return;
1026        }
1027        self.goal_buildup_samples.push(GoalBuildupSample {
1028            time: frame.time,
1029            dt: frame.dt,
1030            ball_y: ball.position().y,
1031        });
1032    }
1033
1034    fn record_goal_buildup_pressure_events(&mut self, events: &FrameEventsState) {
1035        self.goal_buildup_pressure_events.extend(
1036            events
1037                .player_stat_events
1038                .iter()
1039                .filter(|event| event.kind == PlayerStatEventKind::Shot)
1040                .map(|event| GoalBuildupPressureEvent {
1041                    time: event.time,
1042                    is_team_0: event.is_team_0,
1043                }),
1044        );
1045    }
1046
1047    fn classify_goal_buildup(
1048        &self,
1049        goal_time: f32,
1050        scoring_team_is_team_0: bool,
1051    ) -> GoalBuildupKind {
1052        let relevant_samples: Vec<_> = self
1053            .goal_buildup_samples
1054            .iter()
1055            .filter(|entry| entry.time <= goal_time)
1056            .filter(|entry| goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS)
1057            .collect();
1058        if relevant_samples.is_empty() {
1059            return GoalBuildupKind::Other;
1060        }
1061
1062        let mut defensive_half_time = 0.0;
1063        let mut defensive_third_time = 0.0;
1064        let mut offensive_half_time = 0.0;
1065        let mut offensive_third_time = 0.0;
1066        let mut current_attack_time = 0.0;
1067
1068        for entry in &relevant_samples {
1069            let normalized_ball_y = if scoring_team_is_team_0 {
1070                entry.ball_y
1071            } else {
1072                -entry.ball_y
1073            };
1074            if normalized_ball_y < 0.0 {
1075                defensive_half_time += entry.dt;
1076            } else {
1077                offensive_half_time += entry.dt;
1078            }
1079            if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
1080                defensive_third_time += entry.dt;
1081            }
1082            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
1083                offensive_third_time += entry.dt;
1084            }
1085        }
1086
1087        for entry in relevant_samples.iter().rev() {
1088            let normalized_ball_y = if scoring_team_is_team_0 {
1089                entry.ball_y
1090            } else {
1091                -entry.ball_y
1092            };
1093            if normalized_ball_y > 0.0 {
1094                current_attack_time += entry.dt;
1095            } else {
1096                break;
1097            }
1098        }
1099
1100        let opponent_shot_in_lookback = self.goal_buildup_pressure_events.iter().any(|entry| {
1101            entry.time <= goal_time
1102                && goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS
1103                && entry.is_team_0 != scoring_team_is_team_0
1104        });
1105        let has_defensive_pressure_signal = defensive_half_time
1106            >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
1107            || defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
1108            || opponent_shot_in_lookback;
1109
1110        if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS && has_defensive_pressure_signal
1111        {
1112            GoalBuildupKind::CounterAttack
1113        } else if current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
1114            && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
1115            && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
1116        {
1117            GoalBuildupKind::SustainedPressure
1118        } else {
1119            GoalBuildupKind::Other
1120        }
1121    }
1122}
1123
1124impl MatchStatsCalculator {
1125    #[allow(clippy::too_many_arguments)]
1126    pub fn update_parts(
1127        &mut self,
1128        frame: &FrameInfo,
1129        gameplay: &GameplayState,
1130        ball: &BallFrameState,
1131        players: &PlayerFrameState,
1132        events: &FrameEventsState,
1133        live_play_state: &LivePlayState,
1134        touch_state: &TouchState,
1135    ) -> SubtrActorResult<()> {
1136        self.update_kickoff_reference(gameplay, events);
1137        self.prune_goal_buildup_samples(frame.time);
1138        self.update_ball_ground_contact(frame, ball);
1139        if live_play_state.is_live_play {
1140            self.record_goal_buildup_sample(frame, ball);
1141            self.record_goal_buildup_pressure_events(events);
1142            self.update_boost_leadup_samples(frame, players);
1143        } else if events.goal_events.is_empty() {
1144            self.last_touch_context_by_player.clear();
1145            self.boost_leadup_samples_by_player.clear();
1146            self.last_ball_ground_contact_time = None;
1147        }
1148        self.update_last_touch_contexts(ball, players, &touch_state.touch_events);
1149        self.record_goal_context_events(ball, players, events);
1150        let pending_goal_events: Vec<_> = events
1151            .goal_events
1152            .iter()
1153            .cloned()
1154            .map(|event| PendingGoalEvent {
1155                time_after_kickoff: self
1156                    .active_kickoff_touch_time
1157                    .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
1158                goal_buildup: self.classify_goal_buildup(event.time, event.scoring_team_is_team_0),
1159                event,
1160            })
1161            .collect();
1162        self.pending_goal_events.extend(pending_goal_events);
1163        let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
1164            HashMap::new();
1165        for event in &events.player_stat_events {
1166            let kind = match event.kind {
1167                PlayerStatEventKind::Shot => TimelineEventKind::Shot,
1168                PlayerStatEventKind::Save => TimelineEventKind::Save,
1169                PlayerStatEventKind::Assist => TimelineEventKind::Assist,
1170            };
1171            self.timeline.push(TimelineEvent {
1172                time: event.time,
1173                kind,
1174                player_id: Some(event.player.clone()),
1175                is_team_0: Some(event.is_team_0),
1176            });
1177            *processor_event_counts
1178                .entry((event.player.clone(), kind))
1179                .or_default() += 1;
1180        }
1181
1182        for player in &players.players {
1183            self.player_teams
1184                .insert(player.player_id.clone(), player.is_team_0);
1185            let mut current_stats = CorePlayerStats {
1186                score: player.match_score.unwrap_or(0),
1187                goals: player.match_goals.unwrap_or(0),
1188                assists: player.match_assists.unwrap_or(0),
1189                saves: player.match_saves.unwrap_or(0),
1190                shots: player.match_shots.unwrap_or(0),
1191                scoring_context: self
1192                    .player_stats
1193                    .get(&player.player_id)
1194                    .map(|stats| stats.scoring_context.clone())
1195                    .unwrap_or_default(),
1196            };
1197
1198            let previous_stats = self
1199                .previous_player_stats
1200                .get(&player.player_id)
1201                .cloned()
1202                .unwrap_or_default();
1203
1204            let shot_delta = current_stats.shots - previous_stats.shots;
1205            let save_delta = current_stats.saves - previous_stats.saves;
1206            let assist_delta = current_stats.assists - previous_stats.assists;
1207            let goal_delta = current_stats.goals - previous_stats.goals;
1208            let shot_fallback_delta = shot_delta
1209                - processor_event_counts
1210                    .get(&(player.player_id.clone(), TimelineEventKind::Shot))
1211                    .copied()
1212                    .unwrap_or(0);
1213            let save_fallback_delta = save_delta
1214                - processor_event_counts
1215                    .get(&(player.player_id.clone(), TimelineEventKind::Save))
1216                    .copied()
1217                    .unwrap_or(0);
1218            let assist_fallback_delta = assist_delta
1219                - processor_event_counts
1220                    .get(&(player.player_id.clone(), TimelineEventKind::Assist))
1221                    .copied()
1222                    .unwrap_or(0);
1223
1224            if shot_fallback_delta > 0 {
1225                self.emit_timeline_events(
1226                    frame.time,
1227                    TimelineEventKind::Shot,
1228                    &player.player_id,
1229                    player.is_team_0,
1230                    shot_fallback_delta,
1231                );
1232            }
1233            if save_fallback_delta > 0 {
1234                self.emit_timeline_events(
1235                    frame.time,
1236                    TimelineEventKind::Save,
1237                    &player.player_id,
1238                    player.is_team_0,
1239                    save_fallback_delta,
1240                );
1241            }
1242            if assist_fallback_delta > 0 {
1243                self.emit_timeline_events(
1244                    frame.time,
1245                    TimelineEventKind::Assist,
1246                    &player.player_id,
1247                    player.is_team_0,
1248                    assist_fallback_delta,
1249                );
1250            }
1251            if goal_delta > 0 {
1252                for _ in 0..goal_delta.max(0) {
1253                    let pending_goal_event =
1254                        self.take_pending_goal_event(&player.player_id, player.is_team_0);
1255                    let goal_time = pending_goal_event
1256                        .as_ref()
1257                        .map(|event| event.event.time)
1258                        .unwrap_or(frame.time);
1259                    let goal_buildup = pending_goal_event
1260                        .as_ref()
1261                        .map(|event| event.goal_buildup)
1262                        .unwrap_or_else(|| self.classify_goal_buildup(goal_time, player.is_team_0));
1263                    let time_after_kickoff = pending_goal_event
1264                        .and_then(|event| event.time_after_kickoff)
1265                        .or_else(|| {
1266                            self.active_kickoff_touch_time
1267                                .map(|kickoff_touch_time| (goal_time - kickoff_touch_time).max(0.0))
1268                        });
1269                    if let Some(time_after_kickoff) = time_after_kickoff {
1270                        current_stats
1271                            .scoring_context
1272                            .goal_after_kickoff
1273                            .record_goal(time_after_kickoff);
1274                    }
1275                    current_stats
1276                        .scoring_context
1277                        .goal_buildup
1278                        .record(goal_buildup);
1279                    self.timeline.push(TimelineEvent {
1280                        time: goal_time,
1281                        kind: TimelineEventKind::Goal,
1282                        player_id: Some(player.player_id.clone()),
1283                        is_team_0: Some(player.is_team_0),
1284                    });
1285                }
1286            }
1287
1288            self.previous_player_stats
1289                .insert(player.player_id.clone(), current_stats.clone());
1290            self.player_stats
1291                .insert(player.player_id.clone(), current_stats);
1292        }
1293
1294        if let (Some(team_zero_score), Some(team_one_score)) =
1295            (gameplay.team_zero_score, gameplay.team_one_score)
1296        {
1297            if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
1298                let team_zero_delta = team_zero_score - prev_team_zero_score;
1299                let team_one_delta = team_one_score - prev_team_one_score;
1300
1301                if team_zero_delta > 0 {
1302                    if let Some(last_defender) = self.last_defender(players, false) {
1303                        if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1304                            stats.scoring_context.goals_conceded_while_last_defender +=
1305                                team_zero_delta as u32;
1306                        }
1307                    }
1308                }
1309
1310                if team_one_delta > 0 {
1311                    if let Some(last_defender) = self.last_defender(players, true) {
1312                        if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1313                            stats.scoring_context.goals_conceded_while_last_defender +=
1314                                team_one_delta as u32;
1315                        }
1316                    }
1317                }
1318            }
1319
1320            self.previous_team_scores = Some((team_zero_score, team_one_score));
1321        }
1322
1323        self.timeline.sort_by(|a, b| {
1324            a.time
1325                .partial_cmp(&b.time)
1326                .unwrap_or(std::cmp::Ordering::Equal)
1327        });
1328
1329        Ok(())
1330    }
1331}
1332
1333#[cfg(test)]
1334#[path = "match_stats_tests.rs"]
1335mod tests;