1use super::*;
2
3const GOAL_LINE_Y: f32 = 5120.0;
4const GOAL_MOUTH_HEIGHT_Z: f32 = 642.775;
5const GOAL_MOUTH_TRAJECTORY_MARGIN: f32 = BALL_RADIUS_Z * 1.5;
6const SHOT_MAX_TIME_TO_GOAL_SECONDS: f32 = 2.5;
9const SHOT_MIN_BALL_SPEED: f32 = 1000.0;
10const SAVE_MAX_TIME_TO_GOAL_SECONDS: f32 = 2.0;
13const SAVE_MIN_INBOUND_BALL_SPEED: f32 = 250.0;
14const STAT_EVENT_MATCH_WINDOW_SECONDS: f32 = 0.75;
18const CLEAR_MAX_ATTACKING_Y: f32 = -GOAL_LINE_Y / 3.0;
20const CLEAR_MIN_BALL_SPEED: f32 = 1300.0;
21const CLEAR_MIN_AWAY_FROM_OWN_GOAL_ALIGNMENT: f32 = 0.2;
22const BOOM_MIN_BALL_SPEED: f32 = 1500.0;
27const BOOM_MIN_DOWNFIELD_ALIGNMENT: f32 = 0.3;
28const PASS_MIN_BALL_SPEED: f32 = 500.0;
29const PASS_MIN_LEAD_SECONDS: f32 = 0.15;
30const PASS_MAX_LEAD_SECONDS: f32 = 2.5;
31const PASS_RECEIVER_MAX_DISTANCE: f32 = 800.0;
32const PASS_MIN_TRAVEL_DISTANCE: f32 = 500.0;
33const FIRST_TOUCH_RESET_SECONDS: f32 = 2.5;
36const CONTROL_FOLLOW_WINDOW_SECONDS: f32 = 1.25;
39const CONTROL_FOLLOW_MAX_DISTANCE: f32 = 600.0;
42const CONTROL_FOLLOW_MAX_RELATIVE_SPEED: f32 = 800.0;
45const CONTROL_FOLLOW_MIN_TRACKED_SECONDS: f32 = 0.4;
49const CONTROL_FOLLOW_MIN_CONTROLLED_FRACTION: f32 = 0.7;
52const ADVANCE_MIN_PEAK_DISTANCE: f32 = 900.0;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum TouchAction {
75 Shot,
76 Save,
77 Clear,
78 Boom,
83 Pass,
84}
85
86impl TouchAction {
87 pub fn as_label_value(self) -> &'static str {
88 match self {
89 Self::Shot => "shot",
90 Self::Save => "save",
91 Self::Clear => "clear",
92 Self::Boom => "boom",
93 Self::Pass => "pass",
94 }
95 }
96
97 pub(crate) fn watches_possession(self) -> bool {
104 matches!(self, Self::Pass | Self::Clear | Self::Boom)
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Possession {
122 Control,
123 Advance,
124}
125
126impl Possession {
127 pub fn as_label_value(self) -> &'static str {
128 match self {
129 Self::Control => "control",
130 Self::Advance => "advance",
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct TouchActionResolution {
138 pub action: Option<TouchAction>,
141 pub first_touch: bool,
142 pub contested: bool,
143}
144
145pub struct TouchIntentionFrameContext<'a> {
151 pub ball_position: Option<glam::Vec3>,
152 pub ball_velocity: Option<glam::Vec3>,
153 pub previous_ball_position: Option<glam::Vec3>,
154 pub previous_ball_velocity: Option<glam::Vec3>,
155 pub teammate_positions: &'a [glam::Vec3],
157 pub contested: bool,
158}
159
160#[derive(Debug, Clone, PartialEq)]
163struct Reception {
164 player: PlayerId,
165 last_touch_time: f32,
166}
167
168#[derive(Debug, Clone, PartialEq)]
169struct RecentStatEvent {
170 kind: PlayerStatEventKind,
171 player: PlayerId,
172 time: f32,
173}
174
175#[derive(Debug, Clone, Default, PartialEq)]
182pub struct TouchIntentionClassifier {
183 reception: Option<Reception>,
184 recent_stat_events: VecDeque<RecentStatEvent>,
185}
186
187impl TouchIntentionClassifier {
188 pub fn reset(&mut self) {
191 self.reception = None;
192 self.recent_stat_events.clear();
193 }
194
195 pub fn begin_frame(&mut self, frame: &FrameInfo, player_stat_events: &[PlayerStatEvent]) {
198 for event in player_stat_events {
199 match event.kind {
200 PlayerStatEventKind::Shot | PlayerStatEventKind::Save => {
201 self.recent_stat_events.push_back(RecentStatEvent {
202 kind: event.kind,
203 player: event.player.clone(),
204 time: event.time,
205 });
206 }
207 PlayerStatEventKind::Assist => {}
208 }
209 }
210 while self
211 .recent_stat_events
212 .front()
213 .is_some_and(|event| frame.time - event.time > STAT_EVENT_MATCH_WINDOW_SECONDS)
214 {
215 self.recent_stat_events.pop_front();
216 }
217 }
218
219 pub fn classify(
232 &mut self,
233 touch: &TouchEvent,
234 player_id: &PlayerId,
235 ctx: &TouchIntentionFrameContext,
236 ) -> TouchActionResolution {
237 let first_touch = self.is_first_touch(player_id, touch.time);
238 let is_team_0 = touch.team_is_team_0;
239
240 let action =
241 if self.has_matching_stat_event(PlayerStatEventKind::Save, player_id, touch.time) {
242 Some(TouchAction::Save)
243 } else if self.has_matching_stat_event(PlayerStatEventKind::Shot, player_id, touch.time)
244 {
245 Some(TouchAction::Shot)
246 } else if Self::is_geometric_save(ctx, is_team_0) {
247 Some(TouchAction::Save)
248 } else if Self::is_geometric_shot(ctx, is_team_0) {
249 Some(TouchAction::Shot)
250 } else if Self::is_clear(ctx, is_team_0) {
251 Some(TouchAction::Clear)
252 } else if Self::is_pass(ctx) {
253 Some(TouchAction::Pass)
254 } else if Self::is_boom(ctx, is_team_0) {
255 Some(TouchAction::Boom)
256 } else {
257 None
258 };
259
260 self.note_touch(player_id, touch.time, ctx.contested);
261
262 TouchActionResolution {
263 action,
264 first_touch,
265 contested: ctx.contested,
266 }
267 }
268
269 fn is_first_touch(&self, player_id: &PlayerId, time: f32) -> bool {
270 match self.reception.as_ref() {
271 None => true,
272 Some(reception) => {
273 reception.player != *player_id
274 || (time - reception.last_touch_time) > FIRST_TOUCH_RESET_SECONDS
275 }
276 }
277 }
278
279 fn note_touch(&mut self, player_id: &PlayerId, time: f32, contested: bool) {
286 let fresh = self.reception.as_ref().is_some_and(|reception| {
287 (time - reception.last_touch_time) <= FIRST_TOUCH_RESET_SECONDS
288 });
289 match self.reception.as_mut() {
290 Some(reception) if contested && fresh => {
291 reception.last_touch_time = time;
292 }
293 _ => {
294 self.reception = Some(Reception {
295 player: player_id.clone(),
296 last_touch_time: time,
297 });
298 }
299 }
300 }
301
302 fn has_matching_stat_event(
303 &self,
304 kind: PlayerStatEventKind,
305 player_id: &PlayerId,
306 touch_time: f32,
307 ) -> bool {
308 self.recent_stat_events.iter().any(|event| {
309 event.kind == kind
310 && event.player == *player_id
311 && (touch_time - event.time).abs() <= STAT_EVENT_MATCH_WINDOW_SECONDS
312 })
313 }
314
315 fn is_geometric_save(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
316 let (Some(position), Some(velocity)) =
317 (ctx.previous_ball_position, ctx.previous_ball_velocity)
318 else {
319 return false;
320 };
321 velocity.length() >= SAVE_MIN_INBOUND_BALL_SPEED
322 && trajectory_crosses_goal_mouth(
323 position,
324 velocity,
325 own_goal_line_y(is_team_0),
326 SAVE_MAX_TIME_TO_GOAL_SECONDS,
327 )
328 }
329
330 fn is_geometric_shot(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
331 let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
332 return false;
333 };
334 velocity.length() >= SHOT_MIN_BALL_SPEED
335 && trajectory_crosses_goal_mouth(
336 position,
337 velocity,
338 opponent_goal_line_y(is_team_0),
339 SHOT_MAX_TIME_TO_GOAL_SECONDS,
340 )
341 }
342
343 fn is_clear(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
344 let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
345 return false;
346 };
347 let team_forward_sign = if is_team_0 { 1.0 } else { -1.0 };
348 if position.y * team_forward_sign > CLEAR_MAX_ATTACKING_Y {
349 return false;
350 }
351 if velocity.length() < CLEAR_MIN_BALL_SPEED {
352 return false;
353 }
354 let own_goal_center = glam::Vec3::new(0.0, own_goal_line_y(is_team_0), 0.0);
355 let away_from_own_goal = (position - own_goal_center).normalize_or_zero();
356 velocity.normalize_or_zero().dot(away_from_own_goal)
357 >= CLEAR_MIN_AWAY_FROM_OWN_GOAL_ALIGNMENT
358 }
359
360 fn is_pass(ctx: &TouchIntentionFrameContext) -> bool {
361 let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
362 return false;
363 };
364 let speed_squared = velocity.length_squared();
365 if speed_squared < PASS_MIN_BALL_SPEED * PASS_MIN_BALL_SPEED {
366 return false;
367 }
368 ctx.teammate_positions.iter().any(|teammate| {
369 let lead_seconds = (*teammate - position).dot(velocity) / speed_squared;
370 if !(PASS_MIN_LEAD_SECONDS..=PASS_MAX_LEAD_SECONDS).contains(&lead_seconds) {
371 return false;
372 }
373 let lead_travel = velocity * lead_seconds;
374 lead_travel.length() >= PASS_MIN_TRAVEL_DISTANCE
375 && (position + lead_travel - *teammate).length() <= PASS_RECEIVER_MAX_DISTANCE
376 })
377 }
378
379 fn is_boom(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
384 let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
385 return false;
386 };
387 if velocity.length() < BOOM_MIN_BALL_SPEED {
388 return false;
389 }
390 let opponent_goal_center = glam::Vec3::new(0.0, opponent_goal_line_y(is_team_0), 0.0);
391 let toward_opponent_goal = (opponent_goal_center - position).normalize_or_zero();
392 velocity.normalize_or_zero().dot(toward_opponent_goal) >= BOOM_MIN_DOWNFIELD_ALIGNMENT
393 }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub struct PossessionResolution {
401 pub touch_index: usize,
402 pub possession: Option<Possession>,
403}
404
405#[derive(Debug, Clone, PartialEq)]
406struct ControlFollowWindow {
407 touch_index: usize,
408 player: PlayerId,
409 touch_time: f32,
410 tracked_seconds: f32,
411 controlled_seconds: f32,
412 max_ball_distance: f32,
416}
417
418impl ControlFollowWindow {
419 fn stay_close_resolution(&self) -> PossessionResolution {
424 let confirmed = self.tracked_seconds >= CONTROL_FOLLOW_MIN_TRACKED_SECONDS
425 && self.controlled_seconds
426 >= CONTROL_FOLLOW_MIN_CONTROLLED_FRACTION * self.tracked_seconds;
427 PossessionResolution {
428 touch_index: self.touch_index,
429 possession: confirmed.then_some(Possession::Control),
430 }
431 }
432
433 fn follow_up_resolution(&self) -> PossessionResolution {
438 let possession = if self.max_ball_distance >= ADVANCE_MIN_PEAK_DISTANCE {
439 Possession::Advance
440 } else {
441 Possession::Control
442 };
443 PossessionResolution {
444 touch_index: self.touch_index,
445 possession: Some(possession),
446 }
447 }
448}
449
450#[derive(Debug, Clone, Default, PartialEq)]
459pub struct ControlFollowTracker {
460 window: Option<ControlFollowWindow>,
461}
462
463impl ControlFollowTracker {
464 pub fn window_player(&self) -> Option<&PlayerId> {
467 self.window.as_ref().map(|window| &window.player)
468 }
469
470 pub fn open(&mut self, touch_index: usize, player_id: &PlayerId, time: f32) {
473 self.window = Some(ControlFollowWindow {
474 touch_index,
475 player: player_id.clone(),
476 touch_time: time,
477 tracked_seconds: 0.0,
478 controlled_seconds: 0.0,
479 max_ball_distance: 0.0,
480 });
481 }
482
483 pub fn observe_touch(
489 &mut self,
490 player_id: &PlayerId,
491 time: f32,
492 ) -> Option<PossessionResolution> {
493 let window = self.window.take()?;
494 if window.player == *player_id && time - window.touch_time <= CONTROL_FOLLOW_WINDOW_SECONDS
495 {
496 return Some(window.follow_up_resolution());
497 }
498 Some(window.stay_close_resolution())
499 }
500
501 pub fn advance(
505 &mut self,
506 frame: &FrameInfo,
507 ball_position: Option<glam::Vec3>,
508 ball_velocity: Option<glam::Vec3>,
509 player_position: Option<glam::Vec3>,
510 player_velocity: Option<glam::Vec3>,
511 ) -> Option<PossessionResolution> {
512 if self
513 .window
514 .as_ref()
515 .is_some_and(|window| frame.time - window.touch_time > CONTROL_FOLLOW_WINDOW_SECONDS)
516 {
517 return self.flush();
518 }
519 let window = self.window.as_mut()?;
520 let dt = frame.dt.max(0.0);
521 window.tracked_seconds += dt;
522 let distance = match (ball_position, player_position) {
523 (Some(ball_position), Some(player_position)) => {
524 Some((ball_position - player_position).length())
525 }
526 _ => None,
527 };
528 if let Some(distance) = distance {
529 window.max_ball_distance = window.max_ball_distance.max(distance);
530 }
531 let close = distance.is_some_and(|distance| distance <= CONTROL_FOLLOW_MAX_DISTANCE);
532 let speed_matched = match (ball_velocity, player_velocity) {
533 (Some(ball_velocity), Some(player_velocity)) => {
534 (ball_velocity - player_velocity).length() <= CONTROL_FOLLOW_MAX_RELATIVE_SPEED
535 }
536 _ => false,
537 };
538 if close && speed_matched {
539 window.controlled_seconds += dt;
540 }
541 None
542 }
543
544 pub fn flush(&mut self) -> Option<PossessionResolution> {
547 self.window
548 .take()
549 .map(|window| window.stay_close_resolution())
550 }
551}
552
553const SHOT_PROJECTION_MIN_FREE_FLIGHT_SECONDS: f32 = 0.06;
558
559const SHOT_PROJECTION_NEXT_TOUCH_GUARD_SECONDS: f32 = 0.12;
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
570pub struct ShotProjectionResolution {
571 pub touch_index: usize,
572 pub is_shot: bool,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq)]
576struct ShotProjectionSample {
577 position: glam::Vec3,
578 velocity: glam::Vec3,
579 time: f32,
580}
581
582#[derive(Debug, Clone, PartialEq)]
583struct ShotProjectionWindow {
584 touch_index: usize,
585 is_team_0: bool,
586 touch_time: f32,
587 samples: VecDeque<ShotProjectionSample>,
590}
591
592impl ShotProjectionWindow {
593 fn record(&mut self, sample: ShotProjectionSample) {
594 let cutoff = sample.time - (SHOT_PROJECTION_NEXT_TOUCH_GUARD_SECONDS + 0.1);
597 self.samples.push_back(sample);
598 while self
599 .samples
600 .front()
601 .is_some_and(|oldest| oldest.time < cutoff)
602 {
603 self.samples.pop_front();
604 }
605 }
606
607 fn is_shot_from(&self, sample: &ShotProjectionSample) -> bool {
608 sample.time - self.touch_time >= SHOT_PROJECTION_MIN_FREE_FLIGHT_SECONDS
609 && sample.velocity.length() >= SHOT_MIN_BALL_SPEED
610 && trajectory_crosses_goal_mouth(
611 sample.position,
612 sample.velocity,
613 opponent_goal_line_y(self.is_team_0),
614 SHOT_MAX_TIME_TO_GOAL_SECONDS,
615 )
616 }
617
618 fn resolution_latest(&self) -> ShotProjectionResolution {
622 let is_shot = self
623 .samples
624 .back()
625 .is_some_and(|sample| self.is_shot_from(sample));
626 ShotProjectionResolution {
627 touch_index: self.touch_index,
628 is_shot,
629 }
630 }
631
632 fn resolution_before(&self, close_time: f32) -> ShotProjectionResolution {
636 let limit = close_time - SHOT_PROJECTION_NEXT_TOUCH_GUARD_SECONDS;
637 let is_shot = self
638 .samples
639 .iter()
640 .rev()
641 .find(|sample| sample.time <= limit)
642 .is_some_and(|sample| self.is_shot_from(sample));
643 ShotProjectionResolution {
644 touch_index: self.touch_index,
645 is_shot,
646 }
647 }
648}
649
650#[derive(Debug, Clone, Default, PartialEq)]
659pub struct ShotProjectionTracker {
660 window: Option<ShotProjectionWindow>,
661}
662
663impl ShotProjectionTracker {
664 pub fn open(&mut self, touch_index: usize, is_team_0: bool, time: f32) {
666 self.window = Some(ShotProjectionWindow {
667 touch_index,
668 is_team_0,
669 touch_time: time,
670 samples: VecDeque::new(),
671 });
672 }
673
674 pub fn observe_touch(&mut self, next_touch_time: f32) -> Option<ShotProjectionResolution> {
678 self.window
679 .take()
680 .map(|window| window.resolution_before(next_touch_time))
681 }
682
683 pub fn advance(
686 &mut self,
687 frame: &FrameInfo,
688 ball_position: Option<glam::Vec3>,
689 ball_velocity: Option<glam::Vec3>,
690 ) -> Option<ShotProjectionResolution> {
691 if self
692 .window
693 .as_ref()
694 .is_some_and(|window| frame.time - window.touch_time > SHOT_MAX_TIME_TO_GOAL_SECONDS)
695 {
696 return self.window.take().map(|window| window.resolution_latest());
697 }
698 let window = self.window.as_mut()?;
699 if let (Some(position), Some(velocity)) = (ball_position, ball_velocity) {
700 window.record(ShotProjectionSample {
701 position,
702 velocity,
703 time: frame.time,
704 });
705 }
706 None
707 }
708
709 pub fn flush(&mut self) -> Option<ShotProjectionResolution> {
711 self.window.take().map(|window| window.resolution_latest())
712 }
713}
714
715pub(crate) fn fifty_fifty_involves_player(
717 active: &ActiveFiftyFifty,
718 player_id: &PlayerId,
719 is_team_0: bool,
720) -> bool {
721 let contestant = if is_team_0 {
722 active.team_zero_player.as_ref()
723 } else {
724 active.team_one_player.as_ref()
725 };
726 contestant == Some(player_id)
727}
728
729fn own_goal_line_y(is_team_0: bool) -> f32 {
730 if is_team_0 { -GOAL_LINE_Y } else { GOAL_LINE_Y }
731}
732
733fn opponent_goal_line_y(is_team_0: bool) -> f32 {
734 -own_goal_line_y(is_team_0)
735}
736
737fn trajectory_crosses_goal_mouth(
748 position: glam::Vec3,
749 velocity: glam::Vec3,
750 target_goal_y: f32,
751 max_seconds: f32,
752) -> bool {
753 if velocity.length_squared() <= f32::EPSILON {
754 return false;
755 }
756 let time_to_goal_line = (target_goal_y - position.y) / velocity.y;
757 if !time_to_goal_line.is_finite() || !(0.0..=max_seconds).contains(&time_to_goal_line) {
758 return false;
759 }
760 let projected_x = position.x + velocity.x * time_to_goal_line;
761 let projected_z = position.z
762 + velocity.z * time_to_goal_line
763 + 0.5
764 * crate::util::ballistics::STANDARD_BALL_GRAVITY_Z
765 * time_to_goal_line
766 * time_to_goal_line;
767 projected_x.abs() <= BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X + GOAL_MOUTH_TRAJECTORY_MARGIN
768 && (BALL_RADIUS_Z - GOAL_MOUTH_TRAJECTORY_MARGIN
769 ..=GOAL_MOUTH_HEIGHT_Z + GOAL_MOUTH_TRAJECTORY_MARGIN)
770 .contains(&projected_z)
771}
772
773#[cfg(test)]
774#[path = "touch_intention_tests.rs"]
775mod tests;