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}