subtr_actor/ballchasing/comparison/
config.rs1use 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_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 external 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(crate) 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 pub(crate) fn into_mismatches(self) -> Vec<String> {
212 self.mismatches
213 }
214}
215
216#[cfg(test)]
217#[path = "config_test.rs"]
218mod tests;