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;
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
14#[ts(export)]
15pub struct GoalAfterKickoffStats {
16 pub kickoff_goal_count: u32,
17 pub short_goal_count: u32,
18 pub medium_goal_count: u32,
19 pub long_goal_count: u32,
20 #[serde(default, skip_serializing)]
21 goal_times: Vec<f32>,
22}
23
24impl GoalAfterKickoffStats {
25 pub fn goal_times(&self) -> &[f32] {
26 &self.goal_times
27 }
28
29 pub fn record_goal(&mut self, time_after_kickoff: f32) {
30 let clamped_time = time_after_kickoff.max(0.0);
31 self.goal_times.push(clamped_time);
32 if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS {
33 self.kickoff_goal_count += 1;
34 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS {
35 self.short_goal_count += 1;
36 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS {
37 self.medium_goal_count += 1;
38 } else {
39 self.long_goal_count += 1;
40 }
41 }
42
43 pub fn average_goal_time_after_kickoff(&self) -> f32 {
44 if self.goal_times.is_empty() {
45 0.0
46 } else {
47 self.goal_times.iter().sum::<f32>() / self.goal_times.len() as f32
48 }
49 }
50
51 pub fn median_goal_time_after_kickoff(&self) -> f32 {
52 if self.goal_times.is_empty() {
53 return 0.0;
54 }
55
56 let mut sorted_times = self.goal_times.clone();
57 sorted_times.sort_by(|a, b| a.total_cmp(b));
58 let midpoint = sorted_times.len() / 2;
59 if sorted_times.len().is_multiple_of(2) {
60 (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
61 } else {
62 sorted_times[midpoint]
63 }
64 }
65
66 fn merge(&mut self, other: &Self) {
67 self.kickoff_goal_count += other.kickoff_goal_count;
68 self.short_goal_count += other.short_goal_count;
69 self.medium_goal_count += other.medium_goal_count;
70 self.long_goal_count += other.long_goal_count;
71 self.goal_times.extend(other.goal_times.iter().copied());
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum GoalBuildupKind {
77 CounterAttack,
78 SustainedPressure,
79 Other,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
83#[ts(export)]
84pub struct GoalBuildupStats {
85 pub counter_attack_goal_count: u32,
86 pub sustained_pressure_goal_count: u32,
87 pub other_buildup_goal_count: u32,
88}
89
90impl GoalBuildupStats {
91 fn record(&mut self, kind: GoalBuildupKind) {
92 match kind {
93 GoalBuildupKind::CounterAttack => self.counter_attack_goal_count += 1,
94 GoalBuildupKind::SustainedPressure => self.sustained_pressure_goal_count += 1,
95 GoalBuildupKind::Other => self.other_buildup_goal_count += 1,
96 }
97 }
98
99 fn merge(&mut self, other: &Self) {
100 self.counter_attack_goal_count += other.counter_attack_goal_count;
101 self.sustained_pressure_goal_count += other.sustained_pressure_goal_count;
102 self.other_buildup_goal_count += other.other_buildup_goal_count;
103 }
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
107#[ts(export)]
108pub struct PlayerScoringContextStats {
109 pub goals_conceded_while_last_defender: u32,
110 #[serde(flatten)]
111 pub goal_after_kickoff: GoalAfterKickoffStats,
112 #[serde(flatten)]
113 pub goal_buildup: GoalBuildupStats,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
117#[ts(export)]
118pub struct CorePlayerStats {
119 pub score: i32,
120 pub goals: i32,
121 pub assists: i32,
122 pub saves: i32,
123 pub shots: i32,
124 #[serde(flatten)]
125 pub scoring_context: PlayerScoringContextStats,
126}
127
128impl CorePlayerStats {
129 pub fn shooting_percentage(&self) -> f32 {
130 if self.shots == 0 {
131 0.0
132 } else {
133 self.goals as f32 * 100.0 / self.shots as f32
134 }
135 }
136
137 pub fn average_goal_time_after_kickoff(&self) -> f32 {
138 self.scoring_context
139 .goal_after_kickoff
140 .average_goal_time_after_kickoff()
141 }
142
143 pub fn median_goal_time_after_kickoff(&self) -> f32 {
144 self.scoring_context
145 .goal_after_kickoff
146 .median_goal_time_after_kickoff()
147 }
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
151#[ts(export)]
152pub struct TeamScoringContextStats {
153 #[serde(flatten)]
154 pub goal_after_kickoff: GoalAfterKickoffStats,
155 #[serde(flatten)]
156 pub goal_buildup: GoalBuildupStats,
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
160#[ts(export)]
161pub struct CoreTeamStats {
162 pub score: i32,
163 pub goals: i32,
164 pub assists: i32,
165 pub saves: i32,
166 pub shots: i32,
167 #[serde(flatten)]
168 pub scoring_context: TeamScoringContextStats,
169}
170
171impl CoreTeamStats {
172 pub fn shooting_percentage(&self) -> f32 {
173 if self.shots == 0 {
174 0.0
175 } else {
176 self.goals as f32 * 100.0 / self.shots as f32
177 }
178 }
179
180 pub fn average_goal_time_after_kickoff(&self) -> f32 {
181 self.scoring_context
182 .goal_after_kickoff
183 .average_goal_time_after_kickoff()
184 }
185
186 pub fn median_goal_time_after_kickoff(&self) -> f32 {
187 self.scoring_context
188 .goal_after_kickoff
189 .median_goal_time_after_kickoff()
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ts_rs::TS)]
194#[ts(export)]
195pub enum TimelineEventKind {
196 Goal,
197 Shot,
198 Save,
199 Assist,
200 Kill,
201 Death,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
205#[ts(export)]
206pub struct TimelineEvent {
207 pub time: f32,
208 pub kind: TimelineEventKind,
209 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
210 pub player_id: Option<PlayerId>,
211 pub is_team_0: Option<bool>,
212}
213
214#[derive(Debug, Clone)]
215struct PendingGoalEvent {
216 event: GoalEvent,
217 time_after_kickoff: Option<f32>,
218}
219
220#[derive(Debug, Clone)]
221struct GoalBuildupSample {
222 time: f32,
223 dt: f32,
224 ball_y: f32,
225}
226
227#[derive(Debug, Clone, Default)]
228pub struct MatchStatsCalculator {
229 player_stats: HashMap<PlayerId, CorePlayerStats>,
230 player_teams: HashMap<PlayerId, bool>,
231 previous_player_stats: HashMap<PlayerId, CorePlayerStats>,
232 timeline: Vec<TimelineEvent>,
233 pending_goal_events: Vec<PendingGoalEvent>,
234 previous_team_scores: Option<(i32, i32)>,
235 kickoff_waiting_for_first_touch: bool,
236 active_kickoff_touch_time: Option<f32>,
237 goal_buildup_samples: Vec<GoalBuildupSample>,
238}
239
240impl MatchStatsCalculator {
241 pub fn new() -> Self {
242 Self::default()
243 }
244
245 pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
246 &self.player_stats
247 }
248
249 pub fn timeline(&self) -> &[TimelineEvent] {
250 &self.timeline
251 }
252
253 pub fn team_zero_stats(&self) -> CoreTeamStats {
254 self.team_stats_for_side(true)
255 }
256
257 pub fn team_one_stats(&self) -> CoreTeamStats {
258 self.team_stats_for_side(false)
259 }
260
261 fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
262 let mut stats = self
263 .player_stats
264 .iter()
265 .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
266 .fold(CoreTeamStats::default(), |mut stats, (_, player_stats)| {
267 stats.score += player_stats.score;
268 stats.goals += player_stats.goals;
269 stats.assists += player_stats.assists;
270 stats.saves += player_stats.saves;
271 stats.shots += player_stats.shots;
272 stats
273 .scoring_context
274 .goal_after_kickoff
275 .merge(&player_stats.scoring_context.goal_after_kickoff);
276 stats
277 .scoring_context
278 .goal_buildup
279 .merge(&player_stats.scoring_context.goal_buildup);
280 stats
281 });
282 stats
283 .scoring_context
284 .goal_after_kickoff
285 .goal_times
286 .sort_by(|left, right| left.total_cmp(right));
287 stats
288 }
289
290 fn emit_timeline_events(
291 &mut self,
292 time: f32,
293 kind: TimelineEventKind,
294 player_id: &PlayerId,
295 is_team_0: bool,
296 delta: i32,
297 ) {
298 for _ in 0..delta.max(0) {
299 self.timeline.push(TimelineEvent {
300 time,
301 kind,
302 player_id: Some(player_id.clone()),
303 is_team_0: Some(is_team_0),
304 });
305 }
306 }
307
308 fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
309 gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
310 || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
311 || gameplay.ball_has_been_hit == Some(false)
312 }
313
314 fn update_kickoff_reference(&mut self, gameplay: &GameplayState, events: &FrameEventsState) {
315 if let Some(first_touch_time) = events
316 .touch_events
317 .iter()
318 .map(|event| event.time)
319 .min_by(|a, b| a.total_cmp(b))
320 {
321 self.active_kickoff_touch_time = Some(first_touch_time);
322 self.kickoff_waiting_for_first_touch = false;
323 return;
324 }
325
326 if Self::kickoff_phase_active(gameplay) {
327 self.kickoff_waiting_for_first_touch = true;
328 self.active_kickoff_touch_time = None;
329 }
330 }
331
332 fn take_pending_goal_event(
333 &mut self,
334 player_id: &PlayerId,
335 is_team_0: bool,
336 ) -> Option<PendingGoalEvent> {
337 if let Some(index) = self.pending_goal_events.iter().position(|event| {
338 event.event.scoring_team_is_team_0 == is_team_0
339 && event.event.player.as_ref() == Some(player_id)
340 }) {
341 return Some(self.pending_goal_events.remove(index));
342 }
343
344 self.pending_goal_events
345 .iter()
346 .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
347 .map(|index| self.pending_goal_events.remove(index))
348 }
349
350 fn last_defender(
351 &self,
352 players: &PlayerFrameState,
353 defending_team_is_team_0: bool,
354 ) -> Option<PlayerId> {
355 players
356 .players
357 .iter()
358 .filter(|player| player.is_team_0 == defending_team_is_team_0)
359 .filter_map(|player| {
360 player
361 .position()
362 .map(|position| (player.player_id.clone(), position.y))
363 })
364 .reduce(|current, candidate| {
365 if defending_team_is_team_0 {
366 if candidate.1 < current.1 {
367 candidate
368 } else {
369 current
370 }
371 } else if candidate.1 > current.1 {
372 candidate
373 } else {
374 current
375 }
376 })
377 .map(|(player_id, _)| player_id)
378 }
379
380 fn prune_goal_buildup_samples(&mut self, current_time: f32) {
381 self.goal_buildup_samples
382 .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
383 }
384
385 fn record_goal_buildup_sample(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
386 let Some(ball) = ball.sample() else {
387 return;
388 };
389 if frame.dt <= 0.0 {
390 return;
391 }
392 self.goal_buildup_samples.push(GoalBuildupSample {
393 time: frame.time,
394 dt: frame.dt,
395 ball_y: ball.position().y,
396 });
397 }
398
399 fn classify_goal_buildup(
400 &self,
401 goal_time: f32,
402 scoring_team_is_team_0: bool,
403 ) -> GoalBuildupKind {
404 let relevant_samples: Vec<_> = self
405 .goal_buildup_samples
406 .iter()
407 .filter(|entry| goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS)
408 .collect();
409 if relevant_samples.is_empty() {
410 return GoalBuildupKind::Other;
411 }
412
413 let mut defensive_half_time = 0.0;
414 let mut defensive_third_time = 0.0;
415 let mut offensive_half_time = 0.0;
416 let mut offensive_third_time = 0.0;
417 let mut current_attack_time = 0.0;
418
419 for entry in &relevant_samples {
420 let normalized_ball_y = if scoring_team_is_team_0 {
421 entry.ball_y
422 } else {
423 -entry.ball_y
424 };
425 if normalized_ball_y < 0.0 {
426 defensive_half_time += entry.dt;
427 } else {
428 offensive_half_time += entry.dt;
429 }
430 if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
431 defensive_third_time += entry.dt;
432 }
433 if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
434 offensive_third_time += entry.dt;
435 }
436 }
437
438 for entry in relevant_samples.iter().rev() {
439 let normalized_ball_y = if scoring_team_is_team_0 {
440 entry.ball_y
441 } else {
442 -entry.ball_y
443 };
444 if normalized_ball_y > 0.0 {
445 current_attack_time += entry.dt;
446 } else {
447 break;
448 }
449 }
450
451 if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS
452 && defensive_half_time >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
453 && defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
454 {
455 GoalBuildupKind::CounterAttack
456 } else if current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
457 && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
458 && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
459 {
460 GoalBuildupKind::SustainedPressure
461 } else {
462 GoalBuildupKind::Other
463 }
464 }
465}
466
467impl MatchStatsCalculator {
468 pub fn update_parts(
469 &mut self,
470 frame: &FrameInfo,
471 gameplay: &GameplayState,
472 ball: &BallFrameState,
473 players: &PlayerFrameState,
474 events: &FrameEventsState,
475 live_play_state: &LivePlayState,
476 ) -> SubtrActorResult<()> {
477 self.update_kickoff_reference(gameplay, events);
478 self.prune_goal_buildup_samples(frame.time);
479 if live_play_state.is_live_play {
480 self.record_goal_buildup_sample(frame, ball);
481 }
482 self.pending_goal_events
483 .extend(events.goal_events.iter().cloned().map(|event| {
484 PendingGoalEvent {
485 time_after_kickoff: self
486 .active_kickoff_touch_time
487 .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
488 event,
489 }
490 }));
491 let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
492 HashMap::new();
493 for event in &events.player_stat_events {
494 let kind = match event.kind {
495 PlayerStatEventKind::Shot => TimelineEventKind::Shot,
496 PlayerStatEventKind::Save => TimelineEventKind::Save,
497 PlayerStatEventKind::Assist => TimelineEventKind::Assist,
498 };
499 self.timeline.push(TimelineEvent {
500 time: event.time,
501 kind,
502 player_id: Some(event.player.clone()),
503 is_team_0: Some(event.is_team_0),
504 });
505 *processor_event_counts
506 .entry((event.player.clone(), kind))
507 .or_default() += 1;
508 }
509
510 for player in &players.players {
511 self.player_teams
512 .insert(player.player_id.clone(), player.is_team_0);
513 let mut current_stats = CorePlayerStats {
514 score: player.match_score.unwrap_or(0),
515 goals: player.match_goals.unwrap_or(0),
516 assists: player.match_assists.unwrap_or(0),
517 saves: player.match_saves.unwrap_or(0),
518 shots: player.match_shots.unwrap_or(0),
519 scoring_context: self
520 .player_stats
521 .get(&player.player_id)
522 .map(|stats| stats.scoring_context.clone())
523 .unwrap_or_default(),
524 };
525
526 let previous_stats = self
527 .previous_player_stats
528 .get(&player.player_id)
529 .cloned()
530 .unwrap_or_default();
531
532 let shot_delta = current_stats.shots - previous_stats.shots;
533 let save_delta = current_stats.saves - previous_stats.saves;
534 let assist_delta = current_stats.assists - previous_stats.assists;
535 let goal_delta = current_stats.goals - previous_stats.goals;
536 let shot_fallback_delta = shot_delta
537 - processor_event_counts
538 .get(&(player.player_id.clone(), TimelineEventKind::Shot))
539 .copied()
540 .unwrap_or(0);
541 let save_fallback_delta = save_delta
542 - processor_event_counts
543 .get(&(player.player_id.clone(), TimelineEventKind::Save))
544 .copied()
545 .unwrap_or(0);
546 let assist_fallback_delta = assist_delta
547 - processor_event_counts
548 .get(&(player.player_id.clone(), TimelineEventKind::Assist))
549 .copied()
550 .unwrap_or(0);
551
552 if shot_fallback_delta > 0 {
553 self.emit_timeline_events(
554 frame.time,
555 TimelineEventKind::Shot,
556 &player.player_id,
557 player.is_team_0,
558 shot_fallback_delta,
559 );
560 }
561 if save_fallback_delta > 0 {
562 self.emit_timeline_events(
563 frame.time,
564 TimelineEventKind::Save,
565 &player.player_id,
566 player.is_team_0,
567 save_fallback_delta,
568 );
569 }
570 if assist_fallback_delta > 0 {
571 self.emit_timeline_events(
572 frame.time,
573 TimelineEventKind::Assist,
574 &player.player_id,
575 player.is_team_0,
576 assist_fallback_delta,
577 );
578 }
579 if goal_delta > 0 {
580 for _ in 0..goal_delta.max(0) {
581 let pending_goal_event =
582 self.take_pending_goal_event(&player.player_id, player.is_team_0);
583 let goal_time = pending_goal_event
584 .as_ref()
585 .map(|event| event.event.time)
586 .unwrap_or(frame.time);
587 let time_after_kickoff = pending_goal_event
588 .and_then(|event| event.time_after_kickoff)
589 .or_else(|| {
590 self.active_kickoff_touch_time
591 .map(|kickoff_touch_time| (goal_time - kickoff_touch_time).max(0.0))
592 });
593 if let Some(time_after_kickoff) = time_after_kickoff {
594 current_stats
595 .scoring_context
596 .goal_after_kickoff
597 .record_goal(time_after_kickoff);
598 }
599 current_stats
600 .scoring_context
601 .goal_buildup
602 .record(self.classify_goal_buildup(goal_time, player.is_team_0));
603 self.timeline.push(TimelineEvent {
604 time: goal_time,
605 kind: TimelineEventKind::Goal,
606 player_id: Some(player.player_id.clone()),
607 is_team_0: Some(player.is_team_0),
608 });
609 }
610 }
611
612 self.previous_player_stats
613 .insert(player.player_id.clone(), current_stats.clone());
614 self.player_stats
615 .insert(player.player_id.clone(), current_stats);
616 }
617
618 if let (Some(team_zero_score), Some(team_one_score)) =
619 (gameplay.team_zero_score, gameplay.team_one_score)
620 {
621 if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
622 let team_zero_delta = team_zero_score - prev_team_zero_score;
623 let team_one_delta = team_one_score - prev_team_one_score;
624
625 if team_zero_delta > 0 {
626 if let Some(last_defender) = self.last_defender(players, false) {
627 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
628 stats.scoring_context.goals_conceded_while_last_defender +=
629 team_zero_delta as u32;
630 }
631 }
632 }
633
634 if team_one_delta > 0 {
635 if let Some(last_defender) = self.last_defender(players, true) {
636 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
637 stats.scoring_context.goals_conceded_while_last_defender +=
638 team_one_delta as u32;
639 }
640 }
641 }
642 }
643
644 self.previous_team_scores = Some((team_zero_score, team_one_score));
645 }
646
647 self.timeline.sort_by(|a, b| {
648 a.time
649 .partial_cmp(&b.time)
650 .unwrap_or(std::cmp::Ordering::Equal)
651 });
652
653 Ok(())
654 }
655}