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