Skip to main content

subtr_actor/stats/calculators/
flip_reset.rs

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