Skip to main content

subtr_actor/stats/calculators/
match_stats.rs

1use super::*;
2
3const GOAL_BUILDUP_LOOKBACK_SECONDS: f32 = 12.0;
4const COUNTER_ATTACK_MAX_ATTACK_SECONDS: f32 = 4.0;
5const COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS: f32 = 4.0;
6const COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS: f32 = 1.0;
7const SUSTAINED_PRESSURE_MIN_TIME_AFTER_KICKOFF_SECONDS: f32 = 12.0;
8const SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS: f32 = 6.0;
9const SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS: f32 = 7.0;
10const SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS: f32 = 3.5;
11const GOAL_CONTEXT_BOOST_LEADUP_SECONDS: f32 = 5.0;
12const BALL_GROUND_CONTACT_MAX_Z: f32 = BALL_RADIUS_Z + 5.0;
13// A defender is "caught ahead of play" on a conceded goal when the ball is deep in their
14// defensive end while they are stranded well up the field, far in front of the ball.
15const GOAL_CAUGHT_AHEAD_MAX_BALL_Y: f32 = -1200.0;
16const GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y: f32 = -250.0;
17const GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y: f32 = 2200.0;
18// On the frame a goal is recorded, the ball's rigid body is the goal explosion
19// rather than a normal physics update, so its interpolated velocity reads as
20// zero. We carry forward the most recent meaningful ball velocity (anything
21// above this threshold, in uu/s) so `ball_speed_at_goal` reflects the speed of
22// the shot as it crossed the line instead of a spurious 0.
23const MIN_TRACKED_BALL_SPEED: f32 = 1.0;
24/// Classification of how a goal's buildup developed.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
26#[serde(rename_all = "snake_case")]
27#[ts(export)]
28pub enum GoalBuildupKind {
29    CounterAttack,
30    SustainedPressure,
31    #[default]
32    Other,
33}
34
35/// A change to a player's core scoreboard stats.
36#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
37#[ts(export)]
38pub struct CorePlayerScoreboardEvent {
39    pub time: f32,
40    pub frame: usize,
41    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
42    pub player: PlayerId,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub player_position: Option<[f32; 3]>,
45    pub is_team_0: bool,
46    pub score_delta: i32,
47    pub goals_delta: i32,
48    pub assists_delta: i32,
49    pub saves_delta: i32,
50    pub shots_delta: i32,
51}
52
53/// Per-player context attached to a goal.
54#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
55pub struct CorePlayerGoalContextEvent {
56    pub time: f32,
57    pub frame: usize,
58    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
59    pub player: PlayerId,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub player_position: Option<[f32; 3]>,
62    pub is_team_0: bool,
63    pub scoring_team_is_team_0: bool,
64    pub goals_conceded_while_last_defender: bool,
65    pub goals_for_while_most_back: bool,
66    pub goals_against_while_most_back: bool,
67    pub caught_ahead_of_play_on_conceded_goal: bool,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub goal_against_boost_amount: Option<f32>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub goal_against_average_boost_in_leadup: Option<f32>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub goal_against_min_boost_in_leadup: Option<f32>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub goal_against_position: Option<GoalContextPosition>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub scoring_goal_last_touch_position: Option<GoalContextPosition>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub time_after_kickoff: Option<f32>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub goal_buildup: Option<GoalBuildupKind>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub ball_air_time_before_goal: Option<f32>,
84}
85
86/// Kind of high-level match timeline event.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ts_rs::TS)]
88#[ts(export)]
89pub enum TimelineEventKind {
90    Goal,
91    Shot,
92    Save,
93    Assist,
94}
95
96/// A high-level match timeline event (goal, demo, etc.).
97#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
98#[ts(export)]
99pub struct TimelineEvent {
100    pub time: f32,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub frame: Option<usize>,
103    pub kind: TimelineEventKind,
104    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
105    pub player_id: Option<PlayerId>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub player_position: Option<[f32; 3]>,
108    pub is_team_0: Option<bool>,
109}
110
111/// A position recorded in a goal's context.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
113#[ts(export)]
114pub struct GoalContextPosition {
115    pub x: f32,
116    pub y: f32,
117    pub z: f32,
118}
119
120impl From<glam::Vec3> for GoalContextPosition {
121    fn from(position: glam::Vec3) -> Self {
122        Self {
123            x: position.x,
124            y: position.y,
125            z: position.z,
126        }
127    }
128}
129
130/// Per-player context surrounding a goal.
131#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
132#[ts(export)]
133pub struct GoalPlayerContext {
134    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
135    pub player: PlayerId,
136    pub is_team_0: bool,
137    pub position: Option<GoalContextPosition>,
138    pub boost_amount: Option<f32>,
139    pub average_boost_in_leadup: Option<f32>,
140    pub min_boost_in_leadup: Option<f32>,
141    pub is_most_back: bool,
142}
143
144/// Touch context leading into a goal.
145#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
146#[ts(export)]
147pub struct GoalTouchContext {
148    /// Identity of the source [`TouchEvent`](crate::TouchEvent) for this
149    /// scoring touch. Join on this instead of player + frame. `None` only for
150    /// data serialized before touch ids existed.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    #[ts(type = "number")]
153    pub touch_id: Option<u64>,
154    pub time: f32,
155    pub frame: usize,
156    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
157    pub player: PlayerId,
158    pub is_team_0: bool,
159    pub ball_position: Option<GoalContextPosition>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub ball_speed_after_touch: Option<f32>,
162    pub player_position: Option<GoalContextPosition>,
163    pub players: Vec<GoalPlayerContext>,
164}
165
166/// Aggregated context for a single goal.
167#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
168#[ts(export)]
169pub struct GoalContextEvent {
170    pub time: f32,
171    pub frame: usize,
172    pub scoring_team_is_team_0: bool,
173    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
174    pub scorer: Option<PlayerId>,
175    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
176    pub scoring_team_most_back_player: Option<PlayerId>,
177    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
178    pub defending_team_most_back_player: Option<PlayerId>,
179    pub ball_position: Option<GoalContextPosition>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub ball_speed_at_goal: Option<f32>,
182    pub ball_air_time_before_goal: Option<f32>,
183    /// How long the scoring team's established territorial-pressure session had
184    /// been running when the goal was scored (goal_time - session.start_time).
185    /// None for goals scored with no active pressure session (e.g. clean
186    /// counter-attacks). Filled in at finish once pressure sessions are final.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub pressure_duration_before_goal: Option<f32>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub time_after_kickoff: Option<f32>,
191    #[serde(default)]
192    pub goal_buildup: GoalBuildupKind,
193    pub scorer_last_touch: Option<GoalTouchContext>,
194    pub players: Vec<GoalPlayerContext>,
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub tags: Vec<GoalTag>,
197}
198
199#[derive(Debug, Clone)]
200struct PendingGoalEvent {
201    event: GoalEvent,
202    time_after_kickoff: Option<f32>,
203    goal_buildup: GoalBuildupKind,
204    ball_air_time_before_goal: Option<f32>,
205}
206
207#[derive(Debug, Clone)]
208struct GoalBuildupSample {
209    time: f32,
210    dt: f32,
211    ball_y: f32,
212}
213
214#[derive(Debug, Clone)]
215struct GoalBuildupPressureEvent {
216    time: f32,
217    is_team_0: bool,
218}
219
220#[derive(Debug, Clone, Copy)]
221struct BoostLeadupSample {
222    time: f32,
223    boost_amount: f32,
224}
225
226#[derive(Debug, Clone, Copy)]
227struct BoostLeadupSummary {
228    average_boost: f32,
229    min_boost: f32,
230}
231
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
233struct PlayerScoreboardSample {
234    score: i32,
235    goals: i32,
236    assists: i32,
237    saves: i32,
238    shots: i32,
239}
240
241impl PlayerScoreboardSample {
242    fn from_player(player: &PlayerSample) -> Self {
243        Self {
244            score: player.match_score.unwrap_or(0),
245            goals: player.match_goals.unwrap_or(0),
246            assists: player.match_assists.unwrap_or(0),
247            saves: player.match_saves.unwrap_or(0),
248            shots: player.match_shots.unwrap_or(0),
249        }
250    }
251
252    fn delta_from(self, previous: Self) -> Self {
253        Self {
254            score: self.score - previous.score,
255            goals: self.goals - previous.goals,
256            assists: self.assists - previous.assists,
257            saves: self.saves - previous.saves,
258            shots: self.shots - previous.shots,
259        }
260    }
261
262    fn is_zero(self) -> bool {
263        self == Self::default()
264    }
265}
266
267/// Accumulates match-level scoreboard stats and per-goal context.
268#[derive(Debug, Clone, Default)]
269pub struct MatchStatsCalculator {
270    previous_player_scoreboard_samples: HashMap<PlayerId, PlayerScoreboardSample>,
271    core_player_scoreboard_events: EventStream<CorePlayerScoreboardEvent>,
272    core_player_goal_context_events: EventStream<CorePlayerGoalContextEvent>,
273    timeline: EventStream<TimelineEvent>,
274    pending_goal_events: Vec<PendingGoalEvent>,
275    previous_team_scores: Option<(i32, i32)>,
276    kickoff_waiting_for_first_touch: bool,
277    active_kickoff_touch_time: Option<f32>,
278    goal_buildup_samples: Vec<GoalBuildupSample>,
279    goal_buildup_pressure_events: Vec<GoalBuildupPressureEvent>,
280    goal_context_events: EventStream<GoalContextEvent>,
281    last_touch_context_by_player: HashMap<PlayerId, GoalTouchContext>,
282    boost_leadup_samples_by_player: HashMap<PlayerId, VecDeque<BoostLeadupSample>>,
283    last_ball_ground_contact_time: Option<f32>,
284    last_ball_velocity: Option<glam::Vec3>,
285}
286
287impl MatchStatsCalculator {
288    pub fn new() -> Self {
289        Self::default()
290    }
291
292    pub fn timeline(&self) -> &[TimelineEvent] {
293        self.timeline.all()
294    }
295
296    pub fn new_timeline_events(&self) -> &[TimelineEvent] {
297        self.timeline.new_events()
298    }
299
300    pub fn goal_context_events(&self) -> &[GoalContextEvent] {
301        self.goal_context_events.all()
302    }
303
304    pub fn new_goal_context_events(&self) -> &[GoalContextEvent] {
305        self.goal_context_events.new_events()
306    }
307
308    pub fn core_player_events(&self) -> &[CorePlayerScoreboardEvent] {
309        self.core_player_scoreboard_events.all()
310    }
311
312    pub fn new_core_player_events(&self) -> &[CorePlayerScoreboardEvent] {
313        self.core_player_scoreboard_events.new_events()
314    }
315
316    pub fn core_player_goal_context_events(&self) -> &[CorePlayerGoalContextEvent] {
317        self.core_player_goal_context_events.all()
318    }
319
320    pub fn new_core_player_goal_context_events(&self) -> &[CorePlayerGoalContextEvent] {
321        self.core_player_goal_context_events.new_events()
322    }
323
324    pub fn finish(&mut self) -> SubtrActorResult<()> {
325        self.timeline.begin_update();
326        self.goal_context_events.begin_update();
327        self.core_player_scoreboard_events.begin_update();
328        self.core_player_goal_context_events.begin_update();
329        let pending_goal_events = std::mem::take(&mut self.pending_goal_events);
330        for pending_goal_event in pending_goal_events {
331            let Some(scorer) = pending_goal_event.event.player.clone() else {
332                continue;
333            };
334            let scorer_last_touch =
335                self.reconcile_goal_context_scorer(&pending_goal_event.event, &scorer);
336            self.emit_core_player_scoreboard_event_parts(
337                pending_goal_event.event.time,
338                pending_goal_event.event.frame,
339                scorer.clone(),
340                pending_goal_event
341                    .event
342                    .player_position
343                    .map(|position| vec_to_glam(&position).to_array()),
344                pending_goal_event.event.scoring_team_is_team_0,
345                PlayerScoreboardSample {
346                    goals: 1,
347                    ..PlayerScoreboardSample::default()
348                },
349            );
350            self.emit_scoring_goal_context_event(
351                pending_goal_event.event.time,
352                pending_goal_event.event.frame,
353                scorer.clone(),
354                pending_goal_event
355                    .event
356                    .player_position
357                    .map(|position| vec_to_glam(&position).to_array()),
358                pending_goal_event.event.scoring_team_is_team_0,
359                scorer_last_touch.and_then(|touch| touch.ball_position),
360                pending_goal_event.time_after_kickoff,
361                pending_goal_event.goal_buildup,
362                pending_goal_event.ball_air_time_before_goal,
363            );
364
365            self.timeline.push(TimelineEvent {
366                time: pending_goal_event.event.time,
367                frame: Some(pending_goal_event.event.frame),
368                kind: TimelineEventKind::Goal,
369                player_position: pending_goal_event
370                    .event
371                    .player_position
372                    .map(|position| vec_to_glam(&position).to_array()),
373                player_id: Some(scorer),
374                is_team_0: Some(pending_goal_event.event.scoring_team_is_team_0),
375            });
376        }
377
378        self.timeline.sort_by(|a, b| {
379            a.time
380                .partial_cmp(&b.time)
381                .unwrap_or(std::cmp::Ordering::Equal)
382        });
383
384        Ok(())
385    }
386
387    #[allow(clippy::too_many_arguments)]
388    fn emit_timeline_events(
389        &mut self,
390        time: f32,
391        frame: Option<usize>,
392        kind: TimelineEventKind,
393        player_id: &PlayerId,
394        is_team_0: bool,
395        player_position: Option<[f32; 3]>,
396        delta: i32,
397    ) {
398        for _ in 0..delta.max(0) {
399            self.timeline.push(TimelineEvent {
400                time,
401                frame,
402                kind,
403                player_id: Some(player_id.clone()),
404                player_position,
405                is_team_0: Some(is_team_0),
406            });
407        }
408    }
409
410    fn emit_core_player_scoreboard_event(
411        &mut self,
412        frame: &FrameInfo,
413        player: PlayerId,
414        player_position: Option<[f32; 3]>,
415        is_team_0: bool,
416        delta: PlayerScoreboardSample,
417    ) {
418        self.emit_core_player_scoreboard_event_parts(
419            frame.time,
420            frame.frame_number,
421            player,
422            player_position,
423            is_team_0,
424            delta,
425        );
426    }
427
428    fn emit_core_player_scoreboard_event_parts(
429        &mut self,
430        time: f32,
431        frame: usize,
432        player: PlayerId,
433        player_position: Option<[f32; 3]>,
434        is_team_0: bool,
435        delta: PlayerScoreboardSample,
436    ) {
437        if delta.is_zero() {
438            return;
439        }
440        let event = CorePlayerScoreboardEvent {
441            time,
442            frame,
443            player,
444            player_position,
445            is_team_0,
446            score_delta: delta.score,
447            goals_delta: delta.goals,
448            assists_delta: delta.assists,
449            saves_delta: delta.saves,
450            shots_delta: delta.shots,
451        };
452        self.core_player_scoreboard_events.push(event);
453    }
454
455    #[allow(clippy::too_many_arguments)]
456    fn emit_core_player_goal_context_event(
457        &mut self,
458        time: f32,
459        frame: usize,
460        player: PlayerId,
461        player_position: Option<[f32; 3]>,
462        is_team_0: bool,
463        scoring_team_is_team_0: bool,
464        goals_conceded_while_last_defender: bool,
465        goals_for_while_most_back: bool,
466        goals_against_while_most_back: bool,
467        caught_ahead_of_play_on_conceded_goal: bool,
468        goal_against_boost_amount: Option<f32>,
469        goal_against_boost_leadup: Option<(f32, f32)>,
470        goal_against_position: Option<GoalContextPosition>,
471        scoring_goal_last_touch_position: Option<GoalContextPosition>,
472        time_after_kickoff: Option<f32>,
473        goal_buildup: Option<GoalBuildupKind>,
474        ball_air_time_before_goal: Option<f32>,
475    ) {
476        self.core_player_goal_context_events
477            .push(CorePlayerGoalContextEvent {
478                time,
479                frame,
480                player,
481                player_position,
482                is_team_0,
483                scoring_team_is_team_0,
484                goals_conceded_while_last_defender,
485                goals_for_while_most_back,
486                goals_against_while_most_back,
487                caught_ahead_of_play_on_conceded_goal,
488                goal_against_boost_amount,
489                goal_against_average_boost_in_leadup: goal_against_boost_leadup
490                    .map(|leadup| leadup.0),
491                goal_against_min_boost_in_leadup: goal_against_boost_leadup.map(|leadup| leadup.1),
492                goal_against_position,
493                scoring_goal_last_touch_position,
494                time_after_kickoff,
495                goal_buildup,
496                ball_air_time_before_goal,
497            });
498    }
499
500    #[allow(clippy::too_many_arguments)]
501    fn emit_scoring_goal_context_event(
502        &mut self,
503        time: f32,
504        frame: usize,
505        player: PlayerId,
506        player_position: Option<[f32; 3]>,
507        is_team_0: bool,
508        scoring_goal_last_touch_position: Option<GoalContextPosition>,
509        time_after_kickoff: Option<f32>,
510        goal_buildup: GoalBuildupKind,
511        ball_air_time_before_goal: Option<f32>,
512    ) {
513        self.emit_core_player_goal_context_event(
514            time,
515            frame,
516            player,
517            player_position,
518            is_team_0,
519            is_team_0,
520            false,
521            false,
522            false,
523            false,
524            None,
525            None,
526            None,
527            scoring_goal_last_touch_position,
528            time_after_kickoff,
529            Some(goal_buildup),
530            ball_air_time_before_goal,
531        );
532    }
533
534    fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
535        gameplay.kickoff_phase_active()
536    }
537
538    fn update_kickoff_reference(&mut self, gameplay: &GameplayState, events: &FrameEventsState) {
539        // Only the first touch after a kickoff reset establishes the kickoff
540        // reference. Without this gate, every later touch in open play would
541        // overwrite the reference, turning `time_after_kickoff` into "time
542        // since the most recent touch" instead of "time since the kickoff's
543        // first touch".
544        if self.kickoff_waiting_for_first_touch {
545            if let Some(first_touch_time) = events
546                .touch_events
547                .iter()
548                .map(|event| event.time)
549                .min_by(|a, b| a.total_cmp(b))
550            {
551                self.active_kickoff_touch_time = Some(first_touch_time);
552                self.kickoff_waiting_for_first_touch = false;
553                return;
554            }
555        }
556
557        if Self::kickoff_phase_active(gameplay) {
558            self.kickoff_waiting_for_first_touch = true;
559            self.active_kickoff_touch_time = None;
560        }
561    }
562
563    fn take_pending_goal_event(
564        &mut self,
565        player_id: &PlayerId,
566        is_team_0: bool,
567    ) -> Option<PendingGoalEvent> {
568        if let Some(index) = self.pending_goal_events.iter().position(|event| {
569            event.event.scoring_team_is_team_0 == is_team_0
570                && event.event.player.as_ref() == Some(player_id)
571        }) {
572            return Some(self.pending_goal_events.remove(index));
573        }
574
575        self.pending_goal_events
576            .iter()
577            .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
578            .map(|index| self.pending_goal_events.remove(index))
579    }
580
581    fn last_defender(
582        &self,
583        players: &PlayerFrameState,
584        defending_team_is_team_0: bool,
585    ) -> Option<PlayerId> {
586        players
587            .players
588            .iter()
589            .filter(|player| player.is_team_0 == defending_team_is_team_0)
590            .filter_map(|player| {
591                player
592                    .position()
593                    .map(|position| (player.player_id.clone(), position.y))
594            })
595            .reduce(|current, candidate| {
596                if defending_team_is_team_0 {
597                    if candidate.1 < current.1 {
598                        candidate
599                    } else {
600                        current
601                    }
602                } else if candidate.1 > current.1 {
603                    candidate
604                } else {
605                    current
606                }
607            })
608            .map(|(player_id, _)| player_id)
609    }
610
611    fn most_back_player(players: &PlayerFrameState, team_is_team_0: bool) -> Option<PlayerId> {
612        players
613            .players
614            .iter()
615            .filter(|player| player.is_team_0 == team_is_team_0)
616            .filter_map(|player| {
617                player.position().map(|position| {
618                    (
619                        player.player_id.clone(),
620                        normalized_y(team_is_team_0, position),
621                    )
622                })
623            })
624            .min_by(|left, right| left.1.total_cmp(&right.1))
625            .map(|(player_id, _)| player_id)
626    }
627
628    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
629        players
630            .players
631            .iter()
632            .find(|player| &player.player_id == player_id)
633            .and_then(PlayerSample::position)
634    }
635
636    fn update_last_touch_contexts(
637        &mut self,
638        ball: &BallFrameState,
639        players: &PlayerFrameState,
640        touch_events: &[TouchEvent],
641    ) {
642        let ball_position = ball.position().map(GoalContextPosition::from);
643        let ball_speed_after_touch = ball.velocity().map(|velocity| velocity.length());
644        for touch in touch_events {
645            let Some(player_id) = touch.player.clone() else {
646                continue;
647            };
648            let touch_team_most_back_player = Self::most_back_player(players, touch.team_is_team_0);
649            let other_team_most_back_player =
650                Self::most_back_player(players, !touch.team_is_team_0);
651            let touch_players = self.goal_player_contexts(
652                players,
653                touch.team_is_team_0,
654                touch_team_most_back_player.as_ref(),
655                other_team_most_back_player.as_ref(),
656            );
657            self.last_touch_context_by_player.insert(
658                player_id.clone(),
659                GoalTouchContext {
660                    touch_id: touch.touch_id,
661                    time: touch.time,
662                    frame: touch.frame,
663                    player: player_id.clone(),
664                    is_team_0: touch.team_is_team_0,
665                    ball_position,
666                    ball_speed_after_touch,
667                    player_position: Self::player_position(players, &player_id)
668                        .map(GoalContextPosition::from),
669                    players: touch_players,
670                },
671            );
672        }
673    }
674
675    fn update_boost_leadup_samples(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
676        let cutoff_time = frame.time - GOAL_CONTEXT_BOOST_LEADUP_SECONDS;
677        for player in &players.players {
678            let Some(boost_amount) = player.boost_amount.or(player.last_boost_amount) else {
679                continue;
680            };
681            let samples = self
682                .boost_leadup_samples_by_player
683                .entry(player.player_id.clone())
684                .or_default();
685            samples.push_back(BoostLeadupSample {
686                time: frame.time,
687                boost_amount,
688            });
689            while samples
690                .front()
691                .is_some_and(|sample| sample.time < cutoff_time)
692            {
693                samples.pop_front();
694            }
695        }
696
697        self.boost_leadup_samples_by_player
698            .retain(|_, samples| !samples.is_empty());
699    }
700
701    fn update_ball_ground_contact(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
702        if ball
703            .position()
704            .is_some_and(|position| position.z <= BALL_GROUND_CONTACT_MAX_Z)
705        {
706            self.last_ball_ground_contact_time = Some(frame.time);
707        }
708    }
709
710    fn update_ball_velocity(&mut self, ball: &BallFrameState) {
711        if let Some(velocity) = ball.velocity() {
712            if velocity.length() >= MIN_TRACKED_BALL_SPEED {
713                self.last_ball_velocity = Some(velocity);
714            }
715        }
716    }
717
718    // Speed of the ball as it crossed the goal line. The explosion frame itself
719    // carries no usable velocity, so fall back to the most recent tracked
720    // velocity from just before the goal.
721    fn ball_speed_at_goal(&self, ball: &BallFrameState) -> Option<f32> {
722        ball.velocity()
723            .filter(|velocity| velocity.length() >= MIN_TRACKED_BALL_SPEED)
724            .or(self.last_ball_velocity)
725            .map(|velocity| velocity.length())
726    }
727
728    fn ball_air_time_before_goal(&self, goal_time: f32) -> Option<f32> {
729        self.last_ball_ground_contact_time
730            .map(|ground_contact_time| (goal_time - ground_contact_time).max(0.0))
731    }
732
733    fn boost_leadup_for_player(&self, player_id: &PlayerId) -> Option<BoostLeadupSummary> {
734        let samples = self.boost_leadup_samples_by_player.get(player_id)?;
735        if samples.is_empty() {
736            return None;
737        }
738
739        let mut sum = 0.0;
740        let mut min_boost = f32::INFINITY;
741        for sample in samples {
742            sum += sample.boost_amount;
743            min_boost = min_boost.min(sample.boost_amount);
744        }
745
746        Some(BoostLeadupSummary {
747            average_boost: sum / samples.len() as f32,
748            min_boost,
749        })
750    }
751
752    fn goal_player_contexts(
753        &self,
754        players: &PlayerFrameState,
755        scoring_team_is_team_0: bool,
756        scoring_team_most_back_player: Option<&PlayerId>,
757        defending_team_most_back_player: Option<&PlayerId>,
758    ) -> Vec<GoalPlayerContext> {
759        players
760            .players
761            .iter()
762            .map(|player| {
763                let most_back_player = if player.is_team_0 == scoring_team_is_team_0 {
764                    scoring_team_most_back_player
765                } else {
766                    defending_team_most_back_player
767                };
768                let boost_leadup = self.boost_leadup_for_player(&player.player_id);
769                GoalPlayerContext {
770                    player: player.player_id.clone(),
771                    is_team_0: player.is_team_0,
772                    position: player.position().map(GoalContextPosition::from),
773                    boost_amount: player.boost_amount.or(player.last_boost_amount),
774                    average_boost_in_leadup: boost_leadup.map(|stats| stats.average_boost),
775                    min_boost_in_leadup: boost_leadup.map(|stats| stats.min_boost),
776                    is_most_back: most_back_player == Some(&player.player_id),
777                }
778            })
779            .collect()
780    }
781
782    /// A defender is "caught ahead of play" on a conceded goal when the ball is deep in their
783    /// defensive end while they are stranded well up the field, far in front of the ball.
784    fn caught_ahead_of_play_on_conceded_goal(
785        scoring_team_is_team_0: bool,
786        ball_position: Option<glam::Vec3>,
787        player_position: Option<glam::Vec3>,
788    ) -> bool {
789        let (Some(ball_position), Some(player_position)) = (ball_position, player_position) else {
790            return false;
791        };
792        let defending_team_is_team_0 = !scoring_team_is_team_0;
793        let normalized_ball_y = normalized_y(defending_team_is_team_0, ball_position);
794        if normalized_ball_y > GOAL_CAUGHT_AHEAD_MAX_BALL_Y {
795            return false;
796        }
797        let normalized_player_y = normalized_y(defending_team_is_team_0, player_position);
798        if normalized_player_y < GOAL_CAUGHT_AHEAD_MIN_PLAYER_Y {
799            return false;
800        }
801        normalized_player_y - normalized_ball_y >= GOAL_CAUGHT_AHEAD_MIN_BALL_DELTA_Y
802    }
803
804    fn record_goal_context_stats(
805        &mut self,
806        players: &PlayerFrameState,
807        goal_event: &GoalEvent,
808        ball_position: Option<glam::Vec3>,
809        scoring_team_most_back_player: Option<&PlayerId>,
810        defending_team_most_back_player: Option<&PlayerId>,
811    ) {
812        if let Some(player_id) = scoring_team_most_back_player {
813            self.emit_core_player_goal_context_event(
814                goal_event.time,
815                goal_event.frame,
816                player_id.clone(),
817                players.player_position(player_id),
818                goal_event.scoring_team_is_team_0,
819                goal_event.scoring_team_is_team_0,
820                false,
821                true,
822                false,
823                false,
824                None,
825                None,
826                None,
827                None,
828                None,
829                None,
830                None,
831            );
832        }
833
834        if let Some(player_id) = defending_team_most_back_player {
835            self.emit_core_player_goal_context_event(
836                goal_event.time,
837                goal_event.frame,
838                player_id.clone(),
839                players.player_position(player_id),
840                !goal_event.scoring_team_is_team_0,
841                goal_event.scoring_team_is_team_0,
842                false,
843                false,
844                true,
845                false,
846                None,
847                None,
848                None,
849                None,
850                None,
851                None,
852                None,
853            );
854        }
855
856        for player in players
857            .players
858            .iter()
859            .filter(|player| player.is_team_0 != goal_event.scoring_team_is_team_0)
860        {
861            let boost_leadup = self
862                .boost_leadup_for_player(&player.player_id)
863                .map(|stats| (stats.average_boost, stats.min_boost));
864            let caught_ahead = Self::caught_ahead_of_play_on_conceded_goal(
865                goal_event.scoring_team_is_team_0,
866                ball_position,
867                player.position(),
868            );
869            self.emit_core_player_goal_context_event(
870                goal_event.time,
871                goal_event.frame,
872                player.player_id.clone(),
873                player.position().map(|position| position.to_array()),
874                player.is_team_0,
875                goal_event.scoring_team_is_team_0,
876                false,
877                false,
878                false,
879                caught_ahead,
880                player.boost_amount.or(player.last_boost_amount),
881                boost_leadup,
882                player.position().map(GoalContextPosition::from),
883                None,
884                None,
885                None,
886                None,
887            );
888        }
889    }
890
891    fn record_goal_context_events(
892        &mut self,
893        ball: &BallFrameState,
894        players: &PlayerFrameState,
895        events: &FrameEventsState,
896    ) {
897        let ball_world_position = ball.position();
898        let ball_position = ball_world_position.map(GoalContextPosition::from);
899        let ball_speed_at_goal = self.ball_speed_at_goal(ball);
900        for goal_event in &events.goal_events {
901            let scoring_team_most_back_player =
902                Self::most_back_player(players, goal_event.scoring_team_is_team_0);
903            let defending_team_most_back_player =
904                Self::most_back_player(players, !goal_event.scoring_team_is_team_0);
905            let scorer_last_touch = goal_event
906                .player
907                .as_ref()
908                .and_then(|player_id| self.last_touch_context_by_player.get(player_id))
909                .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
910                .cloned();
911            let ball_air_time_before_goal = self.ball_air_time_before_goal(goal_event.time);
912            let time_after_kickoff = self
913                .active_kickoff_touch_time
914                .map(|kickoff_touch_time| (goal_event.time - kickoff_touch_time).max(0.0));
915            let goal_buildup =
916                self.classify_goal_buildup(goal_event.time, goal_event.scoring_team_is_team_0);
917
918            self.record_goal_context_stats(
919                players,
920                goal_event,
921                ball_world_position,
922                scoring_team_most_back_player.as_ref(),
923                defending_team_most_back_player.as_ref(),
924            );
925
926            self.goal_context_events.push(GoalContextEvent {
927                time: goal_event.time,
928                frame: goal_event.frame,
929                scoring_team_is_team_0: goal_event.scoring_team_is_team_0,
930                scorer: goal_event.player.clone(),
931                scoring_team_most_back_player: scoring_team_most_back_player.clone(),
932                defending_team_most_back_player: defending_team_most_back_player.clone(),
933                ball_position,
934                ball_speed_at_goal,
935                ball_air_time_before_goal,
936                // Filled in at finish once territorial-pressure sessions are final.
937                pressure_duration_before_goal: None,
938                time_after_kickoff,
939                goal_buildup,
940                scorer_last_touch,
941                players: self.goal_player_contexts(
942                    players,
943                    goal_event.scoring_team_is_team_0,
944                    scoring_team_most_back_player.as_ref(),
945                    defending_team_most_back_player.as_ref(),
946                ),
947                tags: Vec::new(),
948            });
949        }
950    }
951
952    fn reconcile_goal_context_scorer(
953        &mut self,
954        goal_event: &GoalEvent,
955        scorer: &PlayerId,
956    ) -> Option<GoalTouchContext> {
957        let scorer_last_touch = self
958            .last_touch_context_by_player
959            .get(scorer)
960            .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
961            .cloned();
962        if let Some(context) = self.goal_context_events.iter_mut().rev().find(|context| {
963            context.frame == goal_event.frame
964                && context.time == goal_event.time
965                && context.scoring_team_is_team_0 == goal_event.scoring_team_is_team_0
966                && context.scorer.as_ref() != Some(scorer)
967        }) {
968            context.scorer = Some(scorer.clone());
969            context.scorer_last_touch = scorer_last_touch.clone();
970        }
971        scorer_last_touch
972    }
973
974    fn prune_goal_buildup_samples(&mut self, current_time: f32) {
975        self.goal_buildup_samples
976            .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
977        self.goal_buildup_pressure_events
978            .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
979    }
980
981    fn record_goal_buildup_sample(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
982        let Some(ball) = ball.sample() else {
983            return;
984        };
985        if frame.dt <= 0.0 {
986            return;
987        }
988        self.goal_buildup_samples.push(GoalBuildupSample {
989            time: frame.time,
990            dt: frame.dt,
991            ball_y: ball.position().y,
992        });
993    }
994
995    fn record_goal_buildup_pressure_events(&mut self, events: &FrameEventsState) {
996        self.goal_buildup_pressure_events.extend(
997            events
998                .player_stat_events
999                .iter()
1000                .filter(|event| event.kind == PlayerStatEventKind::Shot)
1001                .map(|event| GoalBuildupPressureEvent {
1002                    time: event.time,
1003                    is_team_0: event.is_team_0,
1004                }),
1005        );
1006    }
1007
1008    fn classify_goal_buildup(
1009        &self,
1010        goal_time: f32,
1011        scoring_team_is_team_0: bool,
1012    ) -> GoalBuildupKind {
1013        // A goal's buildup must not reach back across the kickoff that
1014        // immediately preceded it. Play before that kickoff belongs to a prior,
1015        // already-concluded possession (a kickoff only follows a goal/reset), so
1016        // counting its ball positions as "defensive pressure" for this goal is
1017        // spurious — it is exactly what makes a clean kickoff goal masquerade as
1018        // a counter-attack. Clamp the lookback window to the current kickoff's
1019        // first touch when one is established.
1020        let window_start = self
1021            .active_kickoff_touch_time
1022            .map(|kickoff_touch_time| {
1023                kickoff_touch_time.max(goal_time - GOAL_BUILDUP_LOOKBACK_SECONDS)
1024            })
1025            .unwrap_or(goal_time - GOAL_BUILDUP_LOOKBACK_SECONDS);
1026        let relevant_samples: Vec<_> = self
1027            .goal_buildup_samples
1028            .iter()
1029            .filter(|entry| entry.time <= goal_time)
1030            .filter(|entry| entry.time >= window_start)
1031            .collect();
1032        if relevant_samples.is_empty() {
1033            return GoalBuildupKind::Other;
1034        }
1035
1036        let mut defensive_half_time = 0.0;
1037        let mut defensive_third_time = 0.0;
1038        let mut offensive_half_time = 0.0;
1039        let mut offensive_third_time = 0.0;
1040        let mut current_attack_time = 0.0;
1041
1042        for entry in &relevant_samples {
1043            let normalized_ball_y = if scoring_team_is_team_0 {
1044                entry.ball_y
1045            } else {
1046                -entry.ball_y
1047            };
1048            if normalized_ball_y < 0.0 {
1049                defensive_half_time += entry.dt;
1050            } else {
1051                offensive_half_time += entry.dt;
1052            }
1053            if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
1054                defensive_third_time += entry.dt;
1055            }
1056            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
1057                offensive_third_time += entry.dt;
1058            }
1059        }
1060
1061        for entry in relevant_samples.iter().rev() {
1062            let normalized_ball_y = if scoring_team_is_team_0 {
1063                entry.ball_y
1064            } else {
1065                -entry.ball_y
1066            };
1067            if normalized_ball_y > 0.0 {
1068                current_attack_time += entry.dt;
1069            } else {
1070                break;
1071            }
1072        }
1073
1074        let opponent_shot_in_lookback = self.goal_buildup_pressure_events.iter().any(|entry| {
1075            entry.time <= goal_time
1076                && entry.time >= window_start
1077                && entry.is_team_0 != scoring_team_is_team_0
1078        });
1079        let has_defensive_pressure_signal = defensive_half_time
1080            >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
1081            || defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
1082            || opponent_shot_in_lookback;
1083
1084        if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS && has_defensive_pressure_signal
1085        {
1086            GoalBuildupKind::CounterAttack
1087        } else if self
1088            .active_kickoff_touch_time
1089            .is_none_or(|kickoff_touch_time| {
1090                goal_time - kickoff_touch_time >= SUSTAINED_PRESSURE_MIN_TIME_AFTER_KICKOFF_SECONDS
1091            })
1092            && current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
1093            && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
1094            && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
1095        {
1096            GoalBuildupKind::SustainedPressure
1097        } else {
1098            GoalBuildupKind::Other
1099        }
1100    }
1101
1102    /// Attach to each recorded goal how long the scoring team's established
1103    /// territorial-pressure session had been running when the goal was scored
1104    /// (`goal_time - session.start_time`). The session is the scoring team's
1105    /// pressure session that spans the goal frame. Goals scored with no active
1106    /// pressure session (e.g. clean counter-attacks) are left as None.
1107    ///
1108    /// Runs at finish, after territorial-pressure sessions are finalized, so it
1109    /// must be fed the pressure calculator's projected (final) sessions.
1110    pub fn attach_goal_pressure_durations(
1111        &mut self,
1112        pressure_sessions: &[TerritorialPressureEvent],
1113    ) {
1114        for goal in self.goal_context_events.iter_mut() {
1115            let goal_time = goal.time;
1116            let scoring_team_is_team_0 = goal.scoring_team_is_team_0;
1117            goal.pressure_duration_before_goal = pressure_sessions
1118                .iter()
1119                .find(|session| {
1120                    session.team_is_team_0 == scoring_team_is_team_0
1121                        && session.start_time <= goal_time
1122                        && goal_time <= session.end_time
1123                })
1124                .map(|session| (goal_time - session.start_time).max(0.0));
1125        }
1126    }
1127}
1128
1129impl MatchStatsCalculator {
1130    #[allow(clippy::too_many_arguments)]
1131    pub fn update_parts(
1132        &mut self,
1133        frame: &FrameInfo,
1134        gameplay: &GameplayState,
1135        ball: &BallFrameState,
1136        players: &PlayerFrameState,
1137        events: &FrameEventsState,
1138        live_play_state: &LivePlayState,
1139        touch_state: &TouchState,
1140    ) -> SubtrActorResult<()> {
1141        self.timeline.begin_update();
1142        self.goal_context_events.begin_update();
1143        self.core_player_scoreboard_events.begin_update();
1144        self.core_player_goal_context_events.begin_update();
1145        self.update_kickoff_reference(gameplay, events);
1146        self.prune_goal_buildup_samples(frame.time);
1147        self.update_ball_ground_contact(frame, ball);
1148        self.update_ball_velocity(ball);
1149        if live_play_state.is_live_play {
1150            self.record_goal_buildup_sample(frame, ball);
1151            self.record_goal_buildup_pressure_events(events);
1152            self.update_boost_leadup_samples(frame, players);
1153        } else if events.goal_events.is_empty() {
1154            self.last_touch_context_by_player.clear();
1155            self.boost_leadup_samples_by_player.clear();
1156            self.last_ball_ground_contact_time = None;
1157            self.last_ball_velocity = None;
1158        }
1159        self.update_last_touch_contexts(ball, players, &touch_state.touch_events);
1160        self.record_goal_context_events(ball, players, events);
1161        let pending_goal_events: Vec<_> = events
1162            .goal_events
1163            .iter()
1164            .cloned()
1165            .map(|event| PendingGoalEvent {
1166                time_after_kickoff: self
1167                    .active_kickoff_touch_time
1168                    .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
1169                goal_buildup: self.classify_goal_buildup(event.time, event.scoring_team_is_team_0),
1170                ball_air_time_before_goal: self.ball_air_time_before_goal(event.time),
1171                event,
1172            })
1173            .collect();
1174        self.pending_goal_events.extend(pending_goal_events);
1175        let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
1176            HashMap::new();
1177        for event in &events.player_stat_events {
1178            let kind = match event.kind {
1179                PlayerStatEventKind::Shot => TimelineEventKind::Shot,
1180                PlayerStatEventKind::Save => TimelineEventKind::Save,
1181                PlayerStatEventKind::Assist => TimelineEventKind::Assist,
1182            };
1183            self.timeline.push(TimelineEvent {
1184                time: event.time,
1185                frame: Some(event.frame),
1186                kind,
1187                player_id: Some(event.player.clone()),
1188                player_position: event
1189                    .player_position
1190                    .map(|position| vec_to_glam(&position).to_array())
1191                    .or_else(|| {
1192                        event
1193                            .shot
1194                            .as_ref()
1195                            .and_then(|shot| shot.player_position)
1196                            .map(|position| vec_to_glam(&position).to_array())
1197                    })
1198                    .or_else(|| players.player_position(&event.player)),
1199                is_team_0: Some(event.is_team_0),
1200            });
1201            *processor_event_counts
1202                .entry((event.player.clone(), kind))
1203                .or_default() += 1;
1204        }
1205
1206        for player in &players.players {
1207            let current_stats = PlayerScoreboardSample::from_player(player);
1208
1209            let previous_stats = self
1210                .previous_player_scoreboard_samples
1211                .get(&player.player_id)
1212                .copied()
1213                .unwrap_or_default();
1214            let delta_stats = current_stats.delta_from(previous_stats);
1215
1216            let shot_delta = delta_stats.shots;
1217            let save_delta = delta_stats.saves;
1218            let assist_delta = delta_stats.assists;
1219            let goal_delta = delta_stats.goals;
1220            let shot_fallback_delta = shot_delta
1221                - processor_event_counts
1222                    .get(&(player.player_id.clone(), TimelineEventKind::Shot))
1223                    .copied()
1224                    .unwrap_or(0);
1225            let save_fallback_delta = save_delta
1226                - processor_event_counts
1227                    .get(&(player.player_id.clone(), TimelineEventKind::Save))
1228                    .copied()
1229                    .unwrap_or(0);
1230            let assist_fallback_delta = assist_delta
1231                - processor_event_counts
1232                    .get(&(player.player_id.clone(), TimelineEventKind::Assist))
1233                    .copied()
1234                    .unwrap_or(0);
1235
1236            if shot_fallback_delta > 0 {
1237                self.emit_timeline_events(
1238                    frame.time,
1239                    Some(frame.frame_number),
1240                    TimelineEventKind::Shot,
1241                    &player.player_id,
1242                    player.is_team_0,
1243                    player.position().map(|position| position.to_array()),
1244                    shot_fallback_delta,
1245                );
1246            }
1247            if save_fallback_delta > 0 {
1248                self.emit_timeline_events(
1249                    frame.time,
1250                    Some(frame.frame_number),
1251                    TimelineEventKind::Save,
1252                    &player.player_id,
1253                    player.is_team_0,
1254                    player.position().map(|position| position.to_array()),
1255                    save_fallback_delta,
1256                );
1257            }
1258            if assist_fallback_delta > 0 {
1259                self.emit_timeline_events(
1260                    frame.time,
1261                    Some(frame.frame_number),
1262                    TimelineEventKind::Assist,
1263                    &player.player_id,
1264                    player.is_team_0,
1265                    player.position().map(|position| position.to_array()),
1266                    assist_fallback_delta,
1267                );
1268            }
1269            if goal_delta > 0 {
1270                for _ in 0..goal_delta.max(0) {
1271                    let pending_goal_event =
1272                        self.take_pending_goal_event(&player.player_id, player.is_team_0);
1273                    let (
1274                        goal_time,
1275                        goal_frame,
1276                        time_after_kickoff,
1277                        goal_buildup,
1278                        ball_air_time_before_goal,
1279                        scoring_goal_last_touch_position,
1280                    ) = if let Some(pending_goal_event) = pending_goal_event.as_ref() {
1281                        let scorer_last_touch = self.reconcile_goal_context_scorer(
1282                            &pending_goal_event.event,
1283                            &player.player_id,
1284                        );
1285                        (
1286                            pending_goal_event.event.time,
1287                            pending_goal_event.event.frame,
1288                            pending_goal_event.time_after_kickoff,
1289                            pending_goal_event.goal_buildup,
1290                            pending_goal_event.ball_air_time_before_goal,
1291                            scorer_last_touch.and_then(|touch| touch.ball_position),
1292                        )
1293                    } else {
1294                        let goal_time = frame.time;
1295                        (
1296                            goal_time,
1297                            frame.frame_number,
1298                            self.active_kickoff_touch_time.map(|kickoff_touch_time| {
1299                                (goal_time - kickoff_touch_time).max(0.0)
1300                            }),
1301                            self.classify_goal_buildup(goal_time, player.is_team_0),
1302                            self.ball_air_time_before_goal(goal_time),
1303                            None,
1304                        )
1305                    };
1306                    self.emit_scoring_goal_context_event(
1307                        goal_time,
1308                        goal_frame,
1309                        player.player_id.clone(),
1310                        player.position().map(|position| position.to_array()),
1311                        player.is_team_0,
1312                        scoring_goal_last_touch_position,
1313                        time_after_kickoff,
1314                        goal_buildup,
1315                        ball_air_time_before_goal,
1316                    );
1317                    self.timeline.push(TimelineEvent {
1318                        time: goal_time,
1319                        frame: Some(goal_frame),
1320                        kind: TimelineEventKind::Goal,
1321                        player_id: Some(player.player_id.clone()),
1322                        player_position: player.position().map(|position| position.to_array()),
1323                        is_team_0: Some(player.is_team_0),
1324                    });
1325                }
1326            }
1327
1328            self.previous_player_scoreboard_samples
1329                .insert(player.player_id.clone(), current_stats);
1330            self.emit_core_player_scoreboard_event(
1331                frame,
1332                player.player_id.clone(),
1333                player.position().map(|position| position.to_array()),
1334                player.is_team_0,
1335                delta_stats,
1336            );
1337        }
1338
1339        if let (Some(team_zero_score), Some(team_one_score)) =
1340            (gameplay.team_zero_score, gameplay.team_one_score)
1341        {
1342            if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
1343                let team_zero_delta = team_zero_score - prev_team_zero_score;
1344                let team_one_delta = team_one_score - prev_team_one_score;
1345
1346                if team_zero_delta > 0 {
1347                    if let Some(last_defender) = self.last_defender(players, false) {
1348                        let player_position = players.player_position(&last_defender);
1349                        for _ in 0..team_zero_delta {
1350                            self.emit_core_player_goal_context_event(
1351                                frame.time,
1352                                frame.frame_number,
1353                                last_defender.clone(),
1354                                player_position,
1355                                false,
1356                                true,
1357                                true,
1358                                false,
1359                                false,
1360                                false,
1361                                None,
1362                                None,
1363                                None,
1364                                None,
1365                                None,
1366                                None,
1367                                None,
1368                            );
1369                        }
1370                    }
1371                }
1372
1373                if team_one_delta > 0 {
1374                    if let Some(last_defender) = self.last_defender(players, true) {
1375                        let player_position = players.player_position(&last_defender);
1376                        for _ in 0..team_one_delta {
1377                            self.emit_core_player_goal_context_event(
1378                                frame.time,
1379                                frame.frame_number,
1380                                last_defender.clone(),
1381                                player_position,
1382                                true,
1383                                false,
1384                                true,
1385                                false,
1386                                false,
1387                                false,
1388                                None,
1389                                None,
1390                                None,
1391                                None,
1392                                None,
1393                                None,
1394                                None,
1395                            );
1396                        }
1397                    }
1398                }
1399            }
1400
1401            self.previous_team_scores = Some((team_zero_score, team_one_score));
1402        }
1403
1404        self.timeline.sort_by(|a, b| {
1405            a.time
1406                .partial_cmp(&b.time)
1407                .unwrap_or(std::cmp::Ordering::Equal)
1408        });
1409        Ok(())
1410    }
1411}
1412
1413#[cfg(test)]
1414#[path = "match_stats_tests.rs"]
1415mod tests;