1use super::calculators::BoostStats;
2use crate::*;
3
4pub const BOOST_INVARIANT_BASE_TOLERANCE_RAW: f32 = 2.0;
11pub const BOOST_INVARIANT_PER_PICKUP_TOLERANCE_RAW: f32 = 0.3;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum BoostInvariantKind {
15 BucketAmounts,
16 NominalPickupAmount,
17 NominalStolenPickupAmount,
18 CurrentAmount,
19 UsedSplitAmounts,
20}
21
22impl BoostInvariantKind {
23 pub const ALL: [Self; 5] = [
24 Self::BucketAmounts,
25 Self::NominalPickupAmount,
26 Self::NominalStolenPickupAmount,
27 Self::CurrentAmount,
28 Self::UsedSplitAmounts,
29 ];
30
31 pub fn label(self) -> &'static str {
32 match self {
33 Self::BucketAmounts => "bucket_amounts",
34 Self::NominalPickupAmount => "nominal_pickup_amount",
35 Self::NominalStolenPickupAmount => "nominal_stolen_pickup_amount",
36 Self::CurrentAmount => "current_amount",
37 Self::UsedSplitAmounts => "used_split_amounts",
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub struct BoostInvariantViolation {
44 pub kind: BoostInvariantKind,
45 pub expected: f32,
46 pub actual: f32,
47 pub diff: f32,
48 pub tolerance: f32,
49}
50
51impl BoostInvariantViolation {
52 pub fn message(&self) -> String {
53 match self.kind {
54 BoostInvariantKind::BucketAmounts => format!(
55 "amount_collected_big + amount_collected_small should match amount_collected \
56 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
57 self.actual, self.expected, self.diff, self.tolerance
58 ),
59 BoostInvariantKind::NominalPickupAmount => format!(
60 "amount_collected + overfill_total should match nominal pad value from pickup counts \
61 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
62 self.actual, self.expected, self.diff, self.tolerance
63 ),
64 BoostInvariantKind::NominalStolenPickupAmount => format!(
65 "amount_stolen + overfill_from_stolen should match nominal stolen pad value from pickup counts \
66 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
67 self.actual, self.expected, self.diff, self.tolerance
68 ),
69 BoostInvariantKind::CurrentAmount => format!(
70 "amount_obtained - amount_used should match observed current boost \
71 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
72 self.actual, self.expected, self.diff, self.tolerance
73 ),
74 BoostInvariantKind::UsedSplitAmounts => format!(
75 "amount_used_while_grounded + amount_used_while_airborne should match amount_used \
76 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
77 self.actual, self.expected, self.diff, self.tolerance
78 ),
79 }
80 }
81}
82
83pub fn nominal_pickup_amount_from_counts(stats: &BoostStats) -> f32 {
84 stats.big_pads_collected as f32 * BOOST_MAX_AMOUNT
85 + stats.small_pads_collected as f32 * boost_percent_to_amount(12.0)
86}
87
88pub fn nominal_stolen_pickup_amount_from_counts(stats: &BoostStats) -> f32 {
89 stats.big_pads_stolen as f32 * BOOST_MAX_AMOUNT
90 + stats.small_pads_stolen as f32 * boost_percent_to_amount(12.0)
91}
92
93fn nominal_pickup_tolerance(pickup_count: u32) -> f32 {
94 BOOST_INVARIANT_BASE_TOLERANCE_RAW
95 + BOOST_INVARIANT_PER_PICKUP_TOLERANCE_RAW * pickup_count as f32
96}
97
98fn push_violation(
99 violations: &mut Vec<BoostInvariantViolation>,
100 kind: BoostInvariantKind,
101 expected: f32,
102 actual: f32,
103 tolerance: f32,
104) {
105 let diff = (actual - expected).abs();
106 if diff > tolerance {
107 violations.push(BoostInvariantViolation {
108 kind,
109 expected,
110 actual,
111 diff,
112 tolerance,
113 });
114 }
115}
116
117pub fn boost_invariant_violations(
120 stats: &BoostStats,
121 observed_boost_amount: Option<f32>,
122) -> Vec<BoostInvariantViolation> {
123 let mut violations = Vec::new();
124
125 push_violation(
126 &mut violations,
127 BoostInvariantKind::BucketAmounts,
128 stats.amount_collected,
129 stats.amount_collected_big + stats.amount_collected_small,
130 1.0,
131 );
132 push_violation(
133 &mut violations,
134 BoostInvariantKind::NominalPickupAmount,
135 nominal_pickup_amount_from_counts(stats),
136 stats.amount_collected + stats.overfill_total,
137 nominal_pickup_tolerance(stats.big_pads_collected + stats.small_pads_collected),
138 );
139 push_violation(
140 &mut violations,
141 BoostInvariantKind::NominalStolenPickupAmount,
142 nominal_stolen_pickup_amount_from_counts(stats),
143 stats.amount_stolen + stats.overfill_from_stolen,
144 nominal_pickup_tolerance(stats.big_pads_stolen + stats.small_pads_stolen),
145 );
146 if let Some(current_boost_amount) = observed_boost_amount {
147 push_violation(
148 &mut violations,
149 BoostInvariantKind::CurrentAmount,
150 current_boost_amount,
151 stats.amount_obtained() - stats.amount_used,
152 1.0,
153 );
154 }
155 push_violation(
156 &mut violations,
157 BoostInvariantKind::UsedSplitAmounts,
158 stats.amount_used,
159 stats.amount_used_by_vertical_band(),
160 1.0,
161 );
162
163 violations
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn reports_used_split_mismatch() {
172 let stats = BoostStats {
173 amount_used: 20.0,
174 amount_used_while_grounded: 8.0,
175 amount_used_while_airborne: 9.0,
176 ..Default::default()
177 };
178
179 let violations = boost_invariant_violations(&stats, None);
180
181 assert!(violations
182 .iter()
183 .any(|violation| violation.kind == BoostInvariantKind::UsedSplitAmounts));
184 }
185
186 #[test]
187 fn accepts_matching_used_split() {
188 let stats = BoostStats {
189 amount_used: 20.0,
190 amount_used_while_grounded: 8.0,
191 amount_used_while_airborne: 12.0,
192 ..Default::default()
193 };
194
195 let violations = boost_invariant_violations(&stats, None);
196
197 assert!(!violations
198 .iter()
199 .any(|violation| violation.kind == BoostInvariantKind::UsedSplitAmounts));
200 }
201}