Skip to main content

subtr_actor/stats/calculators/
musty_flick.rs

1use super::*;
2
3const MUSTY_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.22;
4const MUSTY_MIN_PLAYER_HEIGHT: f32 = 80.0;
5const MUSTY_AERIAL_HEIGHT: f32 = 180.0;
6const MUSTY_MIN_FORWARD_APPROACH_SPEED: f32 = 150.0;
7const MUSTY_MIN_BALL_SPEED_CHANGE: f32 = 150.0;
8const MUSTY_MIN_REAR_ALIGNMENT: f32 = 0.15;
9const MUSTY_MIN_TOP_ALIGNMENT: f32 = 0.10;
10const MUSTY_MIN_LOCAL_Z: f32 = 5.0;
11const MUSTY_MAX_LOCAL_X: f32 = 60.0;
12const MUSTY_MAX_LOCAL_Y: f32 = 170.0;
13const MUSTY_MIN_PITCH_RATE: f32 = 2.5;
14const MUSTY_MIN_PITCH_DOMINANCE_RATIO: f32 = 1.1;
15const MUSTY_MIN_DODGE_START_FORWARD_Z: f32 = -0.25;
16const MUSTY_MIN_CONFIDENCE: f32 = 0.55;
17const MUSTY_HIGH_CONFIDENCE: f32 = 0.80;
18
19#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
20#[ts(export)]
21pub struct MustyFlickEvent {
22    pub time: f32,
23    pub frame: usize,
24    pub sample_time: f32,
25    pub sample_frame: usize,
26    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
27    pub player: PlayerId,
28    pub is_team_0: bool,
29    pub aerial: bool,
30    pub dodge_time: f32,
31    pub dodge_frame: usize,
32    pub time_since_dodge: f32,
33    pub confidence: f32,
34    pub local_ball_position: [f32; 3],
35    pub rear_alignment: f32,
36    pub top_alignment: f32,
37    pub forward_approach_speed: f32,
38    pub pitch_rate: f32,
39    pub ball_speed_change: f32,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
43#[ts(export)]
44pub struct MustyFlickStats {
45    pub count: u32,
46    pub aerial_count: u32,
47    pub high_confidence_count: u32,
48    pub is_last_musty: bool,
49    pub last_musty_time: Option<f32>,
50    pub last_musty_frame: Option<usize>,
51    pub time_since_last_musty: Option<f32>,
52    pub frames_since_last_musty: Option<usize>,
53    pub last_confidence: Option<f32>,
54    pub best_confidence: f32,
55    pub cumulative_confidence: f32,
56    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
57    pub labeled_event_counts: LabeledCounts,
58}
59
60impl MustyFlickStats {
61    pub fn average_confidence(&self) -> f32 {
62        if self.count == 0 {
63            0.0
64        } else {
65            self.cumulative_confidence / self.count as f32
66        }
67    }
68
69    fn record_event(&mut self, event: &MustyFlickEvent, aerial: bool) {
70        self.labeled_event_counts.increment([
71            vertical_state_label(aerial),
72            confidence_band_label(event.confidence >= MUSTY_HIGH_CONFIDENCE),
73        ]);
74        self.sync_legacy_counts();
75        self.last_musty_time = Some(event.time);
76        self.last_musty_frame = Some(event.frame);
77        self.last_confidence = Some(event.confidence);
78        self.best_confidence = self.best_confidence.max(event.confidence);
79        self.cumulative_confidence += event.confidence;
80    }
81
82    pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
83        self.labeled_event_counts.count_matching(labels)
84    }
85
86    pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
87        LabeledCounts::complete_from_label_sets(
88            &[&VERTICAL_STATE_LABELS, &CONFIDENCE_BAND_LABELS],
89            &self.labeled_event_counts,
90        )
91    }
92
93    fn sync_legacy_counts(&mut self) {
94        self.count = self.labeled_event_counts.total();
95        self.aerial_count = self.event_count_with_labels(&[vertical_state_label(true)]);
96        self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq)]
101struct RecentDodgeStart {
102    time: f32,
103    frame: usize,
104    forward_z: f32,
105}
106
107#[derive(Debug, Clone, Default, PartialEq)]
108pub struct MustyFlickCalculator {
109    player_stats: HashMap<PlayerId, MustyFlickStats>,
110    events: Vec<MustyFlickEvent>,
111    recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
112    previous_dodge_active: HashMap<PlayerId, bool>,
113    previous_ball_velocity: Option<glam::Vec3>,
114    current_last_musty_player: Option<PlayerId>,
115}
116
117impl MustyFlickCalculator {
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    pub fn player_stats(&self) -> &HashMap<PlayerId, MustyFlickStats> {
123        &self.player_stats
124    }
125
126    pub fn events(&self) -> &[MustyFlickEvent] {
127        &self.events
128    }
129
130    fn begin_sample(&mut self, frame: &FrameInfo) {
131        for stats in self.player_stats.values_mut() {
132            stats.is_last_musty = false;
133            stats.time_since_last_musty = stats
134                .last_musty_time
135                .map(|time| (frame.time - time).max(0.0));
136            stats.frames_since_last_musty = stats
137                .last_musty_frame
138                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
139        }
140    }
141
142    fn ball_speed_change(
143        frame: &FrameInfo,
144        ball: &BallFrameState,
145        previous_ball_velocity: Option<glam::Vec3>,
146    ) -> f32 {
147        const BALL_GRAVITY_Z: f32 = -650.0;
148
149        let Some(ball) = ball.sample() else {
150            return 0.0;
151        };
152        let Some(previous_ball_velocity) = previous_ball_velocity else {
153            return 0.0;
154        };
155
156        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
157        let residual_linear_impulse =
158            ball.velocity() - previous_ball_velocity - expected_linear_delta;
159        residual_linear_impulse.length()
160    }
161
162    fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
163        for player in &players.players {
164            let was_dodge_active = self
165                .previous_dodge_active
166                .insert(player.player_id.clone(), player.dodge_active)
167                .unwrap_or(false);
168            if !player.dodge_active || was_dodge_active {
169                continue;
170            }
171
172            let Some(rigid_body) = player.rigid_body.as_ref() else {
173                continue;
174            };
175            let forward = quat_to_glam(&rigid_body.rotation) * glam::Vec3::X;
176            self.recent_dodge_starts.insert(
177                player.player_id.clone(),
178                RecentDodgeStart {
179                    time: frame.time,
180                    frame: frame.frame_number,
181                    forward_z: forward.z,
182                },
183            );
184        }
185    }
186
187    fn prune_recent_dodge_starts(&mut self, current_time: f32) {
188        self.recent_dodge_starts
189            .retain(|_, dodge| current_time - dodge.time <= MUSTY_MAX_DODGE_TO_TOUCH_SECONDS);
190    }
191
192    fn musty_candidate(
193        &self,
194        ball: &BallFrameState,
195        player: &PlayerSample,
196        touch_event: &TouchEvent,
197        dodge_start: RecentDodgeStart,
198        ball_speed_change: f32,
199    ) -> Option<MustyFlickEvent> {
200        let ball = ball.sample()?;
201        let player_rigid_body = player.rigid_body.as_ref()?;
202        let player_position = player.position()?;
203        if player_position.z < MUSTY_MIN_PLAYER_HEIGHT {
204            return None;
205        }
206
207        let time_since_dodge = touch_event.time - dodge_start.time;
208        if !(0.0..=MUSTY_MAX_DODGE_TO_TOUCH_SECONDS).contains(&time_since_dodge) {
209            return None;
210        }
211        if dodge_start.forward_z < MUSTY_MIN_DODGE_START_FORWARD_Z {
212            return None;
213        }
214
215        let player_rotation = quat_to_glam(&player_rigid_body.rotation);
216        let relative_ball_position = ball.position() - player_position;
217        let to_ball = relative_ball_position.normalize_or_zero();
218        if to_ball.length_squared() <= f32::EPSILON {
219            return None;
220        }
221
222        let local_ball_position = player_rotation.inverse() * relative_ball_position;
223        if local_ball_position.x > MUSTY_MAX_LOCAL_X
224            || local_ball_position.y.abs() > MUSTY_MAX_LOCAL_Y
225            || local_ball_position.z < MUSTY_MIN_LOCAL_Z
226        {
227            return None;
228        }
229
230        let forward = player_rotation * glam::Vec3::X;
231        let up = player_rotation * glam::Vec3::Z;
232        let rear_alignment = (-forward).dot(to_ball);
233        let top_alignment = up.dot(to_ball);
234        if rear_alignment < MUSTY_MIN_REAR_ALIGNMENT || top_alignment < MUSTY_MIN_TOP_ALIGNMENT {
235            return None;
236        }
237
238        let forward_approach_speed = player.velocity().unwrap_or(glam::Vec3::ZERO).dot(to_ball);
239        if forward_approach_speed < MUSTY_MIN_FORWARD_APPROACH_SPEED {
240            return None;
241        }
242        if ball_speed_change < MUSTY_MIN_BALL_SPEED_CHANGE {
243            return None;
244        }
245
246        let angular_velocity = player_rigid_body
247            .angular_velocity
248            .as_ref()
249            .map(vec_to_glam)
250            .unwrap_or(glam::Vec3::ZERO);
251        let local_angular_velocity = player_rotation.inverse() * angular_velocity;
252        let pitch_rate = local_angular_velocity.y.abs();
253        let other_spin = local_angular_velocity
254            .x
255            .abs()
256            .max(local_angular_velocity.z.abs());
257        if pitch_rate < MUSTY_MIN_PITCH_RATE
258            || pitch_rate < other_spin * MUSTY_MIN_PITCH_DOMINANCE_RATIO
259        {
260            return None;
261        }
262
263        let timing_score =
264            (1.0 - time_since_dodge / MUSTY_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
265        let rear_score = ((rear_alignment - MUSTY_MIN_REAR_ALIGNMENT) / 0.70).clamp(0.0, 1.0);
266        let top_score = ((top_alignment - MUSTY_MIN_TOP_ALIGNMENT) / 0.70).clamp(0.0, 1.0);
267        let approach_score =
268            ((forward_approach_speed - MUSTY_MIN_FORWARD_APPROACH_SPEED) / 900.0).clamp(0.0, 1.0);
269        let pitch_score = ((pitch_rate - MUSTY_MIN_PITCH_RATE) / 8.0).clamp(0.0, 1.0);
270        let pitch_dominance_ratio = if other_spin <= f32::EPSILON {
271            pitch_rate
272        } else {
273            pitch_rate / other_spin
274        };
275        let pitch_dominance_score =
276            ((pitch_dominance_ratio - MUSTY_MIN_PITCH_DOMINANCE_RATIO) / 2.5).clamp(0.0, 1.0);
277        let impulse_score =
278            ((ball_speed_change - MUSTY_MIN_BALL_SPEED_CHANGE) / 900.0).clamp(0.0, 1.0);
279        let setup_score =
280            ((dodge_start.forward_z - MUSTY_MIN_DODGE_START_FORWARD_Z) / 1.25).clamp(0.0, 1.0);
281
282        let confidence = 0.17 * timing_score
283            + 0.17 * rear_score
284            + 0.14 * top_score
285            + 0.15 * approach_score
286            + 0.12 * pitch_score
287            + 0.08 * pitch_dominance_score
288            + 0.10 * impulse_score
289            + 0.07 * setup_score;
290        if confidence < MUSTY_MIN_CONFIDENCE {
291            return None;
292        }
293
294        Some(MustyFlickEvent {
295            time: touch_event.time,
296            frame: touch_event.frame,
297            sample_time: touch_event.time,
298            sample_frame: touch_event.frame,
299            player: player.player_id.clone(),
300            is_team_0: player.is_team_0,
301            aerial: player_position.z >= MUSTY_AERIAL_HEIGHT,
302            dodge_time: dodge_start.time,
303            dodge_frame: dodge_start.frame,
304            time_since_dodge,
305            confidence,
306            local_ball_position: local_ball_position.to_array(),
307            rear_alignment,
308            top_alignment,
309            forward_approach_speed,
310            pitch_rate,
311            ball_speed_change,
312        })
313    }
314
315    fn apply_touch_events(
316        &mut self,
317        frame: &FrameInfo,
318        ball: &BallFrameState,
319        players: &PlayerFrameState,
320        touch_events: &[TouchEvent],
321    ) {
322        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
323
324        for touch_event in touch_events {
325            let Some(player_id) = touch_event.player.as_ref() else {
326                continue;
327            };
328            let Some(player) = players
329                .players
330                .iter()
331                .find(|player| &player.player_id == player_id)
332            else {
333                continue;
334            };
335            let Some(dodge_start) = self.recent_dodge_starts.get(player_id).copied() else {
336                continue;
337            };
338            let Some(mut event) =
339                self.musty_candidate(ball, player, touch_event, dodge_start, ball_speed_change)
340            else {
341                continue;
342            };
343            event.sample_time = frame.time;
344            event.sample_frame = frame.frame_number;
345
346            let stats = self.player_stats.entry(player_id.clone()).or_default();
347            stats.record_event(&event, event.aerial);
348            stats.is_last_musty = true;
349            stats.time_since_last_musty = Some((frame.time - event.time).max(0.0));
350            stats.frames_since_last_musty = Some(frame.frame_number.saturating_sub(event.frame));
351
352            self.current_last_musty_player = Some(player_id.clone());
353            self.events.push(event);
354        }
355
356        if let Some(player_id) = self.current_last_musty_player.as_ref() {
357            if let Some(stats) = self.player_stats.get_mut(player_id) {
358                stats.is_last_musty = true;
359            }
360        }
361    }
362
363    fn reset_live_play_state(&mut self, ball: &BallFrameState) {
364        self.current_last_musty_player = None;
365        self.recent_dodge_starts.clear();
366        self.previous_dodge_active.clear();
367        self.previous_ball_velocity = ball.velocity();
368    }
369
370    pub fn update_parts(
371        &mut self,
372        frame: &FrameInfo,
373        ball: &BallFrameState,
374        players: &PlayerFrameState,
375        touch_events: &[TouchEvent],
376        live_play: bool,
377    ) -> SubtrActorResult<()> {
378        if !live_play {
379            self.reset_live_play_state(ball);
380            return Ok(());
381        }
382
383        self.begin_sample(frame);
384        self.prune_recent_dodge_starts(frame.time);
385        self.track_dodge_starts(frame, players);
386        self.apply_touch_events(frame, ball, players, touch_events);
387        self.previous_ball_velocity = ball.velocity();
388        Ok(())
389    }
390}