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