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