1use super::*;
2
3const GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS: f32 = 10.0;
4const GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS: f32 = 20.0;
5const GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS: f32 = 40.0;
6const GOAL_BUILDUP_LOOKBACK_SECONDS: f32 = 12.0;
7const COUNTER_ATTACK_MAX_ATTACK_SECONDS: f32 = 4.0;
8const COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS: f32 = 4.0;
9const COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS: f32 = 1.0;
10const SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS: f32 = 6.0;
11const SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS: f32 = 7.0;
12const SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS: f32 = 3.5;
13const GOAL_CONTEXT_BOOST_LEADUP_SECONDS: f32 = 5.0;
14const BALL_GROUND_CONTACT_MAX_Z: f32 = BALL_RADIUS_Z + 5.0;
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
16#[ts(export)]
17pub struct GoalAfterKickoffStats {
18 pub kickoff_goal_count: u32,
19 pub short_goal_count: u32,
20 pub medium_goal_count: u32,
21 pub long_goal_count: u32,
22 #[serde(default, skip_serializing)]
23 goal_times: Vec<f32>,
24}
25
26impl GoalAfterKickoffStats {
27 pub fn goal_times(&self) -> &[f32] {
28 &self.goal_times
29 }
30
31 pub fn record_goal(&mut self, time_after_kickoff: f32) {
32 let clamped_time = time_after_kickoff.max(0.0);
33 self.goal_times.push(clamped_time);
34 self.goal_times.sort_by(|left, right| left.total_cmp(right));
35 if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS {
36 self.kickoff_goal_count += 1;
37 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS {
38 self.short_goal_count += 1;
39 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS {
40 self.medium_goal_count += 1;
41 } else {
42 self.long_goal_count += 1;
43 }
44 }
45
46 pub fn average_goal_time_after_kickoff(&self) -> f32 {
47 if self.goal_times.is_empty() {
48 0.0
49 } else {
50 self.goal_times.iter().sum::<f32>() / self.goal_times.len() as f32
51 }
52 }
53
54 pub fn median_goal_time_after_kickoff(&self) -> f32 {
55 if self.goal_times.is_empty() {
56 return 0.0;
57 }
58
59 let mut sorted_times = self.goal_times.clone();
60 sorted_times.sort_by(|a, b| a.total_cmp(b));
61 let midpoint = sorted_times.len() / 2;
62 if sorted_times.len().is_multiple_of(2) {
63 (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
64 } else {
65 sorted_times[midpoint]
66 }
67 }
68
69 fn merge(&mut self, other: &Self) {
70 self.kickoff_goal_count += other.kickoff_goal_count;
71 self.short_goal_count += other.short_goal_count;
72 self.medium_goal_count += other.medium_goal_count;
73 self.long_goal_count += other.long_goal_count;
74 self.goal_times.extend(other.goal_times.iter().copied());
75 }
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
79#[ts(export)]
80pub struct GoalBallAirTimeStats {
81 pub goal_ball_air_time_sample_count: u32,
82 pub cumulative_goal_ball_air_time: f32,
83 pub last_goal_ball_air_time: Option<f32>,
84 #[serde(default, skip_serializing)]
85 goal_ball_air_times: Vec<f32>,
86}
87
88impl GoalBallAirTimeStats {
89 pub fn goal_ball_air_times(&self) -> &[f32] {
90 &self.goal_ball_air_times
91 }
92
93 pub fn record_goal(&mut self, ball_air_time: f32) {
94 let clamped_time = ball_air_time.max(0.0);
95 self.goal_ball_air_time_sample_count += 1;
96 self.cumulative_goal_ball_air_time += clamped_time;
97 self.last_goal_ball_air_time = Some(clamped_time);
98 self.goal_ball_air_times.push(clamped_time);
99 self.goal_ball_air_times
100 .sort_by(|left, right| left.total_cmp(right));
101 }
102
103 pub fn average_goal_ball_air_time(&self) -> f32 {
104 if self.goal_ball_air_time_sample_count == 0 {
105 0.0
106 } else {
107 self.cumulative_goal_ball_air_time / self.goal_ball_air_time_sample_count as f32
108 }
109 }
110
111 pub fn median_goal_ball_air_time(&self) -> f32 {
112 if self.goal_ball_air_times.is_empty() {
113 return 0.0;
114 }
115
116 let mut sorted_times = self.goal_ball_air_times.clone();
117 sorted_times.sort_by(|a, b| a.total_cmp(b));
118 let midpoint = sorted_times.len() / 2;
119 if sorted_times.len().is_multiple_of(2) {
120 (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
121 } else {
122 sorted_times[midpoint]
123 }
124 }
125
126 fn merge(&mut self, other: &Self) {
127 self.goal_ball_air_time_sample_count += other.goal_ball_air_time_sample_count;
128 self.cumulative_goal_ball_air_time += other.cumulative_goal_ball_air_time;
129 self.last_goal_ball_air_time = other
130 .last_goal_ball_air_time
131 .or(self.last_goal_ball_air_time);
132 self.goal_ball_air_times
133 .extend(other.goal_ball_air_times.iter().copied());
134 }
135}
136
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
138#[serde(rename_all = "snake_case")]
139#[ts(export)]
140pub enum GoalBuildupKind {
141 CounterAttack,
142 SustainedPressure,
143 #[default]
144 Other,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
148#[ts(export)]
149pub struct GoalBuildupStats {
150 pub counter_attack_goal_count: u32,
151 pub sustained_pressure_goal_count: u32,
152 pub other_buildup_goal_count: u32,
153}
154
155impl GoalBuildupStats {
156 fn record(&mut self, kind: GoalBuildupKind) {
157 match kind {
158 GoalBuildupKind::CounterAttack => self.counter_attack_goal_count += 1,
159 GoalBuildupKind::SustainedPressure => self.sustained_pressure_goal_count += 1,
160 GoalBuildupKind::Other => self.other_buildup_goal_count += 1,
161 }
162 }
163
164 fn merge(&mut self, other: &Self) {
165 self.counter_attack_goal_count += other.counter_attack_goal_count;
166 self.sustained_pressure_goal_count += other.sustained_pressure_goal_count;
167 self.other_buildup_goal_count += other.other_buildup_goal_count;
168 }
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
172#[ts(export)]
173pub struct PlayerScoringContextStats {
174 pub goals_conceded_while_last_defender: u32,
175 pub goals_for_while_most_back: u32,
176 pub goals_against_while_most_back: u32,
177 pub goal_against_boost_sample_count: u32,
178 pub cumulative_boost_on_goals_against: f32,
179 pub last_boost_on_goal_against: Option<f32>,
180 pub goal_against_boost_leadup_sample_count: u32,
181 pub cumulative_average_boost_in_goal_against_leadup: f32,
182 pub cumulative_min_boost_in_goal_against_leadup: f32,
183 pub last_average_boost_in_goal_against_leadup: Option<f32>,
184 pub last_min_boost_in_goal_against_leadup: Option<f32>,
185 pub goal_against_position_sample_count: u32,
186 pub cumulative_goal_against_position_x: f32,
187 pub cumulative_goal_against_position_y: f32,
188 pub cumulative_goal_against_position_z: f32,
189 pub last_goal_against_position: Option<GoalContextPosition>,
190 pub scoring_goal_last_touch_position_sample_count: u32,
191 pub cumulative_scoring_goal_last_touch_position_x: f32,
192 pub cumulative_scoring_goal_last_touch_position_y: f32,
193 pub cumulative_scoring_goal_last_touch_position_z: f32,
194 pub last_scoring_goal_last_touch_position: Option<GoalContextPosition>,
195 #[serde(flatten)]
196 pub goal_after_kickoff: GoalAfterKickoffStats,
197 #[serde(flatten)]
198 pub goal_buildup: GoalBuildupStats,
199 #[serde(default, flatten)]
200 pub goal_ball_air_time: GoalBallAirTimeStats,
201}
202
203impl PlayerScoringContextStats {
204 fn record_goal_against_snapshot(
205 &mut self,
206 boost_amount: Option<f32>,
207 position: Option<GoalContextPosition>,
208 boost_leadup: Option<BoostLeadupStats>,
209 ) {
210 if let Some(boost_amount) = boost_amount {
211 self.goal_against_boost_sample_count += 1;
212 self.cumulative_boost_on_goals_against += boost_amount;
213 self.last_boost_on_goal_against = Some(boost_amount);
214 }
215
216 if let Some(boost_leadup) = boost_leadup {
217 self.goal_against_boost_leadup_sample_count += 1;
218 self.cumulative_average_boost_in_goal_against_leadup += boost_leadup.average_boost;
219 self.cumulative_min_boost_in_goal_against_leadup += boost_leadup.min_boost;
220 self.last_average_boost_in_goal_against_leadup = Some(boost_leadup.average_boost);
221 self.last_min_boost_in_goal_against_leadup = Some(boost_leadup.min_boost);
222 }
223
224 if let Some(position) = position {
225 self.goal_against_position_sample_count += 1;
226 self.cumulative_goal_against_position_x += position.x;
227 self.cumulative_goal_against_position_y += position.y;
228 self.cumulative_goal_against_position_z += position.z;
229 self.last_goal_against_position = Some(position);
230 }
231 }
232
233 fn record_scoring_goal_last_touch_position(&mut self, position: GoalContextPosition) {
234 self.scoring_goal_last_touch_position_sample_count += 1;
235 self.cumulative_scoring_goal_last_touch_position_x += position.x;
236 self.cumulative_scoring_goal_last_touch_position_y += position.y;
237 self.cumulative_scoring_goal_last_touch_position_z += position.z;
238 self.last_scoring_goal_last_touch_position = Some(position);
239 }
240
241 fn record_goal_ball_air_time(&mut self, ball_air_time: f32) {
242 self.goal_ball_air_time.record_goal(ball_air_time);
243 }
244
245 fn average_boost_on_goals_against(&self) -> f32 {
246 if self.goal_against_boost_sample_count == 0 {
247 0.0
248 } else {
249 self.cumulative_boost_on_goals_against / self.goal_against_boost_sample_count as f32
250 }
251 }
252
253 fn average_boost_in_goal_against_leadup(&self) -> f32 {
254 if self.goal_against_boost_leadup_sample_count == 0 {
255 0.0
256 } else {
257 self.cumulative_average_boost_in_goal_against_leadup
258 / self.goal_against_boost_leadup_sample_count as f32
259 }
260 }
261
262 fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
263 if self.goal_against_boost_leadup_sample_count == 0 {
264 0.0
265 } else {
266 self.cumulative_min_boost_in_goal_against_leadup
267 / self.goal_against_boost_leadup_sample_count as f32
268 }
269 }
270
271 fn average_goal_against_position_x(&self) -> f32 {
272 if self.goal_against_position_sample_count == 0 {
273 0.0
274 } else {
275 self.cumulative_goal_against_position_x / self.goal_against_position_sample_count as f32
276 }
277 }
278
279 fn average_goal_against_position_y(&self) -> f32 {
280 if self.goal_against_position_sample_count == 0 {
281 0.0
282 } else {
283 self.cumulative_goal_against_position_y / self.goal_against_position_sample_count as f32
284 }
285 }
286
287 fn average_goal_against_position_z(&self) -> f32 {
288 if self.goal_against_position_sample_count == 0 {
289 0.0
290 } else {
291 self.cumulative_goal_against_position_z / self.goal_against_position_sample_count as f32
292 }
293 }
294
295 fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
296 if self.scoring_goal_last_touch_position_sample_count == 0 {
297 0.0
298 } else {
299 self.cumulative_scoring_goal_last_touch_position_x
300 / self.scoring_goal_last_touch_position_sample_count as f32
301 }
302 }
303
304 fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
305 if self.scoring_goal_last_touch_position_sample_count == 0 {
306 0.0
307 } else {
308 self.cumulative_scoring_goal_last_touch_position_y
309 / self.scoring_goal_last_touch_position_sample_count as f32
310 }
311 }
312
313 fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
314 if self.scoring_goal_last_touch_position_sample_count == 0 {
315 0.0
316 } else {
317 self.cumulative_scoring_goal_last_touch_position_z
318 / self.scoring_goal_last_touch_position_sample_count as f32
319 }
320 }
321}
322
323#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
324#[ts(export)]
325pub struct CorePlayerStats {
326 pub score: i32,
327 pub goals: i32,
328 pub assists: i32,
329 pub saves: i32,
330 pub shots: i32,
331 #[serde(flatten)]
332 pub scoring_context: PlayerScoringContextStats,
333}
334
335impl CorePlayerStats {
336 pub fn shooting_percentage(&self) -> f32 {
337 if self.shots == 0 {
338 0.0
339 } else {
340 self.goals as f32 * 100.0 / self.shots as f32
341 }
342 }
343
344 pub fn average_goal_time_after_kickoff(&self) -> f32 {
345 self.scoring_context
346 .goal_after_kickoff
347 .average_goal_time_after_kickoff()
348 }
349
350 pub fn median_goal_time_after_kickoff(&self) -> f32 {
351 self.scoring_context
352 .goal_after_kickoff
353 .median_goal_time_after_kickoff()
354 }
355
356 pub fn average_boost_on_goals_against(&self) -> f32 {
357 self.scoring_context.average_boost_on_goals_against()
358 }
359
360 pub fn average_boost_in_goal_against_leadup(&self) -> f32 {
361 self.scoring_context.average_boost_in_goal_against_leadup()
362 }
363
364 pub fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
365 self.scoring_context
366 .average_min_boost_in_goal_against_leadup()
367 }
368
369 pub fn average_goal_against_position_x(&self) -> f32 {
370 self.scoring_context.average_goal_against_position_x()
371 }
372
373 pub fn average_goal_against_position_y(&self) -> f32 {
374 self.scoring_context.average_goal_against_position_y()
375 }
376
377 pub fn average_goal_against_position_z(&self) -> f32 {
378 self.scoring_context.average_goal_against_position_z()
379 }
380
381 pub fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
382 self.scoring_context
383 .average_scoring_goal_last_touch_position_x()
384 }
385
386 pub fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
387 self.scoring_context
388 .average_scoring_goal_last_touch_position_y()
389 }
390
391 pub fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
392 self.scoring_context
393 .average_scoring_goal_last_touch_position_z()
394 }
395
396 pub fn average_goal_ball_air_time(&self) -> f32 {
397 self.scoring_context
398 .goal_ball_air_time
399 .average_goal_ball_air_time()
400 }
401
402 pub fn median_goal_ball_air_time(&self) -> f32 {
403 self.scoring_context
404 .goal_ball_air_time
405 .median_goal_ball_air_time()
406 }
407}
408
409#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
410#[ts(export)]
411pub struct TeamScoringContextStats {
412 #[serde(flatten)]
413 pub goal_after_kickoff: GoalAfterKickoffStats,
414 #[serde(flatten)]
415 pub goal_buildup: GoalBuildupStats,
416 #[serde(default, flatten)]
417 pub goal_ball_air_time: GoalBallAirTimeStats,
418}
419
420#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
421#[ts(export)]
422pub struct CoreTeamStats {
423 pub score: i32,
424 pub goals: i32,
425 pub assists: i32,
426 pub saves: i32,
427 pub shots: i32,
428 #[serde(flatten)]
429 pub scoring_context: TeamScoringContextStats,
430}
431
432impl CoreTeamStats {
433 pub fn shooting_percentage(&self) -> f32 {
434 if self.shots == 0 {
435 0.0
436 } else {
437 self.goals as f32 * 100.0 / self.shots as f32
438 }
439 }
440
441 pub fn average_goal_time_after_kickoff(&self) -> f32 {
442 self.scoring_context
443 .goal_after_kickoff
444 .average_goal_time_after_kickoff()
445 }
446
447 pub fn median_goal_time_after_kickoff(&self) -> f32 {
448 self.scoring_context
449 .goal_after_kickoff
450 .median_goal_time_after_kickoff()
451 }
452
453 pub fn average_goal_ball_air_time(&self) -> f32 {
454 self.scoring_context
455 .goal_ball_air_time
456 .average_goal_ball_air_time()
457 }
458
459 pub fn median_goal_ball_air_time(&self) -> f32 {
460 self.scoring_context
461 .goal_ball_air_time
462 .median_goal_ball_air_time()
463 }
464}
465
466#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
467#[ts(export)]
468pub struct CorePlayerStatsEvent {
469 pub time: f32,
470 pub frame: usize,
471 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
472 pub player: PlayerId,
473 pub is_team_0: bool,
474 pub delta: CorePlayerStats,
475}
476
477#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
478#[ts(export)]
479pub struct CoreTeamStatsEvent {
480 pub time: f32,
481 pub frame: usize,
482 pub is_team_0: bool,
483 pub delta: CoreTeamStats,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ts_rs::TS)]
487#[ts(export)]
488pub enum TimelineEventKind {
489 Goal,
490 Shot,
491 Save,
492 Assist,
493 Kill,
494 Death,
495}
496
497#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
498#[ts(export)]
499pub struct TimelineEvent {
500 pub time: f32,
501 pub kind: TimelineEventKind,
502 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
503 pub player_id: Option<PlayerId>,
504 pub is_team_0: Option<bool>,
505}
506
507#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
508#[ts(export)]
509pub struct GoalContextPosition {
510 pub x: f32,
511 pub y: f32,
512 pub z: f32,
513}
514
515impl From<glam::Vec3> for GoalContextPosition {
516 fn from(position: glam::Vec3) -> Self {
517 Self {
518 x: position.x,
519 y: position.y,
520 z: position.z,
521 }
522 }
523}
524
525fn optional_delta<T: Copy + PartialEq>(current: Option<T>, previous: Option<T>) -> Option<T> {
526 if current == previous {
527 None
528 } else {
529 current
530 }
531}
532
533fn sample_delta<T: Copy + PartialEq>(current: &[T], previous: &[T]) -> Vec<T> {
534 let mut unmatched_previous = previous.to_vec();
535 let mut delta = Vec::new();
536 for value in current {
537 if let Some(index) = unmatched_previous
538 .iter()
539 .position(|previous_value| previous_value == value)
540 {
541 unmatched_previous.remove(index);
542 } else {
543 delta.push(*value);
544 }
545 }
546 delta
547}
548
549fn goal_after_kickoff_delta(
550 current: &GoalAfterKickoffStats,
551 previous: &GoalAfterKickoffStats,
552) -> GoalAfterKickoffStats {
553 GoalAfterKickoffStats {
554 kickoff_goal_count: current
555 .kickoff_goal_count
556 .saturating_sub(previous.kickoff_goal_count),
557 short_goal_count: current
558 .short_goal_count
559 .saturating_sub(previous.short_goal_count),
560 medium_goal_count: current
561 .medium_goal_count
562 .saturating_sub(previous.medium_goal_count),
563 long_goal_count: current
564 .long_goal_count
565 .saturating_sub(previous.long_goal_count),
566 goal_times: sample_delta(¤t.goal_times, &previous.goal_times),
567 }
568}
569
570fn goal_buildup_delta(current: &GoalBuildupStats, previous: &GoalBuildupStats) -> GoalBuildupStats {
571 GoalBuildupStats {
572 counter_attack_goal_count: current
573 .counter_attack_goal_count
574 .saturating_sub(previous.counter_attack_goal_count),
575 sustained_pressure_goal_count: current
576 .sustained_pressure_goal_count
577 .saturating_sub(previous.sustained_pressure_goal_count),
578 other_buildup_goal_count: current
579 .other_buildup_goal_count
580 .saturating_sub(previous.other_buildup_goal_count),
581 }
582}
583
584fn goal_ball_air_time_delta(
585 current: &GoalBallAirTimeStats,
586 previous: &GoalBallAirTimeStats,
587) -> GoalBallAirTimeStats {
588 GoalBallAirTimeStats {
589 goal_ball_air_time_sample_count: current
590 .goal_ball_air_time_sample_count
591 .saturating_sub(previous.goal_ball_air_time_sample_count),
592 cumulative_goal_ball_air_time: current.cumulative_goal_ball_air_time
593 - previous.cumulative_goal_ball_air_time,
594 last_goal_ball_air_time: optional_delta(
595 current.last_goal_ball_air_time,
596 previous.last_goal_ball_air_time,
597 ),
598 goal_ball_air_times: sample_delta(
599 ¤t.goal_ball_air_times,
600 &previous.goal_ball_air_times,
601 ),
602 }
603}
604
605fn team_scoring_context_delta(
606 current: &TeamScoringContextStats,
607 previous: &TeamScoringContextStats,
608) -> TeamScoringContextStats {
609 TeamScoringContextStats {
610 goal_after_kickoff: goal_after_kickoff_delta(
611 ¤t.goal_after_kickoff,
612 &previous.goal_after_kickoff,
613 ),
614 goal_buildup: goal_buildup_delta(¤t.goal_buildup, &previous.goal_buildup),
615 goal_ball_air_time: goal_ball_air_time_delta(
616 ¤t.goal_ball_air_time,
617 &previous.goal_ball_air_time,
618 ),
619 }
620}
621
622fn player_scoring_context_delta(
623 current: &PlayerScoringContextStats,
624 previous: &PlayerScoringContextStats,
625) -> PlayerScoringContextStats {
626 PlayerScoringContextStats {
627 goals_conceded_while_last_defender: current
628 .goals_conceded_while_last_defender
629 .saturating_sub(previous.goals_conceded_while_last_defender),
630 goals_for_while_most_back: current
631 .goals_for_while_most_back
632 .saturating_sub(previous.goals_for_while_most_back),
633 goals_against_while_most_back: current
634 .goals_against_while_most_back
635 .saturating_sub(previous.goals_against_while_most_back),
636 goal_against_boost_sample_count: current
637 .goal_against_boost_sample_count
638 .saturating_sub(previous.goal_against_boost_sample_count),
639 cumulative_boost_on_goals_against: current.cumulative_boost_on_goals_against
640 - previous.cumulative_boost_on_goals_against,
641 last_boost_on_goal_against: optional_delta(
642 current.last_boost_on_goal_against,
643 previous.last_boost_on_goal_against,
644 ),
645 goal_against_boost_leadup_sample_count: current
646 .goal_against_boost_leadup_sample_count
647 .saturating_sub(previous.goal_against_boost_leadup_sample_count),
648 cumulative_average_boost_in_goal_against_leadup: current
649 .cumulative_average_boost_in_goal_against_leadup
650 - previous.cumulative_average_boost_in_goal_against_leadup,
651 cumulative_min_boost_in_goal_against_leadup: current
652 .cumulative_min_boost_in_goal_against_leadup
653 - previous.cumulative_min_boost_in_goal_against_leadup,
654 last_average_boost_in_goal_against_leadup: optional_delta(
655 current.last_average_boost_in_goal_against_leadup,
656 previous.last_average_boost_in_goal_against_leadup,
657 ),
658 last_min_boost_in_goal_against_leadup: optional_delta(
659 current.last_min_boost_in_goal_against_leadup,
660 previous.last_min_boost_in_goal_against_leadup,
661 ),
662 goal_against_position_sample_count: current
663 .goal_against_position_sample_count
664 .saturating_sub(previous.goal_against_position_sample_count),
665 cumulative_goal_against_position_x: current.cumulative_goal_against_position_x
666 - previous.cumulative_goal_against_position_x,
667 cumulative_goal_against_position_y: current.cumulative_goal_against_position_y
668 - previous.cumulative_goal_against_position_y,
669 cumulative_goal_against_position_z: current.cumulative_goal_against_position_z
670 - previous.cumulative_goal_against_position_z,
671 last_goal_against_position: optional_delta(
672 current.last_goal_against_position,
673 previous.last_goal_against_position,
674 ),
675 scoring_goal_last_touch_position_sample_count: current
676 .scoring_goal_last_touch_position_sample_count
677 .saturating_sub(previous.scoring_goal_last_touch_position_sample_count),
678 cumulative_scoring_goal_last_touch_position_x: current
679 .cumulative_scoring_goal_last_touch_position_x
680 - previous.cumulative_scoring_goal_last_touch_position_x,
681 cumulative_scoring_goal_last_touch_position_y: current
682 .cumulative_scoring_goal_last_touch_position_y
683 - previous.cumulative_scoring_goal_last_touch_position_y,
684 cumulative_scoring_goal_last_touch_position_z: current
685 .cumulative_scoring_goal_last_touch_position_z
686 - previous.cumulative_scoring_goal_last_touch_position_z,
687 last_scoring_goal_last_touch_position: optional_delta(
688 current.last_scoring_goal_last_touch_position,
689 previous.last_scoring_goal_last_touch_position,
690 ),
691 goal_after_kickoff: goal_after_kickoff_delta(
692 ¤t.goal_after_kickoff,
693 &previous.goal_after_kickoff,
694 ),
695 goal_buildup: goal_buildup_delta(¤t.goal_buildup, &previous.goal_buildup),
696 goal_ball_air_time: goal_ball_air_time_delta(
697 ¤t.goal_ball_air_time,
698 &previous.goal_ball_air_time,
699 ),
700 }
701}
702
703fn core_player_stats_delta(
704 current: &CorePlayerStats,
705 previous: &CorePlayerStats,
706) -> CorePlayerStats {
707 CorePlayerStats {
708 score: current.score - previous.score,
709 goals: current.goals - previous.goals,
710 assists: current.assists - previous.assists,
711 saves: current.saves - previous.saves,
712 shots: current.shots - previous.shots,
713 scoring_context: player_scoring_context_delta(
714 ¤t.scoring_context,
715 &previous.scoring_context,
716 ),
717 }
718}
719
720fn core_team_stats_delta(current: &CoreTeamStats, previous: &CoreTeamStats) -> CoreTeamStats {
721 CoreTeamStats {
722 score: current.score - previous.score,
723 goals: current.goals - previous.goals,
724 assists: current.assists - previous.assists,
725 saves: current.saves - previous.saves,
726 shots: current.shots - previous.shots,
727 scoring_context: team_scoring_context_delta(
728 ¤t.scoring_context,
729 &previous.scoring_context,
730 ),
731 }
732}
733
734fn player_id_sort_key(player_id: &PlayerId) -> String {
735 match player_id {
736 boxcars::RemoteId::PlayStation(id) => {
737 format!("playstation:{}:{}:{:?}", id.online_id, id.name, id.unknown1)
738 }
739 boxcars::RemoteId::PsyNet(id) => format!("psynet:{}:{:?}", id.online_id, id.unknown1),
740 boxcars::RemoteId::SplitScreen(id) => format!("splitscreen:{id}"),
741 boxcars::RemoteId::Steam(id) => format!("steam:{id}"),
742 boxcars::RemoteId::Switch(id) => format!("switch:{}:{:?}", id.online_id, id.unknown1),
743 boxcars::RemoteId::Xbox(id) => format!("xbox:{id}"),
744 boxcars::RemoteId::QQ(id) => format!("qq:{id}"),
745 boxcars::RemoteId::Epic(id) => format!("epic:{id}"),
746 }
747}
748
749#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
750#[ts(export)]
751pub struct GoalPlayerContext {
752 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
753 pub player: PlayerId,
754 pub is_team_0: bool,
755 pub position: Option<GoalContextPosition>,
756 pub boost_amount: Option<f32>,
757 pub average_boost_in_leadup: Option<f32>,
758 pub min_boost_in_leadup: Option<f32>,
759 pub is_most_back: bool,
760}
761
762#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
763#[ts(export)]
764pub struct GoalTouchContext {
765 pub time: f32,
766 pub frame: usize,
767 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
768 pub player: PlayerId,
769 pub is_team_0: bool,
770 pub ball_position: Option<GoalContextPosition>,
771 pub player_position: Option<GoalContextPosition>,
772 pub players: Vec<GoalPlayerContext>,
773}
774
775#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
776#[ts(export)]
777pub struct GoalContextEvent {
778 pub time: f32,
779 pub frame: usize,
780 pub scoring_team_is_team_0: bool,
781 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
782 pub scorer: Option<PlayerId>,
783 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
784 pub scoring_team_most_back_player: Option<PlayerId>,
785 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
786 pub defending_team_most_back_player: Option<PlayerId>,
787 pub ball_position: Option<GoalContextPosition>,
788 pub ball_air_time_before_goal: Option<f32>,
789 #[serde(default)]
790 pub goal_buildup: GoalBuildupKind,
791 pub scorer_last_touch: Option<GoalTouchContext>,
792 pub players: Vec<GoalPlayerContext>,
793}
794
795#[derive(Debug, Clone)]
796struct PendingGoalEvent {
797 event: GoalEvent,
798 time_after_kickoff: Option<f32>,
799 goal_buildup: GoalBuildupKind,
800}
801
802#[derive(Debug, Clone)]
803struct GoalBuildupSample {
804 time: f32,
805 dt: f32,
806 ball_y: f32,
807}
808
809#[derive(Debug, Clone)]
810struct GoalBuildupPressureEvent {
811 time: f32,
812 is_team_0: bool,
813}
814
815#[derive(Debug, Clone, Copy)]
816struct BoostLeadupSample {
817 time: f32,
818 boost_amount: f32,
819}
820
821#[derive(Debug, Clone, Copy)]
822struct BoostLeadupStats {
823 average_boost: f32,
824 min_boost: f32,
825}
826
827#[derive(Debug, Clone, Default)]
828pub struct MatchStatsCalculator {
829 player_stats: HashMap<PlayerId, CorePlayerStats>,
830 player_teams: HashMap<PlayerId, bool>,
831 previous_player_stats: HashMap<PlayerId, CorePlayerStats>,
832 last_emitted_player_stats: HashMap<PlayerId, CorePlayerStats>,
833 last_emitted_team_zero_stats: CoreTeamStats,
834 last_emitted_team_one_stats: CoreTeamStats,
835 core_player_events: Vec<CorePlayerStatsEvent>,
836 core_team_events: Vec<CoreTeamStatsEvent>,
837 timeline: Vec<TimelineEvent>,
838 pending_goal_events: Vec<PendingGoalEvent>,
839 previous_team_scores: Option<(i32, i32)>,
840 kickoff_waiting_for_first_touch: bool,
841 active_kickoff_touch_time: Option<f32>,
842 goal_buildup_samples: Vec<GoalBuildupSample>,
843 goal_buildup_pressure_events: Vec<GoalBuildupPressureEvent>,
844 goal_context_events: Vec<GoalContextEvent>,
845 last_touch_context_by_player: HashMap<PlayerId, GoalTouchContext>,
846 boost_leadup_samples_by_player: HashMap<PlayerId, VecDeque<BoostLeadupSample>>,
847 last_ball_ground_contact_time: Option<f32>,
848}
849
850impl MatchStatsCalculator {
851 pub fn new() -> Self {
852 Self::default()
853 }
854
855 pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
856 &self.player_stats
857 }
858
859 pub fn timeline(&self) -> &[TimelineEvent] {
860 &self.timeline
861 }
862
863 pub fn goal_context_events(&self) -> &[GoalContextEvent] {
864 &self.goal_context_events
865 }
866
867 pub fn core_player_events(&self) -> &[CorePlayerStatsEvent] {
868 &self.core_player_events
869 }
870
871 pub fn core_team_events(&self) -> &[CoreTeamStatsEvent] {
872 &self.core_team_events
873 }
874
875 pub fn team_zero_stats(&self) -> CoreTeamStats {
876 self.team_stats_for_side(true)
877 }
878
879 pub fn team_one_stats(&self) -> CoreTeamStats {
880 self.team_stats_for_side(false)
881 }
882
883 fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
884 let mut player_stats: Vec<_> = self
885 .player_stats
886 .iter()
887 .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
888 .collect();
889 player_stats.sort_by_cached_key(|(player_id, _)| player_id_sort_key(player_id));
890
891 let mut stats = player_stats.into_iter().fold(
892 CoreTeamStats::default(),
893 |mut stats, (_, player_stats)| {
894 stats.score += player_stats.score;
895 stats.goals += player_stats.goals;
896 stats.assists += player_stats.assists;
897 stats.saves += player_stats.saves;
898 stats.shots += player_stats.shots;
899 stats
900 .scoring_context
901 .goal_after_kickoff
902 .merge(&player_stats.scoring_context.goal_after_kickoff);
903 stats
904 .scoring_context
905 .goal_buildup
906 .merge(&player_stats.scoring_context.goal_buildup);
907 stats
908 .scoring_context
909 .goal_ball_air_time
910 .merge(&player_stats.scoring_context.goal_ball_air_time);
911 stats
912 },
913 );
914 stats
915 .scoring_context
916 .goal_after_kickoff
917 .goal_times
918 .sort_by(|left, right| left.total_cmp(right));
919 stats
920 .scoring_context
921 .goal_ball_air_time
922 .goal_ball_air_times
923 .sort_by(|left, right| left.total_cmp(right));
924 stats
925 }
926
927 fn emit_timeline_events(
928 &mut self,
929 time: f32,
930 kind: TimelineEventKind,
931 player_id: &PlayerId,
932 is_team_0: bool,
933 delta: i32,
934 ) {
935 for _ in 0..delta.max(0) {
936 self.timeline.push(TimelineEvent {
937 time,
938 kind,
939 player_id: Some(player_id.clone()),
940 is_team_0: Some(is_team_0),
941 });
942 }
943 }
944
945 fn emit_core_stats_events(&mut self, frame: &FrameInfo) {
946 let mut player_ids: Vec<_> = self.player_stats.keys().cloned().collect();
947 player_ids.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
948 for player_id in player_ids {
949 let Some(stats) = self.player_stats.get(&player_id) else {
950 continue;
951 };
952 let previous_stats = self
953 .last_emitted_player_stats
954 .get(&player_id)
955 .cloned()
956 .unwrap_or_default();
957 if previous_stats == *stats {
958 continue;
959 }
960 let Some(is_team_0) = self.player_teams.get(&player_id).copied() else {
961 continue;
962 };
963 self.core_player_events.push(CorePlayerStatsEvent {
964 time: frame.time,
965 frame: frame.frame_number,
966 player: player_id.clone(),
967 is_team_0,
968 delta: core_player_stats_delta(stats, &previous_stats),
969 });
970 self.last_emitted_player_stats
971 .insert(player_id, stats.clone());
972 }
973
974 let team_zero_stats = self.team_zero_stats();
975 if team_zero_stats != self.last_emitted_team_zero_stats {
976 self.core_team_events.push(CoreTeamStatsEvent {
977 time: frame.time,
978 frame: frame.frame_number,
979 is_team_0: true,
980 delta: core_team_stats_delta(&team_zero_stats, &self.last_emitted_team_zero_stats),
981 });
982 self.last_emitted_team_zero_stats = team_zero_stats;
983 }
984
985 let team_one_stats = self.team_one_stats();
986 if team_one_stats != self.last_emitted_team_one_stats {
987 self.core_team_events.push(CoreTeamStatsEvent {
988 time: frame.time,
989 frame: frame.frame_number,
990 is_team_0: false,
991 delta: core_team_stats_delta(&team_one_stats, &self.last_emitted_team_one_stats),
992 });
993 self.last_emitted_team_one_stats = team_one_stats;
994 }
995 }
996
997 fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
998 gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
999 || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
1000 || gameplay.ball_has_been_hit == Some(false)
1001 }
1002
1003 fn update_kickoff_reference(&mut self, gameplay: &GameplayState, events: &FrameEventsState) {
1004 if let Some(first_touch_time) = events
1005 .touch_events
1006 .iter()
1007 .map(|event| event.time)
1008 .min_by(|a, b| a.total_cmp(b))
1009 {
1010 self.active_kickoff_touch_time = Some(first_touch_time);
1011 self.kickoff_waiting_for_first_touch = false;
1012 return;
1013 }
1014
1015 if Self::kickoff_phase_active(gameplay) {
1016 self.kickoff_waiting_for_first_touch = true;
1017 self.active_kickoff_touch_time = None;
1018 }
1019 }
1020
1021 fn take_pending_goal_event(
1022 &mut self,
1023 player_id: &PlayerId,
1024 is_team_0: bool,
1025 ) -> Option<PendingGoalEvent> {
1026 if let Some(index) = self.pending_goal_events.iter().position(|event| {
1027 event.event.scoring_team_is_team_0 == is_team_0
1028 && event.event.player.as_ref() == Some(player_id)
1029 }) {
1030 return Some(self.pending_goal_events.remove(index));
1031 }
1032
1033 self.pending_goal_events
1034 .iter()
1035 .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
1036 .map(|index| self.pending_goal_events.remove(index))
1037 }
1038
1039 fn last_defender(
1040 &self,
1041 players: &PlayerFrameState,
1042 defending_team_is_team_0: bool,
1043 ) -> Option<PlayerId> {
1044 players
1045 .players
1046 .iter()
1047 .filter(|player| player.is_team_0 == defending_team_is_team_0)
1048 .filter_map(|player| {
1049 player
1050 .position()
1051 .map(|position| (player.player_id.clone(), position.y))
1052 })
1053 .reduce(|current, candidate| {
1054 if defending_team_is_team_0 {
1055 if candidate.1 < current.1 {
1056 candidate
1057 } else {
1058 current
1059 }
1060 } else if candidate.1 > current.1 {
1061 candidate
1062 } else {
1063 current
1064 }
1065 })
1066 .map(|(player_id, _)| player_id)
1067 }
1068
1069 fn most_back_player(players: &PlayerFrameState, team_is_team_0: bool) -> Option<PlayerId> {
1070 players
1071 .players
1072 .iter()
1073 .filter(|player| player.is_team_0 == team_is_team_0)
1074 .filter_map(|player| {
1075 player.position().map(|position| {
1076 (
1077 player.player_id.clone(),
1078 normalized_y(team_is_team_0, position),
1079 )
1080 })
1081 })
1082 .min_by(|left, right| left.1.total_cmp(&right.1))
1083 .map(|(player_id, _)| player_id)
1084 }
1085
1086 fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
1087 players
1088 .players
1089 .iter()
1090 .find(|player| &player.player_id == player_id)
1091 .and_then(PlayerSample::position)
1092 }
1093
1094 fn update_last_touch_contexts(
1095 &mut self,
1096 ball: &BallFrameState,
1097 players: &PlayerFrameState,
1098 touch_events: &[TouchEvent],
1099 ) {
1100 let ball_position = ball.position().map(GoalContextPosition::from);
1101 for touch in touch_events {
1102 let Some(player_id) = touch.player.clone() else {
1103 continue;
1104 };
1105 let touch_team_most_back_player = Self::most_back_player(players, touch.team_is_team_0);
1106 let other_team_most_back_player =
1107 Self::most_back_player(players, !touch.team_is_team_0);
1108 let touch_players = self.goal_player_contexts(
1109 players,
1110 touch.team_is_team_0,
1111 touch_team_most_back_player.as_ref(),
1112 other_team_most_back_player.as_ref(),
1113 );
1114 self.last_touch_context_by_player.insert(
1115 player_id.clone(),
1116 GoalTouchContext {
1117 time: touch.time,
1118 frame: touch.frame,
1119 player: player_id.clone(),
1120 is_team_0: touch.team_is_team_0,
1121 ball_position,
1122 player_position: Self::player_position(players, &player_id)
1123 .map(GoalContextPosition::from),
1124 players: touch_players,
1125 },
1126 );
1127 }
1128 }
1129
1130 fn update_boost_leadup_samples(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
1131 let cutoff_time = frame.time - GOAL_CONTEXT_BOOST_LEADUP_SECONDS;
1132 for player in &players.players {
1133 let Some(boost_amount) = player.boost_amount.or(player.last_boost_amount) else {
1134 continue;
1135 };
1136 let samples = self
1137 .boost_leadup_samples_by_player
1138 .entry(player.player_id.clone())
1139 .or_default();
1140 samples.push_back(BoostLeadupSample {
1141 time: frame.time,
1142 boost_amount,
1143 });
1144 while samples
1145 .front()
1146 .is_some_and(|sample| sample.time < cutoff_time)
1147 {
1148 samples.pop_front();
1149 }
1150 }
1151
1152 self.boost_leadup_samples_by_player
1153 .retain(|_, samples| !samples.is_empty());
1154 }
1155
1156 fn update_ball_ground_contact(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
1157 if ball
1158 .position()
1159 .is_some_and(|position| position.z <= BALL_GROUND_CONTACT_MAX_Z)
1160 {
1161 self.last_ball_ground_contact_time = Some(frame.time);
1162 }
1163 }
1164
1165 fn ball_air_time_before_goal(&self, goal_time: f32) -> Option<f32> {
1166 self.last_ball_ground_contact_time
1167 .map(|ground_contact_time| (goal_time - ground_contact_time).max(0.0))
1168 }
1169
1170 fn boost_leadup_for_player(&self, player_id: &PlayerId) -> Option<BoostLeadupStats> {
1171 let samples = self.boost_leadup_samples_by_player.get(player_id)?;
1172 if samples.is_empty() {
1173 return None;
1174 }
1175
1176 let mut sum = 0.0;
1177 let mut min_boost = f32::INFINITY;
1178 for sample in samples {
1179 sum += sample.boost_amount;
1180 min_boost = min_boost.min(sample.boost_amount);
1181 }
1182
1183 Some(BoostLeadupStats {
1184 average_boost: sum / samples.len() as f32,
1185 min_boost,
1186 })
1187 }
1188
1189 fn goal_player_contexts(
1190 &self,
1191 players: &PlayerFrameState,
1192 scoring_team_is_team_0: bool,
1193 scoring_team_most_back_player: Option<&PlayerId>,
1194 defending_team_most_back_player: Option<&PlayerId>,
1195 ) -> Vec<GoalPlayerContext> {
1196 players
1197 .players
1198 .iter()
1199 .map(|player| {
1200 let most_back_player = if player.is_team_0 == scoring_team_is_team_0 {
1201 scoring_team_most_back_player
1202 } else {
1203 defending_team_most_back_player
1204 };
1205 let boost_leadup = self.boost_leadup_for_player(&player.player_id);
1206 GoalPlayerContext {
1207 player: player.player_id.clone(),
1208 is_team_0: player.is_team_0,
1209 position: player.position().map(GoalContextPosition::from),
1210 boost_amount: player.boost_amount.or(player.last_boost_amount),
1211 average_boost_in_leadup: boost_leadup.map(|stats| stats.average_boost),
1212 min_boost_in_leadup: boost_leadup.map(|stats| stats.min_boost),
1213 is_most_back: most_back_player == Some(&player.player_id),
1214 }
1215 })
1216 .collect()
1217 }
1218
1219 fn record_goal_context_stats(
1220 &mut self,
1221 players: &PlayerFrameState,
1222 goal_event: &GoalEvent,
1223 scoring_team_most_back_player: Option<&PlayerId>,
1224 defending_team_most_back_player: Option<&PlayerId>,
1225 scorer_last_touch: Option<&GoalTouchContext>,
1226 ball_air_time_before_goal: Option<f32>,
1227 ) {
1228 if let Some(player_id) = scoring_team_most_back_player {
1229 self.player_stats
1230 .entry(player_id.clone())
1231 .or_default()
1232 .scoring_context
1233 .goals_for_while_most_back += 1;
1234 }
1235
1236 if let Some(player_id) = defending_team_most_back_player {
1237 self.player_stats
1238 .entry(player_id.clone())
1239 .or_default()
1240 .scoring_context
1241 .goals_against_while_most_back += 1;
1242 }
1243
1244 for player in players
1245 .players
1246 .iter()
1247 .filter(|player| player.is_team_0 != goal_event.scoring_team_is_team_0)
1248 {
1249 let boost_leadup = self.boost_leadup_for_player(&player.player_id);
1250 self.player_stats
1251 .entry(player.player_id.clone())
1252 .or_default()
1253 .scoring_context
1254 .record_goal_against_snapshot(
1255 player.boost_amount.or(player.last_boost_amount),
1256 player.position().map(GoalContextPosition::from),
1257 boost_leadup,
1258 );
1259 }
1260
1261 if let Some(scorer) = goal_event.player.as_ref() {
1262 if let Some(touch_position) = scorer_last_touch.and_then(|touch| touch.ball_position) {
1263 self.player_stats
1264 .entry(scorer.clone())
1265 .or_default()
1266 .scoring_context
1267 .record_scoring_goal_last_touch_position(touch_position);
1268 }
1269 if let Some(ball_air_time_before_goal) = ball_air_time_before_goal {
1270 self.player_stats
1271 .entry(scorer.clone())
1272 .or_default()
1273 .scoring_context
1274 .record_goal_ball_air_time(ball_air_time_before_goal);
1275 }
1276 }
1277 }
1278
1279 fn record_goal_context_events(
1280 &mut self,
1281 ball: &BallFrameState,
1282 players: &PlayerFrameState,
1283 events: &FrameEventsState,
1284 ) {
1285 let ball_position = ball.position().map(GoalContextPosition::from);
1286 for goal_event in &events.goal_events {
1287 let scoring_team_most_back_player =
1288 Self::most_back_player(players, goal_event.scoring_team_is_team_0);
1289 let defending_team_most_back_player =
1290 Self::most_back_player(players, !goal_event.scoring_team_is_team_0);
1291 let scorer_last_touch = goal_event
1292 .player
1293 .as_ref()
1294 .and_then(|player_id| self.last_touch_context_by_player.get(player_id))
1295 .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
1296 .cloned();
1297 let ball_air_time_before_goal = self.ball_air_time_before_goal(goal_event.time);
1298 let goal_buildup =
1299 self.classify_goal_buildup(goal_event.time, goal_event.scoring_team_is_team_0);
1300
1301 self.record_goal_context_stats(
1302 players,
1303 goal_event,
1304 scoring_team_most_back_player.as_ref(),
1305 defending_team_most_back_player.as_ref(),
1306 scorer_last_touch.as_ref(),
1307 ball_air_time_before_goal,
1308 );
1309
1310 self.goal_context_events.push(GoalContextEvent {
1311 time: goal_event.time,
1312 frame: goal_event.frame,
1313 scoring_team_is_team_0: goal_event.scoring_team_is_team_0,
1314 scorer: goal_event.player.clone(),
1315 scoring_team_most_back_player: scoring_team_most_back_player.clone(),
1316 defending_team_most_back_player: defending_team_most_back_player.clone(),
1317 ball_position,
1318 ball_air_time_before_goal,
1319 goal_buildup,
1320 scorer_last_touch,
1321 players: self.goal_player_contexts(
1322 players,
1323 goal_event.scoring_team_is_team_0,
1324 scoring_team_most_back_player.as_ref(),
1325 defending_team_most_back_player.as_ref(),
1326 ),
1327 });
1328 }
1329 }
1330
1331 fn prune_goal_buildup_samples(&mut self, current_time: f32) {
1332 self.goal_buildup_samples
1333 .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1334 self.goal_buildup_pressure_events
1335 .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1336 }
1337
1338 fn record_goal_buildup_sample(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
1339 let Some(ball) = ball.sample() else {
1340 return;
1341 };
1342 if frame.dt <= 0.0 {
1343 return;
1344 }
1345 self.goal_buildup_samples.push(GoalBuildupSample {
1346 time: frame.time,
1347 dt: frame.dt,
1348 ball_y: ball.position().y,
1349 });
1350 }
1351
1352 fn record_goal_buildup_pressure_events(&mut self, events: &FrameEventsState) {
1353 self.goal_buildup_pressure_events.extend(
1354 events
1355 .player_stat_events
1356 .iter()
1357 .filter(|event| event.kind == PlayerStatEventKind::Shot)
1358 .map(|event| GoalBuildupPressureEvent {
1359 time: event.time,
1360 is_team_0: event.is_team_0,
1361 }),
1362 );
1363 }
1364
1365 fn classify_goal_buildup(
1366 &self,
1367 goal_time: f32,
1368 scoring_team_is_team_0: bool,
1369 ) -> GoalBuildupKind {
1370 let relevant_samples: Vec<_> = self
1371 .goal_buildup_samples
1372 .iter()
1373 .filter(|entry| entry.time <= goal_time)
1374 .filter(|entry| goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS)
1375 .collect();
1376 if relevant_samples.is_empty() {
1377 return GoalBuildupKind::Other;
1378 }
1379
1380 let mut defensive_half_time = 0.0;
1381 let mut defensive_third_time = 0.0;
1382 let mut offensive_half_time = 0.0;
1383 let mut offensive_third_time = 0.0;
1384 let mut current_attack_time = 0.0;
1385
1386 for entry in &relevant_samples {
1387 let normalized_ball_y = if scoring_team_is_team_0 {
1388 entry.ball_y
1389 } else {
1390 -entry.ball_y
1391 };
1392 if normalized_ball_y < 0.0 {
1393 defensive_half_time += entry.dt;
1394 } else {
1395 offensive_half_time += entry.dt;
1396 }
1397 if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
1398 defensive_third_time += entry.dt;
1399 }
1400 if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
1401 offensive_third_time += entry.dt;
1402 }
1403 }
1404
1405 for entry in relevant_samples.iter().rev() {
1406 let normalized_ball_y = if scoring_team_is_team_0 {
1407 entry.ball_y
1408 } else {
1409 -entry.ball_y
1410 };
1411 if normalized_ball_y > 0.0 {
1412 current_attack_time += entry.dt;
1413 } else {
1414 break;
1415 }
1416 }
1417
1418 let opponent_shot_in_lookback = self.goal_buildup_pressure_events.iter().any(|entry| {
1419 entry.time <= goal_time
1420 && goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS
1421 && entry.is_team_0 != scoring_team_is_team_0
1422 });
1423 let has_defensive_pressure_signal = defensive_half_time
1424 >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
1425 || defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
1426 || opponent_shot_in_lookback;
1427
1428 if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS && has_defensive_pressure_signal
1429 {
1430 GoalBuildupKind::CounterAttack
1431 } else if current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
1432 && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
1433 && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
1434 {
1435 GoalBuildupKind::SustainedPressure
1436 } else {
1437 GoalBuildupKind::Other
1438 }
1439 }
1440}
1441
1442impl MatchStatsCalculator {
1443 #[allow(clippy::too_many_arguments)]
1444 pub fn update_parts(
1445 &mut self,
1446 frame: &FrameInfo,
1447 gameplay: &GameplayState,
1448 ball: &BallFrameState,
1449 players: &PlayerFrameState,
1450 events: &FrameEventsState,
1451 live_play_state: &LivePlayState,
1452 touch_state: &TouchState,
1453 ) -> SubtrActorResult<()> {
1454 self.update_kickoff_reference(gameplay, events);
1455 self.prune_goal_buildup_samples(frame.time);
1456 self.update_ball_ground_contact(frame, ball);
1457 if live_play_state.is_live_play {
1458 self.record_goal_buildup_sample(frame, ball);
1459 self.record_goal_buildup_pressure_events(events);
1460 self.update_boost_leadup_samples(frame, players);
1461 } else if events.goal_events.is_empty() {
1462 self.last_touch_context_by_player.clear();
1463 self.boost_leadup_samples_by_player.clear();
1464 self.last_ball_ground_contact_time = None;
1465 }
1466 self.update_last_touch_contexts(ball, players, &touch_state.touch_events);
1467 self.record_goal_context_events(ball, players, events);
1468 let pending_goal_events: Vec<_> = events
1469 .goal_events
1470 .iter()
1471 .cloned()
1472 .map(|event| PendingGoalEvent {
1473 time_after_kickoff: self
1474 .active_kickoff_touch_time
1475 .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
1476 goal_buildup: self.classify_goal_buildup(event.time, event.scoring_team_is_team_0),
1477 event,
1478 })
1479 .collect();
1480 self.pending_goal_events.extend(pending_goal_events);
1481 let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
1482 HashMap::new();
1483 for event in &events.player_stat_events {
1484 let kind = match event.kind {
1485 PlayerStatEventKind::Shot => TimelineEventKind::Shot,
1486 PlayerStatEventKind::Save => TimelineEventKind::Save,
1487 PlayerStatEventKind::Assist => TimelineEventKind::Assist,
1488 };
1489 self.timeline.push(TimelineEvent {
1490 time: event.time,
1491 kind,
1492 player_id: Some(event.player.clone()),
1493 is_team_0: Some(event.is_team_0),
1494 });
1495 *processor_event_counts
1496 .entry((event.player.clone(), kind))
1497 .or_default() += 1;
1498 }
1499
1500 for player in &players.players {
1501 self.player_teams
1502 .insert(player.player_id.clone(), player.is_team_0);
1503 let mut current_stats = CorePlayerStats {
1504 score: player.match_score.unwrap_or(0),
1505 goals: player.match_goals.unwrap_or(0),
1506 assists: player.match_assists.unwrap_or(0),
1507 saves: player.match_saves.unwrap_or(0),
1508 shots: player.match_shots.unwrap_or(0),
1509 scoring_context: self
1510 .player_stats
1511 .get(&player.player_id)
1512 .map(|stats| stats.scoring_context.clone())
1513 .unwrap_or_default(),
1514 };
1515
1516 let previous_stats = self
1517 .previous_player_stats
1518 .get(&player.player_id)
1519 .cloned()
1520 .unwrap_or_default();
1521
1522 let shot_delta = current_stats.shots - previous_stats.shots;
1523 let save_delta = current_stats.saves - previous_stats.saves;
1524 let assist_delta = current_stats.assists - previous_stats.assists;
1525 let goal_delta = current_stats.goals - previous_stats.goals;
1526 let shot_fallback_delta = shot_delta
1527 - processor_event_counts
1528 .get(&(player.player_id.clone(), TimelineEventKind::Shot))
1529 .copied()
1530 .unwrap_or(0);
1531 let save_fallback_delta = save_delta
1532 - processor_event_counts
1533 .get(&(player.player_id.clone(), TimelineEventKind::Save))
1534 .copied()
1535 .unwrap_or(0);
1536 let assist_fallback_delta = assist_delta
1537 - processor_event_counts
1538 .get(&(player.player_id.clone(), TimelineEventKind::Assist))
1539 .copied()
1540 .unwrap_or(0);
1541
1542 if shot_fallback_delta > 0 {
1543 self.emit_timeline_events(
1544 frame.time,
1545 TimelineEventKind::Shot,
1546 &player.player_id,
1547 player.is_team_0,
1548 shot_fallback_delta,
1549 );
1550 }
1551 if save_fallback_delta > 0 {
1552 self.emit_timeline_events(
1553 frame.time,
1554 TimelineEventKind::Save,
1555 &player.player_id,
1556 player.is_team_0,
1557 save_fallback_delta,
1558 );
1559 }
1560 if assist_fallback_delta > 0 {
1561 self.emit_timeline_events(
1562 frame.time,
1563 TimelineEventKind::Assist,
1564 &player.player_id,
1565 player.is_team_0,
1566 assist_fallback_delta,
1567 );
1568 }
1569 if goal_delta > 0 {
1570 for _ in 0..goal_delta.max(0) {
1571 let pending_goal_event =
1572 self.take_pending_goal_event(&player.player_id, player.is_team_0);
1573 let goal_time = pending_goal_event
1574 .as_ref()
1575 .map(|event| event.event.time)
1576 .unwrap_or(frame.time);
1577 let goal_buildup = pending_goal_event
1578 .as_ref()
1579 .map(|event| event.goal_buildup)
1580 .unwrap_or_else(|| self.classify_goal_buildup(goal_time, player.is_team_0));
1581 let time_after_kickoff = pending_goal_event
1582 .and_then(|event| event.time_after_kickoff)
1583 .or_else(|| {
1584 self.active_kickoff_touch_time
1585 .map(|kickoff_touch_time| (goal_time - kickoff_touch_time).max(0.0))
1586 });
1587 if let Some(time_after_kickoff) = time_after_kickoff {
1588 current_stats
1589 .scoring_context
1590 .goal_after_kickoff
1591 .record_goal(time_after_kickoff);
1592 }
1593 current_stats
1594 .scoring_context
1595 .goal_buildup
1596 .record(goal_buildup);
1597 self.timeline.push(TimelineEvent {
1598 time: goal_time,
1599 kind: TimelineEventKind::Goal,
1600 player_id: Some(player.player_id.clone()),
1601 is_team_0: Some(player.is_team_0),
1602 });
1603 }
1604 }
1605
1606 self.previous_player_stats
1607 .insert(player.player_id.clone(), current_stats.clone());
1608 self.player_stats
1609 .insert(player.player_id.clone(), current_stats);
1610 }
1611
1612 if let (Some(team_zero_score), Some(team_one_score)) =
1613 (gameplay.team_zero_score, gameplay.team_one_score)
1614 {
1615 if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
1616 let team_zero_delta = team_zero_score - prev_team_zero_score;
1617 let team_one_delta = team_one_score - prev_team_one_score;
1618
1619 if team_zero_delta > 0 {
1620 if let Some(last_defender) = self.last_defender(players, false) {
1621 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1622 stats.scoring_context.goals_conceded_while_last_defender +=
1623 team_zero_delta as u32;
1624 }
1625 }
1626 }
1627
1628 if team_one_delta > 0 {
1629 if let Some(last_defender) = self.last_defender(players, true) {
1630 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1631 stats.scoring_context.goals_conceded_while_last_defender +=
1632 team_one_delta as u32;
1633 }
1634 }
1635 }
1636 }
1637
1638 self.previous_team_scores = Some((team_zero_score, team_one_score));
1639 }
1640
1641 self.timeline.sort_by(|a, b| {
1642 a.time
1643 .partial_cmp(&b.time)
1644 .unwrap_or(std::cmp::Ordering::Equal)
1645 });
1646 self.emit_core_stats_events(frame);
1647
1648 Ok(())
1649 }
1650}
1651
1652#[cfg(test)]
1653#[path = "match_stats_tests.rs"]
1654mod tests;