Skip to main content

subtr_actor/stats/calculators/
dodge_reset.rs

1use super::*;
2
3// Bounds on the delay between an on-ball reset and the *dodge* that converts it.
4// The window is measured from the dodge's onset (its rising edge), not from the
5// conversion touch: a flip-into-ball contact is routinely sampled a frame or two
6// before the dodge component's active byte replicates, so anchoring on the touch
7// would push very fast reset-then-flip conversions under the minimum.
8const FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS: f32 = 0.05;
9const FLIP_RESET_MAX_DODGE_TOUCH_DELAY_SECONDS: f32 = 2.0;
10const FLIP_RESET_GROUNDED_Z: f32 = 80.0;
11const FALLBACK_RESET_MIN_PLAYER_HEIGHT: f32 = 95.0;
12const FALLBACK_RESET_MAX_LOCAL_VERTICAL_OFFSET: f32 = 10.0;
13const FALLBACK_RESET_MAX_LOCAL_FORWARD_OFFSET: f32 = 240.0;
14const FALLBACK_RESET_MAX_LOCAL_LATERAL_OFFSET: f32 = 240.0;
15/// A dodge can start while the car is already dragging through the ball, then
16/// the replay's active byte can drop before the sampled touch that carries the
17/// resulting impulse. Keep a short post-onset continuation window for matching
18/// that touch to the pending reset without broadening the global touch-classifier
19/// dodge-lag tolerance.
20const FLIP_RESET_DODGE_CONTACT_CONTINUATION_SECONDS: f32 = 0.35;
21/// How long after a conversion touch the dodge component's active byte may take
22/// to replicate. The ball-hit and the dodge activation routinely land on adjacent
23/// frames, so a flip-reset conversion touch can be sampled on the frame *before*
24/// the dodge flag flips on. We keep the touch around for this brief window so the
25/// dodge, once it appears, still confirms the reset retroactively. Shared with the
26/// touch classifier and flick detector via
27/// [`DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS`].
28const FLIP_RESET_DODGE_TOUCH_LAG_TOLERANCE_SECONDS: f32 = DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS;
29
30/// How a flip reset (an on-ball dodge reset) was ultimately resolved.
31///
32/// Every on-ball reset resolves into exactly one outcome: it was either used
33/// (converted by a dodge-powered touch) or it went unused. A reset that is
34/// replaced by a newer reset for the same player before being used counts as
35/// unused with the [`Superseded`](Self::Superseded) outcome (no latency is
36/// recorded for it).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
38#[serde(rename_all = "snake_case")]
39#[ts(export, rename_all = "snake_case")]
40pub enum FlipResetOutcome {
41    /// Converted by a dodge-powered touch within the reset-to-touch window.
42    Used,
43    /// The player landed before dodging into the ball.
44    Landed,
45    /// A confirming dodge touch arrived only after the reset-to-touch window
46    /// had already elapsed.
47    Expired,
48    /// Replaced by a newer flip reset for the same player before being used.
49    Superseded,
50    /// Live play ended before the reset was used.
51    PlayEnded,
52    /// A goal was scored before the reset was used.
53    GoalScored,
54    /// The replay ended before the reset was used.
55    ReplayEnded,
56}
57
58impl FlipResetOutcome {
59    pub fn is_used(self) -> bool {
60        matches!(self, Self::Used)
61    }
62}
63
64/// A frame-level dodge refresh marked as occurring on the ball (a flip reset).
65#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
66#[ts(export)]
67pub struct DodgeResetEvent {
68    pub time: f32,
69    pub frame: usize,
70    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
71    pub player: PlayerId,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub player_position: Option<[f32; 3]>,
74    pub is_team_0: bool,
75    pub counter_value: i32,
76    /// Whether the dodge refresh happened on the ball (i.e. this reset is a flip reset).
77    pub on_ball: bool,
78    /// Whether an on-ball reset (flip reset) was later converted by a dodge-powered
79    /// touch. Always `false` for non-`on_ball` resets. Set retroactively once the
80    /// confirming touch is observed, so it is meaningful at finish time.
81    #[serde(default)]
82    pub used: bool,
83    /// Final outcome of an on-ball reset (flip reset). Set retroactively when
84    /// the reset resolves (used, landed, superseded, or cut off by a game-flow
85    /// boundary), so it is meaningful at finish time. Always `None` for
86    /// non-`on_ball` resets.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub outcome: Option<FlipResetOutcome>,
89    /// Seconds between the on-ball reset and the dodge-powered touch that used
90    /// it. Set retroactively together with `used`; `None` for unused resets.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub time_to_use: Option<f32>,
93}
94
95/// Resolution of a flip reset (an on-ball dodge reset), emitted once per reset
96/// at the moment its outcome becomes known: either it was used by a
97/// dodge-powered touch (with the reset-to-use latency) or it went unused.
98#[derive(Debug, Clone, PartialEq, Serialize)]
99pub struct FlipResetOutcomeEvent {
100    /// Time at which the outcome was resolved (touch time, landing time, or
101    /// boundary time).
102    pub time: f32,
103    pub frame: usize,
104    pub reset_time: f32,
105    pub reset_frame: usize,
106    pub player: PlayerId,
107    pub is_team_0: bool,
108    pub counter_value: i32,
109    pub outcome: FlipResetOutcome,
110    /// Seconds between the reset and the confirming dodge touch; `Some` iff
111    /// `outcome` is [`FlipResetOutcome::Used`].
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub time_to_use: Option<f32>,
114}
115
116/// The most recent attributed touch by a player who has a pending on-ball reset,
117/// kept so a dodge whose active byte replicates a frame or two *after* the
118/// conversion touch can still confirm the reset retroactively. See
119/// [`FLIP_RESET_DODGE_TOUCH_LAG_TOLERANCE_SECONDS`].
120#[derive(Debug, Clone, PartialEq)]
121struct RecentResetTouch {
122    time: f32,
123    frame: usize,
124    team_is_team_0: bool,
125    player_position: Option<[f32; 3]>,
126    dodge_contact: bool,
127}
128
129/// Internal bookkeeping for an on-ball dodge reset awaiting confirmation, including
130/// the index of the emitted [`DodgeResetEvent`] so it can be marked `used` later.
131#[derive(Debug, Clone, PartialEq)]
132struct PendingOnBallReset {
133    reset: DodgeRefreshedEvent,
134    event_index: usize,
135}
136
137impl InFlightItem for PendingOnBallReset {
138    fn recognition(&self) -> Recognition {
139        // The reset itself has definitely happened; only its outcome (used vs
140        // unused) is still pending.
141        Recognition::committed(self.reset.time, self.reset.frame)
142    }
143
144    fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
145        // A reset still pending at a boundary resolves as unused at that
146        // boundary rather than being silently dropped.
147        Disposition::Finalize(FinalizeReason::Boundary(boundary))
148    }
149}
150
151/// A flip reset confirmed once later converted by a dodge-powered touch before landing.
152#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
153#[ts(export)]
154pub struct FlipResetEvent {
155    pub time: f32,
156    pub frame: usize,
157    pub reset_time: f32,
158    pub reset_frame: usize,
159    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
160    pub player: PlayerId,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub player_position: Option<[f32; 3]>,
163    pub is_team_0: bool,
164    pub counter_value: i32,
165    pub time_since_reset: f32,
166}
167
168/// Detects flip/dodge resets and resolves their outcomes.
169#[derive(Debug, Clone, Default, PartialEq)]
170pub struct DodgeResetCalculator {
171    events: EventStream<DodgeResetEvent>,
172    confirmed_flip_reset_events: EventStream<FlipResetEvent>,
173    flip_reset_outcome_events: EventStream<FlipResetOutcomeEvent>,
174    pending_on_ball_resets: KeyedInFlightLedger<PlayerId, PendingOnBallReset>,
175    /// Onset time of the post-reset dodge, per player. Presence means a distinct
176    /// dodge has started while the reset was pending (so the reset's own
177    /// establishing contact cannot masquerade as the conversion); the value
178    /// anchors the reset-to-dodge delay window.
179    pending_reset_dodge_onset: HashMap<PlayerId, f32>,
180    /// Latest attributed touch per player with a pending reset, used to confirm a
181    /// reset when the dodge byte replicates after the conversion touch.
182    recent_confirmable_touch: HashMap<PlayerId, RecentResetTouch>,
183    previous_dodge_active: HashMap<PlayerId, bool>,
184    previous_live_play: Option<bool>,
185    last_frame: Option<(f32, usize)>,
186}
187
188impl DodgeResetCalculator {
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    pub fn events(&self) -> &[DodgeResetEvent] {
194        self.events.all()
195    }
196
197    pub fn new_events(&self) -> &[DodgeResetEvent] {
198        self.events.new_events()
199    }
200
201    pub fn confirmed_flip_reset_events(&self) -> &[FlipResetEvent] {
202        self.confirmed_flip_reset_events.all()
203    }
204
205    pub fn new_confirmed_flip_reset_events(&self) -> &[FlipResetEvent] {
206        self.confirmed_flip_reset_events.new_events()
207    }
208
209    pub fn flip_reset_outcome_events(&self) -> &[FlipResetOutcomeEvent] {
210        self.flip_reset_outcome_events.all()
211    }
212
213    pub fn new_flip_reset_outcome_events(&self) -> &[FlipResetOutcomeEvent] {
214        self.flip_reset_outcome_events.new_events()
215    }
216
217    fn player<'a>(players: &'a PlayerFrameState, player_id: &PlayerId) -> Option<&'a PlayerSample> {
218        players
219            .players
220            .iter()
221            .find(|player| &player.player_id == player_id)
222    }
223
224    fn player_is_grounded(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
225        Self::player(players, player_id)
226            .and_then(PlayerSample::position)
227            .is_some_and(|position| position.z <= FLIP_RESET_GROUNDED_Z)
228    }
229
230    fn player_dodge_active(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
231        Self::player(players, player_id).is_some_and(|player| player.dodge_active)
232    }
233
234    fn on_ball_dodge_reset(
235        ball: &BallFrameState,
236        players: &PlayerFrameState,
237        player_id: &PlayerId,
238    ) -> bool {
239        const MIN_PLAYER_HEIGHT: f32 = 95.0;
240        const MIN_BALL_HEIGHT: f32 = 80.0;
241        const MAX_CENTER_DISTANCE: f32 = 180.0;
242        const MAX_LOCAL_VERTICAL_OFFSET: f32 = 140.0;
243
244        let Some(ball) = ball.sample() else {
245            return false;
246        };
247        let Some(player) = Self::player(players, player_id) else {
248            return false;
249        };
250        let Some(player_rigid_body) = &player.rigid_body else {
251            return false;
252        };
253
254        let ball_position = vec_to_glam(&ball.rigid_body.location);
255        let player_position = vec_to_glam(&player_rigid_body.location);
256        if player_position.z < MIN_PLAYER_HEIGHT || ball_position.z < MIN_BALL_HEIGHT {
257            return false;
258        }
259
260        let relative_ball_position = ball_position - player_position;
261        let center_distance = relative_ball_position.length();
262        if !center_distance.is_finite() || center_distance > MAX_CENTER_DISTANCE {
263            return false;
264        }
265
266        let player_rotation = quat_to_glam(&player_rigid_body.rotation);
267        let local_ball_position = player_rotation.inverse() * relative_ball_position;
268        local_ball_position.z <= MAX_LOCAL_VERTICAL_OFFSET
269    }
270
271    fn boundary_outcome(boundary: Boundary) -> FlipResetOutcome {
272        match boundary {
273            Boundary::LivePlayEnded => FlipResetOutcome::PlayEnded,
274            Boundary::GoalScored => FlipResetOutcome::GoalScored,
275            Boundary::ReplayEnded => FlipResetOutcome::ReplayEnded,
276        }
277    }
278
279    /// Convert a resolved pending reset into a [`FlipResetOutcomeEvent`] and
280    /// patch the originating [`DodgeResetEvent`] with the outcome (and `used`
281    /// plus latency when the reset was converted).
282    fn record_outcome(
283        &mut self,
284        pending: PendingOnBallReset,
285        outcome: FlipResetOutcome,
286        time: f32,
287        frame: usize,
288        time_to_use: Option<f32>,
289    ) {
290        if let Some(reset) = self.events.get_mut(pending.event_index) {
291            reset.outcome = Some(outcome);
292            reset.time_to_use = time_to_use;
293            if outcome.is_used() {
294                reset.used = true;
295            }
296        }
297        self.flip_reset_outcome_events.push(FlipResetOutcomeEvent {
298            time,
299            frame,
300            reset_time: pending.reset.time,
301            reset_frame: pending.reset.frame,
302            player: pending.reset.player.clone(),
303            is_team_0: pending.reset.is_team_0,
304            counter_value: pending.reset.counter_value,
305            outcome,
306            time_to_use,
307        });
308    }
309
310    fn resolve_pending(
311        &mut self,
312        player_id: &PlayerId,
313        reason: FinalizeReason,
314        outcome: FlipResetOutcome,
315        time: f32,
316        frame: usize,
317        time_to_use: Option<f32>,
318    ) {
319        let Some(pending) = self.pending_on_ball_resets.finalize(player_id, reason) else {
320            return;
321        };
322        self.clear_pending_reset_tracking(player_id);
323        self.record_outcome(pending, outcome, time, frame, time_to_use);
324    }
325
326    fn apply_ledger_boundary(&mut self, boundary: Boundary, time: f32, frame: usize) {
327        for (player_id, pending, _reason) in self.pending_on_ball_resets.apply_boundary(boundary) {
328            self.clear_pending_reset_tracking(&player_id);
329            self.record_outcome(pending, Self::boundary_outcome(boundary), time, frame, None);
330        }
331    }
332
333    fn prune_pending_resets(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
334        let grounded_players = self
335            .pending_on_ball_resets
336            .keys()
337            .filter(|player_id| Self::player_is_grounded(players, player_id))
338            .cloned()
339            .collect::<Vec<_>>();
340        for player_id in grounded_players {
341            self.resolve_pending(
342                &player_id,
343                FinalizeReason::Completed,
344                FlipResetOutcome::Landed,
345                frame.time,
346                frame.frame_number,
347                None,
348            );
349        }
350    }
351
352    fn clear_pending_reset_tracking(&mut self, player_id: &PlayerId) {
353        self.pending_reset_dodge_onset.remove(player_id);
354        self.recent_confirmable_touch.remove(player_id);
355    }
356
357    fn fallback_on_ball_reset(touch_event: &TouchEvent) -> bool {
358        if touch_event.player.is_none() || touch_event.dodge_contact {
359            return false;
360        }
361        if touch_event
362            .closest_approach_distance
363            .is_none_or(|gap| gap > TouchCandidateScoring::DEFAULT.relaxed_contact_gap_threshold)
364        {
365            return false;
366        }
367        if touch_event
368            .player_position
369            .is_none_or(|position| position.z < FALLBACK_RESET_MIN_PLAYER_HEIGHT)
370        {
371            return false;
372        }
373        let Some(local_ball_position) = touch_event.contact_local_ball_position else {
374            return false;
375        };
376
377        local_ball_position[0].abs() <= FALLBACK_RESET_MAX_LOCAL_FORWARD_OFFSET
378            && local_ball_position[1].abs() <= FALLBACK_RESET_MAX_LOCAL_LATERAL_OFFSET
379            && local_ball_position[2] <= FALLBACK_RESET_MAX_LOCAL_VERTICAL_OFFSET
380    }
381
382    fn arm_fallback_on_ball_reset(&mut self, touch_event: &TouchEvent) {
383        let Some(player_id) = touch_event.player.as_ref() else {
384            return;
385        };
386        if self.pending_on_ball_resets.contains(player_id) {
387            return;
388        }
389        if !Self::fallback_on_ball_reset(touch_event) {
390            return;
391        }
392
393        let reset_event = DodgeRefreshedEvent {
394            time: touch_event.time,
395            frame: touch_event.frame,
396            player: player_id.clone(),
397            player_position: touch_event
398                .player_position
399                .map(|position| vec_to_glam(&position).to_array()),
400            is_team_0: touch_event.team_is_team_0,
401            counter_value: 0,
402        };
403        let event_index = self.events.all().len();
404        self.pending_on_ball_resets.arm(
405            player_id.clone(),
406            PendingOnBallReset {
407                reset: reset_event.clone(),
408                event_index,
409            },
410        );
411        self.clear_pending_reset_tracking(player_id);
412        self.events.push(DodgeResetEvent {
413            time: reset_event.time,
414            frame: reset_event.frame,
415            player: reset_event.player,
416            player_position: reset_event.player_position,
417            is_team_0: reset_event.is_team_0,
418            counter_value: reset_event.counter_value,
419            on_ball: true,
420            used: false,
421            outcome: None,
422            time_to_use: None,
423        });
424    }
425
426    /// Track dodge rising edges for players with a pending reset. When a dodge
427    /// starts we record its onset, then try to confirm against a conversion
428    /// touch that arrived a frame or two earlier (the dodge byte lagging the
429    /// ball-hit it produced).
430    fn update_pending_reset_dodges(&mut self, players: &PlayerFrameState, frame_time: f32) {
431        let mut newly_started = Vec::new();
432        for player in &players.players {
433            let was_dodge_active = self
434                .previous_dodge_active
435                .insert(player.player_id.clone(), player.dodge_active)
436                .unwrap_or(false);
437            if player.dodge_active
438                && !was_dodge_active
439                && self.pending_on_ball_resets.contains(&player.player_id)
440            {
441                self.pending_reset_dodge_onset
442                    .insert(player.player_id.clone(), frame_time);
443                newly_started.push(player.player_id.clone());
444            }
445        }
446
447        for player_id in newly_started {
448            let Some(touch) = self.recent_confirmable_touch.get(&player_id).cloned() else {
449                continue;
450            };
451            if frame_time - touch.time > FLIP_RESET_DODGE_TOUCH_LAG_TOLERANCE_SECONDS {
452                continue;
453            }
454            self.confirm_flip_reset(&player_id, &touch, frame_time);
455        }
456    }
457
458    /// Record an attributed touch as a flip-reset conversion candidate and, when
459    /// the toucher is already dodging, confirm the pending reset immediately.
460    fn reset_and_touch_are_same_dodge_contact(
461        reset_event: &DodgeRefreshedEvent,
462        touch: &RecentResetTouch,
463    ) -> bool {
464        touch.dodge_contact
465            && touch.frame == reset_event.frame
466            && (touch.time - reset_event.time).abs() <= f32::EPSILON
467    }
468
469    fn touch_matches_player_frame(
470        touch_event: &TouchEvent,
471        player_id: &PlayerId,
472        time: f32,
473        frame: usize,
474    ) -> bool {
475        touch_event.player.as_ref() == Some(player_id)
476            && touch_event.frame == frame
477            && (touch_event.time - time).abs() <= f32::EPSILON
478    }
479
480    fn recent_reset_touch(
481        players: &PlayerFrameState,
482        touch_event: &TouchEvent,
483    ) -> Option<RecentResetTouch> {
484        let player_id = touch_event.player.as_ref()?;
485        let dodge_contact =
486            touch_event.dodge_contact || Self::player_dodge_active(players, player_id);
487        Some(RecentResetTouch {
488            time: touch_event.time,
489            frame: touch_event.frame,
490            team_is_team_0: touch_event.team_is_team_0,
491            player_position: touch_event
492                .player_position
493                .map(|position| vec_to_glam(&position).to_array())
494                .or_else(|| players.player_position(player_id)),
495            dodge_contact,
496        })
497    }
498
499    fn touch_within_dodge_continuation(touch_time: f32, dodge_onset_time: f32) -> bool {
500        touch_time >= dodge_onset_time
501            && touch_time - dodge_onset_time <= FLIP_RESET_DODGE_CONTACT_CONTINUATION_SECONDS
502    }
503
504    fn process_touch_for_flip_reset(
505        &mut self,
506        players: &PlayerFrameState,
507        touch_event: &TouchEvent,
508    ) -> bool {
509        let Some(player_id) = touch_event.player.as_ref() else {
510            return false;
511        };
512        if !self.pending_on_ball_resets.contains(player_id) {
513            return false;
514        }
515
516        let Some(touch) = Self::recent_reset_touch(players, touch_event) else {
517            return false;
518        };
519        self.recent_confirmable_touch
520            .insert(player_id.clone(), touch.clone());
521
522        // Dodge-then-touch (or same-frame): the dodge byte is already up, so the
523        // recorded onset anchors the timing window.
524        if let Some(&dodge_onset_time) = self.pending_reset_dodge_onset.get(player_id) {
525            if touch.dodge_contact
526                || Self::touch_within_dodge_continuation(touch.time, dodge_onset_time)
527            {
528                let player_id = player_id.clone();
529                return self.confirm_flip_reset(&player_id, &touch, dodge_onset_time);
530            }
531        }
532
533        // Same-frame dodge-on-ball resets have no positive reset-to-dodge delay:
534        // the contact both refreshes the dodge and hits the ball during the
535        // dodge. Treat that as a zero-latency used reset only when the touch
536        // itself carries dodge evidence, so ordinary reset contacts still remain
537        // pending.
538        if touch.dodge_contact && Self::player_dodge_active(players, player_id) {
539            let Some(pending) = self.pending_on_ball_resets.get(player_id) else {
540                return false;
541            };
542            if Self::reset_and_touch_are_same_dodge_contact(&pending.reset, &touch) {
543                let player_id = player_id.clone();
544                return self.confirm_flip_reset(&player_id, &touch, touch.time);
545            }
546        }
547
548        false
549    }
550
551    /// Confirm a pending on-ball reset as a used flip reset, gating on the
552    /// reset-to-dodge-onset delay. The reported latency stays measured to the
553    /// conversion touch for continuity with `time_to_use`.
554    fn confirm_flip_reset(
555        &mut self,
556        player_id: &PlayerId,
557        touch: &RecentResetTouch,
558        dodge_onset_time: f32,
559    ) -> bool {
560        let Some(pending) = self.pending_on_ball_resets.get(player_id) else {
561            return false;
562        };
563        let reset_event = pending.reset.clone();
564        let dodge_delay = dodge_onset_time - reset_event.time;
565        let immediate_dodge_reset =
566            Self::reset_and_touch_are_same_dodge_contact(&reset_event, touch);
567        let time_since_reset = touch.time - reset_event.time;
568        if dodge_delay < FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS
569            && time_since_reset < FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS
570            && !immediate_dodge_reset
571            && !touch.dodge_contact
572        {
573            // The dodge is too close to the reset to be a distinct conversion.
574            return false;
575        }
576        if dodge_delay > FLIP_RESET_MAX_DODGE_TOUCH_DELAY_SECONDS {
577            self.resolve_pending(
578                player_id,
579                FinalizeReason::Completed,
580                FlipResetOutcome::Expired,
581                touch.time,
582                touch.frame,
583                None,
584            );
585            return true;
586        }
587        if time_since_reset < 0.0 {
588            return false;
589        }
590
591        self.confirmed_flip_reset_events.push(FlipResetEvent {
592            time: touch.time,
593            frame: touch.frame,
594            reset_time: reset_event.time,
595            reset_frame: reset_event.frame,
596            player: player_id.clone(),
597            player_position: touch.player_position,
598            is_team_0: touch.team_is_team_0,
599            counter_value: reset_event.counter_value,
600            time_since_reset,
601        });
602        self.resolve_pending(
603            player_id,
604            FinalizeReason::Completed,
605            FlipResetOutcome::Used,
606            touch.time,
607            touch.frame,
608            Some(time_since_reset),
609        );
610        true
611    }
612
613    fn confirm_pending_with_same_frame_touch(
614        &mut self,
615        players: &PlayerFrameState,
616        touches: &[&TouchEvent],
617        player_id: &PlayerId,
618        time: f32,
619        frame: usize,
620    ) -> Option<usize> {
621        let (touch_index, touch_event) = touches.iter().enumerate().find(|(_, touch)| {
622            let dodge_onset_time = self.pending_reset_dodge_onset.get(player_id).copied();
623            Self::touch_matches_player_frame(touch, player_id, time, frame)
624                && (touch.dodge_contact
625                    || Self::player_dodge_active(players, player_id)
626                    || dodge_onset_time.is_some_and(|onset| {
627                        Self::touch_within_dodge_continuation(touch.time, onset)
628                    }))
629        })?;
630        let touch = Self::recent_reset_touch(players, touch_event)?;
631        self.recent_confirmable_touch
632            .insert(player_id.clone(), touch.clone());
633        let dodge_onset_time = self
634            .pending_reset_dodge_onset
635            .get(player_id)
636            .copied()
637            .unwrap_or(touch.time);
638        self.confirm_flip_reset(player_id, &touch, dodge_onset_time)
639            .then_some(touch_index)
640    }
641
642    pub fn update(
643        &mut self,
644        frame: &FrameInfo,
645        ball: &BallFrameState,
646        players: &PlayerFrameState,
647        events: &FrameEventsState,
648        touch_state: &TouchState,
649        live_play_state: &LivePlayState,
650    ) -> SubtrActorResult<()> {
651        self.events.begin_update();
652        self.confirmed_flip_reset_events.begin_update();
653        self.flip_reset_outcome_events.begin_update();
654        self.last_frame = Some((frame.time, frame.frame_number));
655
656        if !events.goal_events.is_empty() {
657            self.apply_ledger_boundary(Boundary::GoalScored, frame.time, frame.frame_number);
658        }
659        let live_play_just_ended =
660            !live_play_state.is_live_play && self.previous_live_play.unwrap_or(true);
661        if live_play_just_ended {
662            self.apply_ledger_boundary(Boundary::LivePlayEnded, frame.time, frame.frame_number);
663        }
664        self.previous_live_play = Some(live_play_state.is_live_play);
665
666        self.update_pending_reset_dodges(players, frame.time);
667        let ordered_touches = chronological_touch_events(&touch_state.touch_events);
668        let mut consumed_touch_indices = vec![false; ordered_touches.len()];
669        for event in &events.dodge_refreshed_events {
670            let on_ball = Self::on_ball_dodge_reset(ball, players, &event.player);
671            let reset_event = event.clone();
672            let event = DodgeResetEvent {
673                time: event.time,
674                frame: event.frame,
675                player: event.player.clone(),
676                player_position: players.player_position(&event.player),
677                is_team_0: event.is_team_0,
678                counter_value: event.counter_value,
679                on_ball,
680                used: false,
681                outcome: None,
682                time_to_use: None,
683            };
684            if on_ball {
685                if let Some(touch_index) = self.confirm_pending_with_same_frame_touch(
686                    players,
687                    &ordered_touches,
688                    &event.player,
689                    event.time,
690                    event.frame,
691                ) {
692                    consumed_touch_indices[touch_index] = true;
693                }
694                // A still-pending earlier reset for this player is superseded
695                // by the new one and counts as unused (no latency recorded).
696                self.resolve_pending(
697                    &event.player,
698                    FinalizeReason::Superseded,
699                    FlipResetOutcome::Superseded,
700                    reset_event.time,
701                    reset_event.frame,
702                    None,
703                );
704                // Index this event will occupy after the push below, so a later
705                // confirming touch can mark it `used`.
706                let event_index = self.events.all().len();
707                self.pending_on_ball_resets.arm(
708                    event.player.clone(),
709                    PendingOnBallReset {
710                        reset: reset_event,
711                        event_index,
712                    },
713                );
714                self.clear_pending_reset_tracking(&event.player);
715            }
716            self.events.push(event);
717        }
718        for (touch_index, touch_event) in ordered_touches.iter().enumerate() {
719            if consumed_touch_indices[touch_index] {
720                continue;
721            }
722            if !events.dodge_refreshed_counter_available {
723                self.arm_fallback_on_ball_reset(touch_event);
724            }
725            self.process_touch_for_flip_reset(players, touch_event);
726        }
727        self.prune_pending_resets(frame, players);
728        Ok(())
729    }
730
731    /// Resolve any flip resets still pending at end of stream as unused
732    /// (handled uniformly via the ledger so none are silently dropped).
733    pub fn finish(&mut self) {
734        let (time, frame) = self.last_frame.unwrap_or((0.0, 0));
735        for (player_id, pending, _reason) in self.pending_on_ball_resets.finish() {
736            self.clear_pending_reset_tracking(&player_id);
737            self.record_outcome(pending, FlipResetOutcome::ReplayEnded, time, frame, None);
738        }
739    }
740}
741
742#[cfg(test)]
743#[path = "dodge_reset_tests.rs"]
744mod tests;