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