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