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