Skip to main content

subtr_actor/stats/calculators/
touch.rs

1use super::*;
2
3const SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD: f32 = 320.0;
4const HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD: f32 = 900.0;
5const AERIAL_TOUCH_MIN_PLAYER_Z: f32 = AIR_DRIBBLE_MIN_PLAYER_Z;
6
7/// How long after a touch we keep watching the toucher's dodge component before
8/// giving up on associating a flip with it. The `CarComponent_Dodge`
9/// `ReplicatedActive` byte routinely replicates *after* the ball-hit it produced
10/// (the hit and the dodge-activation land on adjacent frames), so a flip-into-ball
11/// contact can be sampled on the one frame where the dodge flag has not yet
12/// flipped on. Re-checking for a brief window lets such touches be recognized as
13/// dodge contacts after the fact. Shared with the flick detector and flip-reset
14/// confirmation via [`DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS`].
15const DODGE_LAG_TOLERANCE_SECONDS: f32 = DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS;
16
17/// How long after a touch a replay-reported shot/save stat event may land and
18/// still be attributed to that touch. The counter increment routinely
19/// replicates a fraction of a second after the touch that caused it.
20const STAT_EVENT_OUTCOME_WINDOW_SECONDS: f32 = 0.75;
21
22/// How long before a goal the scoring touch may have happened and still be
23/// attributed as the shot. Covers slow rollers and screened shots while
24/// rejecting a stale cross-play touch by the same player.
25const GOAL_SHOT_ATTRIBUTION_WINDOW_SECONDS: f32 = 10.0;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum TouchKind {
29    Control,
30    MediumHit,
31    HardHit,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum TouchSurface {
36    Ground,
37    Air,
38    Wall,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum TouchDodgeState {
43    NoDodge,
44    Dodge,
45}
46
47impl TouchKind {
48    fn as_label_value(self) -> &'static str {
49        match self {
50            Self::Control => "control",
51            Self::MediumHit => "medium_hit",
52            Self::HardHit => "hard_hit",
53        }
54    }
55}
56
57impl TouchSurface {
58    fn as_label_value(self) -> &'static str {
59        match self {
60            Self::Ground => "ground",
61            Self::Air => "air",
62            Self::Wall => "wall",
63        }
64    }
65}
66
67impl TouchDodgeState {
68    fn from_dodge_active(dodge_active: bool) -> Self {
69        if dodge_active {
70            Self::Dodge
71        } else {
72            Self::NoDodge
73        }
74    }
75
76    fn as_label_value(self) -> &'static str {
77        match self {
78            Self::NoDodge => "no_dodge",
79            Self::Dodge => "dodge",
80        }
81    }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85struct TouchClassification {
86    kind: TouchKind,
87    height_band: PlayerVerticalBand,
88    surface: TouchSurface,
89    dodge_state: TouchDodgeState,
90}
91
92/// One classification tag on a touch: a `(group, value)` pair, mirroring the
93/// [`StatLabel`](crate::stats::StatLabel) `(key, value)` model the accumulators
94/// already count by. A touch carries a *set* of these instead of a fixed list
95/// of bespoke fields, so independent classifications coexist (a `boom` action
96/// and an `advance` possession outcome are separate tags, not rivals for one
97/// slot). Within a single-valued group (e.g. `kind`, `surface`, `possession`)
98/// at most one tag is present; flag groups (`contested`) are present or absent.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
100#[ts(export)]
101pub struct TouchTag {
102    pub group: String,
103    pub value: String,
104}
105
106impl TouchTag {
107    fn new(group: &str, value: &str) -> Self {
108        Self {
109            group: group.to_owned(),
110            value: value.to_owned(),
111        }
112    }
113}
114
115/// A classified ball touch, carrying its classification as a set of [`TouchTag`]s
116/// (strength kind, surface/height context, action, possession outcome, and a
117/// contested flag) rather than fixed fields.
118#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
119#[ts(export)]
120pub struct TouchClassificationEvent {
121    /// Identity of the source [`TouchEvent`](crate::TouchEvent) this
122    /// classification was derived from. Join on this instead of player + frame.
123    /// `None` only for data serialized before touch ids existed.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    #[ts(type = "number")]
126    pub touch_id: Option<u64>,
127    pub time: f32,
128    pub frame: usize,
129    pub sample_time: f32,
130    pub sample_frame: usize,
131    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
132    pub player: PlayerId,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub player_position: Option<[f32; 3]>,
135    // Ball position (uu) at the touch's sample frame: the actual point of contact
136    // on the ball's trajectory, unlike `player_position` (the car centre, up to a
137    // hitbox+ball-radius away). Diagrams placing a touch on the ball's path prefer
138    // this. Non-doc comment so ts-rs keeps the binding in sync with `player_position`.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub ball_position: Option<[f32; 3]>,
141    pub is_team_0: bool,
142    /// The touch's classification tags (group + value). See [`TouchTag`].
143    /// Groups: `kind`, `height_band`, `surface`, `dodge_state`, `reception`,
144    /// `action`, optional `possession`, and the optional `contested` flag.
145    #[serde(default)]
146    pub tags: Vec<TouchTag>,
147    #[serde(default)]
148    pub role: RoleState,
149    #[serde(default)]
150    pub play_depth: PlayDepthState,
151    pub ball_speed_change: f32,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub ball_movement: Option<TouchBallMovement>,
154}
155
156impl TouchClassificationEvent {
157    /// The always-present classification tags for a touch: `kind`,
158    /// `height_band`, `surface`, `dodge_state`, `reception`, `action`, and the
159    /// optional `contested` flag. The retroactive `possession` tag is added
160    /// later via [`Self::set_tag`], so it is not built here. Shared by the live
161    /// construction path and test fixtures so they cannot drift.
162    pub(crate) fn classification_tags(
163        kind: &str,
164        height_band: &str,
165        surface: &str,
166        dodge_state: &str,
167        action: Option<&str>,
168        first_touch: bool,
169        contested: bool,
170    ) -> Vec<TouchTag> {
171        let mut tags = vec![
172            TouchTag::new("kind", kind),
173            TouchTag::new("height_band", height_band),
174            TouchTag::new("surface", surface),
175            TouchTag::new("dodge_state", dodge_state),
176            TouchTag::new(
177                "reception",
178                if first_touch {
179                    "first_touch"
180                } else {
181                    "continuation"
182                },
183            ),
184        ];
185        if let Some(action) = action {
186            tags.push(TouchTag::new("action", action));
187        }
188        if contested {
189            tags.push(TouchTag::new("contested", "contested"));
190        }
191        tags
192    }
193
194    /// Value of the single-valued tag in `group`, if present.
195    pub fn tag(&self, group: &str) -> Option<&str> {
196        self.tags
197            .iter()
198            .find(|tag| tag.group == group)
199            .map(|tag| tag.value.as_str())
200    }
201
202    /// Whether a tag with this exact group and value is present.
203    pub fn has_tag(&self, group: &str, value: &str) -> bool {
204        self.tags
205            .iter()
206            .any(|tag| tag.group == group && tag.value == value)
207    }
208
209    /// Set (replace or insert) the single-valued tag for `group`. Used by the
210    /// retroactive upgrades (dodge-lag, possession) that revise an
211    /// already-emitted touch.
212    fn set_tag(&mut self, group: &str, value: &str) {
213        if let Some(tag) = self.tags.iter_mut().find(|tag| tag.group == group) {
214            tag.value = value.to_owned();
215        } else {
216            self.tags.push(TouchTag::new(group, value));
217        }
218    }
219}
220
221/// Ball movement produced by a touch.
222#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
223#[ts(export)]
224pub struct TouchBallMovement {
225    pub start_time: f32,
226    pub start_frame: usize,
227    pub end_time: f32,
228    pub end_frame: usize,
229    pub duration: f32,
230    pub travel_distance: f32,
231    pub advance_distance: f32,
232    pub retreat_distance: f32,
233    pub finalized: bool,
234}
235
236impl TouchBallMovement {
237    fn absorb_delta(&mut self, event: Self) {
238        self.end_time = event.end_time;
239        self.end_frame = event.end_frame;
240        self.duration += event.duration;
241        self.travel_distance += event.travel_distance;
242        self.advance_distance += event.advance_distance;
243        self.retreat_distance += event.retreat_distance;
244    }
245
246    fn finalized(mut self) -> Self {
247        self.finalized = true;
248        self
249    }
250}
251
252/// A `no_dodge` touch still within [`DODGE_LAG_TOLERANCE_SECONDS`] of being
253/// retroactively upgraded to a dodge contact, should the toucher's dodge
254/// component go active in the frames immediately following the hit.
255#[derive(Debug, Clone, PartialEq)]
256struct PendingDodgeUpgrade {
257    touch_index: usize,
258    player_id: PlayerId,
259    touch_time: f32,
260}
261
262#[derive(Debug, Clone, Default, PartialEq)]
263struct PendingFiftyFiftyMovement {
264    start_frame: usize,
265    travel_distance: f32,
266    y_delta: f32,
267}
268
269#[derive(Debug, Clone, PartialEq)]
270struct PendingTouchBallMovementCredit {
271    touch_index: usize,
272    movement: TouchBallMovement,
273}
274
275impl InFlightItem for PendingTouchBallMovementCredit {
276    fn recognition(&self) -> Recognition {
277        // A touch credit always corresponds to a real touch, so it is committed
278        // from the moment it is armed.
279        Recognition::committed(self.movement.start_time, self.movement.start_frame)
280    }
281
282    fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
283        // The credit window always closes at a boundary: the accumulated travel
284        // up to the goal / stoppage / end of replay is exactly what we keep.
285        Disposition::Finalize(FinalizeReason::Boundary(boundary))
286    }
287}
288
289/// Classifies ball touches into typed touch events.
290#[derive(Debug, Clone, Default, PartialEq)]
291pub struct TouchCalculator {
292    events: EventStream<TouchClassificationEvent>,
293    ball_movement: InFlightLedger<PendingTouchBallMovementCredit>,
294    active_touch_index_by_player: HashMap<PlayerId, usize>,
295    previous_ball_velocity: Option<glam::Vec3>,
296    previous_ball_position: Option<glam::Vec3>,
297    pending_fifty_fifty_movement: Option<PendingFiftyFiftyMovement>,
298    pending_dodge_upgrades: Vec<PendingDodgeUpgrade>,
299    intention_classifier: TouchIntentionClassifier,
300    control_follow: ControlFollowTracker,
301    shot_projection: ShotProjectionTracker,
302}
303
304/// Precedence of a touch's action tag. Retroactive upgrades may only raise this
305/// rank, never lower it, so an outcome-confirmed shot/save is never clobbered by
306/// a later weaker read and vice versa. Mirrors the at-touch action ladder in
307/// [`TouchIntentionClassifier::classify`]. `None` (no action tag) ranks lowest.
308/// `contested` and `possession` are independent tag axes and are not ranked
309/// here.
310fn action_rank(action: Option<&str>) -> u8 {
311    match action {
312        Some("save") => 5,
313        Some("shot") => 4,
314        Some("clear") => 3,
315        Some("pass") => 2,
316        Some("boom") => 1,
317        _ => 0,
318    }
319}
320
321impl TouchCalculator {
322    pub fn new() -> Self {
323        Self::default()
324    }
325
326    pub fn events(&self) -> &[TouchClassificationEvent] {
327        self.events.all()
328    }
329
330    pub fn new_events(&self) -> &[TouchClassificationEvent] {
331        self.events.new_events()
332    }
333
334    pub fn flush_pending_ball_movement_credit(&mut self) {
335        let finalized = self.ball_movement.finalize_all(FinalizeReason::Completed);
336        self.write_finalized_ball_movement(finalized);
337    }
338
339    /// Finalize any pending ball-movement credit at end of stream. Routed
340    /// through the ledger so the boundary is handled uniformly and can't be
341    /// forgotten.
342    pub fn finish(&mut self) {
343        let finalized = self.ball_movement.finish();
344        self.write_finalized_ball_movement(finalized);
345        let resolution = self.control_follow.flush();
346        self.apply_possession_resolution(resolution);
347        let shot_resolution = self.shot_projection.flush();
348        self.apply_shot_projection_resolution(shot_resolution);
349    }
350
351    fn finalize_ball_movement_at_boundary(&mut self, boundary: Boundary) {
352        let finalized = self.ball_movement.apply_boundary(boundary);
353        self.write_finalized_ball_movement(finalized);
354    }
355
356    fn write_finalized_ball_movement(
357        &mut self,
358        finalized: Vec<(PendingTouchBallMovementCredit, FinalizeReason)>,
359    ) {
360        for (pending, _reason) in finalized {
361            if let Some(event) = self.events.get_mut(pending.touch_index) {
362                event.ball_movement = Some(pending.movement.finalized());
363            }
364        }
365    }
366
367    fn ball_speed_change(
368        frame: &FrameInfo,
369        ball: &BallFrameState,
370        previous_ball_velocity: Option<glam::Vec3>,
371    ) -> f32 {
372        const BALL_GRAVITY_Z: f32 = -650.0;
373
374        let Some(ball) = ball.sample() else {
375            return 0.0;
376        };
377        let Some(previous_ball_velocity) = previous_ball_velocity else {
378            return 0.0;
379        };
380
381        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
382        let residual_linear_impulse =
383            ball.velocity() - previous_ball_velocity - expected_linear_delta;
384        residual_linear_impulse.length()
385    }
386
387    fn classify_touch(
388        height_band: PlayerVerticalBand,
389        surface: TouchSurface,
390        dodge_state: TouchDodgeState,
391        ball_speed_change: f32,
392        controlled_touch_kind: Option<BallCarryKind>,
393    ) -> TouchClassification {
394        let kind = if controlled_touch_kind.is_some()
395            || ball_speed_change <= SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD
396        {
397            TouchKind::Control
398        } else if ball_speed_change < HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
399            TouchKind::MediumHit
400        } else {
401            TouchKind::HardHit
402        };
403
404        TouchClassification {
405            kind,
406            height_band,
407            surface,
408            dodge_state,
409        }
410    }
411
412    fn height_band_for_touch(sample: Option<&PlayerVerticalSample>) -> PlayerVerticalBand {
413        let Some(sample) = sample else {
414            return PlayerVerticalBand::Ground;
415        };
416
417        if sample.height < AERIAL_TOUCH_MIN_PLAYER_Z {
418            PlayerVerticalBand::Ground
419        } else {
420            sample.band
421        }
422    }
423
424    fn surface_for_touch(
425        player_position: Option<glam::Vec3>,
426        height_band: PlayerVerticalBand,
427    ) -> TouchSurface {
428        if player_position.is_some_and(player_is_on_wall) {
429            TouchSurface::Wall
430        } else if height_band.is_grounded() {
431            TouchSurface::Ground
432        } else {
433            TouchSurface::Air
434        }
435    }
436
437    fn controlled_touch_kind(
438        ball: &BallFrameState,
439        players: &PlayerFrameState,
440        player_id: &PlayerId,
441    ) -> Option<BallCarryKind> {
442        let ball = ball.sample()?;
443        players
444            .players
445            .iter()
446            .find(|player| &player.player_id == player_id)
447            .and_then(|player| {
448                BallCarryCalculator::carry_frame_sample(player, ball).map(|sample| sample.kind)
449            })
450    }
451
452    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
453        players
454            .players
455            .iter()
456            .find(|player| &player.player_id == player_id)
457            .and_then(PlayerSample::position)
458    }
459
460    fn player_dodge_active(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
461        players
462            .players
463            .iter()
464            .find(|player| &player.player_id == player_id)
465            .is_some_and(|player| player.dodge_active)
466    }
467
468    fn teammate_positions(
469        players: &PlayerFrameState,
470        player_id: &PlayerId,
471        is_team_0: bool,
472    ) -> Vec<glam::Vec3> {
473        players
474            .players
475            .iter()
476            .filter(|player| player.is_team_0 == is_team_0 && &player.player_id != player_id)
477            .filter_map(PlayerSample::position)
478            .collect()
479    }
480
481    #[allow(clippy::too_many_arguments)]
482    fn apply_touch_events(
483        &mut self,
484        frame: &FrameInfo,
485        ball: &BallFrameState,
486        players: &PlayerFrameState,
487        vertical_state: &PlayerVerticalState,
488        rotation: &RotationCalculator,
489        touch_events: &[TouchEvent],
490        fifty_fifty_state: &FiftyFiftyState,
491    ) {
492        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
493        let contested_frame = touch_events.iter().any(|touch| touch.team_is_team_0)
494            && touch_events.iter().any(|touch| !touch.team_is_team_0);
495
496        for touch_event in touch_events {
497            let Some(player_id) = touch_event.player.as_ref() else {
498                continue;
499            };
500            let height_band = Self::height_band_for_touch(vertical_state.sample(player_id));
501            let surface =
502                Self::surface_for_touch(Self::player_position(players, player_id), height_band);
503            let dodge_state = TouchDodgeState::from_dodge_active(
504                touch_event.dodge_contact || Self::player_dodge_active(players, player_id),
505            );
506            let controlled_touch_kind = Self::controlled_touch_kind(ball, players, player_id);
507            let (role, play_depth) = rotation.current_role_and_depth(player_id);
508            let classification = Self::classify_touch(
509                height_band,
510                surface,
511                dodge_state,
512                ball_speed_change,
513                controlled_touch_kind,
514            );
515            let contested = contested_frame
516                || fifty_fifty_state
517                    .active_event
518                    .as_ref()
519                    .is_some_and(|active| {
520                        fifty_fifty_involves_player(active, player_id, touch_event.team_is_team_0)
521                    });
522            let teammate_positions =
523                Self::teammate_positions(players, player_id, touch_event.team_is_team_0);
524            let possession_resolution = self
525                .control_follow
526                .observe_touch(player_id, touch_event.time);
527            self.apply_possession_resolution(possession_resolution);
528            // This new touch ends the previous touch's free flight; resolve its
529            // shot projection from the settled trajectory shortly before now.
530            let shot_resolution = self.shot_projection.observe_touch(touch_event.time);
531            self.apply_shot_projection_resolution(shot_resolution);
532            let resolution = self.intention_classifier.classify(
533                touch_event,
534                player_id,
535                &TouchIntentionFrameContext {
536                    ball_position: ball.position(),
537                    ball_velocity: ball.velocity(),
538                    previous_ball_position: self.previous_ball_position,
539                    previous_ball_velocity: self.previous_ball_velocity,
540                    teammate_positions: &teammate_positions,
541                    contested,
542                },
543            );
544            let tags = TouchClassificationEvent::classification_tags(
545                classification.kind.as_label_value(),
546                classification.height_band.as_label().value,
547                classification.surface.as_label_value(),
548                classification.dodge_state.as_label_value(),
549                resolution.action.map(TouchAction::as_label_value),
550                resolution.first_touch,
551                resolution.contested,
552            );
553            let event = TouchClassificationEvent {
554                touch_id: touch_event.touch_id,
555                time: touch_event.time,
556                frame: touch_event.frame,
557                sample_time: frame.time,
558                sample_frame: frame.frame_number,
559                player: player_id.clone(),
560                player_position: touch_event
561                    .player_position
562                    .map(|position| vec_to_glam(&position).to_array())
563                    .or_else(|| {
564                        Self::player_position(players, player_id)
565                            .map(|position| position.to_array())
566                    }),
567                ball_position: ball.position().map(|position| position.to_array()),
568                is_team_0: touch_event.team_is_team_0,
569                tags,
570                role,
571                play_depth,
572                ball_speed_change,
573                ball_movement: None,
574            };
575            let touch_index = self.events.len();
576            self.events.push(event);
577            self.active_touch_index_by_player
578                .insert(player_id.clone(), touch_index);
579            // The dodge byte often lags the hit by a frame or two; if this touch
580            // looked dodge-less, keep watching the toucher's dodge component so a
581            // flip-into-ball contact still gets recognized once it activates.
582            if matches!(classification.dodge_state, TouchDodgeState::NoDodge) {
583                self.pending_dodge_upgrades.push(PendingDodgeUpgrade {
584                    touch_index,
585                    player_id: player_id.clone(),
586                    touch_time: touch_event.time,
587                });
588            }
589            // Watch the possession outcome for the looser, recoverable touches —
590            // passes, clears, booms, and action-less loose pokes — but not shots
591            // or saves, which are not possession plays.
592            if resolution
593                .action
594                .is_none_or(TouchAction::watches_possession)
595            {
596                self.control_follow
597                    .open(touch_index, player_id, touch_event.time);
598            }
599            // Every touch is a potential shot whose touch-frame trajectory was
600            // sampled before the impulse finished landing; watch its free flight
601            // and upgrade from the settled trajectory if it turns out goalward.
602            self.shot_projection
603                .open(touch_index, touch_event.team_is_team_0, touch_event.time);
604        }
605    }
606
607    /// Apply a closed control-follow window: tag the touch's possession outcome
608    /// (control or advance) when the toucher kept the ball (stayed with it, or
609    /// won the follow-up touch). The action tag is left untouched, so a
610    /// recovered boom stays a boom.
611    fn apply_possession_resolution(&mut self, resolution: Option<PossessionResolution>) {
612        let Some(resolution) = resolution else {
613            return;
614        };
615        let Some(possession) = resolution.possession else {
616            return;
617        };
618        if let Some(event) = self.events.get_mut(resolution.touch_index) {
619            event.set_tag("possession", possession.as_label_value());
620        }
621    }
622
623    /// Apply a closed shot-projection window: a settled free-flight trajectory
624    /// that crosses the opponent goal mouth upgrades the touch's action to a
625    /// shot. Goes through the action-rank gate, so it never rewrites a higher
626    /// action (an outcome-confirmed shot, or a save). `contested` is an
627    /// independent tag, so a contested touch that flew in still becomes a shot.
628    fn apply_shot_projection_resolution(&mut self, resolution: Option<ShotProjectionResolution>) {
629        let Some(resolution) = resolution else {
630            return;
631        };
632        if !resolution.is_shot {
633            return;
634        }
635        self.raise_action(resolution.touch_index, TouchAction::Shot);
636    }
637
638    /// Raise a touch's action tag to `target` only when `target` outranks the
639    /// current action, so retroactive upgrades never downgrade a touch's action.
640    fn raise_action(&mut self, touch_index: usize, target: TouchAction) {
641        if let Some(event) = self.events.get_mut(touch_index) {
642            let target_label = target.as_label_value();
643            if action_rank(Some(target_label)) > action_rank(event.tag("action")) {
644                event.set_tag("action", target_label);
645            }
646        }
647    }
648
649    /// Promote the toucher's most recent touch to `target` from an
650    /// outcome-authoritative signal (a replay shot/save stat event, or a scored
651    /// goal), matching by player within `window` of `signal_time`.
652    fn confirm_outcome_action(
653        &mut self,
654        player: &PlayerId,
655        signal_time: f32,
656        window: f32,
657        target: TouchAction,
658    ) {
659        let Some(&touch_index) = self.active_touch_index_by_player.get(player) else {
660            return;
661        };
662        let Some(event) = self.events.get(touch_index) else {
663            return;
664        };
665        if signal_time < event.time || signal_time - event.time > window {
666            return;
667        }
668        self.raise_action(touch_index, target);
669    }
670
671    /// Upgrade the scorer's most recent touch to a shot for each goal observed
672    /// this frame. The touch that scores is a shot regardless of what its
673    /// at-touch trajectory projection read.
674    fn confirm_goal_shots(&mut self, events_state: &FrameEventsState) {
675        for goal in &events_state.goal_events {
676            let Some(scorer) = goal.player.as_ref() else {
677                continue;
678            };
679            self.confirm_outcome_action(
680                scorer,
681                goal.time,
682                GOAL_SHOT_ATTRIBUTION_WINDOW_SECONDS,
683                TouchAction::Shot,
684            );
685        }
686    }
687
688    /// Attach replay-reported shot/save stat events that land *after* the touch
689    /// they describe. The classifier matches stat events that precede the touch;
690    /// this covers the common case where the counter increment replicates a
691    /// fraction of a second after the touch (so the touch was already emitted).
692    fn confirm_stat_event_actions(&mut self, events_state: &FrameEventsState) {
693        for stat in &events_state.player_stat_events {
694            let target = match stat.kind {
695                PlayerStatEventKind::Shot => TouchAction::Shot,
696                PlayerStatEventKind::Save => TouchAction::Save,
697                PlayerStatEventKind::Assist => continue,
698            };
699            self.confirm_outcome_action(
700                &stat.player,
701                stat.time,
702                STAT_EVENT_OUTCOME_WINDOW_SECONDS,
703                target,
704            );
705        }
706    }
707
708    /// Re-examine recent `no_dodge` touches against this frame's dodge state.
709    /// The dodge component's active byte frequently replicates a frame or two
710    /// after the ball-hit it caused, so a flip-into-ball contact can be sampled
711    /// on the one frame where the flag has not yet flipped on. Any pending touch
712    /// whose toucher is now dodging within [`DODGE_LAG_TOLERANCE_SECONDS`] is
713    /// upgraded to a dodge contact; entries that age out are dropped.
714    fn advance_dodge_upgrades(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
715        if self.pending_dodge_upgrades.is_empty() {
716            return;
717        }
718        let mut resolved = Vec::new();
719        self.pending_dodge_upgrades.retain(|pending| {
720            if frame.time - pending.touch_time > DODGE_LAG_TOLERANCE_SECONDS {
721                return false;
722            }
723            if Self::player_dodge_active(players, &pending.player_id) {
724                resolved.push(pending.touch_index);
725                return false;
726            }
727            true
728        });
729        for touch_index in resolved {
730            if let Some(event) = self.events.get_mut(touch_index) {
731                event.set_tag("dodge_state", TouchDodgeState::Dodge.as_label_value());
732            }
733        }
734    }
735
736    /// Feed this frame's ball and toucher samples to any open control-follow
737    /// window and apply its resolution once it ages out.
738    fn advance_control_follow(
739        &mut self,
740        frame: &FrameInfo,
741        ball: &BallFrameState,
742        players: &PlayerFrameState,
743    ) {
744        let Some(window_player) = self.control_follow.window_player().cloned() else {
745            return;
746        };
747        let player_sample = players
748            .players
749            .iter()
750            .find(|player| player.player_id == window_player);
751        let resolution = self.control_follow.advance(
752            frame,
753            ball.position(),
754            ball.velocity(),
755            player_sample.and_then(PlayerSample::position),
756            player_sample.and_then(PlayerSample::velocity),
757        );
758        self.apply_possession_resolution(resolution);
759    }
760
761    /// Feed this frame's free-flight ball sample to any open shot-projection
762    /// window and apply its resolution once it ages out.
763    fn advance_shot_projection(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
764        let resolution = self
765            .shot_projection
766            .advance(frame, ball.position(), ball.velocity());
767        self.apply_shot_projection_resolution(resolution);
768    }
769
770    #[allow(clippy::too_many_arguments)]
771    fn apply_ball_movement_credit(
772        &mut self,
773        frame: usize,
774        time: f32,
775        duration: f32,
776        player_id: &PlayerId,
777        team_is_team_0: bool,
778        player_position: Option<[f32; 3]>,
779        delta: glam::Vec3,
780        travel_distance: f32,
781    ) {
782        let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
783        let advance_distance = delta.y * team_forward_sign;
784        let (advance_distance, retreat_distance) = if advance_distance >= 0.0 {
785            (advance_distance, 0.0)
786        } else {
787            (0.0, -advance_distance)
788        };
789        let movement = TouchBallMovement {
790            start_time: time,
791            start_frame: frame,
792            end_time: time,
793            end_frame: frame,
794            duration,
795            travel_distance,
796            advance_distance,
797            retreat_distance,
798            finalized: false,
799        };
800        self.record_ball_movement_credit(player_id, player_position, movement);
801    }
802
803    fn record_ball_movement_credit(
804        &mut self,
805        player_id: &PlayerId,
806        player_position: Option<[f32; 3]>,
807        movement: TouchBallMovement,
808    ) {
809        let Some(&touch_index) = self.active_touch_index_by_player.get(player_id) else {
810            return;
811        };
812        let pending_index = self
813            .ball_movement
814            .in_flight()
815            .first()
816            .map(|pending| pending.touch_index);
817
818        if pending_index == Some(touch_index) {
819            // Same touch still in flight: fold this frame's travel into it.
820            let merged = {
821                let pending = self
822                    .ball_movement
823                    .in_flight_mut()
824                    .first_mut()
825                    .expect("pending credit present");
826                pending.movement.absorb_delta(movement);
827                pending.movement.clone()
828            };
829            if let Some(event) = self.events.get_mut(touch_index) {
830                event.player_position = player_position.or(event.player_position);
831                event.ball_movement = Some(merged);
832            }
833            return;
834        }
835
836        // A different touch (or none) is in flight: supersede the old credit and
837        // arm a fresh one for this touch.
838        if pending_index.is_some() {
839            let finalized = self.ball_movement.finalize_all(FinalizeReason::Superseded);
840            self.write_finalized_ball_movement(finalized);
841        }
842        if let Some(event) = self.events.get_mut(touch_index) {
843            event.player_position = player_position.or(event.player_position);
844            event.ball_movement = Some(movement.clone());
845        }
846        self.ball_movement.arm(PendingTouchBallMovementCredit {
847            touch_index,
848            movement,
849        });
850    }
851
852    fn resolved_fifty_fifty_winner(event: &FiftyFiftyEvent) -> Option<(&PlayerId, bool)> {
853        let winning_team_is_team_0 = event.winning_team_is_team_0?;
854        let player = if winning_team_is_team_0 {
855            event.team_zero_player.as_ref()
856        } else {
857            event.team_one_player.as_ref()
858        }?;
859        Some((player, winning_team_is_team_0))
860    }
861
862    fn buffer_fifty_fifty_movement(
863        &mut self,
864        start_frame: usize,
865        delta: glam::Vec3,
866        travel_distance: f32,
867    ) {
868        let pending = self
869            .pending_fifty_fifty_movement
870            .get_or_insert(PendingFiftyFiftyMovement {
871                start_frame,
872                travel_distance: 0.0,
873                y_delta: 0.0,
874            });
875        if pending.start_frame != start_frame {
876            *pending = PendingFiftyFiftyMovement {
877                start_frame,
878                travel_distance: 0.0,
879                y_delta: 0.0,
880            };
881        }
882        pending.travel_distance += travel_distance;
883        pending.y_delta += delta.y;
884    }
885
886    fn flush_fifty_fifty_movement(&mut self, event: &FiftyFiftyEvent) {
887        let Some(pending) = self.pending_fifty_fifty_movement.take() else {
888            return;
889        };
890        if pending.start_frame != event.start_frame {
891            return;
892        }
893        let Some((player_id, team_is_team_0)) = Self::resolved_fifty_fifty_winner(event) else {
894            return;
895        };
896
897        let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
898        let advance_distance = pending.y_delta * team_forward_sign;
899        let (advance_distance, retreat_distance) = if advance_distance >= 0.0 {
900            (advance_distance, 0.0)
901        } else {
902            (0.0, -advance_distance)
903        };
904        let movement = TouchBallMovement {
905            start_time: event.resolve_time,
906            start_frame: event.resolve_frame,
907            end_time: event.resolve_time,
908            end_frame: event.resolve_frame,
909            duration: 0.0,
910            travel_distance: pending.travel_distance,
911            advance_distance,
912            retreat_distance,
913            finalized: false,
914        };
915        self.flush_pending_ball_movement_credit();
916        self.record_ball_movement_credit(
917            player_id,
918            if team_is_team_0 {
919                Some(event.team_zero_position)
920            } else {
921                Some(event.team_one_position)
922            },
923            movement,
924        );
925        self.flush_pending_ball_movement_credit();
926    }
927
928    fn credit_ball_movement(
929        &mut self,
930        frame: &FrameInfo,
931        ball: &BallFrameState,
932        players: &PlayerFrameState,
933        possession_state: &PossessionState,
934        fifty_fifty_state: &FiftyFiftyState,
935        live_play_state: &LivePlayState,
936    ) {
937        let current_ball_position = ball.position();
938        if !live_play_state.is_live_play {
939            self.finalize_ball_movement_at_boundary(Boundary::LivePlayEnded);
940            self.previous_ball_position = current_ball_position;
941            self.pending_fifty_fifty_movement = None;
942            return;
943        }
944
945        let Some(current_ball_position) = current_ball_position else {
946            self.flush_pending_ball_movement_credit();
947            self.previous_ball_position = None;
948            self.pending_fifty_fifty_movement = None;
949            return;
950        };
951        let Some(previous_ball_position) = self.previous_ball_position else {
952            self.previous_ball_position = Some(current_ball_position);
953            return;
954        };
955        self.previous_ball_position = Some(current_ball_position);
956
957        let delta = current_ball_position - previous_ball_position;
958        let travel_distance = delta.length();
959        if travel_distance <= f32::EPSILON {
960            return;
961        }
962
963        if let Some(active_event) = fifty_fifty_state.active_event.as_ref() {
964            self.flush_pending_ball_movement_credit();
965            self.buffer_fifty_fifty_movement(active_event.start_frame, delta, travel_distance);
966            return;
967        }
968
969        if let Some(event) = fifty_fifty_state.resolved_events.last() {
970            self.buffer_fifty_fifty_movement(event.start_frame, delta, travel_distance);
971            self.flush_fifty_fifty_movement(event);
972            return;
973        }
974
975        self.pending_fifty_fifty_movement = None;
976
977        let (Some(player_id), Some(team_is_team_0)) = (
978            possession_state.active_player_before_sample.as_ref(),
979            possession_state.active_team_before_sample,
980        ) else {
981            self.flush_pending_ball_movement_credit();
982            return;
983        };
984
985        self.apply_ball_movement_credit(
986            frame.frame_number,
987            frame.time,
988            frame.dt,
989            player_id,
990            team_is_team_0,
991            players.player_position(player_id),
992            delta,
993            travel_distance,
994        );
995    }
996
997    #[allow(clippy::too_many_arguments)]
998    pub fn update(
999        &mut self,
1000        frame: &FrameInfo,
1001        ball: &BallFrameState,
1002        players: &PlayerFrameState,
1003        vertical_state: &PlayerVerticalState,
1004        rotation: &RotationCalculator,
1005        touch_state: &TouchState,
1006        possession_state: &PossessionState,
1007        fifty_fifty_state: &FiftyFiftyState,
1008        events_state: &FrameEventsState,
1009        live_play_state: &LivePlayState,
1010    ) -> SubtrActorResult<()> {
1011        self.events.begin_update();
1012        // A scored goal makes the scorer's most recent touch a shot regardless
1013        // of trajectory. Resolve it before any live-play boundary reset, since
1014        // the goal frame is often the frame play stops.
1015        self.confirm_goal_shots(events_state);
1016        if !live_play_state.is_live_play {
1017            self.finalize_ball_movement_at_boundary(Boundary::LivePlayEnded);
1018            self.previous_ball_velocity = ball.velocity();
1019            self.previous_ball_position = ball.position();
1020            self.pending_fifty_fifty_movement = None;
1021            self.pending_dodge_upgrades.clear();
1022            self.intention_classifier.reset();
1023            let resolution = self.control_follow.flush();
1024            self.apply_possession_resolution(resolution);
1025            let shot_resolution = self.shot_projection.flush();
1026            self.apply_shot_projection_resolution(shot_resolution);
1027            return Ok(());
1028        }
1029        self.intention_classifier
1030            .begin_frame(frame, &events_state.player_stat_events);
1031        self.apply_touch_events(
1032            frame,
1033            ball,
1034            players,
1035            vertical_state,
1036            rotation,
1037            &touch_state.touch_events,
1038            fifty_fifty_state,
1039        );
1040        // Replay shot/save stat events that land after their touch (the common
1041        // case) are attached here; the classifier handles ones that precede it.
1042        self.confirm_stat_event_actions(events_state);
1043        self.advance_dodge_upgrades(frame, players);
1044        self.advance_control_follow(frame, ball, players);
1045        self.advance_shot_projection(frame, ball);
1046        self.credit_ball_movement(
1047            frame,
1048            ball,
1049            players,
1050            possession_state,
1051            fifty_fifty_state,
1052            live_play_state,
1053        );
1054        self.previous_ball_velocity = ball.velocity();
1055
1056        Ok(())
1057    }
1058}
1059
1060#[cfg(test)]
1061#[path = "touch_tests.rs"]
1062mod tests;