1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize)]
4pub struct BoostStats {
5 pub tracked_time: f32,
6 pub boost_integral: f32,
7 pub time_zero_boost: f32,
8 pub time_hundred_boost: f32,
9 pub time_boost_0_25: f32,
10 pub time_boost_25_50: f32,
11 pub time_boost_50_75: f32,
12 pub time_boost_75_100: f32,
13 pub amount_collected: f32,
14 pub amount_stolen: f32,
15 pub big_pads_collected: u32,
16 pub small_pads_collected: u32,
17 pub big_pads_stolen: u32,
18 pub small_pads_stolen: u32,
19 pub amount_collected_big: f32,
20 pub amount_stolen_big: f32,
21 pub amount_collected_small: f32,
22 pub amount_stolen_small: f32,
23 pub amount_respawned: f32,
24 pub overfill_total: f32,
25 pub overfill_from_stolen: f32,
26 pub amount_used: f32,
27 pub amount_used_while_grounded: f32,
28 pub amount_used_while_airborne: f32,
29 pub amount_used_while_supersonic: f32,
30}
31
32impl BoostStats {
33 pub fn average_boost_amount(&self) -> f32 {
34 if self.tracked_time == 0.0 {
35 0.0
36 } else {
37 self.boost_integral / self.tracked_time
38 }
39 }
40
41 pub fn bpm(&self) -> f32 {
42 if self.tracked_time == 0.0 {
43 0.0
44 } else {
45 self.amount_collected * 60.0 / self.tracked_time
46 }
47 }
48
49 fn pct(&self, value: f32) -> f32 {
50 if self.tracked_time == 0.0 {
51 0.0
52 } else {
53 value * 100.0 / self.tracked_time
54 }
55 }
56
57 pub fn zero_boost_pct(&self) -> f32 {
58 self.pct(self.time_zero_boost)
59 }
60
61 pub fn hundred_boost_pct(&self) -> f32 {
62 self.pct(self.time_hundred_boost)
63 }
64
65 pub fn boost_0_25_pct(&self) -> f32 {
66 self.pct(self.time_boost_0_25)
67 }
68
69 pub fn boost_25_50_pct(&self) -> f32 {
70 self.pct(self.time_boost_25_50)
71 }
72
73 pub fn boost_50_75_pct(&self) -> f32 {
74 self.pct(self.time_boost_50_75)
75 }
76
77 pub fn boost_75_100_pct(&self) -> f32 {
78 self.pct(self.time_boost_75_100)
79 }
80
81 pub fn amount_obtained(&self) -> f32 {
82 self.amount_collected_big + self.amount_collected_small + self.amount_respawned
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub struct BoostReducerConfig {
88 pub include_non_live_pickups: bool,
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct BoostReducer {
93 config: BoostReducerConfig,
94 player_stats: HashMap<PlayerId, BoostStats>,
95 team_zero_stats: BoostStats,
96 team_one_stats: BoostStats,
97 previous_boost_amounts: HashMap<PlayerId, f32>,
98 previous_player_speeds: HashMap<PlayerId, f32>,
99 observed_pad_positions: HashMap<String, PadPositionEstimate>,
100 known_pad_sizes: HashMap<String, BoostPadSize>,
101 known_pad_indices: HashMap<String, usize>,
102 unavailable_pads: HashSet<String>,
103 seen_pickup_sequences: HashSet<(String, u8)>,
104 pickup_frames: HashMap<(String, PlayerId), usize>,
105 last_pickup_times: HashMap<String, f32>,
106 kickoff_phase_active_last_frame: bool,
107 kickoff_respawn_awarded: HashSet<PlayerId>,
108 initial_respawn_awarded: HashSet<PlayerId>,
109 pending_demo_respawns: HashSet<PlayerId>,
110 previous_boost_levels_live: Option<bool>,
111 active_invariant_warnings: HashSet<BoostInvariantWarningKey>,
112 live_play_tracker: LivePlayTracker,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116struct BoostInvariantWarningKey {
117 scope: String,
118 kind: BoostInvariantKind,
119}
120
121#[derive(Debug, Clone)]
122struct PendingBoostPickup {
123 player_id: PlayerId,
124 is_team_0: bool,
125 previous_boost_amount: f32,
126 pre_applied_collected_amount: f32,
127 pre_applied_pad_size: Option<BoostPadSize>,
128 player_position: glam::Vec3,
129}
130
131impl BoostReducer {
132 pub fn new() -> Self {
133 Self::with_config(BoostReducerConfig::default())
134 }
135
136 pub fn with_config(config: BoostReducerConfig) -> Self {
137 Self {
138 config,
139 ..Self::default()
140 }
141 }
142
143 pub fn player_stats(&self) -> &HashMap<PlayerId, BoostStats> {
144 &self.player_stats
145 }
146
147 pub fn team_zero_stats(&self) -> &BoostStats {
148 &self.team_zero_stats
149 }
150
151 pub fn team_one_stats(&self) -> &BoostStats {
152 &self.team_one_stats
153 }
154
155 fn estimated_pad_position(&self, pad_id: &str) -> Option<glam::Vec3> {
156 self.observed_pad_positions
157 .get(pad_id)
158 .and_then(PadPositionEstimate::mean)
159 }
160
161 fn observed_pad_positions(&self, pad_id: &str) -> &[glam::Vec3] {
162 self.observed_pad_positions
163 .get(pad_id)
164 .map(PadPositionEstimate::observations)
165 .unwrap_or(&[])
166 }
167
168 fn pad_match_radius(pad_size: BoostPadSize) -> f32 {
169 match pad_size {
170 BoostPadSize::Big => STANDARD_PAD_MATCH_RADIUS_BIG,
171 BoostPadSize::Small => STANDARD_PAD_MATCH_RADIUS_SMALL,
172 }
173 }
174
175 pub fn resolved_boost_pads(&self) -> Vec<ResolvedBoostPad> {
176 standard_soccar_boost_pad_layout()
177 .iter()
178 .enumerate()
179 .map(|(index, (position, size))| ResolvedBoostPad {
180 index,
181 pad_id: self
182 .known_pad_indices
183 .iter()
184 .find_map(|(pad_id, pad_index)| (*pad_index == index).then(|| pad_id.clone())),
185 size: *size,
186 position: glam_to_vec(position),
187 })
188 .collect()
189 }
190
191 fn infer_pad_index(
192 &self,
193 pad_id: &str,
194 pad_size: BoostPadSize,
195 observed_position: glam::Vec3,
196 ) -> Option<usize> {
197 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
198 return Some(index);
199 }
200
201 let observed_position = self
202 .estimated_pad_position(pad_id)
203 .unwrap_or(observed_position);
204 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
205 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
206 let radius = Self::pad_match_radius(pad_size);
207 let observed_positions = self.observed_pad_positions(pad_id);
208 let best_candidate = |allow_used: bool| {
209 layout
210 .iter()
211 .enumerate()
212 .filter(|(index, (_, size))| {
213 *size == pad_size && (allow_used || !used_indices.contains(index))
214 })
215 .filter_map(|(index, (candidate_position, _))| {
216 let mut vote_count = 0usize;
217 let mut total_vote_distance = 0.0f32;
218 let mut best_vote_distance = f32::INFINITY;
219
220 for position in observed_positions {
221 let distance = position.distance(*candidate_position);
222 if distance <= radius {
223 vote_count += 1;
224 total_vote_distance += distance;
225 best_vote_distance = best_vote_distance.min(distance);
226 }
227 }
228
229 if vote_count == 0 {
230 return None;
231 }
232
233 let representative_distance = observed_position.distance(*candidate_position);
234 Some((
235 index,
236 vote_count,
237 total_vote_distance / vote_count as f32,
238 best_vote_distance,
239 representative_distance,
240 ))
241 })
242 .max_by(|left, right| {
243 left.1
244 .cmp(&right.1)
245 .then_with(|| right.2.partial_cmp(&left.2).unwrap())
246 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
247 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
248 })
249 .map(|(index, _, _, _, _)| index)
250 };
251
252 best_candidate(false)
253 .or_else(|| best_candidate(true))
254 .or_else(|| {
255 layout
256 .iter()
257 .enumerate()
258 .filter(|(index, (_, size))| *size == pad_size && !used_indices.contains(index))
259 .min_by(|(_, (a, _)), (_, (b, _))| {
260 observed_position
261 .distance_squared(*a)
262 .partial_cmp(&observed_position.distance_squared(*b))
263 .unwrap()
264 })
265 .map(|(index, _)| index)
266 })
267 .or_else(|| {
268 layout
269 .iter()
270 .enumerate()
271 .filter(|(_, (_, size))| *size == pad_size)
272 .min_by(|(_, (a, _)), (_, (b, _))| {
273 observed_position
274 .distance_squared(*a)
275 .partial_cmp(&observed_position.distance_squared(*b))
276 .unwrap()
277 })
278 .map(|(index, _)| index)
279 })
280 .filter(|index| {
281 observed_position.distance(standard_soccar_boost_pad_position(*index)) <= radius
282 })
283 }
284
285 fn infer_pad_details_from_position(
286 &self,
287 pad_id: &str,
288 observed_position: glam::Vec3,
289 ) -> Option<(usize, BoostPadSize)> {
290 if let Some(index) = self.known_pad_indices.get(pad_id).copied() {
291 let (_, size) = standard_soccar_boost_pad_layout().get(index)?;
292 return Some((index, *size));
293 }
294
295 let observed_position = self
296 .estimated_pad_position(pad_id)
297 .unwrap_or(observed_position);
298 let layout = &*STANDARD_SOCCAR_BOOST_PAD_LAYOUT;
299 let used_indices: HashSet<usize> = self.known_pad_indices.values().copied().collect();
300 let observed_positions = self.observed_pad_positions(pad_id);
301 let best_candidate = |allow_used: bool| {
302 layout
303 .iter()
304 .enumerate()
305 .filter(|(index, _)| allow_used || !used_indices.contains(index))
306 .filter_map(|(index, (candidate_position, size))| {
307 let radius = Self::pad_match_radius(*size);
308 let mut vote_count = 0usize;
309 let mut total_vote_distance = 0.0f32;
310 let mut best_vote_distance = f32::INFINITY;
311
312 for position in observed_positions {
313 let distance = position.distance(*candidate_position);
314 if distance <= radius {
315 vote_count += 1;
316 total_vote_distance += distance;
317 best_vote_distance = best_vote_distance.min(distance);
318 }
319 }
320
321 if vote_count == 0 {
322 return None;
323 }
324
325 let representative_distance = observed_position.distance(*candidate_position);
326 Some((
327 index,
328 *size,
329 vote_count,
330 total_vote_distance / vote_count as f32,
331 best_vote_distance,
332 representative_distance,
333 ))
334 })
335 .max_by(|left, right| {
336 left.2
337 .cmp(&right.2)
338 .then_with(|| right.3.partial_cmp(&left.3).unwrap())
339 .then_with(|| right.4.partial_cmp(&left.4).unwrap())
340 .then_with(|| right.5.partial_cmp(&left.5).unwrap())
341 })
342 .map(|(index, size, _, _, _, _)| (index, size))
343 };
344
345 best_candidate(false).or_else(|| best_candidate(true))
346 }
347
348 fn guess_pad_size_from_position(
349 &self,
350 pad_id: &str,
351 observed_position: glam::Vec3,
352 ) -> Option<BoostPadSize> {
353 if let Some(pad_size) = self.known_pad_sizes.get(pad_id).copied() {
354 return Some(pad_size);
355 }
356
357 if let Some((_, pad_size)) = self.infer_pad_details_from_position(pad_id, observed_position)
358 {
359 return Some(pad_size);
360 }
361
362 let observed_position = self
363 .estimated_pad_position(pad_id)
364 .unwrap_or(observed_position);
365 standard_soccar_boost_pad_layout()
366 .iter()
367 .min_by(|(left_position, _), (right_position, _)| {
368 observed_position
369 .distance_squared(*left_position)
370 .partial_cmp(&observed_position.distance_squared(*right_position))
371 .unwrap()
372 })
373 .map(|(_, pad_size)| *pad_size)
374 }
375
376 fn resolve_pickup(
377 &mut self,
378 pad_id: &str,
379 pending_pickup: PendingBoostPickup,
380 pad_size: BoostPadSize,
381 ) {
382 let observed_position = self
383 .estimated_pad_position(pad_id)
384 .unwrap_or(pending_pickup.player_position);
385 let pad_position = self
386 .infer_pad_index(pad_id, pad_size, observed_position)
387 .map(|index| {
388 self.known_pad_indices.insert(pad_id.to_string(), index);
389 standard_soccar_boost_pad_position(index)
390 })
391 .unwrap_or(observed_position);
392 let stolen = is_enemy_side(pending_pickup.is_team_0, pad_position);
393 let stats = self
394 .player_stats
395 .entry(pending_pickup.player_id.clone())
396 .or_default();
397 let team_stats = if pending_pickup.is_team_0 {
398 &mut self.team_zero_stats
399 } else {
400 &mut self.team_one_stats
401 };
402 let nominal_gain = match pad_size {
403 BoostPadSize::Big => BOOST_MAX_AMOUNT,
404 BoostPadSize::Small => SMALL_PAD_AMOUNT_RAW,
405 };
406 let collected_amount = (BOOST_MAX_AMOUNT - pending_pickup.previous_boost_amount)
407 .min(nominal_gain)
408 .max(pending_pickup.pre_applied_collected_amount);
409 let collected_amount_delta = collected_amount - pending_pickup.pre_applied_collected_amount;
410 let overfill = (nominal_gain - collected_amount).max(0.0);
411
412 stats.amount_collected += collected_amount_delta;
413 team_stats.amount_collected += collected_amount_delta;
414
415 match pending_pickup.pre_applied_pad_size {
416 Some(pre_applied_pad_size) if pre_applied_pad_size == pad_size => {
417 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount_delta);
418 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount_delta);
419 }
420 Some(pre_applied_pad_size) => {
421 Self::apply_collected_bucket_amount(
422 stats,
423 pre_applied_pad_size,
424 -pending_pickup.pre_applied_collected_amount,
425 );
426 Self::apply_collected_bucket_amount(
427 team_stats,
428 pre_applied_pad_size,
429 -pending_pickup.pre_applied_collected_amount,
430 );
431 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
432 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
433 }
434 None => {
435 Self::apply_collected_bucket_amount(stats, pad_size, collected_amount);
436 Self::apply_collected_bucket_amount(team_stats, pad_size, collected_amount);
437 }
438 }
439
440 if stolen {
441 stats.amount_stolen += collected_amount;
442 team_stats.amount_stolen += collected_amount;
443 }
444
445 match pad_size {
446 BoostPadSize::Big => {
447 stats.big_pads_collected += 1;
448 team_stats.big_pads_collected += 1;
449 if stolen {
450 stats.big_pads_stolen += 1;
451 team_stats.big_pads_stolen += 1;
452 stats.amount_stolen_big += collected_amount;
453 team_stats.amount_stolen_big += collected_amount;
454 }
455 }
456 BoostPadSize::Small => {
457 stats.small_pads_collected += 1;
458 team_stats.small_pads_collected += 1;
459 if stolen {
460 stats.small_pads_stolen += 1;
461 team_stats.small_pads_stolen += 1;
462 stats.amount_stolen_small += collected_amount;
463 team_stats.amount_stolen_small += collected_amount;
464 }
465 }
466 }
467
468 stats.overfill_total += overfill;
469 team_stats.overfill_total += overfill;
470 if stolen {
471 stats.overfill_from_stolen += overfill;
472 team_stats.overfill_from_stolen += overfill;
473 }
474 }
475
476 fn apply_collected_bucket_amount(stats: &mut BoostStats, pad_size: BoostPadSize, amount: f32) {
477 if amount == 0.0 {
478 return;
479 }
480
481 match pad_size {
482 BoostPadSize::Big => stats.amount_collected_big += amount,
483 BoostPadSize::Small => stats.amount_collected_small += amount,
484 }
485 }
486
487 fn apply_pickup_collected_amount(
488 &mut self,
489 player_id: &PlayerId,
490 is_team_0: bool,
491 amount: f32,
492 pad_size: Option<BoostPadSize>,
493 ) {
494 if amount <= 0.0 {
495 return;
496 }
497
498 let stats = self.player_stats.entry(player_id.clone()).or_default();
499 let team_stats = if is_team_0 {
500 &mut self.team_zero_stats
501 } else {
502 &mut self.team_one_stats
503 };
504 stats.amount_collected += amount;
505 team_stats.amount_collected += amount;
506 if let Some(pad_size) = pad_size {
507 Self::apply_collected_bucket_amount(stats, pad_size, amount);
508 Self::apply_collected_bucket_amount(team_stats, pad_size, amount);
509 }
510 }
511
512 fn apply_respawn_amount(&mut self, player_id: &PlayerId, is_team_0: bool, amount: f32) {
513 if amount <= 0.0 {
514 return;
515 }
516
517 let stats = self.player_stats.entry(player_id.clone()).or_default();
518 let team_stats = if is_team_0 {
519 &mut self.team_zero_stats
520 } else {
521 &mut self.team_one_stats
522 };
523 stats.amount_respawned += amount;
524 team_stats.amount_respawned += amount;
525 }
526
527 fn warn_for_boost_invariant_violations(
528 &mut self,
529 scope: &str,
530 frame_number: usize,
531 time: f32,
532 stats: &BoostStats,
533 observed_boost_amount: Option<f32>,
534 ) {
535 let violations = boost_invariant_violations(stats, observed_boost_amount);
536 let active_kinds: HashSet<BoostInvariantKind> =
537 violations.iter().map(|violation| violation.kind).collect();
538
539 for violation in violations {
540 let key = BoostInvariantWarningKey {
541 scope: scope.to_string(),
542 kind: violation.kind,
543 };
544 if self.active_invariant_warnings.insert(key) {
545 log::warn!(
546 "Boost invariant violation for {} at frame {} (t={:.3}): {}",
547 scope,
548 frame_number,
549 time,
550 violation.message(),
551 );
552 }
553 }
554
555 for kind in BoostInvariantKind::ALL {
556 if active_kinds.contains(&kind) {
557 continue;
558 }
559 self.active_invariant_warnings
560 .remove(&BoostInvariantWarningKey {
561 scope: scope.to_string(),
562 kind,
563 });
564 }
565 }
566
567 fn warn_for_sample_boost_invariants(&mut self, sample: &StatsSample) {
568 let team_zero_stats = self.team_zero_stats.clone();
569 let team_one_stats = self.team_one_stats.clone();
570 let player_scopes: Vec<(PlayerId, Option<f32>, BoostStats)> = sample
571 .players
572 .iter()
573 .map(|player| {
574 (
575 player.player_id.clone(),
576 player.boost_amount,
577 self.player_stats
578 .get(&player.player_id)
579 .cloned()
580 .unwrap_or_default(),
581 )
582 })
583 .collect();
584
585 self.warn_for_boost_invariant_violations(
586 "team_zero",
587 sample.frame_number,
588 sample.time,
589 &team_zero_stats,
590 None,
591 );
592 self.warn_for_boost_invariant_violations(
593 "team_one",
594 sample.frame_number,
595 sample.time,
596 &team_one_stats,
597 None,
598 );
599 for (player_id, observed_boost_amount, stats) in player_scopes {
600 self.warn_for_boost_invariant_violations(
601 &format!("player {player_id:?}"),
602 sample.frame_number,
603 sample.time,
604 &stats,
605 observed_boost_amount,
606 );
607 }
608 }
609
610 fn interval_fraction_in_boost_range(
611 start_boost: f32,
612 end_boost: f32,
613 min_boost: f32,
614 max_boost: f32,
615 ) -> f32 {
616 if (end_boost - start_boost).abs() <= f32::EPSILON {
617 return ((start_boost >= min_boost) && (start_boost < max_boost)) as i32 as f32;
618 }
619
620 let t_at_min = (min_boost - start_boost) / (end_boost - start_boost);
621 let t_at_max = (max_boost - start_boost) / (end_boost - start_boost);
622 let interval_start = t_at_min.min(t_at_max).max(0.0);
623 let interval_end = t_at_min.max(t_at_max).min(1.0);
624 (interval_end - interval_start).max(0.0)
625 }
626
627 fn pad_respawn_time_seconds(pad_size: BoostPadSize) -> f32 {
628 match pad_size {
629 BoostPadSize::Big => 10.0,
630 BoostPadSize::Small => 4.0,
631 }
632 }
633
634 fn boost_levels_live(_sample: &StatsSample, live_play: bool) -> bool {
635 live_play
636 }
637
638 fn tracks_boost_levels(boost_levels_live: bool) -> bool {
639 boost_levels_live
640 }
641
642 fn tracks_boost_pickups(sample: &StatsSample, live_play: bool) -> bool {
643 live_play
644 || (sample.ball_has_been_hit == Some(false)
645 && sample.game_state != Some(GAME_STATE_KICKOFF_COUNTDOWN)
646 && sample.kickoff_countdown_time.is_none_or(|t| t <= 0))
647 }
648}
649
650impl StatsReducer for BoostReducer {
651 fn on_sample(&mut self, sample: &StatsSample) -> SubtrActorResult<()> {
652 let live_play = self.live_play_tracker.is_live_play(sample);
653 let boost_levels_live = Self::boost_levels_live(sample, live_play);
654 let track_boost_levels = Self::tracks_boost_levels(boost_levels_live);
655 let track_boost_pickups = Self::tracks_boost_pickups(sample, live_play);
656 let boost_levels_resumed_this_sample =
657 boost_levels_live && !self.previous_boost_levels_live.unwrap_or(false);
658 let kickoff_phase_active = sample.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
659 || sample.kickoff_countdown_time.is_some_and(|t| t > 0)
660 || sample.ball_has_been_hit == Some(false);
661 let kickoff_phase_started = kickoff_phase_active && !self.kickoff_phase_active_last_frame;
662 if kickoff_phase_started {
663 self.kickoff_respawn_awarded.clear();
664 }
665 for demo in &sample.demo_events {
666 self.pending_demo_respawns.insert(demo.victim.clone());
667 }
668
669 let mut current_boost_amounts = Vec::new();
670 let mut pickup_counts_by_player = HashMap::<PlayerId, usize>::new();
671 let mut respawn_amounts_by_player = HashMap::<PlayerId, f32>::new();
672
673 for event in &sample.boost_pad_events {
674 let BoostPadEventKind::PickedUp { .. } = event.kind else {
675 continue;
676 };
677 let Some(player_id) = &event.player else {
678 continue;
679 };
680 *pickup_counts_by_player
681 .entry(player_id.clone())
682 .or_default() += 1;
683 }
684
685 for player in &sample.players {
686 let Some(boost_amount) = player.boost_amount else {
687 continue;
688 };
689 let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
690 self.previous_boost_amounts
691 .get(&player.player_id)
692 .copied()
693 .unwrap_or(boost_amount)
694 });
695 let previous_boost_amount = if boost_levels_resumed_this_sample {
696 boost_amount
697 } else {
698 previous_boost_amount
699 };
700 let speed = player.speed();
701 let previous_speed = self
702 .previous_player_speeds
703 .get(&player.player_id)
704 .copied()
705 .or(speed);
706 let previous_speed = if boost_levels_resumed_this_sample {
707 speed
708 } else {
709 previous_speed
710 };
711
712 if track_boost_levels {
713 let average_boost_amount = (previous_boost_amount + boost_amount) * 0.5;
714 let time_zero_boost = sample.dt
715 * Self::interval_fraction_in_boost_range(
716 previous_boost_amount,
717 boost_amount,
718 0.0,
719 BOOST_ZERO_BAND_RAW,
720 );
721 let time_hundred_boost = sample.dt
722 * Self::interval_fraction_in_boost_range(
723 previous_boost_amount,
724 boost_amount,
725 BOOST_FULL_BAND_MIN_RAW,
726 BOOST_MAX_AMOUNT + 1.0,
727 );
728 let time_boost_0_25 = sample.dt
729 * Self::interval_fraction_in_boost_range(
730 previous_boost_amount,
731 boost_amount,
732 0.0,
733 boost_percent_to_amount(25.0),
734 );
735 let time_boost_25_50 = sample.dt
736 * Self::interval_fraction_in_boost_range(
737 previous_boost_amount,
738 boost_amount,
739 boost_percent_to_amount(25.0),
740 boost_percent_to_amount(50.0),
741 );
742 let time_boost_50_75 = sample.dt
743 * Self::interval_fraction_in_boost_range(
744 previous_boost_amount,
745 boost_amount,
746 boost_percent_to_amount(50.0),
747 boost_percent_to_amount(75.0),
748 );
749 let time_boost_75_100 = sample.dt
750 * Self::interval_fraction_in_boost_range(
751 previous_boost_amount,
752 boost_amount,
753 boost_percent_to_amount(75.0),
754 BOOST_MAX_AMOUNT + 1.0,
755 );
756 let supersonic_usage = if player.boost_active
757 && speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
758 && previous_speed.unwrap_or(0.0) >= SUPERSONIC_SPEED_THRESHOLD
759 {
760 (previous_boost_amount - boost_amount)
761 .max(0.0)
762 .min(BOOST_USED_RAW_UNITS_PER_SECOND * sample.dt)
763 } else {
764 0.0
765 };
766 let observed_usage = (previous_boost_amount - boost_amount).max(0.0);
767 let grounded_usage = if player
768 .position()
769 .is_some_and(|position| position.z <= GROUND_Z_THRESHOLD)
770 {
771 observed_usage
772 } else {
773 0.0
774 };
775 let airborne_usage = observed_usage - grounded_usage;
776
777 let stats = self
778 .player_stats
779 .entry(player.player_id.clone())
780 .or_default();
781 let team_stats = if player.is_team_0 {
782 &mut self.team_zero_stats
783 } else {
784 &mut self.team_one_stats
785 };
786
787 stats.tracked_time += sample.dt;
788 stats.boost_integral += average_boost_amount * sample.dt;
789 team_stats.tracked_time += sample.dt;
790 team_stats.boost_integral += average_boost_amount * sample.dt;
791 stats.time_zero_boost += time_zero_boost;
792 team_stats.time_zero_boost += time_zero_boost;
793 stats.time_hundred_boost += time_hundred_boost;
794 team_stats.time_hundred_boost += time_hundred_boost;
795 stats.time_boost_0_25 += time_boost_0_25;
796 team_stats.time_boost_0_25 += time_boost_0_25;
797 stats.time_boost_25_50 += time_boost_25_50;
798 team_stats.time_boost_25_50 += time_boost_25_50;
799 stats.time_boost_50_75 += time_boost_50_75;
800 team_stats.time_boost_50_75 += time_boost_50_75;
801 stats.time_boost_75_100 += time_boost_75_100;
802 team_stats.time_boost_75_100 += time_boost_75_100;
803 stats.amount_used_while_grounded += grounded_usage;
804 team_stats.amount_used_while_grounded += grounded_usage;
805 stats.amount_used_while_airborne += airborne_usage;
806 team_stats.amount_used_while_airborne += airborne_usage;
807 stats.amount_used_while_supersonic += supersonic_usage;
808 team_stats.amount_used_while_supersonic += supersonic_usage;
809 }
810
811 let mut respawn_amount = 0.0;
812 let first_seen_player = self
816 .initial_respawn_awarded
817 .insert(player.player_id.clone());
818 if first_seen_player
819 || (kickoff_phase_active
820 && !self.kickoff_respawn_awarded.contains(&player.player_id))
821 {
822 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
823 self.kickoff_respawn_awarded
824 .insert(player.player_id.clone());
825 }
826 if self.pending_demo_respawns.contains(&player.player_id) && player.rigid_body.is_some()
827 {
828 respawn_amount += BOOST_KICKOFF_START_AMOUNT;
829 self.pending_demo_respawns.remove(&player.player_id);
830 }
831 if respawn_amount > 0.0 {
832 self.apply_respawn_amount(&player.player_id, player.is_team_0, respawn_amount);
833 }
834 respawn_amounts_by_player.insert(player.player_id.clone(), respawn_amount);
835
836 current_boost_amounts.push((player.player_id.clone(), boost_amount));
837 }
838
839 for event in &sample.boost_pad_events {
840 match event.kind {
841 BoostPadEventKind::PickedUp { sequence } => {
842 if !track_boost_pickups && !self.config.include_non_live_pickups {
843 continue;
844 }
845 if self.unavailable_pads.contains(&event.pad_id) {
846 continue;
847 }
848 let Some(player_id) = &event.player else {
849 continue;
850 };
851 let pickup_key = (event.pad_id.clone(), player_id.clone());
852 if self.pickup_frames.get(&pickup_key).copied() == Some(event.frame) {
853 continue;
854 }
855 self.pickup_frames.insert(pickup_key, event.frame);
856 if !self
857 .seen_pickup_sequences
858 .insert((event.pad_id.clone(), sequence))
859 {
860 continue;
861 }
862 self.unavailable_pads.insert(event.pad_id.clone());
863 self.last_pickup_times
864 .insert(event.pad_id.clone(), event.time);
865 let Some(player) = sample
866 .players
867 .iter()
868 .find(|player| &player.player_id == player_id)
869 else {
870 continue;
871 };
872 if let Some(position) = player.position() {
873 self.observed_pad_positions
874 .entry(event.pad_id.clone())
875 .or_default()
876 .observe(position);
877 }
878 let previous_boost_amount = player.last_boost_amount.unwrap_or_else(|| {
879 self.previous_boost_amounts
880 .get(player_id)
881 .copied()
882 .unwrap_or_else(|| player.boost_amount.unwrap_or(0.0))
883 });
884 let pre_applied_collected_amount =
885 if pickup_counts_by_player.get(player_id).copied() == Some(1) {
886 self.previous_boost_amounts
887 .get(player_id)
888 .copied()
889 .map(|previous_sample_boost_amount| {
890 let respawn_amount = respawn_amounts_by_player
891 .get(player_id)
892 .copied()
893 .unwrap_or(0.0);
894 (player.boost_amount.unwrap_or(previous_boost_amount)
895 - previous_sample_boost_amount
896 - respawn_amount)
897 .max(0.0)
898 })
899 .unwrap_or(0.0)
900 } else {
901 0.0
902 };
903 let pre_applied_pad_size = (pre_applied_collected_amount > 0.0)
904 .then(|| {
905 self.guess_pad_size_from_position(
906 &event.pad_id,
907 player.position().unwrap_or(glam::Vec3::ZERO),
908 )
909 })
910 .flatten();
911 self.apply_pickup_collected_amount(
912 player_id,
913 player.is_team_0,
914 pre_applied_collected_amount,
915 pre_applied_pad_size,
916 );
917 let pending_pickup = PendingBoostPickup {
918 player_id: player_id.clone(),
919 is_team_0: player.is_team_0,
920 previous_boost_amount,
921 pre_applied_collected_amount,
922 pre_applied_pad_size,
923 player_position: player.position().unwrap_or(glam::Vec3::ZERO),
924 };
925
926 let pad_size = self
927 .known_pad_sizes
928 .get(&event.pad_id)
929 .copied()
930 .or_else(|| {
931 let mut size = self.guess_pad_size_from_position(
932 &event.pad_id,
933 player.position().unwrap_or(glam::Vec3::ZERO),
934 )?;
935 if size == BoostPadSize::Small
939 && pre_applied_collected_amount > SMALL_PAD_AMOUNT_RAW * 1.5
940 {
941 size = BoostPadSize::Big;
942 }
943 self.known_pad_sizes.insert(event.pad_id.clone(), size);
944 Some(size)
945 });
946 if let Some(pad_size) = pad_size {
947 self.resolve_pickup(&event.pad_id, pending_pickup, pad_size);
948 }
949 }
950 BoostPadEventKind::Available => {
951 if let Some(pad_size) = self.known_pad_sizes.get(&event.pad_id).copied() {
952 let Some(last_pickup_time) = self.last_pickup_times.get(&event.pad_id)
953 else {
954 continue;
955 };
956 if event.time - *last_pickup_time < Self::pad_respawn_time_seconds(pad_size)
957 {
958 continue;
959 }
960 }
961 self.unavailable_pads.remove(&event.pad_id);
962 }
963 }
964 }
965
966 for (player_id, boost_amount) in current_boost_amounts {
967 self.previous_boost_amounts.insert(player_id, boost_amount);
968 }
969 for player in &sample.players {
970 if let Some(speed) = player.speed() {
971 self.previous_player_speeds
972 .insert(player.player_id.clone(), speed);
973 }
974 }
975 let mut team_zero_used = 0.0;
976 let mut team_one_used = 0.0;
977 for player in &sample.players {
978 let Some(boost_amount) = player.boost_amount else {
979 continue;
980 };
981 let stats = self
982 .player_stats
983 .entry(player.player_id.clone())
984 .or_default();
985 stats.amount_used = (stats.amount_obtained() - boost_amount).max(0.0);
986 if player.is_team_0 {
987 team_zero_used += stats.amount_used;
988 } else {
989 team_one_used += stats.amount_used;
990 }
991 }
992 self.team_zero_stats.amount_used = team_zero_used;
993 self.team_one_stats.amount_used = team_one_used;
994 self.warn_for_sample_boost_invariants(sample);
995 self.kickoff_phase_active_last_frame = kickoff_phase_active;
996 self.previous_boost_levels_live = Some(boost_levels_live);
997
998 Ok(())
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use boxcars::RemoteId;
1006
1007 fn player(player_id: u64, boost_amount: f32) -> PlayerSample {
1008 PlayerSample {
1009 player_id: RemoteId::Steam(player_id),
1010 is_team_0: true,
1011 rigid_body: None,
1012 boost_amount: Some(boost_amount),
1013 last_boost_amount: None,
1014 boost_active: false,
1015 dodge_active: false,
1016 powerslide_active: false,
1017 match_goals: None,
1018 match_assists: None,
1019 match_saves: None,
1020 match_shots: None,
1021 match_score: None,
1022 }
1023 }
1024
1025 fn sample(
1026 frame_number: usize,
1027 time: f32,
1028 boost_amount: f32,
1029 game_state: Option<i32>,
1030 ball_has_been_hit: Option<bool>,
1031 kickoff_countdown_time: Option<i32>,
1032 ) -> StatsSample {
1033 StatsSample {
1034 frame_number,
1035 time,
1036 dt: 1.0,
1037 seconds_remaining: None,
1038 game_state,
1039 ball_has_been_hit,
1040 kickoff_countdown_time,
1041 team_zero_score: Some(0),
1042 team_one_score: Some(0),
1043 possession_team_is_team_0: None,
1044 scored_on_team_is_team_0: None,
1045 current_in_game_team_player_counts: Some([1, 0]),
1046 ball: None,
1047 players: vec![player(1, boost_amount)],
1048 active_demos: Vec::new(),
1049 demo_events: Vec::new(),
1050 boost_pad_events: Vec::new(),
1051 touch_events: Vec::new(),
1052 dodge_refreshed_events: Vec::new(),
1053 player_stat_events: Vec::new(),
1054 goal_events: Vec::new(),
1055 }
1056 }
1057
1058 #[test]
1059 fn boost_levels_skip_pre_kickoff_dead_time() {
1060 let mut reducer = BoostReducer::new();
1061 let player_id = RemoteId::Steam(1);
1062 reducer.initial_respawn_awarded.insert(player_id.clone());
1063 reducer.kickoff_respawn_awarded.insert(player_id.clone());
1064
1065 reducer
1066 .on_sample(&sample(
1067 0,
1068 0.0,
1069 BOOST_MAX_AMOUNT,
1070 Some(GAME_STATE_KICKOFF_COUNTDOWN),
1071 Some(false),
1072 Some(1),
1073 ))
1074 .unwrap();
1075 reducer
1076 .on_sample(&sample(1, 1.0, 0.0, None, Some(true), None))
1077 .unwrap();
1078 reducer
1079 .on_sample(&sample(2, 2.0, 0.0, None, Some(true), None))
1080 .unwrap();
1081
1082 let stats = reducer.player_stats().get(&player_id).unwrap();
1083 assert_eq!(stats.tracked_time, 2.0);
1084 assert_eq!(stats.average_boost_amount(), 0.0);
1085 }
1086
1087 #[test]
1088 fn boost_levels_still_track_when_replay_starts_live() {
1089 let mut reducer = BoostReducer::new();
1090 let player_id = RemoteId::Steam(1);
1091 reducer.initial_respawn_awarded.insert(player_id.clone());
1092
1093 reducer
1094 .on_sample(&sample(0, 0.0, 42.0, None, Some(true), None))
1095 .unwrap();
1096 reducer
1097 .on_sample(&sample(1, 1.0, 42.0, None, Some(true), None))
1098 .unwrap();
1099
1100 let stats = reducer.player_stats().get(&player_id).unwrap();
1101 assert_eq!(stats.tracked_time, 2.0);
1102 assert_eq!(stats.average_boost_amount(), 42.0);
1103 }
1104}