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