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