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