use super::model::{ComparisonTarget, StatDomain, StatKey, StatScope};
type MatchSelector = dyn Fn(&ComparisonTarget) -> bool;
type MatchPredicate = dyn Fn(f64, f64, &ComparisonTarget) -> bool;
struct MatchRule {
description: String,
selector: Box<MatchSelector>,
predicate: Box<MatchPredicate>,
}
#[derive(Default)]
pub struct MatchConfig {
rules: Vec<MatchRule>,
}
struct MatchOutcome<'a> {
matches: bool,
description: &'a str,
}
impl MatchConfig {
fn exact() -> Self {
Self::default()
}
fn with_rule<S, P>(mut self, description: impl Into<String>, selector: S, predicate: P) -> Self
where
S: Fn(&ComparisonTarget) -> bool + 'static,
P: Fn(f64, f64, &ComparisonTarget) -> bool + 'static,
{
self.rules.push(MatchRule {
description: description.into(),
selector: Box::new(selector),
predicate: Box::new(predicate),
});
self
}
fn evaluate<'a>(
&'a self,
actual: f64,
expected: f64,
target: &ComparisonTarget,
) -> MatchOutcome<'a> {
let default = MatchOutcome {
matches: actual == expected,
description: "exact",
};
self.rules
.iter()
.rev()
.find(|rule| (rule.selector)(target))
.map(|rule| MatchOutcome {
matches: (rule.predicate)(actual, expected, target),
description: &rule.description,
})
.unwrap_or(default)
}
}
pub(super) fn approx_abs(abs_tol: f64) -> impl Fn(f64, f64, &ComparisonTarget) -> bool {
move |actual, expected, _| (actual - expected).abs() <= abs_tol
}
pub fn recommended_match_config() -> MatchConfig {
MatchConfig::exact()
.with_rule(
"shooting percentage abs<=0.01",
|target| target.key == StatKey::ShootingPercentage,
approx_abs(0.01),
)
.with_rule(
"boost amount style fields abs<=2",
|target| {
matches!(
target.key,
StatKey::AmountCollected
| StatKey::AmountStolen
| StatKey::AmountCollectedBig
| StatKey::AmountStolenBig
| StatKey::AmountCollectedSmall
| StatKey::AmountStolenSmall
| StatKey::AmountOverfill
| StatKey::AmountOverfillStolen
| StatKey::AmountUsedWhileSupersonic
)
},
approx_abs(2.0),
)
.with_rule(
"boost timing and percentage fields abs<=1",
|target| {
matches!(
target.key,
StatKey::Bpm
| StatKey::AvgAmount
| StatKey::TimeZeroBoost
| StatKey::PercentZeroBoost
| StatKey::TimeFullBoost
| StatKey::PercentFullBoost
| StatKey::TimeBoost0To25
| StatKey::TimeBoost25To50
| StatKey::TimeBoost50To75
| StatKey::TimeBoost75To100
| StatKey::PercentBoost0To25
| StatKey::PercentBoost25To50
| StatKey::PercentBoost50To75
| StatKey::PercentBoost75To100
)
},
approx_abs(1.0),
)
.with_rule(
"movement timing and percentage fields abs<=1",
|target| {
matches!(
target.key,
StatKey::TimeSupersonicSpeed
| StatKey::TimeBoostSpeed
| StatKey::TimeSlowSpeed
| StatKey::TimeGround
| StatKey::TimeLowAir
| StatKey::TimeHighAir
| StatKey::TimePowerslide
| StatKey::PercentSlowSpeed
| StatKey::PercentBoostSpeed
| StatKey::PercentSupersonicSpeed
| StatKey::PercentGround
| StatKey::PercentLowAir
| StatKey::PercentHighAir
)
},
approx_abs(1.0),
)
.with_rule(
"movement distance/speed fields tolerate external rounding",
|target| {
matches!(
target.key,
StatKey::AvgSpeed
| StatKey::AvgSpeedPercentage
| StatKey::TotalDistance
| StatKey::AvgPowerslideDuration
)
},
|actual, expected, target| {
let tol = match target.key {
StatKey::AvgSpeed => 5.0,
StatKey::AvgSpeedPercentage => 0.5,
StatKey::TotalDistance => 2500.0,
StatKey::AvgPowerslideDuration => 0.1,
_ => 0.0,
};
(actual - expected).abs() <= tol
},
)
.with_rule(
"positioning fields abs<=1 or 50 depending on metric",
|target| target.domain == StatDomain::Positioning,
|actual, expected, target| {
let tol = match target.key {
StatKey::AvgDistanceToBall
| StatKey::AvgDistanceToBallPossession
| StatKey::AvgDistanceToBallNoPossession
| StatKey::AvgDistanceToMates => 50.0,
_ => 1.0,
};
(actual - expected).abs() <= tol
},
)
}
#[derive(Debug, Default)]
pub(crate) struct StatMatcher {
pub(super) mismatches: Vec<String>,
}
impl StatMatcher {
pub(super) fn compare_field(
&mut self,
actual: Option<f64>,
expected: Option<f64>,
target: ComparisonTarget,
config: &MatchConfig,
) {
let Some(expected_value) = expected else {
return;
};
let Some(actual_value) = actual else {
self.mismatches
.push(format!("{target}: missing actual value"));
return;
};
let outcome = config.evaluate(actual_value, expected_value, &target);
if !outcome.matches {
self.mismatches.push(format!(
"{target}: actual={actual_value} expected={expected_value} predicate={}",
outcome.description
));
}
}
pub(super) fn missing_player(&mut self, scope: &StatScope) {
self.mismatches
.push(format!("{scope}: missing actual player"));
}
pub(crate) fn into_mismatches(self) -> Vec<String> {
self.mismatches
}
}
#[cfg(test)]
#[path = "config_test.rs"]
mod tests;