Skip to main content

subtr_actor/stats/calculators/
flick.rs

1use super::*;
2
3const FLICK_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.32;
4const FLICK_MAX_SETUP_STALE_SECONDS: f32 = 0.35;
5const FLICK_MIN_SETUP_SECONDS: f32 = 0.18;
6const FLICK_MIN_SETUP_TOUCHES: u32 = 2;
7const FLICK_MIN_BALL_SPEED_CHANGE: f32 = 450.0;
8const FLICK_HIGH_CONFIDENCE: f32 = 0.80;
9const FLICK_MIN_CONFIDENCE: f32 = 0.55;
10const FLICK_MAX_CONTROL_BALL_Z: f32 = 700.0;
11const FLICK_MAX_CONTROL_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 1.7;
12const FLICK_MIN_CONTROL_VERTICAL_GAP: f32 = 35.0;
13const FLICK_MAX_CONTROL_VERTICAL_GAP: f32 = 280.0;
14const FLICK_MIN_LOCAL_Z: f32 = 20.0;
15const FLICK_MAX_LOCAL_X_BEHIND: f32 = 95.0;
16const FLICK_MAX_LOCAL_X_FRONT: f32 = 210.0;
17const FLICK_MAX_LOCAL_Y: f32 = 170.0;
18const FLICK_MIN_IMPULSE_AWAY_ALIGNMENT: f32 = 0.15;
19
20#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
21#[ts(export)]
22pub struct FlickEvent {
23    pub time: f32,
24    pub frame: usize,
25    pub sample_time: f32,
26    pub sample_frame: usize,
27    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
28    pub player: PlayerId,
29    pub is_team_0: bool,
30    pub dodge_time: f32,
31    pub dodge_frame: usize,
32    pub time_since_dodge: f32,
33    pub setup_start_time: f32,
34    pub setup_start_frame: usize,
35    pub setup_duration: f32,
36    pub setup_touch_count: u32,
37    pub average_horizontal_gap: f32,
38    pub average_vertical_gap: f32,
39    pub ball_speed_change: f32,
40    pub ball_impulse: [f32; 3],
41    pub impulse_away_alignment: f32,
42    pub vertical_impulse: f32,
43    pub confidence: f32,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
47#[ts(export)]
48pub struct FlickStats {
49    pub count: u32,
50    pub high_confidence_count: u32,
51    pub is_last_flick: bool,
52    pub last_flick_time: Option<f32>,
53    pub last_flick_frame: Option<usize>,
54    pub time_since_last_flick: Option<f32>,
55    pub frames_since_last_flick: Option<usize>,
56    pub last_confidence: Option<f32>,
57    pub best_confidence: f32,
58    pub cumulative_confidence: f32,
59    pub cumulative_setup_duration: f32,
60    pub cumulative_ball_speed_change: f32,
61    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
62    pub labeled_event_counts: LabeledCounts,
63}
64
65impl FlickStats {
66    pub fn average_confidence(&self) -> f32 {
67        if self.count == 0 {
68            0.0
69        } else {
70            self.cumulative_confidence / self.count as f32
71        }
72    }
73
74    pub fn average_setup_duration(&self) -> f32 {
75        if self.count == 0 {
76            0.0
77        } else {
78            self.cumulative_setup_duration / self.count as f32
79        }
80    }
81
82    pub fn average_ball_speed_change(&self) -> f32 {
83        if self.count == 0 {
84            0.0
85        } else {
86            self.cumulative_ball_speed_change / self.count as f32
87        }
88    }
89
90    fn record_event(&mut self, event: &FlickEvent) {
91        self.labeled_event_counts.increment([confidence_band_label(
92            event.confidence >= FLICK_HIGH_CONFIDENCE,
93        )]);
94        self.sync_legacy_counts();
95        self.last_flick_time = Some(event.time);
96        self.last_flick_frame = Some(event.frame);
97        self.last_confidence = Some(event.confidence);
98        self.best_confidence = self.best_confidence.max(event.confidence);
99        self.cumulative_confidence += event.confidence;
100        self.cumulative_setup_duration += event.setup_duration;
101        self.cumulative_ball_speed_change += event.ball_speed_change;
102    }
103
104    pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
105        self.labeled_event_counts.count_matching(labels)
106    }
107
108    pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
109        LabeledCounts::complete_from_label_sets(
110            &[&CONFIDENCE_BAND_LABELS],
111            &self.labeled_event_counts,
112        )
113    }
114
115    fn sync_legacy_counts(&mut self) {
116        self.count = self.labeled_event_counts.total();
117        self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
118    }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq)]
122struct FlickControlObservation {
123    horizontal_gap: f32,
124    vertical_gap: f32,
125}
126
127#[derive(Debug, Clone, PartialEq)]
128struct ActiveFlickSetup {
129    is_team_0: bool,
130    start_time: f32,
131    start_frame: usize,
132    last_time: f32,
133    last_frame: usize,
134    duration: f32,
135    horizontal_gap_integral: f32,
136    vertical_gap_integral: f32,
137    touch_count: u32,
138}
139
140#[derive(Debug, Clone, PartialEq)]
141struct FlickSetupSummary {
142    is_team_0: bool,
143    start_time: f32,
144    start_frame: usize,
145    last_time: f32,
146    last_frame: usize,
147    duration: f32,
148    average_horizontal_gap: f32,
149    average_vertical_gap: f32,
150    touch_count: u32,
151}
152
153#[derive(Debug, Clone, PartialEq)]
154struct RecentDodgeStart {
155    time: f32,
156    frame: usize,
157    setup: FlickSetupSummary,
158}
159
160#[derive(Debug, Clone, Default, PartialEq)]
161pub struct FlickCalculator {
162    player_stats: HashMap<PlayerId, FlickStats>,
163    events: Vec<FlickEvent>,
164    active_setups: HashMap<PlayerId, ActiveFlickSetup>,
165    recent_setups: HashMap<PlayerId, FlickSetupSummary>,
166    recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
167    previous_dodge_active: HashMap<PlayerId, bool>,
168    previous_ball_velocity: Option<glam::Vec3>,
169    current_last_flick_player: Option<PlayerId>,
170}
171
172impl FlickCalculator {
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    pub fn player_stats(&self) -> &HashMap<PlayerId, FlickStats> {
178        &self.player_stats
179    }
180
181    pub fn events(&self) -> &[FlickEvent] {
182        &self.events
183    }
184
185    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
186        if max_value <= min_value {
187            return 0.0;
188        }
189
190        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
191    }
192
193    fn begin_sample(&mut self, frame: &FrameInfo) {
194        for stats in self.player_stats.values_mut() {
195            stats.is_last_flick = false;
196            stats.time_since_last_flick = stats
197                .last_flick_time
198                .map(|time| (frame.time - time).max(0.0));
199            stats.frames_since_last_flick = stats
200                .last_flick_frame
201                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
202        }
203
204        if let Some(player_id) = self.current_last_flick_player.as_ref() {
205            if let Some(stats) = self.player_stats.get_mut(player_id) {
206                stats.is_last_flick = true;
207            }
208        }
209    }
210
211    fn ball_impulse(
212        frame: &FrameInfo,
213        ball: &BallFrameState,
214        previous_ball_velocity: Option<glam::Vec3>,
215    ) -> glam::Vec3 {
216        const BALL_GRAVITY_Z: f32 = -650.0;
217
218        let Some(ball) = ball.sample() else {
219            return glam::Vec3::ZERO;
220        };
221        let Some(previous_ball_velocity) = previous_ball_velocity else {
222            return glam::Vec3::ZERO;
223        };
224
225        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
226        ball.velocity() - previous_ball_velocity - expected_linear_delta
227    }
228
229    fn control_observation(
230        ball: &BallSample,
231        player: &PlayerSample,
232        controlling_player: Option<&PlayerId>,
233    ) -> Option<FlickControlObservation> {
234        if controlling_player != Some(&player.player_id) {
235            return None;
236        }
237
238        let player_rigid_body = player.rigid_body.as_ref()?;
239        let player_position = player.position()?;
240        let ball_position = ball.position();
241        if !(BALL_CARRY_MIN_BALL_Z..=FLICK_MAX_CONTROL_BALL_Z).contains(&ball_position.z) {
242            return None;
243        }
244
245        let horizontal_gap = player_position
246            .truncate()
247            .distance(ball_position.truncate());
248        if horizontal_gap > FLICK_MAX_CONTROL_HORIZONTAL_GAP {
249            return None;
250        }
251
252        let vertical_gap = ball_position.z - player_position.z;
253        if !(FLICK_MIN_CONTROL_VERTICAL_GAP..=FLICK_MAX_CONTROL_VERTICAL_GAP)
254            .contains(&vertical_gap)
255        {
256            return None;
257        }
258
259        let local_ball_position =
260            quat_to_glam(&player_rigid_body.rotation).inverse() * (ball_position - player_position);
261        if local_ball_position.x < -FLICK_MAX_LOCAL_X_BEHIND
262            || local_ball_position.x > FLICK_MAX_LOCAL_X_FRONT
263            || local_ball_position.y.abs() > FLICK_MAX_LOCAL_Y
264            || local_ball_position.z < FLICK_MIN_LOCAL_Z
265        {
266            return None;
267        }
268
269        Some(FlickControlObservation {
270            horizontal_gap,
271            vertical_gap,
272        })
273    }
274
275    fn setup_summary(setup: &ActiveFlickSetup) -> FlickSetupSummary {
276        FlickSetupSummary {
277            is_team_0: setup.is_team_0,
278            start_time: setup.start_time,
279            start_frame: setup.start_frame,
280            last_time: setup.last_time,
281            last_frame: setup.last_frame,
282            duration: setup.duration,
283            average_horizontal_gap: setup.horizontal_gap_integral
284                / setup.duration.max(f32::EPSILON),
285            average_vertical_gap: setup.vertical_gap_integral / setup.duration.max(f32::EPSILON),
286            touch_count: setup.touch_count,
287        }
288    }
289
290    fn setup_qualifies(setup: &FlickSetupSummary) -> bool {
291        setup.duration >= FLICK_MIN_SETUP_SECONDS || setup.touch_count >= FLICK_MIN_SETUP_TOUCHES
292    }
293
294    fn store_recent_setup(&mut self, player_id: PlayerId, setup: FlickSetupSummary) {
295        if Self::setup_qualifies(&setup) {
296            self.recent_setups.insert(player_id, setup);
297        }
298    }
299
300    fn finish_setup(&mut self, player_id: &PlayerId) {
301        let Some(setup) = self.active_setups.remove(player_id) else {
302            return;
303        };
304        self.store_recent_setup(player_id.clone(), Self::setup_summary(&setup));
305    }
306
307    fn recent_setup_for_player(
308        &self,
309        player_id: &PlayerId,
310        current_time: f32,
311    ) -> Option<FlickSetupSummary> {
312        if let Some(active) = self.active_setups.get(player_id) {
313            return Some(Self::setup_summary(active));
314        }
315
316        self.recent_setups
317            .get(player_id)
318            .filter(|setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS)
319            .cloned()
320    }
321
322    fn update_control_setups(
323        &mut self,
324        frame: &FrameInfo,
325        ball: &BallFrameState,
326        players: &PlayerFrameState,
327        touch_events: &[TouchEvent],
328        controlling_player: Option<&PlayerId>,
329    ) {
330        let Some(ball) = ball.sample() else {
331            let player_ids: Vec<_> = self.active_setups.keys().cloned().collect();
332            for player_id in player_ids {
333                self.finish_setup(&player_id);
334            }
335            return;
336        };
337
338        let mut observed_players = HashSet::new();
339        for player in &players.players {
340            let Some(observation) = Self::control_observation(ball, player, controlling_player)
341            else {
342                continue;
343            };
344            observed_players.insert(player.player_id.clone());
345            let setup = self
346                .active_setups
347                .entry(player.player_id.clone())
348                .or_insert_with(|| ActiveFlickSetup {
349                    is_team_0: player.is_team_0,
350                    start_time: (frame.time - frame.dt).max(0.0),
351                    start_frame: frame.frame_number.saturating_sub(1),
352                    last_time: frame.time,
353                    last_frame: frame.frame_number,
354                    duration: frame.dt.max(0.0),
355                    horizontal_gap_integral: observation.horizontal_gap * frame.dt.max(0.0),
356                    vertical_gap_integral: observation.vertical_gap * frame.dt.max(0.0),
357                    touch_count: 0,
358                });
359
360            if setup.last_frame != frame.frame_number {
361                setup.last_time = frame.time;
362                setup.last_frame = frame.frame_number;
363                setup.duration += frame.dt.max(0.0);
364                setup.horizontal_gap_integral += observation.horizontal_gap * frame.dt.max(0.0);
365                setup.vertical_gap_integral += observation.vertical_gap * frame.dt.max(0.0);
366            }
367        }
368
369        for touch_event in touch_events {
370            let Some(player_id) = touch_event.player.as_ref() else {
371                continue;
372            };
373            if let Some(setup) = self.active_setups.get_mut(player_id) {
374                setup.touch_count += 1;
375            }
376        }
377
378        let active_ids: Vec<_> = self.active_setups.keys().cloned().collect();
379        for player_id in active_ids {
380            if !observed_players.contains(&player_id) {
381                self.finish_setup(&player_id);
382            }
383        }
384    }
385
386    fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
387        for player in &players.players {
388            let was_dodge_active = self
389                .previous_dodge_active
390                .insert(player.player_id.clone(), player.dodge_active)
391                .unwrap_or(false);
392            if !player.dodge_active || was_dodge_active {
393                continue;
394            }
395
396            let Some(setup) = self.recent_setup_for_player(&player.player_id, frame.time) else {
397                continue;
398            };
399            if !Self::setup_qualifies(&setup) {
400                continue;
401            }
402
403            self.recent_dodge_starts.insert(
404                player.player_id.clone(),
405                RecentDodgeStart {
406                    time: frame.time,
407                    frame: frame.frame_number,
408                    setup,
409                },
410            );
411        }
412    }
413
414    fn prune_recent_state(&mut self, current_time: f32) {
415        self.recent_setups
416            .retain(|_, setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS);
417        self.recent_dodge_starts
418            .retain(|_, dodge| current_time - dodge.time <= FLICK_MAX_DODGE_TO_TOUCH_SECONDS);
419    }
420
421    fn candidate_event(
422        &self,
423        ball: &BallFrameState,
424        player: &PlayerSample,
425        touch_event: &TouchEvent,
426        dodge_start: &RecentDodgeStart,
427        ball_impulse: glam::Vec3,
428    ) -> Option<FlickEvent> {
429        let ball = ball.sample()?;
430        let player_position = player.position()?;
431        let time_since_dodge = touch_event.time - dodge_start.time;
432        if !(0.0..=FLICK_MAX_DODGE_TO_TOUCH_SECONDS).contains(&time_since_dodge) {
433            return None;
434        }
435
436        let ball_speed_change = ball_impulse.length();
437        if ball_speed_change < FLICK_MIN_BALL_SPEED_CHANGE {
438            return None;
439        }
440
441        let to_ball = (ball.position() - player_position).normalize_or_zero();
442        let impulse_direction = ball_impulse.normalize_or_zero();
443        if to_ball.length_squared() <= f32::EPSILON
444            || impulse_direction.length_squared() <= f32::EPSILON
445        {
446            return None;
447        }
448
449        let impulse_away_alignment = impulse_direction.dot(to_ball);
450        if impulse_away_alignment < FLICK_MIN_IMPULSE_AWAY_ALIGNMENT {
451            return None;
452        }
453
454        let vertical_impulse = ball_impulse.z.max(0.0);
455        let setup = &dodge_start.setup;
456        let timing_score =
457            1.0 - (time_since_dodge / FLICK_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
458        let setup_duration_score =
459            Self::normalize_score(setup.duration, FLICK_MIN_SETUP_SECONDS, 0.75);
460        let touch_score =
461            (setup.touch_count as f32 / FLICK_MIN_SETUP_TOUCHES as f32).clamp(0.0, 1.0);
462        let horizontal_control_score =
463            1.0 - (setup.average_horizontal_gap / FLICK_MAX_CONTROL_HORIZONTAL_GAP).clamp(0.0, 1.0);
464        let vertical_control_score = 1.0
465            - ((setup.average_vertical_gap - 110.0).abs() / FLICK_MAX_CONTROL_VERTICAL_GAP)
466                .clamp(0.0, 1.0);
467        let impulse_score =
468            Self::normalize_score(ball_speed_change, FLICK_MIN_BALL_SPEED_CHANGE, 1450.0);
469        let away_score = Self::normalize_score(
470            impulse_away_alignment,
471            FLICK_MIN_IMPULSE_AWAY_ALIGNMENT,
472            0.85,
473        );
474        let vertical_score = Self::normalize_score(vertical_impulse, 100.0, 750.0);
475
476        let confidence = 0.16 * timing_score
477            + 0.19 * setup_duration_score.max(touch_score)
478            + 0.12 * horizontal_control_score
479            + 0.10 * vertical_control_score
480            + 0.22 * impulse_score
481            + 0.15 * away_score
482            + 0.06 * vertical_score;
483        if confidence < FLICK_MIN_CONFIDENCE {
484            return None;
485        }
486
487        Some(FlickEvent {
488            time: touch_event.time,
489            frame: touch_event.frame,
490            sample_time: touch_event.time,
491            sample_frame: touch_event.frame,
492            player: player.player_id.clone(),
493            is_team_0: player.is_team_0,
494            dodge_time: dodge_start.time,
495            dodge_frame: dodge_start.frame,
496            time_since_dodge,
497            setup_start_time: setup.start_time,
498            setup_start_frame: setup.start_frame,
499            setup_duration: setup.duration,
500            setup_touch_count: setup.touch_count,
501            average_horizontal_gap: setup.average_horizontal_gap,
502            average_vertical_gap: setup.average_vertical_gap,
503            ball_speed_change,
504            ball_impulse: ball_impulse.to_array(),
505            impulse_away_alignment,
506            vertical_impulse,
507            confidence,
508        })
509    }
510
511    fn apply_event(&mut self, frame: &FrameInfo, mut event: FlickEvent) {
512        event.sample_time = frame.time;
513        event.sample_frame = frame.frame_number;
514        let stats = self.player_stats.entry(event.player.clone()).or_default();
515        stats.record_event(&event);
516        stats.is_last_flick = true;
517        stats.time_since_last_flick = Some((frame.time - event.time).max(0.0));
518        stats.frames_since_last_flick = Some(frame.frame_number.saturating_sub(event.frame));
519
520        self.current_last_flick_player = Some(event.player.clone());
521        self.events.push(event);
522    }
523
524    fn apply_touch_events(
525        &mut self,
526        frame: &FrameInfo,
527        ball: &BallFrameState,
528        players: &PlayerFrameState,
529        touch_events: &[TouchEvent],
530    ) {
531        let ball_impulse = Self::ball_impulse(frame, ball, self.previous_ball_velocity);
532
533        for touch_event in touch_events {
534            let Some(player_id) = touch_event.player.as_ref() else {
535                continue;
536            };
537            let Some(player) = players
538                .players
539                .iter()
540                .find(|player| &player.player_id == player_id)
541            else {
542                continue;
543            };
544            let Some(dodge_start) = self.recent_dodge_starts.get(player_id) else {
545                continue;
546            };
547            let Some(event) =
548                self.candidate_event(ball, player, touch_event, dodge_start, ball_impulse)
549            else {
550                continue;
551            };
552
553            self.apply_event(frame, event);
554        }
555
556        if let Some(player_id) = self.current_last_flick_player.as_ref() {
557            if let Some(stats) = self.player_stats.get_mut(player_id) {
558                stats.is_last_flick = true;
559            }
560        }
561    }
562
563    fn reset_live_play_state(&mut self, ball: &BallFrameState) {
564        self.current_last_flick_player = None;
565        self.active_setups.clear();
566        self.recent_setups.clear();
567        self.recent_dodge_starts.clear();
568        self.previous_dodge_active.clear();
569        self.previous_ball_velocity = ball.velocity();
570    }
571
572    pub fn update(
573        &mut self,
574        frame: &FrameInfo,
575        ball: &BallFrameState,
576        players: &PlayerFrameState,
577        touch_state: &TouchState,
578        live_play_state: &LivePlayState,
579    ) -> SubtrActorResult<()> {
580        if !live_play_state.is_live_play {
581            self.reset_live_play_state(ball);
582            return Ok(());
583        }
584
585        self.begin_sample(frame);
586        self.prune_recent_state(frame.time);
587        self.update_control_setups(
588            frame,
589            ball,
590            players,
591            &touch_state.touch_events,
592            touch_state.last_touch_player.as_ref(),
593        );
594        self.track_dodge_starts(frame, players);
595        self.apply_touch_events(frame, ball, players, &touch_state.touch_events);
596        self.previous_ball_velocity = ball.velocity();
597        Ok(())
598    }
599}
600
601#[cfg(test)]
602#[path = "flick_tests.rs"]
603mod tests;