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;
6
7#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
9#[ts(export)]
10pub struct GoalAfterKickoffStats {
11 pub kickoff_goal_count: u32,
12 pub short_goal_count: u32,
13 pub medium_goal_count: u32,
14 pub long_goal_count: u32,
15 #[serde(default, skip_serializing)]
16 pub(crate) goal_times: Vec<f32>,
17}
18
19impl GoalAfterKickoffStats {
20 pub fn goal_times(&self) -> &[f32] {
21 &self.goal_times
22 }
23
24 pub fn record_goal(&mut self, time_after_kickoff: f32) {
25 let clamped_time = time_after_kickoff.max(0.0);
26 self.goal_times.push(clamped_time);
27 self.goal_times.sort_by(|left, right| left.total_cmp(right));
28 if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_KICKOFF_MAX_SECONDS {
29 self.kickoff_goal_count += 1;
30 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_SHORT_MAX_SECONDS {
31 self.short_goal_count += 1;
32 } else if clamped_time < GOAL_AFTER_KICKOFF_BUCKET_MEDIUM_MAX_SECONDS {
33 self.medium_goal_count += 1;
34 } else {
35 self.long_goal_count += 1;
36 }
37 }
38
39 pub fn average_goal_time_after_kickoff(&self) -> f32 {
40 if self.goal_times.is_empty() {
41 0.0
42 } else {
43 self.goal_times.iter().sum::<f32>() / self.goal_times.len() as f32
44 }
45 }
46
47 pub fn median_goal_time_after_kickoff(&self) -> f32 {
48 if self.goal_times.is_empty() {
49 return 0.0;
50 }
51
52 let mut sorted_times = self.goal_times.clone();
53 sorted_times.sort_by(|a, b| a.total_cmp(b));
54 let midpoint = sorted_times.len() / 2;
55 if sorted_times.len().is_multiple_of(2) {
56 (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
57 } else {
58 sorted_times[midpoint]
59 }
60 }
61
62 pub(crate) fn merge(&mut self, other: &Self) {
63 self.kickoff_goal_count += other.kickoff_goal_count;
64 self.short_goal_count += other.short_goal_count;
65 self.medium_goal_count += other.medium_goal_count;
66 self.long_goal_count += other.long_goal_count;
67 self.goal_times.extend(other.goal_times.iter().copied());
68 self.goal_times.sort_by(|left, right| left.total_cmp(right));
69 }
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
74#[ts(export)]
75pub struct GoalBallAirTimeStats {
76 pub goal_ball_air_time_sample_count: u32,
77 pub cumulative_goal_ball_air_time: f32,
78 pub last_goal_ball_air_time: Option<f32>,
79 #[serde(default, skip_serializing)]
80 pub(crate) goal_ball_air_times: Vec<f32>,
81}
82
83impl GoalBallAirTimeStats {
84 pub fn goal_ball_air_times(&self) -> &[f32] {
85 &self.goal_ball_air_times
86 }
87
88 pub fn record_goal(&mut self, ball_air_time: f32) {
89 let clamped_time = ball_air_time.max(0.0);
90 self.goal_ball_air_time_sample_count += 1;
91 self.cumulative_goal_ball_air_time += clamped_time;
92 self.last_goal_ball_air_time = Some(clamped_time);
93 self.goal_ball_air_times.push(clamped_time);
94 self.goal_ball_air_times
95 .sort_by(|left, right| left.total_cmp(right));
96 }
97
98 pub fn average_goal_ball_air_time(&self) -> f32 {
99 if self.goal_ball_air_time_sample_count == 0 {
100 0.0
101 } else {
102 self.cumulative_goal_ball_air_time / self.goal_ball_air_time_sample_count as f32
103 }
104 }
105
106 pub fn median_goal_ball_air_time(&self) -> f32 {
107 if self.goal_ball_air_times.is_empty() {
108 return 0.0;
109 }
110
111 let mut sorted_times = self.goal_ball_air_times.clone();
112 sorted_times.sort_by(|a, b| a.total_cmp(b));
113 let midpoint = sorted_times.len() / 2;
114 if sorted_times.len().is_multiple_of(2) {
115 (sorted_times[midpoint - 1] + sorted_times[midpoint]) * 0.5
116 } else {
117 sorted_times[midpoint]
118 }
119 }
120
121 pub(crate) fn merge(&mut self, other: &Self) {
122 self.goal_ball_air_time_sample_count += other.goal_ball_air_time_sample_count;
123 self.cumulative_goal_ball_air_time += other.cumulative_goal_ball_air_time;
124 self.last_goal_ball_air_time = other
125 .last_goal_ball_air_time
126 .or(self.last_goal_ball_air_time);
127 self.goal_ball_air_times
128 .extend(other.goal_ball_air_times.iter().copied());
129 self.goal_ball_air_times
130 .sort_by(|left, right| left.total_cmp(right));
131 }
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
136#[ts(export)]
137pub struct GoalBuildupStats {
138 pub counter_attack_goal_count: u32,
139 pub sustained_pressure_goal_count: u32,
140 pub other_buildup_goal_count: u32,
141}
142
143impl GoalBuildupStats {
144 pub(crate) fn record(&mut self, kind: GoalBuildupKind) {
145 match kind {
146 GoalBuildupKind::CounterAttack => self.counter_attack_goal_count += 1,
147 GoalBuildupKind::SustainedPressure => self.sustained_pressure_goal_count += 1,
148 GoalBuildupKind::Other => self.other_buildup_goal_count += 1,
149 }
150 }
151
152 pub(crate) fn merge(&mut self, other: &Self) {
153 self.counter_attack_goal_count += other.counter_attack_goal_count;
154 self.sustained_pressure_goal_count += other.sustained_pressure_goal_count;
155 self.other_buildup_goal_count += other.other_buildup_goal_count;
156 }
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
161#[ts(export)]
162pub struct PlayerScoringContextStats {
163 pub goals_conceded_while_last_defender: u32,
164 pub goals_for_while_most_back: u32,
165 pub goals_against_while_most_back: u32,
166 pub caught_ahead_of_play_on_conceded_goals: u32,
167 pub goal_against_boost_sample_count: u32,
168 pub cumulative_boost_on_goals_against: f32,
169 pub last_boost_on_goal_against: Option<f32>,
170 pub goal_against_boost_leadup_sample_count: u32,
171 pub cumulative_average_boost_in_goal_against_leadup: f32,
172 pub cumulative_min_boost_in_goal_against_leadup: f32,
173 pub last_average_boost_in_goal_against_leadup: Option<f32>,
174 pub last_min_boost_in_goal_against_leadup: Option<f32>,
175 pub goal_against_position_sample_count: u32,
176 pub cumulative_goal_against_position_x: f32,
177 pub cumulative_goal_against_position_y: f32,
178 pub cumulative_goal_against_position_z: f32,
179 pub last_goal_against_position: Option<GoalContextPosition>,
180 pub scoring_goal_last_touch_position_sample_count: u32,
181 pub cumulative_scoring_goal_last_touch_position_x: f32,
182 pub cumulative_scoring_goal_last_touch_position_y: f32,
183 pub cumulative_scoring_goal_last_touch_position_z: f32,
184 pub last_scoring_goal_last_touch_position: Option<GoalContextPosition>,
185 #[serde(flatten)]
186 pub goal_after_kickoff: GoalAfterKickoffStats,
187 #[serde(flatten)]
188 pub goal_buildup: GoalBuildupStats,
189 #[serde(default, flatten)]
190 pub goal_ball_air_time: GoalBallAirTimeStats,
191}
192
193impl PlayerScoringContextStats {
194 pub(crate) fn record_goal_against_snapshot(
195 &mut self,
196 boost_amount: Option<f32>,
197 position: Option<GoalContextPosition>,
198 boost_leadup: Option<(f32, f32)>,
199 ) {
200 if let Some(boost_amount) = boost_amount {
201 self.goal_against_boost_sample_count += 1;
202 self.cumulative_boost_on_goals_against += boost_amount;
203 self.last_boost_on_goal_against = Some(boost_amount);
204 }
205
206 if let Some((average_boost, min_boost)) = boost_leadup {
207 self.goal_against_boost_leadup_sample_count += 1;
208 self.cumulative_average_boost_in_goal_against_leadup += average_boost;
209 self.cumulative_min_boost_in_goal_against_leadup += min_boost;
210 self.last_average_boost_in_goal_against_leadup = Some(average_boost);
211 self.last_min_boost_in_goal_against_leadup = Some(min_boost);
212 }
213
214 if let Some(position) = position {
215 self.goal_against_position_sample_count += 1;
216 self.cumulative_goal_against_position_x += position.x;
217 self.cumulative_goal_against_position_y += position.y;
218 self.cumulative_goal_against_position_z += position.z;
219 self.last_goal_against_position = Some(position);
220 }
221 }
222
223 pub(crate) fn record_scoring_goal_last_touch_position(
224 &mut self,
225 position: GoalContextPosition,
226 ) {
227 self.scoring_goal_last_touch_position_sample_count += 1;
228 self.cumulative_scoring_goal_last_touch_position_x += position.x;
229 self.cumulative_scoring_goal_last_touch_position_y += position.y;
230 self.cumulative_scoring_goal_last_touch_position_z += position.z;
231 self.last_scoring_goal_last_touch_position = Some(position);
232 }
233
234 pub(crate) fn record_goal_ball_air_time(&mut self, ball_air_time: f32) {
235 self.goal_ball_air_time.record_goal(ball_air_time);
236 }
237
238 fn average_boost_on_goals_against(&self) -> f32 {
239 if self.goal_against_boost_sample_count == 0 {
240 0.0
241 } else {
242 self.cumulative_boost_on_goals_against / self.goal_against_boost_sample_count as f32
243 }
244 }
245
246 fn average_boost_in_goal_against_leadup(&self) -> f32 {
247 if self.goal_against_boost_leadup_sample_count == 0 {
248 0.0
249 } else {
250 self.cumulative_average_boost_in_goal_against_leadup
251 / self.goal_against_boost_leadup_sample_count as f32
252 }
253 }
254
255 fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
256 if self.goal_against_boost_leadup_sample_count == 0 {
257 0.0
258 } else {
259 self.cumulative_min_boost_in_goal_against_leadup
260 / self.goal_against_boost_leadup_sample_count as f32
261 }
262 }
263
264 fn average_goal_against_position_x(&self) -> f32 {
265 if self.goal_against_position_sample_count == 0 {
266 0.0
267 } else {
268 self.cumulative_goal_against_position_x / self.goal_against_position_sample_count as f32
269 }
270 }
271
272 fn average_goal_against_position_y(&self) -> f32 {
273 if self.goal_against_position_sample_count == 0 {
274 0.0
275 } else {
276 self.cumulative_goal_against_position_y / self.goal_against_position_sample_count as f32
277 }
278 }
279
280 fn average_goal_against_position_z(&self) -> f32 {
281 if self.goal_against_position_sample_count == 0 {
282 0.0
283 } else {
284 self.cumulative_goal_against_position_z / self.goal_against_position_sample_count as f32
285 }
286 }
287
288 fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
289 if self.scoring_goal_last_touch_position_sample_count == 0 {
290 0.0
291 } else {
292 self.cumulative_scoring_goal_last_touch_position_x
293 / self.scoring_goal_last_touch_position_sample_count as f32
294 }
295 }
296
297 fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
298 if self.scoring_goal_last_touch_position_sample_count == 0 {
299 0.0
300 } else {
301 self.cumulative_scoring_goal_last_touch_position_y
302 / self.scoring_goal_last_touch_position_sample_count as f32
303 }
304 }
305
306 fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
307 if self.scoring_goal_last_touch_position_sample_count == 0 {
308 0.0
309 } else {
310 self.cumulative_scoring_goal_last_touch_position_z
311 / self.scoring_goal_last_touch_position_sample_count as f32
312 }
313 }
314}
315
316#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
318#[ts(export)]
319pub struct CorePlayerStats {
320 pub score: i32,
321 pub goals: i32,
322 pub assists: i32,
323 pub saves: i32,
324 pub shots: i32,
325 #[serde(flatten)]
326 pub scoring_context: PlayerScoringContextStats,
327}
328
329impl CorePlayerStats {
330 pub fn shooting_percentage(&self) -> f32 {
331 if self.shots == 0 {
332 0.0
333 } else {
334 self.goals as f32 * 100.0 / self.shots as f32
335 }
336 }
337
338 pub fn average_goal_time_after_kickoff(&self) -> f32 {
339 self.scoring_context
340 .goal_after_kickoff
341 .average_goal_time_after_kickoff()
342 }
343
344 pub fn median_goal_time_after_kickoff(&self) -> f32 {
345 self.scoring_context
346 .goal_after_kickoff
347 .median_goal_time_after_kickoff()
348 }
349
350 pub fn average_boost_on_goals_against(&self) -> f32 {
351 self.scoring_context.average_boost_on_goals_against()
352 }
353
354 pub fn average_boost_in_goal_against_leadup(&self) -> f32 {
355 self.scoring_context.average_boost_in_goal_against_leadup()
356 }
357
358 pub fn average_min_boost_in_goal_against_leadup(&self) -> f32 {
359 self.scoring_context
360 .average_min_boost_in_goal_against_leadup()
361 }
362
363 pub fn average_goal_against_position_x(&self) -> f32 {
364 self.scoring_context.average_goal_against_position_x()
365 }
366
367 pub fn average_goal_against_position_y(&self) -> f32 {
368 self.scoring_context.average_goal_against_position_y()
369 }
370
371 pub fn average_goal_against_position_z(&self) -> f32 {
372 self.scoring_context.average_goal_against_position_z()
373 }
374
375 pub fn average_scoring_goal_last_touch_position_x(&self) -> f32 {
376 self.scoring_context
377 .average_scoring_goal_last_touch_position_x()
378 }
379
380 pub fn average_scoring_goal_last_touch_position_y(&self) -> f32 {
381 self.scoring_context
382 .average_scoring_goal_last_touch_position_y()
383 }
384
385 pub fn average_scoring_goal_last_touch_position_z(&self) -> f32 {
386 self.scoring_context
387 .average_scoring_goal_last_touch_position_z()
388 }
389
390 pub fn average_goal_ball_air_time(&self) -> f32 {
391 self.scoring_context
392 .goal_ball_air_time
393 .average_goal_ball_air_time()
394 }
395
396 pub fn median_goal_ball_air_time(&self) -> f32 {
397 self.scoring_context
398 .goal_ball_air_time
399 .median_goal_ball_air_time()
400 }
401}
402
403#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
405#[ts(export)]
406pub struct TeamScoringContextStats {
407 #[serde(flatten)]
408 pub goal_after_kickoff: GoalAfterKickoffStats,
409 #[serde(flatten)]
410 pub goal_buildup: GoalBuildupStats,
411 #[serde(default, flatten)]
412 pub goal_ball_air_time: GoalBallAirTimeStats,
413}
414
415#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
417#[ts(export)]
418pub struct CoreTeamStats {
419 pub score: i32,
420 pub goals: i32,
421 pub assists: i32,
422 pub saves: i32,
423 pub shots: i32,
424 #[serde(flatten)]
425 pub scoring_context: TeamScoringContextStats,
426}
427
428impl CoreTeamStats {
429 pub fn shooting_percentage(&self) -> f32 {
430 if self.shots == 0 {
431 0.0
432 } else {
433 self.goals as f32 * 100.0 / self.shots as f32
434 }
435 }
436
437 pub fn average_goal_time_after_kickoff(&self) -> f32 {
438 self.scoring_context
439 .goal_after_kickoff
440 .average_goal_time_after_kickoff()
441 }
442
443 pub fn median_goal_time_after_kickoff(&self) -> f32 {
444 self.scoring_context
445 .goal_after_kickoff
446 .median_goal_time_after_kickoff()
447 }
448
449 pub fn average_goal_ball_air_time(&self) -> f32 {
450 self.scoring_context
451 .goal_ball_air_time
452 .average_goal_ball_air_time()
453 }
454
455 pub fn median_goal_ball_air_time(&self) -> f32 {
456 self.scoring_context
457 .goal_ball_air_time
458 .median_goal_ball_air_time()
459 }
460}
461
462pub(crate) fn player_id_sort_key(player_id: &PlayerId) -> String {
463 match player_id {
464 boxcars::RemoteId::PlayStation(id) => {
465 format!("playstation:{}:{}:{:?}", id.online_id, id.name, id.unknown1)
466 }
467 boxcars::RemoteId::PsyNet(id) => format!("psynet:{}:{:?}", id.online_id, id.unknown1),
468 boxcars::RemoteId::SplitScreen(id) => format!("splitscreen:{id}"),
469 boxcars::RemoteId::Steam(id) => format!("steam:{id}"),
470 boxcars::RemoteId::Switch(id) => format!("switch:{}:{:?}", id.online_id, id.unknown1),
471 boxcars::RemoteId::Xbox(id) => format!("xbox:{id}"),
472 boxcars::RemoteId::QQ(id) => format!("qq:{id}"),
473 boxcars::RemoteId::Epic(id) => format!("epic:{id}"),
474 }
475}
476
477#[derive(Debug, Clone, Default)]
479pub struct CoreStatsAccumulator {
480 player_stats: HashMap<PlayerId, CorePlayerStats>,
481 player_teams: HashMap<PlayerId, bool>,
482}
483
484impl CoreStatsAccumulator {
485 pub fn new() -> Self {
486 Self::default()
487 }
488
489 pub fn player_stats(&self) -> &HashMap<PlayerId, CorePlayerStats> {
490 &self.player_stats
491 }
492
493 pub fn player_stats_for(&self, player_id: &PlayerId) -> CorePlayerStats {
494 self.player_stats
495 .get(player_id)
496 .cloned()
497 .unwrap_or_default()
498 }
499
500 pub fn ensure_player(&mut self, player_id: PlayerId, is_team_0: bool) {
501 self.player_teams.insert(player_id.clone(), is_team_0);
502 self.player_stats.entry(player_id).or_default();
503 }
504
505 pub fn team_zero_stats(&self) -> CoreTeamStats {
506 self.team_stats_for_side(true)
507 }
508
509 pub fn team_one_stats(&self) -> CoreTeamStats {
510 self.team_stats_for_side(false)
511 }
512
513 pub fn team_stats_for_side(&self, is_team_0: bool) -> CoreTeamStats {
514 let mut player_stats: Vec<_> = self
515 .player_stats
516 .iter()
517 .filter(|(player_id, _)| self.player_teams.get(*player_id) == Some(&is_team_0))
518 .collect();
519 player_stats.sort_by_cached_key(|(player_id, _)| player_id_sort_key(player_id));
520
521 let mut stats = player_stats.into_iter().fold(
522 CoreTeamStats::default(),
523 |mut stats, (_, player_stats)| {
524 stats.score += player_stats.score;
525 stats.goals += player_stats.goals;
526 stats.assists += player_stats.assists;
527 stats.saves += player_stats.saves;
528 stats.shots += player_stats.shots;
529 stats
530 .scoring_context
531 .goal_after_kickoff
532 .merge(&player_stats.scoring_context.goal_after_kickoff);
533 stats
534 .scoring_context
535 .goal_buildup
536 .merge(&player_stats.scoring_context.goal_buildup);
537 stats
538 .scoring_context
539 .goal_ball_air_time
540 .merge(&player_stats.scoring_context.goal_ball_air_time);
541 stats
542 },
543 );
544 stats
545 .scoring_context
546 .goal_after_kickoff
547 .goal_times
548 .sort_by(|left, right| left.total_cmp(right));
549 stats
550 .scoring_context
551 .goal_ball_air_time
552 .goal_ball_air_times
553 .sort_by(|left, right| left.total_cmp(right));
554 stats
555 }
556
557 pub fn apply_scoreboard_event(&mut self, event: &CorePlayerScoreboardEvent) {
558 self.player_teams
559 .insert(event.player.clone(), event.is_team_0);
560 let stats = self.player_stats.entry(event.player.clone()).or_default();
561 stats.score += event.score_delta;
562 stats.goals += event.goals_delta;
563 stats.assists += event.assists_delta;
564 stats.saves += event.saves_delta;
565 stats.shots += event.shots_delta;
566 }
567
568 pub fn apply_goal_context_event(&mut self, event: &CorePlayerGoalContextEvent) {
569 self.player_teams
570 .insert(event.player.clone(), event.is_team_0);
571 let stats = self.player_stats.entry(event.player.clone()).or_default();
572 let scoring_context = &mut stats.scoring_context;
573 if event.goals_conceded_while_last_defender {
574 scoring_context.goals_conceded_while_last_defender += 1;
575 }
576 if event.goals_for_while_most_back {
577 scoring_context.goals_for_while_most_back += 1;
578 }
579 if event.goals_against_while_most_back {
580 scoring_context.goals_against_while_most_back += 1;
581 }
582 if event.caught_ahead_of_play_on_conceded_goal {
583 scoring_context.caught_ahead_of_play_on_conceded_goals += 1;
584 }
585 scoring_context.record_goal_against_snapshot(
586 event.goal_against_boost_amount,
587 event.goal_against_position,
588 match (
589 event.goal_against_average_boost_in_leadup,
590 event.goal_against_min_boost_in_leadup,
591 ) {
592 (Some(average), Some(minimum)) => Some((average, minimum)),
593 _ => None,
594 },
595 );
596 if let Some(position) = event.scoring_goal_last_touch_position {
597 scoring_context.record_scoring_goal_last_touch_position(position);
598 }
599 if let Some(time_after_kickoff) = event.time_after_kickoff {
600 scoring_context
601 .goal_after_kickoff
602 .record_goal(time_after_kickoff);
603 }
604 if let Some(goal_buildup) = event.goal_buildup {
605 scoring_context.goal_buildup.record(goal_buildup);
606 }
607 if let Some(ball_air_time_before_goal) = event.ball_air_time_before_goal {
608 scoring_context.record_goal_ball_air_time(ball_air_time_before_goal);
609 }
610 }
611}