Skip to main content

subtr_actor/stats/calculators/
whiff.rs

1use super::*;
2
3// These thresholds are intentionally permissive. The whiff detector feeds a
4// human confirm/reject review loop (rocket-sense `event_reviews`) whose purpose
5// is to build a labeled dataset, so it is tuned for recall rather than
6// precision: it flags essentially any committed move at a nearby ball that did
7// not produce a touch as a *candidate*, and leaves reviewers to prune the false
8// positives. Precision is recovered downstream from the labels, not here.
9//
10// The non-dodge approach path and the shared distance/time gates carry the
11// loosening; the dodge-specific gates below are left at their stricter values so
12// a clear side-dodge past the ball is still not treated as an attempt.
13const WHIFF_ENTER_DISTANCE: f32 = 220.0;
14const WHIFF_EXIT_DISTANCE: f32 = 360.0;
15const WHIFF_MAX_CANDIDATE_SECONDS: f32 = 1.0;
16const WHIFF_MIN_APPROACH_SPEED: f32 = 350.0;
17const WHIFF_MIN_CLOSING_SPEED: f32 = 250.0;
18const WHIFF_MIN_FORWARD_ALIGNMENT: f32 = 0.3;
19const WHIFF_MIN_VELOCITY_ALIGNMENT: f32 = 0.45;
20const WHIFF_MIN_DODGE_APPROACH_SPEED: f32 = 450.0;
21const WHIFF_MIN_DODGE_CLOSING_SPEED: f32 = 300.0;
22const WHIFF_MIN_DODGE_FORWARD_ALIGNMENT: f32 = 0.25;
23const WHIFF_MAX_LATERAL_OFFSET: f32 = 200.0;
24const WHIFF_MAX_DODGE_LATERAL_OFFSET: f32 = 150.0;
25const WHIFF_MIN_LOCAL_FORWARD_OFFSET: f32 = 0.0;
26const WHIFF_MIN_DODGE_LOCAL_FORWARD_OFFSET: f32 = -20.0;
27
28/// Whether a whiff was a true whiff or a beaten-to-ball attempt.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
30#[serde(rename_all = "snake_case")]
31#[ts(export, rename_all = "snake_case")]
32pub enum WhiffEventKind {
33    #[default]
34    Whiff,
35    BeatenToBall,
36}
37
38/// A committed attempt near the ball that does not result in that player touching it.
39#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
40#[ts(export)]
41pub struct WhiffEvent {
42    #[serde(default)]
43    pub kind: WhiffEventKind,
44    pub time: f32,
45    pub frame: usize,
46    pub resolved_time: f32,
47    pub resolved_frame: usize,
48    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
49    pub player: PlayerId,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub player_position: Option<[f32; 3]>,
52    pub is_team_0: bool,
53    pub closest_approach_distance: f32,
54    pub forward_alignment: f32,
55    pub approach_speed: f32,
56    pub dodge_active: bool,
57    pub aerial: bool,
58}
59
60pub(crate) const WHIFF_DODGE_STATE_LABELS: [StatLabel; 2] = [
61    StatLabel::new("dodge_state", "no_dodge"),
62    StatLabel::new("dodge_state", "dodge"),
63];
64
65impl WhiffEvent {
66    pub(crate) fn labels(&self) -> [StatLabel; 2] {
67        [
68            vertical_state_label(self.aerial),
69            whiff_dodge_state_label(self.dodge_active),
70        ]
71    }
72}
73
74pub(crate) fn whiff_dodge_state_label(dodge_active: bool) -> StatLabel {
75    if dodge_active {
76        StatLabel::new("dodge_state", "dodge")
77    } else {
78        StatLabel::new("dodge_state", "no_dodge")
79    }
80}
81
82#[derive(Debug, Clone, PartialEq)]
83struct ActiveWhiffCandidate {
84    player: PlayerId,
85    is_team_0: bool,
86    start_time: f32,
87    closest_time: f32,
88    closest_frame: usize,
89    closest_position: [f32; 3],
90    closest_approach_distance: f32,
91    forward_alignment: f32,
92    approach_speed: f32,
93    dodge_active: bool,
94    aerial: bool,
95}
96
97impl InFlightItem for ActiveWhiffCandidate {
98    fn recognition(&self) -> Recognition {
99        // A whiff candidate is speculative: it only becomes an event if the
100        // player misses (exit/timeout) or is beaten to the ball. A touch by the
101        // candidate's own player, or a boundary, discards it.
102        Recognition::speculative(self.start_time, self.closest_frame)
103    }
104
105    fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
106        // An in-flight candidate at a boundary never resolved into a whiff.
107        Disposition::Discard
108    }
109}
110
111/// Detects whiffs and beaten-to-ball attempts.
112#[derive(Debug, Clone, Default, PartialEq)]
113pub struct WhiffCalculator {
114    active_candidates: KeyedInFlightLedger<PlayerId, ActiveWhiffCandidate>,
115    events: EventStream<WhiffEvent>,
116}
117
118impl WhiffCalculator {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    pub fn events(&self) -> &[WhiffEvent] {
124        self.events.all()
125    }
126
127    pub fn new_events(&self) -> &[WhiffEvent] {
128        self.events.new_events()
129    }
130
131    fn hitbox_distance(ball_position: glam::Vec3, player: &PlayerSample) -> Option<f32> {
132        let rigid_body = player.rigid_body.as_ref()?;
133        car_hitbox_distance(ball_position, rigid_body, player.hitbox)
134    }
135
136    fn local_ball_position(ball_position: glam::Vec3, player: &PlayerSample) -> Option<glam::Vec3> {
137        let rigid_body = player.rigid_body.as_ref()?;
138        let player_position = player.position()?;
139        Some(quat_to_glam(&rigid_body.rotation).inverse() * (ball_position - player_position))
140    }
141
142    fn whiff_candidate(
143        frame: &FrameInfo,
144        ball_position: glam::Vec3,
145        ball_velocity: glam::Vec3,
146        player: &PlayerSample,
147    ) -> Option<ActiveWhiffCandidate> {
148        let distance = Self::hitbox_distance(ball_position, player)?;
149        if distance > WHIFF_ENTER_DISTANCE {
150            return None;
151        }
152
153        let rigid_body = player.rigid_body.as_ref()?;
154        let player_position = player.position()?;
155        let local_ball_position = Self::local_ball_position(ball_position, player)?;
156        let to_ball = (ball_position - player_position).normalize_or_zero();
157        if to_ball.length_squared() <= f32::EPSILON {
158            return None;
159        }
160
161        let rotation = quat_to_glam(&rigid_body.rotation);
162        let forward_alignment = (rotation * glam::Vec3::X).dot(to_ball);
163        let player_velocity = player.velocity().unwrap_or(glam::Vec3::ZERO);
164        let player_speed = player_velocity.length();
165        let velocity_alignment = if player_speed <= f32::EPSILON {
166            0.0
167        } else {
168            player_velocity.normalize_or_zero().dot(to_ball)
169        };
170        let approach_speed = player_velocity.dot(to_ball);
171        let closing_speed = (player_velocity - ball_velocity).dot(to_ball);
172        let ball_in_front = local_ball_position.x >= WHIFF_MIN_LOCAL_FORWARD_OFFSET
173            && local_ball_position.y.abs() <= WHIFF_MAX_LATERAL_OFFSET;
174        let dodge_ball_in_front = local_ball_position.x >= WHIFF_MIN_DODGE_LOCAL_FORWARD_OFFSET
175            && local_ball_position.y.abs() <= WHIFF_MAX_DODGE_LATERAL_OFFSET;
176        let committed_approach = approach_speed >= WHIFF_MIN_APPROACH_SPEED
177            && closing_speed >= WHIFF_MIN_CLOSING_SPEED
178            && forward_alignment >= WHIFF_MIN_FORWARD_ALIGNMENT;
179        let directed_motion = velocity_alignment >= WHIFF_MIN_VELOCITY_ALIGNMENT;
180        let committed_dodge = player.dodge_active
181            && approach_speed >= WHIFF_MIN_DODGE_APPROACH_SPEED
182            && closing_speed >= WHIFF_MIN_DODGE_CLOSING_SPEED
183            && forward_alignment >= WHIFF_MIN_DODGE_FORWARD_ALIGNMENT
184            && dodge_ball_in_front;
185        if !(committed_dodge || committed_approach && directed_motion && ball_in_front) {
186            return None;
187        }
188
189        Some(ActiveWhiffCandidate {
190            player: player.player_id.clone(),
191            is_team_0: player.is_team_0,
192            start_time: frame.time,
193            closest_time: frame.time,
194            closest_frame: frame.frame_number,
195            closest_position: player_position.to_array(),
196            closest_approach_distance: distance,
197            forward_alignment,
198            approach_speed,
199            dodge_active: player.dodge_active,
200            aerial: player_position.z > POWERSLIDE_MAX_Z_THRESHOLD,
201        })
202    }
203
204    fn finish_touched_candidates(&mut self, frame: &FrameInfo, touch_state: &TouchState) {
205        let touched_players = touch_state
206            .touch_events
207            .iter()
208            .filter_map(|touch| touch.player.as_ref())
209            .collect::<HashSet<_>>();
210        let touched_teams = touch_state
211            .touch_events
212            .iter()
213            .map(|touch| touch.team_is_team_0)
214            .collect::<HashSet<_>>();
215        if touched_players.is_empty() && touched_teams.is_empty() {
216            return;
217        }
218
219        let candidate_players = self.active_candidates.keys().cloned().collect::<Vec<_>>();
220        for player_id in candidate_players {
221            let Some((candidate_player, candidate_team)) = self
222                .active_candidates
223                .get(&player_id)
224                .map(|candidate| (candidate.player.clone(), candidate.is_team_0))
225            else {
226                continue;
227            };
228            if touched_players.contains(&candidate_player) {
229                // The candidate's own player touched the ball: not a whiff.
230                self.active_candidates.discard(&player_id);
231            } else if touched_teams.contains(&!candidate_team) {
232                if let Some(candidate) = self
233                    .active_candidates
234                    .finalize(&player_id, FinalizeReason::Completed)
235                {
236                    self.emit_candidate(candidate, frame, WhiffEventKind::BeatenToBall);
237                }
238            } else {
239                self.active_candidates.discard(&player_id);
240            }
241        }
242    }
243
244    fn emit_candidate(
245        &mut self,
246        candidate: ActiveWhiffCandidate,
247        frame: &FrameInfo,
248        kind: WhiffEventKind,
249    ) {
250        let (time, frame_number) = match kind {
251            WhiffEventKind::Whiff => (candidate.closest_time, candidate.closest_frame),
252            WhiffEventKind::BeatenToBall => (frame.time, frame.frame_number),
253        };
254        let event = WhiffEvent {
255            kind,
256            time,
257            frame: frame_number,
258            resolved_time: frame.time,
259            resolved_frame: frame.frame_number,
260            player: candidate.player.clone(),
261            player_position: Some(candidate.closest_position),
262            is_team_0: candidate.is_team_0,
263            closest_approach_distance: candidate.closest_approach_distance,
264            forward_alignment: candidate.forward_alignment,
265            approach_speed: candidate.approach_speed,
266            dodge_active: candidate.dodge_active,
267            aerial: candidate.aerial,
268        };
269        self.events.push(event);
270    }
271
272    fn update_active_candidates(
273        &mut self,
274        frame: &FrameInfo,
275        ball_position: glam::Vec3,
276        ball_velocity: glam::Vec3,
277        players: &PlayerFrameState,
278    ) {
279        let mut visible_players = HashSet::new();
280
281        for player in &players.players {
282            let player_id = player.player_id.clone();
283            visible_players.insert(player_id.clone());
284            let distance = Self::hitbox_distance(ball_position, player);
285
286            if let (Some(candidate), Some(distance)) =
287                (self.active_candidates.get_mut(&player_id), distance)
288            {
289                if distance < candidate.closest_approach_distance {
290                    candidate.closest_approach_distance = distance;
291                    candidate.closest_time = frame.time;
292                    candidate.closest_frame = frame.frame_number;
293                    if let Some(position) = player.position() {
294                        candidate.closest_position = position.to_array();
295                    }
296                    if let Some(updated) =
297                        Self::whiff_candidate(frame, ball_position, ball_velocity, player)
298                    {
299                        candidate.forward_alignment = updated.forward_alignment;
300                        candidate.approach_speed = updated.approach_speed;
301                        candidate.dodge_active |= updated.dodge_active;
302                        candidate.aerial |= updated.aerial;
303                    }
304                }
305
306                if distance > WHIFF_EXIT_DISTANCE
307                    || frame.time - candidate.start_time > WHIFF_MAX_CANDIDATE_SECONDS
308                {
309                    if let Some(candidate) = self
310                        .active_candidates
311                        .finalize(&player_id, FinalizeReason::Completed)
312                    {
313                        self.emit_candidate(candidate, frame, WhiffEventKind::Whiff);
314                    }
315                }
316                continue;
317            }
318
319            if let Some(candidate) =
320                Self::whiff_candidate(frame, ball_position, ball_velocity, player)
321            {
322                self.active_candidates.arm(player_id, candidate);
323            }
324        }
325
326        let missing_players = self
327            .active_candidates
328            .keys()
329            .filter(|player_id| !visible_players.contains(*player_id))
330            .cloned()
331            .collect::<Vec<_>>();
332        for player_id in missing_players {
333            self.active_candidates.discard(&player_id);
334        }
335    }
336
337    pub fn update(
338        &mut self,
339        frame: &FrameInfo,
340        ball: &BallFrameState,
341        players: &PlayerFrameState,
342        touch_state: &TouchState,
343        live_play_state: &LivePlayState,
344    ) -> SubtrActorResult<()> {
345        self.events.begin_update();
346        if !live_play_state.is_live_play {
347            self.active_candidates
348                .apply_boundary(Boundary::LivePlayEnded);
349            return Ok(());
350        }
351        self.finish_touched_candidates(frame, touch_state);
352        if touch_state.touch_events.is_empty() {
353            if let Some(ball_position) = ball.position() {
354                self.update_active_candidates(
355                    frame,
356                    ball_position,
357                    ball.velocity().unwrap_or(glam::Vec3::ZERO),
358                    players,
359                );
360            }
361        }
362        Ok(())
363    }
364
365    /// Resolve any in-flight candidates at end of stream. An unresolved
366    /// candidate never became a whiff, so it is discarded (handled uniformly via
367    /// the ledger rather than left to drop implicitly).
368    pub fn finish(&mut self) {
369        self.active_candidates.finish();
370    }
371}
372
373#[cfg(test)]
374#[path = "whiff_tests.rs"]
375mod tests;