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