Skip to main content

subtr_actor/stats/calculators/
ceiling_shot.rs

1use super::*;
2
3const SOCCAR_CEILING_Z: f32 = 2044.0;
4const CEILING_CONTACT_MAX_GAP: f32 = 90.0;
5const CEILING_CONTACT_MIN_ROOF_ALIGNMENT: f32 = 0.72;
6const CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS: f32 = 1.35;
7const CEILING_SHOT_MIN_TOUCH_SEPARATION: f32 = 120.0;
8const CEILING_SHOT_MIN_PLAYER_HEIGHT: f32 = 260.0;
9const CEILING_SHOT_MIN_BALL_HEIGHT: f32 = 220.0;
10const CEILING_SHOT_MIN_FORWARD_ALIGNMENT: f32 = 0.12;
11const CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED: f32 = 90.0;
12const CEILING_SHOT_MIN_BALL_SPEED_CHANGE: f32 = 120.0;
13const CEILING_SHOT_MIN_CONFIDENCE: f32 = 0.54;
14const CEILING_SHOT_HIGH_CONFIDENCE: f32 = 0.78;
15
16#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
17#[ts(export)]
18pub struct CeilingShotEvent {
19    pub time: f32,
20    pub frame: usize,
21    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
22    pub player: PlayerId,
23    pub is_team_0: bool,
24    pub ceiling_contact_time: f32,
25    pub ceiling_contact_frame: usize,
26    pub time_since_ceiling_contact: f32,
27    pub ceiling_contact_position: [f32; 3],
28    pub touch_position: [f32; 3],
29    pub local_ball_position: [f32; 3],
30    pub separation_from_ceiling: f32,
31    pub roof_alignment: f32,
32    pub forward_alignment: f32,
33    pub forward_approach_speed: f32,
34    pub ball_speed_change: f32,
35    pub confidence: f32,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
39#[ts(export)]
40pub struct CeilingShotStats {
41    pub count: u32,
42    pub high_confidence_count: u32,
43    pub is_last_ceiling_shot: bool,
44    pub last_ceiling_shot_time: Option<f32>,
45    pub last_ceiling_shot_frame: Option<usize>,
46    pub time_since_last_ceiling_shot: Option<f32>,
47    pub frames_since_last_ceiling_shot: Option<usize>,
48    pub last_confidence: Option<f32>,
49    pub best_confidence: f32,
50    pub cumulative_confidence: f32,
51    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
52    pub labeled_event_counts: LabeledCounts,
53}
54
55impl CeilingShotStats {
56    pub fn average_confidence(&self) -> f32 {
57        if self.count == 0 {
58            0.0
59        } else {
60            self.cumulative_confidence / self.count as f32
61        }
62    }
63
64    fn record_event(&mut self, event: &CeilingShotEvent) {
65        self.labeled_event_counts.increment([confidence_band_label(
66            event.confidence >= CEILING_SHOT_HIGH_CONFIDENCE,
67        )]);
68        self.sync_legacy_counts();
69        self.last_ceiling_shot_time = Some(event.time);
70        self.last_ceiling_shot_frame = Some(event.frame);
71        self.last_confidence = Some(event.confidence);
72        self.best_confidence = self.best_confidence.max(event.confidence);
73        self.cumulative_confidence += event.confidence;
74    }
75
76    pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
77        self.labeled_event_counts.count_matching(labels)
78    }
79
80    pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
81        LabeledCounts::complete_from_label_sets(
82            &[&CONFIDENCE_BAND_LABELS],
83            &self.labeled_event_counts,
84        )
85    }
86
87    fn sync_legacy_counts(&mut self) {
88        self.count = self.labeled_event_counts.total();
89        self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
90    }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq)]
94struct RecentCeilingContact {
95    time: f32,
96    frame: usize,
97    position: [f32; 3],
98    roof_alignment: f32,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
102struct CeilingContactObservation {
103    position: glam::Vec3,
104    roof_alignment: f32,
105}
106
107#[derive(Debug, Clone, Default, PartialEq)]
108pub struct CeilingShotCalculator {
109    player_stats: HashMap<PlayerId, CeilingShotStats>,
110    events: Vec<CeilingShotEvent>,
111    recent_ceiling_contacts: HashMap<PlayerId, RecentCeilingContact>,
112    previous_ball_velocity: Option<glam::Vec3>,
113    current_last_ceiling_shot_player: Option<PlayerId>,
114}
115
116impl CeilingShotCalculator {
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    pub fn player_stats(&self) -> &HashMap<PlayerId, CeilingShotStats> {
122        &self.player_stats
123    }
124
125    pub fn events(&self) -> &[CeilingShotEvent] {
126        &self.events
127    }
128
129    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
130        if max_value <= min_value {
131            return 0.0;
132        }
133
134        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
135    }
136
137    fn ball_speed_change(
138        frame: &FrameInfo,
139        ball: &BallFrameState,
140        previous_ball_velocity: Option<glam::Vec3>,
141    ) -> f32 {
142        const BALL_GRAVITY_Z: f32 = -650.0;
143
144        let Some(ball) = ball.sample() else {
145            return 0.0;
146        };
147        let Some(previous_ball_velocity) = previous_ball_velocity else {
148            return 0.0;
149        };
150
151        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
152        let residual_linear_impulse =
153            ball.velocity() - previous_ball_velocity - expected_linear_delta;
154        residual_linear_impulse.length()
155    }
156
157    fn begin_sample(&mut self, frame: &FrameInfo) {
158        for stats in self.player_stats.values_mut() {
159            stats.is_last_ceiling_shot = false;
160            stats.time_since_last_ceiling_shot = stats
161                .last_ceiling_shot_time
162                .map(|time| (frame.time - time).max(0.0));
163            stats.frames_since_last_ceiling_shot = stats
164                .last_ceiling_shot_frame
165                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
166        }
167
168        if let Some(player_id) = self.current_last_ceiling_shot_player.as_ref() {
169            if let Some(stats) = self.player_stats.get_mut(player_id) {
170                stats.is_last_ceiling_shot = true;
171            }
172        }
173    }
174
175    fn ceiling_contact_observation(player: &PlayerSample) -> Option<CeilingContactObservation> {
176        let rigid_body = player.rigid_body.as_ref()?;
177        let position = player.position()?;
178        let gap_to_ceiling = SOCCAR_CEILING_Z - position.z;
179        if !(0.0..=CEILING_CONTACT_MAX_GAP).contains(&gap_to_ceiling) {
180            return None;
181        }
182
183        let up = quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z;
184        let roof_alignment = (-up).dot(glam::Vec3::Z);
185        if roof_alignment < CEILING_CONTACT_MIN_ROOF_ALIGNMENT {
186            return None;
187        }
188
189        Some(CeilingContactObservation {
190            position,
191            roof_alignment,
192        })
193    }
194
195    fn update_recent_ceiling_contacts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
196        for player in &players.players {
197            let observation = Self::ceiling_contact_observation(player);
198            let Some(observation) = observation else {
199                continue;
200            };
201
202            self.recent_ceiling_contacts.insert(
203                player.player_id.clone(),
204                RecentCeilingContact {
205                    time: frame.time,
206                    frame: frame.frame_number,
207                    position: observation.position.to_array(),
208                    roof_alignment: observation.roof_alignment,
209                },
210            );
211        }
212    }
213
214    fn prune_recent_ceiling_contacts(&mut self, current_time: f32) {
215        self.recent_ceiling_contacts.retain(|_, contact| {
216            current_time - contact.time <= CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS
217        });
218    }
219
220    fn candidate_event(
221        &self,
222        ball: &BallFrameState,
223        player: &PlayerSample,
224        touch_event: &TouchEvent,
225        recent_contact: RecentCeilingContact,
226        ball_speed_change: f32,
227    ) -> Option<CeilingShotEvent> {
228        let ball = ball.sample()?;
229        let player_position = player.position()?;
230        let player_rigid_body = player.rigid_body.as_ref()?;
231        let ball_position = ball.position();
232
233        if player_position.z < CEILING_SHOT_MIN_PLAYER_HEIGHT
234            || ball_position.z < CEILING_SHOT_MIN_BALL_HEIGHT
235        {
236            return None;
237        }
238
239        let time_since_ceiling_contact = touch_event.time - recent_contact.time;
240        if !(0.0..=CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS)
241            .contains(&time_since_ceiling_contact)
242        {
243            return None;
244        }
245
246        let separation_from_ceiling = SOCCAR_CEILING_Z - player_position.z;
247        if separation_from_ceiling < CEILING_SHOT_MIN_TOUCH_SEPARATION {
248            return None;
249        }
250
251        let relative_ball_position = ball_position - player_position;
252        if relative_ball_position.length_squared() <= f32::EPSILON {
253            return None;
254        }
255
256        let player_rotation = quat_to_glam(&player_rigid_body.rotation);
257        let local_ball_position = player_rotation.inverse() * relative_ball_position;
258        if local_ball_position.x < -120.0
259            || local_ball_position.y.abs() > 260.0
260            || local_ball_position.z.abs() > 240.0
261        {
262            return None;
263        }
264
265        let to_ball = relative_ball_position.normalize_or_zero();
266        let forward = player_rotation * glam::Vec3::X;
267        let forward_alignment = forward.dot(to_ball);
268        if forward_alignment < CEILING_SHOT_MIN_FORWARD_ALIGNMENT {
269            return None;
270        }
271
272        let forward_approach_speed = player.velocity().unwrap_or(glam::Vec3::ZERO).dot(to_ball);
273        if forward_approach_speed < CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED {
274            return None;
275        }
276        if ball_speed_change < CEILING_SHOT_MIN_BALL_SPEED_CHANGE {
277            return None;
278        }
279
280        let timing_score = 1.0
281            - Self::normalize_score(
282                time_since_ceiling_contact,
283                0.10,
284                CEILING_SHOT_MAX_TOUCH_AFTER_CONTACT_SECONDS,
285            );
286        let separation_score = Self::normalize_score(separation_from_ceiling, 140.0, 520.0);
287        let height_score = Self::normalize_score(
288            player_position.z.max(ball_position.z),
289            CEILING_SHOT_MIN_BALL_HEIGHT,
290            900.0,
291        );
292        let alignment_score =
293            Self::normalize_score(forward_alignment, CEILING_SHOT_MIN_FORWARD_ALIGNMENT, 0.92);
294        let approach_score = Self::normalize_score(
295            forward_approach_speed,
296            CEILING_SHOT_MIN_FORWARD_APPROACH_SPEED,
297            900.0,
298        );
299        let impulse_score =
300            Self::normalize_score(ball_speed_change, CEILING_SHOT_MIN_BALL_SPEED_CHANGE, 900.0);
301        let contact_score = Self::normalize_score(
302            recent_contact.roof_alignment,
303            CEILING_CONTACT_MIN_ROOF_ALIGNMENT,
304            0.98,
305        );
306
307        let confidence = 0.20 * timing_score
308            + 0.15 * separation_score
309            + 0.12 * height_score
310            + 0.17 * alignment_score
311            + 0.16 * approach_score
312            + 0.10 * impulse_score
313            + 0.10 * contact_score;
314        if confidence < CEILING_SHOT_MIN_CONFIDENCE {
315            return None;
316        }
317
318        Some(CeilingShotEvent {
319            time: touch_event.time,
320            frame: touch_event.frame,
321            player: player.player_id.clone(),
322            is_team_0: player.is_team_0,
323            ceiling_contact_time: recent_contact.time,
324            ceiling_contact_frame: recent_contact.frame,
325            time_since_ceiling_contact,
326            ceiling_contact_position: recent_contact.position,
327            touch_position: ball_position.to_array(),
328            local_ball_position: local_ball_position.to_array(),
329            separation_from_ceiling,
330            roof_alignment: recent_contact.roof_alignment,
331            forward_alignment,
332            forward_approach_speed,
333            ball_speed_change,
334            confidence,
335        })
336    }
337
338    fn apply_touch_events(
339        &mut self,
340        frame: &FrameInfo,
341        ball: &BallFrameState,
342        players: &PlayerFrameState,
343        touch_events: &[TouchEvent],
344    ) {
345        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
346
347        for touch_event in touch_events {
348            let Some(player_id) = touch_event.player.as_ref() else {
349                continue;
350            };
351            let Some(player) = players
352                .players
353                .iter()
354                .find(|player| &player.player_id == player_id)
355            else {
356                continue;
357            };
358            let Some(recent_contact) = self.recent_ceiling_contacts.get(player_id).copied() else {
359                continue;
360            };
361            let Some(event) =
362                self.candidate_event(ball, player, touch_event, recent_contact, ball_speed_change)
363            else {
364                continue;
365            };
366
367            let stats = self.player_stats.entry(player_id.clone()).or_default();
368            stats.record_event(&event);
369            stats.is_last_ceiling_shot = true;
370            stats.time_since_last_ceiling_shot = Some((frame.time - event.time).max(0.0));
371            stats.frames_since_last_ceiling_shot =
372                Some(frame.frame_number.saturating_sub(event.frame));
373
374            self.current_last_ceiling_shot_player = Some(player_id.clone());
375            self.events.push(event);
376        }
377
378        if let Some(player_id) = self.current_last_ceiling_shot_player.as_ref() {
379            if let Some(stats) = self.player_stats.get_mut(player_id) {
380                stats.is_last_ceiling_shot = true;
381            }
382        }
383    }
384
385    fn reset_live_play_state(&mut self, ball: &BallFrameState) {
386        self.current_last_ceiling_shot_player = None;
387        self.recent_ceiling_contacts.clear();
388        self.previous_ball_velocity = ball.velocity();
389    }
390
391    pub fn update_parts(
392        &mut self,
393        frame: &FrameInfo,
394        ball: &BallFrameState,
395        players: &PlayerFrameState,
396        touch_events: &[TouchEvent],
397        live_play: bool,
398    ) -> SubtrActorResult<()> {
399        if !live_play {
400            self.reset_live_play_state(ball);
401            return Ok(());
402        }
403
404        self.begin_sample(frame);
405        self.prune_recent_ceiling_contacts(frame.time);
406        self.apply_touch_events(frame, ball, players, touch_events);
407        self.update_recent_ceiling_contacts(frame, players);
408        self.previous_ball_velocity = ball.velocity();
409        Ok(())
410    }
411}