Skip to main content

subtr_actor/stats/calculators/
flick.rs

1use super::*;
2
3/// Ball gravity (Unreal units / s²) used to remove the gravitational component
4/// when estimating the velocity change a touch imparted to the ball.
5const BALL_GRAVITY_Z: f32 = -650.0;
6
7/// How long after a flick-candidate touch the detector keeps measuring the
8/// ball's velocity change. A flick's power is not delivered in the single frame
9/// the touch is first detected: when a car carries/drags the ball through the
10/// dodge (e.g. a 180 flick) the ball keeps accelerating for a few frames after
11/// contact. Measuring the *peak* gravity-compensated impulse over this window —
12/// instead of one frame — is what lets those flicks clear the impulse gate.
13const FLICK_IMPULSE_WINDOW_SECONDS: f32 = 0.15;
14
15const FLICK_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.32;
16/// How far *before* the recorded dodge transition the flick contact may register.
17/// The ball can start accelerating well before the dodge-active byte flips — the
18/// launch touch has been observed up to ~0.23s ahead of the byte on downsampled
19/// replays — so the touch that the pending flick anchors to sometimes precedes
20/// the recorded dodge start. Allow a negative `time_since_dodge` down to the
21/// shared dodge-byte lag tolerance rather than rejecting these as "touch before
22/// dodge". See [`DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS`].
23const FLICK_DODGE_LEAD_TOLERANCE_SECONDS: f32 = DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS;
24/// How long a pending flick is kept alive waiting to be confirmed. Impulse is
25/// only *measured* over [`FLICK_IMPULSE_WINDOW_SECONDS`], but the entry must
26/// outlive that window so a dodge byte that replicates late (see
27/// [`FLICK_DODGE_LEAD_TOLERANCE_SECONDS`]) can still attach to the launch touch
28/// and emit the flick. Must be at least the impulse window.
29const FLICK_PENDING_RETENTION_SECONDS: f32 = FLICK_DODGE_LEAD_TOLERANCE_SECONDS;
30const _: () = assert!(FLICK_PENDING_RETENTION_SECONDS >= FLICK_IMPULSE_WINDOW_SECONDS);
31const FLICK_MAX_CONTROL_TO_DODGE_SECONDS: f32 = 0.08;
32const FLICK_MAX_SETUP_STALE_SECONDS: f32 = 0.35;
33/// How long a control setup survives without a fresh control observation before
34/// it is finished. A real carry/dribble lets the ball wobble in and out of the
35/// tight control volume (the ball briefly exceeds the gap thresholds), so
36/// finishing the setup on the first dropped frame fragments one ~0.5s carry into
37/// sub-`FLICK_MIN_SETUP_SECONDS` pieces that never qualify. Bridging brief gaps
38/// keeps the setup continuous while still ending it when the carry truly stops.
39const FLICK_SETUP_GAP_GRACE_SECONDS: f32 = 0.12;
40const FLICK_MIN_PENDING_DODGE_SETUP_SECONDS: f32 = 0.10;
41const FLICK_MIN_SETUP_SECONDS: f32 = 0.20;
42const FLICK_MIN_BALL_SPEED_CHANGE: f32 = 325.0;
43const FLICK_MIN_CONFIDENCE: f32 = 0.55;
44const FLICK_MAX_CONTROL_BALL_Z: f32 = 700.0;
45const FLICK_MAX_CONTROL_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 1.7;
46const FLICK_MIN_CONTROL_VERTICAL_GAP: f32 = 35.0;
47const FLICK_MAX_CONTROL_VERTICAL_GAP: f32 = 280.0;
48/// Carry evidence threshold: the *minimum* horizontal speed difference between
49/// the ball and the car observed across a flick setup must fall at or below this
50/// for the setup to count as a genuine carry/dribble. A real flick rides the
51/// ball on the car, so at some point during the setup their horizontal
52/// velocities track within tens of uu/s (observed minima ~24–46). A loose ball
53/// the car is merely driving at keeps its own velocity, so the difference never
54/// drops — its setup-minimum stays ~600+. Taking the minimum over the whole
55/// setup (rather than gating per frame) keeps the high relative velocity at the
56/// instant of the dodge from causing a false negative.
57const FLICK_MAX_CARRY_REL_HORIZONTAL_SPEED: f32 = 300.0;
58const FLICK_MIN_LOCAL_Z: f32 = 20.0;
59const FLICK_MAX_LOCAL_X_BEHIND: f32 = 95.0;
60const FLICK_MAX_LOCAL_X_FRONT: f32 = 210.0;
61const FLICK_MAX_LOCAL_Y: f32 = 170.0;
62const FLICK_MIN_IMPULSE_AWAY_ALIGNMENT: f32 = 0.15;
63
64/// A flick's kind is read purely from the dodge's rotation — the `dodge_torque`
65/// axis, which is **car-relative** (the flip's rotation axis in the car's body
66/// frame, decoded from controlled flips): its `y` component is
67/// `dodge_forward_back` (>0 forward dodge, <0 backflip) and its `x` component is
68/// `dodge_side` (signed left/right). Because the axis is a unit vector in that
69/// plane, `dodge_forward_back² + dodge_side² ≈ 1`. No linear ball impulse, and
70/// no velocity/heading, enter the classification.
71///
72/// The components are read **directly** off the car-relative torque. (An earlier
73/// version decomposed it in a travel frame — dotting this car-relative vector
74/// against the world velocity heading — which was a frame error that made the
75/// classification depend on the car's world facing.)
76///
77/// Reverse flick: the dodge is sufficiently *backward* (a backflip), i.e.
78/// `dodge_forward_back <= -REVERSE_FLICK_MIN_BACKWARD`. Calibrated from a pro
79/// corpus (Mawkzy's 98 flicks across 42 rocket-sense replays): his
80/// `dodge_forward_back` is bimodal — a backflip cluster from ≈-1.0 up to ≈-0.25,
81/// then a sparse valley (only ~6 of 98 in -0.25..+0.1), then a forward cluster
82/// above +0.1. `0.25` sits at the top edge of the backflip cluster / start of
83/// the valley, so it captures pure backflips (back ≈ 1.0) and back-diagonal
84/// "reverse 45s" (back ≈ 0.5-0.7, heavily side) without reaching into the
85/// forward population. (Was 0.35, which sliced into the backflip cluster.)
86const REVERSE_FLICK_MIN_BACKWARD: f32 = 0.25;
87/// A reverse flick must also actually *rotate the car onto its side/back* — a
88/// plain backflip (end-over-end, no roll) is a different mechanic. Gated on
89/// `|underside_rotation|`. Calibrated from the `reverse-flick-vs-backflip`
90/// controlled replay (reviewed ground truth: no pre-goal dodge is a reverse
91/// flick, every post-goal dodge is): the pre-goal backflips sit at
92/// `|rotation| ≈ 0.00-0.13`, the post-goal reverse flicks at `≥ 0.27`. `0.2`
93/// separates them with margin.
94const REVERSE_FLICK_MIN_UNDERSIDE_ROTATION: f32 = 0.2;
95/// A reverse flick drives the ball *forward*, not backward — the dodge is
96/// backward but the carried ball is thrown out ahead of the car. Gated on
97/// `launch_forward_alignment` (horizontal launch direction vs travel heading).
98/// A mild `0.4` keeps every reviewed reverse flick (all launched `≥ 0.82`) while
99/// rejecting backward pops; the precise discriminator is the verticality gate
100/// below.
101const REVERSE_FLICK_MIN_LAUNCH_FORWARD: f32 = 0.4;
102/// The defining tell of a reverse flick vs a plain backflip "flick": a reverse
103/// flick sends the ball *forward and flat*, while a backflip pops it nearly
104/// straight up. Gated on the launch impulse's vertical fraction
105/// (`impulse.z / |impulse|` = sin of the launch elevation). In the
106/// `reverse-flick-vs-backflip` controlled replay (reviewed ground truth) this
107/// separates the two cleanly with a wide margin: the reviewed reverse flicks
108/// launched at vertical fraction `0.30-0.43` (elevation 17-26°), every reviewed
109/// non-reverse pop at `0.82-0.99` (elevation 55-82°). `0.6` (elevation ~37°)
110/// sits in the gap. This is what excludes the ~57s pre-goal vertical pop that
111/// the forward/rotation gates alone let through.
112const REVERSE_FLICK_MAX_LAUNCH_VERTICAL_FRACTION: f32 = 0.6;
113/// A side flick's dodge is dominated by its sideways (roll) component.
114const SIDE_FLICK_MIN_SIDE: f32 = 0.6;
115/// A forward flick's dodge is sufficiently forward (front flip).
116const FORWARD_FLICK_MIN_FORWARD: f32 = 0.35;
117/// Minimum |dodge_side| (the dodge's sideways/roll component) to tag a flick's
118/// handedness left/right; below it the flick is `center` (e.g. a pure 90).
119const FLICK_DIRECTION_MIN_SIDE: f32 = 0.25;
120/// Maps the signed `dodge_side` (already `= -torque.x`, so + means right) to the
121/// right/left labels. Handedness was calibrated from the controlled-flip replay
122/// (left runs read `torque.x > 0`, right runs `< 0`); flip this sign if visual
123/// review shows it inverted — that replay's run order is the only ground truth so
124/// far, not yet visually pinned.
125const FLICK_DODGE_SIDE_RIGHT_SIGN: f32 = 1.0;
126
127/// The kind of flick detected, from the dodge direction (see
128/// [`REVERSE_FLICK_MIN_BACKWARD`]).
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum FlickKind {
131    /// No clear dodge direction (e.g. missing dodge torque), or a dodge that is
132    /// neither clearly forward, backward, nor sideways.
133    Other,
134    /// A front-flip flick: the dodge is forward.
135    Forward,
136    /// A reverse flick: the dodge is a backflip.
137    Reverse,
138    /// A side flick: the dodge is dominated by its sideways component.
139    Side,
140}
141
142/// The handedness of a flick, from the dodge's own sideways component
143/// (`dodge_side`).
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum FlickDirection {
146    Center,
147    Left,
148    Right,
149}
150
151pub(crate) const FLICK_KIND_LABELS: [StatLabel; 4] = [
152    StatLabel::new("kind", "other"),
153    StatLabel::new("kind", "forward"),
154    StatLabel::new("kind", "reverse"),
155    StatLabel::new("kind", "side"),
156];
157
158pub(crate) const FLICK_DIRECTION_LABELS: [StatLabel; 3] = [
159    StatLabel::new("direction", "center"),
160    StatLabel::new("direction", "left"),
161    StatLabel::new("direction", "right"),
162];
163
164impl FlickKind {
165    pub fn as_label_value(self) -> &'static str {
166        match self {
167            Self::Other => "other",
168            Self::Forward => "forward",
169            Self::Reverse => "reverse",
170            Self::Side => "side",
171        }
172    }
173
174    pub fn as_label(self) -> StatLabel {
175        flick_kind_label(self.as_label_value())
176    }
177}
178
179impl FlickDirection {
180    pub fn as_label_value(self) -> &'static str {
181        match self {
182            Self::Center => "center",
183            Self::Left => "left",
184            Self::Right => "right",
185        }
186    }
187}
188
189pub(crate) fn flick_kind_label(value: &str) -> StatLabel {
190    match value {
191        "forward" => StatLabel::new("kind", "forward"),
192        "reverse" => StatLabel::new("kind", "reverse"),
193        "side" => StatLabel::new("kind", "side"),
194        _ => StatLabel::new("kind", "other"),
195    }
196}
197
198pub(crate) fn flick_direction_label(value: &str) -> StatLabel {
199    match value {
200        "left" => StatLabel::new("direction", "left"),
201        "right" => StatLabel::new("direction", "right"),
202        _ => StatLabel::new("direction", "center"),
203    }
204}
205
206/// A dodge-powered touch following a short controlled carry setup.
207#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
208#[ts(export)]
209pub struct FlickEvent {
210    pub time: f32,
211    pub frame: usize,
212    pub sample_time: f32,
213    pub sample_frame: usize,
214    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
215    pub player: PlayerId,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub player_position: Option<[f32; 3]>,
218    pub is_team_0: bool,
219    pub dodge_time: f32,
220    pub dodge_frame: usize,
221    pub time_since_dodge: f32,
222    pub setup_start_time: f32,
223    pub setup_start_frame: usize,
224    pub setup_duration: f32,
225    pub setup_touch_count: u32,
226    pub average_horizontal_gap: f32,
227    pub average_vertical_gap: f32,
228    pub ball_speed_change: f32,
229    pub ball_impulse: [f32; 3],
230    pub impulse_away_alignment: f32,
231    pub vertical_impulse: f32,
232    pub kind: String,
233    pub direction: String,
234    pub local_ball_position: [f32; 3],
235    pub local_ball_impulse: [f32; 3],
236    /// Dodge direction (car-relative): >0 forward dodge, <0 backflip.
237    pub dodge_forward_back: f32,
238    /// Dodge direction (car-relative): signed sideways component (+right, -left;
239    /// the handedness source).
240    pub dodge_side: f32,
241    /// Raw car-relative dodge torque (the flip's rotation axis) `[x, y, z]`:
242    /// `y` = forward/back, `x` = left/right, `z` ≈ 0 (nonzero only when the car
243    /// is tilted). `dodge_forward_back`/`dodge_side` are its normalized 2D
244    /// projection; this is the full raw signal. `None` on inputs that don't
245    /// replicate dodge torque (e.g. the BakkesMod live path).
246    pub dodge_torque: Option<[f32; 3]>,
247    /// Signed horizontal angle (radians) from the car's facing to its velocity
248    /// heading at the launch touch (projected to the x/y plane) — how far the
249    /// car was turned off its line of travel. With the car-relative dodge
250    /// direction this recovers the dodge direction relative to the run's motion.
251    /// 0 when the car's speed or horizontal facing is too small to be meaningful.
252    pub travel_offset_radians: f32,
253    /// How forward the ball was launched: the ball's gravity-compensated launch
254    /// impulse (horizontal) dotted with the run's travel heading at the touch,
255    /// in the range -1 to 1. A high value means the ball was sent forward along
256    /// the run (a real reverse or forward flick); a low or negative value means
257    /// it was sent backward or sideways (e.g. a plain backflip, not a reverse
258    /// flick). 0 when the speed or launch is degenerate.
259    pub launch_forward_alignment: f32,
260    /// How vertical the launch was: the fraction of the gravity-compensated launch
261    /// impulse that points up (`impulse.z / |impulse|` = sin of the launch
262    /// elevation). A reverse flick sends the ball forward and flat, so this is low
263    /// (around 0.3-0.45); a plain backflip "flick" pops the ball nearly straight
264    /// up, so this is high (above 0.8). This is the primary signal separating the
265    /// two; gates the reverse classification.
266    pub launch_vertical_fraction: f32,
267    /// How far the car has rotated onto its side/back at the touch (its underside
268    /// turned away from straight down): signed, positive when rolled to the car's
269    /// right and negative to its left, magnitude about sin(roll angle) so values
270    /// near 1 mean fully on its side. A reverse flick rolls the car (unlike a
271    /// plain backflip); gates the reverse classification together with
272    /// `launch_forward_alignment`.
273    pub underside_rotation: f32,
274    pub confidence: f32,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq)]
278struct FlickControlObservation {
279    horizontal_gap: f32,
280    vertical_gap: f32,
281    /// Horizontal speed difference between ball and car this frame, or `None`
282    /// when velocity data is unavailable. See [`FLICK_MAX_CARRY_REL_HORIZONTAL_SPEED`].
283    relative_horizontal_speed: Option<f32>,
284}
285
286#[derive(Debug, Clone, PartialEq)]
287struct ActiveFlickSetup {
288    is_team_0: bool,
289    start_time: f32,
290    start_frame: usize,
291    last_time: f32,
292    last_frame: usize,
293    duration: f32,
294    horizontal_gap_integral: f32,
295    vertical_gap_integral: f32,
296    touch_count: u32,
297    /// Smallest ball-vs-car horizontal speed difference seen during a *non-dodge*
298    /// frame of the setup, or `f32::INFINITY` if none. See
299    /// [`FLICK_MAX_CARRY_REL_HORIZONTAL_SPEED`].
300    min_relative_horizontal_speed: f32,
301    /// Whether any frame of the setup carried ball+car velocity data. Lets the
302    /// carry check stay lenient on replays without velocities while still
303    /// rejecting a setup that *has* velocity data but no non-dodge carry.
304    observed_velocity: bool,
305}
306
307#[derive(Debug, Clone, PartialEq)]
308struct FlickSetupSummary {
309    is_team_0: bool,
310    start_time: f32,
311    start_frame: usize,
312    last_time: f32,
313    last_frame: usize,
314    duration: f32,
315    average_horizontal_gap: f32,
316    average_vertical_gap: f32,
317    touch_count: u32,
318    min_relative_horizontal_speed: f32,
319    observed_velocity: bool,
320}
321
322#[derive(Debug, Clone, PartialEq)]
323struct RecentDodgeStart {
324    time: f32,
325    frame: usize,
326    setup: FlickSetupSummary,
327    rotation_at_dodge: Option<glam::Quat>,
328    /// Car-relative dodge torque (the flip's rotation axis) captured at the
329    /// dodge: `y` = forward/back, `x` = left/right. `None` when the input did not
330    /// replicate it. This is read directly — no travel/world frame needed.
331    dodge_torque: Option<glam::Vec3>,
332}
333
334/// A touch that looks like it could be a flick, kept alive for a short window so
335/// the detector can watch the ball's full velocity change (see
336/// [`FLICK_IMPULSE_WINDOW_SECONDS`]). `peak_impulse` is the largest
337/// gravity-compensated change observed since just before the touch; `ball` and
338/// `player` are snapshotted at the touch so the flick geometry is measured at
339/// contact while its power is measured across the window.
340#[derive(Debug, Clone)]
341struct PendingFlick {
342    touch_event: TouchEvent,
343    ball: BallFrameState,
344    player: PlayerSample,
345    /// Dodge start recorded by a genuine dodge-active transition, when present.
346    real_dodge_start: Option<RecentDodgeStart>,
347    /// Whether the touch was classified as a dodge contact downstream.
348    classified_dodge: bool,
349    /// Start of the impulse measurement: the time of the first touch in this
350    /// contact episode. Equal to the touch time unless this pending superseded
351    /// an earlier same-episode touch, in which case it inherits that touch's
352    /// anchor so the measured impulse spans the whole episode (a dodge drags
353    /// the ball across several frames; see [`FLICK_IMPULSE_WINDOW_SECONDS`]).
354    measure_start_time: f32,
355    /// Ball velocity in the frame just before the touch at `measure_start_time`.
356    pre_velocity: glam::Vec3,
357    peak_impulse: glam::Vec3,
358    peak_magnitude: f32,
359}
360
361impl PartialEq for PendingFlick {
362    fn eq(&self, other: &Self) -> bool {
363        self.touch_event.touch_id == other.touch_event.touch_id
364            && self.touch_event.player == other.touch_event.player
365            && self.touch_event.frame == other.touch_event.frame
366    }
367}
368
369/// Detects flicks from ball/player state and touches.
370#[derive(Debug, Clone, Default, PartialEq)]
371pub struct FlickCalculator {
372    events: EventStream<FlickEvent>,
373    active_setups: HashMap<PlayerId, ActiveFlickSetup>,
374    recent_setups: HashMap<PlayerId, FlickSetupSummary>,
375    recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
376    pending_flicks: Vec<PendingFlick>,
377    previous_dodge_active: HashMap<PlayerId, bool>,
378    previous_ball_velocity: Option<glam::Vec3>,
379    /// Frame of the dodge start behind the most recent flick emitted for each
380    /// player, used to enforce one flick per dodge. Frame numbers are monotonic,
381    /// so a stored frame never collides with a later dodge.
382    last_emitted_dodge_frame: HashMap<PlayerId, usize>,
383}
384
385impl FlickCalculator {
386    pub fn new() -> Self {
387        Self::default()
388    }
389
390    pub fn events(&self) -> &[FlickEvent] {
391        self.events.all()
392    }
393
394    pub fn new_events(&self) -> &[FlickEvent] {
395        self.events.new_events()
396    }
397
398    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
399        if max_value <= min_value {
400            return 0.0;
401        }
402
403        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
404    }
405
406    /// Velocity change imparted to the ball between `reference_velocity` and
407    /// `current_velocity`, with gravity over `elapsed` removed. With
408    /// `elapsed == dt` and the previous frame's velocity this is the
409    /// single-frame impulse; with a longer `elapsed` it measures the change
410    /// accumulated across a flick's contact window.
411    fn gravity_compensated_impulse(
412        current_velocity: glam::Vec3,
413        reference_velocity: glam::Vec3,
414        elapsed: f32,
415    ) -> glam::Vec3 {
416        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * elapsed.max(0.0));
417        current_velocity - reference_velocity - expected_linear_delta
418    }
419
420    fn control_observation(
421        ball: &BallSample,
422        player: &PlayerSample,
423        controlling_player: Option<&PlayerId>,
424    ) -> Option<FlickControlObservation> {
425        if controlling_player != Some(&player.player_id) {
426            return None;
427        }
428
429        let player_rigid_body = player.rigid_body.as_ref()?;
430        let player_position = player.position()?;
431        let ball_position = ball.position();
432        if !(BALL_CARRY_MIN_BALL_Z..=FLICK_MAX_CONTROL_BALL_Z).contains(&ball_position.z) {
433            return None;
434        }
435
436        let horizontal_gap = player_position
437            .truncate()
438            .distance(ball_position.truncate());
439        if horizontal_gap > FLICK_MAX_CONTROL_HORIZONTAL_GAP {
440            return None;
441        }
442
443        let vertical_gap = ball_position.z - player_position.z;
444        if !(FLICK_MIN_CONTROL_VERTICAL_GAP..=FLICK_MAX_CONTROL_VERTICAL_GAP)
445            .contains(&vertical_gap)
446        {
447            return None;
448        }
449
450        // How closely the ball tracks the car horizontally this frame. A real
451        // flick is set up by a carry/dribble where the ball rides the car, so
452        // this stays small; a loose ball the car is merely driving at keeps its
453        // own velocity. `None` when velocity data is unavailable so the carry
454        // check downstream stays lenient on such replays.
455        let relative_horizontal_speed = match (
456            ball.rigid_body.linear_velocity.as_ref().map(vec_to_glam),
457            player.velocity(),
458        ) {
459            (Some(ball_velocity), Some(player_velocity)) => {
460                Some((ball_velocity.truncate() - player_velocity.truncate()).length())
461            }
462            _ => None,
463        };
464
465        let local_ball_position =
466            quat_to_glam(&player_rigid_body.rotation).inverse() * (ball_position - player_position);
467        if local_ball_position.x < -FLICK_MAX_LOCAL_X_BEHIND
468            || local_ball_position.x > FLICK_MAX_LOCAL_X_FRONT
469            || local_ball_position.y.abs() > FLICK_MAX_LOCAL_Y
470            || local_ball_position.z < FLICK_MIN_LOCAL_Z
471        {
472            return None;
473        }
474
475        Some(FlickControlObservation {
476            horizontal_gap,
477            vertical_gap,
478            relative_horizontal_speed,
479        })
480    }
481
482    fn setup_summary(setup: &ActiveFlickSetup) -> FlickSetupSummary {
483        FlickSetupSummary {
484            is_team_0: setup.is_team_0,
485            start_time: setup.start_time,
486            start_frame: setup.start_frame,
487            last_time: setup.last_time,
488            last_frame: setup.last_frame,
489            duration: setup.duration,
490            average_horizontal_gap: setup.horizontal_gap_integral
491                / setup.duration.max(f32::EPSILON),
492            average_vertical_gap: setup.vertical_gap_integral / setup.duration.max(f32::EPSILON),
493            touch_count: setup.touch_count,
494            min_relative_horizontal_speed: setup.min_relative_horizontal_speed,
495            observed_velocity: setup.observed_velocity,
496        }
497    }
498
499    /// Whether a setup shows genuine carry/dribble evidence: at some non-dodge
500    /// frame the ball tracked the car closely. Lenient when no velocity data was
501    /// available at all (replays without velocities keep prior behavior), but a
502    /// setup that *has* velocity data yet never shows a non-dodge carry — a car
503    /// that drove into a loose ball while already dodging — is rejected. This is
504    /// what separates a flick off a dribble from a dodge into a loose ball that
505    /// merely passed through the control volume.
506    fn setup_shows_carry(setup: &FlickSetupSummary) -> bool {
507        !setup.observed_velocity
508            || setup.min_relative_horizontal_speed <= FLICK_MAX_CARRY_REL_HORIZONTAL_SPEED
509    }
510
511    fn setup_qualifies(setup: &FlickSetupSummary) -> bool {
512        setup.duration >= FLICK_MIN_SETUP_SECONDS
513    }
514
515    /// Ball-relative geometry of the touch, in the dodge reference frame. Returns
516    /// `(local_ball_position, local_ball_impulse)`.
517    fn local_ball_geometry(
518        player_rotation: glam::Quat,
519        rotation_at_dodge: Option<glam::Quat>,
520        relative_ball_position: glam::Vec3,
521        ball_impulse: glam::Vec3,
522    ) -> (glam::Vec3, glam::Vec3) {
523        let local_ball_position = player_rotation.inverse() * relative_ball_position;
524        let impulse_reference_rotation = rotation_at_dodge.unwrap_or(player_rotation);
525        let local_ball_impulse = impulse_reference_rotation.inverse() * ball_impulse;
526        (local_ball_position, local_ball_impulse)
527    }
528
529    /// Classify the flick from the dodge's rotation axis — no ball impulse.
530    ///
531    /// `dodge_torque` is the flip's rotation axis in the **car's body frame**
532    /// (decoded from controlled cancel-free flips, align 0.998 against the
533    /// observed spin axis). Its two horizontal components map straight to the
534    /// dodge the player input — no world/travel frame, because the torque is
535    /// already car-relative:
536    /// - `y` = `dodge_forward_back` (>0 forward dodge, <0 backflip),
537    /// - `-x` = `dodge_side` (signed; +right, -left, see
538    ///   [`FLICK_DODGE_SIDE_RIGHT_SIGN`]).
539    ///
540    /// (The earlier travel-frame decomposition — dotting this car-relative vector
541    /// against the world velocity heading — was a frame error that made the
542    /// result depend on which way the car faced in the world.)
543    ///
544    /// Because the axis is a unit vector in that plane,
545    /// `dodge_forward_back² + dodge_side² ≈ 1`. A reverse flick is a sufficiently
546    /// backward dodge that *also* rolled the car onto its side and launched the
547    /// ball forward (so a plain backflip — which does neither — is not a reverse
548    /// flick, see [`REVERSE_FLICK_MIN_LAUNCH_FORWARD`],
549    /// [`REVERSE_FLICK_MAX_LAUNCH_VERTICAL_FRACTION`], and
550    /// [`REVERSE_FLICK_MIN_UNDERSIDE_ROTATION`]); a side flick is
551    /// sideways-dominant; a forward flick is forward. Returns
552    /// `(kind, direction, dodge_forward_back, dodge_side)`.
553    fn classify_dodge(
554        dodge_torque: Option<glam::Vec3>,
555        launch_forward: f32,
556        launch_vertical_fraction: f32,
557        underside_rotation: f32,
558    ) -> (FlickKind, FlickDirection, f32, f32) {
559        let Some(torque) = dodge_torque else {
560            return (FlickKind::Other, FlickDirection::Center, 0.0, 0.0);
561        };
562        let torque_horizontal = torque.truncate();
563        if torque_horizontal.length_squared() <= f32::EPSILON {
564            return (FlickKind::Other, FlickDirection::Center, 0.0, 0.0);
565        }
566
567        let t = torque_horizontal.normalize();
568        // Car-relative axis: `y` is forward/back (+forward), `x` is the side the
569        // car dodged toward (+left, -right, from the controlled-flip replay).
570        // `dodge_side` negates `x` so that, like the rest of the codebase, a
571        // positive value means *right*.
572        let dodge_forward_back = t.y;
573        let dodge_side = -t.x;
574        let handed_side = dodge_side * FLICK_DODGE_SIDE_RIGHT_SIGN;
575
576        let direction = if handed_side >= FLICK_DIRECTION_MIN_SIDE {
577            FlickDirection::Right
578        } else if handed_side <= -FLICK_DIRECTION_MIN_SIDE {
579            FlickDirection::Left
580        } else {
581            FlickDirection::Center
582        };
583
584        let kind = if dodge_forward_back <= -REVERSE_FLICK_MIN_BACKWARD
585            && launch_forward >= REVERSE_FLICK_MIN_LAUNCH_FORWARD
586            && launch_vertical_fraction <= REVERSE_FLICK_MAX_LAUNCH_VERTICAL_FRACTION
587            && underside_rotation.abs() >= REVERSE_FLICK_MIN_UNDERSIDE_ROTATION
588        {
589            FlickKind::Reverse
590        } else if dodge_side.abs() >= SIDE_FLICK_MIN_SIDE {
591            FlickKind::Side
592        } else if dodge_forward_back >= FORWARD_FLICK_MIN_FORWARD {
593            FlickKind::Forward
594        } else {
595            FlickKind::Other
596        };
597
598        (kind, direction, dodge_forward_back, dodge_side)
599    }
600
601    fn store_recent_setup(&mut self, player_id: PlayerId, setup: FlickSetupSummary) {
602        if Self::setup_qualifies(&setup) {
603            self.recent_setups.insert(player_id, setup);
604        }
605    }
606
607    fn finish_setup(&mut self, player_id: &PlayerId) {
608        let Some(setup) = self.active_setups.remove(player_id) else {
609            return;
610        };
611        self.store_recent_setup(player_id.clone(), Self::setup_summary(&setup));
612    }
613
614    fn recent_setup_for_player(
615        &self,
616        player_id: &PlayerId,
617        current_time: f32,
618    ) -> Option<FlickSetupSummary> {
619        if let Some(active) = self.active_setups.get(player_id) {
620            return Some(Self::setup_summary(active));
621        }
622
623        self.recent_setups
624            .get(player_id)
625            .filter(|setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS)
626            .cloned()
627    }
628
629    fn update_control_setups(
630        &mut self,
631        frame: &FrameInfo,
632        ball: &BallFrameState,
633        players: &PlayerFrameState,
634        touch_events: &[TouchEvent],
635        controlling_player: Option<&PlayerId>,
636    ) {
637        let Some(ball) = ball.sample() else {
638            let player_ids: Vec<_> = self.active_setups.keys().cloned().collect();
639            for player_id in player_ids {
640                self.finish_setup(&player_id);
641            }
642            return;
643        };
644
645        let mut observed_players = HashSet::new();
646        for player in &players.players {
647            let Some(observation) = Self::control_observation(ball, player, controlling_player)
648            else {
649                continue;
650            };
651            observed_players.insert(player.player_id.clone());
652            let setup = self
653                .active_setups
654                .entry(player.player_id.clone())
655                .or_insert_with(|| ActiveFlickSetup {
656                    is_team_0: player.is_team_0,
657                    start_time: (frame.time - frame.dt).max(0.0),
658                    start_frame: frame.frame_number.saturating_sub(1),
659                    last_time: frame.time,
660                    last_frame: frame.frame_number,
661                    duration: frame.dt.max(0.0),
662                    horizontal_gap_integral: observation.horizontal_gap * frame.dt.max(0.0),
663                    vertical_gap_integral: observation.vertical_gap * frame.dt.max(0.0),
664                    touch_count: 0,
665                    min_relative_horizontal_speed: f32::INFINITY,
666                    observed_velocity: false,
667                });
668
669            // Carry evidence is the dribble *before* the flick. Once the player
670            // is dodging, the ball is being struck, and its post-contact velocity
671            // can transiently align with the car — so only frames where the
672            // player is not dodging count toward the carry minimum.
673            if let Some(relative_horizontal_speed) = observation.relative_horizontal_speed {
674                setup.observed_velocity = true;
675                // Carry evidence is the dribble *before* the flick. Once the
676                // player is dodging the ball is being struck, and its
677                // post-contact velocity can transiently align with the car — so
678                // only frames where the player is not dodging count toward the
679                // carry minimum. A setup whose control frames are *all* during a
680                // dodge (a car that drove into a loose ball while already
681                // flicking) therefore shows no carry and is rejected below.
682                if !player.dodge_active {
683                    setup.min_relative_horizontal_speed = setup
684                        .min_relative_horizontal_speed
685                        .min(relative_horizontal_speed);
686                }
687            }
688
689            if setup.last_frame != frame.frame_number {
690                setup.last_time = frame.time;
691                setup.last_frame = frame.frame_number;
692                setup.duration += frame.dt.max(0.0);
693                setup.horizontal_gap_integral += observation.horizontal_gap * frame.dt.max(0.0);
694                setup.vertical_gap_integral += observation.vertical_gap * frame.dt.max(0.0);
695            }
696        }
697
698        for touch_event in touch_events {
699            let Some(player_id) = touch_event.player.as_ref() else {
700                continue;
701            };
702            if let Some(setup) = self.active_setups.get_mut(player_id) {
703                setup.touch_count += 1;
704            }
705        }
706
707        let active_ids: Vec<_> = self.active_setups.keys().cloned().collect();
708        for player_id in active_ids {
709            if observed_players.contains(&player_id) {
710                continue;
711            }
712            // Keep the setup alive across brief observation gaps; only finish it
713            // once the ball has been out of the control volume long enough that
714            // the carry is genuinely over.
715            let gap_elapsed = self
716                .active_setups
717                .get(&player_id)
718                .map(|setup| frame.time - setup.last_time > FLICK_SETUP_GAP_GRACE_SECONDS)
719                .unwrap_or(true);
720            if gap_elapsed {
721                self.finish_setup(&player_id);
722            }
723        }
724    }
725
726    fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
727        for player in &players.players {
728            let was_dodge_active = self
729                .previous_dodge_active
730                .insert(player.player_id.clone(), player.dodge_active)
731                .unwrap_or(false);
732            if !player.dodge_active || was_dodge_active {
733                continue;
734            }
735
736            let Some(setup) = self.recent_setup_for_player(&player.player_id, frame.time) else {
737                continue;
738            };
739            if !Self::setup_qualifies(&setup) {
740                continue;
741            }
742            if !Self::setup_shows_carry(&setup) {
743                continue;
744            }
745            if frame.time - setup.last_time > FLICK_MAX_CONTROL_TO_DODGE_SECONDS {
746                continue;
747            }
748
749            self.recent_dodge_starts.insert(
750                player.player_id.clone(),
751                Self::dodge_start(frame.time, frame.frame_number, setup, player),
752            );
753        }
754    }
755
756    /// Build a [`RecentDodgeStart`] from the player's state at the dodge,
757    /// snapshotting the dodge reference rotation, the world-frame dodge torque,
758    /// and the horizontal travel direction the dodge is measured against.
759    fn dodge_start(
760        time: f32,
761        frame: usize,
762        setup: FlickSetupSummary,
763        player: &PlayerSample,
764    ) -> RecentDodgeStart {
765        RecentDodgeStart {
766            time,
767            frame,
768            setup,
769            rotation_at_dodge: player
770                .rigid_body
771                .as_ref()
772                .map(|rigid_body| quat_to_glam(&rigid_body.rotation)),
773            dodge_torque: player.dodge_torque,
774        }
775    }
776
777    fn prune_recent_state(&mut self, current_time: f32) {
778        self.recent_setups
779            .retain(|_, setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS);
780        self.recent_dodge_starts
781            .retain(|_, dodge| current_time - dodge.time <= FLICK_MAX_DODGE_TO_TOUCH_SECONDS);
782    }
783
784    fn candidate_event(
785        &self,
786        ball: &BallFrameState,
787        player: &PlayerSample,
788        touch_event: &TouchEvent,
789        dodge_start: &RecentDodgeStart,
790        ball_impulse: glam::Vec3,
791    ) -> Option<FlickEvent> {
792        let ball = ball.sample()?;
793        let player_rigid_body = player.rigid_body.as_ref()?;
794        let player_position = player.position()?;
795        let time_since_dodge = touch_event.time - dodge_start.time;
796        if !(-FLICK_DODGE_LEAD_TOLERANCE_SECONDS..=FLICK_MAX_DODGE_TO_TOUCH_SECONDS)
797            .contains(&time_since_dodge)
798        {
799            return None;
800        }
801
802        let ball_speed_change = ball_impulse.length();
803        if ball_speed_change < FLICK_MIN_BALL_SPEED_CHANGE {
804            return None;
805        }
806
807        let to_ball = (ball.position() - player_position).normalize_or_zero();
808        let impulse_direction = ball_impulse.normalize_or_zero();
809        if to_ball.length_squared() <= f32::EPSILON
810            || impulse_direction.length_squared() <= f32::EPSILON
811        {
812            return None;
813        }
814
815        let impulse_away_alignment = impulse_direction.dot(to_ball);
816        if impulse_away_alignment < FLICK_MIN_IMPULSE_AWAY_ALIGNMENT {
817            return None;
818        }
819
820        let vertical_impulse = ball_impulse.z.max(0.0);
821        let player_rotation = quat_to_glam(&player_rigid_body.rotation);
822        let (local_ball_position, local_ball_impulse) = Self::local_ball_geometry(
823            player_rotation,
824            dodge_start.rotation_at_dodge,
825            ball.position() - player_position,
826            ball_impulse,
827        );
828        // How far the car was turned off its line of travel at the launch touch
829        // (x/y plane): signed angle from the car's facing to its velocity
830        // heading. + = velocity is to the car's left of where it points.
831        let travel_offset_radians = player
832            .velocity()
833            .map(|velocity| {
834                let forward = (player_rotation * glam::Vec3::X).truncate();
835                let heading = velocity.truncate();
836                if forward.length() > 0.3 && heading.length() > 50.0 {
837                    let forward = forward.normalize();
838                    let heading = heading.normalize();
839                    (forward.x * heading.y - forward.y * heading.x).atan2(forward.dot(heading))
840                } else {
841                    0.0
842                }
843            })
844            .unwrap_or(0.0);
845        // How forward the ball was launched, along the run's travel heading
846        // (x/y). Distinguishes a reverse flick (ball thrown forward) from a plain
847        // backflip (ball not thrown forward); gates the reverse classification.
848        let launch_forward_alignment = player
849            .velocity()
850            .map(|velocity| {
851                let launch = ball_impulse.truncate();
852                let heading = velocity.truncate();
853                if launch.length() > f32::EPSILON && heading.length() > 50.0 {
854                    launch.normalize().dot(heading.normalize())
855                } else {
856                    0.0
857                }
858            })
859            .unwrap_or(0.0);
860        // How vertical the launch was: the fraction of the launch impulse that is
861        // upward (sin of the launch elevation). A reverse flick sends the ball
862        // forward and flat (low); a plain backflip pops it up (high).
863        let launch_vertical_fraction = {
864            let mag = ball_impulse.length();
865            if mag > f32::EPSILON {
866                ball_impulse.z / mag
867            } else {
868                0.0
869            }
870        };
871        // How far the car has rotated onto its side/back at the touch: + = rolled
872        // to its right (right axis dips below horizontal), magnitude ~sin(roll).
873        // Distinguishes a reverse flick (car rolled) from a plain backflip.
874        let underside_rotation = -(player_rotation * glam::Vec3::Y).z;
875        let (kind, direction, dodge_forward_back, dodge_side) = Self::classify_dodge(
876            dodge_start.dodge_torque,
877            launch_forward_alignment,
878            launch_vertical_fraction,
879            underside_rotation,
880        );
881        let setup = &dodge_start.setup;
882        let timing_score =
883            1.0 - (time_since_dodge / FLICK_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
884        let setup_duration_score =
885            Self::normalize_score(setup.duration, FLICK_MIN_SETUP_SECONDS, 0.75);
886        let horizontal_control_score =
887            1.0 - (setup.average_horizontal_gap / FLICK_MAX_CONTROL_HORIZONTAL_GAP).clamp(0.0, 1.0);
888        let vertical_control_score = 1.0
889            - ((setup.average_vertical_gap - 110.0).abs() / FLICK_MAX_CONTROL_VERTICAL_GAP)
890                .clamp(0.0, 1.0);
891        let impulse_score =
892            Self::normalize_score(ball_speed_change, FLICK_MIN_BALL_SPEED_CHANGE, 1450.0);
893        let away_score = Self::normalize_score(
894            impulse_away_alignment,
895            FLICK_MIN_IMPULSE_AWAY_ALIGNMENT,
896            0.85,
897        );
898        let vertical_score = Self::normalize_score(vertical_impulse, 100.0, 750.0);
899
900        let confidence = 0.16 * timing_score
901            + 0.19 * setup_duration_score
902            + 0.12 * horizontal_control_score
903            + 0.10 * vertical_control_score
904            + 0.22 * impulse_score
905            + 0.15 * away_score
906            + 0.06 * vertical_score;
907        if confidence < FLICK_MIN_CONFIDENCE {
908            return None;
909        }
910
911        Some(FlickEvent {
912            time: touch_event.time,
913            frame: touch_event.frame,
914            sample_time: touch_event.time,
915            sample_frame: touch_event.frame,
916            player: player.player_id.clone(),
917            player_position: Some(player_position.to_array()),
918            is_team_0: player.is_team_0,
919            dodge_time: dodge_start.time,
920            dodge_frame: dodge_start.frame,
921            time_since_dodge,
922            setup_start_time: setup.start_time,
923            setup_start_frame: setup.start_frame,
924            setup_duration: setup.duration,
925            setup_touch_count: setup.touch_count,
926            average_horizontal_gap: setup.average_horizontal_gap,
927            average_vertical_gap: setup.average_vertical_gap,
928            ball_speed_change,
929            ball_impulse: ball_impulse.to_array(),
930            impulse_away_alignment,
931            vertical_impulse,
932            kind: kind.as_label_value().to_owned(),
933            direction: direction.as_label_value().to_owned(),
934            local_ball_position: local_ball_position.to_array(),
935            local_ball_impulse: local_ball_impulse.to_array(),
936            dodge_forward_back,
937            dodge_side,
938            dodge_torque: dodge_start.dodge_torque.map(|torque| torque.to_array()),
939            travel_offset_radians,
940            launch_forward_alignment,
941            launch_vertical_fraction,
942            underside_rotation,
943            confidence,
944        })
945    }
946
947    fn apply_event(&mut self, frame: &FrameInfo, mut event: FlickEvent) {
948        event.sample_time = frame.time;
949        event.sample_frame = frame.frame_number;
950        self.events.push(event);
951    }
952
953    fn dodge_start_for_touch(&self, player: &PlayerSample) -> Option<RecentDodgeStart> {
954        if let Some(dodge_start) = self.recent_dodge_starts.get(&player.player_id) {
955            return Some(dodge_start.clone());
956        }
957        None
958    }
959
960    fn classified_as_dodge_touch(
961        touch_event: &TouchEvent,
962        touch_classification_events: &[TouchClassificationEvent],
963    ) -> bool {
964        let Some(touch_player) = touch_event.player.as_ref() else {
965            return false;
966        };
967        touch_classification_events.iter().any(|event| {
968            let same_touch = match (event.touch_id, touch_event.touch_id) {
969                (Some(event_id), Some(touch_id)) => event_id == touch_id,
970                _ => event.player == *touch_player && event.frame == touch_event.frame,
971            };
972            same_touch && event.has_tag("dodge_state", "dodge")
973        })
974    }
975
976    fn pending_dodge_start_for_touch(
977        &self,
978        player: &PlayerSample,
979        touch_event: &TouchEvent,
980    ) -> Option<RecentDodgeStart> {
981        let setup = self.recent_setup_for_player(&player.player_id, touch_event.time)?;
982        if setup.duration < FLICK_MIN_PENDING_DODGE_SETUP_SECONDS {
983            return None;
984        }
985        if !Self::setup_shows_carry(&setup) {
986            return None;
987        }
988        Some(Self::dodge_start(
989            touch_event.time,
990            touch_event.frame,
991            setup,
992            player,
993        ))
994    }
995
996    /// Open (or refresh) a pending flick for a touch by a player who has a
997    /// recent control setup (i.e. was dribbling/carrying). The pending entry is
998    /// what lets the detector watch the ball's velocity change across the
999    /// [`FLICK_IMPULSE_WINDOW_SECONDS`] window rather than only at the touch
1000    /// frame.
1001    fn store_pending_flick(
1002        &mut self,
1003        ball: &BallFrameState,
1004        player: &PlayerSample,
1005        touch_event: &TouchEvent,
1006        pre_velocity: glam::Vec3,
1007    ) {
1008        let already_tracked = self.pending_flicks.iter().any(|pending| {
1009            pending.touch_event.touch_id == touch_event.touch_id
1010                && pending.touch_event.player == touch_event.player
1011                && pending.touch_event.frame == touch_event.frame
1012        });
1013        if already_tracked {
1014            // Same touch reappearing on a later frame: keep accumulating into
1015            // its existing window rather than resetting it.
1016            return;
1017        }
1018        // Require at least the loose pending-setup threshold; the stricter
1019        // dodge/confidence gates are enforced when the window resolves.
1020        let has_setup = self
1021            .recent_setup_for_player(&player.player_id, touch_event.time)
1022            .is_some_and(|setup| setup.duration >= FLICK_MIN_PENDING_DODGE_SETUP_SECONDS);
1023        if !has_setup {
1024            return;
1025        }
1026        // One flick per dodge: a newer touch by the same player supersedes its
1027        // earlier window so a single dribble cannot emit multiple flicks when
1028        // its control touches fall within one impulse window of each other.
1029        // A still-live pending being superseded is the same contact episode —
1030        // it can only exist within the short retention window, and the touch
1031        // rate limit only lets a same-player pair through that fast when a
1032        // dodge-powered launch follows a passive contact. The new pending
1033        // inherits the earlier measurement anchor instead of resetting it, so
1034        // the launch impulse already delivered before this touch stays in the
1035        // measured peak.
1036        let inherited_measurement = self
1037            .pending_flicks
1038            .iter()
1039            .find(|pending| {
1040                pending.player.player_id == player.player_id
1041                    && touch_event.time >= pending.touch_event.time
1042            })
1043            .map(|pending| {
1044                (
1045                    pending.measure_start_time,
1046                    pending.pre_velocity,
1047                    pending.peak_impulse,
1048                    pending.peak_magnitude,
1049                )
1050            });
1051        self.pending_flicks
1052            .retain(|pending| pending.player.player_id != player.player_id);
1053        let (measure_start_time, pre_velocity, peak_impulse, peak_magnitude) =
1054            inherited_measurement.unwrap_or((
1055                touch_event.time,
1056                pre_velocity,
1057                glam::Vec3::ZERO,
1058                0.0,
1059            ));
1060        self.pending_flicks.push(PendingFlick {
1061            touch_event: touch_event.clone(),
1062            ball: ball.clone(),
1063            player: player.clone(),
1064            real_dodge_start: self.dodge_start_for_touch(player),
1065            classified_dodge: false,
1066            measure_start_time,
1067            pre_velocity,
1068            peak_impulse,
1069            peak_magnitude,
1070        });
1071    }
1072
1073    /// Per-frame step: grow each pending flick's peak impulse from the live ball
1074    /// velocity, refresh its dodge evidence, and emit as soon as the peak clears
1075    /// the gates. Entries that never qualify are dropped once the window closes.
1076    fn update_and_resolve_pending_flicks(
1077        &mut self,
1078        frame: &FrameInfo,
1079        ball: &BallFrameState,
1080        touch_classification_events: &[TouchClassificationEvent],
1081    ) {
1082        let current_velocity = ball.velocity();
1083        let mut pending = std::mem::take(&mut self.pending_flicks);
1084        let mut emitted = Vec::new();
1085        pending.retain_mut(|flick| {
1086            let elapsed = (frame.time - flick.touch_event.time).max(0.0);
1087            if elapsed > FLICK_PENDING_RETENTION_SECONDS {
1088                return false;
1089            }
1090
1091            // Measure the impulse only over the (shorter) impulse window,
1092            // anchored on the latest touch; the entry is kept alive past it
1093            // purely so a late-replicating dodge byte can still confirm the
1094            // launch touch. Gravity compensation spans from the episode's
1095            // measurement anchor, which precedes the touch when this pending
1096            // inherited an earlier same-episode window.
1097            if elapsed <= FLICK_IMPULSE_WINDOW_SECONDS {
1098                if let Some(velocity) = current_velocity {
1099                    let measure_elapsed = (frame.time - flick.measure_start_time).max(0.0);
1100                    let impulse = Self::gravity_compensated_impulse(
1101                        velocity,
1102                        flick.pre_velocity,
1103                        measure_elapsed,
1104                    );
1105                    let magnitude = impulse.length();
1106                    if magnitude > flick.peak_magnitude {
1107                        flick.peak_magnitude = magnitude;
1108                        flick.peak_impulse = impulse;
1109                    }
1110                }
1111            }
1112
1113            if flick.real_dodge_start.is_none() {
1114                flick.real_dodge_start = self.dodge_start_for_touch(&flick.player);
1115            }
1116            if !flick.classified_dodge {
1117                flick.classified_dodge = Self::classified_as_dodge_touch(
1118                    &flick.touch_event,
1119                    touch_classification_events,
1120                );
1121            }
1122
1123            // A genuine dodge transition stands on its own; otherwise the touch
1124            // must have been classified as a dodge contact (the old pending
1125            // path) and have a recent control setup to synthesize a start from.
1126            let dodge_start = flick.real_dodge_start.clone().or_else(|| {
1127                if flick.classified_dodge {
1128                    self.pending_dodge_start_for_touch(&flick.player, &flick.touch_event)
1129                } else {
1130                    None
1131                }
1132            });
1133            let Some(dodge_start) = dodge_start else {
1134                return true;
1135            };
1136
1137            // One flick per dodge. Extending the pending window so a late dodge
1138            // byte can confirm an earlier launch touch means several touches that
1139            // bracket a single dodge (a pre-dodge carry contact and the launch)
1140            // can each resolve against the same dodge start. Drop any candidate
1141            // for a dodge that already produced a flick — whether emitted on an
1142            // earlier frame or earlier in this same frame's batch — so the first
1143            // qualifying touch wins and the dodge is not double-counted.
1144            let already_emitted = self.last_emitted_dodge_frame.get(&flick.player.player_id)
1145                == Some(&dodge_start.frame)
1146                || emitted.iter().any(|event: &FlickEvent| {
1147                    event.player == flick.player.player_id && event.dodge_frame == dodge_start.frame
1148                });
1149            if already_emitted {
1150                return false;
1151            }
1152
1153            if let Some(event) = self.candidate_event(
1154                &flick.ball,
1155                &flick.player,
1156                &flick.touch_event,
1157                &dodge_start,
1158                flick.peak_impulse,
1159            ) {
1160                emitted.push(event);
1161                return false;
1162            }
1163            true
1164        });
1165        self.pending_flicks = pending;
1166        for event in emitted {
1167            self.last_emitted_dodge_frame
1168                .insert(event.player.clone(), event.dodge_frame);
1169            self.apply_event(frame, event);
1170        }
1171    }
1172
1173    fn apply_touch_events(
1174        &mut self,
1175        _frame: &FrameInfo,
1176        ball: &BallFrameState,
1177        players: &PlayerFrameState,
1178        touch_events: &[TouchEvent],
1179    ) {
1180        let pre_velocity = self
1181            .previous_ball_velocity
1182            .or_else(|| ball.velocity())
1183            .unwrap_or(glam::Vec3::ZERO);
1184
1185        for touch_event in touch_events {
1186            let Some(player_id) = touch_event.player.as_ref() else {
1187                continue;
1188            };
1189            let Some(player) = players
1190                .players
1191                .iter()
1192                .find(|player| &player.player_id == player_id)
1193            else {
1194                continue;
1195            };
1196            // Open a measurement window for any touch by a dribbling player; the
1197            // impulse, dodge, and confidence gates resolve over the window in
1198            // `update_and_resolve_pending_flicks`.
1199            self.store_pending_flick(ball, player, touch_event, pre_velocity);
1200        }
1201    }
1202
1203    fn reset_live_play_state(&mut self, ball: &BallFrameState) {
1204        self.active_setups.clear();
1205        self.recent_setups.clear();
1206        self.recent_dodge_starts.clear();
1207        self.pending_flicks.clear();
1208        self.previous_dodge_active.clear();
1209        self.last_emitted_dodge_frame.clear();
1210        self.previous_ball_velocity = ball.velocity();
1211    }
1212
1213    fn update_with_touch_classification_events(
1214        &mut self,
1215        frame: &FrameInfo,
1216        ball: &BallFrameState,
1217        players: &PlayerFrameState,
1218        touch_state: &TouchState,
1219        touch_classification_events: &[TouchClassificationEvent],
1220        live_play_state: &LivePlayState,
1221    ) -> SubtrActorResult<()> {
1222        self.events.begin_update();
1223        if !live_play_state.is_live_play {
1224            self.reset_live_play_state(ball);
1225            return Ok(());
1226        }
1227        self.prune_recent_state(frame.time);
1228        self.update_control_setups(
1229            frame,
1230            ball,
1231            players,
1232            &touch_state.touch_events,
1233            touch_state.last_touch_player.as_ref(),
1234        );
1235        self.track_dodge_starts(frame, players);
1236        self.apply_touch_events(frame, ball, players, &touch_state.touch_events);
1237        self.update_and_resolve_pending_flicks(frame, ball, touch_classification_events);
1238        self.previous_ball_velocity = ball.velocity();
1239        Ok(())
1240    }
1241
1242    pub fn update(
1243        &mut self,
1244        frame: &FrameInfo,
1245        ball: &BallFrameState,
1246        players: &PlayerFrameState,
1247        touch_state: &TouchState,
1248        touch: &TouchCalculator,
1249        live_play_state: &LivePlayState,
1250    ) -> SubtrActorResult<()> {
1251        self.update_with_touch_classification_events(
1252            frame,
1253            ball,
1254            players,
1255            touch_state,
1256            touch.events(),
1257            live_play_state,
1258        )
1259    }
1260}
1261
1262#[cfg(test)]
1263#[path = "flick_tests.rs"]
1264mod tests;