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_reason(
875 previous_boost: f32,
876 boost: f32,
877 kickoff_phase_active: bool,
878 demo_respawn_supported: bool,
879 ) -> BoostIncreaseReason {
880 const TOLERANCE: f32 = 1.0;
881 let delta = boost - previous_boost;
882 if delta <= TOLERANCE {
883 return 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 BoostIncreaseReason::DemoRespawn;
889 }
890 if kickoff_phase_active && is_respawn_value {
891 return BoostIncreaseReason::KickoffRespawn;
892 }
893 if is_respawn_value {
894 return 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 delta > big_pad_floor {
900 return BoostIncreaseReason::BigPad;
901 }
902 if boost >= BOOST_MAX_AMOUNT - TOLERANCE {
903 return BoostIncreaseReason::AmbiguousPad;
904 }
905 if delta >= small_pad_floor {
906 return BoostIncreaseReason::SmallPad;
907 }
908 BoostIncreaseReason::Unknown
909 }
910
911 fn emit_pickup_comparison_event(
912 &mut self,
913 comparison: BoostPickupComparison,
914 inferred: Option<PendingBoostPickupEvent>,
915 reported: Option<PendingBoostPickupEvent>,
916 ) {
917 let reference = inferred.as_ref().or(reported.as_ref()).unwrap();
918 let pad_type = reported
919 .as_ref()
920 .map(|event| event.pad_type)
921 .or_else(|| inferred.as_ref().map(|event| event.pad_type))
922 .unwrap_or(reference.pad_type);
923 let field_half = reported
924 .as_ref()
925 .map(|event| event.field_half)
926 .or_else(|| inferred.as_ref().map(|event| event.field_half))
927 .unwrap_or(reference.field_half);
928 let activity = reported
929 .as_ref()
930 .map(|event| event.activity)
931 .or_else(|| inferred.as_ref().map(|event| event.activity))
932 .unwrap_or(reference.activity);
933 let event_frame = inferred
934 .as_ref()
935 .map(|event| event.frame)
936 .or_else(|| reported.as_ref().map(|event| event.frame))
937 .unwrap_or(reference.frame);
938 let event_time = inferred
939 .as_ref()
940 .map(|event| event.time)
941 .or_else(|| reported.as_ref().map(|event| event.time))
942 .unwrap_or(reference.time);
943 let comparison_event = BoostPickupComparisonEvent {
944 comparison,
945 frame: event_frame,
946 time: event_time,
947 player_id: reference.player_id.clone(),
948 is_team_0: reference.is_team_0,
949 pad_type,
950 field_half,
951 activity,
952 reported_frame: reported.as_ref().map(|event| event.frame),
953 reported_time: reported.as_ref().map(|event| event.time),
954 inferred_frame: inferred.as_ref().map(|event| event.frame),
955 inferred_time: inferred.as_ref().map(|event| event.time),
956 boost_before: inferred.as_ref().and_then(|event| event.boost_before),
957 boost_after: inferred.as_ref().and_then(|event| event.boost_after),
958 };
959 self.pickup_comparison_events.push(comparison_event);
960 }
961
962 fn matching_pending_pickup_index(
963 pending: &VecDeque<PendingBoostPickupEvent>,
964 event: &PendingBoostPickupEvent,
965 pending_is_inferred: bool,
966 ) -> Option<usize> {
967 pending
968 .iter()
969 .enumerate()
970 .filter(|(_, pending_event)| {
971 pending_event.player_id == event.player_id
972 && if pending_is_inferred {
973 pending_event.pad_type.is_compatible_with(event.pad_type)
974 } else {
975 event.pad_type.is_compatible_with(pending_event.pad_type)
976 }
977 && pending_event.frame.abs_diff(event.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
978 })
979 .min_by_key(|(_, pending_event)| pending_event.frame.abs_diff(event.frame))
980 .map(|(index, _)| index)
981 }
982
983 fn record_inferred_pickup(&mut self, event: PendingBoostPickupEvent) {
984 if let Some(index) =
985 Self::matching_pending_pickup_index(&self.pending_reported_pickups, &event, false)
986 {
987 let reported = self
988 .pending_reported_pickups
989 .remove(index)
990 .expect("matched reported pickup index should exist");
991 self.emit_pickup_comparison_event(
992 BoostPickupComparison::Both,
993 Some(event),
994 Some(reported),
995 );
996 } else {
997 self.pending_inferred_pickups.push_back(event);
998 }
999 }
1000
1001 fn record_reported_pickup(&mut self, event: PendingBoostPickupEvent) {
1002 if let Some(index) =
1003 Self::matching_pending_pickup_index(&self.pending_inferred_pickups, &event, true)
1004 {
1005 let inferred = self
1006 .pending_inferred_pickups
1007 .remove(index)
1008 .expect("matched inferred pickup index should exist");
1009 self.emit_pickup_comparison_event(
1010 BoostPickupComparison::Both,
1011 Some(inferred),
1012 Some(event),
1013 );
1014 } else {
1015 self.pending_reported_pickups.push_back(event);
1016 }
1017 }
1018
1019 fn flush_stale_pickup_comparisons(&mut self, current_frame: usize) {
1020 while self
1021 .pending_inferred_pickups
1022 .front()
1023 .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < current_frame)
1024 {
1025 let event = self.pending_inferred_pickups.pop_front().unwrap();
1026 self.emit_pickup_comparison_event(BoostPickupComparison::Missed, Some(event), None);
1027 }
1028 while self
1029 .pending_reported_pickups
1030 .front()
1031 .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < current_frame)
1032 {
1033 let event = self.pending_reported_pickups.pop_front().unwrap();
1034 self.emit_pickup_comparison_event(BoostPickupComparison::Ghost, None, Some(event));
1035 }
1036 }
1037
1038 pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
1039 while let Some(event) = self.pending_inferred_pickups.pop_front() {
1040 self.emit_pickup_comparison_event(BoostPickupComparison::Missed, Some(event), None);
1041 }
1042 while let Some(event) = self.pending_reported_pickups.pop_front() {
1043 self.emit_pickup_comparison_event(BoostPickupComparison::Ghost, None, Some(event));
1044 }
1045 Ok(())
1046 }
1047
1048 fn inactive_pickup_stats(
1049 &self,
1050 player: &PlayerSample,
1051 pad_id: &str,
1052 previous_boost_amount: f32,
1053 respawn_amount: f32,
1054 ) -> Option<(f32, BoostPadSize)> {
1055 let pad_size = self
1056 .known_pad_sizes
1057 .get(pad_id)
1058 .copied()
1059 .or_else(|| self.guess_pad_size_from_position(pad_id, player.position()?))?;
1060 let nominal_gain = match pad_size {
1061 BoostPadSize::Big => BOOST_MAX_AMOUNT,
1062 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
1063 };
1064 let capacity_limited_gain = (BOOST_MAX_AMOUNT - previous_boost_amount)
1065 .min(nominal_gain)
1066 .max(0.0);
1067 let observed_gain = player
1068 .boost_amount
1069 .map(|boost_amount| (boost_amount - previous_boost_amount - respawn_amount).max(0.0))
1070 .unwrap_or(0.0);
1071 if observed_gain <= 1.0 {
1072 return None;
1073 }
1074 Some((
1075 capacity_limited_gain.max(observed_gain).min(nominal_gain),
1076 pad_size,
1077 ))
1078 }
1079
1080 pub fn update_parts(
1081 &mut self,
1082 frame: &FrameInfo,
1083 gameplay: &GameplayState,
1084 players: &PlayerFrameState,
1085 events: &FrameEventsState,
1086 vertical_state: &PlayerVerticalState,
1087 live_play: bool,
1088 ) -> SubtrActorResult<()> {
1089 let boost_levels_live = Self::boost_levels_live(live_play);
1090 let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
1091 let track_boost_pickups = Self::tracks_boost_pickups(gameplay, live_play);
1092 let boost_levels_resumed_this_sample =
1093 boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
1094 let kickoff_phase_active = gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
1095 || gameplay.kickoff_countdown_time.is_some_and(|t| t > 0)
1096 || gameplay.ball_has_been_hit == Some(false);
1097 let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
1098 if kickoff_phase_started {
1099 self.kickoff_respawn_awarded.clear();
1100 }
1101 for demo in &events.demo_events {
1102 self.pending_demo_respawns.insert(demo.victim.clone());
1103 }
1104
1105 let mut current_boost_amounts = Vec::new();
1106 let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
1107 let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
1108
1109 for event in &events.boost_pad_events {
1110 let BoostPadEventKind::PickedUp { .. } = event.kind else {
1111 continue;
1112 };
1113 let Some(player_id) = &event.player else {
1114 continue;
1115 };
1116 *pickup_counts_by_player
1117 .entry(player_id.clone())
1118 .or_default() += 1;
1119 }
1120
1121 for player in &players.players {
1122 let Some(boost_amount) = player.boost_amount else {
1123 continue;
1124 };
1125 let previous_sample_boost_amount =
1126 self.previous_boost_amounts.get(&player.player_id).copied();
1127 let previous_boost_amount = player
1128 .last_boost_amount
1129 .unwrap_or_else(|| previous_sample_boost_amount.unwrap_or(boost_amount));
1130 let previous_boost_amount = if boost_levels_resumed_this_sample {
1131 boost_amount
1132 } else {
1133 previous_boost_amount
1134 };
1135 let demo_respawn_supported = self.pending_demo_respawns.contains(&player.player_id)
1136 && player.rigid_body.is_some();
1137 if let Some(previous_sample_boost_amount) = previous_sample_boost_amount {
1138 let reason = Self::classify_boost_increase_reason(
1139 previous_sample_boost_amount,
1140 boost_amount,
1141 kickoff_phase_active,
1142 demo_respawn_supported,
1143 );
1144 if let Ok(pad_type) = BoostPickupPadType::try_from(reason) {
1145 self.record_inferred_pickup(PendingBoostPickupEvent {
1146 frame: frame.frame_number,
1147 time: frame.time,
1148 player_id: player.player_id.clone(),
1149 is_team_0: player.is_team_0,
1150 pad_type,
1151 field_half: Self::field_half_from_position(
1152 player.is_team_0,
1153 player.position(),
1154 ),
1155 activity: Self::activity_label(live_play),
1156 boost_before: Some(previous_sample_boost_amount),
1157 boost_after: Some(boost_amount),
1158 });
1159 }
1160 }
1161 if track_boost_levels {
1162 let average_boost_amount = (previous_boost_amount + boost_amount) * 0.5;
1163 let time_zero_boost = frame.dt
1164 * Self::interval_fraction_in_boost_range(
1165 previous_boost_amount,
1166 boost_amount,
1167 0.0,
1168 BOOST_ZERO_BAND_RAW,
1169 );
1170 let time_hundred_boost = frame.dt
1171 * Self::interval_fraction_in_boost_range(
1172 previous_boost_amount,
1173 boost_amount,
1174 BOOST_FULL_BAND_MIN_RAW,
1175 BOOST_MAX_AMOUNT + 1.0,
1176 );
1177 let time_boost_0_25 = frame.dt
1178 * Self::interval_fraction_in_boost_range(
1179 previous_boost_amount,
1180 boost_amount,
1181 0.0,
1182 boost_percent_to_amount(25.0),
1183 );
1184 let time_boost_25_50 = frame.dt
1185 * Self::interval_fraction_in_boost_range(
1186 previous_boost_amount,
1187 boost_amount,
1188 boost_percent_to_amount(25.0),
1189 boost_percent_to_amount(50.0),
1190 );
1191 let time_boost_50_75 = frame.dt
1192 * Self::interval_fraction_in_boost_range(
1193 previous_boost_amount,
1194 boost_amount,
1195 boost_percent_to_amount(50.0),
1196 boost_percent_to_amount(75.0),
1197 );
1198 let time_boost_75_100 = frame.dt
1199 * Self::interval_fraction_in_boost_range(
1200 previous_boost_amount,
1201 boost_amount,
1202 boost_percent_to_amount(75.0),
1203 BOOST_MAX_AMOUNT + 1.0,
1204 );
1205 let stats = self
1206 .player_stats
1207 .entry(player.player_id.clone())
1208 .or_default();
1209 let team_stats = if player.is_team_0 {
1210 &mut self.team_zero_stats
1211 } else {
1212 &mut self.team_one_stats
1213 };
1214
1215 stats.tracked_time += frame.dt;
1216 stats.boost_integral += average_boost_amount * frame.dt;
1217 team_stats.tracked_time += frame.dt;
1218 team_stats.boost_integral += average_boost_amount * frame.dt;
1219 stats.time_zero_boost += time_zero_boost;
1220 team_stats.time_zero_boost += time_zero_boost;
1221 stats.time_hundred_boost += time_hundred_boost;
1222 team_stats.time_hundred_boost += time_hundred_boost;
1223 stats.time_boost_0_25 += time_boost_0_25;
1224 team_stats.time_boost_0_25 += time_boost_0_25;
1225 stats.time_boost_25_50 += time_boost_25_50;
1226 team_stats.time_boost_25_50 += time_boost_25_50;
1227 stats.time_boost_50_75 += time_boost_50_75;
1228 team_stats.time_boost_50_75 += time_boost_50_75;
1229 stats.time_boost_75_100 += time_boost_75_100;
1230 team_stats.time_boost_75_100 += time_boost_75_100;
1231 }
1232
1233 let mut respawn_amount = 0.0;
1234 let first_seen_player = self
1238 .initial_respawn_awarded
1239 .insert(player.player_id.clone());
1240 if first_seen_player
1241 || (kickoff_phase_active
1242 && !self.kickoff_respawn_awarded.contains(&player.player_id))
1243 {
1244 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1245 self.kickoff_respawn_awarded
1246 .insert(player.player_id.clone());
1247 }
1248 if demo_respawn_supported {
1249 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1250 self.pending_demo_respawns.remove(&player.player_id);
1251 }
1252 if respawn_amount > 0.0 {
1253 self.apply_respawn_amount(&player.player_id, player.is_team_0, respawn_amount);
1254 }
1255 respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
1256
1257 current_boost_amounts.push((player.player_id.clone(), boost_amount));
1258 }
1259
1260 for event in &events.boost_pad_events {
1261 match event.kind {
1262 BoostPadEventKind::PickedUp { sequence } => {
1263 if !track_boost_pickups && !self.config.include_non_live_pickups {
1264 let Some(player_id) = &event.player else {
1265 continue;
1266 };
1267 let Some(player) = players
1268 .players
1269 .iter()
1270 .find(|player| &player.player_id == player_id)
1271 else {
1272 continue;
1273 };
1274 let previous_boost_amount = self
1275 .previous_boost_amounts
1276 .get(player_id)
1277 .copied()
1278 .or(player.last_boost_amount)
1279 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1280 let respawn_amount = respawn_amounts_by_player
1281 .get(player_id)
1282 .copied()
1283 .unwrap_or(0.0);
1284 let Some((collected_amount, pad_size)) = self.inactive_pickup_stats(
1285 player,
1286 &event.pad_id,
1287 previous_boost_amount,
1288 respawn_amount,
1289 ) else {
1290 continue;
1291 };
1292 if !self.inactive_pickup_frames.insert((
1293 player_id.clone(),
1294 event.frame,
1295 pad_size,
1296 )) {
1297 continue;
1298 }
1299 self.apply_inactive_pickup(
1300 player_id,
1301 player.is_team_0,
1302 collected_amount,
1303 pad_size,
1304 );
1305 self.record_reported_pickup(PendingBoostPickupEvent {
1306 frame: event.frame,
1307 time: event.time,
1308 player_id: player_id.clone(),
1309 is_team_0: player.is_team_0,
1310 pad_type: pad_size.into(),
1311 field_half: Self::field_half_from_position(
1312 player.is_team_0,
1313 player.position(),
1314 ),
1315 activity: BoostPickupActivity::Inactive,
1316 boost_before: None,
1317 boost_after: None,
1318 });
1319 continue;
1320 }
1321 let Some(player_id) = &event.player else {
1322 continue;
1323 };
1324 let Some(player) = players
1325 .players
1326 .iter()
1327 .find(|player| &player.player_id == player_id)
1328 else {
1329 continue;
1330 };
1331 if self.unavailable_pad_is_recent(&event.pad_id, event.time, player.position())
1332 {
1333 continue;
1334 }
1335 let pickup_key = (event.pad_id.clone(), player_id.clone());
1336 if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
1337 continue;
1338 }
1339 self.pickup_frames.insert(pickup_key, event.frame);
1340 if self.seen_pickup_sequence_is_recent(
1341 &event.pad_id,
1342 sequence,
1343 event.time,
1344 player.position(),
1345 ) {
1346 continue;
1347 }
1348 self.seen_pickup_sequence_times
1349 .insert((event.pad_id.clone(), sequence), event.time);
1350 self.unavailable_pads.insert(event.pad_id.clone());
1351 self.last_pickup_times
1352 .insert(event.pad_id.clone(), event.time);
1353 if let Some(position) = player.position() {
1354 self.observed_pad_positions
1355 .entry(event.pad_id.clone())
1356 .or_default()
1357 .observe(position);
1358 }
1359 let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
1360 self.previous_boost_amounts
1361 .get(player_id)
1362 .copied()
1363 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0))
1364 });
1365 let pre_applied_collected_amount =
1366 if pickup_counts_by_player.get(player_id).copied() == Some(1) {
1367 self.previous_boost_amounts
1368 .get(player_id)
1369 .copied()
1370 .map(|previous_sample_boost_amount| {
1371 let respawn_amount = respawn_amounts_by_player
1372 .get(player_id)
1373 .copied()
1374 .unwrap_or(0.0);
1375 (player.boost_amount.unwrap_or(previous_boost_amount)
1376 - previous_sample_boost_amount
1377 - respawn_amount)
1378 .max(0.0)
1379 })
1380 .unwrap_or(0.0)
1381 } else {
1382 0.0
1383 };
1384 let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
1385 .then(|| {
1386 self.guess_pad_size_from_position(
1387 &event.pad_id,
1388 player.position().unwrap_or(glam::Vec3::ZERO),
1389 )
1390 })
1391 .flatten();
1392 self.apply_pickup_collected_amount(
1393 player_id,
1394 player.is_team_0,
1395 pre_applied_collected_amount,
1396 pre_applied_pad_size,
1397 );
1398 let pending_pickup = PendingBoostPickup {
1399 player_id: player_id.clone(),
1400 is_team_0: player.is_team_0,
1401 previous_boost_amount,
1402 pre_applied_collected_amount,
1403 pre_applied_pad_size,
1404 player_position: player.position().unwrap_or(glam::Vec3::ZERO),
1405 };
1406
1407 let pad_size = self
1408 .known_pad_sizes
1409 .get(&event.pad_id)
1410 .copied()
1411 .or_else(|| {
1412 let mut size = self.guess_pad_size_from_position(
1413 &event.pad_id,
1414 player.position().unwrap_or(glam::Vec3::ZERO),
1415 )?;
1416 if size == BoostPadSize::Small
1420 && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW * 1.5
1421 {
1422 size = BoostPadSize::Big;
1423 }
1424 self.known_pad_sizes.insert(event.pad_id.clone(), size);
1425 Some(size)
1426 });
1427 if let Some(pad_size) = pad_size {
1428 let field_half =
1429 self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
1430 self.record_reported_pickup(PendingBoostPickupEvent {
1431 frame: event.frame,
1432 time: event.time,
1433 player_id: player_id.clone(),
1434 is_team_0: player.is_team_0,
1435 pad_type: pad_size.into(),
1436 field_half,
1437 activity: Self::activity_label(track_boost_pickups),
1438 boost_before: None,
1439 boost_after: None,
1440 });
1441 }
1442 }
1443 BoostPadEventKind::Available => {
1444 if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
1445 let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
1446 else {
1447 continue;
1448 };
1449 if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
1450 {
1451 continue;
1452 }
1453 }
1454 self.unavailable_pads.remove(&event.pad_id);
1455 }
1456 }
1457 }
1458 self.flush_stale_pickup_comparisons(frame.frame_number);
1459
1460 let mut team_zero_used = self.team_zero_stats.amount_used;
1461 let mut team_one_used = self.team_one_stats.amount_used;
1462 for player in &players.players {
1463 let Some(boost_amount) = player.boost_amount else {
1464 continue;
1465 };
1466 let stats = self
1467 .player_stats
1468 .entry(player.player_id.clone())
1469 .or_default();
1470 let previous_amount_used = stats.amount_used;
1471 let amount_used_raw = (stats.amount_obtained() - boost_amount).max(0.0);
1472 let amount_used = amount_used_raw.max(stats.amount_used);
1473 if track_boost_levels {
1474 let split_amount = stats.amount_used_by_vertical_band();
1475 let amount_used_delta = (amount_used - split_amount).max(0.0);
1476 if amount_used_delta > 0.0 {
1477 let speed = player.speed();
1478 let previous_speed = self
1479 .previous_player_speeds
1480 .get(&player.player_id)
1481 .copied()
1482 .or(speed);
1483 let previous_speed = if boost_levels_resumed_this_sample {
1484 speed
1485 } else {
1486 previous_speed
1487 };
1488 let used_while_supersonic = player.boost_active
1489 && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
1490 && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD;
1491 let team_stats = if player.is_team_0 {
1492 &mut self.team_zero_stats
1493 } else {
1494 &mut self.team_one_stats
1495 };
1496 if vertical_state.is_grounded(&player.player_id) {
1497 stats.amount_used_while_grounded += amount_used_delta;
1498 team_stats.amount_used_while_grounded += amount_used_delta;
1499 } else {
1500 stats.amount_used_while_airborne += amount_used_delta;
1501 team_stats.amount_used_while_airborne += amount_used_delta;
1502 }
1503 if used_while_supersonic {
1504 stats.amount_used_while_supersonic += amount_used_delta;
1505 team_stats.amount_used_while_supersonic += amount_used_delta;
1506 }
1507 }
1508 }
1509 stats.amount_used = amount_used;
1510 let amount_used_delta = amount_used - previous_amount_used;
1511 if amount_used_delta <= 0.0 {
1512 continue;
1513 }
1514 if player.is_team_0 {
1515 team_zero_used += amount_used_delta;
1516 } else {
1517 team_one_used += amount_used_delta;
1518 }
1519 }
1520 self.team_zero_stats.amount_used = team_zero_used;
1521 self.team_one_stats.amount_used = team_one_used;
1522 for (player_id, boost_amount) in current_boost_amounts {
1523 self.previous_boost_amounts.insert(player_id, boost_amount);
1524 }
1525 for player in &players.players {
1526 if let Some(speed) = player.speed() {
1527 self.previous_player_speeds
1528 .insert(player.player_id.clone(), speed);
1529 }
1530 }
1531 self.warn_for_sample_boost_invariants(frame, players);
1532 self.kickoff_phase_active_last_frame = kickoff_phase_active;
1533 self.previous_boost_levels_live = Some(boost_levels_live);
1534
1535 Ok(())
1536 }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::*;
1542
1543 fn test_player(
1544 player_id: PlayerId,
1545 boost_amount: f32,
1546 last_boost_amount: f32,
1547 position: glam::Vec3,
1548 ) -> PlayerSample {
1549 PlayerSample {
1550 player_id,
1551 is_team_0: true,
1552 rigid_body: Some(boxcars::RigidBody {
1553 sleeping: false,
1554 location: glam_to_vec(&position),
1555 rotation: boxcars::Quaternion {
1556 x: 0.0,
1557 y: 0.0,
1558 z: 0.0,
1559 w: 1.0,
1560 },
1561 linear_velocity: None,
1562 angular_velocity: None,
1563 }),
1564 boost_amount: Some(boost_amount),
1565 last_boost_amount: Some(last_boost_amount),
1566 boost_active: false,
1567 dodge_active: false,
1568 powerslide_active: false,
1569 match_goals: None,
1570 match_assists: None,
1571 match_saves: None,
1572 match_shots: None,
1573 match_score: None,
1574 }
1575 }
1576
1577 #[test]
1578 fn records_inactive_pickup_without_active_collection() {
1579 let mut calculator = BoostCalculator::new();
1580 let player_id = PlayerId::Steam(1);
1581 let (pad_position, _) = standard_soccar_boost_pad_layout()
1582 .iter()
1583 .find(|(_, size)| *size == BoostPadSize::Small)
1584 .copied()
1585 .expect("standard layout should include small pads");
1586 let player = test_player(
1587 player_id.clone(),
1588 BOOST_KICKOFF_START_AMOUNT + SMALL_PAD_AMOUNT_RAW,
1589 0.0,
1590 pad_position,
1591 );
1592
1593 calculator
1594 .update_parts(
1595 &FrameInfo {
1596 frame_number: 1,
1597 time: 1.0,
1598 dt: 1.0 / 30.0,
1599 seconds_remaining: None,
1600 },
1601 &GameplayState {
1602 game_state: Some(GAME_STATE_GOAL_SCORED_REPLAY),
1603 ball_has_been_hit: Some(true),
1604 ..GameplayState::default()
1605 },
1606 &PlayerFrameState {
1607 players: vec![player],
1608 },
1609 &FrameEventsState {
1610 boost_pad_events: vec![BoostPadEvent {
1611 time: 1.0,
1612 frame: 1,
1613 pad_id: "inactive-small-pad".to_string(),
1614 player: Some(player_id.clone()),
1615 kind: BoostPadEventKind::PickedUp { sequence: 1 },
1616 }],
1617 ..FrameEventsState::default()
1618 },
1619 &PlayerVerticalState::default(),
1620 false,
1621 )
1622 .expect("inactive boost update should succeed");
1623
1624 let player_stats = calculator
1625 .player_stats()
1626 .get(&player_id)
1627 .expect("player stats should be recorded");
1628 assert_eq!(player_stats.amount_collected, 0.0);
1629 assert_eq!(player_stats.small_pads_collected, 0);
1630 assert_eq!(player_stats.amount_collected_inactive, SMALL_PAD_AMOUNT_RAW);
1631 assert_eq!(player_stats.small_pads_collected_inactive, 1);
1632 assert_eq!(calculator.team_zero_stats().amount_collected, 0.0);
1633 assert_eq!(
1634 calculator.team_zero_stats().amount_collected_inactive,
1635 SMALL_PAD_AMOUNT_RAW
1636 );
1637 assert_eq!(
1638 calculator.team_zero_stats().small_pads_collected_inactive,
1639 1
1640 );
1641 }
1642
1643 #[test]
1644 fn counts_reused_pickup_sequence_after_pad_respawn() {
1645 let mut calculator = BoostCalculator::new();
1646 let player_id = PlayerId::Steam(1);
1647 let (pad_position, _) = standard_soccar_boost_pad_layout()
1648 .iter()
1649 .find(|(_, size)| *size == BoostPadSize::Big)
1650 .copied()
1651 .expect("standard layout should include big pads");
1652 let pad_id = "reused-sequence-big-pad".to_string();
1653 let active_gameplay = GameplayState {
1654 ball_has_been_hit: Some(true),
1655 ..GameplayState::default()
1656 };
1657
1658 calculator
1659 .update_parts(
1660 &FrameInfo {
1661 frame_number: 1,
1662 time: 1.0,
1663 dt: 1.0 / 30.0,
1664 seconds_remaining: None,
1665 },
1666 &active_gameplay,
1667 &PlayerFrameState {
1668 players: vec![test_player(player_id.clone(), 100.0, 0.0, pad_position)],
1669 },
1670 &FrameEventsState {
1671 boost_pad_events: vec![BoostPadEvent {
1672 time: 1.0,
1673 frame: 1,
1674 pad_id: pad_id.clone(),
1675 player: Some(player_id.clone()),
1676 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1677 }],
1678 ..FrameEventsState::default()
1679 },
1680 &PlayerVerticalState::default(),
1681 true,
1682 )
1683 .expect("first boost update should succeed");
1684
1685 calculator
1686 .update_parts(
1687 &FrameInfo {
1688 frame_number: 2,
1689 time: 11.1,
1690 dt: 1.0 / 30.0,
1691 seconds_remaining: None,
1692 },
1693 &active_gameplay,
1694 &PlayerFrameState {
1695 players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1696 },
1697 &FrameEventsState {
1698 boost_pad_events: vec![BoostPadEvent {
1699 time: 11.1,
1700 frame: 2,
1701 pad_id: pad_id.clone(),
1702 player: None,
1703 kind: BoostPadEventKind::Available,
1704 }],
1705 ..FrameEventsState::default()
1706 },
1707 &PlayerVerticalState::default(),
1708 true,
1709 )
1710 .expect("pad availability update should succeed");
1711
1712 calculator
1713 .update_parts(
1714 &FrameInfo {
1715 frame_number: 3,
1716 time: 11.2,
1717 dt: 1.0 / 30.0,
1718 seconds_remaining: None,
1719 },
1720 &active_gameplay,
1721 &PlayerFrameState {
1722 players: vec![test_player(player_id.clone(), 200.0, 100.0, pad_position)],
1723 },
1724 &FrameEventsState {
1725 boost_pad_events: vec![BoostPadEvent {
1726 time: 11.2,
1727 frame: 3,
1728 pad_id,
1729 player: Some(player_id.clone()),
1730 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1731 }],
1732 ..FrameEventsState::default()
1733 },
1734 &PlayerVerticalState::default(),
1735 true,
1736 )
1737 .expect("second boost update should succeed");
1738
1739 let player_stats = calculator
1740 .player_stats()
1741 .get(&player_id)
1742 .expect("player stats should be recorded");
1743 assert_eq!(player_stats.big_pads_collected, 2);
1744 assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1745 }
1746
1747 #[test]
1748 fn counts_pickup_after_respawn_without_available_event() {
1749 let mut calculator = BoostCalculator::new();
1750 let player_id = PlayerId::Steam(1);
1751 let (pad_position, _) = standard_soccar_boost_pad_layout()
1752 .iter()
1753 .find(|(_, size)| *size == BoostPadSize::Big)
1754 .copied()
1755 .expect("standard layout should include big pads");
1756 let pad_id = "missing-available-big-pad".to_string();
1757 let active_gameplay = GameplayState {
1758 ball_has_been_hit: Some(true),
1759 ..GameplayState::default()
1760 };
1761
1762 for (frame_number, time, sequence, previous_boost, boost_amount) in
1763 [(1, 1.0, 7, 0.0, 100.0), (2, 11.2, 9, 100.0, 200.0)]
1764 {
1765 calculator
1766 .update_parts(
1767 &FrameInfo {
1768 frame_number,
1769 time,
1770 dt: 1.0 / 30.0,
1771 seconds_remaining: None,
1772 },
1773 &active_gameplay,
1774 &PlayerFrameState {
1775 players: vec![test_player(
1776 player_id.clone(),
1777 boost_amount,
1778 previous_boost,
1779 pad_position,
1780 )],
1781 },
1782 &FrameEventsState {
1783 boost_pad_events: vec![BoostPadEvent {
1784 time,
1785 frame: frame_number,
1786 pad_id: pad_id.clone(),
1787 player: Some(player_id.clone()),
1788 kind: BoostPadEventKind::PickedUp { sequence },
1789 }],
1790 ..FrameEventsState::default()
1791 },
1792 &PlayerVerticalState::default(),
1793 true,
1794 )
1795 .expect("boost update should succeed");
1796 }
1797
1798 let player_stats = calculator
1799 .player_stats()
1800 .get(&player_id)
1801 .expect("player stats should be recorded");
1802 assert_eq!(player_stats.big_pads_collected, 2);
1803 assert_eq!(calculator.team_zero_stats().big_pads_collected, 2);
1804 }
1805
1806 #[test]
1807 fn skips_inactive_pickup_without_observed_boost_gain() {
1808 let mut calculator = BoostCalculator::new();
1809 let player_id = PlayerId::Steam(1);
1810 let (pad_position, _) = standard_soccar_boost_pad_layout()
1811 .iter()
1812 .find(|(_, size)| *size == BoostPadSize::Big)
1813 .copied()
1814 .expect("standard layout should include big pads");
1815
1816 calculator
1817 .update_parts(
1818 &FrameInfo {
1819 frame_number: 1,
1820 time: 1.0,
1821 dt: 1.0 / 30.0,
1822 seconds_remaining: None,
1823 },
1824 &GameplayState::default(),
1825 &PlayerFrameState {
1826 players: vec![test_player(player_id.clone(), 100.0, 100.0, pad_position)],
1827 },
1828 &FrameEventsState {
1829 boost_pad_events: vec![BoostPadEvent {
1830 time: 1.0,
1831 frame: 1,
1832 pad_id: "inactive-no-gain-big-pad".to_string(),
1833 player: Some(player_id.clone()),
1834 kind: BoostPadEventKind::PickedUp { sequence: 7 },
1835 }],
1836 ..FrameEventsState::default()
1837 },
1838 &PlayerVerticalState::default(),
1839 false,
1840 )
1841 .expect("boost update should succeed");
1842
1843 let player_stats = calculator
1844 .player_stats()
1845 .get(&player_id)
1846 .expect("player stats should be recorded");
1847 assert_eq!(player_stats.big_pads_collected_inactive, 0);
1848 assert_eq!(player_stats.amount_collected_inactive, 0.0);
1849 }
1850
1851 #[test]
1852 fn infers_pad_counts_from_observed_boost_increases() {
1853 let mut calculator = BoostCalculator::new();
1854 let small_player = PlayerId::Steam(1);
1855 let big_player = PlayerId::Steam(2);
1856 let ambiguous_player = PlayerId::Steam(3);
1857 let respawn_player = PlayerId::Steam(4);
1858 let position = glam::Vec3::ZERO;
1859 let active_gameplay = GameplayState {
1860 ball_has_been_hit: Some(true),
1861 ..GameplayState::default()
1862 };
1863
1864 calculator
1865 .update_parts(
1866 &FrameInfo {
1867 frame_number: 1,
1868 time: 1.0,
1869 dt: 1.0 / 30.0,
1870 seconds_remaining: None,
1871 },
1872 &active_gameplay,
1873 &PlayerFrameState {
1874 players: vec![
1875 test_player(small_player.clone(), 10.0, 10.0, position),
1876 test_player(big_player.clone(), 10.0, 10.0, position),
1877 test_player(ambiguous_player.clone(), 230.0, 230.0, position),
1878 test_player(respawn_player.clone(), 0.0, 0.0, position),
1879 ],
1880 },
1881 &FrameEventsState::default(),
1882 &PlayerVerticalState::default(),
1883 true,
1884 )
1885 .expect("first boost update should succeed");
1886
1887 calculator
1888 .update_parts(
1889 &FrameInfo {
1890 frame_number: 2,
1891 time: 1.1,
1892 dt: 1.0 / 30.0,
1893 seconds_remaining: None,
1894 },
1895 &active_gameplay,
1896 &PlayerFrameState {
1897 players: vec![
1898 test_player(
1899 small_player.clone(),
1900 10.0 + SMALL_PAD_AMOUNT_RAW,
1901 10.0,
1902 position,
1903 ),
1904 test_player(big_player.clone(), 100.0, 10.0, position),
1905 test_player(ambiguous_player.clone(), BOOST_MAX_AMOUNT, 230.0, position),
1906 test_player(
1907 respawn_player.clone(),
1908 BOOST_KICKOFF_START_AMOUNT,
1909 0.0,
1910 position,
1911 ),
1912 ],
1913 },
1914 &FrameEventsState::default(),
1915 &PlayerVerticalState::default(),
1916 true,
1917 )
1918 .expect("second boost update should succeed");
1919
1920 calculator
1921 .finish_calculation()
1922 .expect("pending inferred pickups should flush");
1923 let events = calculator.pickup_comparison_events();
1924 assert_eq!(events.len(), 3);
1925 assert!(events
1926 .iter()
1927 .all(|event| event.comparison == BoostPickupComparison::Missed));
1928 assert_eq!(
1929 events
1930 .iter()
1931 .filter(|event| event.pad_type == BoostPickupPadType::Big)
1932 .count(),
1933 1
1934 );
1935 assert_eq!(
1936 events
1937 .iter()
1938 .filter(|event| event.pad_type == BoostPickupPadType::Small)
1939 .count(),
1940 1
1941 );
1942 assert_eq!(
1943 events
1944 .iter()
1945 .filter(|event| event.pad_type == BoostPickupPadType::Ambiguous)
1946 .count(),
1947 1
1948 );
1949 assert!(events.iter().all(|event| event.player_id != respawn_player));
1950 }
1951}