Skip to main content

subtr_actor/stats/reducers/
boost.rs

1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize)]
4pub struct BoostStats {
5    pub tracked_time: f32,
6    pub boost_integral: f32,
7    pub time_zero_boost: f32,
8    pub time_hundred_boost: f32,
9    pub time_boost_0_25: f32,
10    pub time_boost_25_50: f32,
11    pub time_boost_50_75: f32,
12    pub time_boost_75_100: f32,
13    pub amount_collected: f32,
14    pub amount_stolen: f32,
15    pub big_pads_collected: u32,
16    pub small_pads_collected: u32,
17    pub big_pads_stolen: u32,
18    pub small_pads_stolen: u32,
19    pub amount_collected_big: f32,
20    pub amount_stolen_big: f32,
21    pub amount_collected_small: f32,
22    pub amount_stolen_small: f32,
23    pub amount_respawned: f32,
24    pub overfill_total: f32,
25    pub overfill_from_stolen: f32,
26    pub amount_used: f32,
27    pub amount_used_while_grounded: f32,
28    pub amount_used_while_airborne: f32,
29    pub amount_used_while_supersonic: f32,
30}
31
32impl BoostStats {
33    pub fn average_boost_amount(&self) -> f32 {
34        if self.tracked_time == 0.0 {
35            0.0
36        } else {
37            self.boost_integral / self.tracked_time
38        }
39    }
40
41    pub fn bpm(&self) -> f32 {
42        if self.tracked_time == 0.0 {
43            0.0
44        } else {
45            self.amount_collected * 60.0 / self.tracked_time
46        }
47    }
48
49    fn pct(&self, value: f32) -> f32 {
50        if self.tracked_time == 0.0 {
51            0.0
52        } else {
53            value * 100.0 / self.tracked_time
54        }
55    }
56
57    pub fn zero_boost_pct(&self) -> f32 {
58        self.pct(self.time_zero_boost)
59    }
60
61    pub fn hundred_boost_pct(&self) -> f32 {
62        self.pct(self.time_hundred_boost)
63    }
64
65    pub fn boost_0_25_pct(&self) -> f32 {
66        self.pct(self.time_boost_0_25)
67    }
68
69    pub fn boost_25_50_pct(&self) -> f32 {
70        self.pct(self.time_boost_25_50)
71    }
72
73    pub fn boost_50_75_pct(&self) -> f32 {
74        self.pct(self.time_boost_50_75)
75    }
76
77    pub fn boost_75_100_pct(&self) -> f32 {
78        self.pct(self.time_boost_75_100)
79    }
80
81    pub fn amount_obtained(&self) -> f32 {
82        self.amount_collected_big + self.amount_collected_small + self.amount_respawned
83    }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub struct BoostReducerConfig {
88    pub include_non_live_pickups: bool,
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct BoostReducer {
93    config: BoostReducerConfig,
94    player_stats: HashMap<PlayerId, BoostStats>,
95    team_zero_stats: BoostStats,
96    team_one_stats: BoostStats,
97    previous_boost_amounts: HashMap<PlayerId, f32>,
98    previous_player_speeds: HashMap<PlayerId, f32>,
99    observed_pad_positions: HashMap<String, PadPositionEstimate>,
100    known_pad_sizes: HashMap<String, BoostPadSize>,
101    known_pad_indices: HashMap<String, usize>,
102    unavailable_pads: HashSet<String>,
103    seen_pickup_sequences: HashSet<(String, u8)>,
104    pickup_frames: HashMap<(String, PlayerId), usize>,
105    last_pickup_times: HashMap<String, f32>,
106    kickoff_phase_active_last_frame: bool,
107    kickoff_respawn_awarded: HashSet<PlayerId>,
108    initial_respawn_awarded: HashSet<PlayerId>,
109    pending_demo_respawns: HashSet<PlayerId>,
110    previous_boost_levels_live: Option<bool>,
111    active_invariant_warnings: HashSet<BoostInvariantWarningKey>,
112    live_play_tracker: LivePlayTracker,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116struct BoostInvariantWarningKey {
117    scope: String,
118    kind: BoostInvariantKind,
119}
120
121#[derive(Debug, Clone)]
122struct PendingBoostPickup {
123    player_id: PlayerId,
124    is_team_0: bool,
125    previous_boost_amount: f32,
126    pre_applied_collected_amount: f32,
127    pre_applied_pad_size: Option<BoostPadSize>,
128    player_position: glam::Vec3,
129}
130
131impl BoostReducer {
132    pub fn new() -> Self {
133        Self::with_config(BoostReducerConfig::default())
134    }
135
136    pub fn with_config(config: BoostReducerConfig) -> Self {
137        Self {
138            config,
139            ..Self::default()
140        }
141    }
142
143    pub fn player_stats(&self) -> &HashMap<PlayerId, BoostStats> {
144        &self.player_stats
145    }
146
147    pub fn team_zero_stats(&self) -> &BoostStats {
148        &self.team_zero_stats
149    }
150
151    pub fn team_one_stats(&self) -> &BoostStats {
152        &self.team_one_stats
153    }
154
155    fn estimated_pad_position(&self, pad_id: &str) -> Option<glam::Vec3> {
156        self.observed_pad_positions
157            .get(pad_id)
158            .and_then(PadPositionEstimate::mean)
159    }
160
161    fn observed_pad_positions(&self, pad_id: &str) -> &[glam::Vec3] {
162        self.observed_pad_positions
163            .get(pad_id)
164            .map(PadPositionEstimate::observations)
165            .unwrap_or(&[])
166    }
167
168    fn pad_match_radius(pad_size: BoostPadSize) -> f32 {
169        match pad_size {
170            BoostPadSize::Big => STANDARD_PAD_MATCH_RADIUS_BIG,
171            BoostPadSize::Small => STANDARD_PAD_MATCH_RADIUS_SMALL,
172        }
173    }
174
175    pub fn resolved_boost_pads(&self) -> Vec<ResolvedBoostPad> {
176        standard_soccar_boost_pad_layout()
177            .iter()
178            .enumerate()
179            .map(|(index, (position, size))| ResolvedBoostPad {
180                index,
181                pad_id: self
182                    .known_pad_indices
183                    .iter()
184                    .find_map(|(pad_id, pad_index)| (*pad_index == index).then(|| pad_id.clone())),
185                size: *size,
186                position: glam_to_vec(position),
187            })
188            .collect()
189    }
190
191    fn infer_pad_index(
192        &self,
193        pad_id: &str,
194        pad_size: BoostPadSize,
195        observed_position: glam::Vec3,
196    ) -> Option<usize> {
197        if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
198            return Some(index);
199        }
200
201        let observed_position = self
202            .estimated_pad_position(pad_id)
203            .unwrap_or(observed_position);
204        let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
205        let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
206        let radius = Self::pad_match_radius(pad_size);
207        let observed_positions = self.observed_pad_positions(pad_id);
208        let best_candidate = |allow_used: bool| {
209            layout
210                .iter()
211                .enumerate()
212                .filter(|(index, (_, size))| {
213                    *size == pad_size && (allow_used || !used_indices.contains(index))
214                })
215                .filter_map(|(index, (candidate_position, _))| {
216                    let mut vote_count = 0usize;
217                    let mut total_vote_distance = 0.0f32;
218                    let mut best_vote_distance = f32::INFINITY;
219
220                    for position in observed_positions {
221                        let distance = position.distance(*candidate_position);
222                        if distance <= radius {
223                            vote_count += 1;
224                            total_vote_distance += distance;
225                            best_vote_distance = best_vote_distance.min(distance);
226                        }
227                    }
228
229                    if vote_count == 0 {
230                        return None;
231                    }
232
233                    let representative_distance = observed_position.distance(*candidate_position);
234                    Some((
235                        index,
236                        vote_count,
237                        total_vote_distance / vote_count as f32,
238                        best_vote_distance,
239                        representative_distance,
240                    ))
241                })
242                .max_by(|left, right| {
243                    left.1
244                        .cmp(&right.1)
245                        .then_with(|| right.2.partial_cmp(&left.2).unwrap())
246                        .then_with(|| right.3.partial_cmp(&left.3).unwrap())
247                        .then_with(|| right.4.partial_cmp(&left.4).unwrap())
248                })
249                .map(|(index, _, _, _, _)| index)
250        };
251
252        best_candidate(false)
253            .or_else(|| best_candidate(true))
254            .or_else(|| {
255                layout
256                    .iter()
257                    .enumerate()
258                    .filter(|(index, (_, size))| *size == pad_size && !used_indices.contains(index))
259                    .min_by(|(_, (a, _)), (_, (b, _))| {
260                        observed_position
261                            .distance_squared(*a)
262                            .partial_cmp(&observed_position.distance_squared(*b))
263                            .unwrap()
264                    })
265                    .map(|(index, _)| index)
266            })
267            .or_else(|| {
268                layout
269                    .iter()
270                    .enumerate()
271                    .filter(|(_, (_, size))| *size == pad_size)
272                    .min_by(|(_, (a, _)), (_, (b, _))| {
273                        observed_position
274                            .distance_squared(*a)
275                            .partial_cmp(&observed_position.distance_squared(*b))
276                            .unwrap()
277                    })
278                    .map(|(index, _)| index)
279            })
280            .filter(|index| {
281                observed_position.distance(standard_soccar_boost_pad_position(*index)) <= radius
282            })
283    }
284
285    fn infer_pad_details_from_position(
286        &self,
287        pad_id: &str,
288        observed_position: glam::Vec3,
289    ) -> Option<(usize, BoostPadSize)> {
290        if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
291            let (_, size) = standard_soccar_boost_pad_layout().get(index)?;
292            return Some((index, *size));
293        }
294
295        let observed_position = self
296            .estimated_pad_position(pad_id)
297            .unwrap_or(observed_position);
298        let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
299        let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
300        let observed_positions = self.observed_pad_positions(pad_id);
301        let best_candidate = |allow_used: bool| {
302            layout
303                .iter()
304                .enumerate()
305                .filter(|(index, _)| allow_used || !used_indices.contains(index))
306                .filter_map(|(index, (candidate_position, size))| {
307                    let radius = Self::pad_match_radius(*size);
308                    let mut vote_count = 0usize;
309                    let mut total_vote_distance = 0.0f32;
310                    let mut best_vote_distance = f32::INFINITY;
311
312                    for position in observed_positions {
313                        let distance = position.distance(*candidate_position);
314                        if distance <= radius {
315                            vote_count += 1;
316                            total_vote_distance += distance;
317                            best_vote_distance = best_vote_distance.min(distance);
318                        }
319                    }
320
321                    if vote_count == 0 {
322                        return None;
323                    }
324
325                    let representative_distance = observed_position.distance(*candidate_position);
326                    Some((
327                        index,
328                        *size,
329                        vote_count,
330                        total_vote_distance / vote_count as f32,
331                        best_vote_distance,
332                        representative_distance,
333                    ))
334                })
335                .max_by(|left, right| {
336                    left.2
337                        .cmp(&right.2)
338                        .then_with(|| right.3.partial_cmp(&left.3).unwrap())
339                        .then_with(|| right.4.partial_cmp(&left.4).unwrap())
340                        .then_with(|| right.5.partial_cmp(&left.5).unwrap())
341                })
342                .map(|(index, size, _, _, _, _)| (index, size))
343        };
344
345        best_candidate(false).or_else(|| best_candidate(true))
346    }
347
348    fn guess_pad_size_from_position(
349        &self,
350        pad_id: &str,
351        observed_position: glam::Vec3,
352    ) -> Option<BoostPadSize> {
353        if let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied() {
354            return Some(pad_size);
355        }
356
357        if let Some((_, pad_size)) = self.infer_pad_details_from_position(pad_id, observed_position)
358        {
359            return Some(pad_size);
360        }
361
362        let observed_position = self
363            .estimated_pad_position(pad_id)
364            .unwrap_or(observed_position);
365        standard_soccar_boost_pad_layout()
366            .iter()
367            .min_by(|(left_position, _), (right_position, _)| {
368                observed_position
369                    .distance_squared(*left_position)
370                    .partial_cmp(&observed_position.distance_squared(*right_position))
371                    .unwrap()
372            })
373            .map(|(_, pad_size)| *pad_size)
374    }
375
376    fn resolve_pickup(
377        &mut self,
378        pad_id: &str,
379        pending_pickup: PendingBoostPickup,
380        pad_size: BoostPadSize,
381    ) {
382        let observed_position = self
383            .estimated_pad_position(pad_id)
384            .unwrap_or(pending_pickup.player_position);
385        let pad_position = self
386            .infer_pad_index(pad_id, pad_size, observed_position)
387            .map(|index| {
388                self.known_pad_indices.insert(pad_id.to_string(), index);
389                standard_soccar_boost_pad_position(index)
390            })
391            .unwrap_or(observed_position);
392        let stolen = is_enemy_side(pending_pickup.is_team_0, pad_position);
393        let stats = self
394            .player_stats
395            .entry(pending_pickup.player_id.clone())
396            .or_default();
397        let team_stats = if pending_pickup.is_team_0 {
398            &mut self.team_zero_stats
399        } else {
400            &mut self.team_one_stats
401        };
402        let nominal_gain = match pad_size {
403            BoostPadSize::Big => BOOST_MAX_AMOUNT,
404            BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
405        };
406        let collected_amount = (BOOST_MAX_AMOUNT - pending_pickup.previous_boost_amount)
407            .min(nominal_gain)
408            .max(pending_pickup.pre_applied_collected_amount);
409        let collected_amount_delta = collected_amount - pending_pickup.pre_applied_collected_amount;
410        let overfill = (nominal_gain - collected_amount).max(0.0);
411
412        stats.amount_collected += collected_amount_delta;
413        team_stats.amount_collected += collected_amount_delta;
414
415        match pending_pickup.pre_applied_pad_size {
416            Some(pre_applied_pad_size) if pre_applied_pad_size == pad_size => {
417                Self::apply_collected_bucket_amount(stats, pad_size, collected_amount_delta);
418                Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount_delta);
419            }
420            Some(pre_applied_pad_size) => {
421                Self::apply_collected_bucket_amount(
422                    stats,
423                    pre_applied_pad_size,
424                    -pending_pickup.pre_applied_collected_amount,
425                );
426                Self::apply_collected_bucket_amount(
427                    team_stats,
428                    pre_applied_pad_size,
429                    -pending_pickup.pre_applied_collected_amount,
430                );
431                Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
432                Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
433            }
434            None => {
435                Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
436                Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
437            }
438        }
439
440        if stolen {
441            stats.amount_stolen += collected_amount;
442            team_stats.amount_stolen += collected_amount;
443        }
444
445        match pad_size {
446            BoostPadSize::Big => {
447                stats.big_pads_collected += 1;
448                team_stats.big_pads_collected += 1;
449                if stolen {
450                    stats.big_pads_stolen += 1;
451                    team_stats.big_pads_stolen += 1;
452                    stats.amount_stolen_big += collected_amount;
453                    team_stats.amount_stolen_big += collected_amount;
454                }
455            }
456            BoostPadSize::Small => {
457                stats.small_pads_collected += 1;
458                team_stats.small_pads_collected += 1;
459                if stolen {
460                    stats.small_pads_stolen += 1;
461                    team_stats.small_pads_stolen += 1;
462                    stats.amount_stolen_small += collected_amount;
463                    team_stats.amount_stolen_small += collected_amount;
464                }
465            }
466        }
467
468        stats.overfill_total += overfill;
469        team_stats.overfill_total += overfill;
470        if stolen {
471            stats.overfill_from_stolen += overfill;
472            team_stats.overfill_from_stolen += overfill;
473        }
474    }
475
476    fn apply_collected_bucket_amount(stats: &mut BoostStats, pad_size: BoostPadSize, amount: f32) {
477        if amount == 0.0 {
478            return;
479        }
480
481        match pad_size {
482            BoostPadSize::Big => stats.amount_collected_big += amount,
483            BoostPadSize::Small => stats.amount_collected_small += amount,
484        }
485    }
486
487    fn apply_pickup_collected_amount(
488        &mut self,
489        player_id: &PlayerId,
490        is_team_0: bool,
491        amount: f32,
492        pad_size: Option<BoostPadSize>,
493    ) {
494        if amount <= 0.0 {
495            return;
496        }
497
498        let stats = self.player_stats.entry(player_id.clone()).or_default();
499        let team_stats = if is_team_0 {
500            &mut self.team_zero_stats
501        } else {
502            &mut self.team_one_stats
503        };
504        stats.amount_collected += amount;
505        team_stats.amount_collected += amount;
506        if let Some(pad_size) = pad_size {
507            Self::apply_collected_bucket_amount(stats, pad_size, amount);
508            Self::apply_collected_bucket_amount(team_stats, pad_size, amount);
509        }
510    }
511
512    fn apply_respawn_amount(&mut self, player_id: &PlayerId, is_team_0: bool, amount: f32) {
513        if amount <= 0.0 {
514            return;
515        }
516
517        let stats = self.player_stats.entry(player_id.clone()).or_default();
518        let team_stats = if is_team_0 {
519            &mut self.team_zero_stats
520        } else {
521            &mut self.team_one_stats
522        };
523        stats.amount_respawned += amount;
524        team_stats.amount_respawned += amount;
525    }
526
527    fn warn_for_boost_invariant_violations(
528        &mut self,
529        scope: &str,
530        frame_number: usize,
531        time: f32,
532        stats: &BoostStats,
533        observed_boost_amount: Option<f32>,
534    ) {
535        let violations = boost_invariant_violations(stats, observed_boost_amount);
536        let active_kinds: HashSet<BoostInvariantKind> =
537            violations.iter().map(|violation| violation.kind).collect();
538
539        for violation in violations {
540            let key = BoostInvariantWarningKey {
541                scope: scope.to_string(),
542                kind: violation.kind,
543            };
544            if self.active_invariant_warnings.insert(key) {
545                log::warn!(
546                    "Boost invariant violation for {} at frame {} (t={:.3}): {}",
547                    scope,
548                    frame_number,
549                    time,
550                    violation.message(),
551                );
552            }
553        }
554
555        for kind in BoostInvariantKind::ALL {
556            if active_kinds.contains(&kind) {
557                continue;
558            }
559            self.active_invariant_warnings
560                .remove(&BoostInvariantWarningKey {
561                    scope: scope.to_string(),
562                    kind,
563                });
564        }
565    }
566
567    fn warn_for_sample_boost_invariants(&mut self, sample: &StatsSample) {
568        let team_zero_stats = self.team_zero_stats.clone();
569        let team_one_stats = self.team_one_stats.clone();
570        let player_scopes: Vec<(PlayerId, Option<f32>, BoostStats)> = sample
571            .players
572            .iter()
573            .map(|player| {
574                (
575                    player.player_id.clone(),
576                    player.boost_amount,
577                    self.player_stats
578                        .get(&player.player_id)
579                        .cloned()
580                        .unwrap_or_default(),
581                )
582            })
583            .collect();
584
585        self.warn_for_boost_invariant_violations(
586            "team_zero",
587            sample.frame_number,
588            sample.time,
589            &team_zero_stats,
590            None,
591        );
592        self.warn_for_boost_invariant_violations(
593            "team_one",
594            sample.frame_number,
595            sample.time,
596            &team_one_stats,
597            None,
598        );
599        for (player_id, observed_boost_amount, stats) in player_scopes {
600            self.warn_for_boost_invariant_violations(
601                &format!("player {player_id:?}"),
602                sample.frame_number,
603                sample.time,
604                &stats,
605                observed_boost_amount,
606            );
607        }
608    }
609
610    fn interval_fraction_in_boost_range(
611        start_boost: f32,
612        end_boost: f32,
613        min_boost: f32,
614        max_boost: f32,
615    ) -> f32 {
616        if (end_boost - start_boost).abs() <= f32::EPSILON {
617            return ((start_boost >= min_boost) && (start_boost < max_boost)) as i32 as f32;
618        }
619
620        let t_at_min = (min_boost - start_boost) / (end_boost - start_boost);
621        let t_at_max = (max_boost - start_boost) / (end_boost - start_boost);
622        let interval_start = t_at_min.min(t_at_max).max(0.0);
623        let interval_end = t_at_min.max(t_at_max).min(1.0);
624        (interval_end - interval_start).max(0.0)
625    }
626
627    fn pad_respawn_time_seconds(pad_size: BoostPadSize) -> f32 {
628        match pad_size {
629            BoostPadSize::Big => 10.0,
630            BoostPadSize::Small => 4.0,
631        }
632    }
633
634    fn boost_levels_live(sample: &StatsSample, live_play: bool) -> bool {
635        live_play && sample.ball_has_been_hit != Some(false)
636    }
637
638    fn tracks_boost_levels(boost_levels_live: bool) -> bool {
639        boost_levels_live
640    }
641
642    fn tracks_boost_pickups(sample: &StatsSample, live_play: bool) -> bool {
643        live_play
644            || (sample.ball_has_been_hit == Some(false)
645                && sample.game_state != Some(GAME_STATE_KICKOFF_COUNTDOWN)
646                && sample.kickoff_countdown_time.is_none_or(|t| t <= 0))
647    }
648}
649
650impl StatsReducer for BoostReducer {
651    fn on_sample(&mut self, sample: &StatsSample) -> SubtrActorResult<()> {
652        let live_play = self.live_play_tracker.is_live_play(sample);
653        let boost_levels_live = Self::boost_levels_live(sample, live_play);
654        let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
655        let track_boost_pickups = Self::tracks_boost_pickups(sample, live_play);
656        let boost_levels_resumed_this_sample =
657            boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
658        let kickoff_phase_active = sample.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
659            || sample.kickoff_countdown_time.is_some_and(|t| t > 0)
660            || sample.ball_has_been_hit == Some(false);
661        let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
662        if kickoff_phase_started {
663            self.kickoff_respawn_awarded.clear();
664        }
665        for demo in &sample.demo_events {
666            self.pending_demo_respawns.insert(demo.victim.clone());
667        }
668
669        let mut current_boost_amounts = Vec::new();
670        let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
671        let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
672
673        for event in &sample.boost_pad_events {
674            let BoostPadEventKind::PickedUp { .. } = event.kind else {
675                continue;
676            };
677            let Some(player_id) = &event.player else {
678                continue;
679            };
680            *pickup_counts_by_player
681                .entry(player_id.clone())
682                .or_default() += 1;
683        }
684
685        for player in &sample.players {
686            let Some(boost_amount) = player.boost_amount else {
687                continue;
688            };
689            let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
690                self.previous_boost_amounts
691                    .get(&player.player_id)
692                    .copied()
693                    .unwrap_or(boost_amount)
694            });
695            let previous_boost_amount = if boost_levels_resumed_this_sample {
696                boost_amount
697            } else {
698                previous_boost_amount
699            };
700            let speed = player.speed();
701            let previous_speed = self
702                .previous_player_speeds
703                .get(&player.player_id)
704                .copied()
705                .or(speed);
706            let previous_speed = if boost_levels_resumed_this_sample {
707                speed
708            } else {
709                previous_speed
710            };
711
712            if track_boost_levels {
713                let average_boost_amount = (previous_boost_amount + boost_amount) * 0.5;
714                let time_zero_boost = sample.dt
715                    * Self::interval_fraction_in_boost_range(
716                        previous_boost_amount,
717                        boost_amount,
718                        0.0,
719                        BOOST_ZERO_BAND_RAW,
720                    );
721                let time_hundred_boost = sample.dt
722                    * Self::interval_fraction_in_boost_range(
723                        previous_boost_amount,
724                        boost_amount,
725                        BOOST_FULL_BAND_MIN_RAW,
726                        BOOST_MAX_AMOUNT + 1.0,
727                    );
728                let time_boost_0_25 = sample.dt
729                    * Self::interval_fraction_in_boost_range(
730                        previous_boost_amount,
731                        boost_amount,
732                        0.0,
733                        boost_percent_to_amount(25.0),
734                    );
735                let time_boost_25_50 = sample.dt
736                    * Self::interval_fraction_in_boost_range(
737                        previous_boost_amount,
738                        boost_amount,
739                        boost_percent_to_amount(25.0),
740                        boost_percent_to_amount(50.0),
741                    );
742                let time_boost_50_75 = sample.dt
743                    * Self::interval_fraction_in_boost_range(
744                        previous_boost_amount,
745                        boost_amount,
746                        boost_percent_to_amount(50.0),
747                        boost_percent_to_amount(75.0),
748                    );
749                let time_boost_75_100 = sample.dt
750                    * Self::interval_fraction_in_boost_range(
751                        previous_boost_amount,
752                        boost_amount,
753                        boost_percent_to_amount(75.0),
754                        BOOST_MAX_AMOUNT + 1.0,
755                    );
756                let supersonic_usage = if player.boost_active
757                    && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
758                    && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
759                {
760                    (previous_boost_amount - boost_amount)
761                        .max(0.0)
762                        .min(BOOST_USED_RAW_UNITS_PER_SECOND * sample.dt)
763                } else {
764                    0.0
765                };
766                let observed_usage = (previous_boost_amount - boost_amount).max(0.0);
767                let grounded_usage = if player
768                    .position()
769                    .is_some_and(|position| position.z <= GROUND_Z_THRESHOLD)
770                {
771                    observed_usage
772                } else {
773                    0.0
774                };
775                let airborne_usage = observed_usage - grounded_usage;
776
777                let stats = self
778                    .player_stats
779                    .entry(player.player_id.clone())
780                    .or_default();
781                let team_stats = if player.is_team_0 {
782                    &mut self.team_zero_stats
783                } else {
784                    &mut self.team_one_stats
785                };
786
787                stats.tracked_time += sample.dt;
788                stats.boost_integral += average_boost_amount * sample.dt;
789                team_stats.tracked_time += sample.dt;
790                team_stats.boost_integral += average_boost_amount * sample.dt;
791                stats.time_zero_boost += time_zero_boost;
792                team_stats.time_zero_boost += time_zero_boost;
793                stats.time_hundred_boost += time_hundred_boost;
794                team_stats.time_hundred_boost += time_hundred_boost;
795                stats.time_boost_0_25 += time_boost_0_25;
796                team_stats.time_boost_0_25 += time_boost_0_25;
797                stats.time_boost_25_50 += time_boost_25_50;
798                team_stats.time_boost_25_50 += time_boost_25_50;
799                stats.time_boost_50_75 += time_boost_50_75;
800                team_stats.time_boost_50_75 += time_boost_50_75;
801                stats.time_boost_75_100 += time_boost_75_100;
802                team_stats.time_boost_75_100 += time_boost_75_100;
803                stats.amount_used_while_grounded += grounded_usage;
804                team_stats.amount_used_while_grounded += grounded_usage;
805                stats.amount_used_while_airborne += airborne_usage;
806                team_stats.amount_used_while_airborne += airborne_usage;
807                stats.amount_used_while_supersonic += supersonic_usage;
808                team_stats.amount_used_while_supersonic += supersonic_usage;
809            }
810
811            let mut respawn_amount = 0.0;
812            // Grant initial kickoff respawn the first time we see each player.
813            // This handles replays that start after the kickoff countdown has
814            // already ended (game_state != 55 on the first frame).
815            let first_seen_player = self
816                .initial_respawn_awarded
817                .insert(player.player_id.clone());
818            if first_seen_player
819                || (kickoff_phase_active
820                    && !self.kickoff_respawn_awarded.contains(&player.player_id))
821            {
822                respawn_amount += BOOST_KICKOFF_START_AMOUNT;
823                self.kickoff_respawn_awarded
824                    .insert(player.player_id.clone());
825            }
826            if self.pending_demo_respawns.contains(&player.player_id) && player.rigid_body.is_some()
827            {
828                respawn_amount += BOOST_KICKOFF_START_AMOUNT;
829                self.pending_demo_respawns.remove(&player.player_id);
830            }
831            if respawn_amount > 0.0 {
832                self.apply_respawn_amount(&player.player_id, player.is_team_0, respawn_amount);
833            }
834            respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
835
836            current_boost_amounts.push((player.player_id.clone(), boost_amount));
837        }
838
839        for event in &sample.boost_pad_events {
840            match event.kind {
841                BoostPadEventKind::PickedUp { sequence } => {
842                    if !track_boost_pickups && !self.config.include_non_live_pickups {
843                        continue;
844                    }
845                    if self.unavailable_pads.contains(&event.pad_id) {
846                        continue;
847                    }
848                    let Some(player_id) = &event.player else {
849                        continue;
850                    };
851                    let pickup_key = (event.pad_id.clone(), player_id.clone());
852                    if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
853                        continue;
854                    }
855                    self.pickup_frames.insert(pickup_key, event.frame);
856                    if !self
857                        .seen_pickup_sequences
858                        .insert((event.pad_id.clone(), sequence))
859                    {
860                        continue;
861                    }
862                    self.unavailable_pads.insert(event.pad_id.clone());
863                    self.last_pickup_times
864                        .insert(event.pad_id.clone(), event.time);
865                    let Some(player) = sample
866                        .players
867                        .iter()
868                        .find(|player| &player.player_id == player_id)
869                    else {
870                        continue;
871                    };
872                    if let Some(position) = player.position() {
873                        self.observed_pad_positions
874                            .entry(event.pad_id.clone())
875                            .or_default()
876                            .observe(position);
877                    }
878                    let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
879                        self.previous_boost_amounts
880                            .get(player_id)
881                            .copied()
882                            .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0))
883                    });
884                    let pre_applied_collected_amount =
885                        if pickup_counts_by_player.get(player_id).copied() == Some(1) {
886                            self.previous_boost_amounts
887                                .get(player_id)
888                                .copied()
889                                .map(|previous_sample_boost_amount| {
890                                    let respawn_amount = respawn_amounts_by_player
891                                        .get(player_id)
892                                        .copied()
893                                        .unwrap_or(0.0);
894                                    (player.boost_amount.unwrap_or(previous_boost_amount)
895                                        - previous_sample_boost_amount
896                                        - respawn_amount)
897                                        .max(0.0)
898                                })
899                                .unwrap_or(0.0)
900                        } else {
901                            0.0
902                        };
903                    let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
904                        .then(|| {
905                            self.guess_pad_size_from_position(
906                                &event.pad_id,
907                                player.position().unwrap_or(glam::Vec3::ZERO),
908                            )
909                        })
910                        .flatten();
911                    self.apply_pickup_collected_amount(
912                        player_id,
913                        player.is_team_0,
914                        pre_applied_collected_amount,
915                        pre_applied_pad_size,
916                    );
917                    let pending_pickup = PendingBoostPickup {
918                        player_id: player_id.clone(),
919                        is_team_0: player.is_team_0,
920                        previous_boost_amount,
921                        pre_applied_collected_amount,
922                        pre_applied_pad_size,
923                        player_position: player.position().unwrap_or(glam::Vec3::ZERO),
924                    };
925
926                    let pad_size = self
927                        .known_pad_sizes
928                        .get(&event.pad_id)
929                        .copied()
930                        .or_else(|| {
931                            let mut size = self.guess_pad_size_from_position(
932                                &event.pad_id,
933                                player.position().unwrap_or(glam::Vec3::ZERO),
934                            )?;
935                            // Sanity check: if the observed boost gain clearly
936                            // exceeds what a small pad can provide, the pad must
937                            // be big.  Use a margin to avoid float imprecision.
938                            if size == BoostPadSize::Small
939                                && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW * 1.5
940                            {
941                                size = BoostPadSize::Big;
942                            }
943                            self.known_pad_sizes.insert(event.pad_id.clone(), size);
944                            Some(size)
945                        });
946                    if let Some(pad_size) = pad_size {
947                        self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
948                    }
949                }
950                BoostPadEventKind::Available => {
951                    if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
952                        let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
953                        else {
954                            continue;
955                        };
956                        if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
957                        {
958                            continue;
959                        }
960                    }
961                    self.unavailable_pads.remove(&event.pad_id);
962                }
963            }
964        }
965
966        for (player_id, boost_amount) in current_boost_amounts {
967            self.previous_boost_amounts.insert(player_id, boost_amount);
968        }
969        for player in &sample.players {
970            if let Some(speed) = player.speed() {
971                self.previous_player_speeds
972                    .insert(player.player_id.clone(), speed);
973            }
974        }
975        let mut team_zero_used = 0.0;
976        let mut team_one_used = 0.0;
977        for player in &sample.players {
978            let Some(boost_amount) = player.boost_amount else {
979                continue;
980            };
981            let stats = self
982                .player_stats
983                .entry(player.player_id.clone())
984                .or_default();
985            stats.amount_used = (stats.amount_obtained() - boost_amount).max(0.0);
986            if player.is_team_0 {
987                team_zero_used += stats.amount_used;
988            } else {
989                team_one_used += stats.amount_used;
990            }
991        }
992        self.team_zero_stats.amount_used = team_zero_used;
993        self.team_one_stats.amount_used = team_one_used;
994        self.warn_for_sample_boost_invariants(sample);
995        self.kickoff_phase_active_last_frame = kickoff_phase_active;
996        self.previous_boost_levels_live = Some(boost_levels_live);
997
998        Ok(())
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005    use boxcars::RemoteId;
1006
1007    fn player(player_id: u64, boost_amount: f32) -> PlayerSample {
1008        PlayerSample {
1009            player_id: RemoteId::Steam(player_id),
1010            is_team_0: true,
1011            rigid_body: None,
1012            boost_amount: Some(boost_amount),
1013            last_boost_amount: None,
1014            boost_active: false,
1015            powerslide_active: false,
1016            match_goals: None,
1017            match_assists: None,
1018            match_saves: None,
1019            match_shots: None,
1020            match_score: None,
1021        }
1022    }
1023
1024    fn sample(
1025        frame_number: usize,
1026        time: f32,
1027        boost_amount: f32,
1028        game_state: Option<i32>,
1029        ball_has_been_hit: Option<bool>,
1030        kickoff_countdown_time: Option<i32>,
1031    ) -> StatsSample {
1032        StatsSample {
1033            frame_number,
1034            time,
1035            dt: 1.0,
1036            seconds_remaining: None,
1037            game_state,
1038            ball_has_been_hit,
1039            kickoff_countdown_time,
1040            team_zero_score: Some(0),
1041            team_one_score: Some(0),
1042            possession_team_is_team_0: None,
1043            scored_on_team_is_team_0: None,
1044            current_in_game_team_player_counts: Some([1, 0]),
1045            ball: None,
1046            players: vec![player(1, boost_amount)],
1047            active_demos: Vec::new(),
1048            demo_events: Vec::new(),
1049            boost_pad_events: Vec::new(),
1050            touch_events: Vec::new(),
1051            dodge_refreshed_events: Vec::new(),
1052            player_stat_events: Vec::new(),
1053            goal_events: Vec::new(),
1054        }
1055    }
1056
1057    #[test]
1058    fn boost_levels_skip_pre_kickoff_dead_time() {
1059        let mut reducer = BoostReducer::new();
1060        let player_id = RemoteId::Steam(1);
1061        reducer.initial_respawn_awarded.insert(player_id.clone());
1062        reducer.kickoff_respawn_awarded.insert(player_id.clone());
1063
1064        reducer
1065            .on_sample(&sample(0, 0.0, BOOST_MAX_AMOUNT, None, Some(false), None))
1066            .unwrap();
1067        reducer
1068            .on_sample(&sample(1, 1.0, 0.0, None, Some(true), None))
1069            .unwrap();
1070        reducer
1071            .on_sample(&sample(2, 2.0, 0.0, None, Some(true), None))
1072            .unwrap();
1073
1074        let stats = reducer.player_stats().get(&player_id).unwrap();
1075        assert_eq!(stats.tracked_time, 2.0);
1076        assert_eq!(stats.average_boost_amount(), 0.0);
1077    }
1078
1079    #[test]
1080    fn boost_levels_still_track_when_replay_starts_live() {
1081        let mut reducer = BoostReducer::new();
1082        let player_id = RemoteId::Steam(1);
1083        reducer.initial_respawn_awarded.insert(player_id.clone());
1084
1085        reducer
1086            .on_sample(&sample(0, 0.0, 42.0, None, Some(true), None))
1087            .unwrap();
1088        reducer
1089            .on_sample(&sample(1, 1.0, 42.0, None, Some(true), None))
1090            .unwrap();
1091
1092        let stats = reducer.player_stats().get(&player_id).unwrap();
1093        assert_eq!(stats.tracked_time, 2.0);
1094        assert_eq!(stats.average_boost_amount(), 42.0);
1095    }
1096}