Skip to main content

subtr_actor/
ballchasing.rs

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}