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 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub frame: Option<usize>,
503 pub kind: TimelineEventKind,
504 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
505 pub player_id: Option<PlayerId>,
506 pub is_team_0: Option<bool>,
507}
508
509#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
510#[ts(export)]
511pub struct GoalContextPosition {
512 pub x: f32,
513 pub y: f32,
514 pub z: f32,
515}
516
517impl From<glam::Vec3> for GoalContextPosition {
518 fn from(position: glam::Vec3) -> Self {
519 Self {
520 x: position.x,
521 y: position.y,
522 z: position.z,
523 }
524 }
525}
526
527fn optional_delta<T: Copy + PartialEq>(current: Option<T>, previous: Option<T>) -> Option<T> {
528 if current == previous {
529 None
530 } else {
531 current
532 }
533}
534
535fn sample_delta<T: Copy + PartialEq>(current: &[T], previous: &[T]) -> Vec<T> {
536 let mut unmatched_previous = previous.to_vec();
537 let mut delta = Vec::new();
538 for value in current {
539 if let Some(index) = unmatched_previous
540 .iter()
541 .position(|previous_value| previous_value == value)
542 {
543 unmatched_previous.remove(index);
544 } else {
545 delta.push(*value);
546 }
547 }
548 delta
549}
550
551fn goal_after_kickoff_delta(
552 current: &GoalAfterKickoffStats,
553 previous: &GoalAfterKickoffStats,
554) -> GoalAfterKickoffStats {
555 GoalAfterKickoffStats {
556 kickoff_goal_count: current
557 .kickoff_goal_count
558 .saturating_sub(previous.kickoff_goal_count),
559 short_goal_count: current
560 .short_goal_count
561 .saturating_sub(previous.short_goal_count),
562 medium_goal_count: current
563 .medium_goal_count
564 .saturating_sub(previous.medium_goal_count),
565 long_goal_count: current
566 .long_goal_count
567 .saturating_sub(previous.long_goal_count),
568 goal_times: sample_delta(¤t.goal_times, &previous.goal_times),
569 }
570}
571
572fn goal_buildup_delta(current: &GoalBuildupStats, previous: &GoalBuildupStats) -> GoalBuildupStats {
573 GoalBuildupStats {
574 counter_attack_goal_count: current
575 .counter_attack_goal_count
576 .saturating_sub(previous.counter_attack_goal_count),
577 sustained_pressure_goal_count: current
578 .sustained_pressure_goal_count
579 .saturating_sub(previous.sustained_pressure_goal_count),
580 other_buildup_goal_count: current
581 .other_buildup_goal_count
582 .saturating_sub(previous.other_buildup_goal_count),
583 }
584}
585
586fn goal_ball_air_time_delta(
587 current: &GoalBallAirTimeStats,
588 previous: &GoalBallAirTimeStats,
589) -> GoalBallAirTimeStats {
590 GoalBallAirTimeStats {
591 goal_ball_air_time_sample_count: current
592 .goal_ball_air_time_sample_count
593 .saturating_sub(previous.goal_ball_air_time_sample_count),
594 cumulative_goal_ball_air_time: current.cumulative_goal_ball_air_time
595 - previous.cumulative_goal_ball_air_time,
596 last_goal_ball_air_time: optional_delta(
597 current.last_goal_ball_air_time,
598 previous.last_goal_ball_air_time,
599 ),
600 goal_ball_air_times: sample_delta(
601 ¤t.goal_ball_air_times,
602 &previous.goal_ball_air_times,
603 ),
604 }
605}
606
607fn team_scoring_context_delta(
608 current: &TeamScoringContextStats,
609 previous: &TeamScoringContextStats,
610) -> TeamScoringContextStats {
611 TeamScoringContextStats {
612 goal_after_kickoff: goal_after_kickoff_delta(
613 ¤t.goal_after_kickoff,
614 &previous.goal_after_kickoff,
615 ),
616 goal_buildup: goal_buildup_delta(¤t.goal_buildup, &previous.goal_buildup),
617 goal_ball_air_time: goal_ball_air_time_delta(
618 ¤t.goal_ball_air_time,
619 &previous.goal_ball_air_time,
620 ),
621 }
622}
623
624fn player_scoring_context_delta(
625 current: &PlayerScoringContextStats,
626 previous: &PlayerScoringContextStats,
627) -> PlayerScoringContextStats {
628 PlayerScoringContextStats {
629 goals_conceded_while_last_defender: current
630 .goals_conceded_while_last_defender
631 .saturating_sub(previous.goals_conceded_while_last_defender),
632 goals_for_while_most_back: current
633 .goals_for_while_most_back
634 .saturating_sub(previous.goals_for_while_most_back),
635 goals_against_while_most_back: current
636 .goals_against_while_most_back
637 .saturating_sub(previous.goals_against_while_most_back),
638 goal_against_boost_sample_count: current
639 .goal_against_boost_sample_count
640 .saturating_sub(previous.goal_against_boost_sample_count),
641 cumulative_boost_on_goals_against: current.cumulative_boost_on_goals_against
642 - previous.cumulative_boost_on_goals_against,
643 last_boost_on_goal_against: optional_delta(
644 current.last_boost_on_goal_against,
645 previous.last_boost_on_goal_against,
646 ),
647 goal_against_boost_leadup_sample_count: current
648 .goal_against_boost_leadup_sample_count
649 .saturating_sub(previous.goal_against_boost_leadup_sample_count),
650 cumulative_average_boost_in_goal_against_leadup: current
651 .cumulative_average_boost_in_goal_against_leadup
652 - previous.cumulative_average_boost_in_goal_against_leadup,
653 cumulative_min_boost_in_goal_against_leadup: current
654 .cumulative_min_boost_in_goal_against_leadup
655 - previous.cumulative_min_boost_in_goal_against_leadup,
656 last_average_boost_in_goal_against_leadup: optional_delta(
657 current.last_average_boost_in_goal_against_leadup,
658 previous.last_average_boost_in_goal_against_leadup,
659 ),
660 last_min_boost_in_goal_against_leadup: optional_delta(
661 current.last_min_boost_in_goal_against_leadup,
662 previous.last_min_boost_in_goal_against_leadup,
663 ),
664 goal_against_position_sample_count: current
665 .goal_against_position_sample_count
666 .saturating_sub(previous.goal_against_position_sample_count),
667 cumulative_goal_against_position_x: current.cumulative_goal_against_position_x
668 - previous.cumulative_goal_against_position_x,
669 cumulative_goal_against_position_y: current.cumulative_goal_against_position_y
670 - previous.cumulative_goal_against_position_y,
671 cumulative_goal_against_position_z: current.cumulative_goal_against_position_z
672 - previous.cumulative_goal_against_position_z,
673 last_goal_against_position: optional_delta(
674 current.last_goal_against_position,
675 previous.last_goal_against_position,
676 ),
677 scoring_goal_last_touch_position_sample_count: current
678 .scoring_goal_last_touch_position_sample_count
679 .saturating_sub(previous.scoring_goal_last_touch_position_sample_count),
680 cumulative_scoring_goal_last_touch_position_x: current
681 .cumulative_scoring_goal_last_touch_position_x
682 - previous.cumulative_scoring_goal_last_touch_position_x,
683 cumulative_scoring_goal_last_touch_position_y: current
684 .cumulative_scoring_goal_last_touch_position_y
685 - previous.cumulative_scoring_goal_last_touch_position_y,
686 cumulative_scoring_goal_last_touch_position_z: current
687 .cumulative_scoring_goal_last_touch_position_z
688 - previous.cumulative_scoring_goal_last_touch_position_z,
689 last_scoring_goal_last_touch_position: optional_delta(
690 current.last_scoring_goal_last_touch_position,
691 previous.last_scoring_goal_last_touch_position,
692 ),
693 goal_after_kickoff: goal_after_kickoff_delta(
694 ¤t.goal_after_kickoff,
695 &previous.goal_after_kickoff,
696 ),
697 goal_buildup: goal_buildup_delta(¤t.goal_buildup, &previous.goal_buildup),
698 goal_ball_air_time: goal_ball_air_time_delta(
699 ¤t.goal_ball_air_time,
700 &previous.goal_ball_air_time,
701 ),
702 }
703}
704
705fn core_player_stats_delta(
706 current: &CorePlayerStats,
707 previous: &CorePlayerStats,
708) -> CorePlayerStats {
709 CorePlayerStats {
710 score: current.score - previous.score,
711 goals: current.goals - previous.goals,
712 assists: current.assists - previous.assists,
713 saves: current.saves - previous.saves,
714 shots: current.shots - previous.shots,
715 scoring_context: player_scoring_context_delta(
716 ¤t.scoring_context,
717 &previous.scoring_context,
718 ),
719 }
720}
721
722fn core_team_stats_delta(current: &CoreTeamStats, previous: &CoreTeamStats) -> CoreTeamStats {
723 CoreTeamStats {
724 score: current.score - previous.score,
725 goals: current.goals - previous.goals,
726 assists: current.assists - previous.assists,
727 saves: current.saves - previous.saves,
728 shots: current.shots - previous.shots,
729 scoring_context: team_scoring_context_delta(
730 ¤t.scoring_context,
731 &previous.scoring_context,
732 ),
733 }
734}
735
736fn player_id_sort_key(player_id: &PlayerId) -> String {
737 match player_id {
738 boxcars::RemoteId::PlayStation(id) => {
739 format!("playstation:{}:{}:{:?}", id.online_id, id.name, id.unknown1)
740 }
741 boxcars::RemoteId::PsyNet(id) => format!("psynet:{}:{:?}", id.online_id, id.unknown1),
742 boxcars::RemoteId::SplitScreen(id) => format!("splitscreen:{id}"),
743 boxcars::RemoteId::Steam(id) => format!("steam:{id}"),
744 boxcars::RemoteId::Switch(id) => format!("switch:{}:{:?}", id.online_id, id.unknown1),
745 boxcars::RemoteId::Xbox(id) => format!("xbox:{id}"),
746 boxcars::RemoteId::QQ(id) => format!("qq:{id}"),
747 boxcars::RemoteId::Epic(id) => format!("epic:{id}"),
748 }
749}
750
751#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
752#[ts(export)]
753pub struct GoalPlayerContext {
754 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
755 pub player: PlayerId,
756 pub is_team_0: bool,
757 pub position: Option<GoalContextPosition>,
758 pub boost_amount: Option<f32>,
759 pub average_boost_in_leadup: Option<f32>,
760 pub min_boost_in_leadup: Option<f32>,
761 pub is_most_back: bool,
762}
763
764#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
765#[ts(export)]
766pub struct GoalTouchContext {
767 pub time: f32,
768 pub frame: usize,
769 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
770 pub player: PlayerId,
771 pub is_team_0: bool,
772 pub ball_position: Option<GoalContextPosition>,
773 pub player_position: Option<GoalContextPosition>,
774 pub players: Vec<GoalPlayerContext>,
775}
776
777#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
778#[ts(export)]
779pub struct GoalContextEvent {
780 pub time: f32,
781 pub frame: usize,
782 pub scoring_team_is_team_0: bool,
783 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
784 pub scorer: Option<PlayerId>,
785 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
786 pub scoring_team_most_back_player: Option<PlayerId>,
787 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
788 pub defending_team_most_back_player: Option<PlayerId>,
789 pub ball_position: Option<GoalContextPosition>,
790 pub ball_air_time_before_goal: Option<f32>,
791 #[serde(default)]
792 pub goal_buildup: GoalBuildupKind,
793 pub scorer_last_touch: Option<GoalTouchContext>,
794 pub players: Vec<GoalPlayerContext>,
795}
796
797#[derive(Debug, Clone)]
798struct PendingGoalEvent {
799 event: GoalEvent,
800 time_after_kickoff: Option<f32>,
801 goal_buildup: GoalBuildupKind,
802 ball_air_time_before_goal: Option<f32>,
803}
804
805#[derive(Debug, Clone)]
806struct GoalBuildupSample {
807 time: f32,
808 dt: f32,
809 ball_y: f32,
810}
811
812#[derive(Debug, Clone)]
813struct GoalBuildupPressureEvent {
814 time: f32,
815 is_team_0: bool,
816}
817
818#[derive(Debug, Clone, Copy)]
819struct BoostLeadupSample {
820 time: f32,
821 boost_amount: f32,
822}
823
824#[derive(Debug, Clone, Copy)]
825struct BoostLeadupStats {
826 average_boost: f32,
827 min_boost: f32,
828}
829
830#[derive(Debug, Clone, Default)]
831pub struct MatchStatsCalculator {
832 player_stats: HashMap<PlayerId, CorePlayerStats>,
833 player_teams: HashMap<PlayerId, bool>,
834 previous_player_stats: HashMap<PlayerId, CorePlayerStats>,
835 last_emitted_player_stats: HashMap<PlayerId, CorePlayerStats>,
836 last_emitted_team_zero_stats: CoreTeamStats,
837 last_emitted_team_one_stats: CoreTeamStats,
838 core_player_events: Vec<CorePlayerStatsEvent>,
839 core_team_events: Vec<CoreTeamStatsEvent>,
840 timeline: Vec<TimelineEvent>,
841 pending_goal_events: Vec<PendingGoalEvent>,
842 previous_team_scores: Option<(i32, i32)>,
843 kickoff_waiting_for_first_touch: bool,
844 active_kickoff_touch_time: Option<f32>,
845 goal_buildup_samples: Vec<GoalBuildupSample>,
846 goal_buildup_pressure_events: Vec<GoalBuildupPressureEvent>,
847 goal_context_events: Vec<GoalContextEvent>,
848 last_touch_context_by_player: HashMap<PlayerId, GoalTouchContext>,
849 boost_leadup_samples_by_player: HashMap<PlayerId, VecDeque<BoostLeadupSample>>,
850 last_ball_ground_contact_time: Option<f32>,
851}
852
853impl MatchStatsCalculator {
854 pub fn new() -> Self {
855 Self::default()
856 }
857
858 pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
859 &self.player_stats
860 }
861
862 pub fn timeline(&self) -> &[TimelineEvent] {
863 &self.timeline
864 }
865
866 pub fn goal_context_events(&self) -> &[GoalContextEvent] {
867 &self.goal_context_events
868 }
869
870 pub fn core_player_events(&self) -> &[CorePlayerStatsEvent] {
871 &self.core_player_events
872 }
873
874 pub fn core_team_events(&self) -> &[CoreTeamStatsEvent] {
875 &self.core_team_events
876 }
877
878 pub fn finish(&mut self) -> SubtrActorResult<()> {
879 let pending_goal_events = std::mem::take(&mut self.pending_goal_events);
880 for pending_goal_event in pending_goal_events {
881 let Some(scorer) = pending_goal_event.event.player.clone() else {
882 continue;
883 };
884 let scorer_last_touch =
885 self.reconcile_goal_context_scorer(&pending_goal_event.event, &scorer);
886 let scorer_stats = self.player_stats.entry(scorer.clone()).or_default();
887 scorer_stats.goals += 1;
888 if let Some(touch_position) = scorer_last_touch.and_then(|touch| touch.ball_position) {
889 scorer_stats
890 .scoring_context
891 .record_scoring_goal_last_touch_position(touch_position);
892 }
893 if let Some(time_after_kickoff) = pending_goal_event.time_after_kickoff {
894 scorer_stats
895 .scoring_context
896 .goal_after_kickoff
897 .record_goal(time_after_kickoff);
898 }
899 scorer_stats
900 .scoring_context
901 .goal_buildup
902 .record(pending_goal_event.goal_buildup);
903 if let Some(ball_air_time_before_goal) = pending_goal_event.ball_air_time_before_goal {
904 scorer_stats
905 .scoring_context
906 .record_goal_ball_air_time(ball_air_time_before_goal);
907 }
908
909 self.timeline.push(TimelineEvent {
910 time: pending_goal_event.event.time,
911 frame: Some(pending_goal_event.event.frame),
912 kind: TimelineEventKind::Goal,
913 player_id: Some(scorer),
914 is_team_0: Some(pending_goal_event.event.scoring_team_is_team_0),
915 });
916 }
917
918 self.timeline.sort_by(|a, b| {
919 a.time
920 .partial_cmp(&b.time)
921 .unwrap_or(std::cmp::Ordering::Equal)
922 });
923
924 Ok(())
925 }
926
927 pub fn team_zero_stats(&self) -> CoreTeamStats {
928 self.team_stats_for_side(true)
929 }
930
931 pub fn team_one_stats(&self) -> CoreTeamStats {
932 self.team_stats_for_side(false)
933 }
934
935 fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
936 let mut player_stats: Vec<_> = self
937 .player_stats
938 .iter()
939 .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
940 .collect();
941 player_stats.sort_by_cached_key(|(player_id, _)| player_id_sort_key(player_id));
942
943 let mut stats = player_stats.into_iter().fold(
944 CoreTeamStats::default(),
945 |mut stats, (_, player_stats)| {
946 stats.score += player_stats.score;
947 stats.goals += player_stats.goals;
948 stats.assists += player_stats.assists;
949 stats.saves += player_stats.saves;
950 stats.shots += player_stats.shots;
951 stats
952 .scoring_context
953 .goal_after_kickoff
954 .merge(&player_stats.scoring_context.goal_after_kickoff);
955 stats
956 .scoring_context
957 .goal_buildup
958 .merge(&player_stats.scoring_context.goal_buildup);
959 stats
960 .scoring_context
961 .goal_ball_air_time
962 .merge(&player_stats.scoring_context.goal_ball_air_time);
963 stats
964 },
965 );
966 stats
967 .scoring_context
968 .goal_after_kickoff
969 .goal_times
970 .sort_by(|left, right| left.total_cmp(right));
971 stats
972 .scoring_context
973 .goal_ball_air_time
974 .goal_ball_air_times
975 .sort_by(|left, right| left.total_cmp(right));
976 stats
977 }
978
979 fn emit_timeline_events(
980 &mut self,
981 time: f32,
982 frame: Option<usize>,
983 kind: TimelineEventKind,
984 player_id: &PlayerId,
985 is_team_0: bool,
986 delta: i32,
987 ) {
988 for _ in 0..delta.max(0) {
989 self.timeline.push(TimelineEvent {
990 time,
991 frame,
992 kind,
993 player_id: Some(player_id.clone()),
994 is_team_0: Some(is_team_0),
995 });
996 }
997 }
998
999 fn emit_core_stats_events(&mut self, frame: &FrameInfo) {
1000 let mut player_ids: Vec<_> = self.player_stats.keys().cloned().collect();
1001 player_ids.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
1002 for player_id in player_ids {
1003 let Some(stats) = self.player_stats.get(&player_id) else {
1004 continue;
1005 };
1006 let previous_stats = self
1007 .last_emitted_player_stats
1008 .get(&player_id)
1009 .cloned()
1010 .unwrap_or_default();
1011 if previous_stats == *stats {
1012 continue;
1013 }
1014 let Some(is_team_0) = self.player_teams.get(&player_id).copied() else {
1015 continue;
1016 };
1017 self.core_player_events.push(CorePlayerStatsEvent {
1018 time: frame.time,
1019 frame: frame.frame_number,
1020 player: player_id.clone(),
1021 is_team_0,
1022 delta: core_player_stats_delta(stats, &previous_stats),
1023 });
1024 self.last_emitted_player_stats
1025 .insert(player_id, stats.clone());
1026 }
1027
1028 let team_zero_stats = self.team_zero_stats();
1029 if team_zero_stats != self.last_emitted_team_zero_stats {
1030 self.core_team_events.push(CoreTeamStatsEvent {
1031 time: frame.time,
1032 frame: frame.frame_number,
1033 is_team_0: true,
1034 delta: core_team_stats_delta(&team_zero_stats, &self.last_emitted_team_zero_stats),
1035 });
1036 self.last_emitted_team_zero_stats = team_zero_stats;
1037 }
1038
1039 let team_one_stats = self.team_one_stats();
1040 if team_one_stats != self.last_emitted_team_one_stats {
1041 self.core_team_events.push(CoreTeamStatsEvent {
1042 time: frame.time,
1043 frame: frame.frame_number,
1044 is_team_0: false,
1045 delta: core_team_stats_delta(&team_one_stats, &self.last_emitted_team_one_stats),
1046 });
1047 self.last_emitted_team_one_stats = team_one_stats;
1048 }
1049 }
1050
1051 fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
1052 gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
1053 || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
1054 || gameplay.ball_has_been_hit == Some(false)
1055 }
1056
1057 fn update_kickoff_reference(&mut self, gameplay: &GameplayState, events: &FrameEventsState) {
1058 if let Some(first_touch_time) = events
1059 .touch_events
1060 .iter()
1061 .map(|event| event.time)
1062 .min_by(|a, b| a.total_cmp(b))
1063 {
1064 self.active_kickoff_touch_time = Some(first_touch_time);
1065 self.kickoff_waiting_for_first_touch = false;
1066 return;
1067 }
1068
1069 if Self::kickoff_phase_active(gameplay) {
1070 self.kickoff_waiting_for_first_touch = true;
1071 self.active_kickoff_touch_time = None;
1072 }
1073 }
1074
1075 fn take_pending_goal_event(
1076 &mut self,
1077 player_id: &PlayerId,
1078 is_team_0: bool,
1079 ) -> Option<PendingGoalEvent> {
1080 if let Some(index) = self.pending_goal_events.iter().position(|event| {
1081 event.event.scoring_team_is_team_0 == is_team_0
1082 && event.event.player.as_ref() == Some(player_id)
1083 }) {
1084 return Some(self.pending_goal_events.remove(index));
1085 }
1086
1087 self.pending_goal_events
1088 .iter()
1089 .position(|event| event.event.scoring_team_is_team_0 == is_team_0)
1090 .map(|index| self.pending_goal_events.remove(index))
1091 }
1092
1093 fn last_defender(
1094 &self,
1095 players: &PlayerFrameState,
1096 defending_team_is_team_0: bool,
1097 ) -> Option<PlayerId> {
1098 players
1099 .players
1100 .iter()
1101 .filter(|player| player.is_team_0 == defending_team_is_team_0)
1102 .filter_map(|player| {
1103 player
1104 .position()
1105 .map(|position| (player.player_id.clone(), position.y))
1106 })
1107 .reduce(|current, candidate| {
1108 if defending_team_is_team_0 {
1109 if candidate.1 < current.1 {
1110 candidate
1111 } else {
1112 current
1113 }
1114 } else if candidate.1 > current.1 {
1115 candidate
1116 } else {
1117 current
1118 }
1119 })
1120 .map(|(player_id, _)| player_id)
1121 }
1122
1123 fn most_back_player(players: &PlayerFrameState, team_is_team_0: bool) -> Option<PlayerId> {
1124 players
1125 .players
1126 .iter()
1127 .filter(|player| player.is_team_0 == team_is_team_0)
1128 .filter_map(|player| {
1129 player.position().map(|position| {
1130 (
1131 player.player_id.clone(),
1132 normalized_y(team_is_team_0, position),
1133 )
1134 })
1135 })
1136 .min_by(|left, right| left.1.total_cmp(&right.1))
1137 .map(|(player_id, _)| player_id)
1138 }
1139
1140 fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
1141 players
1142 .players
1143 .iter()
1144 .find(|player| &player.player_id == player_id)
1145 .and_then(PlayerSample::position)
1146 }
1147
1148 fn update_last_touch_contexts(
1149 &mut self,
1150 ball: &BallFrameState,
1151 players: &PlayerFrameState,
1152 touch_events: &[TouchEvent],
1153 ) {
1154 let ball_position = ball.position().map(GoalContextPosition::from);
1155 for touch in touch_events {
1156 let Some(player_id) = touch.player.clone() else {
1157 continue;
1158 };
1159 let touch_team_most_back_player = Self::most_back_player(players, touch.team_is_team_0);
1160 let other_team_most_back_player =
1161 Self::most_back_player(players, !touch.team_is_team_0);
1162 let touch_players = self.goal_player_contexts(
1163 players,
1164 touch.team_is_team_0,
1165 touch_team_most_back_player.as_ref(),
1166 other_team_most_back_player.as_ref(),
1167 );
1168 self.last_touch_context_by_player.insert(
1169 player_id.clone(),
1170 GoalTouchContext {
1171 time: touch.time,
1172 frame: touch.frame,
1173 player: player_id.clone(),
1174 is_team_0: touch.team_is_team_0,
1175 ball_position,
1176 player_position: Self::player_position(players, &player_id)
1177 .map(GoalContextPosition::from),
1178 players: touch_players,
1179 },
1180 );
1181 }
1182 }
1183
1184 fn update_boost_leadup_samples(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
1185 let cutoff_time = frame.time - GOAL_CONTEXT_BOOST_LEADUP_SECONDS;
1186 for player in &players.players {
1187 let Some(boost_amount) = player.boost_amount.or(player.last_boost_amount) else {
1188 continue;
1189 };
1190 let samples = self
1191 .boost_leadup_samples_by_player
1192 .entry(player.player_id.clone())
1193 .or_default();
1194 samples.push_back(BoostLeadupSample {
1195 time: frame.time,
1196 boost_amount,
1197 });
1198 while samples
1199 .front()
1200 .is_some_and(|sample| sample.time < cutoff_time)
1201 {
1202 samples.pop_front();
1203 }
1204 }
1205
1206 self.boost_leadup_samples_by_player
1207 .retain(|_, samples| !samples.is_empty());
1208 }
1209
1210 fn update_ball_ground_contact(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
1211 if ball
1212 .position()
1213 .is_some_and(|position| position.z <= BALL_GROUND_CONTACT_MAX_Z)
1214 {
1215 self.last_ball_ground_contact_time = Some(frame.time);
1216 }
1217 }
1218
1219 fn ball_air_time_before_goal(&self, goal_time: f32) -> Option<f32> {
1220 self.last_ball_ground_contact_time
1221 .map(|ground_contact_time| (goal_time - ground_contact_time).max(0.0))
1222 }
1223
1224 fn boost_leadup_for_player(&self, player_id: &PlayerId) -> Option<BoostLeadupStats> {
1225 let samples = self.boost_leadup_samples_by_player.get(player_id)?;
1226 if samples.is_empty() {
1227 return None;
1228 }
1229
1230 let mut sum = 0.0;
1231 let mut min_boost = f32::INFINITY;
1232 for sample in samples {
1233 sum += sample.boost_amount;
1234 min_boost = min_boost.min(sample.boost_amount);
1235 }
1236
1237 Some(BoostLeadupStats {
1238 average_boost: sum / samples.len() as f32,
1239 min_boost,
1240 })
1241 }
1242
1243 fn goal_player_contexts(
1244 &self,
1245 players: &PlayerFrameState,
1246 scoring_team_is_team_0: bool,
1247 scoring_team_most_back_player: Option<&PlayerId>,
1248 defending_team_most_back_player: Option<&PlayerId>,
1249 ) -> Vec<GoalPlayerContext> {
1250 players
1251 .players
1252 .iter()
1253 .map(|player| {
1254 let most_back_player = if player.is_team_0 == scoring_team_is_team_0 {
1255 scoring_team_most_back_player
1256 } else {
1257 defending_team_most_back_player
1258 };
1259 let boost_leadup = self.boost_leadup_for_player(&player.player_id);
1260 GoalPlayerContext {
1261 player: player.player_id.clone(),
1262 is_team_0: player.is_team_0,
1263 position: player.position().map(GoalContextPosition::from),
1264 boost_amount: player.boost_amount.or(player.last_boost_amount),
1265 average_boost_in_leadup: boost_leadup.map(|stats| stats.average_boost),
1266 min_boost_in_leadup: boost_leadup.map(|stats| stats.min_boost),
1267 is_most_back: most_back_player == Some(&player.player_id),
1268 }
1269 })
1270 .collect()
1271 }
1272
1273 fn record_goal_context_stats(
1274 &mut self,
1275 players: &PlayerFrameState,
1276 goal_event: &GoalEvent,
1277 scoring_team_most_back_player: Option<&PlayerId>,
1278 defending_team_most_back_player: Option<&PlayerId>,
1279 ) {
1280 if let Some(player_id) = scoring_team_most_back_player {
1281 self.player_stats
1282 .entry(player_id.clone())
1283 .or_default()
1284 .scoring_context
1285 .goals_for_while_most_back += 1;
1286 }
1287
1288 if let Some(player_id) = defending_team_most_back_player {
1289 self.player_stats
1290 .entry(player_id.clone())
1291 .or_default()
1292 .scoring_context
1293 .goals_against_while_most_back += 1;
1294 }
1295
1296 for player in players
1297 .players
1298 .iter()
1299 .filter(|player| player.is_team_0 != goal_event.scoring_team_is_team_0)
1300 {
1301 let boost_leadup = self.boost_leadup_for_player(&player.player_id);
1302 self.player_stats
1303 .entry(player.player_id.clone())
1304 .or_default()
1305 .scoring_context
1306 .record_goal_against_snapshot(
1307 player.boost_amount.or(player.last_boost_amount),
1308 player.position().map(GoalContextPosition::from),
1309 boost_leadup,
1310 );
1311 }
1312 }
1313
1314 fn record_goal_context_events(
1315 &mut self,
1316 ball: &BallFrameState,
1317 players: &PlayerFrameState,
1318 events: &FrameEventsState,
1319 ) {
1320 let ball_position = ball.position().map(GoalContextPosition::from);
1321 for goal_event in &events.goal_events {
1322 let scoring_team_most_back_player =
1323 Self::most_back_player(players, goal_event.scoring_team_is_team_0);
1324 let defending_team_most_back_player =
1325 Self::most_back_player(players, !goal_event.scoring_team_is_team_0);
1326 let scorer_last_touch = goal_event
1327 .player
1328 .as_ref()
1329 .and_then(|player_id| self.last_touch_context_by_player.get(player_id))
1330 .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
1331 .cloned();
1332 let ball_air_time_before_goal = self.ball_air_time_before_goal(goal_event.time);
1333 let goal_buildup =
1334 self.classify_goal_buildup(goal_event.time, goal_event.scoring_team_is_team_0);
1335
1336 self.record_goal_context_stats(
1337 players,
1338 goal_event,
1339 scoring_team_most_back_player.as_ref(),
1340 defending_team_most_back_player.as_ref(),
1341 );
1342
1343 self.goal_context_events.push(GoalContextEvent {
1344 time: goal_event.time,
1345 frame: goal_event.frame,
1346 scoring_team_is_team_0: goal_event.scoring_team_is_team_0,
1347 scorer: goal_event.player.clone(),
1348 scoring_team_most_back_player: scoring_team_most_back_player.clone(),
1349 defending_team_most_back_player: defending_team_most_back_player.clone(),
1350 ball_position,
1351 ball_air_time_before_goal,
1352 goal_buildup,
1353 scorer_last_touch,
1354 players: self.goal_player_contexts(
1355 players,
1356 goal_event.scoring_team_is_team_0,
1357 scoring_team_most_back_player.as_ref(),
1358 defending_team_most_back_player.as_ref(),
1359 ),
1360 });
1361 }
1362 }
1363
1364 fn reconcile_goal_context_scorer(
1365 &mut self,
1366 goal_event: &GoalEvent,
1367 scorer: &PlayerId,
1368 ) -> Option<GoalTouchContext> {
1369 let scorer_last_touch = self
1370 .last_touch_context_by_player
1371 .get(scorer)
1372 .filter(|touch| touch.is_team_0 == goal_event.scoring_team_is_team_0)
1373 .cloned();
1374 if let Some(context) = self.goal_context_events.iter_mut().rev().find(|context| {
1375 context.frame == goal_event.frame
1376 && context.time == goal_event.time
1377 && context.scoring_team_is_team_0 == goal_event.scoring_team_is_team_0
1378 && context.scorer.as_ref() != Some(scorer)
1379 }) {
1380 context.scorer = Some(scorer.clone());
1381 context.scorer_last_touch = scorer_last_touch.clone();
1382 }
1383 scorer_last_touch
1384 }
1385
1386 fn prune_goal_buildup_samples(&mut self, current_time: f32) {
1387 self.goal_buildup_samples
1388 .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1389 self.goal_buildup_pressure_events
1390 .retain(|entry| current_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS);
1391 }
1392
1393 fn record_goal_buildup_sample(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
1394 let Some(ball) = ball.sample() else {
1395 return;
1396 };
1397 if frame.dt <= 0.0 {
1398 return;
1399 }
1400 self.goal_buildup_samples.push(GoalBuildupSample {
1401 time: frame.time,
1402 dt: frame.dt,
1403 ball_y: ball.position().y,
1404 });
1405 }
1406
1407 fn record_goal_buildup_pressure_events(&mut self, events: &FrameEventsState) {
1408 self.goal_buildup_pressure_events.extend(
1409 events
1410 .player_stat_events
1411 .iter()
1412 .filter(|event| event.kind == PlayerStatEventKind::Shot)
1413 .map(|event| GoalBuildupPressureEvent {
1414 time: event.time,
1415 is_team_0: event.is_team_0,
1416 }),
1417 );
1418 }
1419
1420 fn classify_goal_buildup(
1421 &self,
1422 goal_time: f32,
1423 scoring_team_is_team_0: bool,
1424 ) -> GoalBuildupKind {
1425 let relevant_samples: Vec<_> = self
1426 .goal_buildup_samples
1427 .iter()
1428 .filter(|entry| entry.time <= goal_time)
1429 .filter(|entry| goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS)
1430 .collect();
1431 if relevant_samples.is_empty() {
1432 return GoalBuildupKind::Other;
1433 }
1434
1435 let mut defensive_half_time = 0.0;
1436 let mut defensive_third_time = 0.0;
1437 let mut offensive_half_time = 0.0;
1438 let mut offensive_third_time = 0.0;
1439 let mut current_attack_time = 0.0;
1440
1441 for entry in &relevant_samples {
1442 let normalized_ball_y = if scoring_team_is_team_0 {
1443 entry.ball_y
1444 } else {
1445 -entry.ball_y
1446 };
1447 if normalized_ball_y < 0.0 {
1448 defensive_half_time += entry.dt;
1449 } else {
1450 offensive_half_time += entry.dt;
1451 }
1452 if normalized_ball_y < -FIELD_ZONE_BOUNDARY_Y {
1453 defensive_third_time += entry.dt;
1454 }
1455 if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
1456 offensive_third_time += entry.dt;
1457 }
1458 }
1459
1460 for entry in relevant_samples.iter().rev() {
1461 let normalized_ball_y = if scoring_team_is_team_0 {
1462 entry.ball_y
1463 } else {
1464 -entry.ball_y
1465 };
1466 if normalized_ball_y > 0.0 {
1467 current_attack_time += entry.dt;
1468 } else {
1469 break;
1470 }
1471 }
1472
1473 let opponent_shot_in_lookback = self.goal_buildup_pressure_events.iter().any(|entry| {
1474 entry.time <= goal_time
1475 && goal_time - entry.time <= GOAL_BUILDUP_LOOKBACK_SECONDS
1476 && entry.is_team_0 != scoring_team_is_team_0
1477 });
1478 let has_defensive_pressure_signal = defensive_half_time
1479 >= COUNTER_ATTACK_MIN_DEFENSIVE_HALF_SECONDS
1480 || defensive_third_time >= COUNTER_ATTACK_MIN_DEFENSIVE_THIRD_SECONDS
1481 || opponent_shot_in_lookback;
1482
1483 if current_attack_time <= COUNTER_ATTACK_MAX_ATTACK_SECONDS && has_defensive_pressure_signal
1484 {
1485 GoalBuildupKind::CounterAttack
1486 } else if current_attack_time >= SUSTAINED_PRESSURE_MIN_ATTACK_SECONDS
1487 && offensive_half_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_HALF_SECONDS
1488 && offensive_third_time >= SUSTAINED_PRESSURE_MIN_OFFENSIVE_THIRD_SECONDS
1489 {
1490 GoalBuildupKind::SustainedPressure
1491 } else {
1492 GoalBuildupKind::Other
1493 }
1494 }
1495}
1496
1497impl MatchStatsCalculator {
1498 #[allow(clippy::too_many_arguments)]
1499 pub fn update_parts(
1500 &mut self,
1501 frame: &FrameInfo,
1502 gameplay: &GameplayState,
1503 ball: &BallFrameState,
1504 players: &PlayerFrameState,
1505 events: &FrameEventsState,
1506 live_play_state: &LivePlayState,
1507 touch_state: &TouchState,
1508 ) -> SubtrActorResult<()> {
1509 self.update_kickoff_reference(gameplay, events);
1510 self.prune_goal_buildup_samples(frame.time);
1511 self.update_ball_ground_contact(frame, ball);
1512 if live_play_state.is_live_play {
1513 self.record_goal_buildup_sample(frame, ball);
1514 self.record_goal_buildup_pressure_events(events);
1515 self.update_boost_leadup_samples(frame, players);
1516 } else if events.goal_events.is_empty() {
1517 self.last_touch_context_by_player.clear();
1518 self.boost_leadup_samples_by_player.clear();
1519 self.last_ball_ground_contact_time = None;
1520 }
1521 self.update_last_touch_contexts(ball, players, &touch_state.touch_events);
1522 self.record_goal_context_events(ball, players, events);
1523 let pending_goal_events: Vec<_> = events
1524 .goal_events
1525 .iter()
1526 .cloned()
1527 .map(|event| PendingGoalEvent {
1528 time_after_kickoff: self
1529 .active_kickoff_touch_time
1530 .map(|kickoff_touch_time| (event.time - kickoff_touch_time).max(0.0)),
1531 goal_buildup: self.classify_goal_buildup(event.time, event.scoring_team_is_team_0),
1532 ball_air_time_before_goal: self.ball_air_time_before_goal(event.time),
1533 event,
1534 })
1535 .collect();
1536 self.pending_goal_events.extend(pending_goal_events);
1537 let mut processor_event_counts: HashMap<(PlayerId, TimelineEventKind), i32> =
1538 HashMap::new();
1539 for event in &events.player_stat_events {
1540 let kind = match event.kind {
1541 PlayerStatEventKind::Shot => TimelineEventKind::Shot,
1542 PlayerStatEventKind::Save => TimelineEventKind::Save,
1543 PlayerStatEventKind::Assist => TimelineEventKind::Assist,
1544 };
1545 self.timeline.push(TimelineEvent {
1546 time: event.time,
1547 frame: Some(event.frame),
1548 kind,
1549 player_id: Some(event.player.clone()),
1550 is_team_0: Some(event.is_team_0),
1551 });
1552 *processor_event_counts
1553 .entry((event.player.clone(), kind))
1554 .or_default() += 1;
1555 }
1556
1557 for player in &players.players {
1558 self.player_teams
1559 .insert(player.player_id.clone(), player.is_team_0);
1560 let mut current_stats = CorePlayerStats {
1561 score: player.match_score.unwrap_or(0),
1562 goals: player.match_goals.unwrap_or(0),
1563 assists: player.match_assists.unwrap_or(0),
1564 saves: player.match_saves.unwrap_or(0),
1565 shots: player.match_shots.unwrap_or(0),
1566 scoring_context: self
1567 .player_stats
1568 .get(&player.player_id)
1569 .map(|stats| stats.scoring_context.clone())
1570 .unwrap_or_default(),
1571 };
1572
1573 let previous_stats = self
1574 .previous_player_stats
1575 .get(&player.player_id)
1576 .cloned()
1577 .unwrap_or_default();
1578
1579 let shot_delta = current_stats.shots - previous_stats.shots;
1580 let save_delta = current_stats.saves - previous_stats.saves;
1581 let assist_delta = current_stats.assists - previous_stats.assists;
1582 let goal_delta = current_stats.goals - previous_stats.goals;
1583 let shot_fallback_delta = shot_delta
1584 - processor_event_counts
1585 .get(&(player.player_id.clone(), TimelineEventKind::Shot))
1586 .copied()
1587 .unwrap_or(0);
1588 let save_fallback_delta = save_delta
1589 - processor_event_counts
1590 .get(&(player.player_id.clone(), TimelineEventKind::Save))
1591 .copied()
1592 .unwrap_or(0);
1593 let assist_fallback_delta = assist_delta
1594 - processor_event_counts
1595 .get(&(player.player_id.clone(), TimelineEventKind::Assist))
1596 .copied()
1597 .unwrap_or(0);
1598
1599 if shot_fallback_delta > 0 {
1600 self.emit_timeline_events(
1601 frame.time,
1602 Some(frame.frame_number),
1603 TimelineEventKind::Shot,
1604 &player.player_id,
1605 player.is_team_0,
1606 shot_fallback_delta,
1607 );
1608 }
1609 if save_fallback_delta > 0 {
1610 self.emit_timeline_events(
1611 frame.time,
1612 Some(frame.frame_number),
1613 TimelineEventKind::Save,
1614 &player.player_id,
1615 player.is_team_0,
1616 save_fallback_delta,
1617 );
1618 }
1619 if assist_fallback_delta > 0 {
1620 self.emit_timeline_events(
1621 frame.time,
1622 Some(frame.frame_number),
1623 TimelineEventKind::Assist,
1624 &player.player_id,
1625 player.is_team_0,
1626 assist_fallback_delta,
1627 );
1628 }
1629 if goal_delta > 0 {
1630 for _ in 0..goal_delta.max(0) {
1631 let pending_goal_event =
1632 self.take_pending_goal_event(&player.player_id, player.is_team_0);
1633 if let Some(pending_goal_event) = pending_goal_event.as_ref() {
1634 let scorer_last_touch = self.reconcile_goal_context_scorer(
1635 &pending_goal_event.event,
1636 &player.player_id,
1637 );
1638 if let Some(touch_position) =
1639 scorer_last_touch.and_then(|touch| touch.ball_position)
1640 {
1641 current_stats
1642 .scoring_context
1643 .record_scoring_goal_last_touch_position(touch_position);
1644 }
1645 if let Some(ball_air_time_before_goal) =
1646 pending_goal_event.ball_air_time_before_goal
1647 {
1648 current_stats
1649 .scoring_context
1650 .record_goal_ball_air_time(ball_air_time_before_goal);
1651 }
1652 }
1653 let goal_time = pending_goal_event
1654 .as_ref()
1655 .map(|event| event.event.time)
1656 .unwrap_or(frame.time);
1657 let goal_buildup = pending_goal_event
1658 .as_ref()
1659 .map(|event| event.goal_buildup)
1660 .unwrap_or_else(|| self.classify_goal_buildup(goal_time, player.is_team_0));
1661 let goal_frame = pending_goal_event
1662 .as_ref()
1663 .map(|event| event.event.frame)
1664 .unwrap_or(frame.frame_number);
1665 let time_after_kickoff = pending_goal_event
1666 .and_then(|event| event.time_after_kickoff)
1667 .or_else(|| {
1668 self.active_kickoff_touch_time
1669 .map(|kickoff_touch_time| (goal_time - kickoff_touch_time).max(0.0))
1670 });
1671 if let Some(time_after_kickoff) = time_after_kickoff {
1672 current_stats
1673 .scoring_context
1674 .goal_after_kickoff
1675 .record_goal(time_after_kickoff);
1676 }
1677 current_stats
1678 .scoring_context
1679 .goal_buildup
1680 .record(goal_buildup);
1681 self.timeline.push(TimelineEvent {
1682 time: goal_time,
1683 frame: Some(goal_frame),
1684 kind: TimelineEventKind::Goal,
1685 player_id: Some(player.player_id.clone()),
1686 is_team_0: Some(player.is_team_0),
1687 });
1688 }
1689 }
1690
1691 self.previous_player_stats
1692 .insert(player.player_id.clone(), current_stats.clone());
1693 self.player_stats
1694 .insert(player.player_id.clone(), current_stats);
1695 }
1696
1697 if let (Some(team_zero_score), Some(team_one_score)) =
1698 (gameplay.team_zero_score, gameplay.team_one_score)
1699 {
1700 if let Some((prev_team_zero_score, prev_team_one_score)) = self.previous_team_scores {
1701 let team_zero_delta = team_zero_score - prev_team_zero_score;
1702 let team_one_delta = team_one_score - prev_team_one_score;
1703
1704 if team_zero_delta > 0 {
1705 if let Some(last_defender) = self.last_defender(players, false) {
1706 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1707 stats.scoring_context.goals_conceded_while_last_defender +=
1708 team_zero_delta as u32;
1709 }
1710 }
1711 }
1712
1713 if team_one_delta > 0 {
1714 if let Some(last_defender) = self.last_defender(players, true) {
1715 if let Some(stats) = self.player_stats.get_mut(&last_defender) {
1716 stats.scoring_context.goals_conceded_while_last_defender +=
1717 team_one_delta as u32;
1718 }
1719 }
1720 }
1721 }
1722
1723 self.previous_team_scores = Some((team_zero_score, team_one_score));
1724 }
1725
1726 self.timeline.sort_by(|a, b| {
1727 a.time
1728 .partial_cmp(&b.time)
1729 .unwrap_or(std::cmp::Ordering::Equal)
1730 });
1731 self.emit_core_stats_events(frame);
1732
1733 Ok(())
1734 }
1735}
1736
1737#[cfg(test)]
1738#[path = "match_stats_tests.rs"]
1739mod tests;