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)]
1522#[path = "boost_tests.rs"]
1523mod tests;