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