Skip to main content

subtr_actor/stats/calculators/
flip_reset.rs

1use crate::*;
2use serde::Serialize;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
6#[ts(export)]
7pub struct FlipResetEvent {
8    pub time: f32,
9    pub frame: usize,
10    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
11    pub player: PlayerId,
12    pub is_team_0: bool,
13    /// Heuristic confidence in the range `[0.0, 1.0]`.
14    pub confidence: f32,
15    /// Ball position relative to the car in the car's local frame.
16    #[ts(as = "crate::ts_bindings::Vector3fTs")]
17    pub local_ball_position: boxcars::Vector3f,
18    /// Motion-aware closest approach distance used for touch attribution.
19    pub closest_approach_distance: f32,
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
23#[ts(export)]
24pub struct DodgeRefreshedEvent {
25    pub time: f32,
26    pub frame: usize,
27    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
28    pub player: PlayerId,
29    pub is_team_0: bool,
30    pub counter_value: i32,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
34#[ts(export)]
35pub struct PostWallDodgeEvent {
36    pub time: f32,
37    pub frame: usize,
38    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
39    pub player: PlayerId,
40    pub is_team_0: bool,
41    pub wall_contact_time: f32,
42    pub time_since_wall_contact: f32,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
46#[ts(export)]
47pub struct FlipResetFollowupDodgeEvent {
48    pub time: f32,
49    pub frame: usize,
50    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
51    pub player: PlayerId,
52    pub is_team_0: bool,
53    pub candidate_touch_time: f32,
54    pub time_since_candidate_touch: f32,
55    pub candidate_touch_confidence: f32,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub(crate) struct FlipResetHeuristic {
60    pub confidence: f32,
61    pub local_ball_position: glam::Vec3,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
65struct FlipResetTouchFeatures {
66    player_position: glam::Vec3,
67    ball_position: glam::Vec3,
68    center_distance: f32,
69    local_ball_position: glam::Vec3,
70    scaled_touch_distance: f32,
71    underside_alignment: f32,
72}
73
74fn scale_factor_for_positions(ball_position: glam::Vec3, player_position: glam::Vec3) -> f32 {
75    if ball_position
76        .truncate()
77        .abs()
78        .max(player_position.truncate().abs())
79        .max_element()
80        < 200.0
81    {
82        100.0
83    } else {
84        1.0
85    }
86}
87
88fn build_touch_features(
89    ball_body: &boxcars::RigidBody,
90    player_body: &boxcars::RigidBody,
91    closest_approach_distance: f32,
92) -> Option<FlipResetTouchFeatures> {
93    let raw_ball_position = vec_to_glam(&ball_body.location);
94    let raw_player_position = vec_to_glam(&player_body.location);
95    let scale_factor = scale_factor_for_positions(raw_ball_position, raw_player_position);
96
97    let ball_position = raw_ball_position * scale_factor;
98    let player_position = raw_player_position * scale_factor;
99    let relative_ball_position = ball_position - player_position;
100    let center_distance = relative_ball_position.length();
101    if !center_distance.is_finite() || center_distance <= 30.0 || center_distance >= 550.0 {
102        return None;
103    }
104
105    let player_rotation = quat_to_glam(&player_body.rotation);
106    let local_ball_position = player_rotation.inverse() * relative_ball_position;
107    let car_up = (player_rotation * glam::Vec3::Z).normalize_or_zero();
108    let underside_alignment = (-car_up).dot(relative_ball_position.normalize_or_zero());
109    let scaled_touch_distance = closest_approach_distance * scale_factor;
110
111    Some(FlipResetTouchFeatures {
112        player_position,
113        ball_position,
114        center_distance,
115        local_ball_position,
116        scaled_touch_distance,
117        underside_alignment,
118    })
119}
120
121fn flip_reset_confidence(features: &FlipResetTouchFeatures) -> f32 {
122    let below_car_score = (-features.local_ball_position.z / 180.0).clamp(0.0, 1.0);
123    let alignment_score = ((features.underside_alignment - 0.45) / 0.50).clamp(0.0, 1.0);
124    let touch_score = (1.0 - ((features.scaled_touch_distance - 20.0) / 220.0)).clamp(0.0, 1.0);
125    let height_score = ((features.player_position.z - 70.0) / 500.0).clamp(0.0, 1.0);
126    let footprint_score = (1.0
127        - (features.local_ball_position.x.abs() / 260.0).clamp(0.0, 1.0) * 0.5
128        - (features.local_ball_position.y.abs() / 260.0).clamp(0.0, 1.0) * 0.5)
129        .clamp(0.0, 1.0);
130    0.28 * below_car_score
131        + 0.26 * alignment_score
132        + 0.20 * touch_score
133        + 0.14 * height_score
134        + 0.12 * footprint_score
135}
136
137/// Returns a conservative flip-reset heuristic for a touch, if the geometry
138/// looks like an underside wheel contact on an airborne car.
139pub(crate) fn flip_reset_candidate(
140    ball_body: &boxcars::RigidBody,
141    player_body: &boxcars::RigidBody,
142    closest_approach_distance: f32,
143) -> Option<FlipResetHeuristic> {
144    const MIN_PLAYER_HEIGHT: f32 = 95.0;
145    const MIN_BALL_HEIGHT: f32 = 80.0;
146    const MAX_TOUCH_DISTANCE: f32 = 220.0;
147    const MIN_UNDERSIDE_ALIGNMENT: f32 = 0.60;
148    const MAX_LOCAL_FORWARD_OFFSET: f32 = 240.0;
149    const MAX_LOCAL_LATERAL_OFFSET: f32 = 240.0;
150    const MIN_CONFIDENCE: f32 = 0.55;
151
152    let features = build_touch_features(ball_body, player_body, closest_approach_distance)?;
153    if features.player_position.z < MIN_PLAYER_HEIGHT || features.ball_position.z < MIN_BALL_HEIGHT
154    {
155        return None;
156    }
157    if features.scaled_touch_distance > MAX_TOUCH_DISTANCE
158        || features.underside_alignment < MIN_UNDERSIDE_ALIGNMENT
159        || features.local_ball_position.x.abs() > MAX_LOCAL_FORWARD_OFFSET
160        || features.local_ball_position.y.abs() > MAX_LOCAL_LATERAL_OFFSET
161        || features.local_ball_position.z >= 10.0
162    {
163        return None;
164    }
165
166    let confidence = flip_reset_confidence(&features);
167    if confidence < MIN_CONFIDENCE {
168        return None;
169    }
170
171    Some(FlipResetHeuristic {
172        confidence,
173        local_ball_position: features.local_ball_position,
174    })
175}
176
177/// Returns a looser underside-touch heuristic intended to be paired with a
178/// later dodge event. This is less precise than [`flip_reset_candidate`] but
179/// useful when the later dodge provides extra evidence.
180pub(crate) fn flip_reset_followup_touch_candidate(
181    ball_body: &boxcars::RigidBody,
182    player_body: &boxcars::RigidBody,
183    closest_approach_distance: f32,
184) -> Option<FlipResetHeuristic> {
185    let features = build_touch_features(ball_body, player_body, closest_approach_distance)?;
186    let confidence = flip_reset_confidence(&features);
187
188    if confidence < 0.45
189        || features.local_ball_position.z >= 20.0
190        || features.underside_alignment < 0.25
191    {
192        return None;
193    }
194
195    Some(FlipResetHeuristic {
196        confidence,
197        local_ball_position: features.local_ball_position,
198    })
199}
200
201fn flip_reset_proximity_candidate(
202    ball_body: &boxcars::RigidBody,
203    player_body: &boxcars::RigidBody,
204) -> Option<FlipResetHeuristic> {
205    const MIN_PLAYER_HEIGHT: f32 = 95.0;
206    const MIN_BALL_HEIGHT: f32 = 80.0;
207    const MAX_CENTER_DISTANCE: f32 = 110.0;
208    const MIN_UNDERSIDE_ALIGNMENT: f32 = 0.52;
209    const MAX_LOCAL_FORWARD_OFFSET: f32 = 260.0;
210    const MAX_LOCAL_LATERAL_OFFSET: f32 = 260.0;
211    const MIN_CONFIDENCE: f32 = 0.52;
212
213    let raw_ball_position = vec_to_glam(&ball_body.location);
214    let raw_player_position = vec_to_glam(&player_body.location);
215    let scale_factor = scale_factor_for_positions(raw_ball_position, raw_player_position);
216    let center_distance = (raw_ball_position - raw_player_position).length() * scale_factor;
217    let features = build_touch_features(ball_body, player_body, center_distance / scale_factor)?;
218    if features.player_position.z < MIN_PLAYER_HEIGHT || features.ball_position.z < MIN_BALL_HEIGHT
219    {
220        return None;
221    }
222    if features.center_distance > MAX_CENTER_DISTANCE
223        || features.underside_alignment < MIN_UNDERSIDE_ALIGNMENT
224        || features.local_ball_position.x.abs() > MAX_LOCAL_FORWARD_OFFSET
225        || features.local_ball_position.y.abs() > MAX_LOCAL_LATERAL_OFFSET
226        || features.local_ball_position.z >= 15.0
227    {
228        return None;
229    }
230
231    let confidence = flip_reset_confidence(&features);
232    if confidence < MIN_CONFIDENCE {
233        return None;
234    }
235
236    Some(FlipResetHeuristic {
237        confidence,
238        local_ball_position: features.local_ball_position,
239    })
240}
241
242#[derive(Debug, Clone, Default)]
243pub struct FlipResetTracker {
244    flip_reset_events: Vec<FlipResetEvent>,
245    current_frame_flip_reset_events: Vec<FlipResetEvent>,
246    post_wall_dodge_events: Vec<PostWallDodgeEvent>,
247    current_frame_post_wall_dodge_events: Vec<PostWallDodgeEvent>,
248    flip_reset_followup_dodge_events: Vec<FlipResetFollowupDodgeEvent>,
249    current_frame_flip_reset_followup_dodge_events: Vec<FlipResetFollowupDodgeEvent>,
250    recent_wall_contact_time: HashMap<PlayerId, f32>,
251    recent_flip_reset_candidates: HashMap<PlayerId, FlipResetEvent>,
252    recent_flip_reset_proximity_event_time: HashMap<PlayerId, f32>,
253    current_frame_dodge_rising_edges: Vec<PlayerId>,
254    previous_dodge_active: HashMap<PlayerId, bool>,
255}
256
257impl FlipResetTracker {
258    pub fn new() -> Self {
259        Self::default()
260    }
261
262    pub fn on_frame(
263        &mut self,
264        processor: &ReplayProcessor,
265        frame: &boxcars::Frame,
266        frame_index: usize,
267    ) -> SubtrActorResult<()> {
268        self.update_flip_reset_events(processor, frame.time, frame_index)?;
269        self.update_dodge_rising_edges(processor)?;
270        self.update_flip_reset_followup_dodge_events(processor, frame, frame_index)?;
271        self.update_post_wall_dodge_events(processor, frame, frame_index)?;
272        Ok(())
273    }
274
275    pub fn flip_reset_events(&self) -> &[FlipResetEvent] {
276        &self.flip_reset_events
277    }
278
279    pub fn current_frame_flip_reset_events(&self) -> &[FlipResetEvent] {
280        &self.current_frame_flip_reset_events
281    }
282
283    pub fn post_wall_dodge_events(&self) -> &[PostWallDodgeEvent] {
284        &self.post_wall_dodge_events
285    }
286
287    pub fn current_frame_post_wall_dodge_events(&self) -> &[PostWallDodgeEvent] {
288        &self.current_frame_post_wall_dodge_events
289    }
290
291    pub fn flip_reset_followup_dodge_events(&self) -> &[FlipResetFollowupDodgeEvent] {
292        &self.flip_reset_followup_dodge_events
293    }
294
295    pub fn current_frame_flip_reset_followup_dodge_events(&self) -> &[FlipResetFollowupDodgeEvent] {
296        &self.current_frame_flip_reset_followup_dodge_events
297    }
298
299    pub fn into_events(
300        self,
301    ) -> (
302        Vec<FlipResetEvent>,
303        Vec<PostWallDodgeEvent>,
304        Vec<FlipResetFollowupDodgeEvent>,
305    ) {
306        (
307            self.flip_reset_events,
308            self.post_wall_dodge_events,
309            self.flip_reset_followup_dodge_events,
310        )
311    }
312
313    fn build_flip_reset_event(
314        &self,
315        processor: &ReplayProcessor,
316        touch_event: &TouchEvent,
317        frame_index: usize,
318    ) -> Option<FlipResetEvent> {
319        let player = touch_event.player.as_ref()?;
320        let closest_approach_distance = touch_event.closest_approach_distance?;
321        let ball_rigid_body = processor.get_normalized_ball_rigid_body().ok()?;
322        let player_rigid_body = processor.get_normalized_player_rigid_body(player).ok()?;
323        let heuristic = flip_reset_candidate(
324            &ball_rigid_body,
325            &player_rigid_body,
326            closest_approach_distance,
327        )?;
328
329        Some(FlipResetEvent {
330            time: touch_event.time,
331            frame: frame_index,
332            player: player.clone(),
333            is_team_0: touch_event.team_is_team_0,
334            confidence: heuristic.confidence,
335            local_ball_position: glam_to_vec(&heuristic.local_ball_position),
336            closest_approach_distance,
337        })
338    }
339
340    fn build_flip_reset_event_for_player(
341        &self,
342        processor: &ReplayProcessor,
343        player: &PlayerId,
344        time: f32,
345        frame_index: usize,
346        is_team_0: bool,
347        closest_approach_distance: f32,
348    ) -> Option<FlipResetEvent> {
349        let ball_rigid_body = processor.get_normalized_ball_rigid_body().ok()?;
350        let player_rigid_body = processor.get_normalized_player_rigid_body(player).ok()?;
351        let heuristic = flip_reset_candidate(
352            &ball_rigid_body,
353            &player_rigid_body,
354            closest_approach_distance,
355        )?;
356
357        Some(FlipResetEvent {
358            time,
359            frame: frame_index,
360            player: player.clone(),
361            is_team_0,
362            confidence: heuristic.confidence,
363            local_ball_position: glam_to_vec(&heuristic.local_ball_position),
364            closest_approach_distance,
365        })
366    }
367
368    fn build_flip_reset_followup_touch_candidate(
369        &self,
370        processor: &ReplayProcessor,
371        touch_event: &TouchEvent,
372        frame_index: usize,
373    ) -> Option<FlipResetEvent> {
374        let player = touch_event.player.as_ref()?;
375        let closest_approach_distance = touch_event.closest_approach_distance?;
376        let ball_rigid_body = processor.get_normalized_ball_rigid_body().ok()?;
377        let player_rigid_body = processor.get_normalized_player_rigid_body(player).ok()?;
378        let heuristic = flip_reset_followup_touch_candidate(
379            &ball_rigid_body,
380            &player_rigid_body,
381            closest_approach_distance,
382        )?;
383
384        Some(FlipResetEvent {
385            time: touch_event.time,
386            frame: frame_index,
387            player: player.clone(),
388            is_team_0: touch_event.team_is_team_0,
389            confidence: heuristic.confidence,
390            local_ball_position: glam_to_vec(&heuristic.local_ball_position),
391            closest_approach_distance,
392        })
393    }
394
395    fn build_flip_reset_proximity_event(
396        &self,
397        processor: &ReplayProcessor,
398        player: &PlayerId,
399        time: f32,
400        frame_index: usize,
401    ) -> Option<FlipResetEvent> {
402        let ball_rigid_body = processor.get_normalized_ball_rigid_body().ok()?;
403        let player_rigid_body = processor.get_normalized_player_rigid_body(player).ok()?;
404        let heuristic = flip_reset_proximity_candidate(&ball_rigid_body, &player_rigid_body)?;
405        let raw_ball_position = vec_to_glam(&ball_rigid_body.location);
406        let raw_player_position = vec_to_glam(&player_rigid_body.location);
407        let scale_factor = scale_factor_for_positions(raw_ball_position, raw_player_position);
408        let closest_approach_distance =
409            (raw_ball_position - raw_player_position).length() * scale_factor;
410
411        Some(FlipResetEvent {
412            time,
413            frame: frame_index,
414            player: player.clone(),
415            is_team_0: processor.get_player_is_team_0(player).unwrap_or(false),
416            confidence: heuristic.confidence,
417            local_ball_position: glam_to_vec(&heuristic.local_ball_position),
418            closest_approach_distance,
419        })
420    }
421
422    fn update_flip_reset_events(
423        &mut self,
424        processor: &ReplayProcessor,
425        current_time: f32,
426        frame_index: usize,
427    ) -> SubtrActorResult<()> {
428        const PROXIMITY_EVENT_DEBOUNCE_SECONDS: f32 = 0.35;
429
430        self.current_frame_flip_reset_events.clear();
431        for touch_event in processor.current_frame_touch_events() {
432            let event = self
433                .build_flip_reset_event(processor, touch_event, frame_index)
434                .or_else(|| {
435                    let ball_rigid_body = processor.get_normalized_ball_rigid_body().ok()?;
436                    let ball_position = vec_to_glam(&ball_rigid_body.location);
437                    processor
438                        .iter_player_ids_in_order()
439                        .filter(|player| {
440                            processor.get_player_is_team_0(player).ok()
441                                == Some(touch_event.team_is_team_0)
442                        })
443                        .filter_map(|player| {
444                            let player_rigid_body =
445                                processor.get_normalized_player_rigid_body(player).ok()?;
446                            let player_position = vec_to_glam(&player_rigid_body.location);
447                            let fallback_touch_distance =
448                                (ball_position - player_position).length();
449                            self.build_flip_reset_event_for_player(
450                                processor,
451                                player,
452                                touch_event.time,
453                                frame_index,
454                                touch_event.team_is_team_0,
455                                fallback_touch_distance,
456                            )
457                        })
458                        .max_by(|left, right| {
459                            left.confidence
460                                .partial_cmp(&right.confidence)
461                                .unwrap_or(std::cmp::Ordering::Equal)
462                        })
463                });
464            let Some(event) = event else {
465                continue;
466            };
467            self.current_frame_flip_reset_events.push(event.clone());
468            self.flip_reset_events.push(event);
469        }
470
471        for player in processor.iter_player_ids_in_order() {
472            let already_emitted_this_frame = self
473                .current_frame_flip_reset_events
474                .iter()
475                .any(|event| &event.player == player);
476            if already_emitted_this_frame {
477                continue;
478            }
479            if self
480                .recent_flip_reset_proximity_event_time
481                .get(player)
482                .map(|previous_time| {
483                    current_time - previous_time < PROXIMITY_EVENT_DEBOUNCE_SECONDS
484                })
485                .unwrap_or(false)
486            {
487                continue;
488            }
489            let Some(event) =
490                self.build_flip_reset_proximity_event(processor, player, current_time, frame_index)
491            else {
492                continue;
493            };
494            self.recent_flip_reset_proximity_event_time
495                .insert(player.clone(), current_time);
496            self.current_frame_flip_reset_events.push(event.clone());
497            self.flip_reset_events.push(event);
498        }
499        Ok(())
500    }
501
502    fn update_dodge_rising_edges(&mut self, processor: &ReplayProcessor) -> SubtrActorResult<()> {
503        self.current_frame_dodge_rising_edges.clear();
504        let player_ids: Vec<_> = processor.iter_player_ids_in_order().cloned().collect();
505
506        for player_id in player_ids {
507            let dodge_active = processor.get_dodge_active(&player_id).unwrap_or(0) % 2 == 1;
508            let was_dodge_active = self
509                .previous_dodge_active
510                .insert(player_id.clone(), dodge_active)
511                .unwrap_or(false);
512            if dodge_active && !was_dodge_active {
513                self.current_frame_dodge_rising_edges.push(player_id);
514            }
515        }
516
517        Ok(())
518    }
519
520    fn wall_sequence_scale_factor(player_rigid_body: &boxcars::RigidBody) -> f32 {
521        if player_rigid_body
522            .location
523            .x
524            .abs()
525            .max(player_rigid_body.location.y.abs())
526            < 200.0
527        {
528            100.0
529        } else {
530            1.0
531        }
532    }
533
534    fn player_is_grounded_for_wall_sequence(player_rigid_body: &boxcars::RigidBody) -> bool {
535        player_rigid_body.location.z * Self::wall_sequence_scale_factor(player_rigid_body) <= 80.0
536    }
537
538    fn player_is_touching_wall(player_rigid_body: &boxcars::RigidBody) -> bool {
539        let scale_factor = Self::wall_sequence_scale_factor(player_rigid_body);
540        let location = &player_rigid_body.location;
541        let x = location.x.abs() * scale_factor;
542        let y = location.y.abs() * scale_factor;
543        let z = location.z * scale_factor;
544        z >= 120.0 && (x >= 3600.0 || y >= 5000.0)
545    }
546
547    fn update_post_wall_dodge_events(
548        &mut self,
549        processor: &ReplayProcessor,
550        frame: &boxcars::Frame,
551        frame_index: usize,
552    ) -> SubtrActorResult<()> {
553        const MIN_DELAY_AFTER_WALL_SECONDS: f32 = 0.20;
554        const MAX_DELAY_AFTER_WALL_SECONDS: f32 = 1.10;
555
556        self.current_frame_post_wall_dodge_events.clear();
557        let current_time = frame.time;
558        let player_ids: Vec<_> = processor.iter_player_ids_in_order().cloned().collect();
559
560        for player_id in &player_ids {
561            let Ok(player_rigid_body) = processor.get_normalized_player_rigid_body(player_id)
562            else {
563                self.previous_dodge_active.remove(player_id);
564                continue;
565            };
566
567            let is_grounded = Self::player_is_grounded_for_wall_sequence(&player_rigid_body);
568            if is_grounded {
569                self.recent_wall_contact_time.remove(player_id);
570            } else if Self::player_is_touching_wall(&player_rigid_body) {
571                self.recent_wall_contact_time
572                    .insert(player_id.clone(), current_time);
573            }
574        }
575
576        for player_id in &self.current_frame_dodge_rising_edges {
577            let Ok(player_rigid_body) = processor.get_normalized_player_rigid_body(player_id)
578            else {
579                continue;
580            };
581            let is_grounded = Self::player_is_grounded_for_wall_sequence(&player_rigid_body);
582            if is_grounded {
583                continue;
584            }
585
586            let Some(wall_contact_time) = self.recent_wall_contact_time.get(player_id).copied()
587            else {
588                continue;
589            };
590            let time_since_wall_contact = current_time - wall_contact_time;
591            if !(MIN_DELAY_AFTER_WALL_SECONDS..=MAX_DELAY_AFTER_WALL_SECONDS)
592                .contains(&time_since_wall_contact)
593            {
594                continue;
595            }
596            if Self::player_is_touching_wall(&player_rigid_body) {
597                continue;
598            }
599
600            let event = PostWallDodgeEvent {
601                time: current_time,
602                frame: frame_index,
603                player: player_id.clone(),
604                is_team_0: processor.get_player_is_team_0(player_id).unwrap_or(false),
605                wall_contact_time,
606                time_since_wall_contact,
607            };
608            self.current_frame_post_wall_dodge_events
609                .push(event.clone());
610            self.post_wall_dodge_events.push(event);
611        }
612
613        Ok(())
614    }
615
616    fn update_flip_reset_followup_dodge_events(
617        &mut self,
618        processor: &ReplayProcessor,
619        frame: &boxcars::Frame,
620        frame_index: usize,
621    ) -> SubtrActorResult<()> {
622        const MIN_DELAY_AFTER_CANDIDATE_TOUCH_SECONDS: f32 = 0.05;
623        const MAX_DELAY_AFTER_CANDIDATE_TOUCH_SECONDS: f32 = 1.75;
624
625        self.current_frame_flip_reset_followup_dodge_events.clear();
626        let current_time = frame.time;
627        let player_ids: Vec<_> = processor.iter_player_ids_in_order().cloned().collect();
628
629        for player_id in &player_ids {
630            let Ok(player_rigid_body) = processor.get_normalized_player_rigid_body(player_id)
631            else {
632                self.recent_flip_reset_candidates.remove(player_id);
633                continue;
634            };
635            if Self::player_is_grounded_for_wall_sequence(&player_rigid_body) {
636                self.recent_flip_reset_candidates.remove(player_id);
637            }
638        }
639
640        for touch_event in processor.current_frame_touch_events() {
641            let Some(event) =
642                self.build_flip_reset_followup_touch_candidate(processor, touch_event, frame_index)
643            else {
644                continue;
645            };
646            self.recent_flip_reset_candidates
647                .insert(event.player.clone(), event.clone());
648        }
649
650        for player_id in &self.current_frame_dodge_rising_edges {
651            let Ok(player_rigid_body) = processor.get_normalized_player_rigid_body(player_id)
652            else {
653                continue;
654            };
655            if Self::player_is_grounded_for_wall_sequence(&player_rigid_body) {
656                continue;
657            }
658
659            let Some(candidate_event) = self.recent_flip_reset_candidates.get(player_id).cloned()
660            else {
661                continue;
662            };
663            let time_since_candidate_touch = current_time - candidate_event.time;
664            if !(MIN_DELAY_AFTER_CANDIDATE_TOUCH_SECONDS..=MAX_DELAY_AFTER_CANDIDATE_TOUCH_SECONDS)
665                .contains(&time_since_candidate_touch)
666            {
667                continue;
668            }
669
670            let event = FlipResetFollowupDodgeEvent {
671                time: current_time,
672                frame: frame_index,
673                player: player_id.clone(),
674                is_team_0: processor.get_player_is_team_0(player_id).unwrap_or(false),
675                candidate_touch_time: candidate_event.time,
676                time_since_candidate_touch,
677                candidate_touch_confidence: candidate_event.confidence,
678            };
679            self.current_frame_flip_reset_followup_dodge_events
680                .push(event.clone());
681            self.flip_reset_followup_dodge_events.push(event);
682            self.recent_flip_reset_candidates.remove(player_id);
683        }
684
685        Ok(())
686    }
687}
688
689impl Collector for FlipResetTracker {
690    fn process_frame(
691        &mut self,
692        processor: &ReplayProcessor,
693        frame: &boxcars::Frame,
694        frame_number: usize,
695        _current_time: f32,
696    ) -> SubtrActorResult<TimeAdvance> {
697        self.on_frame(processor, frame, frame_number)?;
698        Ok(TimeAdvance::NextFrame)
699    }
700}