Skip to main content

subtr_actor/stats/calculators/
boost.rs

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