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