Skip to main content

subtr_actor/ballchasing/
config.rs

1use super::model::{ComparisonTarget, StatDomain, StatKey, StatScope};
2
3type MatchSelector = dyn Fn(&ComparisonTarget) -> bool;
4type MatchPredicate = dyn Fn(f64, f64, &ComparisonTarget) -> bool;
5
6struct MatchRule {
7    description: String,
8    selector: Box<MatchSelector>,
9    predicate: Box<MatchPredicate>,
10}
11
12#[derive(Default)]
13pub struct MatchConfig {
14    rules: Vec<MatchRule>,
15}
16
17struct MatchOutcome<'a> {
18    matches: bool,
19    description: &'a str,
20}
21
22impl MatchConfig {
23    fn exact() -> Self {
24        Self::default()
25    }
26
27    fn with_rule<S, P>(mut self, description: impl Into<String>, selector: S, predicate: P) -> Self
28    where
29        S: Fn(&ComparisonTarget) -> bool + 'static,
30        P: Fn(f64, f64, &ComparisonTarget) -> bool + 'static,
31    {
32        self.rules.push(MatchRule {
33            description: description.into(),
34            selector: Box::new(selector),
35            predicate: Box::new(predicate),
36        });
37        self
38    }
39
40    fn evaluate<'a>(
41        &'a self,
42        actual: f64,
43        expected: f64,
44        target: &ComparisonTarget,
45    ) -> MatchOutcome<'a> {
46        let default = MatchOutcome {
47            matches: actual == expected,
48            description: "exact",
49        };
50
51        self.rules
52            .iter()
53            .rev()
54            .find(|rule| (rule.selector)(target))
55            .map(|rule| MatchOutcome {
56                matches: (rule.predicate)(actual, expected, target),
57                description: &rule.description,
58            })
59            .unwrap_or(default)
60    }
61}
62
63pub(super) fn approx_abs(abs_tol: f64) -> impl Fn(f64, f64, &ComparisonTarget) -> bool {
64    move |actual, expected, _| (actual - expected).abs() <= abs_tol
65}
66
67pub fn recommended_ballchasing_match_config() -> MatchConfig {
68    MatchConfig::exact()
69        .with_rule(
70            "shooting percentage abs<=0.01",
71            |target| target.key == StatKey::ShootingPercentage,
72            approx_abs(0.01),
73        )
74        .with_rule(
75            "boost amount style fields abs<=2",
76            |target| {
77                matches!(
78                    target.key,
79                    StatKey::AmountCollected
80                        | StatKey::AmountStolen
81                        | StatKey::AmountCollectedBig
82                        | StatKey::AmountStolenBig
83                        | StatKey::AmountCollectedSmall
84                        | StatKey::AmountStolenSmall
85                        | StatKey::AmountOverfill
86                        | StatKey::AmountOverfillStolen
87                        | StatKey::AmountUsedWhileSupersonic
88                )
89            },
90            approx_abs(2.0),
91        )
92        .with_rule(
93            "boost timing and percentage fields abs<=1",
94            |target| {
95                matches!(
96                    target.key,
97                    StatKey::Bpm
98                        | StatKey::AvgAmount
99                        | StatKey::TimeZeroBoost
100                        | StatKey::PercentZeroBoost
101                        | StatKey::TimeFullBoost
102                        | StatKey::PercentFullBoost
103                        | StatKey::TimeBoost0To25
104                        | StatKey::TimeBoost25To50
105                        | StatKey::TimeBoost50To75
106                        | StatKey::TimeBoost75To100
107                        | StatKey::PercentBoost0To25
108                        | StatKey::PercentBoost25To50
109                        | StatKey::PercentBoost50To75
110                        | StatKey::PercentBoost75To100
111                )
112            },
113            approx_abs(1.0),
114        )
115        .with_rule(
116            "movement timing and percentage fields abs<=1",
117            |target| {
118                matches!(
119                    target.key,
120                    StatKey::TimeSupersonicSpeed
121                        | StatKey::TimeBoostSpeed
122                        | StatKey::TimeSlowSpeed
123                        | StatKey::TimeGround
124                        | StatKey::TimeLowAir
125                        | StatKey::TimeHighAir
126                        | StatKey::TimePowerslide
127                        | StatKey::PercentSlowSpeed
128                        | StatKey::PercentBoostSpeed
129                        | StatKey::PercentSupersonicSpeed
130                        | StatKey::PercentGround
131                        | StatKey::PercentLowAir
132                        | StatKey::PercentHighAir
133                )
134            },
135            approx_abs(1.0),
136        )
137        .with_rule(
138            "movement distance/speed fields tolerate Ballchasing rounding",
139            |target| {
140                matches!(
141                    target.key,
142                    StatKey::AvgSpeed
143                        | StatKey::AvgSpeedPercentage
144                        | StatKey::TotalDistance
145                        | StatKey::AvgPowerslideDuration
146                )
147            },
148            |actual, expected, target| {
149                let tol = match target.key {
150                    StatKey::AvgSpeed => 5.0,
151                    StatKey::AvgSpeedPercentage => 0.5,
152                    StatKey::TotalDistance => 2500.0,
153                    StatKey::AvgPowerslideDuration => 0.1,
154                    _ => 0.0,
155                };
156                (actual - expected).abs() <= tol
157            },
158        )
159        .with_rule(
160            "positioning fields abs<=1 or 50 depending on metric",
161            |target| target.domain == StatDomain::Positioning,
162            |actual, expected, target| {
163                let tol = match target.key {
164                    StatKey::AvgDistanceToBall
165                    | StatKey::AvgDistanceToBallPossession
166                    | StatKey::AvgDistanceToBallNoPossession
167                    | StatKey::AvgDistanceToMates => 50.0,
168                    _ => 1.0,
169                };
170                (actual - expected).abs() <= tol
171            },
172        )
173}
174
175#[derive(Debug, Default)]
176pub(super) struct StatMatcher {
177    pub(super) mismatches: Vec<String>,
178}
179
180impl StatMatcher {
181    pub(super) fn compare_field(
182        &mut self,
183        actual: Option<f64>,
184        expected: Option<f64>,
185        target: ComparisonTarget,
186        config: &MatchConfig,
187    ) {
188        let Some(expected_value) = expected else {
189            return;
190        };
191        let Some(actual_value) = actual else {
192            self.mismatches
193                .push(format!("{target}: missing actual value"));
194            return;
195        };
196
197        let outcome = config.evaluate(actual_value, expected_value, &target);
198        if !outcome.matches {
199            self.mismatches.push(format!(
200                "{target}: actual={actual_value} expected={expected_value} predicate={}",
201                outcome.description
202            ));
203        }
204    }
205
206    pub(super) fn missing_player(&mut self, scope: &StatScope) {
207        self.mismatches
208            .push(format!("{scope}: missing actual player"));
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::ballchasing::model::TeamColor;
216
217    #[test]
218    fn test_match_config_defaults_to_exact() {
219        let config = MatchConfig::exact().with_rule(
220            "time zero boost abs<=1",
221            |target| target.key == StatKey::TimeZeroBoost,
222            approx_abs(1.0),
223        );
224
225        let default_target = ComparisonTarget {
226            scope: StatScope::Team(TeamColor::Blue),
227            domain: StatDomain::Boost,
228            key: StatKey::CountCollectedBig,
229        };
230        let tolerant_target = ComparisonTarget {
231            scope: StatScope::Team(TeamColor::Blue),
232            domain: StatDomain::Boost,
233            key: StatKey::TimeZeroBoost,
234        };
235
236        assert!(!config.evaluate(3.0, 2.0, &default_target).matches);
237        assert!(config.evaluate(3.5, 3.0, &tolerant_target).matches);
238    }
239
240    #[test]
241    fn test_match_config_uses_last_matching_rule() {
242        let config = MatchConfig::exact()
243            .with_rule(
244                "all movement abs<=1",
245                |target| target.domain == StatDomain::Movement,
246                approx_abs(1.0),
247            )
248            .with_rule(
249                "movement total distance abs<=10",
250                |target| target.key == StatKey::TotalDistance,
251                approx_abs(10.0),
252            );
253
254        let target = ComparisonTarget {
255            scope: StatScope::Team(TeamColor::Blue),
256            domain: StatDomain::Movement,
257            key: StatKey::TotalDistance,
258        };
259
260        let outcome = config.evaluate(1008.0, 1000.0, &target);
261        assert!(outcome.matches);
262        assert_eq!(outcome.description, "movement total distance abs<=10");
263    }
264}