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 pending_reported_pickups: VecDeque<PendingBoostPickupEvent>,
231 pickup_comparison_events: Vec<BoostPickupComparisonEvent>,
232 kickoff_phase_active_last_frame: bool,
233 kickoff_respawn_awarded: HashSet<PlayerId>,
234 initial_respawn_awarded: HashSet<PlayerId>,
235 pending_demo_respawns: HashSet<PlayerId>,
236 previous_boost_levels_live: Option<bool>,
237 active_invariant_warnings: HashSet<BoostInvariantWarningKey>,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Hash)]
241struct BoostInvariantWarningKey {
242 scope: String,
243 kind: BoostInvariantKind,
244}
245
246#[derive(Debug, Clone)]
247struct PendingBoostPickup {
248 player_id: PlayerId,
249 is_team_0: bool,
250 previous_boost_amount: f32,
251 pre_applied_collected_amount: f32,
252 pre_applied_pad_size: Option<BoostPadSize>,
253 player_position: glam::Vec3,
254}
255
256impl BoostCalculator {
257 const PICKUP_MATCH_FRAME_WINDOW: usize = 3;
258
259 pub fn new() -> Self {
260 Self::with_config(BoostCalculatorConfig::default())
261 }
262
263 pub fn with_config(config: BoostCalculatorConfig) -> Self {
264 Self {
265 config,
266 ..Self::default()
267 }
268 }
269
270 pub fn player_stats(&self) -> &HashMap<PlayerId, BoostStats> {
271 &self.player_stats
272 }
273
274 pub fn team_zero_stats(&self) -> &BoostStats {
275 &self.team_zero_stats
276 }
277
278 pub fn team_one_stats(&self) -> &BoostStats {
279 &self.team_one_stats
280 }
281
282 pub fn pickup_comparison_events(&self) -> &[BoostPickupComparisonEvent] {
283 &self.pickup_comparison_events
284 }
285
286 fn estimated_pad_position(&self, pad_id: &str) -> Option<glam::Vec3> {
287 self.observed_pad_positions
288 .get(pad_id)
289 .and_then(PadPositionEstimate::mean)
290 }
291
292 fn observed_pad_positions(&self, pad_id: &str) -> &[glam::Vec3] {
293 self.observed_pad_positions
294 .get(pad_id)
295 .map(PadPositionEstimate::observations)
296 .unwrap_or(&[])
297 }
298
299 fn pad_match_radius(pad_size: BoostPadSize) -> f32 {
300 match pad_size {
301 BoostPadSize::Big => STANDARD_PAD_MATCH_RADIUS_BIG,
302 BoostPadSize::Small => STANDARD_PAD_MATCH_RADIUS_SMALL,
303 }
304 }
305
306 pub fn resolved_boost_pads(&self) -> Vec<ResolvedBoostPad> {
307 standard_soccar_boost_pad_layout()
308 .iter()
309 .enumerate()
310 .map(|(index, (position, size))| ResolvedBoostPad {
311 index,
312 pad_id: self
313 .known_pad_indices
314 .iter()
315 .find_map(|(pad_id, pad_index)| (*pad_index == index).then(|| pad_id.clone())),
316 size: *size,
317 position: glam_to_vec(position),
318 })
319 .collect()
320 }
321
322 fn infer_pad_index(
323 &self,
324 pad_id: &str,
325 pad_size: BoostPadSize,
326 observed_position: glam::Vec3,
327 ) -> Option<usize> {
328 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
329 return Some(index);
330 }
331
332 let observed_position = self
333 .estimated_pad_position(pad_id)
334 .unwrap_or(observed_position);
335 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
336 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
337 let radius = Self::pad_match_radius(pad_size);
338 let observed_positions = self.observed_pad_positions(pad_id);
339 let best_candidate = |allow_used: bool| {
340 layout
341 .iter()
342 .enumerate()
343 .filter(|(index, (_, size))| {
344 *size == pad_size && (allow_used || !used_indices.contains(index))
345 })
346 .filter_map(|(index, (candidate_position, _))| {
347 let mut vote_count = 0usize;
348 let mut total_vote_distance = 0.0f32;
349 let mut best_vote_distance = f32::INFINITY;
350
351 for position in observed_positions {
352 let distance = position.distance(*candidate_position);
353 if distance <= radius {
354 vote_count += 1;
355 total_vote_distance += distance;
356 best_vote_distance = best_vote_distance.min(distance);
357 }
358 }
359
360 if vote_count == 0 {
361 return None;
362 }
363
364 let representative_distance = observed_position.distance(*candidate_position);
365 Some((
366 index,
367 vote_count,
368 total_vote_distance / vote_count as f32,
369 best_vote_distance,
370 representative_distance,
371 ))
372 })
373 .max_by(|left, right| {
374 left.1
375 .cmp(&right.1)
376 .then_with(|| right.2.partial_cmp(&left.2).unwrap())
377 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
378 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
379 })
380 .map(|(index, _, _, _, _)| index)
381 };
382
383 best_candidate(false)
384 .or_else(|| best_candidate(true))
385 .or_else(|| {
386 layout
387 .iter()
388 .enumerate()
389 .filter(|(index, (_, size))| *size == pad_size && !used_indices.contains(index))
390 .min_by(|(_, (a, _)), (_, (b, _))| {
391 observed_position
392 .distance_squared(*a)
393 .partial_cmp(&observed_position.distance_squared(*b))
394 .unwrap()
395 })
396 .map(|(index, _)| index)
397 })
398 .or_else(|| {
399 layout
400 .iter()
401 .enumerate()
402 .filter(|(_, (_, size))| *size == pad_size)
403 .min_by(|(_, (a, _)), (_, (b, _))| {
404 observed_position
405 .distance_squared(*a)
406 .partial_cmp(&observed_position.distance_squared(*b))
407 .unwrap()
408 })
409 .map(|(index, _)| index)
410 })
411 .filter(|index| {
412 observed_position.distance(standard_soccar_boost_pad_position(*index)) <= radius
413 })
414 }
415
416 fn infer_pad_details_from_position(
417 &self,
418 pad_id: &str,
419 observed_position: glam::Vec3,
420 ) -> Option<(usize, BoostPadSize)> {
421 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
422 let (_, size) = standard_soccar_boost_pad_layout().get(index)?;
423 return Some((index, *size));
424 }
425
426 let observed_position = self
427 .estimated_pad_position(pad_id)
428 .unwrap_or(observed_position);
429 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
430 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
431 let observed_positions = self.observed_pad_positions(pad_id);
432 let best_candidate = |allow_used: bool| {
433 layout
434 .iter()
435 .enumerate()
436 .filter(|(index, _)| allow_used || !used_indices.contains(index))
437 .filter_map(|(index, (candidate_position, size))| {
438 let radius = Self::pad_match_radius(*size);
439 let mut vote_count = 0usize;
440 let mut total_vote_distance = 0.0f32;
441 let mut best_vote_distance = f32::INFINITY;
442
443 for position in observed_positions {
444 let distance = position.distance(*candidate_position);
445 if distance <= radius {
446 vote_count += 1;
447 total_vote_distance += distance;
448 best_vote_distance = best_vote_distance.min(distance);
449 }
450 }
451
452 if vote_count == 0 {
453 return None;
454 }
455
456 let representative_distance = observed_position.distance(*candidate_position);
457 Some((
458 index,
459 *size,
460 vote_count,
461 total_vote_distance / vote_count as f32,
462 best_vote_distance,
463 representative_distance,
464 ))
465 })
466 .max_by(|left, right| {
467 left.2
468 .cmp(&right.2)
469 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
470 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
471 .then_with(|| right.5.partial_cmp(&left.5).unwrap())
472 })
473 .map(|(index, size, _, _, _, _)| (index, size))
474 };
475
476 best_candidate(false).or_else(|| best_candidate(true))
477 }
478
479 fn guess_pad_size_from_position(
480 &self,
481 pad_id: &str,
482 observed_position: glam::Vec3,
483 ) -> Option<BoostPadSize> {
484 if let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied() {
485 return Some(pad_size);
486 }
487
488 if let Some((_, pad_size)) = self.infer_pad_details_from_position(pad_id, observed_position)
489 {
490 return Some(pad_size);
491 }
492
493 let observed_position = self
494 .estimated_pad_position(pad_id)
495 .unwrap_or(observed_position);
496 standard_soccar_boost_pad_layout()
497 .iter()
498 .min_by(|(left_position, _), (right_position, _)| {
499 observed_position
500 .distance_squared(*left_position)
501 .partial_cmp(&observed_position.distance_squared(*right_position))
502 .unwrap()
503 })
504 .map(|(_, pad_size)| *pad_size)
505 }
506
507 fn resolve_pickup(
508 &mut self,
509 pad_id: &str,
510 pending_pickup: PendingBoostPickup,
511 pad_size: BoostPadSize,
512 ) -> BoostPickupFieldHalf {
513 let observed_position = self
514 .estimated_pad_position(pad_id)
515 .unwrap_or(pending_pickup.player_position);
516 let pad_position = self
517 .infer_pad_index(pad_id, pad_size, observed_position)
518 .map(|index| {
519 self.known_pad_indices.insert(pad_id.to_string(), index);
520 standard_soccar_boost_pad_position(index)
521 })
522 .unwrap_or(observed_position);
523 let stolen = is_enemy_side(pending_pickup.is_team_0, pad_position);
524 let stats = self
525 .player_stats
526 .entry(pending_pickup.player_id.clone())
527 .or_default();
528 let team_stats = if pending_pickup.is_team_0 {
529 &mut self.team_zero_stats
530 } else {
531 &mut self.team_one_stats
532 };
533 let nominal_gain = match pad_size {
534 BoostPadSize::Big => BOOST_MAX_AMOUNT,
535 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
536 };
537 let collected_amount = (BOOST_MAX_AMOUNT - pending_pickup.previous_boost_amount)
538 .min(nominal_gain)
539 .max(pending_pickup.pre_applied_collected_amount);
540 let collected_amount_delta = collected_amount - pending_pickup.pre_applied_collected_amount;
541 let overfill = (nominal_gain - collected_amount).max(0.0);
542
543 stats.amount_collected += collected_amount_delta;
544 team_stats.amount_collected += collected_amount_delta;
545
546 match pending_pickup.pre_applied_pad_size {
547 Some(pre_applied_pad_size) if pre_applied_pad_size == pad_size => {
548 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount_delta);
549 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount_delta);
550 }
551 Some(pre_applied_pad_size) => {
552 Self::apply_collected_bucket_amount(
553 stats,
554 pre_applied_pad_size,
555 -pending_pickup.pre_applied_collected_amount,
556 );
557 Self::apply_collected_bucket_amount(
558 team_stats,
559 pre_applied_pad_size,
560 -pending_pickup.pre_applied_collected_amount,
561 );
562 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
563 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
564 }
565 None => {
566 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
567 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
568 }
569 }
570
571 if stolen {
572 stats.amount_stolen += collected_amount;
573 team_stats.amount_stolen += collected_amount;
574 }
575
576 match pad_size {
577 BoostPadSize::Big => {
578 stats.big_pads_collected += 1;
579 team_stats.big_pads_collected += 1;
580 if stolen {
581 stats.big_pads_stolen += 1;
582 team_stats.big_pads_stolen += 1;
583 stats.amount_stolen_big += collected_amount;
584 team_stats.amount_stolen_big += collected_amount;
585 }
586 }
587 BoostPadSize::Small => {
588 stats.small_pads_collected += 1;
589 team_stats.small_pads_collected += 1;
590 if stolen {
591 stats.small_pads_stolen += 1;
592 team_stats.small_pads_stolen += 1;
593 stats.amount_stolen_small += collected_amount;
594 team_stats.amount_stolen_small += collected_amount;
595 }
596 }
597 }
598
599 stats.overfill_total += overfill;
600 team_stats.overfill_total += overfill;
601 if stolen {
602 stats.overfill_from_stolen += overfill;
603 team_stats.overfill_from_stolen += overfill;
604 }
605
606 if stolen {
607 BoostPickupFieldHalf::Opponent
608 } else {
609 BoostPickupFieldHalf::Own
610 }
611 }
612
613 fn apply_collected_bucket_amount(stats: &mut BoostStats, pad_size: BoostPadSize, amount: f32) {
614 if amount == 0.0 {
615 return;
616 }
617
618 match pad_size {
619 BoostPadSize::Big => stats.amount_collected_big += amount,
620 BoostPadSize::Small => stats.amount_collected_small += amount,
621 }
622 }
623
624 fn apply_pickup_collected_amount(
625 &mut self,
626 player_id: &PlayerId,
627 is_team_0: bool,
628 amount: f32,
629 pad_size: Option<BoostPadSize>,
630 ) {
631 if amount <= 0.0 {
632 return;
633 }
634
635 let stats = self.player_stats.entry(player_id.clone()).or_default();
636 let team_stats = if is_team_0 {
637 &mut self.team_zero_stats
638 } else {
639 &mut self.team_one_stats
640 };
641 stats.amount_collected += amount;
642 team_stats.amount_collected += amount;
643 if let Some(pad_size) = pad_size {
644 Self::apply_collected_bucket_amount(stats, pad_size, amount);
645 Self::apply_collected_bucket_amount(team_stats, pad_size, amount);
646 }
647 }
648
649 fn apply_inactive_pickup(
650 &mut self,
651 player_id: &PlayerId,
652 is_team_0: bool,
653 amount: f32,
654 pad_size: BoostPadSize,
655 ) {
656 let stats = self.player_stats.entry(player_id.clone()).or_default();
657 let team_stats = if is_team_0 {
658 &mut self.team_zero_stats
659 } else {
660 &mut self.team_one_stats
661 };
662 stats.amount_collected_inactive += amount;
663 team_stats.amount_collected_inactive += amount;
664 match pad_size {
665 BoostPadSize::Big => {
666 stats.big_pads_collected_inactive += 1;
667 team_stats.big_pads_collected_inactive += 1;
668 }
669 BoostPadSize::Small => {
670 stats.small_pads_collected_inactive += 1;
671 team_stats.small_pads_collected_inactive += 1;
672 }
673 }
674 }
675
676 fn apply_respawn_amount(&mut self, player_id: &PlayerId, is_team_0: bool, amount: f32) {
677 if amount <= 0.0 {
678 return;
679 }
680
681 let stats = self.player_stats.entry(player_id.clone()).or_default();
682 let team_stats = if is_team_0 {
683 &mut self.team_zero_stats
684 } else {
685 &mut self.team_one_stats
686 };
687 stats.amount_respawned += amount;
688 team_stats.amount_respawned += amount;
689 }
690
691 fn warn_for_boost_invariant_violations(
692 &mut self,
693 scope: &str,
694 frame_number: usize,
695 time: f32,
696 stats: &BoostStats,
697 observed_boost_amount: Option<f32>,
698 ) {
699 let violations = boost_invariant_violations(stats, observed_boost_amount);
700 let active_kinds: HashSet<BoostInvariantKind> =
701 violations.iter().map(|violation| violation.kind).collect();
702
703 for violation in violations {
704 let key = BoostInvariantWarningKey {
705 scope: scope.to_string(),
706 kind: violation.kind,
707 };
708 if self.active_invariant_warnings.insert(key) {
709 log::warn!(
710 "Boost invariant violation for {} at frame {} (t={:.3}): {}",
711 scope,
712 frame_number,
713 time,
714 violation.message(),
715 );
716 }
717 }
718
719 for kind in BoostInvariantKind::ALL {
720 if active_kinds.contains(&kind) {
721 continue;
722 }
723 self.active_invariant_warnings
724 .remove(&BoostInvariantWarningKey {
725 scope: scope.to_string(),
726 kind,
727 });
728 }
729 }
730
731 fn warn_for_sample_boost_invariants(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
732 let team_zero_stats = self.team_zero_stats.clone();
733 let team_one_stats = self.team_one_stats.clone();
734 let player_scopes: Vec<(PlayerId, Option<f32>, BoostStats)> = players
735 .players
736 .iter()
737 .map(|player| {
738 (
739 player.player_id.clone(),
740 player.boost_amount,
741 self.player_stats
742 .get(&player.player_id)
743 .cloned()
744 .unwrap_or_default(),
745 )
746 })
747 .collect();
748
749 self.warn_for_boost_invariant_violations(
750 "team_zero",
751 frame.frame_number,
752 frame.time,
753 &team_zero_stats,
754 None,
755 );
756 self.warn_for_boost_invariant_violations(
757 "team_one",
758 frame.frame_number,
759 frame.time,
760 &team_one_stats,
761 None,
762 );
763 for (player_id, observed_boost_amount, stats) in player_scopes {
764 self.warn_for_boost_invariant_violations(
765 &format!("player {player_id:?}"),
766 frame.frame_number,
767 frame.time,
768 &stats,
769 observed_boost_amount,
770 );
771 }
772 }
773
774 fn interval_fraction_in_boost_range(
775 start_boost: f32,
776 end_boost: f32,
777 min_boost: f32,
778 max_boost: f32,
779 ) -> f32 {
780 if (end_boost - start_boost).abs() <= f32::EPSILON {
781 return ((start_boost >= min_boost) && (start_boost < max_boost)) as i32 as f32;
782 }
783
784 let t_at_min = (min_boost - start_boost) / (end_boost - start_boost);
785 let t_at_max = (max_boost - start_boost) / (end_boost - start_boost);
786 let interval_start = t_at_min.min(t_at_max).max(0.0);
787 let interval_end = t_at_min.max(t_at_max).min(1.0);
788 (interval_end - interval_start).max(0.0)
789 }
790
791 fn pad_respawn_time_seconds(pad_size: BoostPadSize) -> f32 {
792 match pad_size {
793 BoostPadSize::Big => 10.0,
794 BoostPadSize::Small => 4.0,
795 }
796 }
797
798 fn seen_pickup_sequence_is_recent(
799 &self,
800 pad_id: &str,
801 sequence: u8,
802 event_time: f32,
803 player_position: Option<glam::Vec3>,
804 ) -> bool {
805 let Some(last_time) = self
806 .seen_pickup_sequence_times
807 .get(&(pad_id.to_string(), sequence))
808 .copied()
809 else {
810 return false;
811 };
812 let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
813 player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
814 }) else {
815 return false;
816 };
817 event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
818 }
819
820 fn unavailable_pad_is_recent(
821 &self,
822 pad_id: &str,
823 event_time: f32,
824 player_position: Option<glam::Vec3>,
825 ) -> bool {
826 if !self.unavailable_pads.contains(pad_id) {
827 return false;
828 }
829 let Some(last_time) = self.last_pickup_times.get(pad_id).copied() else {
830 return true;
831 };
832 let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
833 player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
834 }) else {
835 return true;
836 };
837 event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
838 }
839
840 fn boost_levels_live(live_play: bool) -> bool {
841 live_play
842 }
843
844 fn tracks_boost_levels(boost_levels_live: bool) -> bool {
845 boost_levels_live
846 }
847
848 fn tracks_boost_pickups(gameplay: &GameplayState, live_play: bool) -> bool {
849 live_play
850 || (gameplay.ball_has_been_hit == Some(false)
851 && gameplay.game_state != Some(GAME_STATE_KICKOFF_COUNTDOWN)
852 && gameplay.kickoff_countdown_time.is_none_or(|t| t <= 0))
853 }
854
855 fn activity_label(active: bool) -> BoostPickupActivity {
856 if active {
857 BoostPickupActivity::Active
858 } else {
859 BoostPickupActivity::Inactive
860 }
861 }
862
863 fn field_half_from_position(
864 is_team_0: bool,
865 position: Option<glam::Vec3>,
866 ) -> BoostPickupFieldHalf {
867 match position {
868 Some(position) if is_enemy_side(is_team_0, position) => BoostPickupFieldHalf::Opponent,
869 Some(_) => BoostPickupFieldHalf::Own,
870 None => BoostPickupFieldHalf::Unknown,
871 }
872 }
873
874 fn classify_boost_increase_reasons(
875 previous_boost: f32,
876 boost: f32,
877 kickoff_phase_active: bool,
878 demo_respawn_supported: bool,
879 ) -> Vec<BoostIncreaseReason> {
880 const TOLERANCE: f32 = 1.0;
881 let delta = boost - previous_boost;
882 if delta <= TOLERANCE {
883 return vec![BoostIncreaseReason::Unknown];
884 }
885
886 let is_respawn_value = (boost - BOOST_KICKOFF_START_AMOUNT).abs() <= TOLERANCE;
887 if demo_respawn_supported && is_respawn_value {
888 return vec![BoostIncreaseReason::DemoRespawn];
889 }
890 if kickoff_phase_active && is_respawn_value {
891 return vec![BoostIncreaseReason::KickoffRespawn];
892 }
893 if is_respawn_value {
894 return vec![BoostIncreaseReason::Respawn];
895 }
896
897 let small_pad_floor = SMALL_PAD_AMOUNT_RAW - 3.0;
898 let big_pad_floor = SMALL_PAD_AMOUNT_RAW + 5.0;
899 if boost < BOOST_FULL_BAND_MIN_RAW && delta >= small_pad_floor {
900 const SMALL_PICKUP_COUNT_TOLERANCE: f32 = 3.0;
901 let inferred_small_pickups = ((delta - SMALL_PICKUP_COUNT_TOLERANCE)
902 / SMALL_PAD_AMOUNT_RAW)
903 .ceil()
904 .max(1.0) as usize;
905 return vec![BoostIncreaseReason::SmallPad; inferred_small_pickups];
906 }
907
908 if delta > big_pad_floor {
909 return vec![BoostIncreaseReason::BigPad];
910 }
911 if boost >= BOOST_MAX_AMOUNT - TOLERANCE {
912 return vec![BoostIncreaseReason::AmbiguousPad];
913 }
914 if delta >= small_pad_floor {
915 return vec![BoostIncreaseReason::SmallPad];
916 }
917 vec![BoostIncreaseReason::Unknown]
918 }
919
920 fn emit_pickup_comparison_event(
921 &mut self,
922 comparison: BoostPickupComparison,
923 inferred: Option<PendingBoostPickupEvent>,
924 reported: Option<PendingBoostPickupEvent>,
925 ) {
926 let reference = inferred.as_ref().or(reported.as_ref()).unwrap();
927 let pad_type = reported
928 .as_ref()
929 .map(|event| event.pad_type)
930 .or_else(|| inferred.as_ref().map(|event| event.pad_type))
931 .unwrap_or(reference.pad_type);
932 let field_half = reported
933 .as_ref()
934 .map(|event| event.field_half)
935 .or_else(|| inferred.as_ref().map(|event| event.field_half))
936 .unwrap_or(reference.field_half);
937 let activity = reported
938 .as_ref()
939 .map(|event| event.activity)
940 .or_else(|| inferred.as_ref().map(|event| event.activity))
941 .unwrap_or(reference.activity);
942 let event_frame = inferred
943 .as_ref()
944 .map(|event| event.frame)
945 .or_else(|| reported.as_ref().map(|event| event.frame))
946 .unwrap_or(reference.frame);
947 let event_time = inferred
948 .as_ref()
949 .map(|event| event.time)
950 .or_else(|| reported.as_ref().map(|event| event.time))
951 .unwrap_or(reference.time);
952 let comparison_event = BoostPickupComparisonEvent {
953 comparison,
954 frame: event_frame,
955 time: event_time,
956 player_id: reference.player_id.clone(),
957 is_team_0: reference.is_team_0,
958 pad_type,
959 field_half,
960 activity,
961 reported_frame: reported.as_ref().map(|event| event.frame),
962 reported_time: reported.as_ref().map(|event| event.time),
963 inferred_frame: inferred.as_ref().map(|event| event.frame),
964 inferred_time: inferred.as_ref().map(|event| event.time),
965 boost_before: inferred.as_ref().and_then(|event| event.boost_before),
966 boost_after: inferred.as_ref().and_then(|event| event.boost_after),
967 };
968 self.pickup_comparison_events.push(comparison_event);
969 }
970
971 fn matching_pending_pickup_index(
972 pending: &VecDeque<PendingBoostPickupEvent>,
973 event: &PendingBoostPickupEvent,
974 pending_is_inferred: bool,
975 ) -> Option<usize> {
976 pending
977 .iter()
978 .enumerate()
979 .filter(|(_, pending_event)| {
980 pending_event.player_id == event.player_id
981 && if pending_is_inferred {
982 pending_event.pad_type.is_compatible_with(event.pad_type)
983 } else {
984 event.pad_type.is_compatible_with(pending_event.pad_type)
985 }
986 && pending_event.frame.abs_diff(event.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
987 })
988 .min_by_key(|(_, pending_event)| pending_event.frame.abs_diff(event.frame))
989 .map(|(index, _)| index)
990 }
991
992 fn record_inferred_pickup(&mut self, event: PendingBoostPickupEvent) {
993 if let Some(index) =
994 Self::matching_pending_pickup_index(&self.pending_reported_pickups, &event, false)
995 {
996 let reported = self
997 .pending_reported_pickups
998 .remove(index)
999 .expect("matched reported pickup index should exist");
1000 self.emit_pickup_comparison_event(
1001 BoostPickupComparison::Both,
1002 Some(event),
1003 Some(reported),
1004 );
1005 } else {
1006 self.pending_inferred_pickups.push_back(event);
1007 }
1008 }
1009
1010 fn record_reported_pickup(&mut self, event: PendingBoostPickupEvent) {
1011 if let Some(index) =
1012 Self::matching_pending_pickup_index(&self.pending_inferred_pickups, &event, true)
1013 {
1014 let inferred = self
1015 .pending_inferred_pickups
1016 .remove(index)
1017 .expect("matched inferred pickup index should exist");
1018 self.emit_pickup_comparison_event(
1019 BoostPickupComparison::Both,
1020 Some(inferred),
1021 Some(event),
1022 );
1023 } else {
1024 self.pending_reported_pickups.push_back(event);
1025 }
1026 }
1027
1028 fn flush_stale_pickup_comparisons(&mut self, current_frame: usize) {
1029 while self
1030 .pending_inferred_pickups
1031 .front()
1032 .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < current_frame)
1033 {
1034 let event = self.pending_inferred_pickups.pop_front().unwrap();
1035 self.emit_pickup_comparison_event(BoostPickupComparison::Missed, Some(event), None);
1036 }
1037 while self
1038 .pending_reported_pickups
1039 .front()
1040 .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < current_frame)
1041 {
1042 let event = self.pending_reported_pickups.pop_front().unwrap();
1043 self.emit_pickup_comparison_event(BoostPickupComparison::Ghost, None, Some(event));
1044 }
1045 }
1046
1047 pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
1048 while let Some(event) = self.pending_inferred_pickups.pop_front() {
1049 self.emit_pickup_comparison_event(BoostPickupComparison::Missed, Some(event), None);
1050 }
1051 while let Some(event) = self.pending_reported_pickups.pop_front() {
1052 self.emit_pickup_comparison_event(BoostPickupComparison::Ghost, None, Some(event));
1053 }
1054 Ok(())
1055 }
1056
1057 fn inactive_pickup_stats(
1058 &self,
1059 player: &PlayerSample,
1060 pad_id: &str,
1061 previous_boost_amount: f32,
1062 respawn_amount: f32,
1063 ) -> Option<(f32, BoostPadSize)> {
1064 let pad_size = self
1065 .known_pad_sizes
1066 .get(pad_id)
1067 .copied()
1068 .or_else(|| self.guess_pad_size_from_position(pad_id, player.position()?))?;
1069 let nominal_gain = match pad_size {
1070 BoostPadSize::Big => BOOST_MAX_AMOUNT,
1071 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
1072 };
1073 let capacity_limited_gain = (BOOST_MAX_AMOUNT - previous_boost_amount)
1074 .min(nominal_gain)
1075 .max(0.0);
1076 let observed_gain = player
1077 .boost_amount
1078 .map(|boost_amount| (boost_amount - previous_boost_amount - respawn_amount).max(0.0))
1079 .unwrap_or(0.0);
1080 if observed_gain <= 1.0 {
1081 return None;
1082 }
1083 Some((
1084 capacity_limited_gain.max(observed_gain).min(nominal_gain),
1085 pad_size,
1086 ))
1087 }
1088
1089 pub fn update_parts(
1090 &mut self,
1091 frame: &FrameInfo,
1092 gameplay: &GameplayState,
1093 players: &PlayerFrameState,
1094 events: &FrameEventsState,
1095 vertical_state: &PlayerVerticalState,
1096 live_play: bool,
1097 ) -> SubtrActorResult<()> {
1098 let boost_levels_live = Self::boost_levels_live(live_play);
1099 let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
1100 let track_boost_pickups = Self::tracks_boost_pickups(gameplay, live_play);
1101 let boost_levels_resumed_this_sample =
1102 boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
1103 let kickoff_phase_active = gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
1104 || gameplay.kickoff_countdown_time.is_some_and(|t| t > 0)
1105 || gameplay.ball_has_been_hit == Some(false);
1106 let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
1107 if kickoff_phase_started {
1108 self.kickoff_respawn_awarded.clear();
1109 }
1110 for demo in &events.demo_events {
1111 self.pending_demo_respawns.insert(demo.victim.clone());
1112 }
1113
1114 let mut current_boost_amounts = Vec::new();
1115 let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
1116 let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
1117
1118 for event in &events.boost_pad_events {
1119 let BoostPadEventKind::PickedUp { .. } = event.kind else {
1120 continue;
1121 };
1122 let Some(player_id) = &event.player else {
1123 continue;
1124 };
1125 *pickup_counts_by_player
1126 .entry(player_id.clone())
1127 .or_default() += 1;
1128 }
1129
1130 for player in &players.players {
1131 let Some(boost_amount) = player.boost_amount else {
1132 continue;
1133 };
1134 let previous_sample_boost_amount =
1135 self.previous_boost_amounts.get(&player.player_id).copied();
1136 let previous_boost_amount = player
1137 .last_boost_amount
1138 .unwrap_or_else(|| previous_sample_boost_amount.unwrap_or(boost_amount));
1139 let previous_boost_amount = if boost_levels_resumed_this_sample {
1140 boost_amount
1141 } else {
1142 previous_boost_amount
1143 };
1144 let demo_respawn_supported = self.pending_demo_respawns.contains(&player.player_id)
1145 && player.rigid_body.is_some();
1146 if let Some(previous_sample_boost_amount) = previous_sample_boost_amount {
1147 let reasons = Self::classify_boost_increase_reasons(
1148 previous_sample_boost_amount,
1149 boost_amount,
1150 kickoff_phase_active,
1151 demo_respawn_supported,
1152 );
1153 for reason in reasons {
1154 if let Ok(pad_type) = BoostPickupPadType::try_from(reason) {
1155 self.record_inferred_pickup(PendingBoostPickupEvent {
1156 frame: frame.frame_number,
1157 time: frame.time,
1158 player_id: player.player_id.clone(),
1159 is_team_0: player.is_team_0,
1160 pad_type,
1161 field_half: Self::field_half_from_position(
1162 player.is_team_0,
1163 player.position(),
1164 ),
1165 activity: Self::activity_label(live_play),
1166 boost_before: Some(previous_sample_boost_amount),
1167 boost_after: Some(boost_amount),
1168 });
1169 }
1170 }
1171 }
1172 if track_boost_levels {
1173 let average_boost_amount = (previous_boost_amount + boost_amount) * 0.5;
1174 let time_zero_boost = frame.dt
1175 * Self::interval_fraction_in_boost_range(
1176 previous_boost_amount,
1177 boost_amount,
1178 0.0,
1179 BOOST_ZERO_BAND_RAW,
1180 );
1181 let time_hundred_boost = frame.dt
1182 * Self::interval_fraction_in_boost_range(
1183 previous_boost_amount,
1184 boost_amount,
1185 BOOST_FULL_BAND_MIN_RAW,
1186 BOOST_MAX_AMOUNT + 1.0,
1187 );
1188 let time_boost_0_25 = frame.dt
1189 * Self::interval_fraction_in_boost_range(
1190 previous_boost_amount,
1191 boost_amount,
1192 0.0,
1193 boost_percent_to_amount(25.0),
1194 );
1195 let time_boost_25_50 = frame.dt
1196 * Self::interval_fraction_in_boost_range(
1197 previous_boost_amount,
1198 boost_amount,
1199 boost_percent_to_amount(25.0),
1200 boost_percent_to_amount(50.0),
1201 );
1202 let time_boost_50_75 = frame.dt
1203 * Self::interval_fraction_in_boost_range(
1204 previous_boost_amount,
1205 boost_amount,
1206 boost_percent_to_amount(50.0),
1207 boost_percent_to_amount(75.0),
1208 );
1209 let time_boost_75_100 = frame.dt
1210 * Self::interval_fraction_in_boost_range(
1211 previous_boost_amount,
1212 boost_amount,
1213 boost_percent_to_amount(75.0),
1214 BOOST_MAX_AMOUNT + 1.0,
1215 );
1216 let stats = self
1217 .player_stats
1218 .entry(player.player_id.clone())
1219 .or_default();
1220 let team_stats = if player.is_team_0 {
1221 &mut self.team_zero_stats
1222 } else {
1223 &mut self.team_one_stats
1224 };
1225
1226 stats.tracked_time += frame.dt;
1227 stats.boost_integral += average_boost_amount * frame.dt;
1228 team_stats.tracked_time += frame.dt;
1229 team_stats.boost_integral += average_boost_amount * frame.dt;
1230 stats.time_zero_boost += time_zero_boost;
1231 team_stats.time_zero_boost += time_zero_boost;
1232 stats.time_hundred_boost += time_hundred_boost;
1233 team_stats.time_hundred_boost += time_hundred_boost;
1234 stats.time_boost_0_25 += time_boost_0_25;
1235 team_stats.time_boost_0_25 += time_boost_0_25;
1236 stats.time_boost_25_50 += time_boost_25_50;
1237 team_stats.time_boost_25_50 += time_boost_25_50;
1238 stats.time_boost_50_75 += time_boost_50_75;
1239 team_stats.time_boost_50_75 += time_boost_50_75;
1240 stats.time_boost_75_100 += time_boost_75_100;
1241 team_stats.time_boost_75_100 += time_boost_75_100;
1242 }
1243
1244 let mut respawn_amount = 0.0;
1245 let first_seen_player = self
1249 .initial_respawn_awarded
1250 .insert(player.player_id.clone());
1251 if first_seen_player
1252 || (kickoff_phase_active
1253 && !self.kickoff_respawn_awarded.contains(&player.player_id))
1254 {
1255 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1256 self.kickoff_respawn_awarded
1257 .insert(player.player_id.clone());
1258 }
1259 if demo_respawn_supported {
1260 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1261 self.pending_demo_respawns.remove(&player.player_id);
1262 }
1263 if respawn_amount > 0.0 {
1264 self.apply_respawn_amount(&player.player_id, player.is_team_0, respawn_amount);
1265 }
1266 respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
1267
1268 current_boost_amounts.push((player.player_id.clone(), boost_amount));
1269 }
1270
1271 for event in &events.boost_pad_events {
1272 match event.kind {
1273 BoostPadEventKind::PickedUp { sequence } => {
1274 if !track_boost_pickups && !self.config.include_non_live_pickups {
1275 let Some(player_id) = &event.player else {
1276 continue;
1277 };
1278 let Some(player) = players
1279 .players
1280 .iter()
1281 .find(|player| &player.player_id == player_id)
1282 else {
1283 continue;
1284 };
1285 let previous_boost_amount = self
1286 .previous_boost_amounts
1287 .get(player_id)
1288 .copied()
1289 .or(player.last_boost_amount)
1290 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1291 let respawn_amount = respawn_amounts_by_player
1292 .get(player_id)
1293 .copied()
1294 .unwrap_or(0.0);
1295 let Some((collected_amount, pad_size)) = self.inactive_pickup_stats(
1296 player,
1297 &event.pad_id,
1298 previous_boost_amount,
1299 respawn_amount,
1300 ) else {
1301 continue;
1302 };
1303 if !self.inactive_pickup_frames.insert((
1304 player_id.clone(),
1305 event.frame,
1306 pad_size,
1307 )) {
1308 continue;
1309 }
1310 self.apply_inactive_pickup(
1311 player_id,
1312 player.is_team_0,
1313 collected_amount,
1314 pad_size,
1315 );
1316 self.record_reported_pickup(PendingBoostPickupEvent {
1317 frame: event.frame,
1318 time: event.time,
1319 player_id: player_id.clone(),
1320 is_team_0: player.is_team_0,
1321 pad_type: pad_size.into(),
1322 field_half: Self::field_half_from_position(
1323 player.is_team_0,
1324 player.position(),
1325 ),
1326 activity: BoostPickupActivity::Inactive,
1327 boost_before: None,
1328 boost_after: None,
1329 });
1330 continue;
1331 }
1332 let Some(player_id) = &event.player else {
1333 continue;
1334 };
1335 let Some(player) = players
1336 .players
1337 .iter()
1338 .find(|player| &player.player_id == player_id)
1339 else {
1340 continue;
1341 };
1342 if self.unavailable_pad_is_recent(&event.pad_id, event.time, player.position())
1343 {
1344 continue;
1345 }
1346 let pickup_key = (event.pad_id.clone(), player_id.clone());
1347 if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
1348 continue;
1349 }
1350 self.pickup_frames.insert(pickup_key, event.frame);
1351 if self.seen_pickup_sequence_is_recent(
1352 &event.pad_id,
1353 sequence,
1354 event.time,
1355 player.position(),
1356 ) {
1357 continue;
1358 }
1359 self.seen_pickup_sequence_times
1360 .insert((event.pad_id.clone(), sequence), event.time);
1361 self.unavailable_pads.insert(event.pad_id.clone());
1362 self.last_pickup_times
1363 .insert(event.pad_id.clone(), event.time);
1364 if let Some(position) = player.position() {
1365 self.observed_pad_positions
1366 .entry(event.pad_id.clone())
1367 .or_default()
1368 .observe(position);
1369 }
1370 let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
1371 self.previous_boost_amounts
1372 .get(player_id)
1373 .copied()
1374 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0))
1375 });
1376 let pre_applied_collected_amount =
1377 if pickup_counts_by_player.get(player_id).copied() == Some(1) {
1378 self.previous_boost_amounts
1379 .get(player_id)
1380 .copied()
1381 .map(|previous_sample_boost_amount| {
1382 let respawn_amount = respawn_amounts_by_player
1383 .get(player_id)
1384 .copied()
1385 .unwrap_or(0.0);
1386 (player.boost_amount.unwrap_or(previous_boost_amount)
1387 - previous_sample_boost_amount
1388 - respawn_amount)
1389 .max(0.0)
1390 })
1391 .unwrap_or(0.0)
1392 } else {
1393 0.0
1394 };
1395 let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
1396 .then(|| {
1397 self.guess_pad_size_from_position(
1398 &event.pad_id,
1399 player.position().unwrap_or(glam::Vec3::ZERO),
1400 )
1401 })
1402 .flatten();
1403 self.apply_pickup_collected_amount(
1404 player_id,
1405 player.is_team_0,
1406 pre_applied_collected_amount,
1407 pre_applied_pad_size,
1408 );
1409 let pending_pickup = PendingBoostPickup {
1410 player_id: player_id.clone(),
1411 is_team_0: player.is_team_0,
1412 previous_boost_amount,
1413 pre_applied_collected_amount,
1414 pre_applied_pad_size,
1415 player_position: player.position().unwrap_or(glam::Vec3::ZERO),
1416 };
1417
1418 let pad_size = self
1419 .known_pad_sizes
1420 .get(&event.pad_id)
1421 .copied()
1422 .or_else(|| {
1423 let mut size = self.guess_pad_size_from_position(
1424 &event.pad_id,
1425 player.position().unwrap_or(glam::Vec3::ZERO),
1426 )?;
1427 if size == BoostPadSize::Small
1431 && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW * 1.5
1432 {
1433 size = BoostPadSize::Big;
1434 }
1435 self.known_pad_sizes.insert(event.pad_id.clone(), size);
1436 Some(size)
1437 });
1438 if let Some(pad_size) = pad_size {
1439 let field_half =
1440 self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
1441 self.record_reported_pickup(PendingBoostPickupEvent {
1442 frame: event.frame,
1443 time: event.time,
1444 player_id: player_id.clone(),
1445 is_team_0: player.is_team_0,
1446 pad_type: pad_size.into(),
1447 field_half,
1448 activity: Self::activity_label(track_boost_pickups),
1449 boost_before: None,
1450 boost_after: None,
1451 });
1452 }
1453 }
1454 BoostPadEventKind::Available => {
1455 if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
1456 let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
1457 else {
1458 continue;
1459 };
1460 if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
1461 {
1462 continue;
1463 }
1464 }
1465 self.unavailable_pads.remove(&event.pad_id);
1466 }
1467 }
1468 }
1469 self.flush_stale_pickup_comparisons(frame.frame_number);
1470
1471 let mut team_zero_used = self.team_zero_stats.amount_used;
1472 let mut team_one_used = self.team_one_stats.amount_used;
1473 for player in &players.players {
1474 let Some(boost_amount) = player.boost_amount else {
1475 continue;
1476 };
1477 let stats = self
1478 .player_stats
1479 .entry(player.player_id.clone())
1480 .or_default();
1481 let previous_amount_used = stats.amount_used;
1482 let amount_used_raw = (stats.amount_obtained() - boost_amount).max(0.0);
1483 let amount_used = amount_used_raw.max(stats.amount_used);
1484 if track_boost_levels {
1485 let split_amount = stats.amount_used_by_vertical_band();
1486 let amount_used_delta = (amount_used - split_amount).max(0.0);
1487 if amount_used_delta > 0.0 {
1488 let speed = player.speed();
1489 let previous_speed = self
1490 .previous_player_speeds
1491 .get(&player.player_id)
1492 .copied()
1493 .or(speed);
1494 let previous_speed = if boost_levels_resumed_this_sample {
1495 speed
1496 } else {
1497 previous_speed
1498 };
1499 let used_while_supersonic = player.boost_active
1500 && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
1501 && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD;
1502 let team_stats = if player.is_team_0 {
1503 &mut self.team_zero_stats
1504 } else {
1505 &mut self.team_one_stats
1506 };
1507 if vertical_state.is_grounded(&player.player_id) {
1508 stats.amount_used_while_grounded += amount_used_delta;
1509 team_stats.amount_used_while_grounded += amount_used_delta;
1510 } else {
1511 stats.amount_used_while_airborne += amount_used_delta;
1512 team_stats.amount_used_while_airborne += amount_used_delta;
1513 }
1514 if used_while_supersonic {
1515 stats.amount_used_while_supersonic += amount_used_delta;
1516 team_stats.amount_used_while_supersonic += amount_used_delta;
1517 }
1518 }
1519 }
1520 stats.amount_used = amount_used;
1521 let amount_used_delta = amount_used - previous_amount_used;
1522 if amount_used_delta <= 0.0 {
1523 continue;
1524 }
1525 if player.is_team_0 {
1526 team_zero_used += amount_used_delta;
1527 } else {
1528 team_one_used += amount_used_delta;
1529 }
1530 }
1531 self.team_zero_stats.amount_used = team_zero_used;
1532 self.team_one_stats.amount_used = team_one_used;
1533 for (player_id, boost_amount) in current_boost_amounts {
1534 self.previous_boost_amounts.insert(player_id, boost_amount);
1535 }
1536 for player in &players.players {
1537 if let Some(speed) = player.speed() {
1538 self.previous_player_speeds
1539 .insert(player.player_id.clone(), speed);
1540 }
1541 }
1542 self.warn_for_sample_boost_invariants(frame, players);
1543 self.kickoff_phase_active_last_frame = kickoff_phase_active;
1544 self.previous_boost_levels_live = Some(boost_levels_live);
1545
1546 Ok(())
1547 }
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552 use super::*;
1553
1554 fn test_player(
1555 player_id: PlayerId,
1556 boost_amount: f32,
1557 last_boost_amount: f32,
1558 position: glam::Vec3,
1559 ) -> PlayerSample {
1560 PlayerSample {
1561 player_id,
1562 is_team_0: true,
1563 rigid_body: Some(boxcars::RigidBody {
1564 sleeping: false,
1565 location: glam_to_vec(&position),
1566 rotation: boxcars::Quaternion {
1567 x: 0.0,
1568 y: 0.0,
1569 z: 0.0,
1570 w: 1.0,
1571 },
1572 linear_velocity: None,
1573 angular_velocity: None,
1574 }),
1575 boost_amount: Some(boost_amount),
1576 last_boost_amount: Some(last_boost_amount),
1577 boost_active: false,
1578 dodge_active: false,
1579 powerslide_active: false,
1580 match_goals: None,
1581 match_assists: None,
1582 match_saves: None,
1583 match_shots: None,
1584 match_score: None,
1585 }
1586 }
1587
1588 #[test]
1589 fn records_inactive_pickup_without_active_collection() {
1590 let mut calculator = BoostCalculator::new();
1591 let player_id = PlayerId::Steam(1);
1592 let (pad_position, _) = standard_soccar_boost_pad_layout()
1593 .iter()
1594 .find(|(_, size)| *size == BoostPadSize::Small)
1595 .copied()
1596 .expect("standard layout should include small pads");
1597 let player = test_player(
1598 player_id.clone(),
1599 BOOST_KICKOFF_START_AMOUNT + SMALL_PAD_AMOUNT_RAW,
1600 0.0,
1601 pad_position,
1602 );
1603
1604 calculator
1605 .update_parts(
1606 &FrameInfo {
1607 frame_number: 1,
1608 time: 1.0,
1609 dt: 1.0 / 30.0,
1610 seconds_remaining: None,
1611 },
1612 &GameplayState {
1613 game_state: Some(GAME_STATE_GOAL_SCORED_REPLAY),
1614 ball_has_been_hit: Some(true),
1615 ..GameplayState::default()
1616 },
1617 &PlayerFrameState {
1618 players: vec![player],
1619 },
1620 &FrameEventsState {
1621 boost_pad_events: vec![BoostPadEvent {
1622 time: 1.0,
1623 frame: 1,
1624 pad_id: "inactive-small-pad".to_string(),
1625 player: Some(player_id.clone()),
1626 kind: BoostPadEventKind::PickedUp { sequence: 1 },
1627 }],
1628 ..FrameEventsState::default()
1629 },
1630 &PlayerVerticalState::default(),
1631 false,
1632 )
1633 .expect("inactive boost update should succeed");
1634
1635 let player_stats = calculator
1636 .player_stats()
1637 .get(&player_id)
1638 .expect("player stats should be recorded");
1639 assert_eq!(player_stats.amount_collected, 0.0);
1640 assert_eq!(player_stats.small_pads_collected, 0);
1641 assert_eq!(player_stats.amount_collected_inactive, SMALL_PAD_AMOUNT_RAW);
1642 assert_eq!(player_stats.small_pads_collected_inactive, 1);
1643 assert_eq!(calculator.team_zero_stats().amount_collected, 0.0);
1644 assert_eq!(
1645 calculator.team_zero_stats().amount_collected_inactive,
1646 SMALL_PAD_AMOUNT_RAW
1647 );
1648 assert_eq!(
1649 calculator.team_zero_stats().small_pads_collected_inactive,
1650 1
1651 );
1652 }
1653
1654 #[test]
1655 fn counts_reused_pickup_sequence_after_pad_respawn() {
1656 let mut calculator = BoostCalculator::new();
1657 let player_id = PlayerId::Steam(1);
1658 let (pad_position, _) = standard_soccar_boost_pad_layout()
1659 .iter()
1660 .find(|(_, size)| *size == BoostPadSize::Big)
1661 .copied()
1662 .expect("standard layout should include big pads");
1663 let pad_id = "reused-sequence-big-pad".to_string();
1664 let active_gameplay = GameplayState {
1665 ball_has_been_hit: Some(true),
1666 ..GameplayState::default()
1667 };
1668
1669 calculator
1670 .update_parts(
1671 &FrameInfo {
1672 frame_number: 1,
1673 time: 1.0,
1674 dt: 1.0 / 30.0,
1675 seconds_remaining: None,
1676 },
1677 &active_gameplay,
1678 &PlayerFrameState {
1679 players: vec![test_player(player_id.clone(), 100.0, 0.0, pad_position)],
1680 },
1681 &FrameEventsState {
1682 boost_pad_events: vec![BoostPadEvent {
1683 time: 1.0,
1684 frame: 1,
1685 pad_id: pad_id.clone(),
1686 player: Some(player_id.clone()),
1687 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1688 }],
1689 ..FrameEventsState::default()
1690 },
1691 &PlayerVerticalState::default(),
1692 true,
1693 )
1694 .expect("first boost update should succeed");
1695
1696 calculator
1697 .update_parts(
1698 &FrameInfo {
1699 frame_number: 2,
1700 time: 11.1,
1701 dt: 1.0 / 30.0,
1702 seconds_remaining: None,
1703 },
1704 &active_gameplay,
1705 &PlayerFrameState {
1706 players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1707 },
1708 &FrameEventsState {
1709 boost_pad_events: vec![BoostPadEvent {
1710 time: 11.1,
1711 frame: 2,
1712 pad_id: pad_id.clone(),
1713 player: None,
1714 kind: BoostPadEventKind::Available,
1715 }],
1716 ..FrameEventsState::default()
1717 },
1718 &PlayerVerticalState::default(),
1719 true,
1720 )
1721 .expect("pad availability update should succeed");
1722
1723 calculator
1724 .update_parts(
1725 &FrameInfo {
1726 frame_number: 3,
1727 time: 11.2,
1728 dt: 1.0 / 30.0,
1729 seconds_remaining: None,
1730 },
1731 &active_gameplay,
1732 &PlayerFrameState {
1733 players: vec![test_player(player_id.clone(), 200.0, 100.0, pad_position)],
1734 },
1735 &FrameEventsState {
1736 boost_pad_events: vec![BoostPadEvent {
1737 time: 11.2,
1738 frame: 3,
1739 pad_id,
1740 player: Some(player_id.clone()),
1741 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1742 }],
1743 ..FrameEventsState::default()
1744 },
1745 &PlayerVerticalState::default(),
1746 true,
1747 )
1748 .expect("second boost update should succeed");
1749
1750 let player_stats = calculator
1751 .player_stats()
1752 .get(&player_id)
1753 .expect("player stats should be recorded");
1754 assert_eq!(player_stats.big_pads_collected, 2);
1755 assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1756 }
1757
1758 #[test]
1759 fn counts_pickup_after_respawn_without_available_event() {
1760 let mut calculator = BoostCalculator::new();
1761 let player_id = PlayerId::Steam(1);
1762 let (pad_position, _) = standard_soccar_boost_pad_layout()
1763 .iter()
1764 .find(|(_, size)| *size == BoostPadSize::Big)
1765 .copied()
1766 .expect("standard layout should include big pads");
1767 let pad_id = "missing-available-big-pad".to_string();
1768 let active_gameplay = GameplayState {
1769 ball_has_been_hit: Some(true),
1770 ..GameplayState::default()
1771 };
1772
1773 for (frame_number, time, sequence, previous_boost, boost_amount) in
1774 [(1, 1.0, 7, 0.0, 100.0), (2, 11.2, 9, 100.0, 200.0)]
1775 {
1776 calculator
1777 .update_parts(
1778 &FrameInfo {
1779 frame_number,
1780 time,
1781 dt: 1.0 / 30.0,
1782 seconds_remaining: None,
1783 },
1784 &active_gameplay,
1785 &PlayerFrameState {
1786 players: vec![test_player(
1787 player_id.clone(),
1788 boost_amount,
1789 previous_boost,
1790 pad_position,
1791 )],
1792 },
1793 &FrameEventsState {
1794 boost_pad_events: vec![BoostPadEvent {
1795 time,
1796 frame: frame_number,
1797 pad_id: pad_id.clone(),
1798 player: Some(player_id.clone()),
1799 kind: BoostPadEventKind::PickedUp { sequence },
1800 }],
1801 ..FrameEventsState::default()
1802 },
1803 &PlayerVerticalState::default(),
1804 true,
1805 )
1806 .expect("boost update should succeed");
1807 }
1808
1809 let player_stats = calculator
1810 .player_stats()
1811 .get(&player_id)
1812 .expect("player stats should be recorded");
1813 assert_eq!(player_stats.big_pads_collected, 2);
1814 assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1815 }
1816
1817 #[test]
1818 fn skips_inactive_pickup_without_observed_boost_gain() {
1819 let mut calculator = BoostCalculator::new();
1820 let player_id = PlayerId::Steam(1);
1821 let (pad_position, _) = standard_soccar_boost_pad_layout()
1822 .iter()
1823 .find(|(_, size)| *size == BoostPadSize::Big)
1824 .copied()
1825 .expect("standard layout should include big pads");
1826
1827 calculator
1828 .update_parts(
1829 &FrameInfo {
1830 frame_number: 1,
1831 time: 1.0,
1832 dt: 1.0 / 30.0,
1833 seconds_remaining: None,
1834 },
1835 &GameplayState::default(),
1836 &PlayerFrameState {
1837 players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1838 },
1839 &FrameEventsState {
1840 boost_pad_events: vec![BoostPadEvent {
1841 time: 1.0,
1842 frame: 1,
1843 pad_id: "inactive-no-gain-big-pad".to_string(),
1844 player: Some(player_id.clone()),
1845 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1846 }],
1847 ..FrameEventsState::default()
1848 },
1849 &PlayerVerticalState::default(),
1850 false,
1851 )
1852 .expect("boost update should succeed");
1853
1854 let player_stats = calculator
1855 .player_stats()
1856 .get(&player_id)
1857 .expect("player stats should be recorded");
1858 assert_eq!(player_stats.big_pads_collected_inactive, 0);
1859 assert_eq!(player_stats.amount_collected_inactive, 0.0);
1860 }
1861
1862 #[test]
1863 fn infers_pad_counts_from_observed_boost_increases() {
1864 let mut calculator = BoostCalculator::new();
1865 let small_player = PlayerId::Steam(1);
1866 let big_player = PlayerId::Steam(2);
1867 let ambiguous_player = PlayerId::Steam(3);
1868 let respawn_player = PlayerId::Steam(4);
1869 let two_small_player = PlayerId::Steam(5);
1870 let position = glam::Vec3::ZERO;
1871 let active_gameplay = GameplayState {
1872 ball_has_been_hit: Some(true),
1873 ..GameplayState::default()
1874 };
1875
1876 calculator
1877 .update_parts(
1878 &FrameInfo {
1879 frame_number: 1,
1880 time: 1.0,
1881 dt: 1.0 / 30.0,
1882 seconds_remaining: None,
1883 },
1884 &active_gameplay,
1885 &PlayerFrameState {
1886 players: vec![
1887 test_player(small_player.clone(), 10.0, 10.0, position),
1888 test_player(big_player.clone(), 10.0, 10.0, position),
1889 test_player(ambiguous_player.clone(), 230.0, 230.0, position),
1890 test_player(respawn_player.clone(), 0.0, 0.0, position),
1891 test_player(
1892 two_small_player.clone(),
1893 BOOST_KICKOFF_START_AMOUNT,
1894 BOOST_KICKOFF_START_AMOUNT,
1895 position,
1896 ),
1897 ],
1898 },
1899 &FrameEventsState::default(),
1900 &PlayerVerticalState::default(),
1901 true,
1902 )
1903 .expect("first boost update should succeed");
1904
1905 calculator
1906 .update_parts(
1907 &FrameInfo {
1908 frame_number: 2,
1909 time: 1.1,
1910 dt: 1.0 / 30.0,
1911 seconds_remaining: None,
1912 },
1913 &active_gameplay,
1914 &PlayerFrameState {
1915 players: vec![
1916 test_player(
1917 small_player.clone(),
1918 10.0 + SMALL_PAD_AMOUNT_RAW,
1919 10.0,
1920 position,
1921 ),
1922 test_player(big_player.clone(), BOOST_MAX_AMOUNT, 10.0, position),
1923 test_player(ambiguous_player.clone(), BOOST_MAX_AMOUNT, 230.0, position),
1924 test_player(
1925 respawn_player.clone(),
1926 BOOST_KICKOFF_START_AMOUNT,
1927 0.0,
1928 position,
1929 ),
1930 test_player(
1931 two_small_player.clone(),
1932 BOOST_KICKOFF_START_AMOUNT + 2.0 * SMALL_PAD_AMOUNT_RAW,
1933 BOOST_KICKOFF_START_AMOUNT,
1934 position,
1935 ),
1936 ],
1937 },
1938 &FrameEventsState::default(),
1939 &PlayerVerticalState::default(),
1940 true,
1941 )
1942 .expect("second boost update should succeed");
1943
1944 calculator
1945 .finish_calculation()
1946 .expect("pending inferred pickups should flush");
1947 let events = calculator.pickup_comparison_events();
1948 assert_eq!(events.len(), 5);
1949 assert!(events
1950 .iter()
1951 .all(|event| event.comparison == BoostPickupComparison::Missed));
1952 assert_eq!(
1953 events
1954 .iter()
1955 .filter(|event| event.pad_type == BoostPickupPadType::Big)
1956 .count(),
1957 1
1958 );
1959 assert_eq!(
1960 events
1961 .iter()
1962 .filter(|event| event.pad_type == BoostPickupPadType::Small)
1963 .count(),
1964 3
1965 );
1966 assert_eq!(
1967 events
1968 .iter()
1969 .filter(|event| event.pad_type == BoostPickupPadType::Ambiguous)
1970 .count(),
1971 1
1972 );
1973 assert!(events.iter().all(|event| event.player_id != respawn_player));
1974 }
1975
1976 #[test]
1977 fn matches_two_small_pickups_from_one_observed_boost_increase() {
1978 let mut calculator = BoostCalculator::new();
1979 let player_id = PlayerId::Steam(1);
1980 let (pad_position, _) = standard_soccar_boost_pad_layout()
1981 .iter()
1982 .find(|(_, size)| *size == BoostPadSize::Small)
1983 .copied()
1984 .expect("standard layout should include small pads");
1985 let active_gameplay = GameplayState {
1986 ball_has_been_hit: Some(true),
1987 ..GameplayState::default()
1988 };
1989
1990 calculator
1991 .update_parts(
1992 &FrameInfo {
1993 frame_number: 1,
1994 time: 1.0,
1995 dt: 1.0 / 30.0,
1996 seconds_remaining: None,
1997 },
1998 &active_gameplay,
1999 &PlayerFrameState {
2000 players: vec![test_player(
2001 player_id.clone(),
2002 BOOST_KICKOFF_START_AMOUNT,
2003 BOOST_KICKOFF_START_AMOUNT,
2004 pad_position,
2005 )],
2006 },
2007 &FrameEventsState::default(),
2008 &PlayerVerticalState::default(),
2009 true,
2010 )
2011 .expect("first boost update should succeed");
2012
2013 calculator
2014 .update_parts(
2015 &FrameInfo {
2016 frame_number: 2,
2017 time: 1.1,
2018 dt: 1.0 / 30.0,
2019 seconds_remaining: None,
2020 },
2021 &active_gameplay,
2022 &PlayerFrameState {
2023 players: vec![test_player(
2024 player_id.clone(),
2025 BOOST_KICKOFF_START_AMOUNT + 2.0 * SMALL_PAD_AMOUNT_RAW,
2026 BOOST_KICKOFF_START_AMOUNT,
2027 pad_position,
2028 )],
2029 },
2030 &FrameEventsState {
2031 boost_pad_events: vec![
2032 BoostPadEvent {
2033 time: 1.1,
2034 frame: 2,
2035 pad_id: "small-pad-one".to_string(),
2036 player: Some(player_id.clone()),
2037 kind: BoostPadEventKind::PickedUp { sequence: 1 },
2038 },
2039 BoostPadEvent {
2040 time: 1.1,
2041 frame: 2,
2042 pad_id: "small-pad-two".to_string(),
2043 player: Some(player_id.clone()),
2044 kind: BoostPadEventKind::PickedUp { sequence: 1 },
2045 },
2046 ],
2047 ..FrameEventsState::default()
2048 },
2049 &PlayerVerticalState::default(),
2050 true,
2051 )
2052 .expect("second boost update should succeed");
2053
2054 calculator
2055 .finish_calculation()
2056 .expect("pickup comparisons should flush");
2057
2058 let events = calculator.pickup_comparison_events();
2059 assert_eq!(events.len(), 2);
2060 assert!(events.iter().all(|event| {
2061 event.comparison == BoostPickupComparison::Both
2062 && event.pad_type == BoostPickupPadType::Small
2063 }));
2064 }
2065}