subtr_actor/stats/calculators/
wall_aerial_shot.rs1use 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;