1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
4#[ts(export)]
5pub struct BoostStats {
6 pub tracked_time: f32,
7 pub boost_integral: f32,
8 pub time_zero_boost: f32,
9 pub time_hundred_boost: f32,
10 pub time_boost_0_25: f32,
11 pub time_boost_25_50: f32,
12 pub time_boost_50_75: f32,
13 pub time_boost_75_100: f32,
14 pub amount_collected: f32,
15 pub amount_collected_inactive: f32,
16 pub big_pads_collected_inactive: u32,
17 pub small_pads_collected_inactive: u32,
18 pub amount_stolen: f32,
19 pub big_pads_collected: u32,
20 pub small_pads_collected: u32,
21 pub big_pads_stolen: u32,
22 pub small_pads_stolen: u32,
23 pub amount_collected_big: f32,
24 pub amount_stolen_big: f32,
25 pub amount_collected_small: f32,
26 pub amount_stolen_small: f32,
27 pub amount_respawned: f32,
28 pub overfill_total: f32,
29 pub overfill_from_stolen: f32,
30 pub amount_used: f32,
31 pub amount_used_while_grounded: f32,
32 pub amount_used_while_airborne: f32,
33 pub amount_used_while_supersonic: f32,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum BoostIncreaseReason {
38 KickoffRespawn,
39 DemoRespawn,
40 Respawn,
41 BigPad,
42 SmallPad,
43 AmbiguousPad,
44 Unknown,
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
48#[serde(rename_all = "snake_case")]
49#[ts(export)]
50pub enum BoostPickupPadType {
51 Big,
52 Small,
53 Ambiguous,
54}
55
56impl BoostPickupPadType {
57 fn is_compatible_with(self, reported: Self) -> bool {
58 match self {
59 Self::Ambiguous => matches!(reported, Self::Big | Self::Small),
60 _ => self == reported,
61 }
62 }
63}
64
65impl From<BoostPadSize> for BoostPickupPadType {
66 fn from(pad_size: BoostPadSize) -> Self {
67 match pad_size {
68 BoostPadSize::Big => Self::Big,
69 BoostPadSize::Small => Self::Small,
70 }
71 }
72}
73
74impl TryFrom<BoostIncreaseReason> for BoostPickupPadType {
75 type Error = ();
76
77 fn try_from(reason: BoostIncreaseReason) -> Result<Self, Self::Error> {
78 match reason {
79 BoostIncreaseReason::BigPad => Ok(Self::Big),
80 BoostIncreaseReason::SmallPad => Ok(Self::Small),
81 BoostIncreaseReason::AmbiguousPad => Ok(Self::Ambiguous),
82 BoostIncreaseReason::KickoffRespawn
83 | BoostIncreaseReason::DemoRespawn
84 | BoostIncreaseReason::Respawn
85 | BoostIncreaseReason::Unknown => Err(()),
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
91#[serde(rename_all = "snake_case")]
92#[ts(export)]
93pub enum BoostPickupFieldHalf {
94 Own,
95 Opponent,
96 Unknown,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
100#[serde(rename_all = "snake_case")]
101#[ts(export)]
102pub enum BoostPickupActivity {
103 Active,
104 Inactive,
105 Unknown,
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
109#[serde(rename_all = "snake_case")]
110#[ts(export)]
111pub enum BoostPickupComparison {
112 Both,
113 Ghost,
114 Missed,
115}
116
117#[derive(Clone, Debug)]
118struct PendingBoostPickupEvent {
119 frame: usize,
120 time: f32,
121 player_id: PlayerId,
122 is_team_0: bool,
123 pad_type: BoostPickupPadType,
124 field_half: BoostPickupFieldHalf,
125 activity: BoostPickupActivity,
126 boost_before: Option<f32>,
127 boost_after: Option<f32>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
131#[ts(export)]
132pub struct BoostPickupComparisonEvent {
133 pub comparison: BoostPickupComparison,
134 pub frame: usize,
135 pub time: f32,
136 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
137 pub player_id: PlayerId,
138 pub is_team_0: bool,
139 pub pad_type: BoostPickupPadType,
140 pub field_half: BoostPickupFieldHalf,
141 pub activity: BoostPickupActivity,
142 pub reported_frame: Option<usize>,
143 pub reported_time: Option<f32>,
144 pub inferred_frame: Option<usize>,
145 pub inferred_time: Option<f32>,
146 pub boost_before: Option<f32>,
147 pub boost_after: Option<f32>,
148}
149
150impl BoostStats {
151 pub fn average_boost_amount(&self) -> f32 {
152 if self.tracked_time == 0.0 {
153 0.0
154 } else {
155 self.boost_integral / self.tracked_time
156 }
157 }
158
159 pub fn bpm(&self) -> f32 {
160 if self.tracked_time == 0.0 {
161 0.0
162 } else {
163 self.amount_collected * 60.0 / self.tracked_time
164 }
165 }
166
167 fn pct(&self, value: f32) -> f32 {
168 if self.tracked_time == 0.0 {
169 0.0
170 } else {
171 value * 100.0 / self.tracked_time
172 }
173 }
174
175 pub fn zero_boost_pct(&self) -> f32 {
176 self.pct(self.time_zero_boost)
177 }
178
179 pub fn hundred_boost_pct(&self) -> f32 {
180 self.pct(self.time_hundred_boost)
181 }
182
183 pub fn boost_0_25_pct(&self) -> f32 {
184 self.pct(self.time_boost_0_25)
185 }
186
187 pub fn boost_25_50_pct(&self) -> f32 {
188 self.pct(self.time_boost_25_50)
189 }
190
191 pub fn boost_50_75_pct(&self) -> f32 {
192 self.pct(self.time_boost_50_75)
193 }
194
195 pub fn boost_75_100_pct(&self) -> f32 {
196 self.pct(self.time_boost_75_100)
197 }
198
199 pub fn amount_obtained(&self) -> f32 {
200 self.amount_collected_big + self.amount_collected_small + self.amount_respawned
201 }
202
203 pub fn amount_used_by_vertical_band(&self) -> f32 {
204 self.amount_used_while_grounded + self.amount_used_while_airborne
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
209pub struct BoostCalculatorConfig {
210 pub include_non_live_pickups: bool,
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct BoostCalculator {
215 config: BoostCalculatorConfig,
216 player_stats: HashMap<PlayerId, BoostStats>,
217 team_zero_stats: BoostStats,
218 team_one_stats: BoostStats,
219 previous_boost_amounts: HashMap<PlayerId, f32>,
220 previous_player_speeds: HashMap<PlayerId, f32>,
221 observed_pad_positions: HashMap<String, PadPositionEstimate>,
222 known_pad_sizes: HashMap<String, BoostPadSize>,
223 known_pad_indices: HashMap<String, usize>,
224 unavailable_pads: HashSet<String>,
225 seen_pickup_sequence_times: HashMap<(String, u8), f32>,
226 pickup_frames: HashMap<(String, PlayerId), usize>,
227 inactive_pickup_frames: HashSet<(PlayerId, usize, BoostPadSize)>,
228 last_pickup_times: HashMap<String, f32>,
229 pending_inferred_pickups: VecDeque<PendingBoostPickupEvent>,
230 pickup_comparison_events: Vec<BoostPickupComparisonEvent>,
231 kickoff_phase_active_last_frame: bool,
232 kickoff_respawn_awarded: HashSet<PlayerId>,
233 initial_respawn_awarded: HashSet<PlayerId>,
234 pending_demo_respawns: HashSet<PlayerId>,
235 previous_boost_levels_live: Option<bool>,
236 active_invariant_warnings: HashSet<BoostInvariantWarningKey>,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Hash)]
240struct BoostInvariantWarningKey {
241 scope: String,
242 kind: BoostInvariantKind,
243}
244
245#[derive(Debug, Clone)]
246struct PendingBoostPickup {
247 player_id: PlayerId,
248 is_team_0: bool,
249 previous_boost_amount: f32,
250 pre_applied_collected_amount: f32,
251 pre_applied_pad_size: Option<BoostPadSize>,
252 player_position: glam::Vec3,
253}
254
255impl BoostCalculator {
256 const PICKUP_MATCH_FRAME_WINDOW: usize = 3;
257
258 pub fn new() -> Self {
259 Self::with_config(BoostCalculatorConfig::default())
260 }
261
262 pub fn with_config(config: BoostCalculatorConfig) -> Self {
263 Self {
264 config,
265 ..Self::default()
266 }
267 }
268
269 pub fn player_stats(&self) -> &HashMap<PlayerId, BoostStats> {
270 &self.player_stats
271 }
272
273 pub fn team_zero_stats(&self) -> &BoostStats {
274 &self.team_zero_stats
275 }
276
277 pub fn team_one_stats(&self) -> &BoostStats {
278 &self.team_one_stats
279 }
280
281 pub fn pickup_comparison_events(&self) -> &[BoostPickupComparisonEvent] {
282 &self.pickup_comparison_events
283 }
284
285 fn estimated_pad_position(&self, pad_id: &str) -> Option<glam::Vec3> {
286 self.observed_pad_positions
287 .get(pad_id)
288 .and_then(PadPositionEstimate::mean)
289 }
290
291 fn observed_pad_positions(&self, pad_id: &str) -> &[glam::Vec3] {
292 self.observed_pad_positions
293 .get(pad_id)
294 .map(PadPositionEstimate::observations)
295 .unwrap_or(&[])
296 }
297
298 fn pad_match_radius(pad_size: BoostPadSize) -> f32 {
299 match pad_size {
300 BoostPadSize::Big => STANDARD_PAD_MATCH_RADIUS_BIG,
301 BoostPadSize::Small => STANDARD_PAD_MATCH_RADIUS_SMALL,
302 }
303 }
304
305 pub fn resolved_boost_pads(&self) -> Vec<ResolvedBoostPad> {
306 standard_soccar_boost_pad_layout()
307 .iter()
308 .enumerate()
309 .map(|(index, (position, size))| ResolvedBoostPad {
310 index,
311 pad_id: self
312 .known_pad_indices
313 .iter()
314 .find_map(|(pad_id, pad_index)| (*pad_index == index).then(|| pad_id.clone())),
315 size: *size,
316 position: glam_to_vec(position),
317 })
318 .collect()
319 }
320
321 fn infer_pad_index(
322 &self,
323 pad_id: &str,
324 pad_size: BoostPadSize,
325 observed_position: glam::Vec3,
326 ) -> Option<usize> {
327 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
328 return Some(index);
329 }
330
331 let observed_position = self
332 .estimated_pad_position(pad_id)
333 .unwrap_or(observed_position);
334 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
335 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
336 let radius = Self::pad_match_radius(pad_size);
337 let observed_positions = self.observed_pad_positions(pad_id);
338 let best_candidate = |allow_used: bool| {
339 layout
340 .iter()
341 .enumerate()
342 .filter(|(index, (_, size))| {
343 *size == pad_size && (allow_used || !used_indices.contains(index))
344 })
345 .filter_map(|(index, (candidate_position, _))| {
346 let mut vote_count = 0usize;
347 let mut total_vote_distance = 0.0f32;
348 let mut best_vote_distance = f32::INFINITY;
349
350 for position in observed_positions {
351 let distance = position.distance(*candidate_position);
352 if distance <= radius {
353 vote_count += 1;
354 total_vote_distance += distance;
355 best_vote_distance = best_vote_distance.min(distance);
356 }
357 }
358
359 if vote_count == 0 {
360 return None;
361 }
362
363 let representative_distance = observed_position.distance(*candidate_position);
364 Some((
365 index,
366 vote_count,
367 total_vote_distance / vote_count as f32,
368 best_vote_distance,
369 representative_distance,
370 ))
371 })
372 .max_by(|left, right| {
373 left.1
374 .cmp(&right.1)
375 .then_with(|| right.2.partial_cmp(&left.2).unwrap())
376 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
377 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
378 })
379 .map(|(index, _, _, _, _)| index)
380 };
381
382 best_candidate(false)
383 .or_else(|| best_candidate(true))
384 .or_else(|| {
385 layout
386 .iter()
387 .enumerate()
388 .filter(|(index, (_, size))| *size == pad_size && !used_indices.contains(index))
389 .min_by(|(_, (a, _)), (_, (b, _))| {
390 observed_position
391 .distance_squared(*a)
392 .partial_cmp(&observed_position.distance_squared(*b))
393 .unwrap()
394 })
395 .map(|(index, _)| index)
396 })
397 .or_else(|| {
398 layout
399 .iter()
400 .enumerate()
401 .filter(|(_, (_, size))| *size == pad_size)
402 .min_by(|(_, (a, _)), (_, (b, _))| {
403 observed_position
404 .distance_squared(*a)
405 .partial_cmp(&observed_position.distance_squared(*b))
406 .unwrap()
407 })
408 .map(|(index, _)| index)
409 })
410 .filter(|index| {
411 observed_position.distance(standard_soccar_boost_pad_position(*index)) <= radius
412 })
413 }
414
415 fn infer_pad_details_from_position(
416 &self,
417 pad_id: &str,
418 observed_position: glam::Vec3,
419 ) -> Option<(usize, BoostPadSize)> {
420 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
421 let (_, size) = standard_soccar_boost_pad_layout().get(index)?;
422 return Some((index, *size));
423 }
424
425 let observed_position = self
426 .estimated_pad_position(pad_id)
427 .unwrap_or(observed_position);
428 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
429 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
430 let observed_positions = self.observed_pad_positions(pad_id);
431 let best_candidate = |allow_used: bool| {
432 layout
433 .iter()
434 .enumerate()
435 .filter(|(index, _)| allow_used || !used_indices.contains(index))
436 .filter_map(|(index, (candidate_position, size))| {
437 let radius = Self::pad_match_radius(*size);
438 let mut vote_count = 0usize;
439 let mut total_vote_distance = 0.0f32;
440 let mut best_vote_distance = f32::INFINITY;
441
442 for position in observed_positions {
443 let distance = position.distance(*candidate_position);
444 if distance <= radius {
445 vote_count += 1;
446 total_vote_distance += distance;
447 best_vote_distance = best_vote_distance.min(distance);
448 }
449 }
450
451 if vote_count == 0 {
452 return None;
453 }
454
455 let representative_distance = observed_position.distance(*candidate_position);
456 Some((
457 index,
458 *size,
459 vote_count,
460 total_vote_distance / vote_count as f32,
461 best_vote_distance,
462 representative_distance,
463 ))
464 })
465 .max_by(|left, right| {
466 left.2
467 .cmp(&right.2)
468 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
469 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
470 .then_with(|| right.5.partial_cmp(&left.5).unwrap())
471 })
472 .map(|(index, size, _, _, _, _)| (index, size))
473 };
474
475 best_candidate(false).or_else(|| best_candidate(true))
476 }
477
478 fn guess_pad_size_from_position(
479 &self,
480 pad_id: &str,
481 observed_position: glam::Vec3,
482 ) -> Option<BoostPadSize> {
483 if let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied() {
484 return Some(pad_size);
485 }
486
487 if let Some((_, pad_size)) = self.infer_pad_details_from_position(pad_id, observed_position)
488 {
489 return Some(pad_size);
490 }
491
492 let observed_position = self
493 .estimated_pad_position(pad_id)
494 .unwrap_or(observed_position);
495 standard_soccar_boost_pad_layout()
496 .iter()
497 .min_by(|(left_position, _), (right_position, _)| {
498 observed_position
499 .distance_squared(*left_position)
500 .partial_cmp(&observed_position.distance_squared(*right_position))
501 .unwrap()
502 })
503 .map(|(_, pad_size)| *pad_size)
504 }
505
506 fn resolve_pickup(
507 &mut self,
508 pad_id: &str,
509 pending_pickup: PendingBoostPickup,
510 pad_size: BoostPadSize,
511 ) -> BoostPickupFieldHalf {
512 let observed_position = self
513 .estimated_pad_position(pad_id)
514 .unwrap_or(pending_pickup.player_position);
515 let pad_position = self
516 .infer_pad_index(pad_id, pad_size, observed_position)
517 .map(|index| {
518 self.known_pad_indices.insert(pad_id.to_string(), index);
519 standard_soccar_boost_pad_position(index)
520 })
521 .unwrap_or(observed_position);
522 let stolen = is_enemy_side(pending_pickup.is_team_0, pad_position);
523 let stats = self
524 .player_stats
525 .entry(pending_pickup.player_id.clone())
526 .or_default();
527 let team_stats = if pending_pickup.is_team_0 {
528 &mut self.team_zero_stats
529 } else {
530 &mut self.team_one_stats
531 };
532 let nominal_gain = match pad_size {
533 BoostPadSize::Big => BOOST_MAX_AMOUNT,
534 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
535 };
536 let collected_amount = (BOOST_MAX_AMOUNT - pending_pickup.previous_boost_amount)
537 .min(nominal_gain)
538 .max(pending_pickup.pre_applied_collected_amount);
539 let collected_amount_delta = collected_amount - pending_pickup.pre_applied_collected_amount;
540 let overfill = (nominal_gain - collected_amount).max(0.0);
541
542 stats.amount_collected += collected_amount_delta;
543 team_stats.amount_collected += collected_amount_delta;
544
545 match pending_pickup.pre_applied_pad_size {
546 Some(pre_applied_pad_size) if pre_applied_pad_size == pad_size => {
547 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount_delta);
548 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount_delta);
549 }
550 Some(pre_applied_pad_size) => {
551 Self::apply_collected_bucket_amount(
552 stats,
553 pre_applied_pad_size,
554 -pending_pickup.pre_applied_collected_amount,
555 );
556 Self::apply_collected_bucket_amount(
557 team_stats,
558 pre_applied_pad_size,
559 -pending_pickup.pre_applied_collected_amount,
560 );
561 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
562 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
563 }
564 None => {
565 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
566 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
567 }
568 }
569
570 if stolen {
571 stats.amount_stolen += collected_amount;
572 team_stats.amount_stolen += collected_amount;
573 }
574
575 match pad_size {
576 BoostPadSize::Big => {
577 stats.big_pads_collected += 1;
578 team_stats.big_pads_collected += 1;
579 if stolen {
580 stats.big_pads_stolen += 1;
581 team_stats.big_pads_stolen += 1;
582 stats.amount_stolen_big += collected_amount;
583 team_stats.amount_stolen_big += collected_amount;
584 }
585 }
586 BoostPadSize::Small => {
587 stats.small_pads_collected += 1;
588 team_stats.small_pads_collected += 1;
589 if stolen {
590 stats.small_pads_stolen += 1;
591 team_stats.small_pads_stolen += 1;
592 stats.amount_stolen_small += collected_amount;
593 team_stats.amount_stolen_small += collected_amount;
594 }
595 }
596 }
597
598 stats.overfill_total += overfill;
599 team_stats.overfill_total += overfill;
600 if stolen {
601 stats.overfill_from_stolen += overfill;
602 team_stats.overfill_from_stolen += overfill;
603 }
604
605 if stolen {
606 BoostPickupFieldHalf::Opponent
607 } else {
608 BoostPickupFieldHalf::Own
609 }
610 }
611
612 fn apply_collected_bucket_amount(stats: &mut BoostStats, pad_size: BoostPadSize, amount: f32) {
613 if amount == 0.0 {
614 return;
615 }
616
617 match pad_size {
618 BoostPadSize::Big => stats.amount_collected_big += amount,
619 BoostPadSize::Small => stats.amount_collected_small += amount,
620 }
621 }
622
623 fn apply_pickup_collected_amount(
624 &mut self,
625 player_id: &PlayerId,
626 is_team_0: bool,
627 amount: f32,
628 pad_size: Option<BoostPadSize>,
629 ) {
630 if amount <= 0.0 {
631 return;
632 }
633
634 let stats = self.player_stats.entry(player_id.clone()).or_default();
635 let team_stats = if is_team_0 {
636 &mut self.team_zero_stats
637 } else {
638 &mut self.team_one_stats
639 };
640 stats.amount_collected += amount;
641 team_stats.amount_collected += amount;
642 if let Some(pad_size) = pad_size {
643 Self::apply_collected_bucket_amount(stats, pad_size, amount);
644 Self::apply_collected_bucket_amount(team_stats, pad_size, amount);
645 }
646 }
647
648 fn apply_inactive_pickup(
649 &mut self,
650 player_id: &PlayerId,
651 is_team_0: bool,
652 amount: f32,
653 pad_size: BoostPadSize,
654 ) {
655 let stats = self.player_stats.entry(player_id.clone()).or_default();
656 let team_stats = if is_team_0 {
657 &mut self.team_zero_stats
658 } else {
659 &mut self.team_one_stats
660 };
661 stats.amount_collected_inactive += amount;
662 team_stats.amount_collected_inactive += amount;
663 match pad_size {
664 BoostPadSize::Big => {
665 stats.big_pads_collected_inactive += 1;
666 team_stats.big_pads_collected_inactive += 1;
667 }
668 BoostPadSize::Small => {
669 stats.small_pads_collected_inactive += 1;
670 team_stats.small_pads_collected_inactive += 1;
671 }
672 }
673 }
674
675 fn apply_respawn_amount(&mut self, player_id: &PlayerId, is_team_0: bool, amount: f32) {
676 if amount <= 0.0 {
677 return;
678 }
679
680 let stats = self.player_stats.entry(player_id.clone()).or_default();
681 let team_stats = if is_team_0 {
682 &mut self.team_zero_stats
683 } else {
684 &mut self.team_one_stats
685 };
686 stats.amount_respawned += amount;
687 team_stats.amount_respawned += amount;
688 }
689
690 fn warn_for_boost_invariant_violations(
691 &mut self,
692 scope: &str,
693 frame_number: usize,
694 time: f32,
695 stats: &BoostStats,
696 observed_boost_amount: Option<f32>,
697 ) {
698 let violations = boost_invariant_violations(stats, observed_boost_amount);
699 let active_kinds: HashSet<BoostInvariantKind> =
700 violations.iter().map(|violation| violation.kind).collect();
701
702 for violation in violations {
703 let key = BoostInvariantWarningKey {
704 scope: scope.to_string(),
705 kind: violation.kind,
706 };
707 if self.active_invariant_warnings.insert(key) {
708 log::warn!(
709 "Boost invariant violation for {} at frame {} (t={:.3}): {}",
710 scope,
711 frame_number,
712 time,
713 violation.message(),
714 );
715 }
716 }
717
718 for kind in BoostInvariantKind::ALL {
719 if active_kinds.contains(&kind) {
720 continue;
721 }
722 self.active_invariant_warnings
723 .remove(&BoostInvariantWarningKey {
724 scope: scope.to_string(),
725 kind,
726 });
727 }
728 }
729
730 fn warn_for_sample_boost_invariants(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
731 let team_zero_stats = self.team_zero_stats.clone();
732 let team_one_stats = self.team_one_stats.clone();
733 let player_scopes: Vec<(PlayerId, Option<f32>, BoostStats)> = players
734 .players
735 .iter()
736 .map(|player| {
737 (
738 player.player_id.clone(),
739 player.boost_amount,
740 self.player_stats
741 .get(&player.player_id)
742 .cloned()
743 .unwrap_or_default(),
744 )
745 })
746 .collect();
747
748 self.warn_for_boost_invariant_violations(
749 "team_zero",
750 frame.frame_number,
751 frame.time,
752 &team_zero_stats,
753 None,
754 );
755 self.warn_for_boost_invariant_violations(
756 "team_one",
757 frame.frame_number,
758 frame.time,
759 &team_one_stats,
760 None,
761 );
762 for (player_id, observed_boost_amount, stats) in player_scopes {
763 self.warn_for_boost_invariant_violations(
764 &format!("player {player_id:?}"),
765 frame.frame_number,
766 frame.time,
767 &stats,
768 observed_boost_amount,
769 );
770 }
771 }
772
773 fn interval_fraction_in_boost_range(
774 start_boost: f32,
775 end_boost: f32,
776 min_boost: f32,
777 max_boost: f32,
778 ) -> f32 {
779 if (end_boost - start_boost).abs() <= f32::EPSILON {
780 return ((start_boost >= min_boost) && (start_boost < max_boost)) as i32 as f32;
781 }
782
783 let t_at_min = (min_boost - start_boost) / (end_boost - start_boost);
784 let t_at_max = (max_boost - start_boost) / (end_boost - start_boost);
785 let interval_start = t_at_min.min(t_at_max).max(0.0);
786 let interval_end = t_at_min.max(t_at_max).min(1.0);
787 (interval_end - interval_start).max(0.0)
788 }
789
790 fn pad_respawn_time_seconds(pad_size: BoostPadSize) -> f32 {
791 match pad_size {
792 BoostPadSize::Big => 10.0,
793 BoostPadSize::Small => 4.0,
794 }
795 }
796
797 fn seen_pickup_sequence_is_recent(
798 &self,
799 pad_id: &str,
800 sequence: u8,
801 event_time: f32,
802 player_position: Option<glam::Vec3>,
803 ) -> bool {
804 let Some(last_time) = self
805 .seen_pickup_sequence_times
806 .get(&(pad_id.to_string(), sequence))
807 .copied()
808 else {
809 return false;
810 };
811 let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
812 player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
813 }) else {
814 return false;
815 };
816 event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
817 }
818
819 fn unavailable_pad_is_recent(
820 &self,
821 pad_id: &str,
822 event_time: f32,
823 player_position: Option<glam::Vec3>,
824 ) -> bool {
825 if !self.unavailable_pads.contains(pad_id) {
826 return false;
827 }
828 let Some(last_time) = self.last_pickup_times.get(pad_id).copied() else {
829 return true;
830 };
831 let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
832 player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
833 }) else {
834 return true;
835 };
836 event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
837 }
838
839 fn boost_levels_live(live_play: bool) -> bool {
840 live_play
841 }
842
843 fn tracks_boost_levels(boost_levels_live: bool) -> bool {
844 boost_levels_live
845 }
846
847 fn tracks_boost_pickups(gameplay: &GameplayState, live_play: bool) -> bool {
848 live_play
849 || (gameplay.ball_has_been_hit == Some(false)
850 && gameplay.game_state != Some(GAME_STATE_KICKOFF_COUNTDOWN)
851 && gameplay.kickoff_countdown_time.is_none_or(|t| t <= 0))
852 }
853
854 fn activity_label(active: bool) -> BoostPickupActivity {
855 if active {
856 BoostPickupActivity::Active
857 } else {
858 BoostPickupActivity::Inactive
859 }
860 }
861
862 fn field_half_from_position(
863 is_team_0: bool,
864 position: Option<glam::Vec3>,
865 ) -> BoostPickupFieldHalf {
866 match position {
867 Some(position) if is_enemy_side(is_team_0, position) => BoostPickupFieldHalf::Opponent,
868 Some(_) => BoostPickupFieldHalf::Own,
869 None => BoostPickupFieldHalf::Unknown,
870 }
871 }
872
873 fn classify_boost_increase_reasons(
874 previous_boost: f32,
875 boost: f32,
876 kickoff_phase_active: bool,
877 demo_respawn_supported: bool,
878 ) -> Vec<BoostIncreaseReason> {
879 const TOLERANCE: f32 = 1.0;
880 let delta = boost - previous_boost;
881 if delta <= TOLERANCE {
882 return vec![BoostIncreaseReason::Unknown];
883 }
884
885 let is_respawn_value = (boost - BOOST_KICKOFF_START_AMOUNT).abs() <= TOLERANCE;
886 if demo_respawn_supported && is_respawn_value {
887 return vec![BoostIncreaseReason::DemoRespawn];
888 }
889 if kickoff_phase_active && is_respawn_value {
890 return vec![BoostIncreaseReason::KickoffRespawn];
891 }
892 if is_respawn_value {
893 return vec![BoostIncreaseReason::Respawn];
894 }
895
896 let small_pad_floor = SMALL_PAD_AMOUNT_RAW - 3.0;
897 let big_pad_floor = SMALL_PAD_AMOUNT_RAW + 5.0;
898 if boost < BOOST_FULL_BAND_MIN_RAW && delta >= small_pad_floor {
899 const SMALL_PICKUP_COUNT_TOLERANCE: f32 = 3.0;
900 let inferred_small_pickups = ((delta - SMALL_PICKUP_COUNT_TOLERANCE)
901 / SMALL_PAD_AMOUNT_RAW)
902 .ceil()
903 .max(1.0) as usize;
904 return vec![BoostIncreaseReason::SmallPad; inferred_small_pickups];
905 }
906
907 if delta > big_pad_floor {
908 return vec![BoostIncreaseReason::BigPad];
909 }
910 if boost >= BOOST_MAX_AMOUNT - TOLERANCE {
911 return vec![BoostIncreaseReason::AmbiguousPad];
912 }
913 if delta >= small_pad_floor {
914 return vec![BoostIncreaseReason::SmallPad];
915 }
916 vec![BoostIncreaseReason::Unknown]
917 }
918
919 fn emit_pickup_comparison_event(
920 &mut self,
921 comparison: BoostPickupComparison,
922 inferred: Option<PendingBoostPickupEvent>,
923 reported: Option<PendingBoostPickupEvent>,
924 ) {
925 let reference = inferred.as_ref().or(reported.as_ref()).unwrap();
926 let pad_type = reported
927 .as_ref()
928 .map(|event| event.pad_type)
929 .or_else(|| inferred.as_ref().map(|event| event.pad_type))
930 .unwrap_or(reference.pad_type);
931 let field_half = reported
932 .as_ref()
933 .map(|event| event.field_half)
934 .or_else(|| inferred.as_ref().map(|event| event.field_half))
935 .unwrap_or(reference.field_half);
936 let activity = reported
937 .as_ref()
938 .map(|event| event.activity)
939 .or_else(|| inferred.as_ref().map(|event| event.activity))
940 .unwrap_or(reference.activity);
941 let event_frame = inferred
942 .as_ref()
943 .map(|event| event.frame)
944 .or_else(|| reported.as_ref().map(|event| event.frame))
945 .unwrap_or(reference.frame);
946 let event_time = inferred
947 .as_ref()
948 .map(|event| event.time)
949 .or_else(|| reported.as_ref().map(|event| event.time))
950 .unwrap_or(reference.time);
951 let comparison_event = BoostPickupComparisonEvent {
952 comparison,
953 frame: event_frame,
954 time: event_time,
955 player_id: reference.player_id.clone(),
956 is_team_0: reference.is_team_0,
957 pad_type,
958 field_half,
959 activity,
960 reported_frame: reported.as_ref().map(|event| event.frame),
961 reported_time: reported.as_ref().map(|event| event.time),
962 inferred_frame: inferred.as_ref().map(|event| event.frame),
963 inferred_time: inferred.as_ref().map(|event| event.time),
964 boost_before: inferred.as_ref().and_then(|event| event.boost_before),
965 boost_after: inferred.as_ref().and_then(|event| event.boost_after),
966 };
967 self.pickup_comparison_events.push(comparison_event);
968 }
969
970 fn matching_pending_pickup_index(
971 pending: &VecDeque<PendingBoostPickupEvent>,
972 event: &PendingBoostPickupEvent,
973 pending_is_inferred: bool,
974 ) -> Option<usize> {
975 pending
976 .iter()
977 .enumerate()
978 .filter(|(_, pending_event)| {
979 pending_event.player_id == event.player_id
980 && if pending_is_inferred {
981 pending_event.pad_type.is_compatible_with(event.pad_type)
982 } else {
983 event.pad_type.is_compatible_with(pending_event.pad_type)
984 }
985 && pending_event.frame.abs_diff(event.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
986 })
987 .min_by_key(|(_, pending_event)| pending_event.frame.abs_diff(event.frame))
988 .map(|(index, _)| index)
989 }
990
991 fn record_inferred_pickup(&mut self, event: PendingBoostPickupEvent) {
992 self.pending_inferred_pickups.push_back(event);
993 }
994
995 fn record_reported_pickup(&mut self, event: PendingBoostPickupEvent) {
996 if let Some(index) =
997 Self::matching_pending_pickup_index(&self.pending_inferred_pickups, &event, true)
998 {
999 let inferred = self
1000 .pending_inferred_pickups
1001 .remove(index)
1002 .expect("matched inferred pickup index should exist");
1003 self.emit_pickup_comparison_event(
1004 BoostPickupComparison::Both,
1005 Some(inferred),
1006 Some(event),
1007 );
1008 } else {
1009 self.emit_pickup_comparison_event(BoostPickupComparison::Both, None, Some(event));
1010 }
1011 }
1012
1013 fn flush_stale_pickup_comparisons(&mut self, current_frame: usize) {
1014 while self
1015 .pending_inferred_pickups
1016 .front()
1017 .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < current_frame)
1018 {
1019 self.pending_inferred_pickups.pop_front();
1020 }
1021 }
1022
1023 pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
1024 self.pending_inferred_pickups.clear();
1025 Ok(())
1026 }
1027
1028 fn inactive_pickup_stats(
1029 &self,
1030 player: &PlayerSample,
1031 pad_id: &str,
1032 previous_boost_amount: f32,
1033 respawn_amount: f32,
1034 ) -> Option<(f32, BoostPadSize)> {
1035 let pad_size = self
1036 .known_pad_sizes
1037 .get(pad_id)
1038 .copied()
1039 .or_else(|| self.guess_pad_size_from_position(pad_id, player.position()?))?;
1040 let nominal_gain = match pad_size {
1041 BoostPadSize::Big => BOOST_MAX_AMOUNT,
1042 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
1043 };
1044 let capacity_limited_gain = (BOOST_MAX_AMOUNT - previous_boost_amount)
1045 .min(nominal_gain)
1046 .max(0.0);
1047 let observed_gain = player
1048 .boost_amount
1049 .map(|boost_amount| (boost_amount - previous_boost_amount - respawn_amount).max(0.0))
1050 .unwrap_or(0.0);
1051 if observed_gain <= 1.0 {
1052 return None;
1053 }
1054 Some((
1055 capacity_limited_gain.max(observed_gain).min(nominal_gain),
1056 pad_size,
1057 ))
1058 }
1059
1060 pub fn update_parts(
1061 &mut self,
1062 frame: &FrameInfo,
1063 gameplay: &GameplayState,
1064 players: &PlayerFrameState,
1065 events: &FrameEventsState,
1066 vertical_state: &PlayerVerticalState,
1067 live_play: bool,
1068 ) -> SubtrActorResult<()> {
1069 let boost_levels_live = Self::boost_levels_live(live_play);
1070 let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
1071 let track_boost_pickups = Self::tracks_boost_pickups(gameplay, live_play);
1072 let boost_levels_resumed_this_sample =
1073 boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
1074 let kickoff_phase_active = gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
1075 || gameplay.kickoff_countdown_time.is_some_and(|t| t > 0)
1076 || gameplay.ball_has_been_hit == Some(false);
1077 let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
1078 if kickoff_phase_started {
1079 self.kickoff_respawn_awarded.clear();
1080 }
1081 for demo in &events.demo_events {
1082 self.pending_demo_respawns.insert(demo.victim.clone());
1083 }
1084
1085 let mut current_boost_amounts = Vec::new();
1086 let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
1087 let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
1088
1089 for event in &events.boost_pad_events {
1090 let BoostPadEventKind::PickedUp { .. } = event.kind else {
1091 continue;
1092 };
1093 let Some(player_id) = &event.player else {
1094 continue;
1095 };
1096 *pickup_counts_by_player
1097 .entry(player_id.clone())
1098 .or_default() += 1;
1099 }
1100
1101 for player in &players.players {
1102 let Some(boost_amount) = player.boost_amount else {
1103 continue;
1104 };
1105 let previous_sample_boost_amount =
1106 self.previous_boost_amounts.get(&player.player_id).copied();
1107 let previous_boost_amount = player
1108 .last_boost_amount
1109 .unwrap_or_else(|| previous_sample_boost_amount.unwrap_or(boost_amount));
1110 let previous_boost_amount = if boost_levels_resumed_this_sample {
1111 boost_amount
1112 } else {
1113 previous_boost_amount
1114 };
1115 let demo_respawn_supported = self.pending_demo_respawns.contains(&player.player_id)
1116 && player.rigid_body.is_some();
1117 if let Some(previous_sample_boost_amount) = previous_sample_boost_amount {
1118 let reasons = Self::classify_boost_increase_reasons(
1119 previous_sample_boost_amount,
1120 boost_amount,
1121 kickoff_phase_active,
1122 demo_respawn_supported,
1123 );
1124 for reason in reasons {
1125 if let Ok(pad_type) = BoostPickupPadType::try_from(reason) {
1126 self.record_inferred_pickup(PendingBoostPickupEvent {
1127 frame: frame.frame_number,
1128 time: frame.time,
1129 player_id: player.player_id.clone(),
1130 is_team_0: player.is_team_0,
1131 pad_type,
1132 field_half: Self::field_half_from_position(
1133 player.is_team_0,
1134 player.position(),
1135 ),
1136 activity: Self::activity_label(live_play),
1137 boost_before: Some(previous_sample_boost_amount),
1138 boost_after: Some(boost_amount),
1139 });
1140 }
1141 }
1142 }
1143 if track_boost_levels {
1144 let average_boost_amount = (previous_boost_amount + boost_amount) * 0.5;
1145 let time_zero_boost = frame.dt
1146 * Self::interval_fraction_in_boost_range(
1147 previous_boost_amount,
1148 boost_amount,
1149 0.0,
1150 BOOST_ZERO_BAND_RAW,
1151 );
1152 let time_hundred_boost = frame.dt
1153 * Self::interval_fraction_in_boost_range(
1154 previous_boost_amount,
1155 boost_amount,
1156 BOOST_FULL_BAND_MIN_RAW,
1157 BOOST_MAX_AMOUNT + 1.0,
1158 );
1159 let time_boost_0_25 = frame.dt
1160 * Self::interval_fraction_in_boost_range(
1161 previous_boost_amount,
1162 boost_amount,
1163 0.0,
1164 boost_percent_to_amount(25.0),
1165 );
1166 let time_boost_25_50 = frame.dt
1167 * Self::interval_fraction_in_boost_range(
1168 previous_boost_amount,
1169 boost_amount,
1170 boost_percent_to_amount(25.0),
1171 boost_percent_to_amount(50.0),
1172 );
1173 let time_boost_50_75 = frame.dt
1174 * Self::interval_fraction_in_boost_range(
1175 previous_boost_amount,
1176 boost_amount,
1177 boost_percent_to_amount(50.0),
1178 boost_percent_to_amount(75.0),
1179 );
1180 let time_boost_75_100 = frame.dt
1181 * Self::interval_fraction_in_boost_range(
1182 previous_boost_amount,
1183 boost_amount,
1184 boost_percent_to_amount(75.0),
1185 BOOST_MAX_AMOUNT + 1.0,
1186 );
1187 let stats = self
1188 .player_stats
1189 .entry(player.player_id.clone())
1190 .or_default();
1191 let team_stats = if player.is_team_0 {
1192 &mut self.team_zero_stats
1193 } else {
1194 &mut self.team_one_stats
1195 };
1196
1197 stats.tracked_time += frame.dt;
1198 stats.boost_integral += average_boost_amount * frame.dt;
1199 team_stats.tracked_time += frame.dt;
1200 team_stats.boost_integral += average_boost_amount * frame.dt;
1201 stats.time_zero_boost += time_zero_boost;
1202 team_stats.time_zero_boost += time_zero_boost;
1203 stats.time_hundred_boost += time_hundred_boost;
1204 team_stats.time_hundred_boost += time_hundred_boost;
1205 stats.time_boost_0_25 += time_boost_0_25;
1206 team_stats.time_boost_0_25 += time_boost_0_25;
1207 stats.time_boost_25_50 += time_boost_25_50;
1208 team_stats.time_boost_25_50 += time_boost_25_50;
1209 stats.time_boost_50_75 += time_boost_50_75;
1210 team_stats.time_boost_50_75 += time_boost_50_75;
1211 stats.time_boost_75_100 += time_boost_75_100;
1212 team_stats.time_boost_75_100 += time_boost_75_100;
1213 }
1214
1215 let mut respawn_amount = 0.0;
1216 let first_seen_player = self
1220 .initial_respawn_awarded
1221 .insert(player.player_id.clone());
1222 if first_seen_player
1223 || (kickoff_phase_active
1224 && !self.kickoff_respawn_awarded.contains(&player.player_id))
1225 {
1226 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1227 self.kickoff_respawn_awarded
1228 .insert(player.player_id.clone());
1229 }
1230 if demo_respawn_supported {
1231 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1232 self.pending_demo_respawns.remove(&player.player_id);
1233 }
1234 if respawn_amount > 0.0 {
1235 self.apply_respawn_amount(&player.player_id, player.is_team_0, respawn_amount);
1236 }
1237 respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
1238
1239 current_boost_amounts.push((player.player_id.clone(), boost_amount));
1240 }
1241
1242 for event in &events.boost_pad_events {
1243 match event.kind {
1244 BoostPadEventKind::PickedUp { sequence } => {
1245 if !track_boost_pickups && !self.config.include_non_live_pickups {
1246 let Some(player_id) = &event.player else {
1247 continue;
1248 };
1249 let Some(player) = players
1250 .players
1251 .iter()
1252 .find(|player| &player.player_id == player_id)
1253 else {
1254 continue;
1255 };
1256 let previous_boost_amount = self
1257 .previous_boost_amounts
1258 .get(player_id)
1259 .copied()
1260 .or(player.last_boost_amount)
1261 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1262 let respawn_amount = respawn_amounts_by_player
1263 .get(player_id)
1264 .copied()
1265 .unwrap_or(0.0);
1266 let Some((collected_amount, pad_size)) = self.inactive_pickup_stats(
1267 player,
1268 &event.pad_id,
1269 previous_boost_amount,
1270 respawn_amount,
1271 ) else {
1272 continue;
1273 };
1274 if !self.inactive_pickup_frames.insert((
1275 player_id.clone(),
1276 event.frame,
1277 pad_size,
1278 )) {
1279 continue;
1280 }
1281 self.apply_inactive_pickup(
1282 player_id,
1283 player.is_team_0,
1284 collected_amount,
1285 pad_size,
1286 );
1287 self.record_reported_pickup(PendingBoostPickupEvent {
1288 frame: event.frame,
1289 time: event.time,
1290 player_id: player_id.clone(),
1291 is_team_0: player.is_team_0,
1292 pad_type: pad_size.into(),
1293 field_half: Self::field_half_from_position(
1294 player.is_team_0,
1295 player.position(),
1296 ),
1297 activity: BoostPickupActivity::Inactive,
1298 boost_before: None,
1299 boost_after: None,
1300 });
1301 continue;
1302 }
1303 let Some(player_id) = &event.player else {
1304 continue;
1305 };
1306 let Some(player) = players
1307 .players
1308 .iter()
1309 .find(|player| &player.player_id == player_id)
1310 else {
1311 continue;
1312 };
1313 if self.unavailable_pad_is_recent(&event.pad_id, event.time, player.position())
1314 {
1315 continue;
1316 }
1317 let pickup_key = (event.pad_id.clone(), player_id.clone());
1318 if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
1319 continue;
1320 }
1321 self.pickup_frames.insert(pickup_key, event.frame);
1322 if self.seen_pickup_sequence_is_recent(
1323 &event.pad_id,
1324 sequence,
1325 event.time,
1326 player.position(),
1327 ) {
1328 continue;
1329 }
1330 self.seen_pickup_sequence_times
1331 .insert((event.pad_id.clone(), sequence), event.time);
1332 self.unavailable_pads.insert(event.pad_id.clone());
1333 self.last_pickup_times
1334 .insert(event.pad_id.clone(), event.time);
1335 if let Some(position) = player.position() {
1336 self.observed_pad_positions
1337 .entry(event.pad_id.clone())
1338 .or_default()
1339 .observe(position);
1340 }
1341 let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
1342 self.previous_boost_amounts
1343 .get(player_id)
1344 .copied()
1345 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0))
1346 });
1347 let pre_applied_collected_amount =
1348 if pickup_counts_by_player.get(player_id).copied() == Some(1) {
1349 self.previous_boost_amounts
1350 .get(player_id)
1351 .copied()
1352 .map(|previous_sample_boost_amount| {
1353 let respawn_amount = respawn_amounts_by_player
1354 .get(player_id)
1355 .copied()
1356 .unwrap_or(0.0);
1357 (player.boost_amount.unwrap_or(previous_boost_amount)
1358 - previous_sample_boost_amount
1359 - respawn_amount)
1360 .max(0.0)
1361 })
1362 .unwrap_or(0.0)
1363 } else {
1364 0.0
1365 };
1366 let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
1367 .then(|| {
1368 self.guess_pad_size_from_position(
1369 &event.pad_id,
1370 player.position().unwrap_or(glam::Vec3::ZERO),
1371 )
1372 })
1373 .flatten();
1374 self.apply_pickup_collected_amount(
1375 player_id,
1376 player.is_team_0,
1377 pre_applied_collected_amount,
1378 pre_applied_pad_size,
1379 );
1380 let pending_pickup = PendingBoostPickup {
1381 player_id: player_id.clone(),
1382 is_team_0: player.is_team_0,
1383 previous_boost_amount,
1384 pre_applied_collected_amount,
1385 pre_applied_pad_size,
1386 player_position: player.position().unwrap_or(glam::Vec3::ZERO),
1387 };
1388
1389 let pad_size = self
1390 .known_pad_sizes
1391 .get(&event.pad_id)
1392 .copied()
1393 .or_else(|| {
1394 let mut size = self.guess_pad_size_from_position(
1395 &event.pad_id,
1396 player.position().unwrap_or(glam::Vec3::ZERO),
1397 )?;
1398 if size == BoostPadSize::Small
1402 && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW * 1.5
1403 {
1404 size = BoostPadSize::Big;
1405 }
1406 self.known_pad_sizes.insert(event.pad_id.clone(), size);
1407 Some(size)
1408 });
1409 if let Some(pad_size) = pad_size {
1410 let field_half =
1411 self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
1412 self.record_reported_pickup(PendingBoostPickupEvent {
1413 frame: event.frame,
1414 time: event.time,
1415 player_id: player_id.clone(),
1416 is_team_0: player.is_team_0,
1417 pad_type: pad_size.into(),
1418 field_half,
1419 activity: Self::activity_label(track_boost_pickups),
1420 boost_before: None,
1421 boost_after: None,
1422 });
1423 }
1424 }
1425 BoostPadEventKind::Available => {
1426 if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
1427 let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
1428 else {
1429 continue;
1430 };
1431 if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
1432 {
1433 continue;
1434 }
1435 }
1436 self.unavailable_pads.remove(&event.pad_id);
1437 }
1438 }
1439 }
1440 self.flush_stale_pickup_comparisons(frame.frame_number);
1441
1442 let mut team_zero_used = self.team_zero_stats.amount_used;
1443 let mut team_one_used = self.team_one_stats.amount_used;
1444 for player in &players.players {
1445 let Some(boost_amount) = player.boost_amount else {
1446 continue;
1447 };
1448 let stats = self
1449 .player_stats
1450 .entry(player.player_id.clone())
1451 .or_default();
1452 let previous_amount_used = stats.amount_used;
1453 let amount_used_raw = (stats.amount_obtained() - boost_amount).max(0.0);
1454 let amount_used = amount_used_raw.max(stats.amount_used);
1455 if track_boost_levels {
1456 let split_amount = stats.amount_used_by_vertical_band();
1457 let amount_used_delta = (amount_used - split_amount).max(0.0);
1458 if amount_used_delta > 0.0 {
1459 let speed = player.speed();
1460 let previous_speed = self
1461 .previous_player_speeds
1462 .get(&player.player_id)
1463 .copied()
1464 .or(speed);
1465 let previous_speed = if boost_levels_resumed_this_sample {
1466 speed
1467 } else {
1468 previous_speed
1469 };
1470 let used_while_supersonic = player.boost_active
1471 && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
1472 && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD;
1473 let team_stats = if player.is_team_0 {
1474 &mut self.team_zero_stats
1475 } else {
1476 &mut self.team_one_stats
1477 };
1478 if vertical_state.is_grounded(&player.player_id) {
1479 stats.amount_used_while_grounded += amount_used_delta;
1480 team_stats.amount_used_while_grounded += amount_used_delta;
1481 } else {
1482 stats.amount_used_while_airborne += amount_used_delta;
1483 team_stats.amount_used_while_airborne += amount_used_delta;
1484 }
1485 if used_while_supersonic {
1486 stats.amount_used_while_supersonic += amount_used_delta;
1487 team_stats.amount_used_while_supersonic += amount_used_delta;
1488 }
1489 }
1490 }
1491 stats.amount_used = amount_used;
1492 let amount_used_delta = amount_used - previous_amount_used;
1493 if amount_used_delta <= 0.0 {
1494 continue;
1495 }
1496 if player.is_team_0 {
1497 team_zero_used += amount_used_delta;
1498 } else {
1499 team_one_used += amount_used_delta;
1500 }
1501 }
1502 self.team_zero_stats.amount_used = team_zero_used;
1503 self.team_one_stats.amount_used = team_one_used;
1504 for (player_id, boost_amount) in current_boost_amounts {
1505 self.previous_boost_amounts.insert(player_id, boost_amount);
1506 }
1507 for player in &players.players {
1508 if let Some(speed) = player.speed() {
1509 self.previous_player_speeds
1510 .insert(player.player_id.clone(), speed);
1511 }
1512 }
1513 self.warn_for_sample_boost_invariants(frame, players);
1514 self.kickoff_phase_active_last_frame = kickoff_phase_active;
1515 self.previous_boost_levels_live = Some(boost_levels_live);
1516
1517 Ok(())
1518 }
1519}
1520
1521#[cfg(test)]
1522#[path = "boost_tests.rs"]
1523mod tests;