Skip to main content

subtr_actor/stats/calculators/
boost.rs

1use super::*;
2use crate::stats::accumulators::boost::{BoostStats, BoostStatsAccumulator};
3use crate::stats::timeline::types::{AccumulationPoint, AccumulationQuantity, AccumulationTrack};
4
5const DEMO_RESPAWN_WINDOW_SECONDS: f32 = 3.2;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8enum BoostIncreaseReason {
9    KickoffRespawn,
10    DemoRespawn,
11    Respawn,
12    BigPad,
13    SmallPad,
14    AmbiguousPad,
15    Unknown,
16}
17
18/// Whether a boost pad is a small or big (full) pad.
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
20#[serde(rename_all = "snake_case")]
21#[ts(export)]
22pub enum BoostPickupPadType {
23    Big,
24    Small,
25    Ambiguous,
26}
27
28impl BoostPickupPadType {
29    fn is_compatible_with(self, reported: Self) -> bool {
30        match self {
31            Self::Ambiguous => matches!(reported, Self::Big | Self::Small),
32            _ => self == reported,
33        }
34    }
35}
36
37impl From<BoostPadSize> for BoostPickupPadType {
38    fn from(pad_size: BoostPadSize) -> Self {
39        match pad_size {
40            BoostPadSize::Big => Self::Big,
41            BoostPadSize::Small => Self::Small,
42        }
43    }
44}
45
46impl TryFrom<BoostIncreaseReason> for BoostPickupPadType {
47    type Error = ();
48
49    fn try_from(reason: BoostIncreaseReason) -> Result<Self, Self::Error> {
50        match reason {
51            BoostIncreaseReason::BigPad => Ok(Self::Big),
52            BoostIncreaseReason::SmallPad => Ok(Self::Small),
53            BoostIncreaseReason::AmbiguousPad => Ok(Self::Ambiguous),
54            BoostIncreaseReason::KickoffRespawn
55            | BoostIncreaseReason::DemoRespawn
56            | BoostIncreaseReason::Respawn
57            | BoostIncreaseReason::Unknown => Err(()),
58        }
59    }
60}
61
62/// Field half where a boost pickup occurred.
63#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
64#[serde(rename_all = "snake_case")]
65#[ts(export)]
66pub enum BoostPickupFieldHalf {
67    Own,
68    Opponent,
69    Unknown,
70}
71
72/// Field-zone classification for a boost pickup location.
73#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
74#[serde(rename_all = "snake_case")]
75#[ts(export)]
76pub enum BoostPickupPadZone {
77    Offensive,
78    Neutral,
79    Defensive,
80}
81
82/// Player activity context at the time of a boost pickup.
83#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
84#[serde(rename_all = "snake_case")]
85#[ts(export)]
86pub enum BoostPickupActivity {
87    Active,
88    Inactive,
89    Unknown,
90}
91
92/// How a boost pickup was detected (pad pickup vs inferred).
93#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
94#[serde(rename_all = "snake_case")]
95#[ts(export)]
96pub enum BoostPickupDetection {
97    /// Corroborated by both a reported pad event and an inferred boost-amount jump.
98    Both,
99    /// Inferred from a boost-amount jump with no matching reported pad event.
100    InferredOnly,
101    /// Reported by a pad event with no corroborating boost-amount jump.
102    ReportedOnly,
103}
104
105#[derive(Clone, Debug)]
106struct PendingBoostPickupEvent {
107    frame: usize,
108    time: f32,
109    player_id: PlayerId,
110    player_position: Option<[f32; 3]>,
111    is_team_0: bool,
112    pad_type: BoostPickupPadType,
113    field_half: BoostPickupFieldHalf,
114    activity: BoostPickupActivity,
115    boost_before: Option<f32>,
116    boost_after: Option<f32>,
117}
118
119/// A single boost pickup. Replaces the former pickup-comparison + ledger Collected/Stolen/
120/// Overfill events: pad classification, theft, and amounts are all facets of one pickup.
121#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
122#[ts(export)]
123pub struct BoostPickupEvent {
124    pub frame: usize,
125    pub time: f32,
126    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
127    pub player_id: PlayerId,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub player_position: Option<[f32; 3]>,
130    pub is_team_0: bool,
131    pub pad_type: BoostPickupPadType,
132    pub field_half: BoostPickupFieldHalf,
133    pub activity: BoostPickupActivity,
134    pub detection: BoostPickupDetection,
135    /// Oriented pad location bucket. Big pads use offensive/neutral/defensive; small pads use
136    /// offensive/defensive half.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub pad_zone: Option<BoostPickupPadZone>,
139    /// A steal is a pickup collected on the opponent's half (mirrors the former `Stolen` ledger
140    /// transaction condition).
141    pub is_steal: bool,
142    /// Boost actually gained from this pickup.
143    pub collected_amount: f32,
144    /// Boost lost to the cap because the player was already near full when collecting.
145    pub overfill_amount: f32,
146    pub boost_before: Option<f32>,
147    pub boost_after: Option<f32>,
148}
149
150/// A player respawn (kickoff or post-demo). This is a general lifecycle event, not boost-specific;
151/// the boost grant is just one of its effects.
152#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
153#[ts(export)]
154pub struct RespawnEvent {
155    pub frame: usize,
156    pub time: f32,
157    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
158    pub player_id: PlayerId,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub player_position: Option<[f32; 3]>,
161    pub is_team_0: bool,
162    pub kind: RespawnKind,
163    /// Boost the player respawned with, when known.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub boost_granted: Option<f32>,
166}
167
168/// The kind of boost respawn.
169#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
170#[serde(rename_all = "snake_case")]
171#[ts(export)]
172pub enum RespawnKind {
173    Kickoff,
174    Demo,
175}
176
177pub(crate) fn boost_transaction_label(kind: &'static str) -> StatLabel {
178    StatLabel::new("transaction", kind)
179}
180
181pub(crate) fn boost_pad_size_label(pad_size: Option<BoostPadSize>) -> StatLabel {
182    match pad_size {
183        Some(BoostPadSize::Big) => StatLabel::new("pad_size", "big"),
184        Some(BoostPadSize::Small) => StatLabel::new("pad_size", "small"),
185        None => StatLabel::new("pad_size", "unknown"),
186    }
187}
188
189pub(crate) fn boost_activity_label(activity: BoostPickupActivity) -> StatLabel {
190    match activity {
191        BoostPickupActivity::Active => StatLabel::new("activity", "active"),
192        BoostPickupActivity::Inactive => StatLabel::new("activity", "inactive"),
193        BoostPickupActivity::Unknown => StatLabel::new("activity", "unknown"),
194    }
195}
196
197pub(crate) fn boost_field_half_label(field_half: BoostPickupFieldHalf) -> StatLabel {
198    match field_half {
199        BoostPickupFieldHalf::Own => StatLabel::new("field_half", "own"),
200        BoostPickupFieldHalf::Opponent => StatLabel::new("field_half", "opponent"),
201        BoostPickupFieldHalf::Unknown => StatLabel::new("field_half", "unknown"),
202    }
203}
204
205pub(crate) fn boost_supersonic_label(supersonic: bool) -> StatLabel {
206    if supersonic {
207        StatLabel::new("supersonic", "true")
208    } else {
209        StatLabel::new("supersonic", "false")
210    }
211}
212
213/// Configuration thresholds for boost pickup/respawn detection.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub struct BoostCalculatorConfig {
216    pub include_non_live_pickups: bool,
217}
218
219/// Tracks boost-pad pickups and boost respawns per player.
220#[derive(Debug, Clone, Default)]
221pub struct BoostCalculator {
222    config: BoostCalculatorConfig,
223    previous_boost_amounts: HashMap<PlayerId, f32>,
224    previous_player_speeds: HashMap<PlayerId, f32>,
225    observed_pad_positions: HashMap<String, PadPositionEstimate>,
226    known_pad_sizes: HashMap<String, BoostPadSize>,
227    known_pad_indices: HashMap<String, usize>,
228    unavailable_pads: HashSet<String>,
229    seen_pickup_sequence_times: HashMap<(String, u8), f32>,
230    pickup_frames: HashMap<(String, PlayerId), usize>,
231    inactive_pickup_frames: HashSet<(PlayerId, usize, BoostPadSize)>,
232    last_pickup_times: HashMap<String, f32>,
233    pending_inferred_pickups: VecDeque<PendingBoostPickupEvent>,
234    pickup_events: EventStream<BoostPickupEvent>,
235    respawn_events: EventStream<RespawnEvent>,
236    stats: BoostStatsAccumulator,
237    track_builders: HashMap<(PlayerId, AccumulationQuantity), BoostTrackBuilder>,
238    player_usage_state: HashMap<PlayerId, BoostUsageState>,
239    kickoff_phase_active_last_frame: bool,
240    kickoff_respawn_awarded: HashSet<PlayerId>,
241    initial_respawn_awarded: HashSet<PlayerId>,
242    pending_demo_respawns: HashMap<PlayerId, PendingDemoRespawn>,
243    pending_reported_pickups: VecDeque<DeferredReportedBoostPickup>,
244    previous_boost_levels_live: Option<bool>,
245}
246
247#[derive(Debug, Clone, Copy)]
248struct PendingDemoRespawn {
249    demo_time: f32,
250    pre_demo_boost_amount: Option<f32>,
251}
252
253/// Builder for one player's [`AccumulationTrack`]: accumulates change-points, skipping samples
254/// whose value matches the previous point.
255#[derive(Debug, Clone, Default)]
256struct BoostTrackBuilder {
257    is_team_0: bool,
258    points: Vec<AccumulationPoint>,
259}
260
261#[derive(Debug, Clone, Copy, Default)]
262struct BoostUsageState {
263    amount_obtained: f32,
264    amount_removed: f32,
265    amount_used: f32,
266    amount_used_while_grounded: f32,
267    amount_used_while_airborne: f32,
268}
269
270impl BoostUsageState {
271    fn amount_used_by_vertical_band(self) -> f32 {
272        self.amount_used_while_grounded + self.amount_used_while_airborne
273    }
274
275    fn apply_obtained_amount(&mut self, amount: f32) {
276        self.amount_obtained += amount;
277    }
278
279    fn apply_used_amount(&mut self, amount: f32) {
280        self.amount_used += amount;
281    }
282
283    fn apply_used_allocation(&mut self, amount: f32, grounded: bool) {
284        if grounded {
285            self.amount_used_while_grounded += amount;
286        } else {
287            self.amount_used_while_airborne += amount;
288        }
289    }
290
291    fn apply_removed_amount(&mut self, amount: f32) {
292        self.amount_removed += amount;
293    }
294
295    fn inferred_amount_used(self, current_boost_amount: f32) -> f32 {
296        // Boost balance:
297        //   current = obtained - used - removed
298        //
299        // `obtained` contains pickups plus respawn grants. `removed` covers
300        // non-usage losses such as demoing a player with boost. Rearranging the
301        // balance lets us infer cumulative use from the observed current boost:
302        //   used = obtained - removed - current
303        (self.amount_obtained - self.amount_removed - current_boost_amount).max(0.0)
304    }
305}
306
307#[derive(Debug, Clone)]
308struct PendingBoostPickup {
309    frame: usize,
310    time: f32,
311    player_id: PlayerId,
312    is_team_0: bool,
313    previous_boost_amount: f32,
314    pre_applied_collected_amount: f32,
315    player_position: glam::Vec3,
316    boost_before: Option<f32>,
317    boost_after: Option<f32>,
318}
319
320#[derive(Debug, Clone)]
321struct DeferredReportedBoostPickup {
322    pad_id: String,
323    pending_pickup: PendingBoostPickup,
324    pad_size: BoostPadSize,
325    reported_event: PendingBoostPickupEvent,
326}
327
328impl BoostCalculator {
329    const PICKUP_MATCH_FRAME_WINDOW: usize = 3;
330
331    pub fn new() -> Self {
332        Self::with_config(BoostCalculatorConfig::default())
333    }
334
335    pub fn with_config(config: BoostCalculatorConfig) -> Self {
336        Self {
337            config,
338            ..Self::default()
339        }
340    }
341
342    pub fn pickup_events(&self) -> &[BoostPickupEvent] {
343        self.pickup_events.all()
344    }
345
346    pub fn new_pickup_events(&self) -> &[BoostPickupEvent] {
347        self.pickup_events.new_events()
348    }
349
350    pub fn respawn_events(&self) -> &[RespawnEvent] {
351        self.respawn_events.all()
352    }
353
354    pub fn new_respawn_events(&self) -> &[RespawnEvent] {
355        self.respawn_events.new_events()
356    }
357
358    pub fn player_boost_stats(&self) -> &HashMap<PlayerId, BoostStats> {
359        self.stats.player_stats()
360    }
361
362    pub fn player_boost_stats_for(&self, player_id: &PlayerId) -> BoostStats {
363        self.stats.player_stats_for(player_id)
364    }
365
366    pub fn team_zero_boost_stats(&self) -> &BoostStats {
367        self.stats.team_zero_stats()
368    }
369
370    pub fn team_one_boost_stats(&self) -> &BoostStats {
371        self.stats.team_one_stats()
372    }
373
374    /// The boost-stats accumulator, populated directly as frames are processed (no event replay).
375    pub fn boost_stats(&self) -> &BoostStatsAccumulator {
376        &self.stats
377    }
378
379    /// Build the compressed per-player accumulation tracks (boost amount + cumulative used /
380    /// collected / stolen / overfill). Sorted by player id then quantity for deterministic output.
381    pub fn accumulation_tracks(&self) -> Vec<AccumulationTrack> {
382        let mut tracks: Vec<AccumulationTrack> = self
383            .track_builders
384            .iter()
385            .filter(|(_, builder)| !builder.points.is_empty())
386            .map(|((player_id, quantity), builder)| AccumulationTrack {
387                player_id: player_id.clone(),
388                is_team_0: builder.is_team_0,
389                quantity: *quantity,
390                points: builder.points.clone(),
391            })
392            .collect();
393        tracks.sort_by(|left, right| {
394            format!("{:?}", left.player_id)
395                .cmp(&format!("{:?}", right.player_id))
396                .then_with(|| format!("{:?}", left.quantity).cmp(&format!("{:?}", right.quantity)))
397        });
398        tracks
399    }
400
401    fn player_usage_state(&self, player_id: &PlayerId) -> BoostUsageState {
402        self.player_usage_state
403            .get(player_id)
404            .copied()
405            .unwrap_or_default()
406    }
407
408    fn record_track_point(
409        &mut self,
410        player_id: &PlayerId,
411        is_team_0: bool,
412        frame: usize,
413        quantity: AccumulationQuantity,
414        value: f32,
415    ) {
416        let builder = self
417            .track_builders
418            .entry((player_id.clone(), quantity))
419            .or_default();
420        builder.is_team_0 = is_team_0;
421        if builder
422            .points
423            .last()
424            .is_some_and(|point| (point.value - value).abs() <= f32::EPSILON)
425        {
426            return;
427        }
428        builder.points.push(AccumulationPoint { frame, value });
429    }
430
431    /// Sample this player's instantaneous boost amount and their cumulative boost stats into the
432    /// per-frame accumulation tracks.
433    fn record_boost_tracks(
434        &mut self,
435        player_id: &PlayerId,
436        is_team_0: bool,
437        frame: usize,
438        boost_amount: f32,
439    ) {
440        let stats = self.stats.player_stats_for(player_id);
441        self.record_track_point(
442            player_id,
443            is_team_0,
444            frame,
445            AccumulationQuantity::BoostAmount,
446            boost_amount,
447        );
448        self.record_track_point(
449            player_id,
450            is_team_0,
451            frame,
452            AccumulationQuantity::BoostUsed,
453            stats.amount_used,
454        );
455        self.record_track_point(
456            player_id,
457            is_team_0,
458            frame,
459            AccumulationQuantity::BoostUsedGrounded,
460            stats.amount_used_while_grounded,
461        );
462        self.record_track_point(
463            player_id,
464            is_team_0,
465            frame,
466            AccumulationQuantity::BoostUsedAirborne,
467            stats.amount_used_while_airborne,
468        );
469        self.record_track_point(
470            player_id,
471            is_team_0,
472            frame,
473            AccumulationQuantity::BoostUsedSupersonic,
474            stats.amount_used_while_supersonic,
475        );
476        self.record_track_point(
477            player_id,
478            is_team_0,
479            frame,
480            AccumulationQuantity::BoostCollected,
481            stats.amount_collected,
482        );
483        self.record_track_point(
484            player_id,
485            is_team_0,
486            frame,
487            AccumulationQuantity::BoostStolen,
488            stats.amount_stolen,
489        );
490        self.record_track_point(
491            player_id,
492            is_team_0,
493            frame,
494            AccumulationQuantity::BoostOverfill,
495            stats.overfill_total,
496        );
497    }
498
499    /// Push a reported pickup, resolving its detection against any pending inferred pickup that
500    /// corroborates it (and consuming that inferred pickup so it does not later surface as an
501    /// inferred-only pickup).
502    fn push_reported_pickup(
503        &mut self,
504        reported: PendingBoostPickupEvent,
505        mut pickup: BoostPickupEvent,
506    ) {
507        let matched = if let Some(index) =
508            Self::matching_pending_pickup_index(&self.pending_inferred_pickups, &reported, true)
509        {
510            self.pending_inferred_pickups.remove(index);
511            true
512        } else if let Some(index) = self.pending_inferred_pickups.iter().position(|pending| {
513            pending.player_id == reported.player_id
514                && pending.frame.abs_diff(reported.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
515        }) {
516            self.pending_inferred_pickups.remove(index);
517            true
518        } else {
519            false
520        };
521        pickup.detection = if matched {
522            BoostPickupDetection::Both
523        } else {
524            BoostPickupDetection::ReportedOnly
525        };
526        self.pickup_events.push(pickup);
527    }
528
529    fn estimated_pad_position(&self, pad_id: &str) -> Option<glam::Vec3> {
530        self.observed_pad_positions
531            .get(pad_id)
532            .and_then(PadPositionEstimate::mean)
533    }
534
535    fn observed_pad_positions(&self, pad_id: &str) -> &[glam::Vec3] {
536        self.observed_pad_positions
537            .get(pad_id)
538            .map(PadPositionEstimate::observations)
539            .unwrap_or(&[])
540    }
541
542    fn pad_match_radius(pad_size: BoostPadSize) -> f32 {
543        match pad_size {
544            BoostPadSize::Big => STANDARD_PAD_MATCH_RADIUS_BIG,
545            BoostPadSize::Small => STANDARD_PAD_MATCH_RADIUS_SMALL,
546        }
547    }
548
549    pub fn resolved_boost_pads(&self) -> Vec<ResolvedBoostPad> {
550        standard_soccar_boost_pad_layout()
551            .iter()
552            .enumerate()
553            .map(|(index, (position, size))| ResolvedBoostPad {
554                index,
555                pad_id: self
556                    .known_pad_indices
557                    .iter()
558                    .find_map(|(pad_id, pad_index)| (*pad_index == index).then(|| pad_id.clone())),
559                size: *size,
560                position: glam_to_vec(position),
561            })
562            .collect()
563    }
564
565    fn infer_pad_index(
566        &self,
567        pad_id: &str,
568        pad_size: BoostPadSize,
569        observed_position: glam::Vec3,
570    ) -> Option<usize> {
571        if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
572            return Some(index);
573        }
574
575        let observed_position = self
576            .estimated_pad_position(pad_id)
577            .unwrap_or(observed_position);
578        let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
579        let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
580        let radius = Self::pad_match_radius(pad_size);
581        let observed_positions = self.observed_pad_positions(pad_id);
582        let best_candidate = |allow_used: bool| {
583            layout
584                .iter()
585                .enumerate()
586                .filter(|(index, (_, size))| {
587                    *size == pad_size && (allow_used || !used_indices.contains(index))
588                })
589                .filter_map(|(index, (candidate_position, _))| {
590                    let mut vote_count = 0usize;
591                    let mut total_vote_distance = 0.0f32;
592                    let mut best_vote_distance = f32::INFINITY;
593
594                    for position in observed_positions {
595                        let distance = position.distance(*candidate_position);
596                        if distance <= radius {
597                            vote_count += 1;
598                            total_vote_distance += distance;
599                            best_vote_distance = best_vote_distance.min(distance);
600                        }
601                    }
602
603                    if vote_count == 0 {
604                        return None;
605                    }
606
607                    let representative_distance = observed_position.distance(*candidate_position);
608                    Some((
609                        index,
610                        vote_count,
611                        total_vote_distance / vote_count as f32,
612                        best_vote_distance,
613                        representative_distance,
614                    ))
615                })
616                .max_by(|left, right| {
617                    left.1
618                        .cmp(&right.1)
619                        .then_with(|| right.2.partial_cmp(&left.2).unwrap())
620                        .then_with(|| right.3.partial_cmp(&left.3).unwrap())
621                        .then_with(|| right.4.partial_cmp(&left.4).unwrap())
622                })
623                .map(|(index, _, _, _, _)| index)
624        };
625
626        best_candidate(false)
627            .or_else(|| best_candidate(true))
628            .or_else(|| {
629                layout
630                    .iter()
631                    .enumerate()
632                    .filter(|(index, (_, size))| *size == pad_size && !used_indices.contains(index))
633                    .min_by(|(_, (a, _)), (_, (b, _))| {
634                        observed_position
635                            .distance_squared(*a)
636                            .partial_cmp(&observed_position.distance_squared(*b))
637                            .unwrap()
638                    })
639                    .map(|(index, _)| index)
640            })
641            .or_else(|| {
642                layout
643                    .iter()
644                    .enumerate()
645                    .filter(|(_, (_, size))| *size == pad_size)
646                    .min_by(|(_, (a, _)), (_, (b, _))| {
647                        observed_position
648                            .distance_squared(*a)
649                            .partial_cmp(&observed_position.distance_squared(*b))
650                            .unwrap()
651                    })
652                    .map(|(index, _)| index)
653            })
654            .filter(|index| {
655                observed_position.distance(standard_soccar_boost_pad_position(*index)) <= radius
656            })
657    }
658
659    fn infer_pad_details_from_position(
660        &self,
661        pad_id: &str,
662        observed_position: glam::Vec3,
663    ) -> Option<(usize, BoostPadSize)> {
664        if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
665            let (_, size) = standard_soccar_boost_pad_layout().get(index)?;
666            return Some((index, *size));
667        }
668
669        let observed_position = self
670            .estimated_pad_position(pad_id)
671            .unwrap_or(observed_position);
672        let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
673        let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
674        let observed_positions = self.observed_pad_positions(pad_id);
675        let best_candidate = |allow_used: bool| {
676            layout
677                .iter()
678                .enumerate()
679                .filter(|(index, _)| allow_used || !used_indices.contains(index))
680                .filter_map(|(index, (candidate_position, size))| {
681                    let radius = Self::pad_match_radius(*size);
682                    let mut vote_count = 0usize;
683                    let mut total_vote_distance = 0.0f32;
684                    let mut best_vote_distance = f32::INFINITY;
685
686                    for position in observed_positions {
687                        let distance = position.distance(*candidate_position);
688                        if distance <= radius {
689                            vote_count += 1;
690                            total_vote_distance += distance;
691                            best_vote_distance = best_vote_distance.min(distance);
692                        }
693                    }
694
695                    if vote_count == 0 {
696                        return None;
697                    }
698
699                    let representative_distance = observed_position.distance(*candidate_position);
700                    Some((
701                        index,
702                        *size,
703                        vote_count,
704                        total_vote_distance / vote_count as f32,
705                        best_vote_distance,
706                        representative_distance,
707                    ))
708                })
709                .max_by(|left, right| {
710                    left.2
711                        .cmp(&right.2)
712                        .then_with(|| right.3.partial_cmp(&left.3).unwrap())
713                        .then_with(|| right.4.partial_cmp(&left.4).unwrap())
714                        .then_with(|| right.5.partial_cmp(&left.5).unwrap())
715                })
716                .map(|(index, size, _, _, _, _)| (index, size))
717        };
718
719        best_candidate(false).or_else(|| best_candidate(true))
720    }
721
722    fn guess_pad_size_from_position(
723        &self,
724        pad_id: &str,
725        observed_position: glam::Vec3,
726    ) -> Option<BoostPadSize> {
727        if let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied() {
728            return Some(pad_size);
729        }
730
731        if let Some((_, pad_size)) = self.infer_pad_details_from_position(pad_id, observed_position)
732        {
733            return Some(pad_size);
734        }
735
736        let observed_position = self
737            .estimated_pad_position(pad_id)
738            .unwrap_or(observed_position);
739        standard_soccar_boost_pad_layout()
740            .iter()
741            .min_by(|(left_position, _), (right_position, _)| {
742                observed_position
743                    .distance_squared(*left_position)
744                    .partial_cmp(&observed_position.distance_squared(*right_position))
745                    .unwrap()
746            })
747            .map(|(_, pad_size)| *pad_size)
748    }
749
750    fn resolve_pickup(
751        &mut self,
752        pad_id: &str,
753        pending_pickup: PendingBoostPickup,
754        pad_size: BoostPadSize,
755    ) -> BoostPickupEvent {
756        let observed_position = self
757            .estimated_pad_position(pad_id)
758            .unwrap_or(pending_pickup.player_position);
759        let pad_position = self
760            .infer_pad_index(pad_id, pad_size, observed_position)
761            .map(|index| {
762                self.known_pad_indices.insert(pad_id.to_string(), index);
763                standard_soccar_boost_pad_position(index)
764            })
765            .unwrap_or(observed_position);
766        let stolen = is_enemy_side(pending_pickup.is_team_0, pad_position);
767        let nominal_gain = match pad_size {
768            BoostPadSize::Big => BOOST_MAX_AMOUNT,
769            BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
770        };
771        let collected_amount = (BOOST_MAX_AMOUNT - pending_pickup.previous_boost_amount)
772            .min(nominal_gain)
773            .max(0.0);
774        let collected_amount_delta = collected_amount - pending_pickup.pre_applied_collected_amount;
775        let overfill = (nominal_gain - collected_amount).max(0.0);
776        let field_half = if stolen {
777            BoostPickupFieldHalf::Opponent
778        } else {
779            BoostPickupFieldHalf::Own
780        };
781        let pad_zone =
782            Self::pad_zone_from_position(pending_pickup.is_team_0, pad_size, Some(pad_position));
783
784        // The obtained balance is applied incrementally (pre-applied + this delta) so usage
785        // inference stays correctly timed; the pickup stats below carry the full collected amount
786        // once, which sums identically to the former split Collected ledger entries.
787        self.player_usage_state
788            .entry(pending_pickup.player_id.clone())
789            .or_default()
790            .apply_obtained_amount(collected_amount_delta);
791
792        self.stats.apply_pickup(
793            &pending_pickup.player_id,
794            pending_pickup.is_team_0,
795            Some(pad_size),
796            BoostPickupActivity::Active,
797            field_half,
798            stolen,
799            collected_amount,
800            overfill,
801        );
802
803        BoostPickupEvent {
804            frame: pending_pickup.frame,
805            time: pending_pickup.time,
806            player_id: pending_pickup.player_id.clone(),
807            player_position: Some(pending_pickup.player_position.to_array()),
808            is_team_0: pending_pickup.is_team_0,
809            pad_type: pad_size.into(),
810            field_half,
811            activity: BoostPickupActivity::Active,
812            // Overwritten by `push_reported_pickup` for reported pickups; inferred-only callers
813            // set it explicitly.
814            detection: BoostPickupDetection::Both,
815            pad_zone,
816            is_steal: stolen,
817            collected_amount,
818            overfill_amount: overfill,
819            boost_before: pending_pickup.boost_before,
820            boost_after: pending_pickup.boost_after,
821        }
822    }
823
824    /// Pre-apply a partial collected amount to the usage-inference balance only (no stats / event).
825    /// The full pickup is recorded later when [`Self::resolve_pickup`] runs.
826    fn apply_pickup_collected_amount(
827        &mut self,
828        player_id: &PlayerId,
829        amount: f32,
830        pad_size: Option<BoostPadSize>,
831    ) {
832        if amount <= 0.0 {
833            return;
834        }
835        if pad_size.is_some() {
836            self.player_usage_state
837                .entry(player_id.clone())
838                .or_default()
839                .apply_obtained_amount(amount);
840        }
841    }
842
843    #[allow(clippy::too_many_arguments)]
844    fn apply_inactive_pickup(
845        &mut self,
846        player_id: &PlayerId,
847        player_position: Option<[f32; 3]>,
848        is_team_0: bool,
849        frame: usize,
850        time: f32,
851        amount: f32,
852        pad_size: BoostPadSize,
853        boost_before: Option<f32>,
854        boost_after: Option<f32>,
855    ) -> BoostPickupEvent {
856        self.stats.apply_pickup(
857            player_id,
858            is_team_0,
859            Some(pad_size),
860            BoostPickupActivity::Inactive,
861            BoostPickupFieldHalf::Unknown,
862            false,
863            amount,
864            0.0,
865        );
866        BoostPickupEvent {
867            frame,
868            time,
869            player_id: player_id.clone(),
870            player_position,
871            is_team_0,
872            pad_type: pad_size.into(),
873            field_half: BoostPickupFieldHalf::Unknown,
874            activity: BoostPickupActivity::Inactive,
875            detection: BoostPickupDetection::ReportedOnly,
876            pad_zone: Self::pad_zone_from_position(
877                is_team_0,
878                pad_size,
879                player_position.map(glam::Vec3::from_array),
880            ),
881            is_steal: false,
882            collected_amount: amount,
883            overfill_amount: 0.0,
884            boost_before,
885            boost_after,
886        }
887    }
888
889    #[allow(clippy::too_many_arguments)]
890    fn apply_respawn_amount(
891        &mut self,
892        player_id: &PlayerId,
893        player_position: Option<[f32; 3]>,
894        is_team_0: bool,
895        frame: usize,
896        time: f32,
897        amount: f32,
898        kind: RespawnKind,
899    ) {
900        if amount <= 0.0 {
901            return;
902        }
903        self.player_usage_state
904            .entry(player_id.clone())
905            .or_default()
906            .apply_obtained_amount(amount);
907        self.stats.apply_respawn(player_id, is_team_0, amount);
908        self.respawn_events.push(RespawnEvent {
909            frame,
910            time,
911            player_id: player_id.clone(),
912            player_position,
913            is_team_0,
914            kind,
915            boost_granted: Some(amount),
916        });
917    }
918
919    fn pad_respawn_time_seconds(pad_size: BoostPadSize) -> f32 {
920        match pad_size {
921            BoostPadSize::Big => 10.0,
922            BoostPadSize::Small => 4.0,
923        }
924    }
925
926    fn seen_pickup_sequence_is_recent(
927        &self,
928        pad_id: &str,
929        sequence: u8,
930        event_time: f32,
931        player_position: Option<glam::Vec3>,
932    ) -> bool {
933        let Some(last_time) = self
934            .seen_pickup_sequence_times
935            .get(&(pad_id.to_string(), sequence))
936            .copied()
937        else {
938            return false;
939        };
940        let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
941            player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
942        }) else {
943            return false;
944        };
945        event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
946    }
947
948    fn unavailable_pad_is_recent(
949        &self,
950        pad_id: &str,
951        event_time: f32,
952        player_position: Option<glam::Vec3>,
953    ) -> bool {
954        if !self.unavailable_pads.contains(pad_id) {
955            return false;
956        }
957        let Some(last_time) = self.last_pickup_times.get(pad_id).copied() else {
958            return true;
959        };
960        let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
961            player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
962        }) else {
963            return true;
964        };
965        event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
966    }
967
968    fn boost_levels_live(live_play: bool) -> bool {
969        live_play
970    }
971
972    fn tracks_boost_levels(boost_levels_live: bool) -> bool {
973        boost_levels_live
974    }
975
976    fn tracks_boost_pickups(gameplay: &GameplayState, live_play: bool) -> bool {
977        live_play
978            || (gameplay.ball_has_been_hit == Some(false) && !gameplay.kickoff_countdown_active())
979    }
980
981    fn activity_label(active: bool) -> BoostPickupActivity {
982        if active {
983            BoostPickupActivity::Active
984        } else {
985            BoostPickupActivity::Inactive
986        }
987    }
988
989    fn field_half_from_position(
990        is_team_0: bool,
991        position: Option<glam::Vec3>,
992    ) -> BoostPickupFieldHalf {
993        match position {
994            Some(position) if is_enemy_side(is_team_0, position) => BoostPickupFieldHalf::Opponent,
995            Some(_) => BoostPickupFieldHalf::Own,
996            None => BoostPickupFieldHalf::Unknown,
997        }
998    }
999
1000    fn pad_zone_from_position(
1001        is_team_0: bool,
1002        pad_size: BoostPadSize,
1003        position: Option<glam::Vec3>,
1004    ) -> Option<BoostPickupPadZone> {
1005        let normalized_y = normalized_y(is_team_0, position?);
1006        if pad_size == BoostPadSize::Big && normalized_y.abs() <= BOOST_PAD_MIDFIELD_TOLERANCE_Y {
1007            Some(BoostPickupPadZone::Neutral)
1008        } else if normalized_y > 0.0 {
1009            Some(BoostPickupPadZone::Offensive)
1010        } else {
1011            Some(BoostPickupPadZone::Defensive)
1012        }
1013    }
1014
1015    fn classify_boost_increase_reasons(
1016        previous_boost: f32,
1017        boost: f32,
1018        kickoff_phase_active: bool,
1019        demo_respawn_supported: bool,
1020    ) -> Vec<BoostIncreaseReason> {
1021        const TOLERANCE: f32 = 1.0;
1022        let delta = boost - previous_boost;
1023        if delta <= TOLERANCE {
1024            return vec![BoostIncreaseReason::Unknown];
1025        }
1026
1027        let is_respawn_value = (boost - BOOST_KICKOFF_START_AMOUNT).abs() <= TOLERANCE;
1028        if demo_respawn_supported && is_respawn_value {
1029            return vec![BoostIncreaseReason::DemoRespawn];
1030        }
1031        if kickoff_phase_active && is_respawn_value {
1032            return vec![BoostIncreaseReason::KickoffRespawn];
1033        }
1034        if is_respawn_value {
1035            return vec![BoostIncreaseReason::Respawn];
1036        }
1037
1038        let small_pad_floor = SMALL_PAD_AMOUNT_RAW - 3.0;
1039        let big_pad_floor = SMALL_PAD_AMOUNT_RAW + TOLERANCE;
1040        if delta > big_pad_floor {
1041            return vec![BoostIncreaseReason::BigPad];
1042        }
1043        if boost < BOOST_FULL_BAND_MIN_RAW && delta >= small_pad_floor {
1044            const SMALL_PICKUP_COUNT_TOLERANCE: f32 = 3.0;
1045            let inferred_small_pickups = ((delta - SMALL_PICKUP_COUNT_TOLERANCE)
1046                / SMALL_PAD_AMOUNT_RAW)
1047                .ceil()
1048                .max(1.0) as usize;
1049            return vec![BoostIncreaseReason::SmallPad; inferred_small_pickups];
1050        }
1051
1052        if boost >= BOOST_MAX_AMOUNT - TOLERANCE {
1053            return vec![BoostIncreaseReason::AmbiguousPad];
1054        }
1055        if delta >= small_pad_floor {
1056            return vec![BoostIncreaseReason::SmallPad];
1057        }
1058        vec![BoostIncreaseReason::Unknown]
1059    }
1060
1061    fn matching_pending_pickup_index(
1062        pending: &VecDeque<PendingBoostPickupEvent>,
1063        event: &PendingBoostPickupEvent,
1064        pending_is_inferred: bool,
1065    ) -> Option<usize> {
1066        pending
1067            .iter()
1068            .enumerate()
1069            .filter(|(_, pending_event)| {
1070                pending_event.player_id == event.player_id
1071                    && if pending_is_inferred {
1072                        pending_event.pad_type.is_compatible_with(event.pad_type)
1073                    } else {
1074                        event.pad_type.is_compatible_with(pending_event.pad_type)
1075                    }
1076                    && pending_event.frame.abs_diff(event.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
1077            })
1078            .min_by_key(|(_, pending_event)| pending_event.frame.abs_diff(event.frame))
1079            .map(|(index, _)| index)
1080    }
1081
1082    fn record_inferred_pickup(&mut self, event: PendingBoostPickupEvent) {
1083        self.pending_inferred_pickups.push_back(event);
1084    }
1085
1086    fn resolve_deferred_reported_pickups(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
1087        const TOLERANCE: f32 = 1.0;
1088
1089        let mut remaining_pickups = VecDeque::new();
1090        for mut deferred in std::mem::take(&mut self.pending_reported_pickups) {
1091            let player = players
1092                .players
1093                .iter()
1094                .find(|player| player.player_id == deferred.pending_pickup.player_id);
1095            let observed_boost_amount = player.and_then(|player| player.boost_amount);
1096            let previous_sample_boost_amount = self
1097                .previous_boost_amounts
1098                .get(&deferred.pending_pickup.player_id)
1099                .copied()
1100                .unwrap_or(deferred.pending_pickup.previous_boost_amount);
1101            let gain_is_visible = observed_boost_amount.is_some_and(|boost_amount| {
1102                boost_amount > previous_sample_boost_amount + TOLERANCE
1103            });
1104            let pickup_expired = deferred.pending_pickup.frame + Self::PICKUP_MATCH_FRAME_WINDOW
1105                < frame.frame_number;
1106
1107            if !gain_is_visible && !pickup_expired {
1108                remaining_pickups.push_back(deferred);
1109                continue;
1110            }
1111
1112            if gain_is_visible || pickup_expired {
1113                deferred.pending_pickup.frame = frame.frame_number;
1114                deferred.pending_pickup.time = frame.time;
1115                deferred.pending_pickup.boost_after = observed_boost_amount;
1116                if let Some(position) = player.and_then(|player| player.position()) {
1117                    deferred.pending_pickup.player_position = position;
1118                }
1119            }
1120
1121            let pickup =
1122                self.resolve_pickup(&deferred.pad_id, deferred.pending_pickup, deferred.pad_size);
1123            self.push_reported_pickup(deferred.reported_event, pickup);
1124        }
1125        self.pending_reported_pickups = remaining_pickups;
1126    }
1127
1128    fn flush_deferred_reported_pickups(&mut self) {
1129        while let Some(deferred) = self.pending_reported_pickups.pop_front() {
1130            let pickup =
1131                self.resolve_pickup(&deferred.pad_id, deferred.pending_pickup, deferred.pad_size);
1132            self.push_reported_pickup(deferred.reported_event, pickup);
1133        }
1134    }
1135
1136    fn inferred_pickup_pad_size(&self, event: &PendingBoostPickupEvent) -> BoostPadSize {
1137        match event.pad_type {
1138            BoostPickupPadType::Big => BoostPadSize::Big,
1139            BoostPickupPadType::Small => BoostPadSize::Small,
1140            BoostPickupPadType::Ambiguous => event
1141                .player_position
1142                .and_then(|position| {
1143                    self.guess_pad_size_from_position(
1144                        &format!("inferred:{:?}:{}", event.player_id, event.frame),
1145                        glam::Vec3::from_array(position),
1146                    )
1147                })
1148                .unwrap_or(BoostPadSize::Big),
1149        }
1150    }
1151
1152    fn resolve_inferred_pickup(
1153        &mut self,
1154        event: PendingBoostPickupEvent,
1155        application_frame: &FrameInfo,
1156    ) {
1157        let Some(boost_before) = event.boost_before else {
1158            // No boost-before sample to compute amounts from; emit a detection-only inferred
1159            // pickup with no stats contribution (matches the former no-resolve Ghost path).
1160            self.pickup_events.push(BoostPickupEvent {
1161                frame: event.frame,
1162                time: event.time,
1163                player_id: event.player_id.clone(),
1164                player_position: event.player_position,
1165                is_team_0: event.is_team_0,
1166                pad_type: event.pad_type,
1167                field_half: event.field_half,
1168                activity: event.activity,
1169                detection: BoostPickupDetection::InferredOnly,
1170                pad_zone: match event.pad_type {
1171                    BoostPickupPadType::Big => Self::pad_zone_from_position(
1172                        event.is_team_0,
1173                        BoostPadSize::Big,
1174                        event.player_position.map(glam::Vec3::from_array),
1175                    ),
1176                    BoostPickupPadType::Small => Self::pad_zone_from_position(
1177                        event.is_team_0,
1178                        BoostPadSize::Small,
1179                        event.player_position.map(glam::Vec3::from_array),
1180                    ),
1181                    BoostPickupPadType::Ambiguous => None,
1182                },
1183                is_steal: matches!(event.field_half, BoostPickupFieldHalf::Opponent),
1184                collected_amount: 0.0,
1185                overfill_amount: 0.0,
1186                boost_before: event.boost_before,
1187                boost_after: event.boost_after,
1188            });
1189            return;
1190        };
1191
1192        let pad_size = self.inferred_pickup_pad_size(&event);
1193        let player_position = event
1194            .player_position
1195            .map(glam::Vec3::from_array)
1196            .unwrap_or(glam::Vec3::ZERO);
1197        let pad_id = format!("inferred:{:?}:{}", event.player_id, event.frame);
1198        let mut pickup = self.resolve_pickup(
1199            &pad_id,
1200            PendingBoostPickup {
1201                frame: application_frame.frame_number,
1202                time: application_frame.time,
1203                player_id: event.player_id.clone(),
1204                is_team_0: event.is_team_0,
1205                previous_boost_amount: boost_before,
1206                pre_applied_collected_amount: 0.0,
1207                player_position,
1208                boost_before: event.boost_before,
1209                boost_after: event.boost_after,
1210            },
1211            pad_size,
1212        );
1213        // The pickup happened when the boost-amount jump was observed, not at the flush frame.
1214        pickup.frame = event.frame;
1215        pickup.time = event.time;
1216        pickup.detection = BoostPickupDetection::InferredOnly;
1217        self.pickup_events.push(pickup);
1218    }
1219
1220    fn flush_stale_pickup_comparisons(&mut self, frame: &FrameInfo) {
1221        while self
1222            .pending_inferred_pickups
1223            .front()
1224            .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < frame.frame_number)
1225        {
1226            if let Some(event) = self.pending_inferred_pickups.pop_front() {
1227                self.resolve_inferred_pickup(event, frame);
1228            }
1229        }
1230    }
1231
1232    pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
1233        self.flush_deferred_reported_pickups();
1234        self.pending_inferred_pickups.clear();
1235        Ok(())
1236    }
1237
1238    fn inactive_pickup_stats(
1239        &self,
1240        player: &PlayerSample,
1241        pad_id: &str,
1242        previous_boost_amount: f32,
1243        respawn_amount: f32,
1244    ) -> Option<(f32, BoostPadSize)> {
1245        let pad_size = self
1246            .known_pad_sizes
1247            .get(pad_id)
1248            .copied()
1249            .or_else(|| self.guess_pad_size_from_position(pad_id, player.position()?))?;
1250        let nominal_gain = match pad_size {
1251            BoostPadSize::Big => BOOST_MAX_AMOUNT,
1252            BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
1253        };
1254        let capacity_limited_gain = (BOOST_MAX_AMOUNT - previous_boost_amount)
1255            .min(nominal_gain)
1256            .max(0.0);
1257        let observed_gain = player
1258            .boost_amount
1259            .map(|boost_amount| (boost_amount - previous_boost_amount - respawn_amount).max(0.0))
1260            .unwrap_or(0.0);
1261        if observed_gain <= 1.0 {
1262            return None;
1263        }
1264        Some((
1265            capacity_limited_gain.max(observed_gain).min(nominal_gain),
1266            pad_size,
1267        ))
1268    }
1269
1270    pub fn update_parts(
1271        &mut self,
1272        frame: &FrameInfo,
1273        gameplay: &GameplayState,
1274        players: &PlayerFrameState,
1275        events: &FrameEventsState,
1276        vertical_state: &PlayerVerticalState,
1277        live_play_state: &LivePlayState,
1278    ) -> SubtrActorResult<()> {
1279        self.pickup_events.begin_update();
1280        self.respawn_events.begin_update();
1281        let live_play = live_play_state.counts_toward_player_motion();
1282        let boost_levels_live = Self::boost_levels_live(live_play);
1283        let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
1284        let track_boost_pickups = Self::tracks_boost_pickups(gameplay, live_play);
1285        let boost_levels_resumed_this_sample =
1286            boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
1287        let kickoff_phase_active = gameplay.kickoff_phase_active();
1288        let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
1289        if kickoff_phase_started {
1290            self.kickoff_respawn_awarded.clear();
1291        }
1292        for demo in &events.demo_events {
1293            let pre_demo_boost_amount = self.previous_boost_amounts.get(&demo.victim).copied();
1294            self.pending_demo_respawns
1295                .entry(demo.victim.clone())
1296                .or_insert(PendingDemoRespawn {
1297                    demo_time: demo.time,
1298                    pre_demo_boost_amount,
1299                });
1300        }
1301
1302        let mut current_boost_amounts = Vec::new();
1303        let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
1304        let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
1305
1306        for event in &events.boost_pad_events {
1307            let BoostPadEventKind::PickedUp { .. } = event.kind else {
1308                continue;
1309            };
1310            let Some(player_id) = &event.player else {
1311                continue;
1312            };
1313            *pickup_counts_by_player
1314                .entry(player_id.clone())
1315                .or_default() += 1;
1316        }
1317
1318        for player in &players.players {
1319            let Some(boost_amount) = player.boost_amount else {
1320                continue;
1321            };
1322            let previous_sample_boost_amount =
1323                self.previous_boost_amounts.get(&player.player_id).copied();
1324            let previous_boost_amount = previous_sample_boost_amount
1325                .or(player.last_boost_amount)
1326                .unwrap_or(boost_amount);
1327            let previous_boost_amount = if boost_levels_resumed_this_sample {
1328                boost_amount
1329            } else {
1330                previous_boost_amount
1331            };
1332            let pending_demo_respawn = self.pending_demo_respawns.get(&player.player_id);
1333            let demo_respawn_ready = pending_demo_respawn.is_some_and(|pending| {
1334                player.rigid_body.is_some()
1335                    && frame.time - pending.demo_time >= DEMO_RESPAWN_WINDOW_SECONDS
1336            });
1337            let demo_respawn_pending = pending_demo_respawn.is_some() && !demo_respawn_ready;
1338            let demo_respawn_supported = demo_respawn_ready;
1339
1340            if demo_respawn_pending {
1341                if let Some(pending) = self.pending_demo_respawns.get_mut(&player.player_id) {
1342                    pending.pre_demo_boost_amount = pending
1343                        .pre_demo_boost_amount
1344                        .or(previous_sample_boost_amount);
1345                }
1346                continue;
1347            }
1348
1349            let mut boost_increase_reasons = Vec::new();
1350            if let Some(previous_sample_boost_amount) = previous_sample_boost_amount {
1351                boost_increase_reasons = Self::classify_boost_increase_reasons(
1352                    previous_sample_boost_amount,
1353                    boost_amount,
1354                    kickoff_phase_active,
1355                    demo_respawn_supported,
1356                );
1357                if track_boost_levels {
1358                    for reason in &boost_increase_reasons {
1359                        if let Ok(pad_type) = BoostPickupPadType::try_from(*reason) {
1360                            self.record_inferred_pickup(PendingBoostPickupEvent {
1361                                frame: frame.frame_number,
1362                                time: frame.time,
1363                                player_id: player.player_id.clone(),
1364                                player_position: player
1365                                    .position()
1366                                    .map(|position| position.to_array()),
1367                                is_team_0: player.is_team_0,
1368                                pad_type,
1369                                field_half: Self::field_half_from_position(
1370                                    player.is_team_0,
1371                                    player.position(),
1372                                ),
1373                                activity: Self::activity_label(live_play),
1374                                boost_before: Some(previous_sample_boost_amount),
1375                                boost_after: Some(boost_amount),
1376                            });
1377                        }
1378                    }
1379                }
1380            }
1381            let generic_respawn_supported = track_boost_levels
1382                && boost_increase_reasons.contains(&BoostIncreaseReason::Respawn);
1383            if track_boost_levels {
1384                let previous = if boost_levels_resumed_this_sample {
1385                    boost_amount
1386                } else {
1387                    previous_boost_amount
1388                };
1389                self.stats.apply_boost_sample(
1390                    &player.player_id,
1391                    player.is_team_0,
1392                    previous,
1393                    boost_amount,
1394                    frame.dt,
1395                );
1396            }
1397
1398            let mut respawn_amount = 0.0;
1399            // Grant initial kickoff respawn the first time we see each player.
1400            // This handles replays that start after the kickoff countdown has
1401            // already ended on the first frame.
1402            let first_seen_player = self
1403                .initial_respawn_awarded
1404                .insert(player.player_id.clone());
1405            let kickoff_respawn_due = kickoff_phase_active
1406                && !self.kickoff_respawn_awarded.contains(&player.player_id)
1407                && track_boost_levels;
1408            if first_seen_player || kickoff_respawn_due {
1409                respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1410                self.kickoff_respawn_awarded
1411                    .insert(player.player_id.clone());
1412            }
1413            let demo_respawn = demo_respawn_supported || generic_respawn_supported;
1414            if demo_respawn {
1415                if let Some(pending) = self.pending_demo_respawns.get(&player.player_id) {
1416                    // A demo removes the victim's pre-demo boost; the respawn
1417                    // grant below is a separate obtained amount.
1418                    let boost_removed_by_demo_amount = pending
1419                        .pre_demo_boost_amount
1420                        .unwrap_or(previous_boost_amount)
1421                        .max(0.0);
1422                    self.player_usage_state
1423                        .entry(player.player_id.clone())
1424                        .or_default()
1425                        .apply_removed_amount(boost_removed_by_demo_amount);
1426                }
1427                respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1428                self.pending_demo_respawns.remove(&player.player_id);
1429            }
1430            if respawn_amount > 0.0 {
1431                let respawn_kind = if demo_respawn {
1432                    RespawnKind::Demo
1433                } else {
1434                    RespawnKind::Kickoff
1435                };
1436                self.apply_respawn_amount(
1437                    &player.player_id,
1438                    player.position().map(|position| position.to_array()),
1439                    player.is_team_0,
1440                    frame.frame_number,
1441                    frame.time,
1442                    respawn_amount,
1443                    respawn_kind,
1444                );
1445            }
1446            respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
1447
1448            current_boost_amounts.push((player.player_id.clone(), player.is_team_0, boost_amount));
1449        }
1450
1451        self.resolve_deferred_reported_pickups(frame, players);
1452
1453        for event in &events.boost_pad_events {
1454            match event.kind {
1455                BoostPadEventKind::PickedUp { sequence } => {
1456                    if !track_boost_pickups && !self.config.include_non_live_pickups {
1457                        let Some(player_id) = &event.player else {
1458                            continue;
1459                        };
1460                        let Some(player) = players
1461                            .players
1462                            .iter()
1463                            .find(|player| &player.player_id == player_id)
1464                        else {
1465                            continue;
1466                        };
1467                        let previous_boost_amount = self
1468                            .previous_boost_amounts
1469                            .get(player_id)
1470                            .copied()
1471                            .or(player.last_boost_amount)
1472                            .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1473                        let respawn_amount = respawn_amounts_by_player
1474                            .get(player_id)
1475                            .copied()
1476                            .unwrap_or(0.0);
1477                        let Some((collected_amount, pad_size)) = self.inactive_pickup_stats(
1478                            player,
1479                            &event.pad_id,
1480                            previous_boost_amount,
1481                            respawn_amount,
1482                        ) else {
1483                            continue;
1484                        };
1485                        if !self.inactive_pickup_frames.insert((
1486                            player_id.clone(),
1487                            event.frame,
1488                            pad_size,
1489                        )) {
1490                            continue;
1491                        }
1492                        let pickup = self.apply_inactive_pickup(
1493                            player_id,
1494                            player.position().map(|position| position.to_array()),
1495                            player.is_team_0,
1496                            event.frame,
1497                            event.time,
1498                            collected_amount,
1499                            pad_size,
1500                            Some(previous_boost_amount),
1501                            player.boost_amount,
1502                        );
1503                        let reported_event = PendingBoostPickupEvent {
1504                            frame: event.frame,
1505                            time: event.time,
1506                            player_id: player_id.clone(),
1507                            player_position: player.position().map(|position| position.to_array()),
1508                            is_team_0: player.is_team_0,
1509                            pad_type: pad_size.into(),
1510                            field_half: Self::field_half_from_position(
1511                                player.is_team_0,
1512                                player.position(),
1513                            ),
1514                            activity: BoostPickupActivity::Inactive,
1515                            boost_before: None,
1516                            boost_after: None,
1517                        };
1518                        self.push_reported_pickup(reported_event, pickup);
1519                        continue;
1520                    }
1521                    let Some(player_id) = &event.player else {
1522                        continue;
1523                    };
1524                    let Some(player) = players
1525                        .players
1526                        .iter()
1527                        .find(|player| &player.player_id == player_id)
1528                    else {
1529                        continue;
1530                    };
1531                    if self.unavailable_pad_is_recent(&event.pad_id, event.time, player.position())
1532                    {
1533                        continue;
1534                    }
1535                    let pickup_key = (event.pad_id.clone(), player_id.clone());
1536                    if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
1537                        continue;
1538                    }
1539                    self.pickup_frames.insert(pickup_key, event.frame);
1540                    if self.seen_pickup_sequence_is_recent(
1541                        &event.pad_id,
1542                        sequence,
1543                        event.time,
1544                        player.position(),
1545                    ) {
1546                        continue;
1547                    }
1548                    self.seen_pickup_sequence_times
1549                        .insert((event.pad_id.clone(), sequence), event.time);
1550                    self.unavailable_pads.insert(event.pad_id.clone());
1551                    self.last_pickup_times
1552                        .insert(event.pad_id.clone(), event.time);
1553                    if let Some(position) = player.position() {
1554                        self.observed_pad_positions
1555                            .entry(event.pad_id.clone())
1556                            .or_default()
1557                            .observe(position);
1558                    }
1559                    let previous_boost_amount = self
1560                        .previous_boost_amounts
1561                        .get(player_id)
1562                        .copied()
1563                        .or(player.last_boost_amount)
1564                        .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1565                    let pre_applied_collected_amount =
1566                        if pickup_counts_by_player.get(player_id).copied() == Some(1) {
1567                            self.previous_boost_amounts
1568                                .get(player_id)
1569                                .copied()
1570                                .map(|previous_sample_boost_amount| {
1571                                    let respawn_amount = respawn_amounts_by_player
1572                                        .get(player_id)
1573                                        .copied()
1574                                        .unwrap_or(0.0);
1575                                    (player.boost_amount.unwrap_or(previous_boost_amount)
1576                                        - previous_sample_boost_amount
1577                                        - respawn_amount)
1578                                        .max(0.0)
1579                                })
1580                                .unwrap_or(0.0)
1581                        } else {
1582                            0.0
1583                        };
1584                    let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
1585                        .then(|| {
1586                            self.guess_pad_size_from_position(
1587                                &event.pad_id,
1588                                player.position().unwrap_or(glam::Vec3::ZERO),
1589                            )
1590                        })
1591                        .flatten();
1592                    self.apply_pickup_collected_amount(
1593                        player_id,
1594                        pre_applied_collected_amount,
1595                        pre_applied_pad_size,
1596                    );
1597                    let pending_pickup = PendingBoostPickup {
1598                        frame: event.frame,
1599                        time: event.time,
1600                        player_id: player_id.clone(),
1601                        is_team_0: player.is_team_0,
1602                        previous_boost_amount,
1603                        pre_applied_collected_amount,
1604                        player_position: player.position().unwrap_or(glam::Vec3::ZERO),
1605                        boost_before: Some(previous_boost_amount),
1606                        boost_after: player.boost_amount,
1607                    };
1608
1609                    let pad_size = self
1610                        .known_pad_sizes
1611                        .get(&event.pad_id)
1612                        .copied()
1613                        .or_else(|| {
1614                            let mut size = self.guess_pad_size_from_position(
1615                                &event.pad_id,
1616                                player.position().unwrap_or(glam::Vec3::ZERO),
1617                            )?;
1618                            // Sanity check: if the observed boost gain clearly
1619                            // exceeds what a small pad can provide, the pad must
1620                            // be big.  Use a margin to avoid float imprecision.
1621                            if size == BoostPadSize::Small
1622                                && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW + 1.0
1623                            {
1624                                size = BoostPadSize::Big;
1625                            }
1626                            self.known_pad_sizes.insert(event.pad_id.clone(), size);
1627                            Some(size)
1628                        });
1629                    if let Some(pad_size) = pad_size {
1630                        let reported_event = PendingBoostPickupEvent {
1631                            frame: event.frame,
1632                            time: event.time,
1633                            player_id: player_id.clone(),
1634                            player_position: player.position().map(|position| position.to_array()),
1635                            is_team_0: player.is_team_0,
1636                            pad_type: pad_size.into(),
1637                            field_half: Self::field_half_from_position(
1638                                player.is_team_0,
1639                                player.position(),
1640                            ),
1641                            activity: Self::activity_label(track_boost_pickups),
1642                            boost_before: None,
1643                            boost_after: None,
1644                        };
1645                        let pickup_gain_is_visible = pre_applied_collected_amount > 1.0
1646                            || player.boost_amount.is_some_and(|boost_amount| {
1647                                boost_amount > previous_boost_amount + 1.0
1648                            });
1649                        if !pickup_gain_is_visible {
1650                            self.pending_reported_pickups
1651                                .push_back(DeferredReportedBoostPickup {
1652                                    pad_id: event.pad_id.clone(),
1653                                    pending_pickup,
1654                                    pad_size,
1655                                    reported_event,
1656                                });
1657                            continue;
1658                        }
1659
1660                        let pickup = self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
1661                        self.push_reported_pickup(reported_event, pickup);
1662                    }
1663                }
1664                BoostPadEventKind::Available => {
1665                    if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
1666                        let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
1667                        else {
1668                            continue;
1669                        };
1670                        if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
1671                        {
1672                            continue;
1673                        }
1674                    }
1675                    self.unavailable_pads.remove(&event.pad_id);
1676                }
1677            }
1678        }
1679        self.flush_stale_pickup_comparisons(frame);
1680
1681        if track_boost_levels {
1682            for player in &players.players {
1683                if self.pending_demo_respawns.contains_key(&player.player_id) {
1684                    continue;
1685                }
1686                let Some(boost_amount) = player.boost_amount else {
1687                    continue;
1688                };
1689                let usage_state = self.player_usage_state(&player.player_id);
1690                let previous_amount_used = usage_state.amount_used;
1691                let amount_used_raw = usage_state.inferred_amount_used(boost_amount);
1692                let amount_used = amount_used_raw.max(usage_state.amount_used);
1693                let amount_used_delta = amount_used - previous_amount_used;
1694                let split_amount = usage_state.amount_used_by_vertical_band();
1695                let amount_used_allocation_delta = (amount_used - split_amount).max(0.0);
1696                if amount_used_allocation_delta > 0.0 {
1697                    let speed = player.speed();
1698                    let previous_speed = self
1699                        .previous_player_speeds
1700                        .get(&player.player_id)
1701                        .copied()
1702                        .or(speed);
1703                    let previous_speed = if boost_levels_resumed_this_sample {
1704                        speed
1705                    } else {
1706                        previous_speed
1707                    };
1708                    let used_while_supersonic = player.boost_active
1709                        && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
1710                        && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD;
1711                    let grounded = vertical_state.is_grounded(&player.player_id);
1712                    self.stats.apply_used_allocation(
1713                        &player.player_id,
1714                        player.is_team_0,
1715                        amount_used_allocation_delta,
1716                        grounded,
1717                        used_while_supersonic,
1718                    );
1719                    self.player_usage_state
1720                        .entry(player.player_id.clone())
1721                        .or_default()
1722                        .apply_used_allocation(amount_used_allocation_delta, grounded);
1723                }
1724                if amount_used_delta <= 0.0 {
1725                    continue;
1726                }
1727                self.player_usage_state
1728                    .entry(player.player_id.clone())
1729                    .or_default()
1730                    .apply_used_amount(amount_used_delta);
1731                self.stats
1732                    .apply_used(&player.player_id, player.is_team_0, amount_used_delta);
1733            }
1734        }
1735        for (player_id, is_team_0, boost_amount) in current_boost_amounts {
1736            if track_boost_levels {
1737                self.record_boost_tracks(&player_id, is_team_0, frame.frame_number, boost_amount);
1738            }
1739            self.previous_boost_amounts.insert(player_id, boost_amount);
1740        }
1741        for player in &players.players {
1742            if let Some(speed) = player.speed() {
1743                self.previous_player_speeds
1744                    .insert(player.player_id.clone(), speed);
1745            }
1746        }
1747        self.kickoff_phase_active_last_frame = kickoff_phase_active;
1748        self.previous_boost_levels_live = Some(boost_levels_live);
1749
1750        Ok(())
1751    }
1752}
1753
1754#[cfg(test)]
1755#[path = "boost_tests.rs"]
1756mod tests;