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    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub frame: Option<usize>,
503    pub kind: TimelineEventKind,
504    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
505    pub player_id: Option<PlayerId>,
506    pub is_team_0: Option<bool>,
507}
508
509#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
510#[ts(export)]
511pub struct GoalContextPosition {
512    pub x: f32,
513    pub y: f32,
514    pub z: f32,
515}
516
517impl From<glam::Vec3> for GoalContextPosition {
518    fn from(position: glam::Vec3) -> Self {
519        Self {
520            x: position.x,
521            y: position.y,
522            z: position.z,
523        }
524    }
525}
526
527fn optional_delta<T: Copy + PartialEq>(current: Option<T>, previous: Option<T>) -> Option<T> {
528    if current == previous {
529        None
530    } else {
531        current
532    }
533}
534
535fn sample_delta<T: Copy + PartialEq>(current: &[T], previous: &[T]) -> Vec<T> {
536    let mut unmatched_previous = previous.to_vec();
537    let mut delta = Vec::new();
538    for value in current {
539        if let Some(index) = unmatched_previous
540            .iter()
541            .position(|previous_value| previous_value == value)
542        {
543            unmatched_previous.remove(index);
544        } else {
545            delta.push(*value);
546        }
547    }
548    delta
549}
550
551fn goal_after_kickoff_delta(
552    current: &GoalAfterKickoffStats,
553    previous: &GoalAfterKickoffStats,
554) -> GoalAfterKickoffStats {
555    GoalAfterKickoffStats {
556        kickoff_goal_count: current
557            .kickoff_goal_count
558            .saturating_sub(previous.kickoff_goal_count),
559        short_goal_count: current
560            .short_goal_count
561            .saturating_sub(previous.short_goal_count),
562        medium_goal_count: current
563            .medium_goal_count
564            .saturating_sub(previous.medium_goal_count),
565        long_goal_count: current
566            .long_goal_count
567            .saturating_sub(previous.long_goal_count),
568        goal_times: sample_delta(&current.goal_times, &previous.goal_times),
569    }
570}
571
572fn goal_buildup_delta(current: &GoalBuildupStats, previous: &GoalBuildupStats) -> GoalBuildupStats {
573    GoalBuildupStats {
574        counter_attack_goal_count: current
575            .counter_attack_goal_count
576            .saturating_sub(previous.counter_attack_goal_count),
577        sustained_pressure_goal_count: current
578            .sustained_pressure_goal_count
579            .saturating_sub(previous.sustained_pressure_goal_count),
580        other_buildup_goal_count: current
581            .other_buildup_goal_count
582            .saturating_sub(previous.other_buildup_goal_count),
583    }
584}
585
586fn goal_ball_air_time_delta(
587    current: &GoalBallAirTimeStats,
588    previous: &GoalBallAirTimeStats,
589) -> GoalBallAirTimeStats {
590    GoalBallAirTimeStats {
591        goal_ball_air_time_sample_count: current
592            .goal_ball_air_time_sample_count
593            .saturating_sub(previous.goal_ball_air_time_sample_count),
594        cumulative_goal_ball_air_time: current.cumulative_goal_ball_air_time
595            - previous.cumulative_goal_ball_air_time,
596        last_goal_ball_air_time: optional_delta(
597            current.last_goal_ball_air_time,
598            previous.last_goal_ball_air_time,
599        ),
600        goal_ball_air_times: sample_delta(
601            &current.goal_ball_air_times,
602            &previous.goal_ball_air_times,
603        ),
604    }
605}
606
607fn team_scoring_context_delta(
608    current: &TeamScoringContextStats,
609    previous: &TeamScoringContextStats,
610) -> TeamScoringContextStats {
611    TeamScoringContextStats {
612        goal_after_kickoff: goal_after_kickoff_delta(
613            &current.goal_after_kickoff,
614            &previous.goal_after_kickoff,
615        ),
616        goal_buildup: goal_buildup_delta(&current.goal_buildup, &previous.goal_buildup),
617        goal_ball_air_time: goal_ball_air_time_delta(
618            &current.goal_ball_air_time,
619            &previous.goal_ball_air_time,
620        ),
621    }
622}
623
624fn player_scoring_context_delta(
625    current: &PlayerScoringContextStats,
626    previous: &PlayerScoringContextStats,
627) -> PlayerScoringContextStats {
628    PlayerScoringContextStats {
629        goals_conceded_while_last_defender: current
630            .goals_conceded_while_last_defender
631            .saturating_sub(previous.goals_conceded_while_last_defender),
632        goals_for_while_most_back: current
633            .goals_for_while_most_back
634            .saturating_sub(previous.goals_for_while_most_back),
635        goals_against_while_most_back: current
636            .goals_against_while_most_back
637            .saturating_sub(previous.goals_against_while_most_back),
638        goal_against_boost_sample_count: current
639            .goal_against_boost_sample_count
640            .saturating_sub(previous.goal_against_boost_sample_count),
641        cumulative_boost_on_goals_against: current.cumulative_boost_on_goals_against
642            - previous.cumulative_boost_on_goals_against,
643        last_boost_on_goal_against: optional_delta(
644            current.last_boost_on_goal_against,
645            previous.last_boost_on_goal_against,
646        ),
647        goal_against_boost_leadup_sample_count: current
648            .goal_against_boost_leadup_sample_count
649            .saturating_sub(previous.goal_against_boost_leadup_sample_count),
650        cumulative_average_boost_in_goal_against_leadup: current
651            .cumulative_average_boost_in_goal_against_leadup
652            - previous.cumulative_average_boost_in_goal_against_leadup,
653        cumulative_min_boost_in_goal_against_leadup: current
654            .cumulative_min_boost_in_goal_against_leadup
655            - previous.cumulative_min_boost_in_goal_against_leadup,
656        last_average_boost_in_goal_against_leadup: optional_delta(
657            current.last_average_boost_in_goal_against_leadup,
658            previous.last_average_boost_in_goal_against_leadup,
659        ),
660        last_min_boost_in_goal_against_leadup: optional_delta(
661            current.last_min_boost_in_goal_against_leadup,
662            previous.last_min_boost_in_goal_against_leadup,
663        ),
664        goal_against_position_sample_count: current
665            .goal_against_position_sample_count
666            .saturating_sub(previous.goal_against_position_sample_count),
667        cumulative_goal_against_position_x: current.cumulative_goal_against_position_x
668            - previous.cumulative_goal_against_position_x,
669        cumulative_goal_against_position_y: current.cumulative_goal_against_position_y
670            - previous.cumulative_goal_against_position_y,
671        cumulative_goal_against_position_z: current.cumulative_goal_against_position_z
672            - previous.cumulative_goal_against_position_z,
673        last_goal_against_position: optional_delta(
674            current.last_goal_against_position,
675            previous.last_goal_against_position,
676        ),
677        scoring_goal_last_touch_position_sample_count: current
678            .scoring_goal_last_touch_position_sample_count
679            .saturating_sub(previous.scoring_goal_last_touch_position_sample_count),
680        cumulative_scoring_goal_last_touch_position_x: current
681            .cumulative_scoring_goal_last_touch_position_x
682            - previous.cumulative_scoring_goal_last_touch_position_x,
683        cumulative_scoring_goal_last_touch_position_y: current
684            .cumulative_scoring_goal_last_touch_position_y
685            - previous.cumulative_scoring_goal_last_touch_position_y,
686        cumulative_scoring_goal_last_touch_position_z: current
687            .cumulative_scoring_goal_last_touch_position_z
688            - previous.cumulative_scoring_goal_last_touch_position_z,
689        last_scoring_goal_last_touch_position: optional_delta(
690            current.last_scoring_goal_last_touch_position,
691            previous.last_scoring_goal_last_touch_position,
692        ),
693        goal_after_kickoff: goal_after_kickoff_delta(
694            &current.goal_after_kickoff,
695            &previous.goal_after_kickoff,
696        ),
697        goal_buildup: goal_buildup_delta(&current.goal_buildup, &previous.goal_buildup),
698        goal_ball_air_time: goal_ball_air_time_delta(
699            &current.goal_ball_air_time,
700            &previous.goal_ball_air_time,
701        ),
702    }
703}
704
705fn core_player_stats_delta(
706    current: &CorePlayerStats,
707    previous: &CorePlayerStats,
708) -> CorePlayerStats {
709    CorePlayerStats {
710        score: current.score - previous.score,
711        goals: current.goals - previous.goals,
712        assists: current.assists - previous.assists,
713        saves: current.saves - previous.saves,
714        shots: current.shots - previous.shots,
715        scoring_context: player_scoring_context_delta(
716            &current.scoring_context,
717            &previous.scoring_context,
718        ),
719    }
720}
721
722fn core_team_stats_delta(current: &CoreTeamStats, previous: &CoreTeamStats) -> CoreTeamStats {
723    CoreTeamStats {
724        score: current.score - previous.score,
725        goals: current.goals - previous.goals,
726        assists: current.assists - previous.assists,
727        saves: current.saves - previous.saves,
728        shots: current.shots - previous.shots,
729        scoring_context: team_scoring_context_delta(
730            &current.scoring_context,
731            &previous.scoring_context,
732        ),
733    }
734}
735
736fn player_id_sort_key(player_id: &PlayerId) -> String {
737    match player_id {
738        boxcars::RemoteId::PlayStation(id) => {
739            format!("playstation:{}:{}:{:?}", id.online_id, id.name, id.unknown1)
740        }
741        boxcars::RemoteId::PsyNet(id) => format!("psynet:{}:{:?}", id.online_id, id.unknown1),
742        boxcars::RemoteId::SplitScreen(id) => format!("splitscreen:{id}"),
743        boxcars::RemoteId::Steam(id) => format!("steam:{id}"),
744        boxcars::RemoteId::Switch(id) => format!("switch:{}:{:?}", id.online_id, id.unknown1),
745        boxcars::RemoteId::Xbox(id) => format!("xbox:{id}"),
746        boxcars::RemoteId::QQ(id) => format!("qq:{id}"),
747        boxcars::RemoteId::Epic(id) => format!("epic:{id}"),
748    }
749}
750
751#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
752#[ts(export)]
753pub struct GoalPlayerContext {
754    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
755    pub player: PlayerId,
756    pub is_team_0: bool,
757    pub position: Option<GoalContextPosition>,
758    pub boost_amount: Option<f32>,
759    pub average_boost_in_leadup: Option<f32>,
760    pub min_boost_in_leadup: Option<f32>,
761    pub is_most_back: bool,
762}
763
764#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
765#[ts(export)]
766pub struct GoalTouchContext {
767    pub time: f32,
768    pub frame: usize,
769    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
770    pub player: PlayerId,
771    pub is_team_0: bool,
772    pub ball_position: Option<GoalContextPosition>,
773    pub player_position: Option<GoalContextPosition>,
774    pub players: Vec<GoalPlayerContext>,
775}
776
777#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
778#[ts(export)]
779pub struct GoalContextEvent {
780    pub time: f32,
781    pub frame: usize,
782    pub scoring_team_is_team_0: bool,
783    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
784    pub scorer: Option<PlayerId>,
785    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
786    pub scoring_team_most_back_player: Option<PlayerId>,
787    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
788    pub defending_team_most_back_player: Option<PlayerId>,
789    pub ball_position: Option<GoalContextPosition>,
790    pub ball_air_time_before_goal: Option<f32>,
791    #[serde(default)]
792    pub goal_buildup: GoalBuildupKind,
793    pub scorer_last_touch: Option<GoalTouchContext>,
794    pub players: Vec<GoalPlayerContext>,
795}
796
797#[derive(Debug, Clone)]
798struct PendingGoalEvent {
799    event: GoalEvent,
800    time_after_kickoff: Option<f32>,
801    goal_buildup: GoalBuildupKind,
802    ball_air_time_before_goal: Option<f32>,
803}
804
805#[derive(Debug, Clone)]
806struct GoalBuildupSample {
807    time: f32,
808    dt: f32,
809    ball_y: f32,
810}
811
812#[derive(Debug, Clone)]
813struct GoalBuildupPressureEvent {
814    time: f32,
815    is_team_0: bool,
816}
817
818#[derive(Debug, Clone, Copy)]
819struct BoostLeadupSample {
820    time: f32,
821    boost_amount: f32,
822}
823
824#[derive(Debug, Clone, Copy)]
825struct BoostLeadupStats {
826    average_boost: f32,
827    min_boost: f32,
828}
829
830#[derive(Debug, Clone, Default)]
831pub struct MatchStatsCalculator {
832    player_stats: HashMap<PlayerId, CorePlayerStats>,
833    player_teams: HashMap<PlayerId, bool>,
834    previous_player_stats: HashMap<PlayerId, CorePlayerStats>,
835    last_emitted_player_stats: HashMap<PlayerId, CorePlayerStats>,
836    last_emitted_team_zero_stats: CoreTeamStats,
837    last_emitted_team_one_stats: CoreTeamStats,
838    core_player_events: Vec<CorePlayerStatsEvent>,
839    core_team_events: Vec<CoreTeamStatsEvent>,
840    timeline: Vec<TimelineEvent>,
841    pending_goal_events: Vec<PendingGoalEvent>,
842    previous_team_scores: Option<(i32, i32)>,
843    kickoff_waiting_for_first_touch: bool,
844    active_kickoff_touch_time: Option<f32>,
845    goal_buildup_samples: Vec<GoalBuildupSample>,
846    goal_buildup_pressure_events: Vec<GoalBuildupPressureEvent>,
847    goal_context_events: Vec<GoalContextEvent>,
848    last_touch_context_by_player: HashMap<PlayerId, GoalTouchContext>,
849    boost_leadup_samples_by_player: HashMap<PlayerId, VecDeque<BoostLeadupSample>>,
850    last_ball_ground_contact_time: Option<f32>,
851}
852
853impl MatchStatsCalculator {
854    pub fn new() -> Self {
855        Self::default()
856    }
857
858    pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
859        &self.player_stats
860    }
861
862    pub fn timeline(&self) -> &[TimelineEvent] {
863        &self.timeline
864    }
865
866    pub fn goal_context_events(&self) -> &[GoalContextEvent] {
867        &self.goal_context_events
868    }
869
870    pub fn core_player_events(&self) -> &[CorePlayerStatsEvent] {
871        &self.core_player_events
872    }
873
874    pub fn core_team_events(&self) -> &[CoreTeamStatsEvent] {
875        &self.core_team_events
876    }
877
878    pub fn finish(&mut self) -> SubtrActorResult<()> {
879        let pending_goal_events = std::mem::take(&mut self.pending_goal_events);
880        for pending_goal_event in pending_goal_events {
881            let Some(scorer) = pending_goal_event.event.player.clone() else {
882                continue;
883            };
884            let scorer_last_touch =
885                self.reconcile_goal_context_scorer(&pending_goal_event.event, &scorer);
886            let scorer_stats = self.player_stats.entry(scorer.clone()).or_default();
887            scorer_stats.goals += 1;
888            if let Some(touch_position) = scorer_last_touch.and_then(|touch| touch.ball_position) {
889                scorer_stats
890                    .scoring_context
891                    .record_scoring_goal_last_touch_position(touch_position);
892            }
893            if let Some(time_after_kickoff) = pending_goal_event.time_after_kickoff {
894                scorer_stats
895                    .scoring_context
896                    .goal_after_kickoff
897                    .record_goal(time_after_kickoff);
898            }
899            scorer_stats
900                .scoring_context
901                .goal_buildup
902                .record(pending_goal_event.goal_buildup);
903            if let Some(ball_air_time_before_goal) = pending_goal_event.ball_air_time_before_goal {
904                scorer_stats
905                    .scoring_context
906                    .record_goal_ball_air_time(ball_air_time_before_goal);
907            }
908
909            self.timeline.push(TimelineEvent {
910                time: pending_goal_event.event.time,
911                frame: Some(pending_goal_event.event.frame),
912                kind: TimelineEventKind::Goal,
913                player_id: Some(scorer),
914                is_team_0: Some(pending_goal_event.event.scoring_team_is_team_0),
915            });
916        }
917
918        self.timeline.sort_by(|a, b| {
919            a.time
920                .partial_cmp(&b.time)
921                .unwrap_or(std::cmp::Ordering::Equal)
922        });
923
924        Ok(())
925    }
926
927    pub fn team_zero_stats(&self) -> CoreTeamStats {
928        self.team_stats_for_side(true)
929    }
930
931    pub fn team_one_stats(&self) -> CoreTeamStats {
932        self.team_stats_for_side(false)
933    }
934
935    fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
936        let mut player_stats: Vec<_> = self
937            .player_stats
938            .iter()
939            .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
940            .collect();
941        player_stats.sort_by_cached_key(|(player_id, _)| player_id_sort_key(player_id));
942
943        let mut stats = player_stats.into_iter().fold(
944            CoreTeamStats::default(),
945            |mut stats, (_, player_stats)| {
946                stats.score += player_stats.score;
947                stats.goals += player_stats.goals;
948                stats.assists += player_stats.assists;
949                stats.saves += player_stats.saves;
950                stats.shots += player_stats.shots;
951                stats
952                    .scoring_context
953                    .goal_after_kickoff
954                    .merge(&player_stats.scoring_context.goal_after_kickoff);
955                stats
956                    .scoring_context
957                    .goal_buildup
958                    .merge(&player_stats.scoring_context.goal_buildup);
959                stats
960                    .scoring_context
961                    .goal_ball_air_time
962                    .merge(&player_stats.scoring_context.goal_ball_air_time);
963                stats
964            },
965        );
966        stats
967            .scoring_context
968            .goal_after_kickoff
969            .goal_times
970            .sort_by(|left, right| left.total_cmp(right));
971        stats
972            .scoring_context
973            .goal_ball_air_time
974            .goal_ball_air_times
975            .sort_by(|left, right| left.total_cmp(right));
976        stats
977    }
978
979    fn emit_timeline_events(
980        &mut self,
981        time: f32,
982        frame: Option<usize>,
983        kind: TimelineEventKind,
984        player_id: &PlayerId,
985        is_team_0: bool,
986        delta: i32,
987    ) {
988        for _ in 0..delta.max(0) {
989            self.timeline.push(TimelineEvent {
990                time,
991                frame,
992                kind,
993                player_id: Some(player_id.clone()),
994                is_team_0: Some(is_team_0),
995            });
996        }
997    }
998
999    fn emit_core_stats_events(&mut self, frame: &FrameInfo) {
1000        let mut player_ids: Vec<_> = self.player_stats.keys().cloned().collect();
1001        player_ids.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
1002        for player_id in player_ids {
1003            let Some(stats) = self.player_stats.get(&player_id) else {
1004                continue;
1005            };
1006            let previous_stats = self
1007                .last_emitted_player_stats
1008                .get(&player_id)
1009                .cloned()
1010                .unwrap_or_default();
1011            if previous_stats == *stats {
1012                continue;
1013            }
1014            let Some(is_team_0) = self.player_teams.get(&player_id).copied() else {
1015                continue;
1016            };
1017            self.core_player_events.push(CorePlayerStatsEvent {
1018                time: frame.time,
1019                frame: frame.frame_number,
1020                player: player_id.clone(),
1021                is_team_0,
1022                delta: core_player_stats_delta(stats, &previous_stats),
1023            });
1024            self.last_emitted_player_stats
1025                .insert(player_id, stats.clone());
1026        }
1027
1028        let team_zero_stats = self.team_zero_stats();
1029        if team_zero_stats != self.last_emitted_team_zero_stats {
1030            self.core_team_events.push(CoreTeamStatsEvent {
1031                time: frame.time,
1032                frame: frame.frame_number,
1033                is_team_0: true,
1034                delta: core_team_stats_delta(&team_zero_stats, &self.last_emitted_team_zero_stats),
1035            });
1036            self.last_emitted_team_zero_stats = team_zero_stats;
1037        }
1038
1039        let team_one_stats = self.team_one_stats();
1040        if team_one_stats != self.last_emitted_team_one_stats {
1041            self.core_team_events.push(CoreTeamStatsEvent {
1042                time: frame.time,
1043                frame: frame.frame_number,
1044                is_team_0: false,
1045                delta: core_team_stats_delta(&team_one_stats, &self.last_emitted_team_one_stats),
1046            });
1047            self.last_emitted_team_one_stats = team_one_stats;
1048        }
1049    }
1050
1051    fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
1052        gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
1053            || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
1054            || gameplay.ball_has_been_hit == Some(false)
1055    }
1056
1057    fn update_kickoff_reference(&mut self, gameplay: &GameplayState, events: &FrameEventsState) {
1058        if let Some(first_touch_time) = events
1059            .touch_events
1060            .iter()
1061            .map(|event| event.time)
1062            .min_by(|a, b| a.total_cmp(b))
1063        {
1064            self.active_kickoff_touch_time = Some(first_touch_time);
1065            self.kickoff_waiting_for_first_touch = false;
1066            return;
1067        }
1068
1069        if Self::kickoff_phase_active(gameplay) {
1070            self.kickoff_waiting_for_first_touch = true;
1071            self.active_kickoff_touch_time = None;
1072        }
1073    }
1074
1075    fn take_pending_goal_event(
1076        &mut self,
1077        player_id: &PlayerId,
1078        is_team_0: bool,
1079    ) -> Option<PendingGoalEvent> {
1080        if let Some(index) = self.pending_goal_events.iter().position(|event| {
1081            event.event.scoring_team_is_team_0 == is_team_0
1082                && event.event.player.as_ref() == Some(player_id)
1083        }) {
1084            return Some(self.pending_goal_events.remove(index));
1085        }
1086
1087        self.pending_goal_events
1088            .iter()
1089            .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
1090            .map(|index| self.pending_goal_events.remove(index))
1091    }
1092
1093    fn last_defender(
1094        &self,
1095        players: &PlayerFrameState,
1096        defending_team_is_team_0: bool,
1097    ) -> Option<PlayerId> {
1098        players
1099            .players
1100            .iter()
1101            .filter(|player| player.is_team_0 == defending_team_is_team_0)
1102            .filter_map(|player| {
1103                player
1104                    .position()
1105                    .map(|position| (player.player_id.clone(), position.y))
1106            })
1107            .reduce(|current, candidate| {
1108                if defending_team_is_team_0 {
1109                    if candidate.1 < current.1 {
1110                        candidate
1111                    } else {
1112                        current
1113                    }
1114                } else if candidate.1 > current.1 {
1115                    candidate
1116                } else {
1117                    current
1118                }
1119            })
1120            .map(|(player_id, _)| player_id)
1121    }
1122
1123    fn most_back_player(players: &PlayerFrameState, team_is_team_0: bool) -> Option<PlayerId> {
1124        players
1125            .players
1126            .iter()
1127            .filter(|player| player.is_team_0 == team_is_team_0)
1128            .filter_map(|player| {
1129                player.position().map(|position| {
1130                    (
1131                        player.player_id.clone(),
1132                        normalized_y(team_is_team_0, position),
1133                    )
1134                })
1135            })
1136            .min_by(|left, right| left.1.total_cmp(&right.1))
1137            .map(|(player_id, _)| player_id)
1138    }
1139
1140    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
1141        players
1142            .players
1143            .iter()
1144            .find(|player| &player.player_id == player_id)
1145            .and_then(PlayerSample::position)
1146    }
1147
1148    fn update_last_touch_contexts(
1149        &mut self,
1150        ball: &BallFrameState,
1151        players: &PlayerFrameState,
1152        touch_events: &[TouchEvent],
1153    ) {
1154        let ball_position = ball.position().map(GoalContextPosition::from);
1155        for touch in touch_events {
1156            let Some(player_id) = touch.player.clone() else {
1157                continue;
1158            };
1159            let touch_team_most_back_player = Self::most_back_player(players, touch.team_is_team_0);
1160            let other_team_most_back_player =
1161                Self::most_back_player(players, !touch.team_is_team_0);
1162            let touch_players = self.goal_player_contexts(
1163                players,
1164                touch.team_is_team_0,
1165                touch_team_most_back_player.as_ref(),
1166                other_team_most_back_player.as_ref(),
1167            );
1168            self.last_touch_context_by_player.insert(
1169                player_id.clone(),
1170                GoalTouchContext {
1171                    time: touch.time,
1172                    frame: touch.frame,
1173                    player: player_id.clone(),
1174                    is_team_0: touch.team_is_team_0,
1175                    ball_position,
1176                    player_position: Self::player_position(players, &player_id)
1177                        .map(GoalContextPosition::from),
1178                    players: touch_players,
1179                },
1180            );
1181        }
1182    }
1183
1184    fn update_boost_leadup_samples(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
1185        let cutoff_time = frame.time - GOAL_CONTEXT_BOOST_LEADUP_SECONDS;
1186        for player in &players.players {
1187            let Some(boost_amount) = player.boost_amount.or(player.last_boost_amount) else {
1188                continue;
1189            };
1190            let samples = self
1191                .boost_leadup_samples_by_player
1192                .entry(player.player_id.clone())
1193                .or_default();
1194            samples.push_back(BoostLeadupSample {
1195                time: frame.time,
1196                boost_amount,
1197            });
1198            while samples
1199                .front()
1200                .is_some_and(|sample| sample.time < cutoff_time)
1201            {
1202                samples.pop_front();
1203            }
1204        }
1205
1206        self.boost_leadup_samples_by_player
1207            .retain(|_, samples| !samples.is_empty());
1208    }
1209
1210    fn update_ball_ground_contact(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
1211        if ball
1212            .position()
1213            .is_some_and(|position| position.z <= BALL_GROUND_CONTACT_MAX_Z)
1214        {
1215            self.last_ball_ground_contact_time = Some(frame.time);
1216        }
1217    }
1218
1219    fn ball_air_time_before_goal(&self, goal_time: f32) -> Option<f32> {
1220        self.last_ball_ground_contact_time
1221            .map(|ground_contact_time| (goal_time - ground_contact_time).max(0.0))
1222    }
1223
1224    fn boost_leadup_for_player(&self, player_id: &PlayerId) -> Option<BoostLeadupStats> {
1225        let samples = self.boost_leadup_samples_by_player.get(player_id)?;
1226        if samples.is_empty() {
1227            return None;
1228        }
1229
1230        let mut sum = 0.0;
1231        let mut min_boost = f32::INFINITY;
1232        for sample in samples {
1233            sum += sample.boost_amount;
1234            min_boost = min_boost.min(sample.boost_amount);
1235        }
1236
1237        Some(BoostLeadupStats {
1238            average_boost: sum / samples.len() as f32,
1239            min_boost,
1240        })
1241    }
1242
1243    fn goal_player_contexts(
1244        &self,
1245        players: &PlayerFrameState,
1246        scoring_team_is_team_0: bool,
1247        scoring_team_most_back_player: Option<&PlayerId>,
1248        defending_team_most_back_player: Option<&PlayerId>,
1249    ) -> Vec<GoalPlayerContext> {
1250        players
1251            .players
1252            .iter()
1253            .map(|player| {
1254                let most_back_player = if player.is_team_0 == scoring_team_is_team_0 {
1255                    scoring_team_most_back_player
1256                } else {
1257                    defending_team_most_back_player
1258                };
1259                let boost_leadup = self.boost_leadup_for_player(&player.player_id);
1260                GoalPlayerContext {
1261                    player: player.player_id.clone(),
1262                    is_team_0: player.is_team_0,
1263                    position: player.position().map(GoalContextPosition::from),
1264                    boost_amount: player.boost_amount.or(player.last_boost_amount),
1265                    average_boost_in_leadup: boost_leadup.map(|stats| stats.average_boost),
1266                    min_boost_in_leadup: boost_leadup.map(|stats| stats.min_boost),
1267                    is_most_back: most_back_player == Some(&player.player_id),
1268                }
1269            })
1270            .collect()
1271    }
1272
1273    fn record_goal_context_stats(
1274        &mut self,
1275        players: &PlayerFrameState,
1276        goal_event: &GoalEvent,
1277        scoring_team_most_back_player: Option<&PlayerId>,
1278        defending_team_most_back_player: Option<&PlayerId>,
1279    ) {
1280        if let Some(player_id) = scoring_team_most_back_player {
1281            self.player_stats
1282                .entry(player_id.clone())
1283                .or_default()
1284                .scoring_context
1285                .goals_for_while_most_back += 1;
1286        }
1287
1288        if let Some(player_id) = defending_team_most_back_player {
1289            self.player_stats
1290                .entry(player_id.clone())
1291                .or_default()
1292                .scoring_context
1293                .goals_against_while_most_back += 1;
1294        }
1295
1296        for player in players
1297            .players
1298            .iter()
1299            .filter(|player| player.is_team_0 != goal_event.scoring_team_is_team_0)
1300        {
1301            let boost_leadup = self.boost_leadup_for_player(&player.player_id);
1302            self.player_stats
1303                .entry(player.player_id.clone())
1304                .or_default()
1305                .scoring_context
1306                .record_goal_against_snapshot(
1307                    player.boost_amount.or(player.last_boost_amount),
1308                    player.position().map(GoalContextPosition::from),
1309                    boost_leadup,
1310                );
1311        }
1312    }
1313
1314    fn record_goal_context_events(
1315        &mut self,
1316        ball: &BallFrameState,
1317        players: &PlayerFrameState,
1318        events: &FrameEventsState,
1319    ) {
1320        let ball_position = ball.position().map(GoalContextPosition::from);
1321        for goal_event in &events.goal_events {
1322            let scoring_team_most_back_player =
1323                Self::most_back_player(players, goal_event.scoring_team_is_team_0);
1324            let defending_team_most_back_player =
1325                Self::most_back_player(players, !goal_event.scoring_team_is_team_0);
1326            let scorer_last_touch = goal_event
1327                .player
1328                .as_ref()
1329                .and_then(|player_id| self.last_touch_context_by_player.get(player_id))
1330                .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
1331                .cloned();
1332            let ball_air_time_before_goal = self.ball_air_time_before_goal(goal_event.time);
1333            let goal_buildup =
1334                self.classify_goal_buildup(goal_event.time, goal_event.scoring_team_is_team_0);
1335
1336            self.record_goal_context_stats(
1337                players,
1338                goal_event,
1339                scoring_team_most_back_player.as_ref(),
1340                defending_team_most_back_player.as_ref(),
1341            );
1342
1343            self.goal_context_events.push(GoalContextEvent {
1344                time: goal_event.time,
1345                frame: goal_event.frame,
1346                scoring_team_is_team_0: goal_event.scoring_team_is_team_0,
1347                scorer: goal_event.player.clone(),
1348                scoring_team_most_back_player: scoring_team_most_back_player.clone(),
1349                defending_team_most_back_player: defending_team_most_back_player.clone(),
1350                ball_position,
1351                ball_air_time_before_goal,
1352                goal_buildup,
1353                scorer_last_touch,
1354                players: self.goal_player_contexts(
1355                    players,
1356                    goal_event.scoring_team_is_team_0,
1357                    scoring_team_most_back_player.as_ref(),
1358                    defending_team_most_back_player.as_ref(),
1359                ),
1360            });
1361        }
1362    }
1363
1364    fn reconcile_goal_context_scorer(
1365        &mut self,
1366        goal_event: &GoalEvent,
1367        scorer: &PlayerId,
1368    ) -> Option<GoalTouchContext> {
1369        let scorer_last_touch = self
1370            .last_touch_context_by_player
1371            .get(scorer)
1372            .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
1373            .cloned();
1374        if let Some(context) = self.goal_context_events.iter_mut().rev().find(|context| {
1375            context.frame == goal_event.frame
1376                && context.time == goal_event.time
1377                && context.scoring_team_is_team_0 == goal_event.scoring_team_is_team_0
1378                && context.scorer.as_ref() != Some(scorer)
1379        }) {
1380            context.scorer = Some(scorer.clone());
1381            context.scorer_last_touch = scorer_last_touch.clone();
1382        }
1383        scorer_last_touch
1384    }
1385
1386    fn prune_goal_buildup_samples(&mut self, current_time: f32) {
1387        self.goal_buildup_samples
1388            .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1389        self.goal_buildup_pressure_events
1390            .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1391    }
1392
1393    fn record_goal_buildup_sample(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
1394        let Some(ball) = ball.sample() else {
1395            return;
1396        };
1397        if frame.dt <= 0.0 {
1398            return;
1399        }
1400        self.goal_buildup_samples.push(GoalBuildupSample {
1401            time: frame.time,
1402            dt: frame.dt,
1403            ball_y: ball.position().y,
1404        });
1405    }
1406
1407    fn record_goal_buildup_pressure_events(&mut self, events: &FrameEventsState) {
1408        self.goal_buildup_pressure_events.extend(
1409            events
1410                .player_stat_events
1411                .iter()
1412                .filter(|event| event.kind == PlayerStatEventKind::Shot)
1413                .map(|event| GoalBuildupPressureEvent {
1414                    time: event.time,
1415                    is_team_0: event.is_team_0,
1416                }),
1417        );
1418    }
1419
1420    fn classify_goal_buildup(
1421        &self,
1422        goal_time: f32,
1423        scoring_team_is_team_0: bool,
1424    ) -> GoalBuildupKind {
1425        let relevant_samples: Vec<_> = self
1426            .goal_buildup_samples
1427            .iter()
1428            .filter(|entry| entry.time <= goal_time)
1429            .filter(|entry| goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS)
1430            .collect();
1431        if relevant_samples.is_empty() {
1432            return GoalBuildupKind::Other;
1433        }
1434
1435        let mut defensive_half_time = 0.0;
1436        let mut defensive_third_time = 0.0;
1437        let mut offensive_half_time = 0.0;
1438        let mut offensive_third_time = 0.0;
1439        let mut current_attack_time = 0.0;
1440
1441        for entry in &relevant_samples {
1442            let normalized_ball_y = if scoring_team_is_team_0 {
1443                entry.ball_y
1444            } else {
1445                -entry.ball_y
1446            };
1447            if normalized_ball_y < 0.0 {
1448                defensive_half_time += entry.dt;
1449            } else {
1450                offensive_half_time += entry.dt;
1451            }
1452            if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
1453                defensive_third_time += entry.dt;
1454            }
1455            if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
1456                offensive_third_time += entry.dt;
1457            }
1458        }
1459
1460        for entry in relevant_samples.iter().rev() {
1461            let normalized_ball_y = if scoring_team_is_team_0 {
1462                entry.ball_y
1463            } else {
1464                -entry.ball_y
1465            };
1466            if normalized_ball_y > 0.0 {
1467                current_attack_time += entry.dt;
1468            } else {
1469                break;
1470            }
1471        }
1472
1473        let opponent_shot_in_lookback = self.goal_buildup_pressure_events.iter().any(|entry| {
1474            entry.time <= goal_time
1475                && goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS
1476                && entry.is_team_0 != scoring_team_is_team_0
1477        });
1478        let has_defensive_pressure_signal = defensive_half_time
1479            >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
1480            || defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
1481            || opponent_shot_in_lookback;
1482
1483        if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS && has_defensive_pressure_signal
1484        {
1485            GoalBuildupKind::CounterAttack
1486        } else if current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
1487            && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
1488            && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
1489        {
1490            GoalBuildupKind::SustainedPressure
1491        } else {
1492            GoalBuildupKind::Other
1493        }
1494    }
1495}
1496
1497impl MatchStatsCalculator {
1498    #[allow(clippy::too_many_arguments)]
1499    pub fn update_parts(
1500        &mut self,
1501        frame: &FrameInfo,
1502        gameplay: &GameplayState,
1503        ball: &BallFrameState,
1504        players: &PlayerFrameState,
1505        events: &FrameEventsState,
1506        live_play_state: &LivePlayState,
1507        touch_state: &TouchState,
1508    ) -> SubtrActorResult<()> {
1509        self.update_kickoff_reference(gameplay, events);
1510        self.prune_goal_buildup_samples(frame.time);
1511        self.update_ball_ground_contact(frame, ball);
1512        if live_play_state.is_live_play {
1513            self.record_goal_buildup_sample(frame, ball);
1514            self.record_goal_buildup_pressure_events(events);
1515            self.update_boost_leadup_samples(frame, players);
1516        } else if events.goal_events.is_empty() {
1517            self.last_touch_context_by_player.clear();
1518            self.boost_leadup_samples_by_player.clear();
1519            self.last_ball_ground_contact_time = None;
1520        }
1521        self.update_last_touch_contexts(ball, players, &touch_state.touch_events);
1522        self.record_goal_context_events(ball, players, events);
1523        let pending_goal_events: Vec<_> = events
1524            .goal_events
1525            .iter()
1526            .cloned()
1527            .map(|event| PendingGoalEvent {
1528                time_after_kickoff: self
1529                    .active_kickoff_touch_time
1530                    .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
1531                goal_buildup: self.classify_goal_buildup(event.time, event.scoring_team_is_team_0),
1532                ball_air_time_before_goal: self.ball_air_time_before_goal(event.time),
1533                event,
1534            })
1535            .collect();
1536        self.pending_goal_events.extend(pending_goal_events);
1537        let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
1538            HashMap::new();
1539        for event in &events.player_stat_events {
1540            let kind = match event.kind {
1541                PlayerStatEventKind::Shot => TimelineEventKind::Shot,
1542                PlayerStatEventKind::Save => TimelineEventKind::Save,
1543                PlayerStatEventKind::Assist => TimelineEventKind::Assist,
1544            };
1545            self.timeline.push(TimelineEvent {
1546                time: event.time,
1547                frame: Some(event.frame),
1548                kind,
1549                player_id: Some(event.player.clone()),
1550                is_team_0: Some(event.is_team_0),
1551            });
1552            *processor_event_counts
1553                .entry((event.player.clone(), kind))
1554                .or_default() += 1;
1555        }
1556
1557        for player in &players.players {
1558            self.player_teams
1559                .insert(player.player_id.clone(), player.is_team_0);
1560            let mut current_stats = CorePlayerStats {
1561                score: player.match_score.unwrap_or(0),
1562                goals: player.match_goals.unwrap_or(0),
1563                assists: player.match_assists.unwrap_or(0),
1564                saves: player.match_saves.unwrap_or(0),
1565                shots: player.match_shots.unwrap_or(0),
1566                scoring_context: self
1567                    .player_stats
1568                    .get(&player.player_id)
1569                    .map(|stats| stats.scoring_context.clone())
1570                    .unwrap_or_default(),
1571            };
1572
1573            let previous_stats = self
1574                .previous_player_stats
1575                .get(&player.player_id)
1576                .cloned()
1577                .unwrap_or_default();
1578
1579            let shot_delta = current_stats.shots - previous_stats.shots;
1580            let save_delta = current_stats.saves - previous_stats.saves;
1581            let assist_delta = current_stats.assists - previous_stats.assists;
1582            let goal_delta = current_stats.goals - previous_stats.goals;
1583            let shot_fallback_delta = shot_delta
1584                - processor_event_counts
1585                    .get(&(player.player_id.clone(), TimelineEventKind::Shot))
1586                    .copied()
1587                    .unwrap_or(0);
1588            let save_fallback_delta = save_delta
1589                - processor_event_counts
1590                    .get(&(player.player_id.clone(), TimelineEventKind::Save))
1591                    .copied()
1592                    .unwrap_or(0);
1593            let assist_fallback_delta = assist_delta
1594                - processor_event_counts
1595                    .get(&(player.player_id.clone(), TimelineEventKind::Assist))
1596                    .copied()
1597                    .unwrap_or(0);
1598
1599            if shot_fallback_delta > 0 {
1600                self.emit_timeline_events(
1601                    frame.time,
1602                    Some(frame.frame_number),
1603                    TimelineEventKind::Shot,
1604                    &player.player_id,
1605                    player.is_team_0,
1606                    shot_fallback_delta,
1607                );
1608            }
1609            if save_fallback_delta > 0 {
1610                self.emit_timeline_events(
1611                    frame.time,
1612                    Some(frame.frame_number),
1613                    TimelineEventKind::Save,
1614                    &player.player_id,
1615                    player.is_team_0,
1616                    save_fallback_delta,
1617                );
1618            }
1619            if assist_fallback_delta > 0 {
1620                self.emit_timeline_events(
1621                    frame.time,
1622                    Some(frame.frame_number),
1623                    TimelineEventKind::Assist,
1624                    &player.player_id,
1625                    player.is_team_0,
1626                    assist_fallback_delta,
1627                );
1628            }
1629            if goal_delta > 0 {
1630                for _ in 0..goal_delta.max(0) {
1631                    let pending_goal_event =
1632                        self.take_pending_goal_event(&player.player_id, player.is_team_0);
1633                    if let Some(pending_goal_event) = pending_goal_event.as_ref() {
1634                        let scorer_last_touch = self.reconcile_goal_context_scorer(
1635                            &pending_goal_event.event,
1636                            &player.player_id,
1637                        );
1638                        if let Some(touch_position) =
1639                            scorer_last_touch.and_then(|touch| touch.ball_position)
1640                        {
1641                            current_stats
1642                                .scoring_context
1643                                .record_scoring_goal_last_touch_position(touch_position);
1644                        }
1645                        if let Some(ball_air_time_before_goal) =
1646                            pending_goal_event.ball_air_time_before_goal
1647                        {
1648                            current_stats
1649                                .scoring_context
1650                                .record_goal_ball_air_time(ball_air_time_before_goal);
1651                        }
1652                    }
1653                    let goal_time = pending_goal_event
1654                        .as_ref()
1655                        .map(|event| event.event.time)
1656                        .unwrap_or(frame.time);
1657                    let goal_buildup = pending_goal_event
1658                        .as_ref()
1659                        .map(|event| event.goal_buildup)
1660                        .unwrap_or_else(|| self.classify_goal_buildup(goal_time, player.is_team_0));
1661                    let goal_frame = pending_goal_event
1662                        .as_ref()
1663                        .map(|event| event.event.frame)
1664                        .unwrap_or(frame.frame_number);
1665                    let time_after_kickoff = pending_goal_event
1666                        .and_then(|event| event.time_after_kickoff)
1667                        .or_else(|| {
1668                            self.active_kickoff_touch_time
1669                                .map(|kickoff_touch_time| (goal_time - kickoff_touch_time).max(0.0))
1670                        });
1671                    if let Some(time_after_kickoff) = time_after_kickoff {
1672                        current_stats
1673                            .scoring_context
1674                            .goal_after_kickoff
1675                            .record_goal(time_after_kickoff);
1676                    }
1677                    current_stats
1678                        .scoring_context
1679                        .goal_buildup
1680                        .record(goal_buildup);
1681                    self.timeline.push(TimelineEvent {
1682                        time: goal_time,
1683                        frame: Some(goal_frame),
1684                        kind: TimelineEventKind::Goal,
1685                        player_id: Some(player.player_id.clone()),
1686                        is_team_0: Some(player.is_team_0),
1687                    });
1688                }
1689            }
1690
1691            self.previous_player_stats
1692                .insert(player.player_id.clone(), current_stats.clone());
1693            self.player_stats
1694                .insert(player.player_id.clone(), current_stats);
1695        }
1696
1697        if let (Some(team_zero_score), Some(team_one_score)) =
1698            (gameplay.team_zero_score, gameplay.team_one_score)
1699        {
1700            if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
1701                let team_zero_delta = team_zero_score - prev_team_zero_score;
1702                let team_one_delta = team_one_score - prev_team_one_score;
1703
1704                if team_zero_delta > 0 {
1705                    if let Some(last_defender) = self.last_defender(players, false) {
1706                        if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1707                            stats.scoring_context.goals_conceded_while_last_defender +=
1708                                team_zero_delta as u32;
1709                        }
1710                    }
1711                }
1712
1713                if team_one_delta > 0 {
1714                    if let Some(last_defender) = self.last_defender(players, true) {
1715                        if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1716                            stats.scoring_context.goals_conceded_while_last_defender +=
1717                                team_one_delta as u32;
1718                        }
1719                    }
1720                }
1721            }
1722
1723            self.previous_team_scores = Some((team_zero_score, team_one_score));
1724        }
1725
1726        self.timeline.sort_by(|a, b| {
1727            a.time
1728                .partial_cmp(&b.time)
1729                .unwrap_or(std::cmp::Ordering::Equal)
1730        });
1731        self.emit_core_stats_events(frame);
1732
1733        Ok(())
1734    }
1735}
1736
1737#[cfg(test)]
1738#[path = "match_stats_tests.rs"]
1739mod tests;