Skip to main content

subtr_actor/stats/calculators/
wall_aerial_shot.rs

1use super::wall_aerial::{
2    wall_aerial_normalize_score, wall_aerial_wall_for_position, WALL_AERIAL_HIGH_CONFIDENCE,
3    WALL_AERIAL_MAX_TAKEOFF_TO_SHOT_SECONDS, WALL_AERIAL_MIN_TOUCH_BALL_Z,
4    WALL_AERIAL_MIN_TOUCH_PLAYER_Z,
5};
6use super::*;
7
8#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
9#[ts(export)]
10pub struct WallAerialShotEvent {
11    pub time: f32,
12    pub frame: usize,
13    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
14    pub player: PlayerId,
15    pub is_team_0: bool,
16    pub wall: WallAerialWall,
17    pub wall_contact_time: f32,
18    pub wall_contact_frame: usize,
19    pub takeoff_time: f32,
20    pub takeoff_frame: usize,
21    pub time_since_takeoff: f32,
22    pub wall_contact_position: [f32; 3],
23    pub takeoff_position: [f32; 3],
24    pub player_position: [f32; 3],
25    pub ball_position: [f32; 3],
26    pub ball_speed: Option<f32>,
27    pub goal_alignment: Option<f32>,
28    pub confidence: f32,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
32#[ts(export)]
33pub struct WallAerialShotStats {
34    pub count: u32,
35    pub high_confidence_count: u32,
36    pub is_last_wall_aerial_shot: bool,
37    pub last_wall_aerial_shot_time: Option<f32>,
38    pub last_wall_aerial_shot_frame: Option<usize>,
39    pub time_since_last_wall_aerial_shot: Option<f32>,
40    pub frames_since_last_wall_aerial_shot: Option<usize>,
41    pub last_confidence: Option<f32>,
42    pub best_confidence: f32,
43    pub cumulative_confidence: f32,
44    pub cumulative_takeoff_to_shot_time: f32,
45    pub cumulative_shot_height: f32,
46}
47
48impl WallAerialShotStats {
49    fn average(&self, value: f32) -> f32 {
50        if self.count == 0 {
51            0.0
52        } else {
53            value / self.count as f32
54        }
55    }
56
57    pub fn average_confidence(&self) -> f32 {
58        self.average(self.cumulative_confidence)
59    }
60
61    pub fn average_takeoff_to_shot_time(&self) -> f32 {
62        self.average(self.cumulative_takeoff_to_shot_time)
63    }
64
65    pub fn average_shot_height(&self) -> f32 {
66        self.average(self.cumulative_shot_height)
67    }
68}
69
70#[derive(Debug, Clone, PartialEq)]
71struct RecentWallContact {
72    player: PlayerId,
73    is_team_0: bool,
74    wall: WallAerialWall,
75    time: f32,
76    frame: usize,
77    position: glam::Vec3,
78}
79
80#[derive(Debug, Clone, PartialEq)]
81struct ArmedWallAerialShot {
82    player: PlayerId,
83    is_team_0: bool,
84    wall: WallAerialWall,
85    wall_contact_time: f32,
86    wall_contact_frame: usize,
87    wall_contact_position: glam::Vec3,
88    takeoff_time: f32,
89    takeoff_frame: usize,
90    takeoff_position: glam::Vec3,
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct WallAerialShotCalculator {
95    player_stats: HashMap<PlayerId, WallAerialShotStats>,
96    events: Vec<WallAerialShotEvent>,
97    recent_wall_contacts: HashMap<PlayerId, RecentWallContact>,
98    armed_shots: HashMap<PlayerId, ArmedWallAerialShot>,
99    current_last_wall_aerial_shot_player: Option<PlayerId>,
100}
101
102impl WallAerialShotCalculator {
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    pub fn player_stats(&self) -> &HashMap<PlayerId, WallAerialShotStats> {
108        &self.player_stats
109    }
110
111    pub fn events(&self) -> &[WallAerialShotEvent] {
112        &self.events
113    }
114
115    fn begin_sample(&mut self, frame: &FrameInfo) {
116        for stats in self.player_stats.values_mut() {
117            stats.is_last_wall_aerial_shot = false;
118            stats.time_since_last_wall_aerial_shot = stats
119                .last_wall_aerial_shot_time
120                .map(|time| (frame.time - time).max(0.0));
121            stats.frames_since_last_wall_aerial_shot = stats
122                .last_wall_aerial_shot_frame
123                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
124        }
125    }
126
127    fn update_wall_contacts_and_takeoffs(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
128        for player in &players.players {
129            let Some(position) = player.position() else {
130                continue;
131            };
132            if let Some(wall) = wall_aerial_wall_for_position(position) {
133                self.recent_wall_contacts.insert(
134                    player.player_id.clone(),
135                    RecentWallContact {
136                        player: player.player_id.clone(),
137                        is_team_0: player.is_team_0,
138                        wall,
139                        time: frame.time,
140                        frame: frame.frame_number,
141                        position,
142                    },
143                );
144                continue;
145            }
146
147            if position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z {
148                self.armed_shots.remove(&player.player_id);
149                continue;
150            }
151
152            let Some(contact) = self.recent_wall_contacts.get(&player.player_id).cloned() else {
153                continue;
154            };
155            if self.armed_shots.contains_key(&player.player_id) {
156                continue;
157            }
158            self.armed_shots.insert(
159                player.player_id.clone(),
160                ArmedWallAerialShot {
161                    player: contact.player,
162                    is_team_0: contact.is_team_0,
163                    wall: contact.wall,
164                    wall_contact_time: contact.time,
165                    wall_contact_frame: contact.frame,
166                    wall_contact_position: contact.position,
167                    takeoff_time: frame.time,
168                    takeoff_frame: frame.frame_number,
169                    takeoff_position: position,
170                },
171            );
172        }
173    }
174
175    fn prune_armed_shots(&mut self, current_time: f32) {
176        self.armed_shots.retain(|_, armed| {
177            current_time - armed.takeoff_time <= WALL_AERIAL_MAX_TAKEOFF_TO_SHOT_SECONDS
178        });
179    }
180
181    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
182        players
183            .players
184            .iter()
185            .find(|player| &player.player_id == player_id)
186            .and_then(PlayerSample::position)
187    }
188
189    fn shot_event(
190        &self,
191        players: &PlayerFrameState,
192        event: &PlayerStatEvent,
193    ) -> Option<WallAerialShotEvent> {
194        if event.kind != PlayerStatEventKind::Shot {
195            return None;
196        }
197        let armed = self.armed_shots.get(&event.player)?;
198        let time_since_takeoff = event.time - armed.takeoff_time;
199        if !(0.0..=WALL_AERIAL_MAX_TAKEOFF_TO_SHOT_SECONDS).contains(&time_since_takeoff) {
200            return None;
201        }
202
203        let player_position = event
204            .shot
205            .as_ref()
206            .and_then(|shot| shot.player_position.as_ref().map(vec_to_glam))
207            .or_else(|| Self::player_position(players, &event.player))?;
208        if player_is_on_wall(player_position) || player_position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z
209        {
210            return None;
211        }
212
213        let shot = event.shot.as_ref()?;
214        let ball_position = vec_to_glam(&shot.ball_position);
215        if ball_position.z < WALL_AERIAL_MIN_TOUCH_BALL_Z {
216            return None;
217        }
218
219        let ball_speed = shot.ball_speed;
220        let goal_alignment = shot.ball_goal_alignment;
221        let confidence = 0.42
222            + 0.20
223                * (1.0
224                    - wall_aerial_normalize_score(
225                        time_since_takeoff,
226                        0.15,
227                        WALL_AERIAL_MAX_TAKEOFF_TO_SHOT_SECONDS,
228                    ))
229            + 0.16
230                * wall_aerial_normalize_score(
231                    player_position.z,
232                    WALL_AERIAL_MIN_TOUCH_PLAYER_Z,
233                    850.0,
234                )
235            + 0.12 * goal_alignment.unwrap_or(0.0).clamp(0.0, 1.0)
236            + 0.10 * wall_aerial_normalize_score(ball_speed.unwrap_or(0.0), 600.0, 1800.0);
237
238        Some(WallAerialShotEvent {
239            time: event.time,
240            frame: event.frame,
241            player: event.player.clone(),
242            is_team_0: event.is_team_0,
243            wall: armed.wall,
244            wall_contact_time: armed.wall_contact_time,
245            wall_contact_frame: armed.wall_contact_frame,
246            takeoff_time: armed.takeoff_time,
247            takeoff_frame: armed.takeoff_frame,
248            time_since_takeoff,
249            wall_contact_position: armed.wall_contact_position.to_array(),
250            takeoff_position: armed.takeoff_position.to_array(),
251            player_position: player_position.to_array(),
252            ball_position: ball_position.to_array(),
253            ball_speed,
254            goal_alignment,
255            confidence: confidence.clamp(0.0, 1.0),
256        })
257    }
258
259    fn record_event(&mut self, frame: &FrameInfo, event: WallAerialShotEvent) {
260        let stats = self.player_stats.entry(event.player.clone()).or_default();
261        stats.count += 1;
262        if event.confidence >= WALL_AERIAL_HIGH_CONFIDENCE {
263            stats.high_confidence_count += 1;
264        }
265        stats.is_last_wall_aerial_shot = true;
266        stats.last_wall_aerial_shot_time = Some(event.time);
267        stats.last_wall_aerial_shot_frame = Some(event.frame);
268        stats.time_since_last_wall_aerial_shot = Some((frame.time - event.time).max(0.0));
269        stats.frames_since_last_wall_aerial_shot =
270            Some(frame.frame_number.saturating_sub(event.frame));
271        stats.last_confidence = Some(event.confidence);
272        stats.best_confidence = stats.best_confidence.max(event.confidence);
273        stats.cumulative_confidence += event.confidence;
274        stats.cumulative_takeoff_to_shot_time += event.time_since_takeoff;
275        stats.cumulative_shot_height += event.player_position[2];
276
277        self.current_last_wall_aerial_shot_player = Some(event.player.clone());
278        self.events.push(event);
279    }
280
281    pub fn update(
282        &mut self,
283        frame: &FrameInfo,
284        players: &PlayerFrameState,
285        frame_events: &FrameEventsState,
286        live_play: bool,
287    ) -> SubtrActorResult<()> {
288        self.begin_sample(frame);
289        if !live_play {
290            self.recent_wall_contacts.clear();
291            self.armed_shots.clear();
292            self.current_last_wall_aerial_shot_player = None;
293            return Ok(());
294        }
295
296        self.update_wall_contacts_and_takeoffs(frame, players);
297        self.prune_armed_shots(frame.time);
298
299        for stat_event in &frame_events.player_stat_events {
300            if let Some(event) = self.shot_event(players, stat_event) {
301                self.record_event(frame, event);
302            }
303        }
304
305        if let Some(player_id) = self.current_last_wall_aerial_shot_player.as_ref() {
306            if let Some(stats) = self.player_stats.get_mut(player_id) {
307                stats.is_last_wall_aerial_shot = true;
308            }
309        }
310
311        Ok(())
312    }
313}
314
315#[cfg(test)]
316#[path = "wall_aerial_shot_tests.rs"]
317mod tests;