Skip to main content

subtr_actor/stats/calculators/
touch_intention.rs

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;
6/// A post-touch trajectory must cross the opponent goal mouth within this many
7/// seconds for the touch to read as a shot.
8const SHOT_MAX_TIME_TO_GOAL_SECONDS: f32 = 2.5;
9const SHOT_MIN_BALL_SPEED: f32 = 1000.0;
10/// The pre-touch trajectory must have been crossing the toucher's own goal
11/// mouth within this many seconds for the touch to read as a save.
12const SAVE_MAX_TIME_TO_GOAL_SECONDS: f32 = 2.0;
13const SAVE_MIN_INBOUND_BALL_SPEED: f32 = 250.0;
14/// Window for matching a replay-reported shot/save stat event to a touch. Stat
15/// events are matched looking backward only; the touch sample itself can lag
16/// the stat event by a few frames because of touch-candidate scoring.
17const STAT_EVENT_MATCH_WINDOW_SECONDS: f32 = 0.75;
18/// Clears must start inside the toucher's defensive third.
19const 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;
22/// A boom is a hard hit sent a long way downfield into space. It only gets
23/// evaluated after shot/clear/pass fail, so the remaining gate is "fast and
24/// pointed toward the opponent half" — distinguishing a deliberate big hit from
25/// a soft loose-ball poke (neutral).
26const 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;
33/// A touch starts a new reception (a "first touch") when the previous touch by
34/// anyone was either by a different player or this long ago.
35const FIRST_TOUCH_RESET_SECONDS: f32 = 2.5;
36/// How long after a touch the control-follow window watches whether the
37/// toucher stays with the ball.
38const CONTROL_FOLLOW_WINDOW_SECONDS: f32 = 1.25;
39/// A follow frame counts as controlled only while the toucher is at most this
40/// far from the ball.
41const CONTROL_FOLLOW_MAX_DISTANCE: f32 = 600.0;
42/// A follow frame counts as controlled only while the toucher's velocity
43/// roughly matches the ball's.
44const CONTROL_FOLLOW_MAX_RELATIVE_SPEED: f32 = 800.0;
45/// Minimum follow time before a window can resolve as control on the
46/// stay-close criterion, so a window cut short (goal, stoppage) keeps its
47/// provisional intention instead of fake-confirming control.
48const CONTROL_FOLLOW_MIN_TRACKED_SECONDS: f32 = 0.4;
49/// Fraction of the tracked follow time that must be controlled for the touch
50/// to resolve as control without a follow-up touch.
51const CONTROL_FOLLOW_MIN_CONTROLLED_FRACTION: f32 = 0.7;
52/// In the same-player follow-up path, how far the ball must get from the
53/// toucher at some point in the window for the touch to read as an *advance*
54/// (the ball was played into space and recovered) rather than *control* (the
55/// ball was kept close). Comfortably above [`CONTROL_FOLLOW_MAX_DISTANCE`] so a
56/// brief bobble out of the control radius during a tight dribble still resolves
57/// as control, not advance.
58const ADVANCE_MIN_PEAK_DISTANCE: f32 = 900.0;
59
60/// What a touch was trying to *do* with the ball, read at contact time.
61///
62/// This is the touch's action axis: it sits alongside the orthogonal
63/// `possession` axis ([`Possession`], assigned retroactively) and the
64/// `contested` flag, rather than competing with them in one slot. A touch can
65/// therefore be both `Boom` (action) and [`Possession::Advance`] (outcome) — a
66/// dump-in the player chases down stays a boom.
67///
68/// Actions are mutually exclusive; overlaps are resolved by a precedence ladder
69/// (see [`TouchIntentionClassifier::classify`]). There is no "nothing" action:
70/// a touch with no recognized action simply has no action tag, rather than a
71/// catch-all value. Contested and first-touch context is preserved separately
72/// on [`TouchActionResolution`].
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum TouchAction {
75    Shot,
76    Save,
77    Clear,
78    /// A hard hit sent a long way downfield into space (not toward a teammate,
79    /// not a defensive-third clear, not on goal). Booms count as booms even
80    /// when the hitter recovers them — recovery is recorded on the orthogonal
81    /// [`Possession`] axis.
82    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    /// Whether a follow window should watch this action for a possession
98    /// outcome. Shots and saves are not possession plays, so a shooter who
99    /// happens to recover their own shot is not credited with control/advance;
100    /// the looser, recoverable actions (pass, clear, boom) are. An action-less
101    /// touch (a loose poke or soft dribble touch) is watched too; callers gate
102    /// on `action.is_none_or(TouchAction::watches_possession)`.
103    pub(crate) fn watches_possession(self) -> bool {
104        matches!(self, Self::Pass | Self::Clear | Self::Boom)
105    }
106}
107
108/// What *happened to possession* after a touch, assigned retroactively by
109/// [`ControlFollowTracker`] once the outcome is known. Orthogonal to
110/// [`TouchAction`]: it answers "did the toucher keep the ball?", independent of
111/// what the touch was trying to do.
112///
113/// - `Control` — the ball is kept close. Either the toucher stays with it
114///   (close and speed-matched) or wins the follow-up touch without the ball
115///   ever leaving the control radius.
116/// - `Advance` — the ball is played into space (it leaves the control radius)
117///   and the same toucher still wins the next touch. The follow-up touch is the
118///   evidence they got to it before anyone else: a self-pass into space they
119///   knew they would win.
120#[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/// The resolved touch intention with supporting context.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct TouchActionResolution {
138    /// The recognized action, or `None` when the touch has no notable action
139    /// (it is described by its other tags — kind, possession, etc. — instead).
140    pub action: Option<TouchAction>,
141    pub first_touch: bool,
142    pub contested: bool,
143}
144
145/// Per-frame context a touch intention classification draws on.
146///
147/// Ball state is split into pre-touch (previous frame) and post-touch (current
148/// frame) samples: saves read the inbound trajectory, while shots, clears, and
149/// passes read the outbound one.
150pub 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    /// Positions of the toucher's teammates (excluding the toucher).
156    pub teammate_positions: &'a [glam::Vec3],
157    pub contested: bool,
158}
159
160/// The reception currently in progress: who most recently took clean
161/// possession of the ball, and when the ball was last touched during it.
162#[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/// Stateful helper that classifies the intention of each touch.
176///
177/// Holds the small amount of cross-frame state classification needs: the
178/// reception in progress (for first-touch detection) and a short backward
179/// window of replay-reported shot/save stat events (for replay-confirmed
180/// classification).
181#[derive(Debug, Clone, Default, PartialEq)]
182pub struct TouchIntentionClassifier {
183    reception: Option<Reception>,
184    recent_stat_events: VecDeque<RecentStatEvent>,
185}
186
187impl TouchIntentionClassifier {
188    /// Clear cross-frame state at a live-play boundary so each kickoff starts
189    /// a fresh reception sequence.
190    pub fn reset(&mut self) {
191        self.reception = None;
192        self.recent_stat_events.clear();
193    }
194
195    /// Ingest this frame's replay-reported stat events and drop ones that have
196    /// aged out of the matching window.
197    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    /// Classify one touch's action and advance first-touch tracking.
220    ///
221    /// Touches must be supplied in chronological order. The precedence ladder:
222    /// replay-confirmed saves and shots first (game-authoritative), then
223    /// trajectory-based saves, shots, clears out of the defensive third, booms
224    /// downfield into space, and passes led toward a teammate. A touch matching
225    /// none of these has no action (`None`) rather than a catch-all value.
226    /// `contested` is not a rung — it is reported on the resolution as an
227    /// independent flag, so a contested touch keeps its real action (a contested
228    /// shot stays a shot). The `possession` axis is likewise independent: a
229    /// [`ControlFollowTracker`] window may later record control/advance on a
230    /// pass/clear/boom or action-less touch without changing its action.
231    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    /// Advance reception tracking past this touch.
280    ///
281    /// Uncontested touches claim the reception. Contested touches refresh a
282    /// still-fresh reception without transferring it, so a 50/50 interruption
283    /// does not break the original toucher's continuation; the challenger only
284    /// claims the reception once they get a clean touch of their own.
285    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    /// A hard hit pointed downfield into space. Evaluated only after shot,
380    /// clear, and pass have been ruled out, so this captures the deliberate
381    /// "boom it forward / dump it in" hit that isn't on goal, isn't a defensive
382    /// clear, and isn't aimed at a teammate.
383    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/// Outcome of a closed control-follow window. `touch_index` addresses the
397/// touch event whose `possession` tag should be set; `possession` is the tag to
398/// apply, or `None` when the window resolved without confirming possession.
399#[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    /// Greatest toucher-to-ball distance seen so far in the window (only frames
413    /// with both positions known count). Distinguishes an advance — the ball
414    /// played into space — from control, where it stays within reach.
415    max_ball_distance: f32,
416}
417
418impl ControlFollowWindow {
419    /// Resolve on the stay-close criterion: most of the tracked follow time
420    /// had the toucher close to the ball and roughly matching its velocity.
421    /// Stay-close resolutions are always `Control` — the ball never left the
422    /// toucher, so this path never yields an advance.
423    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    /// Resolve on a same-player follow-up touch: the toucher won the next touch.
434    /// Whether that reads as control or advance turns on how far the ball got
435    /// from them in between — kept close is control, played into space is an
436    /// advance.
437    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/// Watches the window after a touch to decide, by outcome, whether the touch
451/// was a control touch: did the toucher stay with the ball (close and
452/// speed-matched) or earn the follow-up touch? Resolutions are applied
453/// retroactively to the already-emitted touch event, mirroring how
454/// ball-movement credit is finalized.
455///
456/// At most one window is open at a time: any new touch closes the previous
457/// window, so the tracker never accumulates state.
458#[derive(Debug, Clone, Default, PartialEq)]
459pub struct ControlFollowTracker {
460    window: Option<ControlFollowWindow>,
461}
462
463impl ControlFollowTracker {
464    /// The player whose follow window is open, so the caller can supply that
465    /// player's frame sample to [`Self::advance`].
466    pub fn window_player(&self) -> Option<&PlayerId> {
467        self.window.as_ref().map(|window| &window.player)
468    }
469
470    /// Open a follow window for a just-emitted touch event. Only call this for
471    /// touches whose action watches the possession axis (pass/neutral/clear/boom).
472    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    /// Resolve any open window against a new touch. A follow-up touch by the
484    /// same player within the window confirms possession directly (the touch
485    /// enabled a follow-up) — control if the ball stayed close, advance if it
486    /// was played into space first; a touch by anyone else, or a late
487    /// follow-up, closes the window on the stay-close criterion.
488    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    /// Accumulate one frame of follow data for the open window and resolve it
502    /// once it ages out. Missing ball or player data counts as uncontrolled
503    /// time (a demolished toucher is not in control of anything).
504    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    /// Close any open window immediately (live-play boundary or end of
545    /// replay) on the stay-close criterion.
546    pub fn flush(&mut self) -> Option<PossessionResolution> {
547        self.window
548            .take()
549            .map(|window| window.stay_close_resolution())
550    }
551}
552
553/// A free-flight sample must be at least this far past the touch before its
554/// projection is trusted. The dodge/flick impulse is delivered over several
555/// frames, so the touch frame itself is the *least* reliable sample of where
556/// the ball is actually headed; we wait for the trajectory to settle.
557const SHOT_PROJECTION_MIN_FREE_FLIGHT_SECONDS: f32 = 0.06;
558
559/// When a new touch ends free flight, samples within this many seconds of that
560/// touch are ignored. The incoming touch's impulse can start moving the ball a
561/// frame or two before its touch marker is detected (the same replication lag
562/// as the dodge byte), so the very last free-flight samples may already be
563/// contaminated by the next touch. Resolving from a few frames earlier keeps
564/// the read on the ball's own settled trajectory.
565const SHOT_PROJECTION_NEXT_TOUCH_GUARD_SECONDS: f32 = 0.12;
566
567/// Outcome of a closed shot-projection window. `touch_index` addresses the
568/// touch event to upgrade to a shot when `is_shot` is true.
569#[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    /// Recent free-flight samples, oldest first. Bounded to the look-back the
588    /// next-touch guard needs, so it never grows with window length.
589    samples: VecDeque<ShotProjectionSample>,
590}
591
592impl ShotProjectionWindow {
593    fn record(&mut self, sample: ShotProjectionSample) {
594        // Retain enough history to look back past the next-touch guard, plus a
595        // small margin, and no more.
596        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    /// Resolve from the most recent sample. Used when free flight ends at a
619    /// boundary (goal / stoppage / end of replay): nothing perturbs the ball, so
620    /// the last sample is the cleanest read of where it was headed.
621    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    /// Resolve from the most recent sample at least the guard interval before
633    /// `close_time`. Used when a new touch ends free flight, so the read sits on
634    /// the ball's own trajectory rather than the next touch's incoming impulse.
635    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/// Watches the ball's free flight after a touch and decides, from the settled
651/// trajectory before the next touch, whether the touch sent the ball goalward —
652/// recovering shots whose touch-frame velocity had not yet finished updating.
653/// Resolutions are applied retroactively to the already-emitted touch event,
654/// mirroring [`ControlFollowTracker`].
655///
656/// At most one window is open at a time: any new touch closes the previous
657/// window.
658#[derive(Debug, Clone, Default, PartialEq)]
659pub struct ShotProjectionTracker {
660    window: Option<ShotProjectionWindow>,
661}
662
663impl ShotProjectionTracker {
664    /// Open a projection window for a just-emitted touch event.
665    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    /// Close the open window because a new touch at `next_touch_time` ended free
675    /// flight, resolving it from a sample taken a guard interval before that
676    /// touch so the next touch's pre-detection impulse can't skew the read.
677    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    /// Record this frame's free-flight ball sample, or resolve the window once
684    /// it ages past the shot time-to-goal horizon.
685    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    /// Close any open window immediately (live-play boundary or end of replay).
710    pub fn flush(&mut self) -> Option<ShotProjectionResolution> {
711        self.window.take().map(|window| window.resolution_latest())
712    }
713}
714
715/// True when an active 50/50 lists this player as one of its contestants.
716pub(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
737/// Returns true when a ballistic projection of the trajectory crosses the goal
738/// line at `target_goal_y` inside the goal mouth (with a ball-radius margin)
739/// within `max_seconds`.
740///
741/// The horizontal (x/y) path is a straight line — gravity is purely vertical,
742/// so it does not change when the ball reaches the goal line nor its lateral
743/// position there. The vertical (z) path applies constant gravity, so a shot
744/// hit upward that arcs back down into the net is read correctly instead of
745/// being projected straight over the crossbar. Wall bounces and later touches
746/// are still deliberately ignored.
747fn 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;