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