Skip to main content

subtr_actor/stats/calculators/
boost.rs

1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
4#[ts(export)]
5pub struct BoostStats {
6    pub tracked_time: f32,
7    pub boost_integral: f32,
8    pub time_zero_boost: f32,
9    pub time_hundred_boost: f32,
10    pub time_boost_0_25: f32,
11    pub time_boost_25_50: f32,
12    pub time_boost_50_75: f32,
13    pub time_boost_75_100: f32,
14    pub amount_collected: f32,
15    pub amount_collected_inactive: f32,
16    pub big_pads_collected_inactive: u32,
17    pub small_pads_collected_inactive: u32,
18    pub amount_stolen: f32,
19    pub big_pads_collected: u32,
20    pub small_pads_collected: u32,
21    pub big_pads_stolen: u32,
22    pub small_pads_stolen: u32,
23    pub amount_collected_big: f32,
24    pub amount_stolen_big: f32,
25    pub amount_collected_small: f32,
26    pub amount_stolen_small: f32,
27    pub amount_respawned: f32,
28    pub overfill_total: f32,
29    pub overfill_from_stolen: f32,
30    pub amount_used: f32,
31    pub amount_used_while_grounded: f32,
32    pub amount_used_while_airborne: f32,
33    pub amount_used_while_supersonic: f32,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum BoostIncreaseReason {
38    KickoffRespawn,
39    DemoRespawn,
40    Respawn,
41    BigPad,
42    SmallPad,
43    AmbiguousPad,
44    Unknown,
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
48#[serde(rename_all = "snake_case")]
49#[ts(export)]
50pub enum BoostPickupPadType {
51    Big,
52    Small,
53    Ambiguous,
54}
55
56impl BoostPickupPadType {
57    fn is_compatible_with(self, reported: Self) -> bool {
58        match self {
59            Self::Ambiguous => matches!(reported, Self::Big | Self::Small),
60            _ => self == reported,
61        }
62    }
63}
64
65impl From<BoostPadSize> for BoostPickupPadType {
66    fn from(pad_size: BoostPadSize) -> Self {
67        match pad_size {
68            BoostPadSize::Big => Self::Big,
69            BoostPadSize::Small => Self::Small,
70        }
71    }
72}
73
74impl TryFrom<BoostIncreaseReason> for BoostPickupPadType {
75    type Error = ();
76
77    fn try_from(reason: BoostIncreaseReason) -> Result<Self, Self::Error> {
78        match reason {
79            BoostIncreaseReason::BigPad => Ok(Self::Big),
80            BoostIncreaseReason::SmallPad => Ok(Self::Small),
81            BoostIncreaseReason::AmbiguousPad => Ok(Self::Ambiguous),
82            BoostIncreaseReason::KickoffRespawn
83            | BoostIncreaseReason::DemoRespawn
84            | BoostIncreaseReason::Respawn
85            | BoostIncreaseReason::Unknown => Err(()),
86        }
87    }
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
91#[serde(rename_all = "snake_case")]
92#[ts(export)]
93pub enum BoostPickupFieldHalf {
94    Own,
95    Opponent,
96    Unknown,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
100#[serde(rename_all = "snake_case")]
101#[ts(export)]
102pub enum BoostPickupActivity {
103    Active,
104    Inactive,
105    Unknown,
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
109#[serde(rename_all = "snake_case")]
110#[ts(export)]
111pub enum BoostPickupComparison {
112    Both,
113    Ghost,
114    Missed,
115}
116
117#[derive(Clone, Debug)]
118struct PendingBoostPickupEvent {
119    frame: usize,
120    time: f32,
121    player_id: PlayerId,
122    is_team_0: bool,
123    pad_type: BoostPickupPadType,
124    field_half: BoostPickupFieldHalf,
125    activity: BoostPickupActivity,
126    boost_before: Option<f32>,
127    boost_after: Option<f32>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
131#[ts(export)]
132pub struct BoostPickupComparisonEvent {
133    pub comparison: BoostPickupComparison,
134    pub frame: usize,
135    pub time: f32,
136    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
137    pub player_id: PlayerId,
138    pub is_team_0: bool,
139    pub pad_type: BoostPickupPadType,
140    pub field_half: BoostPickupFieldHalf,
141    pub activity: BoostPickupActivity,
142    pub reported_frame: Option<usize>,
143    pub reported_time: Option<f32>,
144    pub inferred_frame: Option<usize>,
145    pub inferred_time: Option<f32>,
146    pub boost_before: Option<f32>,
147    pub boost_after: Option<f32>,
148}
149
150impl BoostStats {
151    pub fn average_boost_amount(&self) -> f32 {
152        if self.tracked_time == 0.0 {
153            0.0
154        } else {
155            self.boost_integral / self.tracked_time
156        }
157    }
158
159    pub fn bpm(&self) -> f32 {
160        if self.tracked_time == 0.0 {
161            0.0
162        } else {
163            self.amount_collected * 60.0 / self.tracked_time
164        }
165    }
166
167    fn pct(&self, value: f32) -> f32 {
168        if self.tracked_time == 0.0 {
169            0.0
170        } else {
171            value * 100.0 / self.tracked_time
172        }
173    }
174
175    pub fn zero_boost_pct(&self) -> f32 {
176        self.pct(self.time_zero_boost)
177    }
178
179    pub fn hundred_boost_pct(&self) -> f32 {
180        self.pct(self.time_hundred_boost)
181    }
182
183    pub fn boost_0_25_pct(&self) -> f32 {
184        self.pct(self.time_boost_0_25)
185    }
186
187    pub fn boost_25_50_pct(&self) -> f32 {
188        self.pct(self.time_boost_25_50)
189    }
190
191    pub fn boost_50_75_pct(&self) -> f32 {
192        self.pct(self.time_boost_50_75)
193    }
194
195    pub fn boost_75_100_pct(&self) -> f32 {
196        self.pct(self.time_boost_75_100)
197    }
198
199    pub fn amount_obtained(&self) -> f32 {
200        self.amount_collected_big + self.amount_collected_small + self.amount_respawned
201    }
202
203    pub fn amount_used_by_vertical_band(&self) -> f32 {
204        self.amount_used_while_grounded + self.amount_used_while_airborne
205    }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
209pub struct BoostCalculatorConfig {
210    pub include_non_live_pickups: bool,
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct BoostCalculator {
215    config: BoostCalculatorConfig,
216    player_stats: HashMap<PlayerId, BoostStats>,
217    team_zero_stats: BoostStats,
218    team_one_stats: BoostStats,
219    previous_boost_amounts: HashMap<PlayerId, f32>,
220    previous_player_speeds: HashMap<PlayerId, f32>,
221    observed_pad_positions: HashMap<String, PadPositionEstimate>,
222    known_pad_sizes: HashMap<String, BoostPadSize>,
223    known_pad_indices: HashMap<String, usize>,
224    unavailable_pads: HashSet<String>,
225    seen_pickup_sequence_times: HashMap<(String, u8), f32>,
226    pickup_frames: HashMap<(String, PlayerId), usize>,
227    inactive_pickup_frames: HashSet<(PlayerId, usize, BoostPadSize)>,
228    last_pickup_times: HashMap<String, f32>,
229    pending_inferred_pickups: VecDeque<PendingBoostPickupEvent>,
230    pending_reported_pickups: VecDeque<PendingBoostPickupEvent>,
231    pickup_comparison_events: Vec<BoostPickupComparisonEvent>,
232    kickoff_phase_active_last_frame: bool,
233    kickoff_respawn_awarded: HashSet<PlayerId>,
234    initial_respawn_awarded: HashSet<PlayerId>,
235    pending_demo_respawns: HashSet<PlayerId>,
236    previous_boost_levels_live: Option<bool>,
237    active_invariant_warnings: HashSet<BoostInvariantWarningKey>,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Hash)]
241struct BoostInvariantWarningKey {
242    scope: String,
243    kind: BoostInvariantKind,
244}
245
246#[derive(Debug, Clone)]
247struct PendingBoostPickup {
248    player_id: PlayerId,
249    is_team_0: bool,
250    previous_boost_amount: f32,
251    pre_applied_collected_amount: f32,
252    pre_applied_pad_size: Option<BoostPadSize>,
253    player_position: glam::Vec3,
254}
255
256impl BoostCalculator {
257    const PICKUP_MATCH_FRAME_WINDOW: usize = 3;
258
259    pub fn new() -> Self {
260        Self::with_config(BoostCalculatorConfig::default())
261    }
262
263    pub fn with_config(config: BoostCalculatorConfig) -> Self {
264        Self {
265            config,
266            ..Self::default()
267        }
268    }
269
270    pub fn player_stats(&self) -> &HashMap<PlayerId, BoostStats> {
271        &self.player_stats
272    }
273
274    pub fn team_zero_stats(&self) -> &BoostStats {
275        &self.team_zero_stats
276    }
277
278    pub fn team_one_stats(&self) -> &BoostStats {
279        &self.team_one_stats
280    }
281
282    pub fn pickup_comparison_events(&self) -> &[BoostPickupComparisonEvent] {
283        &self.pickup_comparison_events
284    }
285
286    fn estimated_pad_position(&self, pad_id: &str) -> Option<glam::Vec3> {
287        self.observed_pad_positions
288            .get(pad_id)
289            .and_then(PadPositionEstimate::mean)
290    }
291
292    fn observed_pad_positions(&self, pad_id: &str) -> &[glam::Vec3] {
293        self.observed_pad_positions
294            .get(pad_id)
295            .map(PadPositionEstimate::observations)
296            .unwrap_or(&[])
297    }
298
299    fn pad_match_radius(pad_size: BoostPadSize) -> f32 {
300        match pad_size {
301            BoostPadSize::Big => STANDARD_PAD_MATCH_RADIUS_BIG,
302            BoostPadSize::Small => STANDARD_PAD_MATCH_RADIUS_SMALL,
303        }
304    }
305
306    pub fn resolved_boost_pads(&self) -> Vec<ResolvedBoostPad> {
307        standard_soccar_boost_pad_layout()
308            .iter()
309            .enumerate()
310            .map(|(index, (position, size))| ResolvedBoostPad {
311                index,
312                pad_id: self
313                    .known_pad_indices
314                    .iter()
315                    .find_map(|(pad_id, pad_index)| (*pad_index == index).then(|| pad_id.clone())),
316                size: *size,
317                position: glam_to_vec(position),
318            })
319            .collect()
320    }
321
322    fn infer_pad_index(
323        &self,
324        pad_id: &str,
325        pad_size: BoostPadSize,
326        observed_position: glam::Vec3,
327    ) -> Option<usize> {
328        if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
329            return Some(index);
330        }
331
332        let observed_position = self
333            .estimated_pad_position(pad_id)
334            .unwrap_or(observed_position);
335        let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
336        let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
337        let radius = Self::pad_match_radius(pad_size);
338        let observed_positions = self.observed_pad_positions(pad_id);
339        let best_candidate = |allow_used: bool| {
340            layout
341                .iter()
342                .enumerate()
343                .filter(|(index, (_, size))| {
344                    *size == pad_size && (allow_used || !used_indices.contains(index))
345                })
346                .filter_map(|(index, (candidate_position, _))| {
347                    let mut vote_count = 0usize;
348                    let mut total_vote_distance = 0.0f32;
349                    let mut best_vote_distance = f32::INFINITY;
350
351                    for position in observed_positions {
352                        let distance = position.distance(*candidate_position);
353                        if distance <= radius {
354                            vote_count += 1;
355                            total_vote_distance += distance;
356                            best_vote_distance = best_vote_distance.min(distance);
357                        }
358                    }
359
360                    if vote_count == 0 {
361                        return None;
362                    }
363
364                    let representative_distance = observed_position.distance(*candidate_position);
365                    Some((
366                        index,
367                        vote_count,
368                        total_vote_distance / vote_count as f32,
369                        best_vote_distance,
370                        representative_distance,
371                    ))
372                })
373                .max_by(|left, right| {
374                    left.1
375                        .cmp(&right.1)
376                        .then_with(|| right.2.partial_cmp(&left.2).unwrap())
377                        .then_with(|| right.3.partial_cmp(&left.3).unwrap())
378                        .then_with(|| right.4.partial_cmp(&left.4).unwrap())
379                })
380                .map(|(index, _, _, _, _)| index)
381        };
382
383        best_candidate(false)
384            .or_else(|| best_candidate(true))
385            .or_else(|| {
386                layout
387                    .iter()
388                    .enumerate()
389                    .filter(|(index, (_, size))| *size == pad_size && !used_indices.contains(index))
390                    .min_by(|(_, (a, _)), (_, (b, _))| {
391                        observed_position
392                            .distance_squared(*a)
393                            .partial_cmp(&observed_position.distance_squared(*b))
394                            .unwrap()
395                    })
396                    .map(|(index, _)| index)
397            })
398            .or_else(|| {
399                layout
400                    .iter()
401                    .enumerate()
402                    .filter(|(_, (_, size))| *size == pad_size)
403                    .min_by(|(_, (a, _)), (_, (b, _))| {
404                        observed_position
405                            .distance_squared(*a)
406                            .partial_cmp(&observed_position.distance_squared(*b))
407                            .unwrap()
408                    })
409                    .map(|(index, _)| index)
410            })
411            .filter(|index| {
412                observed_position.distance(standard_soccar_boost_pad_position(*index)) <= radius
413            })
414    }
415
416    fn infer_pad_details_from_position(
417        &self,
418        pad_id: &str,
419        observed_position: glam::Vec3,
420    ) -> Option<(usize, BoostPadSize)> {
421        if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
422            let (_, size) = standard_soccar_boost_pad_layout().get(index)?;
423            return Some((index, *size));
424        }
425
426        let observed_position = self
427            .estimated_pad_position(pad_id)
428            .unwrap_or(observed_position);
429        let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
430        let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
431        let observed_positions = self.observed_pad_positions(pad_id);
432        let best_candidate = |allow_used: bool| {
433            layout
434                .iter()
435                .enumerate()
436                .filter(|(index, _)| allow_used || !used_indices.contains(index))
437                .filter_map(|(index, (candidate_position, size))| {
438                    let radius = Self::pad_match_radius(*size);
439                    let mut vote_count = 0usize;
440                    let mut total_vote_distance = 0.0f32;
441                    let mut best_vote_distance = f32::INFINITY;
442
443                    for position in observed_positions {
444                        let distance = position.distance(*candidate_position);
445                        if distance <= radius {
446                            vote_count += 1;
447                            total_vote_distance += distance;
448                            best_vote_distance = best_vote_distance.min(distance);
449                        }
450                    }
451
452                    if vote_count == 0 {
453                        return None;
454                    }
455
456                    let representative_distance = observed_position.distance(*candidate_position);
457                    Some((
458                        index,
459                        *size,
460                        vote_count,
461                        total_vote_distance / vote_count as f32,
462                        best_vote_distance,
463                        representative_distance,
464                    ))
465                })
466                .max_by(|left, right| {
467                    left.2
468                        .cmp(&right.2)
469                        .then_with(|| right.3.partial_cmp(&left.3).unwrap())
470                        .then_with(|| right.4.partial_cmp(&left.4).unwrap())
471                        .then_with(|| right.5.partial_cmp(&left.5).unwrap())
472                })
473                .map(|(index, size, _, _, _, _)| (index, size))
474        };
475
476        best_candidate(false).or_else(|| best_candidate(true))
477    }
478
479    fn guess_pad_size_from_position(
480        &self,
481        pad_id: &str,
482        observed_position: glam::Vec3,
483    ) -> Option<BoostPadSize> {
484        if let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied() {
485            return Some(pad_size);
486        }
487
488        if let Some((_, pad_size)) = self.infer_pad_details_from_position(pad_id, observed_position)
489        {
490            return Some(pad_size);
491        }
492
493        let observed_position = self
494            .estimated_pad_position(pad_id)
495            .unwrap_or(observed_position);
496        standard_soccar_boost_pad_layout()
497            .iter()
498            .min_by(|(left_position, _), (right_position, _)| {
499                observed_position
500                    .distance_squared(*left_position)
501                    .partial_cmp(&observed_position.distance_squared(*right_position))
502                    .unwrap()
503            })
504            .map(|(_, pad_size)| *pad_size)
505    }
506
507    fn resolve_pickup(
508        &mut self,
509        pad_id: &str,
510        pending_pickup: PendingBoostPickup,
511        pad_size: BoostPadSize,
512    ) -> BoostPickupFieldHalf {
513        let observed_position = self
514            .estimated_pad_position(pad_id)
515            .unwrap_or(pending_pickup.player_position);
516        let pad_position = self
517            .infer_pad_index(pad_id, pad_size, observed_position)
518            .map(|index| {
519                self.known_pad_indices.insert(pad_id.to_string(), index);
520                standard_soccar_boost_pad_position(index)
521            })
522            .unwrap_or(observed_position);
523        let stolen = is_enemy_side(pending_pickup.is_team_0, pad_position);
524        let stats = self
525            .player_stats
526            .entry(pending_pickup.player_id.clone())
527            .or_default();
528        let team_stats = if pending_pickup.is_team_0 {
529            &mut self.team_zero_stats
530        } else {
531            &mut self.team_one_stats
532        };
533        let nominal_gain = match pad_size {
534            BoostPadSize::Big => BOOST_MAX_AMOUNT,
535            BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
536        };
537        let collected_amount = (BOOST_MAX_AMOUNT - pending_pickup.previous_boost_amount)
538            .min(nominal_gain)
539            .max(pending_pickup.pre_applied_collected_amount);
540        let collected_amount_delta = collected_amount - pending_pickup.pre_applied_collected_amount;
541        let overfill = (nominal_gain - collected_amount).max(0.0);
542
543        stats.amount_collected += collected_amount_delta;
544        team_stats.amount_collected += collected_amount_delta;
545
546        match pending_pickup.pre_applied_pad_size {
547            Some(pre_applied_pad_size) if pre_applied_pad_size == pad_size => {
548                Self::apply_collected_bucket_amount(stats, pad_size, collected_amount_delta);
549                Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount_delta);
550            }
551            Some(pre_applied_pad_size) => {
552                Self::apply_collected_bucket_amount(
553                    stats,
554                    pre_applied_pad_size,
555                    -pending_pickup.pre_applied_collected_amount,
556                );
557                Self::apply_collected_bucket_amount(
558                    team_stats,
559                    pre_applied_pad_size,
560                    -pending_pickup.pre_applied_collected_amount,
561                );
562                Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
563                Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
564            }
565            None => {
566                Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
567                Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
568            }
569        }
570
571        if stolen {
572            stats.amount_stolen += collected_amount;
573            team_stats.amount_stolen += collected_amount;
574        }
575
576        match pad_size {
577            BoostPadSize::Big => {
578                stats.big_pads_collected += 1;
579                team_stats.big_pads_collected += 1;
580                if stolen {
581                    stats.big_pads_stolen += 1;
582                    team_stats.big_pads_stolen += 1;
583                    stats.amount_stolen_big += collected_amount;
584                    team_stats.amount_stolen_big += collected_amount;
585                }
586            }
587            BoostPadSize::Small => {
588                stats.small_pads_collected += 1;
589                team_stats.small_pads_collected += 1;
590                if stolen {
591                    stats.small_pads_stolen += 1;
592                    team_stats.small_pads_stolen += 1;
593                    stats.amount_stolen_small += collected_amount;
594                    team_stats.amount_stolen_small += collected_amount;
595                }
596            }
597        }
598
599        stats.overfill_total += overfill;
600        team_stats.overfill_total += overfill;
601        if stolen {
602            stats.overfill_from_stolen += overfill;
603            team_stats.overfill_from_stolen += overfill;
604        }
605
606        if stolen {
607            BoostPickupFieldHalf::Opponent
608        } else {
609            BoostPickupFieldHalf::Own
610        }
611    }
612
613    fn apply_collected_bucket_amount(stats: &mut BoostStats, pad_size: BoostPadSize, amount: f32) {
614        if amount == 0.0 {
615            return;
616        }
617
618        match pad_size {
619            BoostPadSize::Big => stats.amount_collected_big += amount,
620            BoostPadSize::Small => stats.amount_collected_small += amount,
621        }
622    }
623
624    fn apply_pickup_collected_amount(
625        &mut self,
626        player_id: &PlayerId,
627        is_team_0: bool,
628        amount: f32,
629        pad_size: Option<BoostPadSize>,
630    ) {
631        if amount <= 0.0 {
632            return;
633        }
634
635        let stats = self.player_stats.entry(player_id.clone()).or_default();
636        let team_stats = if is_team_0 {
637            &mut self.team_zero_stats
638        } else {
639            &mut self.team_one_stats
640        };
641        stats.amount_collected += amount;
642        team_stats.amount_collected += amount;
643        if let Some(pad_size) = pad_size {
644            Self::apply_collected_bucket_amount(stats, pad_size, amount);
645            Self::apply_collected_bucket_amount(team_stats, pad_size, amount);
646        }
647    }
648
649    fn apply_inactive_pickup(
650        &mut self,
651        player_id: &PlayerId,
652        is_team_0: bool,
653        amount: f32,
654        pad_size: BoostPadSize,
655    ) {
656        let stats = self.player_stats.entry(player_id.clone()).or_default();
657        let team_stats = if is_team_0 {
658            &mut self.team_zero_stats
659        } else {
660            &mut self.team_one_stats
661        };
662        stats.amount_collected_inactive += amount;
663        team_stats.amount_collected_inactive += amount;
664        match pad_size {
665            BoostPadSize::Big => {
666                stats.big_pads_collected_inactive += 1;
667                team_stats.big_pads_collected_inactive += 1;
668            }
669            BoostPadSize::Small => {
670                stats.small_pads_collected_inactive += 1;
671                team_stats.small_pads_collected_inactive += 1;
672            }
673        }
674    }
675
676    fn apply_respawn_amount(&mut self, player_id: &PlayerId, is_team_0: bool, amount: f32) {
677        if amount <= 0.0 {
678            return;
679        }
680
681        let stats = self.player_stats.entry(player_id.clone()).or_default();
682        let team_stats = if is_team_0 {
683            &mut self.team_zero_stats
684        } else {
685            &mut self.team_one_stats
686        };
687        stats.amount_respawned += amount;
688        team_stats.amount_respawned += amount;
689    }
690
691    fn warn_for_boost_invariant_violations(
692        &mut self,
693        scope: &str,
694        frame_number: usize,
695        time: f32,
696        stats: &BoostStats,
697        observed_boost_amount: Option<f32>,
698    ) {
699        let violations = boost_invariant_violations(stats, observed_boost_amount);
700        let active_kinds: HashSet<BoostInvariantKind> =
701            violations.iter().map(|violation| violation.kind).collect();
702
703        for violation in violations {
704            let key = BoostInvariantWarningKey {
705                scope: scope.to_string(),
706                kind: violation.kind,
707            };
708            if self.active_invariant_warnings.insert(key) {
709                log::warn!(
710                    "Boost invariant violation for {} at frame {} (t={:.3}): {}",
711                    scope,
712                    frame_number,
713                    time,
714                    violation.message(),
715                );
716            }
717        }
718
719        for kind in BoostInvariantKind::ALL {
720            if active_kinds.contains(&kind) {
721                continue;
722            }
723            self.active_invariant_warnings
724                .remove(&BoostInvariantWarningKey {
725                    scope: scope.to_string(),
726                    kind,
727                });
728        }
729    }
730
731    fn warn_for_sample_boost_invariants(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
732        let team_zero_stats = self.team_zero_stats.clone();
733        let team_one_stats = self.team_one_stats.clone();
734        let player_scopes: Vec<(PlayerId, Option<f32>, BoostStats)> = players
735            .players
736            .iter()
737            .map(|player| {
738                (
739                    player.player_id.clone(),
740                    player.boost_amount,
741                    self.player_stats
742                        .get(&player.player_id)
743                        .cloned()
744                        .unwrap_or_default(),
745                )
746            })
747            .collect();
748
749        self.warn_for_boost_invariant_violations(
750            "team_zero",
751            frame.frame_number,
752            frame.time,
753            &team_zero_stats,
754            None,
755        );
756        self.warn_for_boost_invariant_violations(
757            "team_one",
758            frame.frame_number,
759            frame.time,
760            &team_one_stats,
761            None,
762        );
763        for (player_id, observed_boost_amount, stats) in player_scopes {
764            self.warn_for_boost_invariant_violations(
765                &format!("player {player_id:?}"),
766                frame.frame_number,
767                frame.time,
768                &stats,
769                observed_boost_amount,
770            );
771        }
772    }
773
774    fn interval_fraction_in_boost_range(
775        start_boost: f32,
776        end_boost: f32,
777        min_boost: f32,
778        max_boost: f32,
779    ) -> f32 {
780        if (end_boost - start_boost).abs() <= f32::EPSILON {
781            return ((start_boost >= min_boost) && (start_boost < max_boost)) as i32 as f32;
782        }
783
784        let t_at_min = (min_boost - start_boost) / (end_boost - start_boost);
785        let t_at_max = (max_boost - start_boost) / (end_boost - start_boost);
786        let interval_start = t_at_min.min(t_at_max).max(0.0);
787        let interval_end = t_at_min.max(t_at_max).min(1.0);
788        (interval_end - interval_start).max(0.0)
789    }
790
791    fn pad_respawn_time_seconds(pad_size: BoostPadSize) -> f32 {
792        match pad_size {
793            BoostPadSize::Big => 10.0,
794            BoostPadSize::Small => 4.0,
795        }
796    }
797
798    fn seen_pickup_sequence_is_recent(
799        &self,
800        pad_id: &str,
801        sequence: u8,
802        event_time: f32,
803        player_position: Option<glam::Vec3>,
804    ) -> bool {
805        let Some(last_time) = self
806            .seen_pickup_sequence_times
807            .get(&(pad_id.to_string(), sequence))
808            .copied()
809        else {
810            return false;
811        };
812        let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
813            player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
814        }) else {
815            return false;
816        };
817        event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
818    }
819
820    fn unavailable_pad_is_recent(
821        &self,
822        pad_id: &str,
823        event_time: f32,
824        player_position: Option<glam::Vec3>,
825    ) -> bool {
826        if !self.unavailable_pads.contains(pad_id) {
827            return false;
828        }
829        let Some(last_time) = self.last_pickup_times.get(pad_id).copied() else {
830            return true;
831        };
832        let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
833            player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
834        }) else {
835            return true;
836        };
837        event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
838    }
839
840    fn boost_levels_live(live_play: bool) -> bool {
841        live_play
842    }
843
844    fn tracks_boost_levels(boost_levels_live: bool) -> bool {
845        boost_levels_live
846    }
847
848    fn tracks_boost_pickups(gameplay: &GameplayState, live_play: bool) -> bool {
849        live_play
850            || (gameplay.ball_has_been_hit == Some(false)
851                && gameplay.game_state != Some(GAME_STATE_KICKOFF_COUNTDOWN)
852                && gameplay.kickoff_countdown_time.is_none_or(|t| t <= 0))
853    }
854
855    fn activity_label(active: bool) -> BoostPickupActivity {
856        if active {
857            BoostPickupActivity::Active
858        } else {
859            BoostPickupActivity::Inactive
860        }
861    }
862
863    fn field_half_from_position(
864        is_team_0: bool,
865        position: Option<glam::Vec3>,
866    ) -> BoostPickupFieldHalf {
867        match position {
868            Some(position) if is_enemy_side(is_team_0, position) => BoostPickupFieldHalf::Opponent,
869            Some(_) => BoostPickupFieldHalf::Own,
870            None => BoostPickupFieldHalf::Unknown,
871        }
872    }
873
874    fn classify_boost_increase_reasons(
875        previous_boost: f32,
876        boost: f32,
877        kickoff_phase_active: bool,
878        demo_respawn_supported: bool,
879    ) -> Vec<BoostIncreaseReason> {
880        const TOLERANCE: f32 = 1.0;
881        let delta = boost - previous_boost;
882        if delta <= TOLERANCE {
883            return vec![BoostIncreaseReason::Unknown];
884        }
885
886        let is_respawn_value = (boost - BOOST_KICKOFF_START_AMOUNT).abs() <= TOLERANCE;
887        if demo_respawn_supported && is_respawn_value {
888            return vec![BoostIncreaseReason::DemoRespawn];
889        }
890        if kickoff_phase_active && is_respawn_value {
891            return vec![BoostIncreaseReason::KickoffRespawn];
892        }
893        if is_respawn_value {
894            return vec![BoostIncreaseReason::Respawn];
895        }
896
897        let small_pad_floor = SMALL_PAD_AMOUNT_RAW - 3.0;
898        let big_pad_floor = SMALL_PAD_AMOUNT_RAW + 5.0;
899        if boost < BOOST_FULL_BAND_MIN_RAW && delta >= small_pad_floor {
900            const SMALL_PICKUP_COUNT_TOLERANCE: f32 = 3.0;
901            let inferred_small_pickups = ((delta - SMALL_PICKUP_COUNT_TOLERANCE)
902                / SMALL_PAD_AMOUNT_RAW)
903                .ceil()
904                .max(1.0) as usize;
905            return vec![BoostIncreaseReason::SmallPad; inferred_small_pickups];
906        }
907
908        if delta > big_pad_floor {
909            return vec![BoostIncreaseReason::BigPad];
910        }
911        if boost >= BOOST_MAX_AMOUNT - TOLERANCE {
912            return vec![BoostIncreaseReason::AmbiguousPad];
913        }
914        if delta >= small_pad_floor {
915            return vec![BoostIncreaseReason::SmallPad];
916        }
917        vec![BoostIncreaseReason::Unknown]
918    }
919
920    fn emit_pickup_comparison_event(
921        &mut self,
922        comparison: BoostPickupComparison,
923        inferred: Option<PendingBoostPickupEvent>,
924        reported: Option<PendingBoostPickupEvent>,
925    ) {
926        let reference = inferred.as_ref().or(reported.as_ref()).unwrap();
927        let pad_type = reported
928            .as_ref()
929            .map(|event| event.pad_type)
930            .or_else(|| inferred.as_ref().map(|event| event.pad_type))
931            .unwrap_or(reference.pad_type);
932        let field_half = reported
933            .as_ref()
934            .map(|event| event.field_half)
935            .or_else(|| inferred.as_ref().map(|event| event.field_half))
936            .unwrap_or(reference.field_half);
937        let activity = reported
938            .as_ref()
939            .map(|event| event.activity)
940            .or_else(|| inferred.as_ref().map(|event| event.activity))
941            .unwrap_or(reference.activity);
942        let event_frame = inferred
943            .as_ref()
944            .map(|event| event.frame)
945            .or_else(|| reported.as_ref().map(|event| event.frame))
946            .unwrap_or(reference.frame);
947        let event_time = inferred
948            .as_ref()
949            .map(|event| event.time)
950            .or_else(|| reported.as_ref().map(|event| event.time))
951            .unwrap_or(reference.time);
952        let comparison_event = BoostPickupComparisonEvent {
953            comparison,
954            frame: event_frame,
955            time: event_time,
956            player_id: reference.player_id.clone(),
957            is_team_0: reference.is_team_0,
958            pad_type,
959            field_half,
960            activity,
961            reported_frame: reported.as_ref().map(|event| event.frame),
962            reported_time: reported.as_ref().map(|event| event.time),
963            inferred_frame: inferred.as_ref().map(|event| event.frame),
964            inferred_time: inferred.as_ref().map(|event| event.time),
965            boost_before: inferred.as_ref().and_then(|event| event.boost_before),
966            boost_after: inferred.as_ref().and_then(|event| event.boost_after),
967        };
968        self.pickup_comparison_events.push(comparison_event);
969    }
970
971    fn matching_pending_pickup_index(
972        pending: &VecDeque<PendingBoostPickupEvent>,
973        event: &PendingBoostPickupEvent,
974        pending_is_inferred: bool,
975    ) -> Option<usize> {
976        pending
977            .iter()
978            .enumerate()
979            .filter(|(_, pending_event)| {
980                pending_event.player_id == event.player_id
981                    && if pending_is_inferred {
982                        pending_event.pad_type.is_compatible_with(event.pad_type)
983                    } else {
984                        event.pad_type.is_compatible_with(pending_event.pad_type)
985                    }
986                    && pending_event.frame.abs_diff(event.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
987            })
988            .min_by_key(|(_, pending_event)| pending_event.frame.abs_diff(event.frame))
989            .map(|(index, _)| index)
990    }
991
992    fn record_inferred_pickup(&mut self, event: PendingBoostPickupEvent) {
993        if let Some(index) =
994            Self::matching_pending_pickup_index(&self.pending_reported_pickups, &event, false)
995        {
996            let reported = self
997                .pending_reported_pickups
998                .remove(index)
999                .expect("matched reported pickup index should exist");
1000            self.emit_pickup_comparison_event(
1001                BoostPickupComparison::Both,
1002                Some(event),
1003                Some(reported),
1004            );
1005        } else {
1006            self.pending_inferred_pickups.push_back(event);
1007        }
1008    }
1009
1010    fn record_reported_pickup(&mut self, event: PendingBoostPickupEvent) {
1011        if let Some(index) =
1012            Self::matching_pending_pickup_index(&self.pending_inferred_pickups, &event, true)
1013        {
1014            let inferred = self
1015                .pending_inferred_pickups
1016                .remove(index)
1017                .expect("matched inferred pickup index should exist");
1018            self.emit_pickup_comparison_event(
1019                BoostPickupComparison::Both,
1020                Some(inferred),
1021                Some(event),
1022            );
1023        } else {
1024            self.pending_reported_pickups.push_back(event);
1025        }
1026    }
1027
1028    fn flush_stale_pickup_comparisons(&mut self, current_frame: usize) {
1029        while self
1030            .pending_inferred_pickups
1031            .front()
1032            .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < current_frame)
1033        {
1034            let event = self.pending_inferred_pickups.pop_front().unwrap();
1035            self.emit_pickup_comparison_event(BoostPickupComparison::Missed, Some(event), None);
1036        }
1037        while self
1038            .pending_reported_pickups
1039            .front()
1040            .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < current_frame)
1041        {
1042            let event = self.pending_reported_pickups.pop_front().unwrap();
1043            self.emit_pickup_comparison_event(BoostPickupComparison::Ghost, None, Some(event));
1044        }
1045    }
1046
1047    pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
1048        while let Some(event) = self.pending_inferred_pickups.pop_front() {
1049            self.emit_pickup_comparison_event(BoostPickupComparison::Missed, Some(event), None);
1050        }
1051        while let Some(event) = self.pending_reported_pickups.pop_front() {
1052            self.emit_pickup_comparison_event(BoostPickupComparison::Ghost, None, Some(event));
1053        }
1054        Ok(())
1055    }
1056
1057    fn inactive_pickup_stats(
1058        &self,
1059        player: &PlayerSample,
1060        pad_id: &str,
1061        previous_boost_amount: f32,
1062        respawn_amount: f32,
1063    ) -> Option<(f32, BoostPadSize)> {
1064        let pad_size = self
1065            .known_pad_sizes
1066            .get(pad_id)
1067            .copied()
1068            .or_else(|| self.guess_pad_size_from_position(pad_id, player.position()?))?;
1069        let nominal_gain = match pad_size {
1070            BoostPadSize::Big => BOOST_MAX_AMOUNT,
1071            BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
1072        };
1073        let capacity_limited_gain = (BOOST_MAX_AMOUNT - previous_boost_amount)
1074            .min(nominal_gain)
1075            .max(0.0);
1076        let observed_gain = player
1077            .boost_amount
1078            .map(|boost_amount| (boost_amount - previous_boost_amount - respawn_amount).max(0.0))
1079            .unwrap_or(0.0);
1080        if observed_gain <= 1.0 {
1081            return None;
1082        }
1083        Some((
1084            capacity_limited_gain.max(observed_gain).min(nominal_gain),
1085            pad_size,
1086        ))
1087    }
1088
1089    pub fn update_parts(
1090        &mut self,
1091        frame: &FrameInfo,
1092        gameplay: &GameplayState,
1093        players: &PlayerFrameState,
1094        events: &FrameEventsState,
1095        vertical_state: &PlayerVerticalState,
1096        live_play: bool,
1097    ) -> SubtrActorResult<()> {
1098        let boost_levels_live = Self::boost_levels_live(live_play);
1099        let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
1100        let track_boost_pickups = Self::tracks_boost_pickups(gameplay, live_play);
1101        let boost_levels_resumed_this_sample =
1102            boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
1103        let kickoff_phase_active = gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
1104            || gameplay.kickoff_countdown_time.is_some_and(|t| t > 0)
1105            || gameplay.ball_has_been_hit == Some(false);
1106        let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
1107        if kickoff_phase_started {
1108            self.kickoff_respawn_awarded.clear();
1109        }
1110        for demo in &events.demo_events {
1111            self.pending_demo_respawns.insert(demo.victim.clone());
1112        }
1113
1114        let mut current_boost_amounts = Vec::new();
1115        let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
1116        let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
1117
1118        for event in &events.boost_pad_events {
1119            let BoostPadEventKind::PickedUp { .. } = event.kind else {
1120                continue;
1121            };
1122            let Some(player_id) = &event.player else {
1123                continue;
1124            };
1125            *pickup_counts_by_player
1126                .entry(player_id.clone())
1127                .or_default() += 1;
1128        }
1129
1130        for player in &players.players {
1131            let Some(boost_amount) = player.boost_amount else {
1132                continue;
1133            };
1134            let previous_sample_boost_amount =
1135                self.previous_boost_amounts.get(&player.player_id).copied();
1136            let previous_boost_amount = player
1137                .last_boost_amount
1138                .unwrap_or_else(|| previous_sample_boost_amount.unwrap_or(boost_amount));
1139            let previous_boost_amount = if boost_levels_resumed_this_sample {
1140                boost_amount
1141            } else {
1142                previous_boost_amount
1143            };
1144            let demo_respawn_supported = self.pending_demo_respawns.contains(&player.player_id)
1145                && player.rigid_body.is_some();
1146            if let Some(previous_sample_boost_amount) = previous_sample_boost_amount {
1147                let reasons = Self::classify_boost_increase_reasons(
1148                    previous_sample_boost_amount,
1149                    boost_amount,
1150                    kickoff_phase_active,
1151                    demo_respawn_supported,
1152                );
1153                for reason in reasons {
1154                    if let Ok(pad_type) = BoostPickupPadType::try_from(reason) {
1155                        self.record_inferred_pickup(PendingBoostPickupEvent {
1156                            frame: frame.frame_number,
1157                            time: frame.time,
1158                            player_id: player.player_id.clone(),
1159                            is_team_0: player.is_team_0,
1160                            pad_type,
1161                            field_half: Self::field_half_from_position(
1162                                player.is_team_0,
1163                                player.position(),
1164                            ),
1165                            activity: Self::activity_label(live_play),
1166                            boost_before: Some(previous_sample_boost_amount),
1167                            boost_after: Some(boost_amount),
1168                        });
1169                    }
1170                }
1171            }
1172            if track_boost_levels {
1173                let average_boost_amount = (previous_boost_amount + boost_amount) * 0.5;
1174                let time_zero_boost = frame.dt
1175                    * Self::interval_fraction_in_boost_range(
1176                        previous_boost_amount,
1177                        boost_amount,
1178                        0.0,
1179                        BOOST_ZERO_BAND_RAW,
1180                    );
1181                let time_hundred_boost = frame.dt
1182                    * Self::interval_fraction_in_boost_range(
1183                        previous_boost_amount,
1184                        boost_amount,
1185                        BOOST_FULL_BAND_MIN_RAW,
1186                        BOOST_MAX_AMOUNT + 1.0,
1187                    );
1188                let time_boost_0_25 = frame.dt
1189                    * Self::interval_fraction_in_boost_range(
1190                        previous_boost_amount,
1191                        boost_amount,
1192                        0.0,
1193                        boost_percent_to_amount(25.0),
1194                    );
1195                let time_boost_25_50 = frame.dt
1196                    * Self::interval_fraction_in_boost_range(
1197                        previous_boost_amount,
1198                        boost_amount,
1199                        boost_percent_to_amount(25.0),
1200                        boost_percent_to_amount(50.0),
1201                    );
1202                let time_boost_50_75 = frame.dt
1203                    * Self::interval_fraction_in_boost_range(
1204                        previous_boost_amount,
1205                        boost_amount,
1206                        boost_percent_to_amount(50.0),
1207                        boost_percent_to_amount(75.0),
1208                    );
1209                let time_boost_75_100 = frame.dt
1210                    * Self::interval_fraction_in_boost_range(
1211                        previous_boost_amount,
1212                        boost_amount,
1213                        boost_percent_to_amount(75.0),
1214                        BOOST_MAX_AMOUNT + 1.0,
1215                    );
1216                let stats = self
1217                    .player_stats
1218                    .entry(player.player_id.clone())
1219                    .or_default();
1220                let team_stats = if player.is_team_0 {
1221                    &mut self.team_zero_stats
1222                } else {
1223                    &mut self.team_one_stats
1224                };
1225
1226                stats.tracked_time += frame.dt;
1227                stats.boost_integral += average_boost_amount * frame.dt;
1228                team_stats.tracked_time += frame.dt;
1229                team_stats.boost_integral += average_boost_amount * frame.dt;
1230                stats.time_zero_boost += time_zero_boost;
1231                team_stats.time_zero_boost += time_zero_boost;
1232                stats.time_hundred_boost += time_hundred_boost;
1233                team_stats.time_hundred_boost += time_hundred_boost;
1234                stats.time_boost_0_25 += time_boost_0_25;
1235                team_stats.time_boost_0_25 += time_boost_0_25;
1236                stats.time_boost_25_50 += time_boost_25_50;
1237                team_stats.time_boost_25_50 += time_boost_25_50;
1238                stats.time_boost_50_75 += time_boost_50_75;
1239                team_stats.time_boost_50_75 += time_boost_50_75;
1240                stats.time_boost_75_100 += time_boost_75_100;
1241                team_stats.time_boost_75_100 += time_boost_75_100;
1242            }
1243
1244            let mut respawn_amount = 0.0;
1245            // Grant initial kickoff respawn the first time we see each player.
1246            // This handles replays that start after the kickoff countdown has
1247            // already ended (game_state != 55 on the first frame).
1248            let first_seen_player = self
1249                .initial_respawn_awarded
1250                .insert(player.player_id.clone());
1251            if first_seen_player
1252                || (kickoff_phase_active
1253                    && !self.kickoff_respawn_awarded.contains(&player.player_id))
1254            {
1255                respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1256                self.kickoff_respawn_awarded
1257                    .insert(player.player_id.clone());
1258            }
1259            if demo_respawn_supported {
1260                respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1261                self.pending_demo_respawns.remove(&player.player_id);
1262            }
1263            if respawn_amount > 0.0 {
1264                self.apply_respawn_amount(&player.player_id, player.is_team_0, respawn_amount);
1265            }
1266            respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
1267
1268            current_boost_amounts.push((player.player_id.clone(), boost_amount));
1269        }
1270
1271        for event in &events.boost_pad_events {
1272            match event.kind {
1273                BoostPadEventKind::PickedUp { sequence } => {
1274                    if !track_boost_pickups && !self.config.include_non_live_pickups {
1275                        let Some(player_id) = &event.player else {
1276                            continue;
1277                        };
1278                        let Some(player) = players
1279                            .players
1280                            .iter()
1281                            .find(|player| &player.player_id == player_id)
1282                        else {
1283                            continue;
1284                        };
1285                        let previous_boost_amount = self
1286                            .previous_boost_amounts
1287                            .get(player_id)
1288                            .copied()
1289                            .or(player.last_boost_amount)
1290                            .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1291                        let respawn_amount = respawn_amounts_by_player
1292                            .get(player_id)
1293                            .copied()
1294                            .unwrap_or(0.0);
1295                        let Some((collected_amount, pad_size)) = self.inactive_pickup_stats(
1296                            player,
1297                            &event.pad_id,
1298                            previous_boost_amount,
1299                            respawn_amount,
1300                        ) else {
1301                            continue;
1302                        };
1303                        if !self.inactive_pickup_frames.insert((
1304                            player_id.clone(),
1305                            event.frame,
1306                            pad_size,
1307                        )) {
1308                            continue;
1309                        }
1310                        self.apply_inactive_pickup(
1311                            player_id,
1312                            player.is_team_0,
1313                            collected_amount,
1314                            pad_size,
1315                        );
1316                        self.record_reported_pickup(PendingBoostPickupEvent {
1317                            frame: event.frame,
1318                            time: event.time,
1319                            player_id: player_id.clone(),
1320                            is_team_0: player.is_team_0,
1321                            pad_type: pad_size.into(),
1322                            field_half: Self::field_half_from_position(
1323                                player.is_team_0,
1324                                player.position(),
1325                            ),
1326                            activity: BoostPickupActivity::Inactive,
1327                            boost_before: None,
1328                            boost_after: None,
1329                        });
1330                        continue;
1331                    }
1332                    let Some(player_id) = &event.player else {
1333                        continue;
1334                    };
1335                    let Some(player) = players
1336                        .players
1337                        .iter()
1338                        .find(|player| &player.player_id == player_id)
1339                    else {
1340                        continue;
1341                    };
1342                    if self.unavailable_pad_is_recent(&event.pad_id, event.time, player.position())
1343                    {
1344                        continue;
1345                    }
1346                    let pickup_key = (event.pad_id.clone(), player_id.clone());
1347                    if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
1348                        continue;
1349                    }
1350                    self.pickup_frames.insert(pickup_key, event.frame);
1351                    if self.seen_pickup_sequence_is_recent(
1352                        &event.pad_id,
1353                        sequence,
1354                        event.time,
1355                        player.position(),
1356                    ) {
1357                        continue;
1358                    }
1359                    self.seen_pickup_sequence_times
1360                        .insert((event.pad_id.clone(), sequence), event.time);
1361                    self.unavailable_pads.insert(event.pad_id.clone());
1362                    self.last_pickup_times
1363                        .insert(event.pad_id.clone(), event.time);
1364                    if let Some(position) = player.position() {
1365                        self.observed_pad_positions
1366                            .entry(event.pad_id.clone())
1367                            .or_default()
1368                            .observe(position);
1369                    }
1370                    let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
1371                        self.previous_boost_amounts
1372                            .get(player_id)
1373                            .copied()
1374                            .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0))
1375                    });
1376                    let pre_applied_collected_amount =
1377                        if pickup_counts_by_player.get(player_id).copied() == Some(1) {
1378                            self.previous_boost_amounts
1379                                .get(player_id)
1380                                .copied()
1381                                .map(|previous_sample_boost_amount| {
1382                                    let respawn_amount = respawn_amounts_by_player
1383                                        .get(player_id)
1384                                        .copied()
1385                                        .unwrap_or(0.0);
1386                                    (player.boost_amount.unwrap_or(previous_boost_amount)
1387                                        - previous_sample_boost_amount
1388                                        - respawn_amount)
1389                                        .max(0.0)
1390                                })
1391                                .unwrap_or(0.0)
1392                        } else {
1393                            0.0
1394                        };
1395                    let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
1396                        .then(|| {
1397                            self.guess_pad_size_from_position(
1398                                &event.pad_id,
1399                                player.position().unwrap_or(glam::Vec3::ZERO),
1400                            )
1401                        })
1402                        .flatten();
1403                    self.apply_pickup_collected_amount(
1404                        player_id,
1405                        player.is_team_0,
1406                        pre_applied_collected_amount,
1407                        pre_applied_pad_size,
1408                    );
1409                    let pending_pickup = PendingBoostPickup {
1410                        player_id: player_id.clone(),
1411                        is_team_0: player.is_team_0,
1412                        previous_boost_amount,
1413                        pre_applied_collected_amount,
1414                        pre_applied_pad_size,
1415                        player_position: player.position().unwrap_or(glam::Vec3::ZERO),
1416                    };
1417
1418                    let pad_size = self
1419                        .known_pad_sizes
1420                        .get(&event.pad_id)
1421                        .copied()
1422                        .or_else(|| {
1423                            let mut size = self.guess_pad_size_from_position(
1424                                &event.pad_id,
1425                                player.position().unwrap_or(glam::Vec3::ZERO),
1426                            )?;
1427                            // Sanity check: if the observed boost gain clearly
1428                            // exceeds what a small pad can provide, the pad must
1429                            // be big.  Use a margin to avoid float imprecision.
1430                            if size == BoostPadSize::Small
1431                                && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW * 1.5
1432                            {
1433                                size = BoostPadSize::Big;
1434                            }
1435                            self.known_pad_sizes.insert(event.pad_id.clone(), size);
1436                            Some(size)
1437                        });
1438                    if let Some(pad_size) = pad_size {
1439                        let field_half =
1440                            self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
1441                        self.record_reported_pickup(PendingBoostPickupEvent {
1442                            frame: event.frame,
1443                            time: event.time,
1444                            player_id: player_id.clone(),
1445                            is_team_0: player.is_team_0,
1446                            pad_type: pad_size.into(),
1447                            field_half,
1448                            activity: Self::activity_label(track_boost_pickups),
1449                            boost_before: None,
1450                            boost_after: None,
1451                        });
1452                    }
1453                }
1454                BoostPadEventKind::Available => {
1455                    if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
1456                        let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
1457                        else {
1458                            continue;
1459                        };
1460                        if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
1461                        {
1462                            continue;
1463                        }
1464                    }
1465                    self.unavailable_pads.remove(&event.pad_id);
1466                }
1467            }
1468        }
1469        self.flush_stale_pickup_comparisons(frame.frame_number);
1470
1471        let mut team_zero_used = self.team_zero_stats.amount_used;
1472        let mut team_one_used = self.team_one_stats.amount_used;
1473        for player in &players.players {
1474            let Some(boost_amount) = player.boost_amount else {
1475                continue;
1476            };
1477            let stats = self
1478                .player_stats
1479                .entry(player.player_id.clone())
1480                .or_default();
1481            let previous_amount_used = stats.amount_used;
1482            let amount_used_raw = (stats.amount_obtained() - boost_amount).max(0.0);
1483            let amount_used = amount_used_raw.max(stats.amount_used);
1484            if track_boost_levels {
1485                let split_amount = stats.amount_used_by_vertical_band();
1486                let amount_used_delta = (amount_used - split_amount).max(0.0);
1487                if amount_used_delta > 0.0 {
1488                    let speed = player.speed();
1489                    let previous_speed = self
1490                        .previous_player_speeds
1491                        .get(&player.player_id)
1492                        .copied()
1493                        .or(speed);
1494                    let previous_speed = if boost_levels_resumed_this_sample {
1495                        speed
1496                    } else {
1497                        previous_speed
1498                    };
1499                    let used_while_supersonic = player.boost_active
1500                        && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
1501                        && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD;
1502                    let team_stats = if player.is_team_0 {
1503                        &mut self.team_zero_stats
1504                    } else {
1505                        &mut self.team_one_stats
1506                    };
1507                    if vertical_state.is_grounded(&player.player_id) {
1508                        stats.amount_used_while_grounded += amount_used_delta;
1509                        team_stats.amount_used_while_grounded += amount_used_delta;
1510                    } else {
1511                        stats.amount_used_while_airborne += amount_used_delta;
1512                        team_stats.amount_used_while_airborne += amount_used_delta;
1513                    }
1514                    if used_while_supersonic {
1515                        stats.amount_used_while_supersonic += amount_used_delta;
1516                        team_stats.amount_used_while_supersonic += amount_used_delta;
1517                    }
1518                }
1519            }
1520            stats.amount_used = amount_used;
1521            let amount_used_delta = amount_used - previous_amount_used;
1522            if amount_used_delta <= 0.0 {
1523                continue;
1524            }
1525            if player.is_team_0 {
1526                team_zero_used += amount_used_delta;
1527            } else {
1528                team_one_used += amount_used_delta;
1529            }
1530        }
1531        self.team_zero_stats.amount_used = team_zero_used;
1532        self.team_one_stats.amount_used = team_one_used;
1533        for (player_id, boost_amount) in current_boost_amounts {
1534            self.previous_boost_amounts.insert(player_id, boost_amount);
1535        }
1536        for player in &players.players {
1537            if let Some(speed) = player.speed() {
1538                self.previous_player_speeds
1539                    .insert(player.player_id.clone(), speed);
1540            }
1541        }
1542        self.warn_for_sample_boost_invariants(frame, players);
1543        self.kickoff_phase_active_last_frame = kickoff_phase_active;
1544        self.previous_boost_levels_live = Some(boost_levels_live);
1545
1546        Ok(())
1547    }
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552    use super::*;
1553
1554    fn test_player(
1555        player_id: PlayerId,
1556        boost_amount: f32,
1557        last_boost_amount: f32,
1558        position: glam::Vec3,
1559    ) -> PlayerSample {
1560        PlayerSample {
1561            player_id,
1562            is_team_0: true,
1563            rigid_body: Some(boxcars::RigidBody {
1564                sleeping: false,
1565                location: glam_to_vec(&position),
1566                rotation: boxcars::Quaternion {
1567                    x: 0.0,
1568                    y: 0.0,
1569                    z: 0.0,
1570                    w: 1.0,
1571                },
1572                linear_velocity: None,
1573                angular_velocity: None,
1574            }),
1575            boost_amount: Some(boost_amount),
1576            last_boost_amount: Some(last_boost_amount),
1577            boost_active: false,
1578            dodge_active: false,
1579            powerslide_active: false,
1580            match_goals: None,
1581            match_assists: None,
1582            match_saves: None,
1583            match_shots: None,
1584            match_score: None,
1585        }
1586    }
1587
1588    #[test]
1589    fn records_inactive_pickup_without_active_collection() {
1590        let mut calculator = BoostCalculator::new();
1591        let player_id = PlayerId::Steam(1);
1592        let (pad_position, _) = standard_soccar_boost_pad_layout()
1593            .iter()
1594            .find(|(_, size)| *size == BoostPadSize::Small)
1595            .copied()
1596            .expect("standard layout should include small pads");
1597        let player = test_player(
1598            player_id.clone(),
1599            BOOST_KICKOFF_START_AMOUNT + SMALL_PAD_AMOUNT_RAW,
1600            0.0,
1601            pad_position,
1602        );
1603
1604        calculator
1605            .update_parts(
1606                &FrameInfo {
1607                    frame_number: 1,
1608                    time: 1.0,
1609                    dt: 1.0 / 30.0,
1610                    seconds_remaining: None,
1611                },
1612                &GameplayState {
1613                    game_state: Some(GAME_STATE_GOAL_SCORED_REPLAY),
1614                    ball_has_been_hit: Some(true),
1615                    ..GameplayState::default()
1616                },
1617                &PlayerFrameState {
1618                    players: vec![player],
1619                },
1620                &FrameEventsState {
1621                    boost_pad_events: vec![BoostPadEvent {
1622                        time: 1.0,
1623                        frame: 1,
1624                        pad_id: "inactive-small-pad".to_string(),
1625                        player: Some(player_id.clone()),
1626                        kind: BoostPadEventKind::PickedUp { sequence: 1 },
1627                    }],
1628                    ..FrameEventsState::default()
1629                },
1630                &PlayerVerticalState::default(),
1631                false,
1632            )
1633            .expect("inactive boost update should succeed");
1634
1635        let player_stats = calculator
1636            .player_stats()
1637            .get(&player_id)
1638            .expect("player stats should be recorded");
1639        assert_eq!(player_stats.amount_collected, 0.0);
1640        assert_eq!(player_stats.small_pads_collected, 0);
1641        assert_eq!(player_stats.amount_collected_inactive, SMALL_PAD_AMOUNT_RAW);
1642        assert_eq!(player_stats.small_pads_collected_inactive, 1);
1643        assert_eq!(calculator.team_zero_stats().amount_collected, 0.0);
1644        assert_eq!(
1645            calculator.team_zero_stats().amount_collected_inactive,
1646            SMALL_PAD_AMOUNT_RAW
1647        );
1648        assert_eq!(
1649            calculator.team_zero_stats().small_pads_collected_inactive,
1650            1
1651        );
1652    }
1653
1654    #[test]
1655    fn counts_reused_pickup_sequence_after_pad_respawn() {
1656        let mut calculator = BoostCalculator::new();
1657        let player_id = PlayerId::Steam(1);
1658        let (pad_position, _) = standard_soccar_boost_pad_layout()
1659            .iter()
1660            .find(|(_, size)| *size == BoostPadSize::Big)
1661            .copied()
1662            .expect("standard layout should include big pads");
1663        let pad_id = "reused-sequence-big-pad".to_string();
1664        let active_gameplay = GameplayState {
1665            ball_has_been_hit: Some(true),
1666            ..GameplayState::default()
1667        };
1668
1669        calculator
1670            .update_parts(
1671                &FrameInfo {
1672                    frame_number: 1,
1673                    time: 1.0,
1674                    dt: 1.0 / 30.0,
1675                    seconds_remaining: None,
1676                },
1677                &active_gameplay,
1678                &PlayerFrameState {
1679                    players: vec![test_player(player_id.clone(), 100.0, 0.0, pad_position)],
1680                },
1681                &FrameEventsState {
1682                    boost_pad_events: vec![BoostPadEvent {
1683                        time: 1.0,
1684                        frame: 1,
1685                        pad_id: pad_id.clone(),
1686                        player: Some(player_id.clone()),
1687                        kind: BoostPadEventKind::PickedUp { sequence: 7 },
1688                    }],
1689                    ..FrameEventsState::default()
1690                },
1691                &PlayerVerticalState::default(),
1692                true,
1693            )
1694            .expect("first boost update should succeed");
1695
1696        calculator
1697            .update_parts(
1698                &FrameInfo {
1699                    frame_number: 2,
1700                    time: 11.1,
1701                    dt: 1.0 / 30.0,
1702                    seconds_remaining: None,
1703                },
1704                &active_gameplay,
1705                &PlayerFrameState {
1706                    players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1707                },
1708                &FrameEventsState {
1709                    boost_pad_events: vec![BoostPadEvent {
1710                        time: 11.1,
1711                        frame: 2,
1712                        pad_id: pad_id.clone(),
1713                        player: None,
1714                        kind: BoostPadEventKind::Available,
1715                    }],
1716                    ..FrameEventsState::default()
1717                },
1718                &PlayerVerticalState::default(),
1719                true,
1720            )
1721            .expect("pad availability update should succeed");
1722
1723        calculator
1724            .update_parts(
1725                &FrameInfo {
1726                    frame_number: 3,
1727                    time: 11.2,
1728                    dt: 1.0 / 30.0,
1729                    seconds_remaining: None,
1730                },
1731                &active_gameplay,
1732                &PlayerFrameState {
1733                    players: vec![test_player(player_id.clone(), 200.0, 100.0, pad_position)],
1734                },
1735                &FrameEventsState {
1736                    boost_pad_events: vec![BoostPadEvent {
1737                        time: 11.2,
1738                        frame: 3,
1739                        pad_id,
1740                        player: Some(player_id.clone()),
1741                        kind: BoostPadEventKind::PickedUp { sequence: 7 },
1742                    }],
1743                    ..FrameEventsState::default()
1744                },
1745                &PlayerVerticalState::default(),
1746                true,
1747            )
1748            .expect("second boost update should succeed");
1749
1750        let player_stats = calculator
1751            .player_stats()
1752            .get(&player_id)
1753            .expect("player stats should be recorded");
1754        assert_eq!(player_stats.big_pads_collected, 2);
1755        assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1756    }
1757
1758    #[test]
1759    fn counts_pickup_after_respawn_without_available_event() {
1760        let mut calculator = BoostCalculator::new();
1761        let player_id = PlayerId::Steam(1);
1762        let (pad_position, _) = standard_soccar_boost_pad_layout()
1763            .iter()
1764            .find(|(_, size)| *size == BoostPadSize::Big)
1765            .copied()
1766            .expect("standard layout should include big pads");
1767        let pad_id = "missing-available-big-pad".to_string();
1768        let active_gameplay = GameplayState {
1769            ball_has_been_hit: Some(true),
1770            ..GameplayState::default()
1771        };
1772
1773        for (frame_number, time, sequence, previous_boost, boost_amount) in
1774            [(1, 1.0, 7, 0.0, 100.0), (2, 11.2, 9, 100.0, 200.0)]
1775        {
1776            calculator
1777                .update_parts(
1778                    &FrameInfo {
1779                        frame_number,
1780                        time,
1781                        dt: 1.0 / 30.0,
1782                        seconds_remaining: None,
1783                    },
1784                    &active_gameplay,
1785                    &PlayerFrameState {
1786                        players: vec![test_player(
1787                            player_id.clone(),
1788                            boost_amount,
1789                            previous_boost,
1790                            pad_position,
1791                        )],
1792                    },
1793                    &FrameEventsState {
1794                        boost_pad_events: vec![BoostPadEvent {
1795                            time,
1796                            frame: frame_number,
1797                            pad_id: pad_id.clone(),
1798                            player: Some(player_id.clone()),
1799                            kind: BoostPadEventKind::PickedUp { sequence },
1800                        }],
1801                        ..FrameEventsState::default()
1802                    },
1803                    &PlayerVerticalState::default(),
1804                    true,
1805                )
1806                .expect("boost update should succeed");
1807        }
1808
1809        let player_stats = calculator
1810            .player_stats()
1811            .get(&player_id)
1812            .expect("player stats should be recorded");
1813        assert_eq!(player_stats.big_pads_collected, 2);
1814        assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1815    }
1816
1817    #[test]
1818    fn skips_inactive_pickup_without_observed_boost_gain() {
1819        let mut calculator = BoostCalculator::new();
1820        let player_id = PlayerId::Steam(1);
1821        let (pad_position, _) = standard_soccar_boost_pad_layout()
1822            .iter()
1823            .find(|(_, size)| *size == BoostPadSize::Big)
1824            .copied()
1825            .expect("standard layout should include big pads");
1826
1827        calculator
1828            .update_parts(
1829                &FrameInfo {
1830                    frame_number: 1,
1831                    time: 1.0,
1832                    dt: 1.0 / 30.0,
1833                    seconds_remaining: None,
1834                },
1835                &GameplayState::default(),
1836                &PlayerFrameState {
1837                    players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1838                },
1839                &FrameEventsState {
1840                    boost_pad_events: vec![BoostPadEvent {
1841                        time: 1.0,
1842                        frame: 1,
1843                        pad_id: "inactive-no-gain-big-pad".to_string(),
1844                        player: Some(player_id.clone()),
1845                        kind: BoostPadEventKind::PickedUp { sequence: 7 },
1846                    }],
1847                    ..FrameEventsState::default()
1848                },
1849                &PlayerVerticalState::default(),
1850                false,
1851            )
1852            .expect("boost update should succeed");
1853
1854        let player_stats = calculator
1855            .player_stats()
1856            .get(&player_id)
1857            .expect("player stats should be recorded");
1858        assert_eq!(player_stats.big_pads_collected_inactive, 0);
1859        assert_eq!(player_stats.amount_collected_inactive, 0.0);
1860    }
1861
1862    #[test]
1863    fn infers_pad_counts_from_observed_boost_increases() {
1864        let mut calculator = BoostCalculator::new();
1865        let small_player = PlayerId::Steam(1);
1866        let big_player = PlayerId::Steam(2);
1867        let ambiguous_player = PlayerId::Steam(3);
1868        let respawn_player = PlayerId::Steam(4);
1869        let two_small_player = PlayerId::Steam(5);
1870        let position = glam::Vec3::ZERO;
1871        let active_gameplay = GameplayState {
1872            ball_has_been_hit: Some(true),
1873            ..GameplayState::default()
1874        };
1875
1876        calculator
1877            .update_parts(
1878                &FrameInfo {
1879                    frame_number: 1,
1880                    time: 1.0,
1881                    dt: 1.0 / 30.0,
1882                    seconds_remaining: None,
1883                },
1884                &active_gameplay,
1885                &PlayerFrameState {
1886                    players: vec![
1887                        test_player(small_player.clone(), 10.0, 10.0, position),
1888                        test_player(big_player.clone(), 10.0, 10.0, position),
1889                        test_player(ambiguous_player.clone(), 230.0, 230.0, position),
1890                        test_player(respawn_player.clone(), 0.0, 0.0, position),
1891                        test_player(
1892                            two_small_player.clone(),
1893                            BOOST_KICKOFF_START_AMOUNT,
1894                            BOOST_KICKOFF_START_AMOUNT,
1895                            position,
1896                        ),
1897                    ],
1898                },
1899                &FrameEventsState::default(),
1900                &PlayerVerticalState::default(),
1901                true,
1902            )
1903            .expect("first boost update should succeed");
1904
1905        calculator
1906            .update_parts(
1907                &FrameInfo {
1908                    frame_number: 2,
1909                    time: 1.1,
1910                    dt: 1.0 / 30.0,
1911                    seconds_remaining: None,
1912                },
1913                &active_gameplay,
1914                &PlayerFrameState {
1915                    players: vec![
1916                        test_player(
1917                            small_player.clone(),
1918                            10.0 + SMALL_PAD_AMOUNT_RAW,
1919                            10.0,
1920                            position,
1921                        ),
1922                        test_player(big_player.clone(), BOOST_MAX_AMOUNT, 10.0, position),
1923                        test_player(ambiguous_player.clone(), BOOST_MAX_AMOUNT, 230.0, position),
1924                        test_player(
1925                            respawn_player.clone(),
1926                            BOOST_KICKOFF_START_AMOUNT,
1927                            0.0,
1928                            position,
1929                        ),
1930                        test_player(
1931                            two_small_player.clone(),
1932                            BOOST_KICKOFF_START_AMOUNT + 2.0 * SMALL_PAD_AMOUNT_RAW,
1933                            BOOST_KICKOFF_START_AMOUNT,
1934                            position,
1935                        ),
1936                    ],
1937                },
1938                &FrameEventsState::default(),
1939                &PlayerVerticalState::default(),
1940                true,
1941            )
1942            .expect("second boost update should succeed");
1943
1944        calculator
1945            .finish_calculation()
1946            .expect("pending inferred pickups should flush");
1947        let events = calculator.pickup_comparison_events();
1948        assert_eq!(events.len(), 5);
1949        assert!(events
1950            .iter()
1951            .all(|event| event.comparison == BoostPickupComparison::Missed));
1952        assert_eq!(
1953            events
1954                .iter()
1955                .filter(|event| event.pad_type == BoostPickupPadType::Big)
1956                .count(),
1957            1
1958        );
1959        assert_eq!(
1960            events
1961                .iter()
1962                .filter(|event| event.pad_type == BoostPickupPadType::Small)
1963                .count(),
1964            3
1965        );
1966        assert_eq!(
1967            events
1968                .iter()
1969                .filter(|event| event.pad_type == BoostPickupPadType::Ambiguous)
1970                .count(),
1971            1
1972        );
1973        assert!(events.iter().all(|event| event.player_id != respawn_player));
1974    }
1975
1976    #[test]
1977    fn matches_two_small_pickups_from_one_observed_boost_increase() {
1978        let mut calculator = BoostCalculator::new();
1979        let player_id = PlayerId::Steam(1);
1980        let (pad_position, _) = standard_soccar_boost_pad_layout()
1981            .iter()
1982            .find(|(_, size)| *size == BoostPadSize::Small)
1983            .copied()
1984            .expect("standard layout should include small pads");
1985        let active_gameplay = GameplayState {
1986            ball_has_been_hit: Some(true),
1987            ..GameplayState::default()
1988        };
1989
1990        calculator
1991            .update_parts(
1992                &FrameInfo {
1993                    frame_number: 1,
1994                    time: 1.0,
1995                    dt: 1.0 / 30.0,
1996                    seconds_remaining: None,
1997                },
1998                &active_gameplay,
1999                &PlayerFrameState {
2000                    players: vec![test_player(
2001                        player_id.clone(),
2002                        BOOST_KICKOFF_START_AMOUNT,
2003                        BOOST_KICKOFF_START_AMOUNT,
2004                        pad_position,
2005                    )],
2006                },
2007                &FrameEventsState::default(),
2008                &PlayerVerticalState::default(),
2009                true,
2010            )
2011            .expect("first boost update should succeed");
2012
2013        calculator
2014            .update_parts(
2015                &FrameInfo {
2016                    frame_number: 2,
2017                    time: 1.1,
2018                    dt: 1.0 / 30.0,
2019                    seconds_remaining: None,
2020                },
2021                &active_gameplay,
2022                &PlayerFrameState {
2023                    players: vec![test_player(
2024                        player_id.clone(),
2025                        BOOST_KICKOFF_START_AMOUNT + 2.0 * SMALL_PAD_AMOUNT_RAW,
2026                        BOOST_KICKOFF_START_AMOUNT,
2027                        pad_position,
2028                    )],
2029                },
2030                &FrameEventsState {
2031                    boost_pad_events: vec![
2032                        BoostPadEvent {
2033                            time: 1.1,
2034                            frame: 2,
2035                            pad_id: "small-pad-one".to_string(),
2036                            player: Some(player_id.clone()),
2037                            kind: BoostPadEventKind::PickedUp { sequence: 1 },
2038                        },
2039                        BoostPadEvent {
2040                            time: 1.1,
2041                            frame: 2,
2042                            pad_id: "small-pad-two".to_string(),
2043                            player: Some(player_id.clone()),
2044                            kind: BoostPadEventKind::PickedUp { sequence: 1 },
2045                        },
2046                    ],
2047                    ..FrameEventsState::default()
2048                },
2049                &PlayerVerticalState::default(),
2050                true,
2051            )
2052            .expect("second boost update should succeed");
2053
2054        calculator
2055            .finish_calculation()
2056            .expect("pickup comparisons should flush");
2057
2058        let events = calculator.pickup_comparison_events();
2059        assert_eq!(events.len(), 2);
2060        assert!(events.iter().all(|event| {
2061            event.comparison == BoostPickupComparison::Both
2062                && event.pad_type == BoostPickupPadType::Small
2063        }));
2064    }
2065}