Skip to main content

subtr_actor/stats/calculators/
speed_flip.rs

1use super::*;
2
3const SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS: f32 = 1.1;
4const SPEED_FLIP_EVALUATION_SECONDS: f32 = 0.22;
5const SPEED_FLIP_MAX_CANDIDATE_SECONDS: f32 = 0.4;
6const SPEED_FLIP_MAX_GROUND_Z: f32 = 80.0;
7const SPEED_FLIP_MIN_START_SPEED: f32 = 700.0;
8const SPEED_FLIP_MIN_ALIGNMENT: f32 = 0.72;
9const SPEED_FLIP_MIN_CONFIDENCE: f32 = 0.45;
10const SPEED_FLIP_HIGH_CONFIDENCE: f32 = 0.75;
11const SPEED_FLIP_TARGET_DIAGONAL_RATIO: f32 = 0.42;
12
13#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
14#[ts(export)]
15pub struct SpeedFlipEvent {
16    pub time: f32,
17    pub frame: usize,
18    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
19    pub player: PlayerId,
20    pub is_team_0: bool,
21    pub time_since_kickoff_start: f32,
22    pub start_position: [f32; 3],
23    pub end_position: [f32; 3],
24    pub start_speed: f32,
25    pub max_speed: f32,
26    pub best_alignment: f32,
27    pub diagonal_score: f32,
28    pub cancel_score: f32,
29    pub speed_score: f32,
30    pub confidence: f32,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
34#[ts(export)]
35pub struct SpeedFlipStats {
36    pub count: u32,
37    pub high_confidence_count: u32,
38    pub is_last_speed_flip: bool,
39    pub last_speed_flip_time: Option<f32>,
40    pub last_speed_flip_frame: Option<usize>,
41    pub time_since_last_speed_flip: Option<f32>,
42    pub frames_since_last_speed_flip: Option<usize>,
43    pub last_quality: Option<f32>,
44    pub best_quality: f32,
45    pub cumulative_quality: f32,
46}
47
48impl SpeedFlipStats {
49    pub fn average_quality(&self) -> f32 {
50        if self.count == 0 {
51            0.0
52        } else {
53            self.cumulative_quality / self.count as f32
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq)]
59struct ActiveSpeedFlipCandidate {
60    is_team_0: bool,
61    is_kickoff: bool,
62    kickoff_start_time: Option<f32>,
63    start_time: f32,
64    start_frame: usize,
65    start_position: [f32; 3],
66    end_position: [f32; 3],
67    start_speed: f32,
68    max_speed: f32,
69    best_alignment: f32,
70    best_diagonal_score: f32,
71    min_forward_z: f32,
72    latest_forward_z: f32,
73    latest_time: f32,
74    latest_frame: usize,
75}
76
77#[derive(Debug, Clone, Default, PartialEq)]
78pub struct SpeedFlipCalculator {
79    player_stats: HashMap<PlayerId, SpeedFlipStats>,
80    events: Vec<SpeedFlipEvent>,
81    active_candidates: HashMap<PlayerId, ActiveSpeedFlipCandidate>,
82    previous_dodge_active: HashMap<PlayerId, bool>,
83    kickoff_approach_active_last_frame: bool,
84    current_kickoff_start_time: Option<f32>,
85    current_last_speed_flip_player: Option<PlayerId>,
86}
87
88impl SpeedFlipCalculator {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    pub fn player_stats(&self) -> &HashMap<PlayerId, SpeedFlipStats> {
94        &self.player_stats
95    }
96
97    pub fn events(&self) -> &[SpeedFlipEvent] {
98        &self.events
99    }
100
101    fn kickoff_approach_active(gameplay: &GameplayState) -> bool {
102        gameplay.ball_has_been_hit == Some(false)
103    }
104
105    fn player_by_id<'a>(
106        players: &'a PlayerFrameState,
107        player_id: &PlayerId,
108    ) -> Option<&'a PlayerSample> {
109        players
110            .players
111            .iter()
112            .find(|player| &player.player_id == player_id)
113    }
114
115    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
116        if max_value <= min_value {
117            return 0.0;
118        }
119
120        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
121    }
122
123    fn diagonal_score(local_angular_velocity: glam::Vec3) -> f32 {
124        let pitch_rate = local_angular_velocity.y.abs();
125        let side_spin = local_angular_velocity
126            .x
127            .abs()
128            .max(local_angular_velocity.z.abs());
129        if pitch_rate <= f32::EPSILON {
130            return 0.0;
131        }
132
133        let pitch_score = Self::normalize_score(pitch_rate, 3.5, 8.5);
134        let ratio = side_spin / pitch_rate;
135        let ratio_score = 1.0
136            - ((ratio - SPEED_FLIP_TARGET_DIAGONAL_RATIO).abs() / SPEED_FLIP_TARGET_DIAGONAL_RATIO)
137                .clamp(0.0, 1.0);
138
139        pitch_score * ratio_score
140    }
141
142    fn horizontal_alignment_to_target(
143        player: &PlayerSample,
144        target_position: glam::Vec3,
145    ) -> Option<f32> {
146        let velocity = player.velocity()?;
147        let player_position = player.position()?;
148        let to_target = target_position - player_position;
149        let velocity_xy = velocity.truncate().normalize_or_zero();
150        let to_target_xy = to_target.truncate().normalize_or_zero();
151        if velocity_xy.length_squared() <= f32::EPSILON
152            || to_target_xy.length_squared() <= f32::EPSILON
153        {
154            return None;
155        }
156
157        Some(velocity_xy.dot(to_target_xy))
158    }
159
160    fn kickoff_alignment_target(ball: &BallFrameState) -> glam::Vec3 {
161        ball.sample()
162            .map(BallSample::position)
163            .unwrap_or(glam::Vec3::new(0.0, 0.0, BALL_RADIUS_Z))
164    }
165
166    fn forward_speed_alignment(player: &PlayerSample) -> Option<f32> {
167        let velocity = player.velocity()?;
168        let rigid_body = player.rigid_body.as_ref()?;
169        let velocity_xy = velocity.truncate().normalize_or_zero();
170        if velocity_xy.length_squared() <= f32::EPSILON {
171            return None;
172        }
173
174        let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
175            .truncate()
176            .normalize_or_zero();
177        if forward_xy.length_squared() <= f32::EPSILON {
178            return None;
179        }
180
181        Some(forward_xy.dot(velocity_xy))
182    }
183
184    fn candidate_alignment(
185        ball: &BallFrameState,
186        player: &PlayerSample,
187        is_kickoff: bool,
188    ) -> Option<f32> {
189        if is_kickoff {
190            Self::horizontal_alignment_to_target(player, Self::kickoff_alignment_target(ball))
191        } else {
192            Self::forward_speed_alignment(player)
193        }
194    }
195
196    fn apply_event(&mut self, event: SpeedFlipEvent) {
197        for stats in self.player_stats.values_mut() {
198            stats.is_last_speed_flip = false;
199        }
200
201        let stats = self.player_stats.entry(event.player.clone()).or_default();
202        stats.count += 1;
203        if event.confidence >= SPEED_FLIP_HIGH_CONFIDENCE {
204            stats.high_confidence_count += 1;
205        }
206        stats.is_last_speed_flip = true;
207        stats.last_speed_flip_time = Some(event.time);
208        stats.last_speed_flip_frame = Some(event.frame);
209        stats.time_since_last_speed_flip = Some(0.0);
210        stats.frames_since_last_speed_flip = Some(0);
211        stats.last_quality = Some(event.confidence);
212        stats.best_quality = stats.best_quality.max(event.confidence);
213        stats.cumulative_quality += event.confidence;
214
215        self.current_last_speed_flip_player = Some(event.player.clone());
216        self.events.push(event);
217    }
218
219    fn begin_sample(&mut self, frame: &FrameInfo) {
220        for stats in self.player_stats.values_mut() {
221            stats.is_last_speed_flip = false;
222            stats.time_since_last_speed_flip = stats
223                .last_speed_flip_time
224                .map(|time| (frame.time - time).max(0.0));
225            stats.frames_since_last_speed_flip = stats
226                .last_speed_flip_frame
227                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
228        }
229
230        if let Some(player_id) = self.current_last_speed_flip_player.as_ref() {
231            if let Some(stats) = self.player_stats.get_mut(player_id) {
232                stats.is_last_speed_flip = true;
233            }
234        }
235    }
236
237    fn reset_kickoff_state(&mut self, frame: &FrameInfo) {
238        self.active_candidates.clear();
239        self.current_kickoff_start_time = Some(frame.time);
240    }
241
242    fn maybe_start_candidate(
243        &mut self,
244        frame: &FrameInfo,
245        gameplay: &GameplayState,
246        ball: &BallFrameState,
247        player: &PlayerSample,
248        _live_play: bool,
249    ) {
250        let was_dodge_active = self
251            .previous_dodge_active
252            .insert(player.player_id.clone(), player.dodge_active)
253            .unwrap_or(false);
254        if !player.dodge_active || was_dodge_active {
255            return;
256        }
257
258        let is_kickoff = Self::kickoff_approach_active(gameplay);
259        let kickoff_start_time = if is_kickoff {
260            let Some(kickoff_start_time) = self.current_kickoff_start_time else {
261                return;
262            };
263            if frame.time - kickoff_start_time > SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS {
264                return;
265            }
266            Some(kickoff_start_time)
267        } else {
268            None
269        };
270
271        let Some(rigid_body) = player.rigid_body.as_ref() else {
272            return;
273        };
274        let Some(player_position) = player.position() else {
275            return;
276        };
277        if player_position.z > SPEED_FLIP_MAX_GROUND_Z {
278            return;
279        }
280
281        let start_speed = player.speed().unwrap_or(0.0);
282        if start_speed < SPEED_FLIP_MIN_START_SPEED {
283            return;
284        }
285
286        let Some(best_alignment) = Self::candidate_alignment(ball, player, is_kickoff) else {
287            return;
288        };
289        if best_alignment < SPEED_FLIP_MIN_ALIGNMENT {
290            return;
291        }
292
293        let rotation = quat_to_glam(&rigid_body.rotation);
294        let local_angular_velocity = rigid_body
295            .angular_velocity
296            .as_ref()
297            .map(vec_to_glam)
298            .map(|angular_velocity| rotation.inverse() * angular_velocity)
299            .unwrap_or(glam::Vec3::ZERO);
300        let best_diagonal_score = Self::diagonal_score(local_angular_velocity);
301        let forward_z = (rotation * glam::Vec3::X).z;
302
303        self.active_candidates.insert(
304            player.player_id.clone(),
305            ActiveSpeedFlipCandidate {
306                is_team_0: player.is_team_0,
307                is_kickoff,
308                kickoff_start_time,
309                start_time: frame.time,
310                start_frame: frame.frame_number,
311                start_position: player_position.to_array(),
312                end_position: player_position.to_array(),
313                start_speed,
314                max_speed: start_speed,
315                best_alignment,
316                best_diagonal_score,
317                min_forward_z: forward_z,
318                latest_forward_z: forward_z,
319                latest_time: frame.time,
320                latest_frame: frame.frame_number,
321            },
322        );
323    }
324
325    fn update_candidate(
326        candidate: &mut ActiveSpeedFlipCandidate,
327        frame: &FrameInfo,
328        ball: &BallFrameState,
329        player: &PlayerSample,
330    ) {
331        let Some(rigid_body) = player.rigid_body.as_ref() else {
332            return;
333        };
334
335        if let Some(player_position) = player.position() {
336            candidate.end_position = player_position.to_array();
337        }
338        candidate.max_speed = candidate.max_speed.max(player.speed().unwrap_or(0.0));
339        if let Some(alignment) = Self::candidate_alignment(ball, player, candidate.is_kickoff) {
340            candidate.best_alignment = candidate.best_alignment.max(alignment);
341        }
342
343        let rotation = quat_to_glam(&rigid_body.rotation);
344        let local_angular_velocity = rigid_body
345            .angular_velocity
346            .as_ref()
347            .map(vec_to_glam)
348            .map(|angular_velocity| rotation.inverse() * angular_velocity)
349            .unwrap_or(glam::Vec3::ZERO);
350        candidate.best_diagonal_score = candidate
351            .best_diagonal_score
352            .max(Self::diagonal_score(local_angular_velocity));
353
354        let forward_z = (rotation * glam::Vec3::X).z;
355        candidate.min_forward_z = candidate.min_forward_z.min(forward_z);
356        candidate.latest_forward_z = forward_z;
357        candidate.latest_time = frame.time;
358        candidate.latest_frame = frame.frame_number;
359    }
360
361    fn candidate_event(
362        player_id: &PlayerId,
363        candidate: ActiveSpeedFlipCandidate,
364    ) -> Option<SpeedFlipEvent> {
365        let time_since_kickoff_start = candidate
366            .kickoff_start_time
367            .map(|kickoff_start_time| (candidate.start_time - kickoff_start_time).max(0.0))
368            .unwrap_or(0.0);
369        let timeliness_score = if candidate.is_kickoff {
370            1.0 - Self::normalize_score(time_since_kickoff_start, 0.55, 1.1)
371        } else {
372            1.0
373        };
374        let cancel_recovery = candidate.latest_forward_z - candidate.min_forward_z;
375        let cancel_score = 0.35 * Self::normalize_score(-candidate.min_forward_z, 0.22, 0.75)
376            + 0.40 * Self::normalize_score(cancel_recovery, 0.18, 0.7)
377            + 0.25 * Self::normalize_score(candidate.latest_forward_z, -0.55, -0.05);
378        let speed_score = 0.55 * Self::normalize_score(candidate.max_speed, 1450.0, 1900.0)
379            + 0.45
380                * Self::normalize_score(candidate.max_speed - candidate.start_speed, 180.0, 650.0);
381        let alignment_score = Self::normalize_score(candidate.best_alignment, 0.78, 0.98);
382        let confidence = 0.30 * candidate.best_diagonal_score
383            + 0.30 * cancel_score
384            + 0.20 * speed_score
385            + 0.15 * alignment_score
386            + 0.05 * timeliness_score;
387
388        if cancel_score < 0.45 || confidence < SPEED_FLIP_MIN_CONFIDENCE {
389            return None;
390        }
391
392        Some(SpeedFlipEvent {
393            time: candidate.start_time,
394            frame: candidate.start_frame,
395            player: player_id.clone(),
396            is_team_0: candidate.is_team_0,
397            time_since_kickoff_start,
398            start_position: candidate.start_position,
399            end_position: candidate.end_position,
400            start_speed: candidate.start_speed,
401            max_speed: candidate.max_speed,
402            best_alignment: candidate.best_alignment,
403            diagonal_score: candidate.best_diagonal_score,
404            cancel_score,
405            speed_score,
406            confidence,
407        })
408    }
409
410    fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
411        let mut finished_candidates = Vec::new();
412
413        for (player_id, candidate) in &self.active_candidates {
414            let duration = frame.time - candidate.start_time;
415            if force_all || duration >= SPEED_FLIP_EVALUATION_SECONDS {
416                finished_candidates.push((
417                    candidate.start_time,
418                    candidate.start_frame,
419                    format!("{player_id:?}"),
420                    player_id.clone(),
421                ));
422            }
423        }
424
425        finished_candidates.sort_by(|left, right| {
426            left.0
427                .total_cmp(&right.0)
428                .then_with(|| left.1.cmp(&right.1))
429                .then_with(|| left.2.cmp(&right.2))
430        });
431
432        for (_, _, _, player_id) in finished_candidates {
433            let Some(candidate) = self.active_candidates.remove(&player_id) else {
434                continue;
435            };
436            if let Some(event) = Self::candidate_event(&player_id, candidate) {
437                self.apply_event(event);
438            }
439        }
440    }
441
442    pub fn update_parts(
443        &mut self,
444        frame: &FrameInfo,
445        gameplay: &GameplayState,
446        ball: &BallFrameState,
447        players: &PlayerFrameState,
448        live_play: bool,
449    ) -> SubtrActorResult<()> {
450        let kickoff_approach_active = Self::kickoff_approach_active(gameplay);
451        if !live_play && !kickoff_approach_active {
452            self.active_candidates.clear();
453            self.current_kickoff_start_time = None;
454            self.kickoff_approach_active_last_frame = false;
455            return Ok(());
456        }
457
458        self.begin_sample(frame);
459
460        if kickoff_approach_active && !self.kickoff_approach_active_last_frame {
461            self.reset_kickoff_state(frame);
462        }
463
464        for player in &players.players {
465            self.maybe_start_candidate(frame, gameplay, ball, player, live_play);
466        }
467
468        for (player_id, candidate) in &mut self.active_candidates {
469            let Some(player) = Self::player_by_id(players, player_id) else {
470                continue;
471            };
472            Self::update_candidate(candidate, frame, ball, player);
473        }
474
475        self.finalize_candidates(frame, false);
476
477        self.active_candidates.retain(|_, candidate| {
478            frame.time - candidate.start_time <= SPEED_FLIP_MAX_CANDIDATE_SECONDS
479        });
480
481        if !kickoff_approach_active {
482            self.current_kickoff_start_time = None;
483        }
484
485        self.kickoff_approach_active_last_frame = kickoff_approach_active;
486        Ok(())
487    }
488
489    pub fn finalize_parts(&mut self, frame: &FrameInfo) {
490        self.finalize_candidates(frame, true);
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn kickoff_approach_stays_active_before_first_touch_even_when_not_live_play() {
500        let mut calculator = SpeedFlipCalculator::default();
501        let frame = FrameInfo {
502            frame_number: 1,
503            time: 0.5,
504            dt: 0.1,
505            seconds_remaining: None,
506        };
507        let gameplay = GameplayState {
508            ball_has_been_hit: Some(false),
509            ..Default::default()
510        };
511
512        calculator
513            .update_parts(
514                &frame,
515                &gameplay,
516                &BallFrameState::default(),
517                &PlayerFrameState::default(),
518                false,
519            )
520            .unwrap();
521
522        assert!(calculator.kickoff_approach_active_last_frame);
523        assert_eq!(calculator.current_kickoff_start_time, Some(frame.time));
524    }
525}