1use anyhow::Context;
2use std::collections::BTreeMap;
3use std::fmt;
4use std::path::Path;
5
6use crate::*;
7use serde_json::Value;
8
9pub fn parse_replay_bytes(data: &[u8]) -> anyhow::Result<boxcars::Replay> {
10 boxcars::ParserBuilder::new(data)
11 .always_check_crc()
12 .must_parse_network_data()
13 .parse()
14 .context("Failed to parse replay")
15}
16
17pub fn parse_replay_file(path: impl AsRef<Path>) -> anyhow::Result<boxcars::Replay> {
18 let path = path.as_ref();
19 let data = std::fs::read(path)
20 .with_context(|| format!("Failed to read replay file: {}", path.display()))?;
21 parse_replay_bytes(&data).with_context(|| format!("Failed to parse replay: {}", path.display()))
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25enum TeamColor {
26 Blue,
27 Orange,
28}
29
30impl TeamColor {
31 fn ballchasing_key(self) -> &'static str {
32 match self {
33 Self::Blue => "blue",
34 Self::Orange => "orange",
35 }
36 }
37}
38
39impl fmt::Display for TeamColor {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::Blue => write!(f, "blue"),
43 Self::Orange => write!(f, "orange"),
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49enum StatScope {
50 Team(TeamColor),
51 Player { team: TeamColor, name: String },
52}
53
54impl fmt::Display for StatScope {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 Self::Team(team) => write!(f, "team.{team}"),
58 Self::Player { team, name } => write!(f, "player.{team}.{name}"),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum StatDomain {
65 Core,
66 Boost,
67 Movement,
68 Positioning,
69 Demo,
70}
71
72impl fmt::Display for StatDomain {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 match self {
75 Self::Core => write!(f, "core"),
76 Self::Boost => write!(f, "boost"),
77 Self::Movement => write!(f, "movement"),
78 Self::Positioning => write!(f, "positioning"),
79 Self::Demo => write!(f, "demo"),
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum StatKey {
86 Score,
87 Goals,
88 Assists,
89 Saves,
90 Shots,
91 ShootingPercentage,
92 Bpm,
93 AvgAmount,
94 AmountCollected,
95 AmountStolen,
96 AmountCollectedBig,
97 AmountStolenBig,
98 AmountCollectedSmall,
99 AmountStolenSmall,
100 CountCollectedBig,
101 CountStolenBig,
102 CountCollectedSmall,
103 CountStolenSmall,
104 AmountOverfill,
105 AmountOverfillStolen,
106 AmountUsedWhileSupersonic,
107 TimeZeroBoost,
108 PercentZeroBoost,
109 TimeFullBoost,
110 PercentFullBoost,
111 TimeBoost0To25,
112 TimeBoost25To50,
113 TimeBoost50To75,
114 TimeBoost75To100,
115 PercentBoost0To25,
116 PercentBoost25To50,
117 PercentBoost50To75,
118 PercentBoost75To100,
119 AvgSpeed,
120 TotalDistance,
121 TimeSupersonicSpeed,
122 TimeBoostSpeed,
123 TimeSlowSpeed,
124 TimeGround,
125 TimeLowAir,
126 TimeHighAir,
127 TimePowerslide,
128 CountPowerslide,
129 AvgPowerslideDuration,
130 AvgSpeedPercentage,
131 PercentSlowSpeed,
132 PercentBoostSpeed,
133 PercentSupersonicSpeed,
134 PercentGround,
135 PercentLowAir,
136 PercentHighAir,
137 AvgDistanceToBall,
138 AvgDistanceToBallPossession,
139 AvgDistanceToBallNoPossession,
140 AvgDistanceToMates,
141 TimeDefensiveThird,
142 TimeNeutralThird,
143 TimeOffensiveThird,
144 TimeDefensiveHalf,
145 TimeOffensiveHalf,
146 TimeBehindBall,
147 TimeInfrontBall,
148 TimeMostBack,
149 TimeMostForward,
150 TimeClosestToBall,
151 TimeFarthestFromBall,
152 PercentDefensiveThird,
153 PercentNeutralThird,
154 PercentOffensiveThird,
155 PercentDefensiveHalf,
156 PercentOffensiveHalf,
157 PercentBehindBall,
158 PercentInfrontBall,
159 PercentMostBack,
160 PercentMostForward,
161 PercentClosestToBall,
162 PercentFarthestFromBall,
163 DemoInflicted,
164 DemoTaken,
165}
166
167impl fmt::Display for StatKey {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 let name = match self {
170 Self::Score => "score",
171 Self::Goals => "goals",
172 Self::Assists => "assists",
173 Self::Saves => "saves",
174 Self::Shots => "shots",
175 Self::ShootingPercentage => "shooting_percentage",
176 Self::Bpm => "bpm",
177 Self::AvgAmount => "avg_amount",
178 Self::AmountCollected => "amount_collected",
179 Self::AmountStolen => "amount_stolen",
180 Self::AmountCollectedBig => "amount_collected_big",
181 Self::AmountStolenBig => "amount_stolen_big",
182 Self::AmountCollectedSmall => "amount_collected_small",
183 Self::AmountStolenSmall => "amount_stolen_small",
184 Self::CountCollectedBig => "count_collected_big",
185 Self::CountStolenBig => "count_stolen_big",
186 Self::CountCollectedSmall => "count_collected_small",
187 Self::CountStolenSmall => "count_stolen_small",
188 Self::AmountOverfill => "amount_overfill",
189 Self::AmountOverfillStolen => "amount_overfill_stolen",
190 Self::AmountUsedWhileSupersonic => "amount_used_while_supersonic",
191 Self::TimeZeroBoost => "time_zero_boost",
192 Self::PercentZeroBoost => "percent_zero_boost",
193 Self::TimeFullBoost => "time_full_boost",
194 Self::PercentFullBoost => "percent_full_boost",
195 Self::TimeBoost0To25 => "time_boost_0_25",
196 Self::TimeBoost25To50 => "time_boost_25_50",
197 Self::TimeBoost50To75 => "time_boost_50_75",
198 Self::TimeBoost75To100 => "time_boost_75_100",
199 Self::PercentBoost0To25 => "percent_boost_0_25",
200 Self::PercentBoost25To50 => "percent_boost_25_50",
201 Self::PercentBoost50To75 => "percent_boost_50_75",
202 Self::PercentBoost75To100 => "percent_boost_75_100",
203 Self::AvgSpeed => "avg_speed",
204 Self::TotalDistance => "total_distance",
205 Self::TimeSupersonicSpeed => "time_supersonic_speed",
206 Self::TimeBoostSpeed => "time_boost_speed",
207 Self::TimeSlowSpeed => "time_slow_speed",
208 Self::TimeGround => "time_ground",
209 Self::TimeLowAir => "time_low_air",
210 Self::TimeHighAir => "time_high_air",
211 Self::TimePowerslide => "time_powerslide",
212 Self::CountPowerslide => "count_powerslide",
213 Self::AvgPowerslideDuration => "avg_powerslide_duration",
214 Self::AvgSpeedPercentage => "avg_speed_percentage",
215 Self::PercentSlowSpeed => "percent_slow_speed",
216 Self::PercentBoostSpeed => "percent_boost_speed",
217 Self::PercentSupersonicSpeed => "percent_supersonic_speed",
218 Self::PercentGround => "percent_ground",
219 Self::PercentLowAir => "percent_low_air",
220 Self::PercentHighAir => "percent_high_air",
221 Self::AvgDistanceToBall => "avg_distance_to_ball",
222 Self::AvgDistanceToBallPossession => "avg_distance_to_ball_possession",
223 Self::AvgDistanceToBallNoPossession => "avg_distance_to_ball_no_possession",
224 Self::AvgDistanceToMates => "avg_distance_to_mates",
225 Self::TimeDefensiveThird => "time_defensive_third",
226 Self::TimeNeutralThird => "time_neutral_third",
227 Self::TimeOffensiveThird => "time_offensive_third",
228 Self::TimeDefensiveHalf => "time_defensive_half",
229 Self::TimeOffensiveHalf => "time_offensive_half",
230 Self::TimeBehindBall => "time_behind_ball",
231 Self::TimeInfrontBall => "time_infront_ball",
232 Self::TimeMostBack => "time_most_back",
233 Self::TimeMostForward => "time_most_forward",
234 Self::TimeClosestToBall => "time_closest_to_ball",
235 Self::TimeFarthestFromBall => "time_farthest_from_ball",
236 Self::PercentDefensiveThird => "percent_defensive_third",
237 Self::PercentNeutralThird => "percent_neutral_third",
238 Self::PercentOffensiveThird => "percent_offensive_third",
239 Self::PercentDefensiveHalf => "percent_defensive_half",
240 Self::PercentOffensiveHalf => "percent_offensive_half",
241 Self::PercentBehindBall => "percent_behind_ball",
242 Self::PercentInfrontBall => "percent_infront_ball",
243 Self::PercentMostBack => "percent_most_back",
244 Self::PercentMostForward => "percent_most_forward",
245 Self::PercentClosestToBall => "percent_closest_to_ball",
246 Self::PercentFarthestFromBall => "percent_farthest_from_ball",
247 Self::DemoInflicted => "inflicted",
248 Self::DemoTaken => "taken",
249 };
250 write!(f, "{name}")
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
255struct ComparisonTarget {
256 scope: StatScope,
257 domain: StatDomain,
258 key: StatKey,
259}
260
261impl fmt::Display for ComparisonTarget {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 write!(f, "{}.{}.{}", self.scope, self.domain, self.key)
264 }
265}
266
267type MatchSelector = dyn Fn(&ComparisonTarget) -> bool;
268type MatchPredicate = dyn Fn(f64, f64, &ComparisonTarget) -> bool;
269
270struct MatchRule {
271 description: String,
272 selector: Box<MatchSelector>,
273 predicate: Box<MatchPredicate>,
274}
275
276#[derive(Default)]
277pub struct MatchConfig {
278 rules: Vec<MatchRule>,
279}
280
281struct MatchOutcome<'a> {
282 matches: bool,
283 description: &'a str,
284}
285
286impl MatchConfig {
287 fn exact() -> Self {
288 Self::default()
289 }
290
291 fn with_rule<S, P>(mut self, description: impl Into<String>, selector: S, predicate: P) -> Self
292 where
293 S: Fn(&ComparisonTarget) -> bool + 'static,
294 P: Fn(f64, f64, &ComparisonTarget) -> bool + 'static,
295 {
296 self.rules.push(MatchRule {
297 description: description.into(),
298 selector: Box::new(selector),
299 predicate: Box::new(predicate),
300 });
301 self
302 }
303
304 fn evaluate<'a>(
305 &'a self,
306 actual: f64,
307 expected: f64,
308 target: &ComparisonTarget,
309 ) -> MatchOutcome<'a> {
310 let default = MatchOutcome {
311 matches: actual == expected,
312 description: "exact",
313 };
314
315 self.rules
316 .iter()
317 .rev()
318 .find(|rule| (rule.selector)(target))
319 .map(|rule| MatchOutcome {
320 matches: (rule.predicate)(actual, expected, target),
321 description: &rule.description,
322 })
323 .unwrap_or(default)
324 }
325}
326
327fn approx_abs(abs_tol: f64) -> impl Fn(f64, f64, &ComparisonTarget) -> bool {
328 move |actual, expected, _| (actual - expected).abs() <= abs_tol
329}
330
331pub fn recommended_ballchasing_match_config() -> MatchConfig {
332 MatchConfig::exact()
333 .with_rule(
334 "shooting percentage abs<=0.01",
335 |target| target.key == StatKey::ShootingPercentage,
336 approx_abs(0.01),
337 )
338 .with_rule(
339 "boost amount style fields abs<=2",
340 |target| {
341 matches!(
342 target.key,
343 StatKey::AmountCollected
344 | StatKey::AmountStolen
345 | StatKey::AmountCollectedBig
346 | StatKey::AmountStolenBig
347 | StatKey::AmountCollectedSmall
348 | StatKey::AmountStolenSmall
349 | StatKey::AmountOverfill
350 | StatKey::AmountOverfillStolen
351 | StatKey::AmountUsedWhileSupersonic
352 )
353 },
354 approx_abs(2.0),
355 )
356 .with_rule(
357 "boost timing and percentage fields abs<=1",
358 |target| {
359 matches!(
360 target.key,
361 StatKey::Bpm
362 | StatKey::AvgAmount
363 | StatKey::TimeZeroBoost
364 | StatKey::PercentZeroBoost
365 | StatKey::TimeFullBoost
366 | StatKey::PercentFullBoost
367 | StatKey::TimeBoost0To25
368 | StatKey::TimeBoost25To50
369 | StatKey::TimeBoost50To75
370 | StatKey::TimeBoost75To100
371 | StatKey::PercentBoost0To25
372 | StatKey::PercentBoost25To50
373 | StatKey::PercentBoost50To75
374 | StatKey::PercentBoost75To100
375 )
376 },
377 approx_abs(1.0),
378 )
379 .with_rule(
380 "movement timing and percentage fields abs<=1",
381 |target| {
382 matches!(
383 target.key,
384 StatKey::TimeSupersonicSpeed
385 | StatKey::TimeBoostSpeed
386 | StatKey::TimeSlowSpeed
387 | StatKey::TimeGround
388 | StatKey::TimeLowAir
389 | StatKey::TimeHighAir
390 | StatKey::TimePowerslide
391 | StatKey::PercentSlowSpeed
392 | StatKey::PercentBoostSpeed
393 | StatKey::PercentSupersonicSpeed
394 | StatKey::PercentGround
395 | StatKey::PercentLowAir
396 | StatKey::PercentHighAir
397 )
398 },
399 approx_abs(1.0),
400 )
401 .with_rule(
402 "movement distance/speed fields tolerate Ballchasing rounding",
403 |target| {
404 matches!(
405 target.key,
406 StatKey::AvgSpeed
407 | StatKey::AvgSpeedPercentage
408 | StatKey::TotalDistance
409 | StatKey::AvgPowerslideDuration
410 )
411 },
412 |actual, expected, target| {
413 let tol = match target.key {
414 StatKey::AvgSpeed => 5.0,
415 StatKey::AvgSpeedPercentage => 0.5,
416 StatKey::TotalDistance => 2500.0,
417 StatKey::AvgPowerslideDuration => 0.1,
418 _ => 0.0,
419 };
420 (actual - expected).abs() <= tol
421 },
422 )
423 .with_rule(
424 "positioning fields abs<=1 or 50 depending on metric",
425 |target| target.domain == StatDomain::Positioning,
426 |actual, expected, target| {
427 let tol = match target.key {
428 StatKey::AvgDistanceToBall
429 | StatKey::AvgDistanceToBallPossession
430 | StatKey::AvgDistanceToBallNoPossession
431 | StatKey::AvgDistanceToMates => 50.0,
432 _ => 1.0,
433 };
434 (actual - expected).abs() <= tol
435 },
436 )
437}
438
439#[derive(Debug, Default)]
440struct StatMatcher {
441 mismatches: Vec<String>,
442}
443
444impl StatMatcher {
445 fn compare_field(
446 &mut self,
447 actual: Option<f64>,
448 expected: Option<f64>,
449 target: ComparisonTarget,
450 config: &MatchConfig,
451 ) {
452 let Some(expected_value) = expected else {
453 return;
454 };
455 let Some(actual_value) = actual else {
456 self.mismatches
457 .push(format!("{target}: missing actual value"));
458 return;
459 };
460
461 let outcome = config.evaluate(actual_value, expected_value, &target);
462 if !outcome.matches {
463 self.mismatches.push(format!(
464 "{target}: actual={actual_value} expected={expected_value} predicate={}",
465 outcome.description
466 ));
467 }
468 }
469
470 fn missing_player(&mut self, scope: &StatScope) {
471 self.mismatches
472 .push(format!("{scope}: missing actual player"));
473 }
474}
475
476#[derive(Debug, Clone, Default, PartialEq)]
477struct ComparableCoreStats {
478 score: Option<f64>,
479 goals: Option<f64>,
480 assists: Option<f64>,
481 saves: Option<f64>,
482 shots: Option<f64>,
483 shooting_percentage: Option<f64>,
484}
485
486#[derive(Debug, Clone, Default, PartialEq)]
487struct ComparableBoostStats {
488 bpm: Option<f64>,
489 avg_amount: Option<f64>,
490 amount_collected: Option<f64>,
491 amount_stolen: Option<f64>,
492 amount_collected_big: Option<f64>,
493 amount_stolen_big: Option<f64>,
494 amount_collected_small: Option<f64>,
495 amount_stolen_small: Option<f64>,
496 count_collected_big: Option<f64>,
497 count_stolen_big: Option<f64>,
498 count_collected_small: Option<f64>,
499 count_stolen_small: Option<f64>,
500 amount_overfill: Option<f64>,
501 amount_overfill_stolen: Option<f64>,
502 amount_used_while_supersonic: Option<f64>,
503 time_zero_boost: Option<f64>,
504 percent_zero_boost: Option<f64>,
505 time_full_boost: Option<f64>,
506 percent_full_boost: Option<f64>,
507 time_boost_0_25: Option<f64>,
508 time_boost_25_50: Option<f64>,
509 time_boost_50_75: Option<f64>,
510 time_boost_75_100: Option<f64>,
511 percent_boost_0_25: Option<f64>,
512 percent_boost_25_50: Option<f64>,
513 percent_boost_50_75: Option<f64>,
514 percent_boost_75_100: Option<f64>,
515}
516
517#[derive(Debug, Clone, Default, PartialEq)]
518struct ComparableMovementStats {
519 avg_speed: Option<f64>,
520 total_distance: Option<f64>,
521 time_supersonic_speed: Option<f64>,
522 time_boost_speed: Option<f64>,
523 time_slow_speed: Option<f64>,
524 time_ground: Option<f64>,
525 time_low_air: Option<f64>,
526 time_high_air: Option<f64>,
527 time_powerslide: Option<f64>,
528 count_powerslide: Option<f64>,
529 avg_powerslide_duration: Option<f64>,
530 avg_speed_percentage: Option<f64>,
531 percent_slow_speed: Option<f64>,
532 percent_boost_speed: Option<f64>,
533 percent_supersonic_speed: Option<f64>,
534 percent_ground: Option<f64>,
535 percent_low_air: Option<f64>,
536 percent_high_air: Option<f64>,
537}
538
539#[derive(Debug, Clone, Default, PartialEq)]
540struct ComparablePositioningStats {
541 avg_distance_to_ball: Option<f64>,
542 avg_distance_to_ball_possession: Option<f64>,
543 avg_distance_to_ball_no_possession: Option<f64>,
544 avg_distance_to_mates: Option<f64>,
545 time_defensive_third: Option<f64>,
546 time_neutral_third: Option<f64>,
547 time_offensive_third: Option<f64>,
548 time_defensive_half: Option<f64>,
549 time_offensive_half: Option<f64>,
550 time_behind_ball: Option<f64>,
551 time_infront_ball: Option<f64>,
552 time_most_back: Option<f64>,
553 time_most_forward: Option<f64>,
554 time_closest_to_ball: Option<f64>,
555 time_farthest_from_ball: Option<f64>,
556 percent_defensive_third: Option<f64>,
557 percent_neutral_third: Option<f64>,
558 percent_offensive_third: Option<f64>,
559 percent_defensive_half: Option<f64>,
560 percent_offensive_half: Option<f64>,
561 percent_behind_ball: Option<f64>,
562 percent_infront_ball: Option<f64>,
563 percent_most_back: Option<f64>,
564 percent_most_forward: Option<f64>,
565 percent_closest_to_ball: Option<f64>,
566 percent_farthest_from_ball: Option<f64>,
567}
568
569#[derive(Debug, Clone, Default, PartialEq)]
570struct ComparableDemoStats {
571 inflicted: Option<f64>,
572 taken: Option<f64>,
573}
574
575#[derive(Debug, Clone, Default, PartialEq)]
576struct ComparablePlayerStats {
577 core: ComparableCoreStats,
578 boost: ComparableBoostStats,
579 movement: ComparableMovementStats,
580 positioning: ComparablePositioningStats,
581 demo: ComparableDemoStats,
582}
583
584#[derive(Debug, Clone, Default, PartialEq)]
585struct ComparableTeamStats {
586 core: ComparableCoreStats,
587 boost: ComparableBoostStats,
588 movement: ComparableMovementStats,
589 demo: ComparableDemoStats,
590 players: BTreeMap<String, ComparablePlayerStats>,
591}
592
593#[derive(Debug, Clone, Default, PartialEq)]
594struct ComparableReplayStats {
595 blue: ComparableTeamStats,
596 orange: ComparableTeamStats,
597}
598
599impl ComparableReplayStats {
600 fn team(&self, color: TeamColor) -> &ComparableTeamStats {
601 match color {
602 TeamColor::Blue => &self.blue,
603 TeamColor::Orange => &self.orange,
604 }
605 }
606
607 fn team_mut(&mut self, color: TeamColor) -> &mut ComparableTeamStats {
608 match color {
609 TeamColor::Blue => &mut self.blue,
610 TeamColor::Orange => &mut self.orange,
611 }
612 }
613
614 fn compare(&self, actual: &Self, matcher: &mut StatMatcher, config: &MatchConfig) {
615 for team in [TeamColor::Blue, TeamColor::Orange] {
616 self.team(team)
617 .compare(team, actual.team(team), matcher, config);
618 }
619 }
620}
621
622impl ComparableTeamStats {
623 fn compare(
624 &self,
625 team: TeamColor,
626 actual: &Self,
627 matcher: &mut StatMatcher,
628 config: &MatchConfig,
629 ) {
630 let team_scope = StatScope::Team(team);
631 self.core
632 .compare(&team_scope, StatDomain::Core, &actual.core, matcher, config);
633 self.boost.compare(
634 &team_scope,
635 StatDomain::Boost,
636 &actual.boost,
637 matcher,
638 config,
639 );
640 self.movement.compare(
641 &team_scope,
642 StatDomain::Movement,
643 &actual.movement,
644 matcher,
645 config,
646 );
647 self.demo
648 .compare_team(&team_scope, &actual.demo, matcher, config);
649
650 for (name, expected_player) in &self.players {
651 let scope = StatScope::Player {
652 team,
653 name: name.clone(),
654 };
655 let Some(actual_player) = actual.players.get(name) else {
656 matcher.missing_player(&scope);
657 continue;
658 };
659 expected_player.compare(&scope, actual_player, matcher, config);
660 }
661 }
662}
663
664impl ComparablePlayerStats {
665 fn compare(
666 &self,
667 scope: &StatScope,
668 actual: &Self,
669 matcher: &mut StatMatcher,
670 config: &MatchConfig,
671 ) {
672 self.core
673 .compare(scope, StatDomain::Core, &actual.core, matcher, config);
674 self.boost
675 .compare(scope, StatDomain::Boost, &actual.boost, matcher, config);
676 self.movement.compare(
677 scope,
678 StatDomain::Movement,
679 &actual.movement,
680 matcher,
681 config,
682 );
683 self.positioning.compare(
684 scope,
685 StatDomain::Positioning,
686 &actual.positioning,
687 matcher,
688 config,
689 );
690 self.demo
691 .compare_player(scope, &actual.demo, matcher, config);
692 }
693}
694
695impl ComparableCoreStats {
696 fn compare(
697 &self,
698 scope: &StatScope,
699 domain: StatDomain,
700 actual: &Self,
701 matcher: &mut StatMatcher,
702 config: &MatchConfig,
703 ) {
704 matcher.compare_field(
705 actual.score,
706 self.score,
707 ComparisonTarget {
708 scope: scope.clone(),
709 domain,
710 key: StatKey::Score,
711 },
712 config,
713 );
714 matcher.compare_field(
715 actual.goals,
716 self.goals,
717 ComparisonTarget {
718 scope: scope.clone(),
719 domain,
720 key: StatKey::Goals,
721 },
722 config,
723 );
724 matcher.compare_field(
725 actual.assists,
726 self.assists,
727 ComparisonTarget {
728 scope: scope.clone(),
729 domain,
730 key: StatKey::Assists,
731 },
732 config,
733 );
734 matcher.compare_field(
735 actual.saves,
736 self.saves,
737 ComparisonTarget {
738 scope: scope.clone(),
739 domain,
740 key: StatKey::Saves,
741 },
742 config,
743 );
744 matcher.compare_field(
745 actual.shots,
746 self.shots,
747 ComparisonTarget {
748 scope: scope.clone(),
749 domain,
750 key: StatKey::Shots,
751 },
752 config,
753 );
754 matcher.compare_field(
755 actual.shooting_percentage,
756 self.shooting_percentage,
757 ComparisonTarget {
758 scope: scope.clone(),
759 domain,
760 key: StatKey::ShootingPercentage,
761 },
762 config,
763 );
764 }
765}
766
767impl ComparableBoostStats {
768 fn compare(
769 &self,
770 scope: &StatScope,
771 domain: StatDomain,
772 actual: &Self,
773 matcher: &mut StatMatcher,
774 config: &MatchConfig,
775 ) {
776 macro_rules! compare {
777 ($field:ident, $key:ident) => {
778 matcher.compare_field(
779 actual.$field,
780 self.$field,
781 ComparisonTarget {
782 scope: scope.clone(),
783 domain,
784 key: StatKey::$key,
785 },
786 config,
787 );
788 };
789 }
790
791 compare!(bpm, Bpm);
792 compare!(avg_amount, AvgAmount);
793 compare!(amount_collected, AmountCollected);
794 compare!(amount_stolen, AmountStolen);
795 compare!(amount_collected_big, AmountCollectedBig);
796 compare!(amount_stolen_big, AmountStolenBig);
797 compare!(amount_collected_small, AmountCollectedSmall);
798 compare!(amount_stolen_small, AmountStolenSmall);
799 compare!(count_collected_big, CountCollectedBig);
800 compare!(count_stolen_big, CountStolenBig);
801 compare!(count_collected_small, CountCollectedSmall);
802 compare!(count_stolen_small, CountStolenSmall);
803 compare!(amount_overfill, AmountOverfill);
804 compare!(amount_overfill_stolen, AmountOverfillStolen);
805 compare!(amount_used_while_supersonic, AmountUsedWhileSupersonic);
806 compare!(time_zero_boost, TimeZeroBoost);
807 compare!(percent_zero_boost, PercentZeroBoost);
808 compare!(time_full_boost, TimeFullBoost);
809 compare!(percent_full_boost, PercentFullBoost);
810 compare!(time_boost_0_25, TimeBoost0To25);
811 compare!(time_boost_25_50, TimeBoost25To50);
812 compare!(time_boost_50_75, TimeBoost50To75);
813 compare!(time_boost_75_100, TimeBoost75To100);
814 compare!(percent_boost_0_25, PercentBoost0To25);
815 compare!(percent_boost_25_50, PercentBoost25To50);
816 compare!(percent_boost_50_75, PercentBoost50To75);
817 compare!(percent_boost_75_100, PercentBoost75To100);
818 }
819}
820
821impl ComparableMovementStats {
822 fn compare(
823 &self,
824 scope: &StatScope,
825 domain: StatDomain,
826 actual: &Self,
827 matcher: &mut StatMatcher,
828 config: &MatchConfig,
829 ) {
830 macro_rules! compare {
831 ($field:ident, $key:ident) => {
832 matcher.compare_field(
833 actual.$field,
834 self.$field,
835 ComparisonTarget {
836 scope: scope.clone(),
837 domain,
838 key: StatKey::$key,
839 },
840 config,
841 );
842 };
843 }
844
845 compare!(avg_speed, AvgSpeed);
846 compare!(total_distance, TotalDistance);
847 compare!(time_supersonic_speed, TimeSupersonicSpeed);
848 compare!(time_boost_speed, TimeBoostSpeed);
849 compare!(time_slow_speed, TimeSlowSpeed);
850 compare!(time_ground, TimeGround);
851 compare!(time_low_air, TimeLowAir);
852 compare!(time_high_air, TimeHighAir);
853 compare!(time_powerslide, TimePowerslide);
854 compare!(count_powerslide, CountPowerslide);
855 compare!(avg_powerslide_duration, AvgPowerslideDuration);
856 compare!(avg_speed_percentage, AvgSpeedPercentage);
857 compare!(percent_slow_speed, PercentSlowSpeed);
858 compare!(percent_boost_speed, PercentBoostSpeed);
859 compare!(percent_supersonic_speed, PercentSupersonicSpeed);
860 compare!(percent_ground, PercentGround);
861 compare!(percent_low_air, PercentLowAir);
862 compare!(percent_high_air, PercentHighAir);
863 }
864}
865
866impl ComparablePositioningStats {
867 fn compare(
868 &self,
869 scope: &StatScope,
870 domain: StatDomain,
871 actual: &Self,
872 matcher: &mut StatMatcher,
873 config: &MatchConfig,
874 ) {
875 macro_rules! compare {
876 ($field:ident, $key:ident) => {
877 matcher.compare_field(
878 actual.$field,
879 self.$field,
880 ComparisonTarget {
881 scope: scope.clone(),
882 domain,
883 key: StatKey::$key,
884 },
885 config,
886 );
887 };
888 }
889
890 compare!(avg_distance_to_ball, AvgDistanceToBall);
891 compare!(avg_distance_to_ball_possession, AvgDistanceToBallPossession);
892 compare!(
893 avg_distance_to_ball_no_possession,
894 AvgDistanceToBallNoPossession
895 );
896 compare!(avg_distance_to_mates, AvgDistanceToMates);
897 compare!(time_defensive_third, TimeDefensiveThird);
898 compare!(time_neutral_third, TimeNeutralThird);
899 compare!(time_offensive_third, TimeOffensiveThird);
900 compare!(time_defensive_half, TimeDefensiveHalf);
901 compare!(time_offensive_half, TimeOffensiveHalf);
902 compare!(time_behind_ball, TimeBehindBall);
903 compare!(time_infront_ball, TimeInfrontBall);
904 compare!(time_most_back, TimeMostBack);
905 compare!(time_most_forward, TimeMostForward);
906 compare!(time_closest_to_ball, TimeClosestToBall);
907 compare!(time_farthest_from_ball, TimeFarthestFromBall);
908 compare!(percent_defensive_third, PercentDefensiveThird);
909 compare!(percent_neutral_third, PercentNeutralThird);
910 compare!(percent_offensive_third, PercentOffensiveThird);
911 compare!(percent_defensive_half, PercentDefensiveHalf);
912 compare!(percent_offensive_half, PercentOffensiveHalf);
913 compare!(percent_behind_ball, PercentBehindBall);
914 compare!(percent_infront_ball, PercentInfrontBall);
915 compare!(percent_most_back, PercentMostBack);
916 compare!(percent_most_forward, PercentMostForward);
917 compare!(percent_closest_to_ball, PercentClosestToBall);
918 compare!(percent_farthest_from_ball, PercentFarthestFromBall);
919 }
920}
921
922impl ComparableDemoStats {
923 fn compare_player(
924 &self,
925 scope: &StatScope,
926 actual: &Self,
927 matcher: &mut StatMatcher,
928 config: &MatchConfig,
929 ) {
930 matcher.compare_field(
931 actual.inflicted,
932 self.inflicted,
933 ComparisonTarget {
934 scope: scope.clone(),
935 domain: StatDomain::Demo,
936 key: StatKey::DemoInflicted,
937 },
938 config,
939 );
940 matcher.compare_field(
941 actual.taken,
942 self.taken,
943 ComparisonTarget {
944 scope: scope.clone(),
945 domain: StatDomain::Demo,
946 key: StatKey::DemoTaken,
947 },
948 config,
949 );
950 }
951
952 fn compare_team(
953 &self,
954 scope: &StatScope,
955 actual: &Self,
956 matcher: &mut StatMatcher,
957 config: &MatchConfig,
958 ) {
959 matcher.compare_field(
960 actual.inflicted,
961 self.inflicted,
962 ComparisonTarget {
963 scope: scope.clone(),
964 domain: StatDomain::Demo,
965 key: StatKey::DemoInflicted,
966 },
967 config,
968 );
969 }
970}
971
972fn json_number(stats: Option<&Value>, field: &str) -> Option<f64> {
973 stats
974 .and_then(|stats| stats.get(field))
975 .and_then(Value::as_f64)
976}
977
978fn comparable_core_from_json(stats: Option<&Value>) -> ComparableCoreStats {
979 ComparableCoreStats {
980 score: json_number(stats, "score"),
981 goals: json_number(stats, "goals"),
982 assists: json_number(stats, "assists"),
983 saves: json_number(stats, "saves"),
984 shots: json_number(stats, "shots"),
985 shooting_percentage: json_number(stats, "shooting_percentage"),
986 }
987}
988
989fn comparable_boost_from_json(stats: Option<&Value>) -> ComparableBoostStats {
990 ComparableBoostStats {
991 bpm: json_number(stats, "bpm"),
992 avg_amount: json_number(stats, "avg_amount"),
993 amount_collected: json_number(stats, "amount_collected"),
994 amount_stolen: json_number(stats, "amount_stolen"),
995 amount_collected_big: json_number(stats, "amount_collected_big"),
996 amount_stolen_big: json_number(stats, "amount_stolen_big"),
997 amount_collected_small: json_number(stats, "amount_collected_small"),
998 amount_stolen_small: json_number(stats, "amount_stolen_small"),
999 count_collected_big: json_number(stats, "count_collected_big"),
1000 count_stolen_big: json_number(stats, "count_stolen_big"),
1001 count_collected_small: json_number(stats, "count_collected_small"),
1002 count_stolen_small: json_number(stats, "count_stolen_small"),
1003 amount_overfill: json_number(stats, "amount_overfill"),
1004 amount_overfill_stolen: json_number(stats, "amount_overfill_stolen"),
1005 amount_used_while_supersonic: json_number(stats, "amount_used_while_supersonic"),
1006 time_zero_boost: json_number(stats, "time_zero_boost"),
1007 percent_zero_boost: json_number(stats, "percent_zero_boost"),
1008 time_full_boost: json_number(stats, "time_full_boost"),
1009 percent_full_boost: json_number(stats, "percent_full_boost"),
1010 time_boost_0_25: json_number(stats, "time_boost_0_25"),
1011 time_boost_25_50: json_number(stats, "time_boost_25_50"),
1012 time_boost_50_75: json_number(stats, "time_boost_50_75"),
1013 time_boost_75_100: json_number(stats, "time_boost_75_100"),
1014 percent_boost_0_25: json_number(stats, "percent_boost_0_25"),
1015 percent_boost_25_50: json_number(stats, "percent_boost_25_50"),
1016 percent_boost_50_75: json_number(stats, "percent_boost_50_75"),
1017 percent_boost_75_100: json_number(stats, "percent_boost_75_100"),
1018 }
1019}
1020
1021fn comparable_movement_from_json(stats: Option<&Value>) -> ComparableMovementStats {
1022 ComparableMovementStats {
1023 avg_speed: json_number(stats, "avg_speed"),
1024 total_distance: json_number(stats, "total_distance"),
1025 time_supersonic_speed: json_number(stats, "time_supersonic_speed"),
1026 time_boost_speed: json_number(stats, "time_boost_speed"),
1027 time_slow_speed: json_number(stats, "time_slow_speed"),
1028 time_ground: json_number(stats, "time_ground"),
1029 time_low_air: json_number(stats, "time_low_air"),
1030 time_high_air: json_number(stats, "time_high_air"),
1031 time_powerslide: json_number(stats, "time_powerslide"),
1032 count_powerslide: json_number(stats, "count_powerslide"),
1033 avg_powerslide_duration: json_number(stats, "avg_powerslide_duration"),
1034 avg_speed_percentage: json_number(stats, "avg_speed_percentage"),
1035 percent_slow_speed: json_number(stats, "percent_slow_speed"),
1036 percent_boost_speed: json_number(stats, "percent_boost_speed"),
1037 percent_supersonic_speed: json_number(stats, "percent_supersonic_speed"),
1038 percent_ground: json_number(stats, "percent_ground"),
1039 percent_low_air: json_number(stats, "percent_low_air"),
1040 percent_high_air: json_number(stats, "percent_high_air"),
1041 }
1042}
1043
1044fn comparable_positioning_from_json(stats: Option<&Value>) -> ComparablePositioningStats {
1045 ComparablePositioningStats {
1046 avg_distance_to_ball: json_number(stats, "avg_distance_to_ball"),
1047 avg_distance_to_ball_possession: json_number(stats, "avg_distance_to_ball_possession"),
1048 avg_distance_to_ball_no_possession: json_number(
1049 stats,
1050 "avg_distance_to_ball_no_possession",
1051 ),
1052 avg_distance_to_mates: json_number(stats, "avg_distance_to_mates"),
1053 time_defensive_third: json_number(stats, "time_defensive_third"),
1054 time_neutral_third: json_number(stats, "time_neutral_third"),
1055 time_offensive_third: json_number(stats, "time_offensive_third"),
1056 time_defensive_half: json_number(stats, "time_defensive_half"),
1057 time_offensive_half: json_number(stats, "time_offensive_half"),
1058 time_behind_ball: json_number(stats, "time_behind_ball"),
1059 time_infront_ball: json_number(stats, "time_infront_ball"),
1060 time_most_back: json_number(stats, "time_most_back"),
1061 time_most_forward: json_number(stats, "time_most_forward"),
1062 time_closest_to_ball: json_number(stats, "time_closest_to_ball"),
1063 time_farthest_from_ball: json_number(stats, "time_farthest_from_ball"),
1064 percent_defensive_third: json_number(stats, "percent_defensive_third"),
1065 percent_neutral_third: json_number(stats, "percent_neutral_third"),
1066 percent_offensive_third: json_number(stats, "percent_offensive_third"),
1067 percent_defensive_half: json_number(stats, "percent_defensive_half"),
1068 percent_offensive_half: json_number(stats, "percent_offensive_half"),
1069 percent_behind_ball: json_number(stats, "percent_behind_ball"),
1070 percent_infront_ball: json_number(stats, "percent_infront_ball"),
1071 percent_most_back: json_number(stats, "percent_most_back"),
1072 percent_most_forward: json_number(stats, "percent_most_forward"),
1073 percent_closest_to_ball: json_number(stats, "percent_closest_to_ball"),
1074 percent_farthest_from_ball: json_number(stats, "percent_farthest_from_ball"),
1075 }
1076}
1077
1078fn comparable_demo_from_json(stats: Option<&Value>) -> ComparableDemoStats {
1079 ComparableDemoStats {
1080 inflicted: json_number(stats, "inflicted"),
1081 taken: json_number(stats, "taken"),
1082 }
1083}
1084
1085fn comparable_team_demo_from_json(stats: Option<&Value>) -> ComparableDemoStats {
1086 ComparableDemoStats {
1087 inflicted: json_number(stats, "inflicted"),
1088 taken: None,
1089 }
1090}
1091
1092fn comparable_core_from_player(stats: &CorePlayerStats) -> ComparableCoreStats {
1093 ComparableCoreStats {
1094 score: Some(stats.score as f64),
1095 goals: Some(stats.goals as f64),
1096 assists: Some(stats.assists as f64),
1097 saves: Some(stats.saves as f64),
1098 shots: Some(stats.shots as f64),
1099 shooting_percentage: Some(stats.shooting_percentage() as f64),
1100 }
1101}
1102
1103fn comparable_core_from_team(stats: &CoreTeamStats) -> ComparableCoreStats {
1104 ComparableCoreStats {
1105 score: Some(stats.score as f64),
1106 goals: Some(stats.goals as f64),
1107 assists: Some(stats.assists as f64),
1108 saves: Some(stats.saves as f64),
1109 shots: Some(stats.shots as f64),
1110 shooting_percentage: Some(stats.shooting_percentage() as f64),
1111 }
1112}
1113
1114fn raw_boost_amount_as_ballchasing_units(value: f32) -> f64 {
1115 boost_amount_to_percent(value) as f64
1116}
1117
1118fn comparable_boost_from_stats(stats: &BoostStats) -> ComparableBoostStats {
1119 ComparableBoostStats {
1120 bpm: Some(raw_boost_amount_as_ballchasing_units(stats.bpm())),
1121 avg_amount: Some(raw_boost_amount_as_ballchasing_units(
1122 stats.average_boost_amount(),
1123 )),
1124 amount_collected: Some(raw_boost_amount_as_ballchasing_units(
1125 stats.amount_collected,
1126 )),
1127 amount_stolen: Some(raw_boost_amount_as_ballchasing_units(stats.amount_stolen)),
1128 amount_collected_big: Some(raw_boost_amount_as_ballchasing_units(
1129 stats.amount_collected_big,
1130 )),
1131 amount_stolen_big: Some(raw_boost_amount_as_ballchasing_units(
1132 stats.amount_stolen_big,
1133 )),
1134 amount_collected_small: Some(raw_boost_amount_as_ballchasing_units(
1135 stats.amount_collected_small,
1136 )),
1137 amount_stolen_small: Some(raw_boost_amount_as_ballchasing_units(
1138 stats.amount_stolen_small,
1139 )),
1140 count_collected_big: Some(stats.big_pads_collected as f64),
1141 count_stolen_big: Some(stats.big_pads_stolen as f64),
1142 count_collected_small: Some(stats.small_pads_collected as f64),
1143 count_stolen_small: Some(stats.small_pads_stolen as f64),
1144 amount_overfill: Some(raw_boost_amount_as_ballchasing_units(stats.overfill_total)),
1145 amount_overfill_stolen: Some(raw_boost_amount_as_ballchasing_units(
1146 stats.overfill_from_stolen,
1147 )),
1148 amount_used_while_supersonic: Some(raw_boost_amount_as_ballchasing_units(
1149 stats.amount_used_while_supersonic,
1150 )),
1151 time_zero_boost: Some(stats.time_zero_boost as f64),
1152 percent_zero_boost: Some(stats.zero_boost_pct() as f64),
1153 time_full_boost: Some(stats.time_hundred_boost as f64),
1154 percent_full_boost: Some(stats.hundred_boost_pct() as f64),
1155 time_boost_0_25: Some(stats.time_boost_0_25 as f64),
1156 time_boost_25_50: Some(stats.time_boost_25_50 as f64),
1157 time_boost_50_75: Some(stats.time_boost_50_75 as f64),
1158 time_boost_75_100: Some(stats.time_boost_75_100 as f64),
1159 percent_boost_0_25: Some(stats.boost_0_25_pct() as f64),
1160 percent_boost_25_50: Some(stats.boost_25_50_pct() as f64),
1161 percent_boost_50_75: Some(stats.boost_50_75_pct() as f64),
1162 percent_boost_75_100: Some(stats.boost_75_100_pct() as f64),
1163 }
1164}
1165
1166fn sum_present(values: impl IntoIterator<Item = Option<f64>>) -> Option<f64> {
1167 let mut saw_value = false;
1168 let sum = values.into_iter().fold(0.0, |acc, value| match value {
1169 Some(value) => {
1170 saw_value = true;
1171 acc + value
1172 }
1173 None => acc,
1174 });
1175 saw_value.then_some(sum)
1176}
1177
1178fn comparable_movement_from_stats(
1179 movement: &MovementStats,
1180 powerslide: &PowerslideStats,
1181) -> ComparableMovementStats {
1182 ComparableMovementStats {
1183 avg_speed: Some(movement.average_speed() as f64),
1184 total_distance: Some(movement.total_distance as f64),
1185 time_supersonic_speed: Some(movement.time_supersonic_speed as f64),
1186 time_boost_speed: Some(movement.time_boost_speed as f64),
1187 time_slow_speed: Some(movement.time_slow_speed as f64),
1188 time_ground: Some(movement.time_on_ground as f64),
1189 time_low_air: Some(movement.time_low_air as f64),
1190 time_high_air: Some(movement.time_high_air as f64),
1191 time_powerslide: Some(powerslide.total_duration as f64),
1192 count_powerslide: Some(powerslide.press_count as f64),
1193 avg_powerslide_duration: Some(powerslide.average_duration() as f64),
1194 avg_speed_percentage: Some(movement.average_speed_pct() as f64),
1195 percent_slow_speed: Some(movement.slow_speed_pct() as f64),
1196 percent_boost_speed: Some(movement.boost_speed_pct() as f64),
1197 percent_supersonic_speed: Some(movement.supersonic_speed_pct() as f64),
1198 percent_ground: Some(movement.on_ground_pct() as f64),
1199 percent_low_air: Some(movement.low_air_pct() as f64),
1200 percent_high_air: Some(movement.high_air_pct() as f64),
1201 }
1202}
1203
1204fn comparable_positioning_from_stats(stats: &PositioningStats) -> ComparablePositioningStats {
1205 ComparablePositioningStats {
1206 avg_distance_to_ball: Some(stats.average_distance_to_ball() as f64),
1207 avg_distance_to_ball_possession: Some(
1208 stats.average_distance_to_ball_has_possession() as f64
1209 ),
1210 avg_distance_to_ball_no_possession: Some(
1211 stats.average_distance_to_ball_no_possession() as f64
1212 ),
1213 avg_distance_to_mates: Some(stats.average_distance_to_teammates() as f64),
1214 time_defensive_third: Some(stats.time_defensive_zone as f64),
1215 time_neutral_third: Some(stats.time_neutral_zone as f64),
1216 time_offensive_third: Some(stats.time_offensive_zone as f64),
1217 time_defensive_half: Some(stats.time_defensive_half as f64),
1218 time_offensive_half: Some(stats.time_offensive_half as f64),
1219 time_behind_ball: Some(stats.time_behind_ball as f64),
1220 time_infront_ball: Some(stats.time_in_front_of_ball as f64),
1221 time_most_back: Some(stats.time_most_back as f64),
1222 time_most_forward: Some(stats.time_most_forward as f64),
1223 time_closest_to_ball: Some(stats.time_closest_to_ball as f64),
1224 time_farthest_from_ball: Some(stats.time_farthest_from_ball as f64),
1225 percent_defensive_third: Some(stats.defensive_zone_pct() as f64),
1226 percent_neutral_third: Some(stats.neutral_zone_pct() as f64),
1227 percent_offensive_third: Some(stats.offensive_zone_pct() as f64),
1228 percent_defensive_half: Some(stats.defensive_half_pct() as f64),
1229 percent_offensive_half: Some(stats.offensive_half_pct() as f64),
1230 percent_behind_ball: Some(stats.behind_ball_pct() as f64),
1231 percent_infront_ball: Some(stats.in_front_of_ball_pct() as f64),
1232 percent_most_back: Some(stats.most_back_pct() as f64),
1233 percent_most_forward: Some(stats.most_forward_pct() as f64),
1234 percent_closest_to_ball: Some(stats.closest_to_ball_pct() as f64),
1235 percent_farthest_from_ball: Some(stats.farthest_from_ball_pct() as f64),
1236 }
1237}
1238
1239fn comparable_demo_from_player(stats: &DemoPlayerStats) -> ComparableDemoStats {
1240 ComparableDemoStats {
1241 inflicted: Some(stats.demos_inflicted as f64),
1242 taken: Some(stats.demos_taken as f64),
1243 }
1244}
1245
1246fn comparable_demo_from_team(stats: &DemoTeamStats) -> ComparableDemoStats {
1247 ComparableDemoStats {
1248 inflicted: Some(stats.demos_inflicted as f64),
1249 taken: None,
1250 }
1251}
1252
1253struct ComputedBallchasingComparableStats {
1254 replay_meta: ReplayMeta,
1255 match_stats: MatchStatsReducer,
1256 boost: BoostReducer,
1257 movement: MovementReducer,
1258 positioning: PositioningReducer,
1259 demo: DemoReducer,
1260 powerslide: PowerslideReducer,
1261}
1262
1263fn compute_ballchasing_comparable_stats(
1264 replay: &boxcars::Replay,
1265) -> SubtrActorResult<ComputedBallchasingComparableStats> {
1266 let mut match_collector = ReducerCollector::new(MatchStatsReducer::new());
1267 let mut boost_collector = ReducerCollector::new(BoostReducer::new());
1268 let mut movement_collector = ReducerCollector::new(MovementReducer::new());
1269 let mut positioning_collector = ReducerCollector::new(PositioningReducer::new());
1270 let mut demo_collector = ReducerCollector::new(DemoReducer::new());
1271 let mut powerslide_collector = ReducerCollector::new(PowerslideReducer::new());
1272
1273 let mut processor = ReplayProcessor::new(replay)?;
1274 let mut collectors: [&mut dyn Collector; 6] = [
1275 &mut match_collector,
1276 &mut boost_collector,
1277 &mut movement_collector,
1278 &mut positioning_collector,
1279 &mut demo_collector,
1280 &mut powerslide_collector,
1281 ];
1282 processor.process_all(&mut collectors)?;
1283
1284 Ok(ComputedBallchasingComparableStats {
1285 replay_meta: processor.get_replay_meta()?,
1286 match_stats: match_collector.into_inner(),
1287 boost: boost_collector.into_inner(),
1288 movement: movement_collector.into_inner(),
1289 positioning: positioning_collector.into_inner(),
1290 demo: demo_collector.into_inner(),
1291 powerslide: powerslide_collector.into_inner(),
1292 })
1293}
1294
1295fn build_actual_comparable_stats(
1296 stats: &ComputedBallchasingComparableStats,
1297) -> ComparableReplayStats {
1298 let mut comparable = ComparableReplayStats::default();
1299
1300 for (team_color, players) in [
1301 (TeamColor::Blue, &stats.replay_meta.team_zero),
1302 (TeamColor::Orange, &stats.replay_meta.team_one),
1303 ] {
1304 let team_stats = comparable.team_mut(team_color);
1305 team_stats.core = comparable_core_from_team(&match team_color {
1306 TeamColor::Blue => stats.match_stats.team_zero_stats(),
1307 TeamColor::Orange => stats.match_stats.team_one_stats(),
1308 });
1309 let mut team_boost = comparable_boost_from_stats(match team_color {
1310 TeamColor::Blue => stats.boost.team_zero_stats(),
1311 TeamColor::Orange => stats.boost.team_one_stats(),
1312 });
1313 team_stats.movement = comparable_movement_from_stats(
1314 match team_color {
1315 TeamColor::Blue => stats.movement.team_zero_stats(),
1316 TeamColor::Orange => stats.movement.team_one_stats(),
1317 },
1318 match team_color {
1319 TeamColor::Blue => stats.powerslide.team_zero_stats(),
1320 TeamColor::Orange => stats.powerslide.team_one_stats(),
1321 },
1322 );
1323 team_stats.demo = comparable_demo_from_team(match team_color {
1324 TeamColor::Blue => stats.demo.team_zero_stats(),
1325 TeamColor::Orange => stats.demo.team_one_stats(),
1326 });
1327
1328 let mut player_boost_stats = Vec::new();
1329 for player in players {
1330 let player_boost = comparable_boost_from_stats(
1331 &stats
1332 .boost
1333 .player_stats()
1334 .get(&player.remote_id)
1335 .cloned()
1336 .unwrap_or_default(),
1337 );
1338 player_boost_stats.push(player_boost.clone());
1339 let player_stats = ComparablePlayerStats {
1340 core: comparable_core_from_player(
1341 &stats
1342 .match_stats
1343 .player_stats()
1344 .get(&player.remote_id)
1345 .cloned()
1346 .unwrap_or_default(),
1347 ),
1348 boost: player_boost,
1349 movement: comparable_movement_from_stats(
1350 &stats
1351 .movement
1352 .player_stats()
1353 .get(&player.remote_id)
1354 .cloned()
1355 .unwrap_or_default(),
1356 &stats
1357 .powerslide
1358 .player_stats()
1359 .get(&player.remote_id)
1360 .cloned()
1361 .unwrap_or_default(),
1362 ),
1363 positioning: comparable_positioning_from_stats(
1364 &stats
1365 .positioning
1366 .player_stats()
1367 .get(&player.remote_id)
1368 .cloned()
1369 .unwrap_or_default(),
1370 ),
1371 demo: comparable_demo_from_player(
1372 &stats
1373 .demo
1374 .player_stats()
1375 .get(&player.remote_id)
1376 .cloned()
1377 .unwrap_or_default(),
1378 ),
1379 };
1380 team_stats.players.insert(player.name.clone(), player_stats);
1381 }
1382
1383 team_boost.avg_amount =
1384 sum_present(player_boost_stats.iter().map(|stats| stats.avg_amount));
1385 team_boost.bpm = sum_present(player_boost_stats.iter().map(|stats| stats.bpm));
1386 team_stats.boost = team_boost;
1387 }
1388
1389 comparable
1390}
1391
1392fn build_expected_comparable_stats(ballchasing: &Value) -> ComparableReplayStats {
1393 let mut comparable = ComparableReplayStats::default();
1394
1395 for team_color in [TeamColor::Blue, TeamColor::Orange] {
1396 let Some(team) = ballchasing.get(team_color.ballchasing_key()) else {
1397 continue;
1398 };
1399
1400 let team_stats = comparable.team_mut(team_color);
1401 let team_json_stats = team.get("stats");
1402 team_stats.core =
1403 comparable_core_from_json(team_json_stats.and_then(|stats| stats.get("core")));
1404 team_stats.boost =
1405 comparable_boost_from_json(team_json_stats.and_then(|stats| stats.get("boost")));
1406 team_stats.movement =
1407 comparable_movement_from_json(team_json_stats.and_then(|stats| stats.get("movement")));
1408 team_stats.demo =
1409 comparable_team_demo_from_json(team_json_stats.and_then(|stats| stats.get("demo")));
1410
1411 let Some(players) = team.get("players").and_then(Value::as_array) else {
1412 continue;
1413 };
1414
1415 for player in players {
1416 let Some(name) = player.get("name").and_then(Value::as_str) else {
1417 continue;
1418 };
1419 let stats = player.get("stats");
1420 team_stats.players.insert(
1421 name.to_string(),
1422 ComparablePlayerStats {
1423 core: comparable_core_from_json(stats.and_then(|stats| stats.get("core"))),
1424 boost: comparable_boost_from_json(stats.and_then(|stats| stats.get("boost"))),
1425 movement: comparable_movement_from_json(
1426 stats.and_then(|stats| stats.get("movement")),
1427 ),
1428 positioning: comparable_positioning_from_json(
1429 stats.and_then(|stats| stats.get("positioning")),
1430 ),
1431 demo: comparable_demo_from_json(stats.and_then(|stats| stats.get("demo"))),
1432 },
1433 );
1434 }
1435 }
1436
1437 comparable
1438}
1439
1440pub struct BallchasingComparisonReport {
1441 mismatches: Vec<String>,
1442}
1443
1444impl BallchasingComparisonReport {
1445 pub fn is_match(&self) -> bool {
1446 self.mismatches.is_empty()
1447 }
1448
1449 pub fn mismatches(&self) -> &[String] {
1450 &self.mismatches
1451 }
1452
1453 pub fn assert_matches(&self) {
1454 if self.is_match() {
1455 return;
1456 }
1457
1458 panic!(
1459 "Ballchasing comparison failed:\n{}",
1460 self.mismatches.join("\n")
1461 );
1462 }
1463}
1464
1465pub fn compare_replay_against_ballchasing(
1466 replay: &boxcars::Replay,
1467 ballchasing: &Value,
1468 config: &MatchConfig,
1469) -> SubtrActorResult<BallchasingComparisonReport> {
1470 let computed = compute_ballchasing_comparable_stats(replay)?;
1471 let actual = build_actual_comparable_stats(&computed);
1472 let expected = build_expected_comparable_stats(ballchasing);
1473
1474 let mut matcher = StatMatcher::default();
1475 expected.compare(&actual, &mut matcher, config);
1476 Ok(BallchasingComparisonReport {
1477 mismatches: matcher.mismatches,
1478 })
1479}
1480
1481pub fn compare_replay_against_ballchasing_json(
1482 replay_path: impl AsRef<Path>,
1483 json_path: impl AsRef<Path>,
1484 config: &MatchConfig,
1485) -> anyhow::Result<BallchasingComparisonReport> {
1486 let replay_path = replay_path.as_ref();
1487 let json_path = json_path.as_ref();
1488 let replay = parse_replay_file(replay_path)?;
1489 let json_file = std::fs::File::open(json_path)
1490 .with_context(|| format!("Failed to open ballchasing json: {}", json_path.display()))?;
1491 let ballchasing: Value = serde_json::from_reader(json_file)
1492 .with_context(|| format!("Failed to parse ballchasing json: {}", json_path.display()))?;
1493
1494 compare_replay_against_ballchasing(&replay, &ballchasing, config)
1495 .map_err(|error| anyhow::Error::new(error.variant))
1496}
1497
1498pub fn compare_fixture_directory(
1499 path: &Path,
1500 config: &MatchConfig,
1501) -> anyhow::Result<BallchasingComparisonReport> {
1502 let replay_path = path.join("replay.replay");
1503 let json_path = path.join("ballchasing.json");
1504 compare_replay_against_ballchasing_json(&replay_path, &json_path, config)
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510
1511 #[test]
1512 fn test_match_config_defaults_to_exact() {
1513 let config = MatchConfig::exact().with_rule(
1514 "time zero boost abs<=1",
1515 |target| target.key == StatKey::TimeZeroBoost,
1516 approx_abs(1.0),
1517 );
1518
1519 let default_target = ComparisonTarget {
1520 scope: StatScope::Team(TeamColor::Blue),
1521 domain: StatDomain::Boost,
1522 key: StatKey::CountCollectedBig,
1523 };
1524 let tolerant_target = ComparisonTarget {
1525 scope: StatScope::Team(TeamColor::Blue),
1526 domain: StatDomain::Boost,
1527 key: StatKey::TimeZeroBoost,
1528 };
1529
1530 assert!(!config.evaluate(3.0, 2.0, &default_target).matches);
1531 assert!(config.evaluate(3.5, 3.0, &tolerant_target).matches);
1532 }
1533
1534 #[test]
1535 fn test_match_config_uses_last_matching_rule() {
1536 let config = MatchConfig::exact()
1537 .with_rule(
1538 "all movement abs<=1",
1539 |target| target.domain == StatDomain::Movement,
1540 approx_abs(1.0),
1541 )
1542 .with_rule(
1543 "movement total distance abs<=10",
1544 |target| target.key == StatKey::TotalDistance,
1545 approx_abs(10.0),
1546 );
1547
1548 let target = ComparisonTarget {
1549 scope: StatScope::Team(TeamColor::Blue),
1550 domain: StatDomain::Movement,
1551 key: StatKey::TotalDistance,
1552 };
1553
1554 let outcome = config.evaluate(1008.0, 1000.0, &target);
1555 assert!(outcome.matches);
1556 assert_eq!(outcome.description, "movement total distance abs<=10");
1557 }
1558
1559 #[test]
1560 fn test_raw_boost_amount_conversion_matches_ballchasing_scale() {
1561 assert_eq!(raw_boost_amount_as_ballchasing_units(255.0), 100.0);
1562 assert!((raw_boost_amount_as_ballchasing_units(30.6) - 12.0).abs() < 0.1);
1563 assert!((raw_boost_amount_as_ballchasing_units(510.0) - 200.0).abs() < 0.1);
1564 }
1565}