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