1use super::*;
2use crate::stats::accumulators::boost::{BoostStats, BoostStatsAccumulator};
3use crate::stats::timeline::types::{AccumulationPoint, AccumulationQuantity, AccumulationTrack};
4
5const DEMO_RESPAWN_WINDOW_SECONDS: f32 = 3.2;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8enum BoostIncreaseReason {
9 KickoffRespawn,
10 DemoRespawn,
11 Respawn,
12 BigPad,
13 SmallPad,
14 AmbiguousPad,
15 Unknown,
16}
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
20#[serde(rename_all = "snake_case")]
21#[ts(export)]
22pub enum BoostPickupPadType {
23 Big,
24 Small,
25 Ambiguous,
26}
27
28impl BoostPickupPadType {
29 fn is_compatible_with(self, reported: Self) -> bool {
30 match self {
31 Self::Ambiguous => matches!(reported, Self::Big | Self::Small),
32 _ => self == reported,
33 }
34 }
35}
36
37impl From<BoostPadSize> for BoostPickupPadType {
38 fn from(pad_size: BoostPadSize) -> Self {
39 match pad_size {
40 BoostPadSize::Big => Self::Big,
41 BoostPadSize::Small => Self::Small,
42 }
43 }
44}
45
46impl TryFrom<BoostIncreaseReason> for BoostPickupPadType {
47 type Error = ();
48
49 fn try_from(reason: BoostIncreaseReason) -> Result<Self, Self::Error> {
50 match reason {
51 BoostIncreaseReason::BigPad => Ok(Self::Big),
52 BoostIncreaseReason::SmallPad => Ok(Self::Small),
53 BoostIncreaseReason::AmbiguousPad => Ok(Self::Ambiguous),
54 BoostIncreaseReason::KickoffRespawn
55 | BoostIncreaseReason::DemoRespawn
56 | BoostIncreaseReason::Respawn
57 | BoostIncreaseReason::Unknown => Err(()),
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
64#[serde(rename_all = "snake_case")]
65#[ts(export)]
66pub enum BoostPickupFieldHalf {
67 Own,
68 Opponent,
69 Unknown,
70}
71
72#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
74#[serde(rename_all = "snake_case")]
75#[ts(export)]
76pub enum BoostPickupPadZone {
77 Offensive,
78 Neutral,
79 Defensive,
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
84#[serde(rename_all = "snake_case")]
85#[ts(export)]
86pub enum BoostPickupActivity {
87 Active,
88 Inactive,
89 Unknown,
90}
91
92#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
94#[serde(rename_all = "snake_case")]
95#[ts(export)]
96pub enum BoostPickupDetection {
97 Both,
99 InferredOnly,
101 ReportedOnly,
103}
104
105#[derive(Clone, Debug)]
106struct PendingBoostPickupEvent {
107 frame: usize,
108 time: f32,
109 player_id: PlayerId,
110 player_position: Option<[f32; 3]>,
111 is_team_0: bool,
112 pad_type: BoostPickupPadType,
113 field_half: BoostPickupFieldHalf,
114 activity: BoostPickupActivity,
115 boost_before: Option<f32>,
116 boost_after: Option<f32>,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
122#[ts(export)]
123pub struct BoostPickupEvent {
124 pub frame: usize,
125 pub time: f32,
126 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
127 pub player_id: PlayerId,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub player_position: Option<[f32; 3]>,
130 pub is_team_0: bool,
131 pub pad_type: BoostPickupPadType,
132 pub field_half: BoostPickupFieldHalf,
133 pub activity: BoostPickupActivity,
134 pub detection: BoostPickupDetection,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub pad_zone: Option<BoostPickupPadZone>,
139 pub is_steal: bool,
142 pub collected_amount: f32,
144 pub overfill_amount: f32,
146 pub boost_before: Option<f32>,
147 pub boost_after: Option<f32>,
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
153#[ts(export)]
154pub struct RespawnEvent {
155 pub frame: usize,
156 pub time: f32,
157 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
158 pub player_id: PlayerId,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub player_position: Option<[f32; 3]>,
161 pub is_team_0: bool,
162 pub kind: RespawnKind,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub boost_granted: Option<f32>,
166}
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, ts_rs::TS)]
170#[serde(rename_all = "snake_case")]
171#[ts(export)]
172pub enum RespawnKind {
173 Kickoff,
174 Demo,
175}
176
177pub(crate) fn boost_transaction_label(kind: &'static str) -> StatLabel {
178 StatLabel::new("transaction", kind)
179}
180
181pub(crate) fn boost_pad_size_label(pad_size: Option<BoostPadSize>) -> StatLabel {
182 match pad_size {
183 Some(BoostPadSize::Big) => StatLabel::new("pad_size", "big"),
184 Some(BoostPadSize::Small) => StatLabel::new("pad_size", "small"),
185 None => StatLabel::new("pad_size", "unknown"),
186 }
187}
188
189pub(crate) fn boost_activity_label(activity: BoostPickupActivity) -> StatLabel {
190 match activity {
191 BoostPickupActivity::Active => StatLabel::new("activity", "active"),
192 BoostPickupActivity::Inactive => StatLabel::new("activity", "inactive"),
193 BoostPickupActivity::Unknown => StatLabel::new("activity", "unknown"),
194 }
195}
196
197pub(crate) fn boost_field_half_label(field_half: BoostPickupFieldHalf) -> StatLabel {
198 match field_half {
199 BoostPickupFieldHalf::Own => StatLabel::new("field_half", "own"),
200 BoostPickupFieldHalf::Opponent => StatLabel::new("field_half", "opponent"),
201 BoostPickupFieldHalf::Unknown => StatLabel::new("field_half", "unknown"),
202 }
203}
204
205pub(crate) fn boost_supersonic_label(supersonic: bool) -> StatLabel {
206 if supersonic {
207 StatLabel::new("supersonic", "true")
208 } else {
209 StatLabel::new("supersonic", "false")
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub struct BoostCalculatorConfig {
216 pub include_non_live_pickups: bool,
217}
218
219#[derive(Debug, Clone, Default)]
221pub struct BoostCalculator {
222 config: BoostCalculatorConfig,
223 previous_boost_amounts: HashMap<PlayerId, f32>,
224 previous_player_speeds: HashMap<PlayerId, f32>,
225 observed_pad_positions: HashMap<String, PadPositionEstimate>,
226 known_pad_sizes: HashMap<String, BoostPadSize>,
227 known_pad_indices: HashMap<String, usize>,
228 unavailable_pads: HashSet<String>,
229 seen_pickup_sequence_times: HashMap<(String, u8), f32>,
230 pickup_frames: HashMap<(String, PlayerId), usize>,
231 inactive_pickup_frames: HashSet<(PlayerId, usize, BoostPadSize)>,
232 last_pickup_times: HashMap<String, f32>,
233 pending_inferred_pickups: VecDeque<PendingBoostPickupEvent>,
234 pickup_events: EventStream<BoostPickupEvent>,
235 respawn_events: EventStream<RespawnEvent>,
236 stats: BoostStatsAccumulator,
237 track_builders: HashMap<(PlayerId, AccumulationQuantity), BoostTrackBuilder>,
238 player_usage_state: HashMap<PlayerId, BoostUsageState>,
239 kickoff_phase_active_last_frame: bool,
240 kickoff_respawn_awarded: HashSet<PlayerId>,
241 initial_respawn_awarded: HashSet<PlayerId>,
242 pending_demo_respawns: HashMap<PlayerId, PendingDemoRespawn>,
243 pending_reported_pickups: VecDeque<DeferredReportedBoostPickup>,
244 previous_boost_levels_live: Option<bool>,
245}
246
247#[derive(Debug, Clone, Copy)]
248struct PendingDemoRespawn {
249 demo_time: f32,
250 pre_demo_boost_amount: Option<f32>,
251}
252
253#[derive(Debug, Clone, Default)]
256struct BoostTrackBuilder {
257 is_team_0: bool,
258 points: Vec<AccumulationPoint>,
259}
260
261#[derive(Debug, Clone, Copy, Default)]
262struct BoostUsageState {
263 amount_obtained: f32,
264 amount_removed: f32,
265 amount_used: f32,
266 amount_used_while_grounded: f32,
267 amount_used_while_airborne: f32,
268}
269
270impl BoostUsageState {
271 fn amount_used_by_vertical_band(self) -> f32 {
272 self.amount_used_while_grounded + self.amount_used_while_airborne
273 }
274
275 fn apply_obtained_amount(&mut self, amount: f32) {
276 self.amount_obtained += amount;
277 }
278
279 fn apply_used_amount(&mut self, amount: f32) {
280 self.amount_used += amount;
281 }
282
283 fn apply_used_allocation(&mut self, amount: f32, grounded: bool) {
284 if grounded {
285 self.amount_used_while_grounded += amount;
286 } else {
287 self.amount_used_while_airborne += amount;
288 }
289 }
290
291 fn apply_removed_amount(&mut self, amount: f32) {
292 self.amount_removed += amount;
293 }
294
295 fn inferred_amount_used(self, current_boost_amount: f32) -> f32 {
296 (self.amount_obtained - self.amount_removed - current_boost_amount).max(0.0)
304 }
305}
306
307#[derive(Debug, Clone)]
308struct PendingBoostPickup {
309 frame: usize,
310 time: f32,
311 player_id: PlayerId,
312 is_team_0: bool,
313 previous_boost_amount: f32,
314 pre_applied_collected_amount: f32,
315 player_position: glam::Vec3,
316 boost_before: Option<f32>,
317 boost_after: Option<f32>,
318}
319
320#[derive(Debug, Clone)]
321struct DeferredReportedBoostPickup {
322 pad_id: String,
323 pending_pickup: PendingBoostPickup,
324 pad_size: BoostPadSize,
325 reported_event: PendingBoostPickupEvent,
326}
327
328impl BoostCalculator {
329 const PICKUP_MATCH_FRAME_WINDOW: usize = 3;
330
331 pub fn new() -> Self {
332 Self::with_config(BoostCalculatorConfig::default())
333 }
334
335 pub fn with_config(config: BoostCalculatorConfig) -> Self {
336 Self {
337 config,
338 ..Self::default()
339 }
340 }
341
342 pub fn pickup_events(&self) -> &[BoostPickupEvent] {
343 self.pickup_events.all()
344 }
345
346 pub fn new_pickup_events(&self) -> &[BoostPickupEvent] {
347 self.pickup_events.new_events()
348 }
349
350 pub fn respawn_events(&self) -> &[RespawnEvent] {
351 self.respawn_events.all()
352 }
353
354 pub fn new_respawn_events(&self) -> &[RespawnEvent] {
355 self.respawn_events.new_events()
356 }
357
358 pub fn player_boost_stats(&self) -> &HashMap<PlayerId, BoostStats> {
359 self.stats.player_stats()
360 }
361
362 pub fn player_boost_stats_for(&self, player_id: &PlayerId) -> BoostStats {
363 self.stats.player_stats_for(player_id)
364 }
365
366 pub fn team_zero_boost_stats(&self) -> &BoostStats {
367 self.stats.team_zero_stats()
368 }
369
370 pub fn team_one_boost_stats(&self) -> &BoostStats {
371 self.stats.team_one_stats()
372 }
373
374 pub fn boost_stats(&self) -> &BoostStatsAccumulator {
376 &self.stats
377 }
378
379 pub fn accumulation_tracks(&self) -> Vec<AccumulationTrack> {
382 let mut tracks: Vec<AccumulationTrack> = self
383 .track_builders
384 .iter()
385 .filter(|(_, builder)| !builder.points.is_empty())
386 .map(|((player_id, quantity), builder)| AccumulationTrack {
387 player_id: player_id.clone(),
388 is_team_0: builder.is_team_0,
389 quantity: *quantity,
390 points: builder.points.clone(),
391 })
392 .collect();
393 tracks.sort_by(|left, right| {
394 format!("{:?}", left.player_id)
395 .cmp(&format!("{:?}", right.player_id))
396 .then_with(|| format!("{:?}", left.quantity).cmp(&format!("{:?}", right.quantity)))
397 });
398 tracks
399 }
400
401 fn player_usage_state(&self, player_id: &PlayerId) -> BoostUsageState {
402 self.player_usage_state
403 .get(player_id)
404 .copied()
405 .unwrap_or_default()
406 }
407
408 fn record_track_point(
409 &mut self,
410 player_id: &PlayerId,
411 is_team_0: bool,
412 frame: usize,
413 quantity: AccumulationQuantity,
414 value: f32,
415 ) {
416 let builder = self
417 .track_builders
418 .entry((player_id.clone(), quantity))
419 .or_default();
420 builder.is_team_0 = is_team_0;
421 if builder
422 .points
423 .last()
424 .is_some_and(|point| (point.value - value).abs() <= f32::EPSILON)
425 {
426 return;
427 }
428 builder.points.push(AccumulationPoint { frame, value });
429 }
430
431 fn record_boost_tracks(
434 &mut self,
435 player_id: &PlayerId,
436 is_team_0: bool,
437 frame: usize,
438 boost_amount: f32,
439 ) {
440 let stats = self.stats.player_stats_for(player_id);
441 self.record_track_point(
442 player_id,
443 is_team_0,
444 frame,
445 AccumulationQuantity::BoostAmount,
446 boost_amount,
447 );
448 self.record_track_point(
449 player_id,
450 is_team_0,
451 frame,
452 AccumulationQuantity::BoostUsed,
453 stats.amount_used,
454 );
455 self.record_track_point(
456 player_id,
457 is_team_0,
458 frame,
459 AccumulationQuantity::BoostUsedGrounded,
460 stats.amount_used_while_grounded,
461 );
462 self.record_track_point(
463 player_id,
464 is_team_0,
465 frame,
466 AccumulationQuantity::BoostUsedAirborne,
467 stats.amount_used_while_airborne,
468 );
469 self.record_track_point(
470 player_id,
471 is_team_0,
472 frame,
473 AccumulationQuantity::BoostUsedSupersonic,
474 stats.amount_used_while_supersonic,
475 );
476 self.record_track_point(
477 player_id,
478 is_team_0,
479 frame,
480 AccumulationQuantity::BoostCollected,
481 stats.amount_collected,
482 );
483 self.record_track_point(
484 player_id,
485 is_team_0,
486 frame,
487 AccumulationQuantity::BoostStolen,
488 stats.amount_stolen,
489 );
490 self.record_track_point(
491 player_id,
492 is_team_0,
493 frame,
494 AccumulationQuantity::BoostOverfill,
495 stats.overfill_total,
496 );
497 }
498
499 fn push_reported_pickup(
503 &mut self,
504 reported: PendingBoostPickupEvent,
505 mut pickup: BoostPickupEvent,
506 ) {
507 let matched = if let Some(index) =
508 Self::matching_pending_pickup_index(&self.pending_inferred_pickups, &reported, true)
509 {
510 self.pending_inferred_pickups.remove(index);
511 true
512 } else if let Some(index) = self.pending_inferred_pickups.iter().position(|pending| {
513 pending.player_id == reported.player_id
514 && pending.frame.abs_diff(reported.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
515 }) {
516 self.pending_inferred_pickups.remove(index);
517 true
518 } else {
519 false
520 };
521 pickup.detection = if matched {
522 BoostPickupDetection::Both
523 } else {
524 BoostPickupDetection::ReportedOnly
525 };
526 self.pickup_events.push(pickup);
527 }
528
529 fn estimated_pad_position(&self, pad_id: &str) -> Option<glam::Vec3> {
530 self.observed_pad_positions
531 .get(pad_id)
532 .and_then(PadPositionEstimate::mean)
533 }
534
535 fn observed_pad_positions(&self, pad_id: &str) -> &[glam::Vec3] {
536 self.observed_pad_positions
537 .get(pad_id)
538 .map(PadPositionEstimate::observations)
539 .unwrap_or(&[])
540 }
541
542 fn pad_match_radius(pad_size: BoostPadSize) -> f32 {
543 match pad_size {
544 BoostPadSize::Big => STANDARD_PAD_MATCH_RADIUS_BIG,
545 BoostPadSize::Small => STANDARD_PAD_MATCH_RADIUS_SMALL,
546 }
547 }
548
549 pub fn resolved_boost_pads(&self) -> Vec<ResolvedBoostPad> {
550 standard_soccar_boost_pad_layout()
551 .iter()
552 .enumerate()
553 .map(|(index, (position, size))| ResolvedBoostPad {
554 index,
555 pad_id: self
556 .known_pad_indices
557 .iter()
558 .find_map(|(pad_id, pad_index)| (*pad_index == index).then(|| pad_id.clone())),
559 size: *size,
560 position: glam_to_vec(position),
561 })
562 .collect()
563 }
564
565 fn infer_pad_index(
566 &self,
567 pad_id: &str,
568 pad_size: BoostPadSize,
569 observed_position: glam::Vec3,
570 ) -> Option<usize> {
571 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
572 return Some(index);
573 }
574
575 let observed_position = self
576 .estimated_pad_position(pad_id)
577 .unwrap_or(observed_position);
578 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
579 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
580 let radius = Self::pad_match_radius(pad_size);
581 let observed_positions = self.observed_pad_positions(pad_id);
582 let best_candidate = |allow_used: bool| {
583 layout
584 .iter()
585 .enumerate()
586 .filter(|(index, (_, size))| {
587 *size == pad_size && (allow_used || !used_indices.contains(index))
588 })
589 .filter_map(|(index, (candidate_position, _))| {
590 let mut vote_count = 0usize;
591 let mut total_vote_distance = 0.0f32;
592 let mut best_vote_distance = f32::INFINITY;
593
594 for position in observed_positions {
595 let distance = position.distance(*candidate_position);
596 if distance <= radius {
597 vote_count += 1;
598 total_vote_distance += distance;
599 best_vote_distance = best_vote_distance.min(distance);
600 }
601 }
602
603 if vote_count == 0 {
604 return None;
605 }
606
607 let representative_distance = observed_position.distance(*candidate_position);
608 Some((
609 index,
610 vote_count,
611 total_vote_distance / vote_count as f32,
612 best_vote_distance,
613 representative_distance,
614 ))
615 })
616 .max_by(|left, right| {
617 left.1
618 .cmp(&right.1)
619 .then_with(|| right.2.partial_cmp(&left.2).unwrap())
620 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
621 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
622 })
623 .map(|(index, _, _, _, _)| index)
624 };
625
626 best_candidate(false)
627 .or_else(|| best_candidate(true))
628 .or_else(|| {
629 layout
630 .iter()
631 .enumerate()
632 .filter(|(index, (_, size))| *size == pad_size && !used_indices.contains(index))
633 .min_by(|(_, (a, _)), (_, (b, _))| {
634 observed_position
635 .distance_squared(*a)
636 .partial_cmp(&observed_position.distance_squared(*b))
637 .unwrap()
638 })
639 .map(|(index, _)| index)
640 })
641 .or_else(|| {
642 layout
643 .iter()
644 .enumerate()
645 .filter(|(_, (_, size))| *size == pad_size)
646 .min_by(|(_, (a, _)), (_, (b, _))| {
647 observed_position
648 .distance_squared(*a)
649 .partial_cmp(&observed_position.distance_squared(*b))
650 .unwrap()
651 })
652 .map(|(index, _)| index)
653 })
654 .filter(|index| {
655 observed_position.distance(standard_soccar_boost_pad_position(*index)) <= radius
656 })
657 }
658
659 fn infer_pad_details_from_position(
660 &self,
661 pad_id: &str,
662 observed_position: glam::Vec3,
663 ) -> Option<(usize, BoostPadSize)> {
664 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
665 let (_, size) = standard_soccar_boost_pad_layout().get(index)?;
666 return Some((index, *size));
667 }
668
669 let observed_position = self
670 .estimated_pad_position(pad_id)
671 .unwrap_or(observed_position);
672 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
673 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
674 let observed_positions = self.observed_pad_positions(pad_id);
675 let best_candidate = |allow_used: bool| {
676 layout
677 .iter()
678 .enumerate()
679 .filter(|(index, _)| allow_used || !used_indices.contains(index))
680 .filter_map(|(index, (candidate_position, size))| {
681 let radius = Self::pad_match_radius(*size);
682 let mut vote_count = 0usize;
683 let mut total_vote_distance = 0.0f32;
684 let mut best_vote_distance = f32::INFINITY;
685
686 for position in observed_positions {
687 let distance = position.distance(*candidate_position);
688 if distance <= radius {
689 vote_count += 1;
690 total_vote_distance += distance;
691 best_vote_distance = best_vote_distance.min(distance);
692 }
693 }
694
695 if vote_count == 0 {
696 return None;
697 }
698
699 let representative_distance = observed_position.distance(*candidate_position);
700 Some((
701 index,
702 *size,
703 vote_count,
704 total_vote_distance / vote_count as f32,
705 best_vote_distance,
706 representative_distance,
707 ))
708 })
709 .max_by(|left, right| {
710 left.2
711 .cmp(&right.2)
712 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
713 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
714 .then_with(|| right.5.partial_cmp(&left.5).unwrap())
715 })
716 .map(|(index, size, _, _, _, _)| (index, size))
717 };
718
719 best_candidate(false).or_else(|| best_candidate(true))
720 }
721
722 fn guess_pad_size_from_position(
723 &self,
724 pad_id: &str,
725 observed_position: glam::Vec3,
726 ) -> Option<BoostPadSize> {
727 if let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied() {
728 return Some(pad_size);
729 }
730
731 if let Some((_, pad_size)) = self.infer_pad_details_from_position(pad_id, observed_position)
732 {
733 return Some(pad_size);
734 }
735
736 let observed_position = self
737 .estimated_pad_position(pad_id)
738 .unwrap_or(observed_position);
739 standard_soccar_boost_pad_layout()
740 .iter()
741 .min_by(|(left_position, _), (right_position, _)| {
742 observed_position
743 .distance_squared(*left_position)
744 .partial_cmp(&observed_position.distance_squared(*right_position))
745 .unwrap()
746 })
747 .map(|(_, pad_size)| *pad_size)
748 }
749
750 fn resolve_pickup(
751 &mut self,
752 pad_id: &str,
753 pending_pickup: PendingBoostPickup,
754 pad_size: BoostPadSize,
755 ) -> BoostPickupEvent {
756 let observed_position = self
757 .estimated_pad_position(pad_id)
758 .unwrap_or(pending_pickup.player_position);
759 let pad_position = self
760 .infer_pad_index(pad_id, pad_size, observed_position)
761 .map(|index| {
762 self.known_pad_indices.insert(pad_id.to_string(), index);
763 standard_soccar_boost_pad_position(index)
764 })
765 .unwrap_or(observed_position);
766 let stolen = is_enemy_side(pending_pickup.is_team_0, pad_position);
767 let nominal_gain = match pad_size {
768 BoostPadSize::Big => BOOST_MAX_AMOUNT,
769 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
770 };
771 let collected_amount = (BOOST_MAX_AMOUNT - pending_pickup.previous_boost_amount)
772 .min(nominal_gain)
773 .max(0.0);
774 let collected_amount_delta = collected_amount - pending_pickup.pre_applied_collected_amount;
775 let overfill = (nominal_gain - collected_amount).max(0.0);
776 let field_half = if stolen {
777 BoostPickupFieldHalf::Opponent
778 } else {
779 BoostPickupFieldHalf::Own
780 };
781 let pad_zone =
782 Self::pad_zone_from_position(pending_pickup.is_team_0, pad_size, Some(pad_position));
783
784 self.player_usage_state
788 .entry(pending_pickup.player_id.clone())
789 .or_default()
790 .apply_obtained_amount(collected_amount_delta);
791
792 self.stats.apply_pickup(
793 &pending_pickup.player_id,
794 pending_pickup.is_team_0,
795 Some(pad_size),
796 BoostPickupActivity::Active,
797 field_half,
798 stolen,
799 collected_amount,
800 overfill,
801 );
802
803 BoostPickupEvent {
804 frame: pending_pickup.frame,
805 time: pending_pickup.time,
806 player_id: pending_pickup.player_id.clone(),
807 player_position: Some(pending_pickup.player_position.to_array()),
808 is_team_0: pending_pickup.is_team_0,
809 pad_type: pad_size.into(),
810 field_half,
811 activity: BoostPickupActivity::Active,
812 detection: BoostPickupDetection::Both,
815 pad_zone,
816 is_steal: stolen,
817 collected_amount,
818 overfill_amount: overfill,
819 boost_before: pending_pickup.boost_before,
820 boost_after: pending_pickup.boost_after,
821 }
822 }
823
824 fn apply_pickup_collected_amount(
827 &mut self,
828 player_id: &PlayerId,
829 amount: f32,
830 pad_size: Option<BoostPadSize>,
831 ) {
832 if amount <= 0.0 {
833 return;
834 }
835 if pad_size.is_some() {
836 self.player_usage_state
837 .entry(player_id.clone())
838 .or_default()
839 .apply_obtained_amount(amount);
840 }
841 }
842
843 #[allow(clippy::too_many_arguments)]
844 fn apply_inactive_pickup(
845 &mut self,
846 player_id: &PlayerId,
847 player_position: Option<[f32; 3]>,
848 is_team_0: bool,
849 frame: usize,
850 time: f32,
851 amount: f32,
852 pad_size: BoostPadSize,
853 boost_before: Option<f32>,
854 boost_after: Option<f32>,
855 ) -> BoostPickupEvent {
856 self.stats.apply_pickup(
857 player_id,
858 is_team_0,
859 Some(pad_size),
860 BoostPickupActivity::Inactive,
861 BoostPickupFieldHalf::Unknown,
862 false,
863 amount,
864 0.0,
865 );
866 BoostPickupEvent {
867 frame,
868 time,
869 player_id: player_id.clone(),
870 player_position,
871 is_team_0,
872 pad_type: pad_size.into(),
873 field_half: BoostPickupFieldHalf::Unknown,
874 activity: BoostPickupActivity::Inactive,
875 detection: BoostPickupDetection::ReportedOnly,
876 pad_zone: Self::pad_zone_from_position(
877 is_team_0,
878 pad_size,
879 player_position.map(glam::Vec3::from_array),
880 ),
881 is_steal: false,
882 collected_amount: amount,
883 overfill_amount: 0.0,
884 boost_before,
885 boost_after,
886 }
887 }
888
889 #[allow(clippy::too_many_arguments)]
890 fn apply_respawn_amount(
891 &mut self,
892 player_id: &PlayerId,
893 player_position: Option<[f32; 3]>,
894 is_team_0: bool,
895 frame: usize,
896 time: f32,
897 amount: f32,
898 kind: RespawnKind,
899 ) {
900 if amount <= 0.0 {
901 return;
902 }
903 self.player_usage_state
904 .entry(player_id.clone())
905 .or_default()
906 .apply_obtained_amount(amount);
907 self.stats.apply_respawn(player_id, is_team_0, amount);
908 self.respawn_events.push(RespawnEvent {
909 frame,
910 time,
911 player_id: player_id.clone(),
912 player_position,
913 is_team_0,
914 kind,
915 boost_granted: Some(amount),
916 });
917 }
918
919 fn pad_respawn_time_seconds(pad_size: BoostPadSize) -> f32 {
920 match pad_size {
921 BoostPadSize::Big => 10.0,
922 BoostPadSize::Small => 4.0,
923 }
924 }
925
926 fn seen_pickup_sequence_is_recent(
927 &self,
928 pad_id: &str,
929 sequence: u8,
930 event_time: f32,
931 player_position: Option<glam::Vec3>,
932 ) -> bool {
933 let Some(last_time) = self
934 .seen_pickup_sequence_times
935 .get(&(pad_id.to_string(), sequence))
936 .copied()
937 else {
938 return false;
939 };
940 let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
941 player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
942 }) else {
943 return false;
944 };
945 event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
946 }
947
948 fn unavailable_pad_is_recent(
949 &self,
950 pad_id: &str,
951 event_time: f32,
952 player_position: Option<glam::Vec3>,
953 ) -> bool {
954 if !self.unavailable_pads.contains(pad_id) {
955 return false;
956 }
957 let Some(last_time) = self.last_pickup_times.get(pad_id).copied() else {
958 return true;
959 };
960 let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied().or_else(|| {
961 player_position.and_then(|position| self.guess_pad_size_from_position(pad_id, position))
962 }) else {
963 return true;
964 };
965 event_time - last_time < Self::pad_respawn_time_seconds(pad_size)
966 }
967
968 fn boost_levels_live(live_play: bool) -> bool {
969 live_play
970 }
971
972 fn tracks_boost_levels(boost_levels_live: bool) -> bool {
973 boost_levels_live
974 }
975
976 fn tracks_boost_pickups(gameplay: &GameplayState, live_play: bool) -> bool {
977 live_play
978 || (gameplay.ball_has_been_hit == Some(false) && !gameplay.kickoff_countdown_active())
979 }
980
981 fn activity_label(active: bool) -> BoostPickupActivity {
982 if active {
983 BoostPickupActivity::Active
984 } else {
985 BoostPickupActivity::Inactive
986 }
987 }
988
989 fn field_half_from_position(
990 is_team_0: bool,
991 position: Option<glam::Vec3>,
992 ) -> BoostPickupFieldHalf {
993 match position {
994 Some(position) if is_enemy_side(is_team_0, position) => BoostPickupFieldHalf::Opponent,
995 Some(_) => BoostPickupFieldHalf::Own,
996 None => BoostPickupFieldHalf::Unknown,
997 }
998 }
999
1000 fn pad_zone_from_position(
1001 is_team_0: bool,
1002 pad_size: BoostPadSize,
1003 position: Option<glam::Vec3>,
1004 ) -> Option<BoostPickupPadZone> {
1005 let normalized_y = normalized_y(is_team_0, position?);
1006 if pad_size == BoostPadSize::Big && normalized_y.abs() <= BOOST_PAD_MIDFIELD_TOLERANCE_Y {
1007 Some(BoostPickupPadZone::Neutral)
1008 } else if normalized_y > 0.0 {
1009 Some(BoostPickupPadZone::Offensive)
1010 } else {
1011 Some(BoostPickupPadZone::Defensive)
1012 }
1013 }
1014
1015 fn classify_boost_increase_reasons(
1016 previous_boost: f32,
1017 boost: f32,
1018 kickoff_phase_active: bool,
1019 demo_respawn_supported: bool,
1020 ) -> Vec<BoostIncreaseReason> {
1021 const TOLERANCE: f32 = 1.0;
1022 let delta = boost - previous_boost;
1023 if delta <= TOLERANCE {
1024 return vec![BoostIncreaseReason::Unknown];
1025 }
1026
1027 let is_respawn_value = (boost - BOOST_KICKOFF_START_AMOUNT).abs() <= TOLERANCE;
1028 if demo_respawn_supported && is_respawn_value {
1029 return vec![BoostIncreaseReason::DemoRespawn];
1030 }
1031 if kickoff_phase_active && is_respawn_value {
1032 return vec![BoostIncreaseReason::KickoffRespawn];
1033 }
1034 if is_respawn_value {
1035 return vec![BoostIncreaseReason::Respawn];
1036 }
1037
1038 let small_pad_floor = SMALL_PAD_AMOUNT_RAW - 3.0;
1039 let big_pad_floor = SMALL_PAD_AMOUNT_RAW + TOLERANCE;
1040 if delta > big_pad_floor {
1041 return vec![BoostIncreaseReason::BigPad];
1042 }
1043 if boost < BOOST_FULL_BAND_MIN_RAW && delta >= small_pad_floor {
1044 const SMALL_PICKUP_COUNT_TOLERANCE: f32 = 3.0;
1045 let inferred_small_pickups = ((delta - SMALL_PICKUP_COUNT_TOLERANCE)
1046 / SMALL_PAD_AMOUNT_RAW)
1047 .ceil()
1048 .max(1.0) as usize;
1049 return vec![BoostIncreaseReason::SmallPad; inferred_small_pickups];
1050 }
1051
1052 if boost >= BOOST_MAX_AMOUNT - TOLERANCE {
1053 return vec![BoostIncreaseReason::AmbiguousPad];
1054 }
1055 if delta >= small_pad_floor {
1056 return vec![BoostIncreaseReason::SmallPad];
1057 }
1058 vec![BoostIncreaseReason::Unknown]
1059 }
1060
1061 fn matching_pending_pickup_index(
1062 pending: &VecDeque<PendingBoostPickupEvent>,
1063 event: &PendingBoostPickupEvent,
1064 pending_is_inferred: bool,
1065 ) -> Option<usize> {
1066 pending
1067 .iter()
1068 .enumerate()
1069 .filter(|(_, pending_event)| {
1070 pending_event.player_id == event.player_id
1071 && if pending_is_inferred {
1072 pending_event.pad_type.is_compatible_with(event.pad_type)
1073 } else {
1074 event.pad_type.is_compatible_with(pending_event.pad_type)
1075 }
1076 && pending_event.frame.abs_diff(event.frame) <= Self::PICKUP_MATCH_FRAME_WINDOW
1077 })
1078 .min_by_key(|(_, pending_event)| pending_event.frame.abs_diff(event.frame))
1079 .map(|(index, _)| index)
1080 }
1081
1082 fn record_inferred_pickup(&mut self, event: PendingBoostPickupEvent) {
1083 self.pending_inferred_pickups.push_back(event);
1084 }
1085
1086 fn resolve_deferred_reported_pickups(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
1087 const TOLERANCE: f32 = 1.0;
1088
1089 let mut remaining_pickups = VecDeque::new();
1090 for mut deferred in std::mem::take(&mut self.pending_reported_pickups) {
1091 let player = players
1092 .players
1093 .iter()
1094 .find(|player| player.player_id == deferred.pending_pickup.player_id);
1095 let observed_boost_amount = player.and_then(|player| player.boost_amount);
1096 let previous_sample_boost_amount = self
1097 .previous_boost_amounts
1098 .get(&deferred.pending_pickup.player_id)
1099 .copied()
1100 .unwrap_or(deferred.pending_pickup.previous_boost_amount);
1101 let gain_is_visible = observed_boost_amount.is_some_and(|boost_amount| {
1102 boost_amount > previous_sample_boost_amount + TOLERANCE
1103 });
1104 let pickup_expired = deferred.pending_pickup.frame + Self::PICKUP_MATCH_FRAME_WINDOW
1105 < frame.frame_number;
1106
1107 if !gain_is_visible && !pickup_expired {
1108 remaining_pickups.push_back(deferred);
1109 continue;
1110 }
1111
1112 if gain_is_visible || pickup_expired {
1113 deferred.pending_pickup.frame = frame.frame_number;
1114 deferred.pending_pickup.time = frame.time;
1115 deferred.pending_pickup.boost_after = observed_boost_amount;
1116 if let Some(position) = player.and_then(|player| player.position()) {
1117 deferred.pending_pickup.player_position = position;
1118 }
1119 }
1120
1121 let pickup =
1122 self.resolve_pickup(&deferred.pad_id, deferred.pending_pickup, deferred.pad_size);
1123 self.push_reported_pickup(deferred.reported_event, pickup);
1124 }
1125 self.pending_reported_pickups = remaining_pickups;
1126 }
1127
1128 fn flush_deferred_reported_pickups(&mut self) {
1129 while let Some(deferred) = self.pending_reported_pickups.pop_front() {
1130 let pickup =
1131 self.resolve_pickup(&deferred.pad_id, deferred.pending_pickup, deferred.pad_size);
1132 self.push_reported_pickup(deferred.reported_event, pickup);
1133 }
1134 }
1135
1136 fn inferred_pickup_pad_size(&self, event: &PendingBoostPickupEvent) -> BoostPadSize {
1137 match event.pad_type {
1138 BoostPickupPadType::Big => BoostPadSize::Big,
1139 BoostPickupPadType::Small => BoostPadSize::Small,
1140 BoostPickupPadType::Ambiguous => event
1141 .player_position
1142 .and_then(|position| {
1143 self.guess_pad_size_from_position(
1144 &format!("inferred:{:?}:{}", event.player_id, event.frame),
1145 glam::Vec3::from_array(position),
1146 )
1147 })
1148 .unwrap_or(BoostPadSize::Big),
1149 }
1150 }
1151
1152 fn resolve_inferred_pickup(
1153 &mut self,
1154 event: PendingBoostPickupEvent,
1155 application_frame: &FrameInfo,
1156 ) {
1157 let Some(boost_before) = event.boost_before else {
1158 self.pickup_events.push(BoostPickupEvent {
1161 frame: event.frame,
1162 time: event.time,
1163 player_id: event.player_id.clone(),
1164 player_position: event.player_position,
1165 is_team_0: event.is_team_0,
1166 pad_type: event.pad_type,
1167 field_half: event.field_half,
1168 activity: event.activity,
1169 detection: BoostPickupDetection::InferredOnly,
1170 pad_zone: match event.pad_type {
1171 BoostPickupPadType::Big => Self::pad_zone_from_position(
1172 event.is_team_0,
1173 BoostPadSize::Big,
1174 event.player_position.map(glam::Vec3::from_array),
1175 ),
1176 BoostPickupPadType::Small => Self::pad_zone_from_position(
1177 event.is_team_0,
1178 BoostPadSize::Small,
1179 event.player_position.map(glam::Vec3::from_array),
1180 ),
1181 BoostPickupPadType::Ambiguous => None,
1182 },
1183 is_steal: matches!(event.field_half, BoostPickupFieldHalf::Opponent),
1184 collected_amount: 0.0,
1185 overfill_amount: 0.0,
1186 boost_before: event.boost_before,
1187 boost_after: event.boost_after,
1188 });
1189 return;
1190 };
1191
1192 let pad_size = self.inferred_pickup_pad_size(&event);
1193 let player_position = event
1194 .player_position
1195 .map(glam::Vec3::from_array)
1196 .unwrap_or(glam::Vec3::ZERO);
1197 let pad_id = format!("inferred:{:?}:{}", event.player_id, event.frame);
1198 let mut pickup = self.resolve_pickup(
1199 &pad_id,
1200 PendingBoostPickup {
1201 frame: application_frame.frame_number,
1202 time: application_frame.time,
1203 player_id: event.player_id.clone(),
1204 is_team_0: event.is_team_0,
1205 previous_boost_amount: boost_before,
1206 pre_applied_collected_amount: 0.0,
1207 player_position,
1208 boost_before: event.boost_before,
1209 boost_after: event.boost_after,
1210 },
1211 pad_size,
1212 );
1213 pickup.frame = event.frame;
1215 pickup.time = event.time;
1216 pickup.detection = BoostPickupDetection::InferredOnly;
1217 self.pickup_events.push(pickup);
1218 }
1219
1220 fn flush_stale_pickup_comparisons(&mut self, frame: &FrameInfo) {
1221 while self
1222 .pending_inferred_pickups
1223 .front()
1224 .is_some_and(|event| event.frame + Self::PICKUP_MATCH_FRAME_WINDOW < frame.frame_number)
1225 {
1226 if let Some(event) = self.pending_inferred_pickups.pop_front() {
1227 self.resolve_inferred_pickup(event, frame);
1228 }
1229 }
1230 }
1231
1232 pub fn finish_calculation(&mut self) -> SubtrActorResult<()> {
1233 self.flush_deferred_reported_pickups();
1234 self.pending_inferred_pickups.clear();
1235 Ok(())
1236 }
1237
1238 fn inactive_pickup_stats(
1239 &self,
1240 player: &PlayerSample,
1241 pad_id: &str,
1242 previous_boost_amount: f32,
1243 respawn_amount: f32,
1244 ) -> Option<(f32, BoostPadSize)> {
1245 let pad_size = self
1246 .known_pad_sizes
1247 .get(pad_id)
1248 .copied()
1249 .or_else(|| self.guess_pad_size_from_position(pad_id, player.position()?))?;
1250 let nominal_gain = match pad_size {
1251 BoostPadSize::Big => BOOST_MAX_AMOUNT,
1252 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
1253 };
1254 let capacity_limited_gain = (BOOST_MAX_AMOUNT - previous_boost_amount)
1255 .min(nominal_gain)
1256 .max(0.0);
1257 let observed_gain = player
1258 .boost_amount
1259 .map(|boost_amount| (boost_amount - previous_boost_amount - respawn_amount).max(0.0))
1260 .unwrap_or(0.0);
1261 if observed_gain <= 1.0 {
1262 return None;
1263 }
1264 Some((
1265 capacity_limited_gain.max(observed_gain).min(nominal_gain),
1266 pad_size,
1267 ))
1268 }
1269
1270 pub fn update_parts(
1271 &mut self,
1272 frame: &FrameInfo,
1273 gameplay: &GameplayState,
1274 players: &PlayerFrameState,
1275 events: &FrameEventsState,
1276 vertical_state: &PlayerVerticalState,
1277 live_play_state: &LivePlayState,
1278 ) -> SubtrActorResult<()> {
1279 self.pickup_events.begin_update();
1280 self.respawn_events.begin_update();
1281 let live_play = live_play_state.counts_toward_player_motion();
1282 let boost_levels_live = Self::boost_levels_live(live_play);
1283 let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
1284 let track_boost_pickups = Self::tracks_boost_pickups(gameplay, live_play);
1285 let boost_levels_resumed_this_sample =
1286 boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
1287 let kickoff_phase_active = gameplay.kickoff_phase_active();
1288 let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
1289 if kickoff_phase_started {
1290 self.kickoff_respawn_awarded.clear();
1291 }
1292 for demo in &events.demo_events {
1293 let pre_demo_boost_amount = self.previous_boost_amounts.get(&demo.victim).copied();
1294 self.pending_demo_respawns
1295 .entry(demo.victim.clone())
1296 .or_insert(PendingDemoRespawn {
1297 demo_time: demo.time,
1298 pre_demo_boost_amount,
1299 });
1300 }
1301
1302 let mut current_boost_amounts = Vec::new();
1303 let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
1304 let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
1305
1306 for event in &events.boost_pad_events {
1307 let BoostPadEventKind::PickedUp { .. } = event.kind else {
1308 continue;
1309 };
1310 let Some(player_id) = &event.player else {
1311 continue;
1312 };
1313 *pickup_counts_by_player
1314 .entry(player_id.clone())
1315 .or_default() += 1;
1316 }
1317
1318 for player in &players.players {
1319 let Some(boost_amount) = player.boost_amount else {
1320 continue;
1321 };
1322 let previous_sample_boost_amount =
1323 self.previous_boost_amounts.get(&player.player_id).copied();
1324 let previous_boost_amount = previous_sample_boost_amount
1325 .or(player.last_boost_amount)
1326 .unwrap_or(boost_amount);
1327 let previous_boost_amount = if boost_levels_resumed_this_sample {
1328 boost_amount
1329 } else {
1330 previous_boost_amount
1331 };
1332 let pending_demo_respawn = self.pending_demo_respawns.get(&player.player_id);
1333 let demo_respawn_ready = pending_demo_respawn.is_some_and(|pending| {
1334 player.rigid_body.is_some()
1335 && frame.time - pending.demo_time >= DEMO_RESPAWN_WINDOW_SECONDS
1336 });
1337 let demo_respawn_pending = pending_demo_respawn.is_some() && !demo_respawn_ready;
1338 let demo_respawn_supported = demo_respawn_ready;
1339
1340 if demo_respawn_pending {
1341 if let Some(pending) = self.pending_demo_respawns.get_mut(&player.player_id) {
1342 pending.pre_demo_boost_amount = pending
1343 .pre_demo_boost_amount
1344 .or(previous_sample_boost_amount);
1345 }
1346 continue;
1347 }
1348
1349 let mut boost_increase_reasons = Vec::new();
1350 if let Some(previous_sample_boost_amount) = previous_sample_boost_amount {
1351 boost_increase_reasons = Self::classify_boost_increase_reasons(
1352 previous_sample_boost_amount,
1353 boost_amount,
1354 kickoff_phase_active,
1355 demo_respawn_supported,
1356 );
1357 if track_boost_levels {
1358 for reason in &boost_increase_reasons {
1359 if let Ok(pad_type) = BoostPickupPadType::try_from(*reason) {
1360 self.record_inferred_pickup(PendingBoostPickupEvent {
1361 frame: frame.frame_number,
1362 time: frame.time,
1363 player_id: player.player_id.clone(),
1364 player_position: player
1365 .position()
1366 .map(|position| position.to_array()),
1367 is_team_0: player.is_team_0,
1368 pad_type,
1369 field_half: Self::field_half_from_position(
1370 player.is_team_0,
1371 player.position(),
1372 ),
1373 activity: Self::activity_label(live_play),
1374 boost_before: Some(previous_sample_boost_amount),
1375 boost_after: Some(boost_amount),
1376 });
1377 }
1378 }
1379 }
1380 }
1381 let generic_respawn_supported = track_boost_levels
1382 && boost_increase_reasons.contains(&BoostIncreaseReason::Respawn);
1383 if track_boost_levels {
1384 let previous = if boost_levels_resumed_this_sample {
1385 boost_amount
1386 } else {
1387 previous_boost_amount
1388 };
1389 self.stats.apply_boost_sample(
1390 &player.player_id,
1391 player.is_team_0,
1392 previous,
1393 boost_amount,
1394 frame.dt,
1395 );
1396 }
1397
1398 let mut respawn_amount = 0.0;
1399 let first_seen_player = self
1403 .initial_respawn_awarded
1404 .insert(player.player_id.clone());
1405 let kickoff_respawn_due = kickoff_phase_active
1406 && !self.kickoff_respawn_awarded.contains(&player.player_id)
1407 && track_boost_levels;
1408 if first_seen_player || kickoff_respawn_due {
1409 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1410 self.kickoff_respawn_awarded
1411 .insert(player.player_id.clone());
1412 }
1413 let demo_respawn = demo_respawn_supported || generic_respawn_supported;
1414 if demo_respawn {
1415 if let Some(pending) = self.pending_demo_respawns.get(&player.player_id) {
1416 let boost_removed_by_demo_amount = pending
1419 .pre_demo_boost_amount
1420 .unwrap_or(previous_boost_amount)
1421 .max(0.0);
1422 self.player_usage_state
1423 .entry(player.player_id.clone())
1424 .or_default()
1425 .apply_removed_amount(boost_removed_by_demo_amount);
1426 }
1427 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
1428 self.pending_demo_respawns.remove(&player.player_id);
1429 }
1430 if respawn_amount > 0.0 {
1431 let respawn_kind = if demo_respawn {
1432 RespawnKind::Demo
1433 } else {
1434 RespawnKind::Kickoff
1435 };
1436 self.apply_respawn_amount(
1437 &player.player_id,
1438 player.position().map(|position| position.to_array()),
1439 player.is_team_0,
1440 frame.frame_number,
1441 frame.time,
1442 respawn_amount,
1443 respawn_kind,
1444 );
1445 }
1446 respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
1447
1448 current_boost_amounts.push((player.player_id.clone(), player.is_team_0, boost_amount));
1449 }
1450
1451 self.resolve_deferred_reported_pickups(frame, players);
1452
1453 for event in &events.boost_pad_events {
1454 match event.kind {
1455 BoostPadEventKind::PickedUp { sequence } => {
1456 if !track_boost_pickups && !self.config.include_non_live_pickups {
1457 let Some(player_id) = &event.player else {
1458 continue;
1459 };
1460 let Some(player) = players
1461 .players
1462 .iter()
1463 .find(|player| &player.player_id == player_id)
1464 else {
1465 continue;
1466 };
1467 let previous_boost_amount = self
1468 .previous_boost_amounts
1469 .get(player_id)
1470 .copied()
1471 .or(player.last_boost_amount)
1472 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1473 let respawn_amount = respawn_amounts_by_player
1474 .get(player_id)
1475 .copied()
1476 .unwrap_or(0.0);
1477 let Some((collected_amount, pad_size)) = self.inactive_pickup_stats(
1478 player,
1479 &event.pad_id,
1480 previous_boost_amount,
1481 respawn_amount,
1482 ) else {
1483 continue;
1484 };
1485 if !self.inactive_pickup_frames.insert((
1486 player_id.clone(),
1487 event.frame,
1488 pad_size,
1489 )) {
1490 continue;
1491 }
1492 let pickup = self.apply_inactive_pickup(
1493 player_id,
1494 player.position().map(|position| position.to_array()),
1495 player.is_team_0,
1496 event.frame,
1497 event.time,
1498 collected_amount,
1499 pad_size,
1500 Some(previous_boost_amount),
1501 player.boost_amount,
1502 );
1503 let reported_event = PendingBoostPickupEvent {
1504 frame: event.frame,
1505 time: event.time,
1506 player_id: player_id.clone(),
1507 player_position: player.position().map(|position| position.to_array()),
1508 is_team_0: player.is_team_0,
1509 pad_type: pad_size.into(),
1510 field_half: Self::field_half_from_position(
1511 player.is_team_0,
1512 player.position(),
1513 ),
1514 activity: BoostPickupActivity::Inactive,
1515 boost_before: None,
1516 boost_after: None,
1517 };
1518 self.push_reported_pickup(reported_event, pickup);
1519 continue;
1520 }
1521 let Some(player_id) = &event.player else {
1522 continue;
1523 };
1524 let Some(player) = players
1525 .players
1526 .iter()
1527 .find(|player| &player.player_id == player_id)
1528 else {
1529 continue;
1530 };
1531 if self.unavailable_pad_is_recent(&event.pad_id, event.time, player.position())
1532 {
1533 continue;
1534 }
1535 let pickup_key = (event.pad_id.clone(), player_id.clone());
1536 if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
1537 continue;
1538 }
1539 self.pickup_frames.insert(pickup_key, event.frame);
1540 if self.seen_pickup_sequence_is_recent(
1541 &event.pad_id,
1542 sequence,
1543 event.time,
1544 player.position(),
1545 ) {
1546 continue;
1547 }
1548 self.seen_pickup_sequence_times
1549 .insert((event.pad_id.clone(), sequence), event.time);
1550 self.unavailable_pads.insert(event.pad_id.clone());
1551 self.last_pickup_times
1552 .insert(event.pad_id.clone(), event.time);
1553 if let Some(position) = player.position() {
1554 self.observed_pad_positions
1555 .entry(event.pad_id.clone())
1556 .or_default()
1557 .observe(position);
1558 }
1559 let previous_boost_amount = self
1560 .previous_boost_amounts
1561 .get(player_id)
1562 .copied()
1563 .or(player.last_boost_amount)
1564 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0));
1565 let pre_applied_collected_amount =
1566 if pickup_counts_by_player.get(player_id).copied() == Some(1) {
1567 self.previous_boost_amounts
1568 .get(player_id)
1569 .copied()
1570 .map(|previous_sample_boost_amount| {
1571 let respawn_amount = respawn_amounts_by_player
1572 .get(player_id)
1573 .copied()
1574 .unwrap_or(0.0);
1575 (player.boost_amount.unwrap_or(previous_boost_amount)
1576 - previous_sample_boost_amount
1577 - respawn_amount)
1578 .max(0.0)
1579 })
1580 .unwrap_or(0.0)
1581 } else {
1582 0.0
1583 };
1584 let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
1585 .then(|| {
1586 self.guess_pad_size_from_position(
1587 &event.pad_id,
1588 player.position().unwrap_or(glam::Vec3::ZERO),
1589 )
1590 })
1591 .flatten();
1592 self.apply_pickup_collected_amount(
1593 player_id,
1594 pre_applied_collected_amount,
1595 pre_applied_pad_size,
1596 );
1597 let pending_pickup = PendingBoostPickup {
1598 frame: event.frame,
1599 time: event.time,
1600 player_id: player_id.clone(),
1601 is_team_0: player.is_team_0,
1602 previous_boost_amount,
1603 pre_applied_collected_amount,
1604 player_position: player.position().unwrap_or(glam::Vec3::ZERO),
1605 boost_before: Some(previous_boost_amount),
1606 boost_after: player.boost_amount,
1607 };
1608
1609 let pad_size = self
1610 .known_pad_sizes
1611 .get(&event.pad_id)
1612 .copied()
1613 .or_else(|| {
1614 let mut size = self.guess_pad_size_from_position(
1615 &event.pad_id,
1616 player.position().unwrap_or(glam::Vec3::ZERO),
1617 )?;
1618 if size == BoostPadSize::Small
1622 && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW + 1.0
1623 {
1624 size = BoostPadSize::Big;
1625 }
1626 self.known_pad_sizes.insert(event.pad_id.clone(), size);
1627 Some(size)
1628 });
1629 if let Some(pad_size) = pad_size {
1630 let reported_event = PendingBoostPickupEvent {
1631 frame: event.frame,
1632 time: event.time,
1633 player_id: player_id.clone(),
1634 player_position: player.position().map(|position| position.to_array()),
1635 is_team_0: player.is_team_0,
1636 pad_type: pad_size.into(),
1637 field_half: Self::field_half_from_position(
1638 player.is_team_0,
1639 player.position(),
1640 ),
1641 activity: Self::activity_label(track_boost_pickups),
1642 boost_before: None,
1643 boost_after: None,
1644 };
1645 let pickup_gain_is_visible = pre_applied_collected_amount > 1.0
1646 || player.boost_amount.is_some_and(|boost_amount| {
1647 boost_amount > previous_boost_amount + 1.0
1648 });
1649 if !pickup_gain_is_visible {
1650 self.pending_reported_pickups
1651 .push_back(DeferredReportedBoostPickup {
1652 pad_id: event.pad_id.clone(),
1653 pending_pickup,
1654 pad_size,
1655 reported_event,
1656 });
1657 continue;
1658 }
1659
1660 let pickup = self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
1661 self.push_reported_pickup(reported_event, pickup);
1662 }
1663 }
1664 BoostPadEventKind::Available => {
1665 if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
1666 let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
1667 else {
1668 continue;
1669 };
1670 if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
1671 {
1672 continue;
1673 }
1674 }
1675 self.unavailable_pads.remove(&event.pad_id);
1676 }
1677 }
1678 }
1679 self.flush_stale_pickup_comparisons(frame);
1680
1681 if track_boost_levels {
1682 for player in &players.players {
1683 if self.pending_demo_respawns.contains_key(&player.player_id) {
1684 continue;
1685 }
1686 let Some(boost_amount) = player.boost_amount else {
1687 continue;
1688 };
1689 let usage_state = self.player_usage_state(&player.player_id);
1690 let previous_amount_used = usage_state.amount_used;
1691 let amount_used_raw = usage_state.inferred_amount_used(boost_amount);
1692 let amount_used = amount_used_raw.max(usage_state.amount_used);
1693 let amount_used_delta = amount_used - previous_amount_used;
1694 let split_amount = usage_state.amount_used_by_vertical_band();
1695 let amount_used_allocation_delta = (amount_used - split_amount).max(0.0);
1696 if amount_used_allocation_delta > 0.0 {
1697 let speed = player.speed();
1698 let previous_speed = self
1699 .previous_player_speeds
1700 .get(&player.player_id)
1701 .copied()
1702 .or(speed);
1703 let previous_speed = if boost_levels_resumed_this_sample {
1704 speed
1705 } else {
1706 previous_speed
1707 };
1708 let used_while_supersonic = player.boost_active
1709 && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
1710 && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD;
1711 let grounded = vertical_state.is_grounded(&player.player_id);
1712 self.stats.apply_used_allocation(
1713 &player.player_id,
1714 player.is_team_0,
1715 amount_used_allocation_delta,
1716 grounded,
1717 used_while_supersonic,
1718 );
1719 self.player_usage_state
1720 .entry(player.player_id.clone())
1721 .or_default()
1722 .apply_used_allocation(amount_used_allocation_delta, grounded);
1723 }
1724 if amount_used_delta <= 0.0 {
1725 continue;
1726 }
1727 self.player_usage_state
1728 .entry(player.player_id.clone())
1729 .or_default()
1730 .apply_used_amount(amount_used_delta);
1731 self.stats
1732 .apply_used(&player.player_id, player.is_team_0, amount_used_delta);
1733 }
1734 }
1735 for (player_id, is_team_0, boost_amount) in current_boost_amounts {
1736 if track_boost_levels {
1737 self.record_boost_tracks(&player_id, is_team_0, frame.frame_number, boost_amount);
1738 }
1739 self.previous_boost_amounts.insert(player_id, boost_amount);
1740 }
1741 for player in &players.players {
1742 if let Some(speed) = player.speed() {
1743 self.previous_player_speeds
1744 .insert(player.player_id.clone(), speed);
1745 }
1746 }
1747 self.kickoff_phase_active_last_frame = kickoff_phase_active;
1748 self.previous_boost_levels_live = Some(boost_levels_live);
1749
1750 Ok(())
1751 }
1752}
1753
1754#[cfg(test)]
1755#[path = "boost_tests.rs"]
1756mod tests;