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