Skip to main content

subtr_actor/stats/calculators/
boost.rs

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