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