Skip to main content

subtr_actor/stats/calculators/
touch_state.rs

1use super::*;
2
3/// Shared per-frame raw-touch state.
4#[derive(Debug, Clone, Default)]
5pub struct TouchState {
6    pub touch_events: Vec<TouchEvent>,
7    pub last_touch: Option<TouchEvent>,
8    pub last_touch_player: Option<PlayerId>,
9    pub last_touch_team_is_team_0: Option<bool>,
10}
11
12impl TouchState {
13    pub fn primary_touch_event(&self) -> Option<&TouchEvent> {
14        primary_touch_event(&self.touch_events)
15    }
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19enum TouchCooldownKey {
20    Player(PlayerId),
21    Team(bool),
22}
23
24/// Last rate-limit-accepted touch for a cooldown key: its time plus whether it
25/// carried dodge evidence, so a dodge-powered touch can bypass the rate limit
26/// imposed by an earlier passive contact.
27#[derive(Debug, Clone, Copy)]
28struct LastCooldownTouch {
29    time: f32,
30    dodge_contact: bool,
31}
32
33const TOUCH_SCORING: TouchCandidateScoring = TouchCandidateScoring::DEFAULT;
34const TOUCH_CANDIDATE_WINDOW_FRAMES: usize = 4;
35const CONTESTED_TOUCH_WINDOW_FRAMES: usize = 1;
36const BALL_GRAVITY_Z: f32 = -650.0;
37
38/// Maximum contact gap (uu) at which a replay-reported team touch marker may be
39/// attributed to that team's closest player using current-frame geometry alone.
40///
41/// A `BallHitTeamNum` marker is authoritative that the team touched the ball, but
42/// on a contested 50/50 the losing challenger frequently contacts the ball a frame
43/// before any detectable trajectory deviation, so it never enters the candidate
44/// cache and the marker would otherwise be dropped. When the cache cannot resolve
45/// the marker, the closest same-team car sitting on the ball is the touch. Kept
46/// within the system's relaxed contact boundary so every emitted touch still
47/// reflects a genuine hitbox contact.
48const MARKER_CONTACT_ATTRIBUTION_MAX_GAP: f32 = TOUCH_SCORING.relaxed_contact_gap_threshold;
49
50/// Maximum contact gap (uu) for a car to be credited a *geometric contested touch*:
51/// a car that is physically on the ball but is never accepted as a deviation
52/// candidate because its tight contact lands a frame off the ball's measurable
53/// trajectory deviation (the other car in the 50/50 already redirected the ball).
54const GEOMETRIC_CONTEST_MAX_GAP: f32 = 5.0;
55
56/// How recently (frames / seconds) an opposing-team touch must have been confirmed
57/// for a car now sitting on the ball to be credited a geometric contested touch.
58const GEOMETRIC_CONTEST_WINDOW_FRAMES: usize = 4;
59const GEOMETRIC_CONTEST_WINDOW_SECONDS: f32 = 0.2;
60
61fn accepted_contact_gap(closest_contact_gap: f32, ball_deviation: BallTrajectoryDeviation) -> bool {
62    TOUCH_SCORING.accepts_contact_gap(
63        closest_contact_gap,
64        ball_deviation.position_deviation,
65        ball_deviation.velocity_deviation,
66    )
67}
68
69fn touch_candidate_score(closest_contact_gap: f32, dodge_contact: bool) -> f32 {
70    TOUCH_SCORING.score_contact_gap(closest_contact_gap, dodge_contact)
71}
72
73fn touch_event_score(event: &TouchEvent) -> f32 {
74    touch_candidate_score(
75        event.closest_approach_distance.unwrap_or(f32::INFINITY),
76        event.dodge_contact,
77    )
78}
79
80fn player_sort_key(player: &PlayerId) -> (u8, u64, String) {
81    match player {
82        boxcars::RemoteId::PlayStation(id) => (0, id.online_id, id.name.clone()),
83        boxcars::RemoteId::PsyNet(id) => (1, id.online_id, format!("{:?}", id.unknown1)),
84        boxcars::RemoteId::SplitScreen(id) => (2, u64::from(*id), String::new()),
85        boxcars::RemoteId::Steam(id) => (3, *id, String::new()),
86        boxcars::RemoteId::Switch(id) => (4, id.online_id, format!("{:?}", id.unknown1)),
87        boxcars::RemoteId::Xbox(id) => (5, *id, String::new()),
88        boxcars::RemoteId::QQ(id) => (6, *id, String::new()),
89        boxcars::RemoteId::Epic(id) => (7, 0, id.clone()),
90    }
91}
92
93fn touch_event_player_sort_key(event: &TouchEvent) -> Option<(u8, u64, String)> {
94    event.player.as_ref().map(player_sort_key)
95}
96
97pub(crate) fn touch_event_ordering(left: &TouchEvent, right: &TouchEvent) -> std::cmp::Ordering {
98    touch_event_score(left)
99        .total_cmp(&touch_event_score(right))
100        .then_with(|| {
101            left.closest_approach_distance
102                .unwrap_or(f32::INFINITY)
103                .total_cmp(&right.closest_approach_distance.unwrap_or(f32::INFINITY))
104        })
105        .then_with(|| right.dodge_contact.cmp(&left.dodge_contact))
106        .then_with(|| right.frame.cmp(&left.frame))
107        .then_with(|| right.time.total_cmp(&left.time))
108        .then_with(|| right.player.is_some().cmp(&left.player.is_some()))
109        .then_with(|| left.team_is_team_0.cmp(&right.team_is_team_0))
110        .then_with(|| touch_event_player_sort_key(left).cmp(&touch_event_player_sort_key(right)))
111}
112
113fn touch_event_chronological_ordering(left: &TouchEvent, right: &TouchEvent) -> std::cmp::Ordering {
114    TouchEvent::timestamp_ordering(left, right).then_with(|| touch_event_ordering(left, right))
115}
116
117fn primary_touch_event(touch_events: &[TouchEvent]) -> Option<&TouchEvent> {
118    let latest_touch = touch_events
119        .iter()
120        .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))?;
121    touch_events
122        .iter()
123        .filter(|event| TouchEvent::timestamp_ordering(event, latest_touch).is_eq())
124        .min_by(|left, right| touch_event_ordering(left, right))
125}
126
127#[derive(Debug, Clone, Copy, Default)]
128struct TouchEventContactFields {
129    local_ball_position: Option<[f32; 3]>,
130    local_hitbox_point: Option<[f32; 3]>,
131    world_hitbox_point: Option<[f32; 3]>,
132}
133
134fn touch_event_contact_fields(
135    ball_position: glam::Vec3,
136    player_body: &boxcars::RigidBody,
137    hitbox: CarHitbox,
138) -> TouchEventContactFields {
139    let Some(estimate) = car_hitbox_contact_estimate(ball_position, player_body, hitbox) else {
140        return TouchEventContactFields::default();
141    };
142
143    let car_position = vec_to_glam(&player_body.location);
144    let car_rotation = quat_to_glam(&player_body.rotation);
145    let hitbox_center = glam::Vec3::new(hitbox.offset, 0.0, hitbox.elevation);
146    let hitbox_rotation = glam::Quat::from_rotation_y(hitbox.angle.to_radians());
147    let world_hitbox_point = car_position
148        + car_rotation * (hitbox_center + hitbox_rotation * estimate.local_contact_point);
149
150    TouchEventContactFields {
151        local_ball_position: Some(estimate.local_ball_position.to_array()),
152        local_hitbox_point: Some(estimate.local_contact_point.to_array()),
153        world_hitbox_point: Some(world_hitbox_point.to_array()),
154    }
155}
156
157#[derive(Debug, Clone)]
158struct RecentTeamTouch {
159    frame: usize,
160    time: f32,
161    team_is_team_0: bool,
162}
163
164/// Detects raw ball touches per frame from ball/player state and frame events.
165#[derive(Debug, Clone, Default)]
166pub struct TouchStateCalculator {
167    previous_ball_rigid_body: Option<(boxcars::RigidBody, f32)>,
168    current_last_touch: Option<TouchEvent>,
169    recent_touch_candidates: HashMap<PlayerId, TouchEvent>,
170    last_touch_times: HashMap<TouchCooldownKey, LastCooldownTouch>,
171    recent_team_touches: Vec<RecentTeamTouch>,
172    next_touch_id: u64,
173}
174
175impl TouchStateCalculator {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    fn prune_recent_touch_candidates(&mut self, current_frame: usize) {
181        self.recent_touch_candidates.retain(|_, candidate| {
182            current_frame.saturating_sub(candidate.frame) <= TOUCH_CANDIDATE_WINDOW_FRAMES
183        });
184    }
185
186    fn ball_trajectory_deviation(
187        &self,
188        frame: &FrameInfo,
189        ball: &BallFrameState,
190    ) -> Option<BallTrajectoryDeviation> {
191        let current_ball = ball.sample()?;
192        let (previous_ball, previous_time) = &self.previous_ball_rigid_body?;
193        ball_trajectory_deviation_with_gravity(
194            previous_ball,
195            *previous_time,
196            &current_ball.rigid_body,
197            frame.time,
198            BALL_GRAVITY_Z,
199        )
200    }
201
202    fn proximity_touch_candidates(
203        &self,
204        frame: &FrameInfo,
205        ball: &BallFrameState,
206        players: &PlayerFrameState,
207        ball_deviation: BallTrajectoryDeviation,
208    ) -> Vec<TouchEvent> {
209        let Some(ball) = ball.sample() else {
210            return Vec::new();
211        };
212
213        let mut candidates = players
214            .players
215            .iter()
216            .filter_map(|player| {
217                let rigid_body = player.rigid_body.as_ref()?;
218                let (closest_contact_gap, _current_contact_gap) =
219                    touch_candidate_contact_gap_rank_with_hitbox(
220                        &ball.rigid_body,
221                        rigid_body,
222                        player.hitbox,
223                    )?;
224                if !accepted_contact_gap(closest_contact_gap, ball_deviation) {
225                    return None;
226                }
227                let contact_fields =
228                    touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
229
230                Some(TouchEvent {
231                    touch_id: None,
232                    time: frame.time,
233                    frame: frame.frame_number,
234                    team_is_team_0: player.is_team_0,
235                    player: Some(player.player_id.clone()),
236                    player_position: Some(rigid_body.location),
237                    closest_approach_distance: Some(closest_contact_gap),
238                    contact_local_ball_position: contact_fields.local_ball_position,
239                    contact_local_hitbox_point: contact_fields.local_hitbox_point,
240                    contact_world_hitbox_point: contact_fields.world_hitbox_point,
241                    dodge_contact: player.dodge_active,
242                })
243            })
244            .collect::<Vec<_>>();
245
246        candidates.sort_by(touch_event_ordering);
247        candidates
248    }
249
250    fn candidate_touch_events(
251        &self,
252        frame: &FrameInfo,
253        ball: &BallFrameState,
254        players: &PlayerFrameState,
255        ball_deviation: BallTrajectoryDeviation,
256    ) -> Vec<TouchEvent> {
257        let candidates = self.proximity_touch_candidates(frame, ball, players, ball_deviation);
258        let Some(primary) = candidates.first() else {
259            return Vec::new();
260        };
261        let primary_score = touch_candidate_score(
262            primary.closest_approach_distance.unwrap_or(f32::INFINITY),
263            primary.dodge_contact,
264        );
265        candidates
266            .into_iter()
267            .filter(|candidate| {
268                let score = touch_candidate_score(
269                    candidate.closest_approach_distance.unwrap_or(f32::INFINITY),
270                    candidate.dodge_contact,
271                );
272                score <= primary_score + TOUCH_SCORING.simultaneous_touch_score_margin
273            })
274            .collect()
275    }
276
277    fn update_recent_touch_candidates(
278        &mut self,
279        frame: &FrameInfo,
280        ball: &BallFrameState,
281        players: &PlayerFrameState,
282    ) {
283        let Some(ball_deviation) = self.ball_trajectory_deviation(frame, ball) else {
284            return;
285        };
286
287        for candidate in self.proximity_touch_candidates(frame, ball, players, ball_deviation) {
288            let Some(player_id) = candidate.player.clone() else {
289                continue;
290            };
291
292            if self
293                .recent_touch_candidates
294                .get(&player_id)
295                .is_none_or(|previous| touch_event_ordering(&candidate, previous).is_lt())
296            {
297                self.recent_touch_candidates.insert(player_id, candidate);
298            }
299        }
300    }
301
302    fn candidate_for_player(&self, player_id: &PlayerId) -> Option<TouchEvent> {
303        self.recent_touch_candidates.get(player_id).cloned()
304    }
305
306    fn best_candidate_for_team(&self, team_is_team_0: bool) -> Option<TouchEvent> {
307        self.recent_touch_candidates
308            .values()
309            .filter(|candidate| candidate.team_is_team_0 == team_is_team_0)
310            .min_by(|left, right| touch_event_ordering(left, right))
311            .cloned()
312    }
313
314    /// Finds the closest same-team car to the ball at the current frame, used to
315    /// attribute a replay touch marker that the candidate cache cannot resolve.
316    fn current_frame_touch_candidate_for_team(
317        &self,
318        event: &TouchEvent,
319        ball: &BallFrameState,
320        players: &PlayerFrameState,
321    ) -> Option<TouchEvent> {
322        let ball = ball.sample()?;
323
324        players
325            .players
326            .iter()
327            .filter(|player| player.is_team_0 == event.team_is_team_0)
328            .filter_map(|player| {
329                let rigid_body = player.rigid_body.as_ref()?;
330                let (closest_contact_gap, _current_contact_gap) =
331                    touch_candidate_contact_gap_rank_with_hitbox(
332                        &ball.rigid_body,
333                        rigid_body,
334                        player.hitbox,
335                    )?;
336                Some((closest_contact_gap, player, rigid_body))
337            })
338            .min_by(|left, right| left.0.total_cmp(&right.0))
339            .filter(|(closest_contact_gap, _, _)| {
340                *closest_contact_gap <= MARKER_CONTACT_ATTRIBUTION_MAX_GAP
341            })
342            .map(|(closest_contact_gap, player, rigid_body)| {
343                let contact_fields =
344                    touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
345                TouchEvent {
346                    touch_id: None,
347                    time: event.time,
348                    frame: event.frame,
349                    team_is_team_0: event.team_is_team_0,
350                    player: Some(player.player_id.clone()),
351                    player_position: Some(rigid_body.location),
352                    closest_approach_distance: Some(closest_contact_gap),
353                    contact_local_ball_position: contact_fields.local_ball_position,
354                    contact_local_hitbox_point: contact_fields.local_hitbox_point,
355                    contact_world_hitbox_point: contact_fields.world_hitbox_point,
356                    dodge_contact: player.dodge_active,
357                }
358            })
359    }
360
361    /// Finds a concrete current-frame contact for a dodge refresh when no
362    /// trajectory-deviation candidate was cached. Flip-reset underside contacts
363    /// can refresh the dodge without producing a replay `BallHitTeamNum` marker
364    /// or enough ball deviation to enter the candidate cache.
365    fn current_frame_touch_candidate_for_dodge_refresh(
366        &self,
367        dodge_refresh: &DodgeRefreshedEvent,
368        ball: &BallFrameState,
369        players: &PlayerFrameState,
370    ) -> Option<TouchEvent> {
371        self.previous_ball_rigid_body?;
372        let ball = ball.sample()?;
373        let player = players
374            .players
375            .iter()
376            .find(|player| player.player_id == dodge_refresh.player)?;
377        let rigid_body = player.rigid_body.as_ref()?;
378        let (closest_contact_gap, _current_contact_gap) =
379            touch_candidate_contact_gap_rank_with_hitbox(
380                &ball.rigid_body,
381                rigid_body,
382                player.hitbox,
383            )?;
384        if closest_contact_gap > MARKER_CONTACT_ATTRIBUTION_MAX_GAP {
385            return None;
386        }
387        let contact_fields = touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
388        Some(TouchEvent {
389            touch_id: None,
390            time: dodge_refresh.time,
391            frame: dodge_refresh.frame,
392            team_is_team_0: dodge_refresh.is_team_0,
393            player: Some(dodge_refresh.player.clone()),
394            player_position: Some(rigid_body.location),
395            closest_approach_distance: Some(closest_contact_gap),
396            contact_local_ball_position: contact_fields.local_ball_position,
397            contact_local_hitbox_point: contact_fields.local_hitbox_point,
398            contact_world_hitbox_point: contact_fields.world_hitbox_point,
399            dodge_contact: player.dodge_active,
400        })
401    }
402
403    /// Attributes a team-only replay touch marker to a concrete player.
404    ///
405    /// Prefers whichever of the recent candidate cache or the current frame's
406    /// geometry puts a car closest to the ball. The cache covers the normal case
407    /// where a trajectory deviation already identified the toucher; the
408    /// current-frame fallback recovers contested 50/50 touches where the
409    /// challenger contacted the ball before any deviation was detectable (so it
410    /// never cached) yet is still sitting on the ball at the marker frame.
411    fn enrich_team_touch_event_from_recent_cache(
412        &self,
413        event: &TouchEvent,
414        ball: &BallFrameState,
415        players: &PlayerFrameState,
416    ) -> Option<TouchEvent> {
417        let gap_of =
418            |candidate: &TouchEvent| candidate.closest_approach_distance.unwrap_or(f32::INFINITY);
419
420        // Current-frame geometry is only trustworthy during continuous live play,
421        // where a previous ball frame establishes a real trajectory. Without that
422        // baseline (e.g. the very first frame after a reset) a car merely parked on
423        // a stationary ball must not be credited from a bare team marker.
424        let current_frame_candidate = if self.previous_ball_rigid_body.is_some() {
425            self.current_frame_touch_candidate_for_team(event, ball, players)
426        } else {
427            None
428        };
429
430        let candidate = match (
431            self.best_candidate_for_team(event.team_is_team_0),
432            current_frame_candidate,
433        ) {
434            (Some(cache_candidate), Some(current_candidate)) => {
435                if gap_of(&current_candidate) < gap_of(&cache_candidate) {
436                    current_candidate
437                } else {
438                    cache_candidate
439                }
440            }
441            (Some(cache_candidate), None) => cache_candidate,
442            (None, Some(current_candidate)) => current_candidate,
443            (None, None) => return None,
444        };
445
446        Some(TouchEvent {
447            touch_id: None,
448            time: event.time,
449            frame: event.frame,
450            team_is_team_0: event.team_is_team_0,
451            player: event.player.clone().or(candidate.player),
452            player_position: event.player_position.or(candidate.player_position),
453            closest_approach_distance: event
454                .closest_approach_distance
455                .or(candidate.closest_approach_distance),
456            contact_local_ball_position: event
457                .contact_local_ball_position
458                .or(candidate.contact_local_ball_position),
459            contact_local_hitbox_point: event
460                .contact_local_hitbox_point
461                .or(candidate.contact_local_hitbox_point),
462            contact_world_hitbox_point: event
463                .contact_world_hitbox_point
464                .or(candidate.contact_world_hitbox_point),
465            dodge_contact: event.dodge_contact || candidate.dodge_contact,
466        })
467    }
468
469    fn enrich_explicit_touch_event_from_current_frame(
470        &self,
471        event: &TouchEvent,
472        ball: &BallFrameState,
473        players: &PlayerFrameState,
474    ) -> TouchEvent {
475        let Some(player_id) = event.player.as_ref() else {
476            return event.clone();
477        };
478        let Some(player) = players
479            .players
480            .iter()
481            .find(|sample| &sample.player_id == player_id)
482        else {
483            return event.clone();
484        };
485        let rigid_body = player.rigid_body.as_ref();
486        let closest_contact_gap = ball
487            .sample()
488            .zip(rigid_body)
489            .and_then(|(ball, rigid_body)| {
490                touch_candidate_contact_gap_rank_with_hitbox(
491                    &ball.rigid_body,
492                    rigid_body,
493                    player.hitbox,
494                )
495            })
496            .map(|(closest_contact_gap, _current_contact_gap)| closest_contact_gap);
497        let contact_fields = ball
498            .position()
499            .zip(rigid_body)
500            .map(|(ball_position, rigid_body)| {
501                touch_event_contact_fields(ball_position, rigid_body, player.hitbox)
502            })
503            .unwrap_or_default();
504
505        TouchEvent {
506            team_is_team_0: player.is_team_0,
507            player_position: event
508                .player_position
509                .or_else(|| rigid_body.map(|rigid_body| rigid_body.location)),
510            closest_approach_distance: event.closest_approach_distance.or(closest_contact_gap),
511            contact_local_ball_position: event
512                .contact_local_ball_position
513                .or(contact_fields.local_ball_position),
514            contact_local_hitbox_point: event
515                .contact_local_hitbox_point
516                .or(contact_fields.local_hitbox_point),
517            contact_world_hitbox_point: event
518                .contact_world_hitbox_point
519                .or(contact_fields.world_hitbox_point),
520            dodge_contact: event.dodge_contact || player.dodge_active,
521            ..event.clone()
522        }
523    }
524
525    fn touch_event_from_dodge_refresh(
526        dodge_refresh: &DodgeRefreshedEvent,
527        candidate: TouchEvent,
528    ) -> TouchEvent {
529        TouchEvent {
530            touch_id: None,
531            time: dodge_refresh.time,
532            frame: dodge_refresh.frame,
533            team_is_team_0: dodge_refresh.is_team_0,
534            player: Some(dodge_refresh.player.clone()),
535            player_position: dodge_refresh
536                .player_position
537                .map(|position| glam_to_vec(&glam::Vec3::from_array(position)))
538                .or(candidate.player_position),
539            closest_approach_distance: candidate.closest_approach_distance,
540            contact_local_ball_position: candidate.contact_local_ball_position,
541            contact_local_hitbox_point: candidate.contact_local_hitbox_point,
542            contact_world_hitbox_point: candidate.contact_world_hitbox_point,
543            dodge_contact: candidate.dodge_contact,
544        }
545    }
546
547    fn explicit_touch_event(
548        &self,
549        event: &TouchEvent,
550        ball: &BallFrameState,
551        players: &PlayerFrameState,
552        allow_team_only_cache_attribution: bool,
553    ) -> Option<TouchEvent> {
554        if event.player.is_some() {
555            Some(self.enrich_explicit_touch_event_from_current_frame(event, ball, players))
556        } else if allow_team_only_cache_attribution {
557            self.enrich_team_touch_event_from_recent_cache(event, ball, players)
558        } else {
559            None
560        }
561    }
562
563    fn contested_touch_candidates(&self, primary: &TouchEvent) -> Vec<TouchEvent> {
564        let primary_score = touch_candidate_score(
565            primary.closest_approach_distance.unwrap_or(f32::INFINITY),
566            primary.dodge_contact,
567        );
568
569        let mut opposing_candidates = self
570            .recent_touch_candidates
571            .values()
572            .filter(|candidate| candidate.team_is_team_0 != primary.team_is_team_0)
573            .filter(|candidate| {
574                candidate.frame.abs_diff(primary.frame) <= CONTESTED_TOUCH_WINDOW_FRAMES
575            })
576            .filter(|candidate| {
577                touch_candidate_score(
578                    candidate.closest_approach_distance.unwrap_or(f32::INFINITY),
579                    candidate.dodge_contact,
580                ) <= primary_score + TOUCH_SCORING.contested_touch_score_margin
581            })
582            .cloned()
583            .collect::<Vec<_>>();
584        opposing_candidates.sort_by(touch_event_ordering);
585
586        opposing_candidates
587    }
588
589    /// Credits cars that are physically on the ball but were never accepted as a
590    /// deviation candidate, when an opposing-team touch was confirmed in the recent
591    /// window. This recovers the losing side of a contested 50/50: the challenger
592    /// reaches the ball a frame off the measurable trajectory deviation (which the
593    /// winning car already produced), so it never caches and is otherwise dropped.
594    fn geometric_contested_touches(
595        &self,
596        frame: &FrameInfo,
597        ball: &BallFrameState,
598        players: &PlayerFrameState,
599        confirmed_players: &HashSet<PlayerId>,
600    ) -> Vec<TouchEvent> {
601        if self.previous_ball_rigid_body.is_none() {
602            return Vec::new();
603        }
604        let Some(ball) = ball.sample() else {
605            return Vec::new();
606        };
607
608        players
609            .players
610            .iter()
611            .filter(|player| !confirmed_players.contains(&player.player_id))
612            .filter(|player| self.has_recent_opposing_team_touch(frame, player.is_team_0))
613            .filter_map(|player| {
614                let rigid_body = player.rigid_body.as_ref()?;
615                let (closest_contact_gap, _current_contact_gap) =
616                    touch_candidate_contact_gap_rank_with_hitbox(
617                        &ball.rigid_body,
618                        rigid_body,
619                        player.hitbox,
620                    )?;
621                if closest_contact_gap > GEOMETRIC_CONTEST_MAX_GAP {
622                    return None;
623                }
624                let contact_fields =
625                    touch_event_contact_fields(ball.position(), rigid_body, player.hitbox);
626                Some(TouchEvent {
627                    touch_id: None,
628                    time: frame.time,
629                    frame: frame.frame_number,
630                    team_is_team_0: player.is_team_0,
631                    player: Some(player.player_id.clone()),
632                    player_position: Some(rigid_body.location),
633                    closest_approach_distance: Some(closest_contact_gap),
634                    contact_local_ball_position: contact_fields.local_ball_position,
635                    contact_local_hitbox_point: contact_fields.local_hitbox_point,
636                    contact_world_hitbox_point: contact_fields.world_hitbox_point,
637                    dodge_contact: player.dodge_active,
638                })
639            })
640            .collect()
641    }
642
643    fn has_recent_opposing_team_touch(&self, frame: &FrameInfo, team_is_team_0: bool) -> bool {
644        self.recent_team_touches.iter().any(|touch| {
645            touch.team_is_team_0 != team_is_team_0
646                && frame.frame_number.saturating_sub(touch.frame) <= GEOMETRIC_CONTEST_WINDOW_FRAMES
647                && frame.time - touch.time <= GEOMETRIC_CONTEST_WINDOW_SECONDS
648        })
649    }
650
651    fn confirmed_touch_events(
652        &self,
653        frame: &FrameInfo,
654        ball: &BallFrameState,
655        players: &PlayerFrameState,
656        events: &FrameEventsState,
657    ) -> Vec<TouchEvent> {
658        let mut touch_events = Vec::new();
659        let mut confirmed_players = HashSet::new();
660
661        if let Some(ball_deviation) = self.ball_trajectory_deviation(frame, ball) {
662            let candidate_events =
663                self.candidate_touch_events(frame, ball, players, ball_deviation);
664            if let Some(candidate) = candidate_events.first() {
665                for contested_candidate in self.contested_touch_candidates(candidate) {
666                    if let Some(player_id) = contested_candidate.player.as_ref() {
667                        if confirmed_players.contains(player_id) {
668                            continue;
669                        }
670                        confirmed_players.insert(player_id.clone());
671                    }
672                    touch_events.push(contested_candidate);
673                }
674                for candidate in candidate_events {
675                    if let Some(player_id) = candidate.player.clone() {
676                        if confirmed_players.contains(&player_id) {
677                            continue;
678                        }
679                        confirmed_players.insert(player_id);
680                    }
681                    touch_events.push(candidate);
682                }
683            }
684        }
685
686        if touch_events.is_empty() {
687            for event in &events.touch_events {
688                let Some(event) = self.explicit_touch_event(event, ball, players, true) else {
689                    continue;
690                };
691                if let Some(player_id) = event.player.clone() {
692                    confirmed_players.insert(player_id);
693                }
694                touch_events.push(event);
695            }
696        } else {
697            for event in &events.touch_events {
698                if event.player.is_none() {
699                    continue;
700                }
701                let Some(event) = self.explicit_touch_event(event, ball, players, false) else {
702                    continue;
703                };
704                let Some(player_id) = event.player.clone() else {
705                    continue;
706                };
707                if !confirmed_players.insert(player_id) {
708                    continue;
709                }
710                touch_events.push(event);
711            }
712        }
713
714        for dodge_refresh in &events.dodge_refreshed_events {
715            if !confirmed_players.insert(dodge_refresh.player.clone()) {
716                continue;
717            }
718            let Some(candidate) = self
719                .candidate_for_player(&dodge_refresh.player)
720                .or_else(|| {
721                    self.current_frame_touch_candidate_for_dodge_refresh(
722                        dodge_refresh,
723                        ball,
724                        players,
725                    )
726                })
727            else {
728                continue;
729            };
730            touch_events.push(Self::touch_event_from_dodge_refresh(
731                dodge_refresh,
732                candidate,
733            ));
734        }
735
736        for contested in self.geometric_contested_touches(frame, ball, players, &confirmed_players)
737        {
738            if let Some(player_id) = contested.player.clone() {
739                if !confirmed_players.insert(player_id) {
740                    continue;
741                }
742            }
743            touch_events.push(contested);
744        }
745
746        touch_events
747    }
748
749    fn record_recent_team_touches(&mut self, frame: usize, touch_events: &[TouchEvent]) {
750        self.recent_team_touches
751            .retain(|touch| frame.saturating_sub(touch.frame) <= GEOMETRIC_CONTEST_WINDOW_FRAMES);
752        for event in touch_events {
753            self.recent_team_touches.push(RecentTeamTouch {
754                frame: event.frame,
755                time: event.time,
756                team_is_team_0: event.team_is_team_0,
757            });
758        }
759    }
760
761    fn touch_cooldown_key(event: &TouchEvent) -> TouchCooldownKey {
762        event
763            .player
764            .clone()
765            .map(TouchCooldownKey::Player)
766            .unwrap_or(TouchCooldownKey::Team(event.team_is_team_0))
767    }
768
769    fn touch_cooldown_allows(&mut self, event: &TouchEvent) -> bool {
770        const FLOAT_EPSILON: f32 = 0.0001;
771
772        let key = Self::touch_cooldown_key(event);
773        let allowed = self.last_touch_times.get(&key).is_none_or(|last| {
774            // The rate limit dedupes repeat samples of one continuous contact
775            // (a carry or dribble). A dodge-powered touch arriving on the heels
776            // of a passive contact is a distinct mechanic (flip-reset
777            // conversion, flick), not another sample of the same contact, so it
778            // bypasses the limit. Repeated dodge touches still rate-limit
779            // against each other.
780            (event.dodge_contact && !last.dodge_contact)
781                || event.time - last.time + FLOAT_EPSILON >= TOUCH_RATE_LIMIT_SECONDS
782        });
783        if allowed {
784            self.last_touch_times.insert(
785                key,
786                LastCooldownTouch {
787                    time: event.time,
788                    dodge_contact: event.dodge_contact,
789                },
790            );
791        }
792        allowed
793    }
794
795    fn apply_touch_cooldown(&mut self, mut touch_events: Vec<TouchEvent>) -> Vec<TouchEvent> {
796        touch_events.sort_by(touch_event_chronological_ordering);
797        let mut accepted = touch_events
798            .into_iter()
799            .filter(|event| self.touch_cooldown_allows(event))
800            .collect::<Vec<_>>();
801        accepted.sort_by(touch_event_chronological_ordering);
802        accepted
803    }
804
805    pub fn update(
806        &mut self,
807        frame: &FrameInfo,
808        ball: &BallFrameState,
809        players: &PlayerFrameState,
810        events: &FrameEventsState,
811        live_play_state: &LivePlayState,
812    ) -> TouchState {
813        let touch_events = if live_play_state.counts_toward_player_motion() {
814            self.prune_recent_touch_candidates(frame.frame_number);
815            self.update_recent_touch_candidates(frame, ball, players);
816            let touch_events = self.confirmed_touch_events(frame, ball, players, events);
817            let mut touch_events = self.apply_touch_cooldown(touch_events);
818            for event in &mut touch_events {
819                event.touch_id = Some(self.next_touch_id);
820                self.next_touch_id += 1;
821            }
822            self.record_recent_team_touches(frame.frame_number, &touch_events);
823            touch_events
824        } else {
825            self.current_last_touch = None;
826            self.recent_touch_candidates.clear();
827            self.last_touch_times.clear();
828            self.recent_team_touches.clear();
829            Vec::new()
830        };
831
832        if let Some(last_touch) = primary_touch_event(&touch_events) {
833            self.current_last_touch = Some(last_touch.clone());
834        }
835        self.previous_ball_rigid_body = ball.sample().map(|sample| (sample.rigid_body, frame.time));
836
837        TouchState {
838            touch_events,
839            last_touch: self.current_last_touch.clone(),
840            last_touch_player: self
841                .current_last_touch
842                .as_ref()
843                .and_then(|touch| touch.player.clone()),
844            last_touch_team_is_team_0: self
845                .current_last_touch
846                .as_ref()
847                .map(|touch| touch.team_is_team_0),
848        }
849    }
850}
851
852#[cfg(test)]
853#[path = "touch_state_tests.rs"]
854mod tests;