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_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;