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