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)]
1522mod tests {
1523 use super::*;
1524
1525 fn test_player(
1526 player_id: PlayerId,
1527 boost_amount: f32,
1528 last_boost_amount: f32,
1529 position: glam::Vec3,
1530 ) -> PlayerSample {
1531 PlayerSample {
1532 player_id,
1533 is_team_0: true,
1534 rigid_body: Some(boxcars::RigidBody {
1535 sleeping: false,
1536 location: glam_to_vec(&position),
1537 rotation: boxcars::Quaternion {
1538 x: 0.0,
1539 y: 0.0,
1540 z: 0.0,
1541 w: 1.0,
1542 },
1543 linear_velocity: None,
1544 angular_velocity: None,
1545 }),
1546 boost_amount: Some(boost_amount),
1547 last_boost_amount: Some(last_boost_amount),
1548 boost_active: false,
1549 dodge_active: false,
1550 powerslide_active: false,
1551 match_goals: None,
1552 match_assists: None,
1553 match_saves: None,
1554 match_shots: None,
1555 match_score: None,
1556 }
1557 }
1558
1559 #[test]
1560 fn records_inactive_pickup_without_active_collection() {
1561 let mut calculator = BoostCalculator::new();
1562 let player_id = PlayerId::Steam(1);
1563 let (pad_position, _) = standard_soccar_boost_pad_layout()
1564 .iter()
1565 .find(|(_, size)| *size == BoostPadSize::Small)
1566 .copied()
1567 .expect("standard layout should include small pads");
1568 let player = test_player(
1569 player_id.clone(),
1570 BOOST_KICKOFF_START_AMOUNT + SMALL_PAD_AMOUNT_RAW,
1571 0.0,
1572 pad_position,
1573 );
1574
1575 calculator
1576 .update_parts(
1577 &FrameInfo {
1578 frame_number: 1,
1579 time: 1.0,
1580 dt: 1.0 / 30.0,
1581 seconds_remaining: None,
1582 },
1583 &GameplayState {
1584 game_state: Some(GAME_STATE_GOAL_SCORED_REPLAY),
1585 ball_has_been_hit: Some(true),
1586 ..GameplayState::default()
1587 },
1588 &PlayerFrameState {
1589 players: vec![player],
1590 },
1591 &FrameEventsState {
1592 boost_pad_events: vec![BoostPadEvent {
1593 time: 1.0,
1594 frame: 1,
1595 pad_id: "inactive-small-pad".to_string(),
1596 player: Some(player_id.clone()),
1597 kind: BoostPadEventKind::PickedUp { sequence: 1 },
1598 }],
1599 ..FrameEventsState::default()
1600 },
1601 &PlayerVerticalState::default(),
1602 false,
1603 )
1604 .expect("inactive boost update should succeed");
1605
1606 let player_stats = calculator
1607 .player_stats()
1608 .get(&player_id)
1609 .expect("player stats should be recorded");
1610 assert_eq!(player_stats.amount_collected, 0.0);
1611 assert_eq!(player_stats.small_pads_collected, 0);
1612 assert_eq!(player_stats.amount_collected_inactive, SMALL_PAD_AMOUNT_RAW);
1613 assert_eq!(player_stats.small_pads_collected_inactive, 1);
1614 assert_eq!(calculator.team_zero_stats().amount_collected, 0.0);
1615 assert_eq!(
1616 calculator.team_zero_stats().amount_collected_inactive,
1617 SMALL_PAD_AMOUNT_RAW
1618 );
1619 assert_eq!(
1620 calculator.team_zero_stats().small_pads_collected_inactive,
1621 1
1622 );
1623 }
1624
1625 #[test]
1626 fn counts_reused_pickup_sequence_after_pad_respawn() {
1627 let mut calculator = BoostCalculator::new();
1628 let player_id = PlayerId::Steam(1);
1629 let (pad_position, _) = standard_soccar_boost_pad_layout()
1630 .iter()
1631 .find(|(_, size)| *size == BoostPadSize::Big)
1632 .copied()
1633 .expect("standard layout should include big pads");
1634 let pad_id = "reused-sequence-big-pad".to_string();
1635 let active_gameplay = GameplayState {
1636 ball_has_been_hit: Some(true),
1637 ..GameplayState::default()
1638 };
1639
1640 calculator
1641 .update_parts(
1642 &FrameInfo {
1643 frame_number: 1,
1644 time: 1.0,
1645 dt: 1.0 / 30.0,
1646 seconds_remaining: None,
1647 },
1648 &active_gameplay,
1649 &PlayerFrameState {
1650 players: vec![test_player(player_id.clone(), 100.0, 0.0, pad_position)],
1651 },
1652 &FrameEventsState {
1653 boost_pad_events: vec![BoostPadEvent {
1654 time: 1.0,
1655 frame: 1,
1656 pad_id: pad_id.clone(),
1657 player: Some(player_id.clone()),
1658 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1659 }],
1660 ..FrameEventsState::default()
1661 },
1662 &PlayerVerticalState::default(),
1663 true,
1664 )
1665 .expect("first boost update should succeed");
1666
1667 calculator
1668 .update_parts(
1669 &FrameInfo {
1670 frame_number: 2,
1671 time: 11.1,
1672 dt: 1.0 / 30.0,
1673 seconds_remaining: None,
1674 },
1675 &active_gameplay,
1676 &PlayerFrameState {
1677 players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1678 },
1679 &FrameEventsState {
1680 boost_pad_events: vec![BoostPadEvent {
1681 time: 11.1,
1682 frame: 2,
1683 pad_id: pad_id.clone(),
1684 player: None,
1685 kind: BoostPadEventKind::Available,
1686 }],
1687 ..FrameEventsState::default()
1688 },
1689 &PlayerVerticalState::default(),
1690 true,
1691 )
1692 .expect("pad availability update should succeed");
1693
1694 calculator
1695 .update_parts(
1696 &FrameInfo {
1697 frame_number: 3,
1698 time: 11.2,
1699 dt: 1.0 / 30.0,
1700 seconds_remaining: None,
1701 },
1702 &active_gameplay,
1703 &PlayerFrameState {
1704 players: vec![test_player(player_id.clone(), 200.0, 100.0, pad_position)],
1705 },
1706 &FrameEventsState {
1707 boost_pad_events: vec![BoostPadEvent {
1708 time: 11.2,
1709 frame: 3,
1710 pad_id,
1711 player: Some(player_id.clone()),
1712 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1713 }],
1714 ..FrameEventsState::default()
1715 },
1716 &PlayerVerticalState::default(),
1717 true,
1718 )
1719 .expect("second boost update should succeed");
1720
1721 let player_stats = calculator
1722 .player_stats()
1723 .get(&player_id)
1724 .expect("player stats should be recorded");
1725 assert_eq!(player_stats.big_pads_collected, 2);
1726 assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1727 }
1728
1729 #[test]
1730 fn counts_pickup_after_respawn_without_available_event() {
1731 let mut calculator = BoostCalculator::new();
1732 let player_id = PlayerId::Steam(1);
1733 let (pad_position, _) = standard_soccar_boost_pad_layout()
1734 .iter()
1735 .find(|(_, size)| *size == BoostPadSize::Big)
1736 .copied()
1737 .expect("standard layout should include big pads");
1738 let pad_id = "missing-available-big-pad".to_string();
1739 let active_gameplay = GameplayState {
1740 ball_has_been_hit: Some(true),
1741 ..GameplayState::default()
1742 };
1743
1744 for (frame_number, time, sequence, previous_boost, boost_amount) in
1745 [(1, 1.0, 7, 0.0, 100.0), (2, 11.2, 9, 100.0, 200.0)]
1746 {
1747 calculator
1748 .update_parts(
1749 &FrameInfo {
1750 frame_number,
1751 time,
1752 dt: 1.0 / 30.0,
1753 seconds_remaining: None,
1754 },
1755 &active_gameplay,
1756 &PlayerFrameState {
1757 players: vec![test_player(
1758 player_id.clone(),
1759 boost_amount,
1760 previous_boost,
1761 pad_position,
1762 )],
1763 },
1764 &FrameEventsState {
1765 boost_pad_events: vec![BoostPadEvent {
1766 time,
1767 frame: frame_number,
1768 pad_id: pad_id.clone(),
1769 player: Some(player_id.clone()),
1770 kind: BoostPadEventKind::PickedUp { sequence },
1771 }],
1772 ..FrameEventsState::default()
1773 },
1774 &PlayerVerticalState::default(),
1775 true,
1776 )
1777 .expect("boost update should succeed");
1778 }
1779
1780 let player_stats = calculator
1781 .player_stats()
1782 .get(&player_id)
1783 .expect("player stats should be recorded");
1784 assert_eq!(player_stats.big_pads_collected, 2);
1785 assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1786 }
1787
1788 #[test]
1789 fn skips_inactive_pickup_without_observed_boost_gain() {
1790 let mut calculator = BoostCalculator::new();
1791 let player_id = PlayerId::Steam(1);
1792 let (pad_position, _) = standard_soccar_boost_pad_layout()
1793 .iter()
1794 .find(|(_, size)| *size == BoostPadSize::Big)
1795 .copied()
1796 .expect("standard layout should include big pads");
1797
1798 calculator
1799 .update_parts(
1800 &FrameInfo {
1801 frame_number: 1,
1802 time: 1.0,
1803 dt: 1.0 / 30.0,
1804 seconds_remaining: None,
1805 },
1806 &GameplayState::default(),
1807 &PlayerFrameState {
1808 players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1809 },
1810 &FrameEventsState {
1811 boost_pad_events: vec![BoostPadEvent {
1812 time: 1.0,
1813 frame: 1,
1814 pad_id: "inactive-no-gain-big-pad".to_string(),
1815 player: Some(player_id.clone()),
1816 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1817 }],
1818 ..FrameEventsState::default()
1819 },
1820 &PlayerVerticalState::default(),
1821 false,
1822 )
1823 .expect("boost update should succeed");
1824
1825 let player_stats = calculator
1826 .player_stats()
1827 .get(&player_id)
1828 .expect("player stats should be recorded");
1829 assert_eq!(player_stats.big_pads_collected_inactive, 0);
1830 assert_eq!(player_stats.amount_collected_inactive, 0.0);
1831 }
1832
1833 #[test]
1834 fn observed_boost_increases_do_not_emit_pickup_events_without_reported_pad_pickups() {
1835 let mut calculator = BoostCalculator::new();
1836 let small_player = PlayerId::Steam(1);
1837 let big_player = PlayerId::Steam(2);
1838 let ambiguous_player = PlayerId::Steam(3);
1839 let respawn_player = PlayerId::Steam(4);
1840 let two_small_player = PlayerId::Steam(5);
1841 let position = glam::Vec3::ZERO;
1842 let active_gameplay = GameplayState {
1843 ball_has_been_hit: Some(true),
1844 ..GameplayState::default()
1845 };
1846
1847 calculator
1848 .update_parts(
1849 &FrameInfo {
1850 frame_number: 1,
1851 time: 1.0,
1852 dt: 1.0 / 30.0,
1853 seconds_remaining: None,
1854 },
1855 &active_gameplay,
1856 &PlayerFrameState {
1857 players: vec![
1858 test_player(small_player.clone(), 10.0, 10.0, position),
1859 test_player(big_player.clone(), 10.0, 10.0, position),
1860 test_player(ambiguous_player.clone(), 230.0, 230.0, position),
1861 test_player(respawn_player.clone(), 0.0, 0.0, position),
1862 test_player(
1863 two_small_player.clone(),
1864 BOOST_KICKOFF_START_AMOUNT,
1865 BOOST_KICKOFF_START_AMOUNT,
1866 position,
1867 ),
1868 ],
1869 },
1870 &FrameEventsState::default(),
1871 &PlayerVerticalState::default(),
1872 true,
1873 )
1874 .expect("first boost update should succeed");
1875
1876 calculator
1877 .update_parts(
1878 &FrameInfo {
1879 frame_number: 2,
1880 time: 1.1,
1881 dt: 1.0 / 30.0,
1882 seconds_remaining: None,
1883 },
1884 &active_gameplay,
1885 &PlayerFrameState {
1886 players: vec![
1887 test_player(
1888 small_player.clone(),
1889 10.0 + SMALL_PAD_AMOUNT_RAW,
1890 10.0,
1891 position,
1892 ),
1893 test_player(big_player.clone(), BOOST_MAX_AMOUNT, 10.0, position),
1894 test_player(ambiguous_player.clone(), BOOST_MAX_AMOUNT, 230.0, position),
1895 test_player(
1896 respawn_player.clone(),
1897 BOOST_KICKOFF_START_AMOUNT,
1898 0.0,
1899 position,
1900 ),
1901 test_player(
1902 two_small_player.clone(),
1903 BOOST_KICKOFF_START_AMOUNT + 2.0 * SMALL_PAD_AMOUNT_RAW,
1904 BOOST_KICKOFF_START_AMOUNT,
1905 position,
1906 ),
1907 ],
1908 },
1909 &FrameEventsState::default(),
1910 &PlayerVerticalState::default(),
1911 true,
1912 )
1913 .expect("second boost update should succeed");
1914
1915 calculator
1916 .finish_calculation()
1917 .expect("pending inferred pickups should be discarded");
1918 let events = calculator.pickup_comparison_events();
1919 assert!(events.is_empty());
1920 assert!(calculator.player_stats().get(&respawn_player).is_some());
1921 }
1922
1923 #[test]
1924 fn reported_pickup_without_observed_boost_increase_is_emitted_as_counted_pickup() {
1925 let mut calculator = BoostCalculator::new();
1926 let player_id = PlayerId::Steam(1);
1927 let (pad_position, _) = standard_soccar_boost_pad_layout()
1928 .iter()
1929 .find(|(_, size)| *size == BoostPadSize::Small)
1930 .copied()
1931 .expect("standard layout should include small pads");
1932 let active_gameplay = GameplayState {
1933 ball_has_been_hit: Some(true),
1934 ..GameplayState::default()
1935 };
1936
1937 calculator
1938 .update_parts(
1939 &FrameInfo {
1940 frame_number: 1,
1941 time: 1.0,
1942 dt: 1.0 / 30.0,
1943 seconds_remaining: None,
1944 },
1945 &active_gameplay,
1946 &PlayerFrameState {
1947 players: vec![test_player(
1948 player_id.clone(),
1949 BOOST_MAX_AMOUNT,
1950 BOOST_MAX_AMOUNT,
1951 pad_position,
1952 )],
1953 },
1954 &FrameEventsState::default(),
1955 &PlayerVerticalState::default(),
1956 true,
1957 )
1958 .expect("first boost update should succeed");
1959
1960 calculator
1961 .update_parts(
1962 &FrameInfo {
1963 frame_number: 2,
1964 time: 1.1,
1965 dt: 1.0 / 30.0,
1966 seconds_remaining: None,
1967 },
1968 &active_gameplay,
1969 &PlayerFrameState {
1970 players: vec![test_player(
1971 player_id.clone(),
1972 BOOST_MAX_AMOUNT,
1973 BOOST_MAX_AMOUNT,
1974 pad_position,
1975 )],
1976 },
1977 &FrameEventsState {
1978 boost_pad_events: vec![BoostPadEvent {
1979 time: 1.1,
1980 frame: 2,
1981 pad_id: "full-boost-small-pad".to_string(),
1982 player: Some(player_id.clone()),
1983 kind: BoostPadEventKind::PickedUp { sequence: 1 },
1984 }],
1985 ..FrameEventsState::default()
1986 },
1987 &PlayerVerticalState::default(),
1988 true,
1989 )
1990 .expect("second boost update should succeed");
1991
1992 calculator
1993 .finish_calculation()
1994 .expect("pickup comparisons should finish");
1995
1996 let player_stats = calculator
1997 .player_stats()
1998 .get(&player_id)
1999 .expect("player stats should exist");
2000 assert_eq!(player_stats.small_pads_collected, 1);
2001 assert_eq!(player_stats.amount_collected_small, 0.0);
2002 assert_eq!(player_stats.overfill_total, SMALL_PAD_AMOUNT_RAW);
2003
2004 let events = calculator.pickup_comparison_events();
2005 assert_eq!(events.len(), 1);
2006 assert_eq!(events[0].comparison, BoostPickupComparison::Both);
2007 assert_eq!(events[0].pad_type, BoostPickupPadType::Small);
2008 assert_eq!(events[0].reported_frame, Some(2));
2009 assert_eq!(events[0].inferred_frame, None);
2010 }
2011
2012 #[test]
2013 fn matches_two_small_pickups_from_one_observed_boost_increase() {
2014 let mut calculator = BoostCalculator::new();
2015 let player_id = PlayerId::Steam(1);
2016 let (pad_position, _) = standard_soccar_boost_pad_layout()
2017 .iter()
2018 .find(|(_, size)| *size == BoostPadSize::Small)
2019 .copied()
2020 .expect("standard layout should include small pads");
2021 let active_gameplay = GameplayState {
2022 ball_has_been_hit: Some(true),
2023 ..GameplayState::default()
2024 };
2025
2026 calculator
2027 .update_parts(
2028 &FrameInfo {
2029 frame_number: 1,
2030 time: 1.0,
2031 dt: 1.0 / 30.0,
2032 seconds_remaining: None,
2033 },
2034 &active_gameplay,
2035 &PlayerFrameState {
2036 players: vec![test_player(
2037 player_id.clone(),
2038 BOOST_KICKOFF_START_AMOUNT,
2039 BOOST_KICKOFF_START_AMOUNT,
2040 pad_position,
2041 )],
2042 },
2043 &FrameEventsState::default(),
2044 &PlayerVerticalState::default(),
2045 true,
2046 )
2047 .expect("first boost update should succeed");
2048
2049 calculator
2050 .update_parts(
2051 &FrameInfo {
2052 frame_number: 2,
2053 time: 1.1,
2054 dt: 1.0 / 30.0,
2055 seconds_remaining: None,
2056 },
2057 &active_gameplay,
2058 &PlayerFrameState {
2059 players: vec![test_player(
2060 player_id.clone(),
2061 BOOST_KICKOFF_START_AMOUNT + 2.0 * SMALL_PAD_AMOUNT_RAW,
2062 BOOST_KICKOFF_START_AMOUNT,
2063 pad_position,
2064 )],
2065 },
2066 &FrameEventsState {
2067 boost_pad_events: vec![
2068 BoostPadEvent {
2069 time: 1.1,
2070 frame: 2,
2071 pad_id: "small-pad-one".to_string(),
2072 player: Some(player_id.clone()),
2073 kind: BoostPadEventKind::PickedUp { sequence: 1 },
2074 },
2075 BoostPadEvent {
2076 time: 1.1,
2077 frame: 2,
2078 pad_id: "small-pad-two".to_string(),
2079 player: Some(player_id.clone()),
2080 kind: BoostPadEventKind::PickedUp { sequence: 1 },
2081 },
2082 ],
2083 ..FrameEventsState::default()
2084 },
2085 &PlayerVerticalState::default(),
2086 true,
2087 )
2088 .expect("second boost update should succeed");
2089
2090 calculator
2091 .finish_calculation()
2092 .expect("pickup comparisons should flush");
2093
2094 let events = calculator.pickup_comparison_events();
2095 assert_eq!(events.len(), 2);
2096 assert!(events.iter().all(|event| {
2097 event.comparison == BoostPickupComparison::Both
2098 && event.pad_type == BoostPickupPadType::Small
2099 }));
2100 }
2101}