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