Skip to main content

subtr_actor/stats/calculators/
match_stats.rs

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