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