Skip to main content

subtr_actor/stats/calculators/
half_flip.rs

1use super::*;
2
3const HALF_FLIP_EVALUATION_SECONDS: f32 = 0.65;
4const HALF_FLIP_MAX_CANDIDATE_SECONDS: f32 = 1.0;
5const HALF_FLIP_MIN_FORWARD_REVERSAL: f32 = 0.75;
6const HALF_FLIP_MIN_POST_REVERSAL_RETAINED_REVERSAL: f32 = 0.45;
7const HALF_FLIP_MIN_FINAL_FORWARD_HORIZONTAL: f32 = 0.55;
8const HALF_FLIP_MIN_FORWARD_VERTICAL: f32 = 0.55;
9const HALF_FLIP_MIN_CONFIDENCE: f32 = 0.55;
10/// Pre-dodge angular speed above which the car was already spinning hard before
11/// the dodge byte fired. Replay angular velocity is stored in hundredths of
12/// rad/s (RL saturates around 550, i.e. 5.5 rad/s), so 250 is ~2.5 rad/s.
13const HALF_FLIP_PRE_DODGE_TUMBLE_ANGULAR_SPEED: f32 = 250.0;
14/// Pre-dodge nose verticality (|forward.z|) above which the car was already
15/// pitched well off flat before the dodge byte fired.
16const HALF_FLIP_PRE_DODGE_TUMBLE_FORWARD_VERTICAL: f32 = 0.5;
17/// Minimum backward-entry alignment (velocity opposing facing) below which a
18/// dodge is not a deliberate half-flip entry. Real half-flips reverse the car
19/// (positive alignment) or at worst travel forward with a controlled body; an
20/// air-hit tumble is knocked forward into the spin (strongly negative).
21const HALF_FLIP_PRE_DODGE_MIN_BACKWARD_ENTRY: f32 = 0.0;
22
23/// A dodge sequence that cancels a flip into an opposite facing direction.
24#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
25#[ts(export)]
26pub struct HalfFlipEvent {
27    pub time: f32,
28    pub frame: usize,
29    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
30    pub player: PlayerId,
31    pub is_team_0: bool,
32    pub start_position: [f32; 3],
33    pub end_position: [f32; 3],
34    pub start_speed: f32,
35    pub end_speed: f32,
36    pub start_backward_alignment: f32,
37    pub best_reorientation_alignment: f32,
38    pub best_forward_reversal: f32,
39    pub max_forward_vertical: f32,
40    pub confidence: f32,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44struct ActiveHalfFlipCandidate {
45    is_team_0: bool,
46    start_time: f32,
47    start_frame: usize,
48    latest_time: f32,
49    latest_frame: usize,
50    start_position: [f32; 3],
51    end_position: [f32; 3],
52    start_speed: f32,
53    end_speed: f32,
54    start_forward_xy: glam::Vec2,
55    start_backward_alignment: f32,
56    best_reorientation_alignment: f32,
57    best_forward_reversal: f32,
58    latest_forward_reversal: f32,
59    min_forward_reversal_after_reaching_opposite: f32,
60    latest_forward_horizontal: f32,
61    max_forward_vertical: f32,
62    pre_dodge_angular_speed: f32,
63    pre_dodge_forward_vertical: f32,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
67struct PreDodgeSnapshot {
68    angular_speed: f32,
69    forward_vertical: f32,
70}
71
72/// Detects half-flips from player frame state.
73#[derive(Debug, Clone, Default, PartialEq)]
74pub struct HalfFlipCalculator {
75    events: EventStream<HalfFlipEvent>,
76    active_candidates: HashMap<PlayerId, ActiveHalfFlipCandidate>,
77    previous_dodge_active: HashMap<PlayerId, bool>,
78    pre_dodge_snapshot: HashMap<PlayerId, PreDodgeSnapshot>,
79}
80
81impl HalfFlipCalculator {
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    pub fn events(&self) -> &[HalfFlipEvent] {
87        self.events.all()
88    }
89
90    pub fn new_events(&self) -> &[HalfFlipEvent] {
91        self.events.new_events()
92    }
93
94    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
95        if max_value <= min_value {
96            return 0.0;
97        }
98
99        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
100    }
101
102    fn horizontal_velocity(player: &PlayerSample) -> Option<glam::Vec2> {
103        let velocity = player.velocity()?.truncate();
104        if velocity.length_squared() <= f32::EPSILON {
105            return None;
106        }
107        Some(velocity)
108    }
109
110    fn forward_vector(player: &PlayerSample) -> Option<glam::Vec3> {
111        let rigid_body = player.rigid_body.as_ref()?;
112        Some(quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
113    }
114
115    fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
116        let forward_xy = Self::forward_vector(player)?.truncate().normalize_or_zero();
117        if forward_xy.length_squared() <= f32::EPSILON {
118            return None;
119        }
120        Some(forward_xy)
121    }
122
123    fn angular_speed(player: &PlayerSample) -> f32 {
124        player
125            .rigid_body
126            .as_ref()
127            .and_then(|rb| rb.angular_velocity.as_ref())
128            .map_or(0.0, |angular| vec_to_glam(angular).length())
129    }
130
131    fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
132        let was_dodge_active = self
133            .previous_dodge_active
134            .insert(player.player_id.clone(), player.dodge_active)
135            .unwrap_or(false);
136        if !player.dodge_active || was_dodge_active {
137            return;
138        }
139
140        let Some(position) = player.position() else {
141            return;
142        };
143
144        let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
145        let start_speed = velocity_xy.length();
146
147        let Some(start_forward_xy) = Self::forward_xy(player) else {
148            return;
149        };
150        let velocity_direction = velocity_xy.normalize_or_zero();
151        let start_backward_alignment = -start_forward_xy.dot(velocity_direction);
152
153        let max_forward_vertical =
154            Self::forward_vector(player).map_or(0.0, |forward| forward.z.abs());
155
156        let pre_dodge = self
157            .pre_dodge_snapshot
158            .get(&player.player_id)
159            .copied()
160            .unwrap_or(PreDodgeSnapshot {
161                angular_speed: 0.0,
162                forward_vertical: 0.0,
163            });
164
165        self.active_candidates.insert(
166            player.player_id.clone(),
167            ActiveHalfFlipCandidate {
168                is_team_0: player.is_team_0,
169                start_time: frame.time,
170                start_frame: frame.frame_number,
171                latest_time: frame.time,
172                latest_frame: frame.frame_number,
173                start_position: position.to_array(),
174                end_position: position.to_array(),
175                start_speed,
176                end_speed: start_speed,
177                start_forward_xy,
178                start_backward_alignment,
179                best_reorientation_alignment: 0.0,
180                best_forward_reversal: 0.0,
181                latest_forward_reversal: 0.0,
182                min_forward_reversal_after_reaching_opposite: 1.0,
183                latest_forward_horizontal: start_forward_xy.length(),
184                max_forward_vertical,
185                pre_dodge_angular_speed: pre_dodge.angular_speed,
186                pre_dodge_forward_vertical: pre_dodge.forward_vertical,
187            },
188        );
189    }
190
191    fn update_candidate(
192        candidate: &mut ActiveHalfFlipCandidate,
193        frame: &FrameInfo,
194        player: &PlayerSample,
195    ) {
196        if let Some(position) = player.position() {
197            candidate.end_position = position.to_array();
198        }
199
200        let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
201        candidate.end_speed = velocity_xy.length();
202        let velocity_direction = velocity_xy.normalize_or_zero();
203
204        if let Some(forward) = Self::forward_vector(player) {
205            candidate.max_forward_vertical = candidate.max_forward_vertical.max(forward.z.abs());
206            let forward_xy_raw = forward.truncate();
207            candidate.latest_forward_horizontal = forward_xy_raw.length();
208            if candidate.latest_forward_horizontal >= HALF_FLIP_MIN_FINAL_FORWARD_HORIZONTAL {
209                let forward_xy = forward_xy_raw.normalize_or_zero();
210                candidate.latest_forward_reversal =
211                    (-candidate.start_forward_xy.dot(forward_xy)).clamp(-1.0, 1.0);
212                candidate.best_forward_reversal = candidate
213                    .best_forward_reversal
214                    .max(candidate.latest_forward_reversal);
215                if candidate.best_forward_reversal >= HALF_FLIP_MIN_FORWARD_REVERSAL {
216                    candidate.min_forward_reversal_after_reaching_opposite = candidate
217                        .min_forward_reversal_after_reaching_opposite
218                        .min(candidate.latest_forward_reversal);
219                }
220                if velocity_direction.length_squared() > f32::EPSILON {
221                    candidate.best_reorientation_alignment = candidate
222                        .best_reorientation_alignment
223                        .max(forward_xy.dot(velocity_direction));
224                }
225            }
226        }
227
228        candidate.latest_time = frame.time;
229        candidate.latest_frame = frame.frame_number;
230    }
231
232    fn candidate_event(
233        player_id: &PlayerId,
234        candidate: ActiveHalfFlipCandidate,
235    ) -> Option<HalfFlipEvent> {
236        if candidate.latest_forward_reversal < HALF_FLIP_MIN_FORWARD_REVERSAL
237            || candidate.min_forward_reversal_after_reaching_opposite
238                < HALF_FLIP_MIN_POST_REVERSAL_RETAINED_REVERSAL
239            || candidate.latest_forward_horizontal < HALF_FLIP_MIN_FINAL_FORWARD_HORIZONTAL
240            || candidate.max_forward_vertical < HALF_FLIP_MIN_FORWARD_VERTICAL
241        {
242            return None;
243        }
244
245        let reversal_score = Self::normalize_score(
246            candidate.latest_forward_reversal,
247            HALF_FLIP_MIN_FORWARD_REVERSAL,
248            0.98,
249        );
250        let flip_score = Self::normalize_score(
251            candidate.max_forward_vertical,
252            HALF_FLIP_MIN_FORWARD_VERTICAL,
253            0.85,
254        );
255        let confidence = 0.75 * reversal_score + 0.25 * flip_score;
256
257        if confidence < HALF_FLIP_MIN_CONFIDENCE {
258            return None;
259        }
260
261        // Reject dodges where the facing reversal was not produced by the dodge:
262        // if the car was already tumbling (high pre-dodge spin or an
263        // already-pitched nose) when the dodge byte fired *and* it was not a
264        // deliberate backward half-flip entry, this is an air-hit tumble / failed
265        // wave dash that happens to flip around through vertical, not a half-flip.
266        let was_already_tumbling = candidate.pre_dodge_angular_speed
267            >= HALF_FLIP_PRE_DODGE_TUMBLE_ANGULAR_SPEED
268            || candidate.pre_dodge_forward_vertical >= HALF_FLIP_PRE_DODGE_TUMBLE_FORWARD_VERTICAL;
269        if was_already_tumbling
270            && candidate.start_backward_alignment < HALF_FLIP_PRE_DODGE_MIN_BACKWARD_ENTRY
271        {
272            return None;
273        }
274
275        Some(HalfFlipEvent {
276            time: candidate.latest_time,
277            frame: candidate.latest_frame,
278            player: player_id.clone(),
279            is_team_0: candidate.is_team_0,
280            start_position: candidate.start_position,
281            end_position: candidate.end_position,
282            start_speed: candidate.start_speed,
283            end_speed: candidate.end_speed,
284            start_backward_alignment: candidate.start_backward_alignment,
285            best_reorientation_alignment: candidate.best_reorientation_alignment,
286            best_forward_reversal: candidate.best_forward_reversal,
287            max_forward_vertical: candidate.max_forward_vertical,
288            confidence,
289        })
290    }
291
292    fn apply_event(&mut self, event: HalfFlipEvent) {
293        self.events.push(event);
294    }
295
296    fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
297        let mut finished_candidates = Vec::new();
298
299        for (player_id, candidate) in &self.active_candidates {
300            let duration = frame.time - candidate.start_time;
301            if force_all || duration >= HALF_FLIP_EVALUATION_SECONDS {
302                finished_candidates.push((
303                    candidate.start_time,
304                    candidate.start_frame,
305                    format!("{player_id:?}"),
306                    player_id.clone(),
307                ));
308            }
309        }
310
311        finished_candidates.sort_by(|left, right| {
312            left.0
313                .total_cmp(&right.0)
314                .then_with(|| left.1.cmp(&right.1))
315                .then_with(|| left.2.cmp(&right.2))
316        });
317
318        for (_, _, _, player_id) in finished_candidates {
319            let Some(candidate) = self.active_candidates.remove(&player_id) else {
320                continue;
321            };
322            if let Some(event) = Self::candidate_event(&player_id, candidate) {
323                self.apply_event(event);
324            }
325        }
326    }
327
328    pub fn update(
329        &mut self,
330        frame: &FrameInfo,
331        players: &PlayerFrameState,
332        live_play_state: &LivePlayState,
333    ) -> SubtrActorResult<()> {
334        self.events.begin_update();
335        if !live_play_state.is_live_play {
336            self.active_candidates.clear();
337            return Ok(());
338        }
339
340        for player in &players.players {
341            self.maybe_start_candidate(frame, player);
342        }
343
344        let mut visible_players = HashSet::new();
345        for player in &players.players {
346            visible_players.insert(player.player_id.clone());
347            if let Some(candidate) = self.active_candidates.get_mut(&player.player_id) {
348                Self::update_candidate(candidate, frame, player);
349            }
350            // Remember the most recent non-dodging orientation so a freshly
351            // started candidate can tell whether the car was already tumbling
352            // (e.g. knocked into an air spin) *before* the dodge began. A real
353            // half-flip starts from a controlled, roughly-flat car and the
354            // dodge initiates the rotation; an air-hit tumble is already
355            // spinning hard when the dodge byte appears.
356            if !player.dodge_active {
357                self.pre_dodge_snapshot.insert(
358                    player.player_id.clone(),
359                    PreDodgeSnapshot {
360                        angular_speed: Self::angular_speed(player),
361                        forward_vertical: Self::forward_vector(player)
362                            .map_or(0.0, |forward| forward.z.abs()),
363                    },
364                );
365            }
366        }
367
368        self.finalize_candidates(frame, false);
369        self.active_candidates.retain(|player_id, candidate| {
370            visible_players.contains(player_id)
371                && frame.time - candidate.start_time <= HALF_FLIP_MAX_CANDIDATE_SECONDS
372        });
373
374        Ok(())
375    }
376
377    pub fn finalize(&mut self, frame: &FrameInfo) {
378        self.finalize_candidates(frame, true);
379    }
380}
381
382#[cfg(test)]
383#[path = "half_flip_tests.rs"]
384mod tests;