Skip to main content

subtr_actor/stats/calculators/
wall_aerial.rs

1use super::*;
2
3const WALL_AERIAL_MIN_CONTROL_DURATION: f32 = 0.30;
4const WALL_AERIAL_MAX_CONTROL_BALL_DISTANCE: f32 = 380.0;
5const WALL_AERIAL_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS: f32 = 1.25;
6const WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS: f32 = 2.25;
7const WALL_AERIAL_MIN_SECONDS_BETWEEN_ATTEMPTS: f32 = 3.0;
8pub(crate) const WALL_AERIAL_MIN_TOUCH_PLAYER_Z: f32 = AIR_DRIBBLE_MIN_PLAYER_Z;
9const WALL_AERIAL_SETUP_SIDE_WALL_START_ABS_X: f32 = 3200.0;
10const WALL_AERIAL_SETUP_BACK_WALL_START_ABS_Y: f32 = 4600.0;
11const WALL_AERIAL_MIN_CONTINUATION_PLAYER_Z: f32 = 300.0;
12pub(crate) const WALL_AERIAL_MIN_TOUCH_BALL_Z: f32 = 400.0;
13const WALL_AERIAL_REFERENCE_BALL_SPEED_CHANGE: f32 = 80.0;
14pub(crate) const WALL_AERIAL_HIGH_CONFIDENCE: f32 = 0.78;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
17#[ts(export)]
18#[serde(rename_all = "snake_case")]
19pub enum WallAerialWall {
20    Side,
21    Back,
22}
23
24impl WallAerialWall {
25    pub fn as_label_value(self) -> &'static str {
26        match self {
27            Self::Side => "side",
28            Self::Back => "back",
29        }
30    }
31}
32
33pub(crate) fn wall_aerial_wall_for_position(position: glam::Vec3) -> Option<WallAerialWall> {
34    if position.z < WALL_CONTACT_MIN_PLAYER_Z {
35        return None;
36    }
37    if position.y.abs() >= BACK_WALL_CONTACT_ABS_Y
38        && position.x.abs() > BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X
39    {
40        return Some(WallAerialWall::Back);
41    }
42    if position.x.abs() >= SIDE_WALL_CONTACT_ABS_X {
43        return Some(WallAerialWall::Side);
44    }
45    None
46}
47
48fn wall_aerial_setup_wall_for_position(position: glam::Vec3) -> Option<WallAerialWall> {
49    if position.z < WALL_CONTACT_MIN_PLAYER_Z {
50        return None;
51    }
52    if position.y.abs() >= WALL_AERIAL_SETUP_BACK_WALL_START_ABS_Y
53        && position.x.abs() > BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X
54    {
55        return Some(WallAerialWall::Back);
56    }
57    if position.x.abs() >= WALL_AERIAL_SETUP_SIDE_WALL_START_ABS_X {
58        return Some(WallAerialWall::Side);
59    }
60    None
61}
62
63pub(crate) fn wall_aerial_normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
64    if max_value <= min_value {
65        return 0.0;
66    }
67    ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
68}
69
70pub(crate) fn wall_aerial_goal_alignment(
71    is_team_0: bool,
72    ball_position: glam::Vec3,
73    ball_velocity: glam::Vec3,
74) -> f32 {
75    const GOAL_CENTER_Y: f32 = 5120.0;
76
77    let target_y = if is_team_0 {
78        GOAL_CENTER_Y
79    } else {
80        -GOAL_CENTER_Y
81    };
82    let goal_direction =
83        (glam::Vec3::new(0.0, target_y, ball_position.z) - ball_position).normalize_or_zero();
84    goal_direction.dot(ball_velocity.normalize_or_zero())
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
88#[ts(export)]
89pub struct WallAerialEvent {
90    pub time: f32,
91    pub frame: usize,
92    pub sample_time: f32,
93    pub sample_frame: usize,
94    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
95    pub player: PlayerId,
96    pub is_team_0: bool,
97    pub wall: WallAerialWall,
98    pub wall_contact_time: f32,
99    pub wall_contact_frame: usize,
100    pub takeoff_time: f32,
101    pub takeoff_frame: usize,
102    pub time_since_takeoff: f32,
103    pub wall_contact_position: [f32; 3],
104    pub takeoff_position: [f32; 3],
105    pub player_position: [f32; 3],
106    pub ball_position: [f32; 3],
107    pub setup_start_time: f32,
108    pub setup_start_frame: usize,
109    pub setup_duration: f32,
110    pub ball_speed: f32,
111    pub ball_speed_change: f32,
112    pub goal_alignment: f32,
113    pub confidence: f32,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
117#[ts(export)]
118pub struct WallAerialStats {
119    pub count: u32,
120    pub high_confidence_count: u32,
121    pub is_last_wall_aerial: bool,
122    pub last_wall_aerial_time: Option<f32>,
123    pub last_wall_aerial_frame: Option<usize>,
124    pub time_since_last_wall_aerial: Option<f32>,
125    pub frames_since_last_wall_aerial: Option<usize>,
126    pub last_confidence: Option<f32>,
127    pub best_confidence: f32,
128    pub cumulative_confidence: f32,
129    pub cumulative_setup_duration: f32,
130    pub cumulative_takeoff_to_touch_time: f32,
131    pub cumulative_touch_height: f32,
132}
133
134impl WallAerialStats {
135    fn average(&self, value: f32) -> f32 {
136        if self.count == 0 {
137            0.0
138        } else {
139            value / self.count as f32
140        }
141    }
142
143    pub fn average_confidence(&self) -> f32 {
144        self.average(self.cumulative_confidence)
145    }
146
147    pub fn average_setup_duration(&self) -> f32 {
148        self.average(self.cumulative_setup_duration)
149    }
150
151    pub fn average_takeoff_to_touch_time(&self) -> f32 {
152        self.average(self.cumulative_takeoff_to_touch_time)
153    }
154
155    pub fn average_touch_height(&self) -> f32 {
156        self.average(self.cumulative_touch_height)
157    }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq)]
161struct WallControl {
162    player_position: glam::Vec3,
163    ball_position: glam::Vec3,
164    wall: WallAerialWall,
165}
166
167#[derive(Debug, Clone, PartialEq)]
168struct ActiveWallControl {
169    player: PlayerId,
170    is_team_0: bool,
171    wall: WallAerialWall,
172    start_time: f32,
173    start_frame: usize,
174    last_time: f32,
175    last_frame: usize,
176    start_position: glam::Vec3,
177    last_position: glam::Vec3,
178    last_ball_position: glam::Vec3,
179}
180
181#[derive(Debug, Clone, PartialEq)]
182struct RecentWallContact {
183    player: PlayerId,
184    is_team_0: bool,
185    wall: WallAerialWall,
186    time: f32,
187    frame: usize,
188    position: glam::Vec3,
189    controlled_setup: Option<CompletedWallSetup>,
190}
191
192#[derive(Debug, Clone, PartialEq)]
193struct CompletedWallSetup {
194    start_time: f32,
195    start_frame: usize,
196    duration: f32,
197}
198
199#[derive(Debug, Clone, PartialEq)]
200struct ArmedWallAerial {
201    player: PlayerId,
202    is_team_0: bool,
203    wall: WallAerialWall,
204    wall_contact_time: f32,
205    wall_contact_frame: usize,
206    wall_contact_position: glam::Vec3,
207    takeoff_time: f32,
208    takeoff_frame: usize,
209    takeoff_position: glam::Vec3,
210    controlled_setup: CompletedWallSetup,
211    recorded: bool,
212}
213
214#[derive(Debug, Clone, Default)]
215pub struct WallAerialCalculator {
216    player_stats: HashMap<PlayerId, WallAerialStats>,
217    events: Vec<WallAerialEvent>,
218    active_wall_controls: HashMap<PlayerId, ActiveWallControl>,
219    recent_wall_contacts: HashMap<PlayerId, RecentWallContact>,
220    armed_aerials: HashMap<PlayerId, ArmedWallAerial>,
221    recent_event_times: HashMap<PlayerId, f32>,
222    previous_ball_velocity: Option<glam::Vec3>,
223    current_last_wall_aerial_player: Option<PlayerId>,
224}
225
226impl WallAerialCalculator {
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    pub fn player_stats(&self) -> &HashMap<PlayerId, WallAerialStats> {
232        &self.player_stats
233    }
234
235    pub fn events(&self) -> &[WallAerialEvent] {
236        &self.events
237    }
238
239    fn begin_sample(&mut self, frame: &FrameInfo) {
240        for stats in self.player_stats.values_mut() {
241            stats.is_last_wall_aerial = false;
242            stats.time_since_last_wall_aerial = stats
243                .last_wall_aerial_time
244                .map(|time| (frame.time - time).max(0.0));
245            stats.frames_since_last_wall_aerial = stats
246                .last_wall_aerial_frame
247                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
248        }
249    }
250
251    fn control_observation(
252        ball: &BallFrameState,
253        players: &PlayerFrameState,
254        touch_state: &TouchState,
255    ) -> Option<(PlayerId, bool, WallControl)> {
256        let player_id = touch_state.last_touch_player.as_ref()?;
257        let ball_position = ball.position()?;
258        let player = players
259            .players
260            .iter()
261            .find(|player| &player.player_id == player_id)?;
262        let player_position = player.position()?;
263        let wall = wall_aerial_setup_wall_for_position(player_position)?;
264        if player_position.distance(ball_position) > WALL_AERIAL_MAX_CONTROL_BALL_DISTANCE {
265            return None;
266        }
267
268        Some((
269            player_id.clone(),
270            player.is_team_0,
271            WallControl {
272                player_position,
273                ball_position,
274                wall,
275            },
276        ))
277    }
278
279    fn update_active_wall_control(
280        &mut self,
281        frame: &FrameInfo,
282        control: Option<(PlayerId, bool, WallControl)>,
283    ) {
284        let Some((player_id, is_team_0, control)) = control else {
285            self.active_wall_controls.clear();
286            return;
287        };
288
289        self.active_wall_controls
290            .retain(|active_player, _| active_player == &player_id);
291
292        let same_sequence = self
293            .active_wall_controls
294            .get(&player_id)
295            .is_some_and(|active| active.wall == control.wall);
296        if same_sequence {
297            if let Some(active) = self.active_wall_controls.get_mut(&player_id) {
298                active.last_time = frame.time;
299                active.last_frame = frame.frame_number;
300                active.last_position = control.player_position;
301                active.last_ball_position = control.ball_position;
302            }
303        } else {
304            self.active_wall_controls.insert(
305                player_id.clone(),
306                ActiveWallControl {
307                    player: player_id,
308                    is_team_0,
309                    wall: control.wall,
310                    start_time: frame.time,
311                    start_frame: frame.frame_number,
312                    last_time: frame.time,
313                    last_frame: frame.frame_number,
314                    start_position: control.player_position,
315                    last_position: control.player_position,
316                    last_ball_position: control.ball_position,
317                },
318            );
319        }
320    }
321
322    fn completed_setup(active: &ActiveWallControl) -> Option<CompletedWallSetup> {
323        let duration = active.last_time - active.start_time;
324        (duration >= WALL_AERIAL_MIN_CONTROL_DURATION).then_some(CompletedWallSetup {
325            start_time: active.start_time,
326            start_frame: active.start_frame,
327            duration,
328        })
329    }
330
331    fn update_wall_contacts_and_takeoffs(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
332        for player in &players.players {
333            let Some(position) = player.position() else {
334                continue;
335            };
336            let setup_wall = wall_aerial_setup_wall_for_position(position);
337            if let Some(wall) = setup_wall {
338                let controlled_setup = self
339                    .active_wall_controls
340                    .get(&player.player_id)
341                    .and_then(Self::completed_setup)
342                    .or_else(|| {
343                        self.recent_wall_contacts
344                            .get(&player.player_id)
345                            .and_then(|contact| contact.controlled_setup.clone())
346                    });
347                self.recent_wall_contacts.insert(
348                    player.player_id.clone(),
349                    RecentWallContact {
350                        player: player.player_id.clone(),
351                        is_team_0: player.is_team_0,
352                        wall,
353                        time: frame.time,
354                        frame: frame.frame_number,
355                        position,
356                        controlled_setup,
357                    },
358                );
359                if player_is_on_wall(position) {
360                    continue;
361                }
362            }
363
364            if position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z {
365                self.armed_aerials.remove(&player.player_id);
366                continue;
367            }
368
369            let Some(contact) = self.recent_wall_contacts.remove(&player.player_id) else {
370                continue;
371            };
372            if frame.time - contact.time > WALL_AERIAL_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS {
373                continue;
374            }
375            let Some(controlled_setup) = contact.controlled_setup.clone() else {
376                continue;
377            };
378            if self.armed_aerials.contains_key(&player.player_id) {
379                continue;
380            }
381            if self
382                .recent_event_times
383                .get(&player.player_id)
384                .is_some_and(|time| frame.time - time < WALL_AERIAL_MIN_SECONDS_BETWEEN_ATTEMPTS)
385            {
386                continue;
387            }
388            self.armed_aerials.insert(
389                player.player_id.clone(),
390                ArmedWallAerial {
391                    player: contact.player,
392                    is_team_0: contact.is_team_0,
393                    wall: contact.wall,
394                    wall_contact_time: contact.time,
395                    wall_contact_frame: contact.frame,
396                    wall_contact_position: contact.position,
397                    takeoff_time: frame.time,
398                    takeoff_frame: frame.frame_number,
399                    takeoff_position: position,
400                    controlled_setup,
401                    recorded: false,
402                },
403            );
404        }
405    }
406
407    fn prune_armed_aerials(&mut self, current_time: f32) {
408        self.armed_aerials.retain(|_, armed| {
409            current_time - armed.takeoff_time <= WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS
410        });
411    }
412
413    fn ball_speed_change(
414        frame: &FrameInfo,
415        ball: &BallFrameState,
416        previous_ball_velocity: Option<glam::Vec3>,
417    ) -> f32 {
418        const BALL_GRAVITY_Z: f32 = -650.0;
419
420        let Some(ball) = ball.sample() else {
421            return 0.0;
422        };
423        let Some(previous_ball_velocity) = previous_ball_velocity else {
424            return 0.0;
425        };
426
427        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
428        let residual_linear_impulse =
429            ball.velocity() - previous_ball_velocity - expected_linear_delta;
430        residual_linear_impulse.length()
431    }
432
433    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
434        players
435            .players
436            .iter()
437            .find(|player| &player.player_id == player_id)
438            .and_then(PlayerSample::position)
439    }
440
441    fn controlled_play_event(
442        &self,
443        ball: &BallFrameState,
444        players: &PlayerFrameState,
445        touch: &TouchEvent,
446        ball_speed_change: f32,
447    ) -> Option<WallAerialEvent> {
448        let player_id = touch.player.as_ref()?;
449        let armed = self.armed_aerials.get(player_id)?;
450        if armed.recorded {
451            return None;
452        }
453        let player_position = Self::player_position(players, player_id)?;
454        if player_is_on_wall(player_position) || player_position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z
455        {
456            return None;
457        }
458        let ball = ball.sample()?;
459        let ball_position = ball.position();
460        if ball_position.z < WALL_AERIAL_MIN_TOUCH_BALL_Z {
461            return None;
462        }
463        if player_position.z < WALL_AERIAL_MIN_CONTINUATION_PLAYER_Z {
464            return None;
465        }
466        let time_since_takeoff = touch.time - armed.takeoff_time;
467        if !(0.0..=WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS).contains(&time_since_takeoff) {
468            return None;
469        }
470        let setup = &armed.controlled_setup;
471        let confidence = 0.30
472            + 0.20
473                * wall_aerial_normalize_score(
474                    setup.duration,
475                    WALL_AERIAL_MIN_CONTROL_DURATION,
476                    1.2,
477                )
478            + 0.18
479                * (1.0
480                    - wall_aerial_normalize_score(
481                        time_since_takeoff,
482                        0.15,
483                        WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS,
484                    ))
485            + 0.16
486                * wall_aerial_normalize_score(
487                    player_position.z,
488                    WALL_AERIAL_MIN_TOUCH_PLAYER_Z,
489                    850.0,
490                )
491            + 0.16
492                * wall_aerial_normalize_score(
493                    ball_speed_change,
494                    WALL_AERIAL_REFERENCE_BALL_SPEED_CHANGE,
495                    900.0,
496                );
497
498        Some(WallAerialEvent {
499            time: touch.time,
500            frame: touch.frame,
501            sample_time: touch.time,
502            sample_frame: touch.frame,
503            player: player_id.clone(),
504            is_team_0: touch.team_is_team_0,
505            wall: armed.wall,
506            wall_contact_time: armed.wall_contact_time,
507            wall_contact_frame: armed.wall_contact_frame,
508            takeoff_time: armed.takeoff_time,
509            takeoff_frame: armed.takeoff_frame,
510            time_since_takeoff,
511            wall_contact_position: armed.wall_contact_position.to_array(),
512            takeoff_position: armed.takeoff_position.to_array(),
513            player_position: player_position.to_array(),
514            ball_position: ball_position.to_array(),
515            setup_start_time: setup.start_time,
516            setup_start_frame: setup.start_frame,
517            setup_duration: setup.duration,
518            ball_speed: ball.velocity().length(),
519            ball_speed_change,
520            goal_alignment: wall_aerial_goal_alignment(
521                touch.team_is_team_0,
522                ball_position,
523                ball.velocity(),
524            ),
525            confidence: confidence.clamp(0.0, 1.0),
526        })
527    }
528
529    fn record_event(&mut self, frame: &FrameInfo, mut event: WallAerialEvent) {
530        event.sample_time = frame.time;
531        event.sample_frame = frame.frame_number;
532        let stats = self.player_stats.entry(event.player.clone()).or_default();
533        stats.count += 1;
534        if event.confidence >= WALL_AERIAL_HIGH_CONFIDENCE {
535            stats.high_confidence_count += 1;
536        }
537        stats.is_last_wall_aerial = true;
538        stats.last_wall_aerial_time = Some(event.time);
539        stats.last_wall_aerial_frame = Some(event.frame);
540        stats.time_since_last_wall_aerial = Some((frame.time - event.time).max(0.0));
541        stats.frames_since_last_wall_aerial = Some(frame.frame_number.saturating_sub(event.frame));
542        stats.last_confidence = Some(event.confidence);
543        stats.best_confidence = stats.best_confidence.max(event.confidence);
544        stats.cumulative_confidence += event.confidence;
545        stats.cumulative_setup_duration += event.setup_duration;
546        stats.cumulative_takeoff_to_touch_time += event.time_since_takeoff;
547        stats.cumulative_touch_height += event.player_position[2];
548
549        self.current_last_wall_aerial_player = Some(event.player.clone());
550        self.recent_event_times
551            .insert(event.player.clone(), event.time);
552        self.events.push(event);
553    }
554
555    pub fn update(
556        &mut self,
557        frame: &FrameInfo,
558        ball: &BallFrameState,
559        players: &PlayerFrameState,
560        touch_state: &TouchState,
561        live_play: bool,
562    ) -> SubtrActorResult<()> {
563        self.begin_sample(frame);
564        if !live_play {
565            self.active_wall_controls.clear();
566            self.recent_wall_contacts.clear();
567            self.armed_aerials.clear();
568            self.recent_event_times.clear();
569            self.previous_ball_velocity = ball.velocity();
570            self.current_last_wall_aerial_player = None;
571            return Ok(());
572        }
573
574        self.update_active_wall_control(
575            frame,
576            Self::control_observation(ball, players, touch_state),
577        );
578        self.update_wall_contacts_and_takeoffs(frame, players);
579        self.prune_armed_aerials(frame.time);
580
581        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
582        for touch in &touch_state.touch_events {
583            if let Some(event) = self.controlled_play_event(ball, players, touch, ball_speed_change)
584            {
585                if let Some(armed) = self.armed_aerials.get_mut(&event.player) {
586                    armed.recorded = true;
587                }
588                self.record_event(frame, event);
589            }
590        }
591
592        self.previous_ball_velocity = ball.velocity();
593        if let Some(player_id) = self.current_last_wall_aerial_player.as_ref() {
594            if let Some(stats) = self.player_stats.get_mut(player_id) {
595                stats.is_last_wall_aerial = true;
596            }
597        }
598
599        Ok(())
600    }
601}
602
603#[cfg(test)]
604#[path = "wall_aerial_tests.rs"]
605mod tests;