Skip to main content

subtr_actor/stats/reducers/
mod.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::LazyLock;
3
4use boxcars;
5use boxcars::HeaderProp;
6use serde::Serialize;
7
8use super::boost_invariants::{boost_invariant_violations, BoostInvariantKind};
9use crate::*;
10
11#[derive(Debug, Clone)]
12pub struct BallSample {
13    pub rigid_body: boxcars::RigidBody,
14}
15
16impl BallSample {
17    pub fn position(&self) -> glam::Vec3 {
18        vec_to_glam(&self.rigid_body.location)
19    }
20
21    pub fn velocity(&self) -> glam::Vec3 {
22        self.rigid_body
23            .linear_velocity
24            .as_ref()
25            .map(vec_to_glam)
26            .unwrap_or(glam::Vec3::ZERO)
27    }
28}
29
30fn interval_fraction_in_scalar_range(start: f32, end: f32, min_value: f32, max_value: f32) -> f32 {
31    if (end - start).abs() <= f32::EPSILON {
32        return ((start >= min_value) && (start < max_value)) as i32 as f32;
33    }
34
35    let t_at_min = (min_value - start) / (end - start);
36    let t_at_max = (max_value - start) / (end - start);
37    let interval_start = t_at_min.min(t_at_max).max(0.0);
38    let interval_end = t_at_min.max(t_at_max).min(1.0);
39    (interval_end - interval_start).max(0.0)
40}
41
42fn interval_fraction_below_threshold(start: f32, end: f32, threshold: f32) -> f32 {
43    if (end - start).abs() <= f32::EPSILON {
44        return (start < threshold) as i32 as f32;
45    }
46
47    let threshold_time = ((threshold - start) / (end - start)).clamp(0.0, 1.0);
48    if start < threshold {
49        if end < threshold {
50            1.0
51        } else {
52            threshold_time
53        }
54    } else if end < threshold {
55        1.0 - threshold_time
56    } else {
57        0.0
58    }
59}
60
61fn interval_fraction_above_threshold(start: f32, end: f32, threshold: f32) -> f32 {
62    if (end - start).abs() <= f32::EPSILON {
63        return (start > threshold) as i32 as f32;
64    }
65
66    let threshold_time = ((threshold - start) / (end - start)).clamp(0.0, 1.0);
67    if start > threshold {
68        if end > threshold {
69            1.0
70        } else {
71            threshold_time
72        }
73    } else if end > threshold {
74        1.0 - threshold_time
75    } else {
76        0.0
77    }
78}
79
80#[derive(Debug, Clone)]
81pub struct PlayerSample {
82    pub player_id: PlayerId,
83    pub is_team_0: bool,
84    pub rigid_body: Option<boxcars::RigidBody>,
85    pub boost_amount: Option<f32>,
86    pub last_boost_amount: Option<f32>,
87    pub boost_active: bool,
88    pub dodge_active: bool,
89    pub powerslide_active: bool,
90    pub match_goals: Option<i32>,
91    pub match_assists: Option<i32>,
92    pub match_saves: Option<i32>,
93    pub match_shots: Option<i32>,
94    pub match_score: Option<i32>,
95}
96
97impl PlayerSample {
98    pub fn position(&self) -> Option<glam::Vec3> {
99        self.rigid_body.as_ref().map(|rb| vec_to_glam(&rb.location))
100    }
101
102    pub fn velocity(&self) -> Option<glam::Vec3> {
103        self.rigid_body
104            .as_ref()
105            .and_then(|rb| rb.linear_velocity.as_ref().map(vec_to_glam))
106    }
107
108    pub fn speed(&self) -> Option<f32> {
109        self.velocity().map(|velocity| velocity.length())
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct DemoEventSample {
115    pub attacker: PlayerId,
116    pub victim: PlayerId,
117}
118
119#[derive(Debug, Clone)]
120pub struct StatsSample {
121    pub frame_number: usize,
122    pub time: f32,
123    pub dt: f32,
124    pub seconds_remaining: Option<i32>,
125    pub game_state: Option<i32>,
126    pub ball_has_been_hit: Option<bool>,
127    pub kickoff_countdown_time: Option<i32>,
128    pub team_zero_score: Option<i32>,
129    pub team_one_score: Option<i32>,
130    pub possession_team_is_team_0: Option<bool>,
131    pub scored_on_team_is_team_0: Option<bool>,
132    pub current_in_game_team_player_counts: Option<[usize; 2]>,
133    pub ball: Option<BallSample>,
134    pub players: Vec<PlayerSample>,
135    pub active_demos: Vec<DemoEventSample>,
136    pub demo_events: Vec<DemolishInfo>,
137    pub boost_pad_events: Vec<BoostPadEvent>,
138    pub touch_events: Vec<TouchEvent>,
139    pub dodge_refreshed_events: Vec<DodgeRefreshedEvent>,
140    pub player_stat_events: Vec<PlayerStatEvent>,
141    pub goal_events: Vec<GoalEvent>,
142}
143
144const GAME_STATE_KICKOFF_COUNTDOWN: i32 = 55;
145const GAME_STATE_GOAL_SCORED_REPLAY: i32 = 86;
146
147#[derive(Debug, Clone, Default, PartialEq)]
148pub struct LivePlayTracker {
149    post_goal_phase_active: bool,
150    last_score: Option<(i32, i32)>,
151}
152
153impl LivePlayTracker {
154    fn current_score(sample: &StatsSample) -> Option<(i32, i32)> {
155        Some((sample.team_zero_score?, sample.team_one_score?))
156    }
157
158    fn kickoff_phase_active(sample: &StatsSample) -> bool {
159        sample.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
160            || sample.kickoff_countdown_time.is_some_and(|time| time > 0)
161    }
162
163    fn live_play_internal(&mut self, sample: &StatsSample) -> bool {
164        let kickoff_phase_active = Self::kickoff_phase_active(sample);
165        let score_changed = Self::current_score(sample)
166            .zip(self.last_score)
167            .is_some_and(
168                |((team_zero_score, team_one_score), (last_team_zero, last_team_one))| {
169                    team_zero_score > last_team_zero || team_one_score > last_team_one
170                },
171            );
172
173        if !sample.goal_events.is_empty() || score_changed {
174            self.post_goal_phase_active = true;
175        }
176
177        let live_play = sample.is_live_play() && !self.post_goal_phase_active;
178
179        if kickoff_phase_active {
180            self.post_goal_phase_active = false;
181        }
182
183        if let Some(score) = Self::current_score(sample) {
184            self.last_score = Some(score);
185        }
186
187        live_play
188    }
189
190    pub fn is_live_play(&mut self, sample: &StatsSample) -> bool {
191        self.live_play_internal(sample)
192    }
193}
194
195impl StatsSample {
196    pub(crate) fn from_processor(
197        processor: &ReplayProcessor,
198        frame_number: usize,
199        current_time: f32,
200        dt: f32,
201    ) -> SubtrActorResult<Self> {
202        let ball = processor
203            .get_interpolated_ball_rigid_body(current_time, 0.0)
204            .ok()
205            .filter(|rigid_body| !rigid_body.sleeping)
206            .map(|rigid_body| BallSample { rigid_body });
207
208        let mut players = Vec::new();
209        for player_id in processor.iter_player_ids_in_order() {
210            // Some replays include metadata/header players before their actor
211            // graph is fully available in-frame. Skip those players until their
212            // live actor/team links resolve instead of aborting the whole sample.
213            let Ok(is_team_0) = processor.get_player_is_team_0(player_id) else {
214                continue;
215            };
216            players.push(PlayerSample {
217                player_id: player_id.clone(),
218                is_team_0,
219                rigid_body: processor
220                    .get_interpolated_player_rigid_body(player_id, current_time, 0.0)
221                    .ok()
222                    .filter(|rigid_body| !rigid_body.sleeping),
223                boost_amount: processor.get_player_boost_level(player_id).ok(),
224                last_boost_amount: processor.get_player_last_boost_level(player_id).ok(),
225                boost_active: processor.get_boost_active(player_id).unwrap_or(0) % 2 == 1,
226                dodge_active: processor.get_dodge_active(player_id).unwrap_or(0) % 2 == 1,
227                powerslide_active: processor.get_powerslide_active(player_id).unwrap_or(false),
228                match_goals: processor.get_player_match_goals(player_id).ok(),
229                match_assists: processor.get_player_match_assists(player_id).ok(),
230                match_saves: processor.get_player_match_saves(player_id).ok(),
231                match_shots: processor.get_player_match_shots(player_id).ok(),
232                match_score: processor.get_player_match_score(player_id).ok(),
233            });
234        }
235
236        let team_scores = processor.get_team_scores().ok();
237        let possession_team_is_team_0 =
238            processor
239                .get_ball_hit_team_num()
240                .ok()
241                .and_then(|team_num| match team_num {
242                    0 => Some(true),
243                    1 => Some(false),
244                    _ => None,
245                });
246        let scored_on_team_is_team_0 =
247            processor
248                .get_scored_on_team_num()
249                .ok()
250                .and_then(|team_num| match team_num {
251                    0 => Some(true),
252                    1 => Some(false),
253                    _ => None,
254                });
255        let active_demos = if let Ok(demos) = processor.get_active_demos() {
256            demos
257                .filter_map(|demo| {
258                    let attacker = processor
259                        .get_player_id_from_car_id(&demo.attacker_actor_id())
260                        .ok()?;
261                    let victim = processor
262                        .get_player_id_from_car_id(&demo.victim_actor_id())
263                        .ok()?;
264                    Some(DemoEventSample { attacker, victim })
265                })
266                .collect()
267        } else {
268            Vec::new()
269        };
270
271        Ok(Self {
272            frame_number,
273            time: current_time,
274            dt,
275            seconds_remaining: processor.get_seconds_remaining().ok(),
276            game_state: processor.get_replicated_state_name().ok(),
277            ball_has_been_hit: processor.get_ball_has_been_hit().ok(),
278            kickoff_countdown_time: processor.get_replicated_game_state_time_remaining().ok(),
279            team_zero_score: team_scores.map(|scores| scores.0),
280            team_one_score: team_scores.map(|scores| scores.1),
281            possession_team_is_team_0,
282            scored_on_team_is_team_0,
283            current_in_game_team_player_counts: Some(
284                processor.current_in_game_team_player_counts(),
285            ),
286            ball,
287            players,
288            active_demos,
289            demo_events: Vec::new(),
290            boost_pad_events: processor.current_frame_boost_pad_events().to_vec(),
291            touch_events: processor.current_frame_touch_events().to_vec(),
292            dodge_refreshed_events: processor.current_frame_dodge_refreshed_events().to_vec(),
293            player_stat_events: processor.current_frame_player_stat_events().to_vec(),
294            goal_events: processor.current_frame_goal_events().to_vec(),
295        })
296    }
297
298    /// Returns whether time-based stats should treat this sample as live play.
299    ///
300    /// We exclude frozen kickoff countdown frames and post-goal replay frames,
301    /// but keep the movable pre-touch kickoff approach live.
302    ///
303    /// Use [`LivePlayTracker`] when you need to exclude the full post-goal
304    /// reset segment that can continue after the goal frame itself.
305    pub fn is_live_play(&self) -> bool {
306        if matches!(
307            self.game_state,
308            Some(GAME_STATE_KICKOFF_COUNTDOWN | GAME_STATE_GOAL_SCORED_REPLAY)
309        ) {
310            return false;
311        }
312
313        true
314    }
315
316    pub fn current_in_game_team_player_count(&self, is_team_0: bool) -> usize {
317        self.current_in_game_team_player_counts
318            .map(|counts| counts[usize::from(!is_team_0)])
319            .unwrap_or_else(|| {
320                self.players
321                    .iter()
322                    .filter(|player| player.is_team_0 == is_team_0)
323                    .count()
324            })
325    }
326}
327
328pub trait StatsReducer {
329    fn on_replay_meta(&mut self, _meta: &ReplayMeta) -> SubtrActorResult<()> {
330        Ok(())
331    }
332
333    fn on_sample(&mut self, _sample: &StatsSample) -> SubtrActorResult<()> {
334        Ok(())
335    }
336
337    fn on_sample_with_context(
338        &mut self,
339        sample: &StatsSample,
340        _ctx: &AnalysisContext,
341    ) -> SubtrActorResult<()> {
342        self.on_sample(sample)
343    }
344
345    fn finish(&mut self) -> SubtrActorResult<()> {
346        Ok(())
347    }
348}
349
350#[derive(Default)]
351pub struct CompositeStatsReducer {
352    children: Vec<Box<dyn StatsReducer>>,
353}
354
355impl CompositeStatsReducer {
356    pub fn new() -> Self {
357        Self::default()
358    }
359
360    pub fn push<R: StatsReducer + 'static>(&mut self, reducer: R) {
361        self.children.push(Box::new(reducer));
362    }
363
364    pub fn with_child<R: StatsReducer + 'static>(mut self, reducer: R) -> Self {
365        self.push(reducer);
366        self
367    }
368
369    pub fn children(&self) -> &[Box<dyn StatsReducer>] {
370        &self.children
371    }
372
373    pub fn children_mut(&mut self) -> &mut [Box<dyn StatsReducer>] {
374        &mut self.children
375    }
376}
377
378impl StatsReducer for CompositeStatsReducer {
379    fn on_replay_meta(&mut self, meta: &ReplayMeta) -> SubtrActorResult<()> {
380        for child in &mut self.children {
381            child.on_replay_meta(meta)?;
382        }
383        Ok(())
384    }
385
386    fn on_sample(&mut self, sample: &StatsSample) -> SubtrActorResult<()> {
387        for child in &mut self.children {
388            child.on_sample(sample)?;
389        }
390        Ok(())
391    }
392
393    fn on_sample_with_context(
394        &mut self,
395        sample: &StatsSample,
396        ctx: &AnalysisContext,
397    ) -> SubtrActorResult<()> {
398        for child in &mut self.children {
399            child.on_sample_with_context(sample, ctx)?;
400        }
401        Ok(())
402    }
403
404    fn finish(&mut self) -> SubtrActorResult<()> {
405        for child in &mut self.children {
406            child.finish()?;
407        }
408        Ok(())
409    }
410}
411
412pub struct ReducerCollector<R> {
413    reducer: R,
414    derived_signals: DerivedSignalGraph,
415    last_sample_time: Option<f32>,
416    replay_meta_initialized: bool,
417    last_demolish_count: usize,
418    last_boost_pad_event_count: usize,
419    last_touch_event_count: usize,
420    last_player_stat_event_count: usize,
421    last_goal_event_count: usize,
422}
423
424impl<R> ReducerCollector<R> {
425    pub fn new(reducer: R) -> Self {
426        Self {
427            reducer,
428            derived_signals: default_derived_signal_graph(),
429            last_sample_time: None,
430            replay_meta_initialized: false,
431            last_demolish_count: 0,
432            last_boost_pad_event_count: 0,
433            last_touch_event_count: 0,
434            last_player_stat_event_count: 0,
435            last_goal_event_count: 0,
436        }
437    }
438
439    pub fn into_inner(self) -> R {
440        self.reducer
441    }
442
443    pub fn reducer(&self) -> &R {
444        &self.reducer
445    }
446
447    pub fn reducer_mut(&mut self) -> &mut R {
448        &mut self.reducer
449    }
450}
451
452impl<R> From<R> for ReducerCollector<R> {
453    fn from(reducer: R) -> Self {
454        Self::new(reducer)
455    }
456}
457
458impl<R: StatsReducer> Collector for ReducerCollector<R> {
459    fn process_frame(
460        &mut self,
461        processor: &ReplayProcessor,
462        _frame: &boxcars::Frame,
463        frame_number: usize,
464        current_time: f32,
465    ) -> SubtrActorResult<TimeAdvance> {
466        if !self.replay_meta_initialized {
467            let replay_meta = processor.get_replay_meta()?;
468            self.derived_signals.on_replay_meta(&replay_meta)?;
469            self.reducer.on_replay_meta(&replay_meta)?;
470            self.replay_meta_initialized = true;
471        }
472
473        let dt = self
474            .last_sample_time
475            .map(|last_time| (current_time - last_time).max(0.0))
476            .unwrap_or(0.0);
477        let mut sample = StatsSample::from_processor(processor, frame_number, current_time, dt)?;
478        sample.active_demos.clear();
479        sample.demo_events = processor.demolishes[self.last_demolish_count..].to_vec();
480        sample.boost_pad_events =
481            processor.boost_pad_events[self.last_boost_pad_event_count..].to_vec();
482        sample.touch_events = processor.touch_events[self.last_touch_event_count..].to_vec();
483        sample.player_stat_events =
484            processor.player_stat_events[self.last_player_stat_event_count..].to_vec();
485        sample.goal_events = processor.goal_events[self.last_goal_event_count..].to_vec();
486        let analysis_context = self.derived_signals.evaluate(&sample)?;
487        self.reducer
488            .on_sample_with_context(&sample, analysis_context)?;
489        self.last_sample_time = Some(current_time);
490        self.last_demolish_count = processor.demolishes.len();
491        self.last_boost_pad_event_count = processor.boost_pad_events.len();
492        self.last_touch_event_count = processor.touch_events.len();
493        self.last_player_stat_event_count = processor.player_stat_events.len();
494        self.last_goal_event_count = processor.goal_events.len();
495
496        Ok(TimeAdvance::NextFrame)
497    }
498
499    fn finish_replay(&mut self, _processor: &ReplayProcessor) -> SubtrActorResult<()> {
500        self.derived_signals.finish()?;
501        self.reducer.finish()
502    }
503}
504
505const CAR_MAX_SPEED: f32 = 2300.0;
506const SUPERSONIC_SPEED_THRESHOLD: f32 = 2200.0;
507const BOOST_SPEED_THRESHOLD: f32 = 1410.0;
508const GROUND_Z_THRESHOLD: f32 = 20.0;
509const POWERSLIDE_MAX_Z_THRESHOLD: f32 = 40.0;
510const BALL_RADIUS_Z: f32 = 92.75;
511const BALL_CARRY_MIN_BALL_Z: f32 = BALL_RADIUS_Z + 5.0;
512const BALL_CARRY_MAX_BALL_Z: f32 = 600.0;
513const BALL_CARRY_MAX_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 1.4;
514const BALL_CARRY_MAX_VERTICAL_GAP: f32 = 220.0;
515const BALL_CARRY_MIN_DURATION: f32 = 1.0;
516// Ballchasing's high-air bucket lines up better with the car center clearing a
517// crossbar-height ball than with plain goal height.
518const HIGH_AIR_Z_THRESHOLD: f32 = 642.775 + BALL_RADIUS_Z;
519// Ballchasing's defensive / neutral / offensive zones track the standard
520// soccar lane markings more closely than a literal geometric third of the full
521// playable length.
522const FIELD_ZONE_BOUNDARY_Y: f32 = BOOST_PAD_SIDE_LANE_Y;
523/// Approximate length of two Octane hitboxes in Unreal units.
524const DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y: f32 = 236.0;
525const SMALL_PAD_AMOUNT_RAW: f32 = BOOST_MAX_AMOUNT * 12.0 / 100.0;
526const BOOST_ZERO_BAND_RAW: f32 = 1.0;
527const BOOST_FULL_BAND_MIN_RAW: f32 = BOOST_MAX_AMOUNT - 1.0;
528const STANDARD_PAD_MATCH_RADIUS_SMALL: f32 = 450.0;
529const STANDARD_PAD_MATCH_RADIUS_BIG: f32 = 1000.0;
530const BOOST_PAD_MIDFIELD_TOLERANCE_Y: f32 = 128.0;
531const BOOST_PAD_SMALL_Z: f32 = 70.0;
532const BOOST_PAD_BIG_Z: f32 = 73.0;
533const BOOST_PAD_BACK_CORNER_X: f32 = 3072.0;
534const BOOST_PAD_BACK_CORNER_Y: f32 = 4096.0;
535const BOOST_PAD_BACK_LANE_X: f32 = 1792.0;
536const BOOST_PAD_BACK_LANE_Y: f32 = 4184.0;
537const BOOST_PAD_BACK_MID_X: f32 = 940.0;
538const BOOST_PAD_BACK_MID_Y: f32 = 3308.0;
539const BOOST_PAD_CENTER_BACK_Y: f32 = 2816.0;
540const BOOST_PAD_SIDE_WALL_X: f32 = 3584.0;
541const BOOST_PAD_SIDE_WALL_Y: f32 = 2484.0;
542const BOOST_PAD_SIDE_LANE_X: f32 = 1788.0;
543const BOOST_PAD_SIDE_LANE_Y: f32 = 2300.0;
544const BOOST_PAD_FRONT_LANE_X: f32 = 2048.0;
545const BOOST_PAD_FRONT_LANE_Y: f32 = 1036.0;
546const BOOST_PAD_CENTER_X: f32 = 1024.0;
547const BOOST_PAD_CENTER_MID_Y: f32 = 1024.0;
548const BOOST_PAD_GOAL_LINE_Y: f32 = 4240.0;
549
550fn push_pad(
551    pads: &mut Vec<(glam::Vec3, BoostPadSize)>,
552    x: f32,
553    y: f32,
554    z: f32,
555    size: BoostPadSize,
556) {
557    pads.push((glam::Vec3::new(x, y, z), size));
558}
559
560fn push_mirror_x(
561    pads: &mut Vec<(glam::Vec3, BoostPadSize)>,
562    x: f32,
563    y: f32,
564    z: f32,
565    size: BoostPadSize,
566) {
567    push_pad(pads, -x, y, z, size);
568    push_pad(pads, x, y, z, size);
569}
570
571fn push_mirror_y(
572    pads: &mut Vec<(glam::Vec3, BoostPadSize)>,
573    x: f32,
574    y: f32,
575    z: f32,
576    size: BoostPadSize,
577) {
578    push_pad(pads, x, -y, z, size);
579    push_pad(pads, x, y, z, size);
580}
581
582fn push_mirror_xy(
583    pads: &mut Vec<(glam::Vec3, BoostPadSize)>,
584    x: f32,
585    y: f32,
586    z: f32,
587    size: BoostPadSize,
588) {
589    push_mirror_x(pads, x, -y, z, size);
590    push_mirror_x(pads, x, y, z, size);
591}
592
593fn build_standard_soccar_boost_pad_layout() -> Vec<(glam::Vec3, BoostPadSize)> {
594    let mut pads = Vec::with_capacity(34);
595
596    push_mirror_y(
597        &mut pads,
598        0.0,
599        BOOST_PAD_GOAL_LINE_Y,
600        BOOST_PAD_SMALL_Z,
601        BoostPadSize::Small,
602    );
603    push_mirror_xy(
604        &mut pads,
605        BOOST_PAD_BACK_LANE_X,
606        BOOST_PAD_BACK_LANE_Y,
607        BOOST_PAD_SMALL_Z,
608        BoostPadSize::Small,
609    );
610    push_mirror_xy(
611        &mut pads,
612        BOOST_PAD_BACK_CORNER_X,
613        BOOST_PAD_BACK_CORNER_Y,
614        BOOST_PAD_BIG_Z,
615        BoostPadSize::Big,
616    );
617    push_mirror_xy(
618        &mut pads,
619        BOOST_PAD_BACK_MID_X,
620        BOOST_PAD_BACK_MID_Y,
621        BOOST_PAD_SMALL_Z,
622        BoostPadSize::Small,
623    );
624    push_mirror_y(
625        &mut pads,
626        0.0,
627        BOOST_PAD_CENTER_BACK_Y,
628        BOOST_PAD_SMALL_Z,
629        BoostPadSize::Small,
630    );
631    push_mirror_xy(
632        &mut pads,
633        BOOST_PAD_SIDE_WALL_X,
634        BOOST_PAD_SIDE_WALL_Y,
635        BOOST_PAD_SMALL_Z,
636        BoostPadSize::Small,
637    );
638    push_mirror_xy(
639        &mut pads,
640        BOOST_PAD_SIDE_LANE_X,
641        BOOST_PAD_SIDE_LANE_Y,
642        BOOST_PAD_SMALL_Z,
643        BoostPadSize::Small,
644    );
645    push_mirror_xy(
646        &mut pads,
647        BOOST_PAD_FRONT_LANE_X,
648        BOOST_PAD_FRONT_LANE_Y,
649        BOOST_PAD_SMALL_Z,
650        BoostPadSize::Small,
651    );
652    push_mirror_y(
653        &mut pads,
654        0.0,
655        BOOST_PAD_CENTER_MID_Y,
656        BOOST_PAD_SMALL_Z,
657        BoostPadSize::Small,
658    );
659    push_mirror_x(
660        &mut pads,
661        BOOST_PAD_SIDE_WALL_X,
662        0.0,
663        BOOST_PAD_BIG_Z,
664        BoostPadSize::Big,
665    );
666    push_mirror_x(
667        &mut pads,
668        BOOST_PAD_CENTER_X,
669        0.0,
670        BOOST_PAD_SMALL_Z,
671        BoostPadSize::Small,
672    );
673
674    pads
675}
676
677static STANDARD_SOCCAR_BOOST_PAD_LAYOUT: LazyLock<Vec<(glam::Vec3, BoostPadSize)>> =
678    LazyLock::new(build_standard_soccar_boost_pad_layout);
679
680pub fn standard_soccar_boost_pad_layout() -> &'static [(glam::Vec3, BoostPadSize)] {
681    STANDARD_SOCCAR_BOOST_PAD_LAYOUT.as_slice()
682}
683
684fn normalized_y(is_team_0: bool, position: glam::Vec3) -> f32 {
685    if is_team_0 {
686        position.y
687    } else {
688        -position.y
689    }
690}
691
692fn is_enemy_side(is_team_0: bool, position: glam::Vec3) -> bool {
693    normalized_y(is_team_0, position) > BOOST_PAD_MIDFIELD_TOLERANCE_Y
694}
695
696fn standard_soccar_boost_pad_position(index: usize) -> glam::Vec3 {
697    STANDARD_SOCCAR_BOOST_PAD_LAYOUT[index].0
698}
699
700#[derive(Debug, Clone, Default)]
701struct PadPositionEstimate {
702    observations: Vec<glam::Vec3>,
703}
704
705impl PadPositionEstimate {
706    fn observe(&mut self, position: glam::Vec3) {
707        self.observations.push(position);
708    }
709
710    fn observations(&self) -> &[glam::Vec3] {
711        self.observations.as_slice()
712    }
713
714    fn mean(&self) -> Option<glam::Vec3> {
715        if self.observations.is_empty() {
716            return None;
717        }
718
719        let sum = self
720            .observations
721            .iter()
722            .copied()
723            .fold(glam::Vec3::ZERO, |acc, position| acc + position);
724        Some(sum / self.observations.len() as f32)
725    }
726}
727
728fn header_prop_to_f32(prop: &HeaderProp) -> Option<f32> {
729    match prop {
730        HeaderProp::Float(value) => Some(*value),
731        HeaderProp::Int(value) => Some(*value as f32),
732        HeaderProp::QWord(value) => Some(*value as f32),
733        _ => None,
734    }
735}
736
737fn get_header_f32(stats: &HashMap<String, HeaderProp>, keys: &[&str]) -> Option<f32> {
738    keys.iter()
739        .find_map(|key| stats.get(*key).and_then(header_prop_to_f32))
740}
741
742pub mod powerslide;
743#[allow(unused_imports)]
744pub use powerslide::*;
745pub mod analysis;
746pub use analysis::*;
747pub mod pressure;
748#[allow(unused_imports)]
749pub use pressure::*;
750pub mod rush;
751#[allow(unused_imports)]
752pub use rush::*;
753pub mod possession;
754#[allow(unused_imports)]
755pub use possession::*;
756pub mod settings;
757pub use settings::*;
758pub mod match_stats;
759pub use match_stats::*;
760pub mod demo;
761pub use demo::*;
762pub mod dodge_reset;
763pub use dodge_reset::*;
764pub mod musty_flick;
765pub use musty_flick::*;
766pub mod touch;
767pub use touch::*;
768pub mod fifty_fifty;
769pub use fifty_fifty::*;
770pub mod speed_flip;
771pub use speed_flip::*;
772pub mod movement;
773pub use movement::*;
774pub mod positioning;
775pub use positioning::*;
776pub mod ball_carry;
777pub use ball_carry::*;
778pub mod boost;
779pub use boost::*;