Skip to main content

subtr_actor/stats/calculators/
touch.rs

1use super::*;
2
3const SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD: f32 = 320.0;
4const HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD: f32 = 900.0;
5const AERIAL_TOUCH_MIN_PLAYER_Z: f32 = AIR_DRIBBLE_MIN_PLAYER_Z;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8enum TouchKind {
9    Control,
10    MediumHit,
11    HardHit,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15enum TouchSurface {
16    Ground,
17    Air,
18    Wall,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum TouchDodgeState {
23    NoDodge,
24    Dodge,
25}
26
27const ALL_TOUCH_KINDS: [TouchKind; 3] =
28    [TouchKind::Control, TouchKind::MediumHit, TouchKind::HardHit];
29const ALL_TOUCH_SURFACES: [TouchSurface; 3] =
30    [TouchSurface::Ground, TouchSurface::Air, TouchSurface::Wall];
31const ALL_TOUCH_DODGE_STATES: [TouchDodgeState; 2] =
32    [TouchDodgeState::NoDodge, TouchDodgeState::Dodge];
33
34impl TouchKind {
35    fn as_label(self) -> StatLabel {
36        let value = match self {
37            Self::Control => "control",
38            Self::MediumHit => "medium_hit",
39            Self::HardHit => "hard_hit",
40        };
41        StatLabel::new("kind", value)
42    }
43}
44
45impl TouchSurface {
46    fn as_label(self) -> StatLabel {
47        let value = match self {
48            Self::Ground => "ground",
49            Self::Air => "air",
50            Self::Wall => "wall",
51        };
52        StatLabel::new("surface", value)
53    }
54}
55
56impl TouchDodgeState {
57    fn from_dodge_active(dodge_active: bool) -> Self {
58        if dodge_active {
59            Self::Dodge
60        } else {
61            Self::NoDodge
62        }
63    }
64
65    fn as_label(self) -> StatLabel {
66        let value = match self {
67            Self::NoDodge => "no_dodge",
68            Self::Dodge => "dodge",
69        };
70        StatLabel::new("dodge_state", value)
71    }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75struct TouchClassification {
76    kind: TouchKind,
77    height_band: PlayerVerticalBand,
78    surface: TouchSurface,
79    dodge_state: TouchDodgeState,
80}
81
82impl TouchClassification {
83    fn labels(self) -> [StatLabel; 4] {
84        [
85            self.kind.as_label(),
86            self.height_band.as_label(),
87            self.surface.as_label(),
88            self.dodge_state.as_label(),
89        ]
90    }
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
94#[ts(export)]
95pub struct TouchStats {
96    pub touch_count: u32,
97    pub control_touch_count: u32,
98    pub medium_hit_count: u32,
99    pub hard_hit_count: u32,
100    pub aerial_touch_count: u32,
101    pub high_aerial_touch_count: u32,
102    #[serde(default)]
103    pub wall_touch_count: u32,
104    pub is_last_touch: bool,
105    pub last_touch_time: Option<f32>,
106    pub last_touch_frame: Option<usize>,
107    pub time_since_last_touch: Option<f32>,
108    pub frames_since_last_touch: Option<usize>,
109    pub last_ball_speed_change: Option<f32>,
110    pub max_ball_speed_change: f32,
111    pub cumulative_ball_speed_change: f32,
112    #[serde(default)]
113    pub total_ball_travel_distance: f32,
114    #[serde(default)]
115    pub total_ball_advance_distance: f32,
116    #[serde(default)]
117    pub total_ball_retreat_distance: f32,
118    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
119    pub labeled_touch_counts: LabeledCounts,
120}
121
122impl TouchStats {
123    pub fn average_ball_speed_change(&self) -> f32 {
124        if self.touch_count == 0 {
125            0.0
126        } else {
127            self.cumulative_ball_speed_change / self.touch_count as f32
128        }
129    }
130
131    pub fn touch_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
132        self.labeled_touch_counts.count_matching(labels)
133    }
134
135    pub fn dodge_touch_count(&self) -> u32 {
136        self.touch_count_with_labels(&[StatLabel::new("dodge_state", "dodge")])
137    }
138
139    pub fn dodge_hit_count(&self) -> u32 {
140        self.touch_count_with_labels(&[
141            StatLabel::new("dodge_state", "dodge"),
142            StatLabel::new("kind", "medium_hit"),
143        ]) + self.touch_count_with_labels(&[
144            StatLabel::new("dodge_state", "dodge"),
145            StatLabel::new("kind", "hard_hit"),
146        ])
147    }
148
149    pub fn complete_labeled_touch_counts(&self) -> LabeledCounts {
150        let mut entries: Vec<_> = ALL_PLAYER_VERTICAL_BANDS
151            .into_iter()
152            .flat_map(|height_band| {
153                ALL_TOUCH_SURFACES.into_iter().flat_map(move |surface| {
154                    ALL_TOUCH_DODGE_STATES
155                        .into_iter()
156                        .flat_map(move |dodge_state| {
157                            ALL_TOUCH_KINDS.into_iter().map(move |kind| {
158                                let mut labels = vec![
159                                    kind.as_label(),
160                                    height_band.as_label(),
161                                    surface.as_label(),
162                                    dodge_state.as_label(),
163                                ];
164                                labels.sort();
165                                LabeledCountEntry {
166                                    count: self.labeled_touch_counts.count_exact(&labels),
167                                    labels,
168                                }
169                            })
170                        })
171                })
172            })
173            .collect();
174
175        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
176
177        LabeledCounts { entries }
178    }
179
180    pub fn with_complete_labeled_touch_counts(mut self) -> Self {
181        self.labeled_touch_counts = self.complete_labeled_touch_counts();
182        self
183    }
184}
185
186#[derive(Debug, Clone, Default, PartialEq)]
187struct PendingFiftyFiftyMovement {
188    start_frame: usize,
189    travel_distance: f32,
190    y_delta: f32,
191}
192
193#[derive(Debug, Clone, Default, PartialEq)]
194pub struct TouchCalculator {
195    player_stats: HashMap<PlayerId, TouchStats>,
196    current_last_touch_player: Option<PlayerId>,
197    previous_ball_velocity: Option<glam::Vec3>,
198    previous_ball_position: Option<glam::Vec3>,
199    pending_fifty_fifty_movement: Option<PendingFiftyFiftyMovement>,
200}
201
202impl TouchCalculator {
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    pub fn player_stats(&self) -> &HashMap<PlayerId, TouchStats> {
208        &self.player_stats
209    }
210
211    fn ball_speed_change(
212        frame: &FrameInfo,
213        ball: &BallFrameState,
214        previous_ball_velocity: Option<glam::Vec3>,
215    ) -> f32 {
216        const BALL_GRAVITY_Z: f32 = -650.0;
217
218        let Some(ball) = ball.sample() else {
219            return 0.0;
220        };
221        let Some(previous_ball_velocity) = previous_ball_velocity else {
222            return 0.0;
223        };
224
225        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
226        let residual_linear_impulse =
227            ball.velocity() - previous_ball_velocity - expected_linear_delta;
228        residual_linear_impulse.length()
229    }
230
231    fn classify_touch(
232        height_band: PlayerVerticalBand,
233        surface: TouchSurface,
234        dodge_state: TouchDodgeState,
235        ball_speed_change: f32,
236        controlled_touch_kind: Option<BallCarryKind>,
237    ) -> TouchClassification {
238        let kind = if controlled_touch_kind.is_some()
239            || ball_speed_change <= SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD
240        {
241            TouchKind::Control
242        } else if ball_speed_change < HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
243            TouchKind::MediumHit
244        } else {
245            TouchKind::HardHit
246        };
247
248        TouchClassification {
249            kind,
250            height_band,
251            surface,
252            dodge_state,
253        }
254    }
255
256    fn height_band_for_touch(sample: Option<&PlayerVerticalSample>) -> PlayerVerticalBand {
257        let Some(sample) = sample else {
258            return PlayerVerticalBand::Ground;
259        };
260
261        if sample.height < AERIAL_TOUCH_MIN_PLAYER_Z {
262            PlayerVerticalBand::Ground
263        } else {
264            sample.band
265        }
266    }
267
268    fn surface_for_touch(
269        player_position: Option<glam::Vec3>,
270        height_band: PlayerVerticalBand,
271    ) -> TouchSurface {
272        if player_position.is_some_and(player_is_on_wall) {
273            TouchSurface::Wall
274        } else if height_band.is_grounded() {
275            TouchSurface::Ground
276        } else {
277            TouchSurface::Air
278        }
279    }
280
281    fn apply_touch_classification(stats: &mut TouchStats, classification: TouchClassification) {
282        match classification.height_band {
283            PlayerVerticalBand::Ground => {}
284            PlayerVerticalBand::LowAir => stats.aerial_touch_count += 1,
285            PlayerVerticalBand::HighAir => {
286                stats.aerial_touch_count += 1;
287                stats.high_aerial_touch_count += 1;
288            }
289        }
290
291        match classification.kind {
292            TouchKind::Control => stats.control_touch_count += 1,
293            TouchKind::MediumHit => stats.medium_hit_count += 1,
294            TouchKind::HardHit => stats.hard_hit_count += 1,
295        }
296
297        if classification.surface == TouchSurface::Wall {
298            stats.wall_touch_count += 1;
299        }
300
301        stats
302            .labeled_touch_counts
303            .increment(classification.labels());
304    }
305
306    fn begin_sample(&mut self, frame: &FrameInfo) {
307        for stats in self.player_stats.values_mut() {
308            stats.is_last_touch = false;
309            stats.time_since_last_touch = stats
310                .last_touch_time
311                .map(|time| (frame.time - time).max(0.0));
312            stats.frames_since_last_touch = stats
313                .last_touch_frame
314                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
315        }
316    }
317
318    fn controlled_touch_kind(
319        ball: &BallFrameState,
320        players: &PlayerFrameState,
321        player_id: &PlayerId,
322    ) -> Option<BallCarryKind> {
323        let ball = ball.sample()?;
324        players
325            .players
326            .iter()
327            .find(|player| &player.player_id == player_id)
328            .and_then(|player| {
329                BallCarryCalculator::carry_frame_sample(player, ball).map(|sample| sample.kind)
330            })
331    }
332
333    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
334        players
335            .players
336            .iter()
337            .find(|player| &player.player_id == player_id)
338            .and_then(PlayerSample::position)
339    }
340
341    fn player_dodge_active(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
342        players
343            .players
344            .iter()
345            .find(|player| &player.player_id == player_id)
346            .is_some_and(|player| player.dodge_active)
347    }
348
349    fn apply_touch_events(
350        &mut self,
351        frame: &FrameInfo,
352        ball: &BallFrameState,
353        players: &PlayerFrameState,
354        vertical_state: &PlayerVerticalState,
355        touch_events: &[TouchEvent],
356    ) {
357        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
358
359        for touch_event in touch_events {
360            let Some(player_id) = touch_event.player.as_ref() else {
361                continue;
362            };
363            let height_band = Self::height_band_for_touch(vertical_state.sample(player_id));
364            let surface =
365                Self::surface_for_touch(Self::player_position(players, player_id), height_band);
366            let dodge_state =
367                TouchDodgeState::from_dodge_active(Self::player_dodge_active(players, player_id));
368            let controlled_touch_kind = Self::controlled_touch_kind(ball, players, player_id);
369            let classification = Self::classify_touch(
370                height_band,
371                surface,
372                dodge_state,
373                ball_speed_change,
374                controlled_touch_kind,
375            );
376            let stats = self.player_stats.entry(player_id.clone()).or_default();
377            stats.touch_count += 1;
378            Self::apply_touch_classification(stats, classification);
379            stats.last_touch_time = Some(touch_event.time);
380            stats.last_touch_frame = Some(touch_event.frame);
381            stats.time_since_last_touch = Some((frame.time - touch_event.time).max(0.0));
382            stats.frames_since_last_touch =
383                Some(frame.frame_number.saturating_sub(touch_event.frame));
384            stats.last_ball_speed_change = Some(ball_speed_change);
385            stats.max_ball_speed_change = stats.max_ball_speed_change.max(ball_speed_change);
386            stats.cumulative_ball_speed_change += ball_speed_change;
387        }
388
389        if let Some(last_touch) = touch_events.last() {
390            self.current_last_touch_player = last_touch.player.clone();
391        }
392
393        if let Some(player_id) = self.current_last_touch_player.as_ref() {
394            if let Some(stats) = self.player_stats.get_mut(player_id) {
395                stats.is_last_touch = true;
396            }
397        }
398    }
399
400    fn apply_ball_movement_credit(
401        &mut self,
402        player_id: &PlayerId,
403        team_is_team_0: bool,
404        delta: glam::Vec3,
405        travel_distance: f32,
406    ) {
407        let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
408        let advance_distance = delta.y * team_forward_sign;
409        let stats = self.player_stats.entry(player_id.clone()).or_default();
410        stats.total_ball_travel_distance += travel_distance;
411        if advance_distance >= 0.0 {
412            stats.total_ball_advance_distance += advance_distance;
413        } else {
414            stats.total_ball_retreat_distance += -advance_distance;
415        }
416    }
417
418    fn resolved_fifty_fifty_winner(event: &FiftyFiftyEvent) -> Option<(&PlayerId, bool)> {
419        let winning_team_is_team_0 = event.winning_team_is_team_0?;
420        let player = if winning_team_is_team_0 {
421            event.team_zero_player.as_ref()
422        } else {
423            event.team_one_player.as_ref()
424        }?;
425        Some((player, winning_team_is_team_0))
426    }
427
428    fn buffer_fifty_fifty_movement(
429        &mut self,
430        start_frame: usize,
431        delta: glam::Vec3,
432        travel_distance: f32,
433    ) {
434        let pending = self
435            .pending_fifty_fifty_movement
436            .get_or_insert(PendingFiftyFiftyMovement {
437                start_frame,
438                travel_distance: 0.0,
439                y_delta: 0.0,
440            });
441        if pending.start_frame != start_frame {
442            *pending = PendingFiftyFiftyMovement {
443                start_frame,
444                travel_distance: 0.0,
445                y_delta: 0.0,
446            };
447        }
448        pending.travel_distance += travel_distance;
449        pending.y_delta += delta.y;
450    }
451
452    fn flush_fifty_fifty_movement(&mut self, event: &FiftyFiftyEvent) {
453        let Some(pending) = self.pending_fifty_fifty_movement.take() else {
454            return;
455        };
456        if pending.start_frame != event.start_frame {
457            return;
458        }
459        let Some((player_id, team_is_team_0)) = Self::resolved_fifty_fifty_winner(event) else {
460            return;
461        };
462
463        let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
464        let advance_distance = pending.y_delta * team_forward_sign;
465        let stats = self.player_stats.entry(player_id.clone()).or_default();
466        stats.total_ball_travel_distance += pending.travel_distance;
467        if advance_distance >= 0.0 {
468            stats.total_ball_advance_distance += advance_distance;
469        } else {
470            stats.total_ball_retreat_distance += -advance_distance;
471        }
472    }
473
474    fn credit_ball_movement(
475        &mut self,
476        ball: &BallFrameState,
477        possession_state: &PossessionState,
478        fifty_fifty_state: &FiftyFiftyState,
479        live_play: bool,
480    ) {
481        let current_ball_position = ball.position();
482        if !live_play {
483            self.previous_ball_position = current_ball_position;
484            self.pending_fifty_fifty_movement = None;
485            return;
486        }
487
488        let Some(current_ball_position) = current_ball_position else {
489            self.previous_ball_position = None;
490            self.pending_fifty_fifty_movement = None;
491            return;
492        };
493        let Some(previous_ball_position) = self.previous_ball_position else {
494            self.previous_ball_position = Some(current_ball_position);
495            return;
496        };
497        self.previous_ball_position = Some(current_ball_position);
498
499        let delta = current_ball_position - previous_ball_position;
500        let travel_distance = delta.length();
501        if travel_distance <= f32::EPSILON {
502            return;
503        }
504
505        if let Some(active_event) = fifty_fifty_state.active_event.as_ref() {
506            self.buffer_fifty_fifty_movement(active_event.start_frame, delta, travel_distance);
507            return;
508        }
509
510        if let Some(event) = fifty_fifty_state.resolved_events.last() {
511            self.buffer_fifty_fifty_movement(event.start_frame, delta, travel_distance);
512            self.flush_fifty_fifty_movement(event);
513            return;
514        }
515
516        self.pending_fifty_fifty_movement = None;
517
518        let (Some(player_id), Some(team_is_team_0)) = (
519            possession_state.active_player_before_sample.as_ref(),
520            possession_state.active_team_before_sample,
521        ) else {
522            return;
523        };
524
525        self.apply_ball_movement_credit(player_id, team_is_team_0, delta, travel_distance);
526    }
527
528    #[allow(clippy::too_many_arguments)]
529    pub fn update(
530        &mut self,
531        frame: &FrameInfo,
532        ball: &BallFrameState,
533        players: &PlayerFrameState,
534        vertical_state: &PlayerVerticalState,
535        touch_state: &TouchState,
536        possession_state: &PossessionState,
537        fifty_fifty_state: &FiftyFiftyState,
538        live_play: bool,
539    ) -> SubtrActorResult<()> {
540        if !live_play {
541            self.current_last_touch_player = None;
542            self.previous_ball_velocity = ball.velocity();
543            self.previous_ball_position = ball.position();
544            self.pending_fifty_fifty_movement = None;
545            return Ok(());
546        }
547
548        self.begin_sample(frame);
549        self.apply_touch_events(
550            frame,
551            ball,
552            players,
553            vertical_state,
554            &touch_state.touch_events,
555        );
556        self.credit_ball_movement(ball, possession_state, fifty_fifty_state, live_play);
557        self.previous_ball_velocity = ball.velocity();
558
559        if let Some(player_id) = touch_state.last_touch_player.as_ref() {
560            self.current_last_touch_player = Some(player_id.clone());
561        }
562
563        if let Some(player_id) = self.current_last_touch_player.as_ref() {
564            if let Some(stats) = self.player_stats.get_mut(player_id) {
565                stats.is_last_touch = true;
566            }
567        }
568
569        Ok(())
570    }
571}
572
573#[cfg(test)]
574#[path = "touch_tests.rs"]
575mod tests;