Skip to main content

subtr_actor/stats/reducers/
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_Z_THRESHOLD: f32 = 180.0;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8enum TouchKind {
9    Dribble,
10    Control,
11    MediumHit,
12    HardHit,
13}
14
15const ALL_TOUCH_KINDS: [TouchKind; 4] = [
16    TouchKind::Dribble,
17    TouchKind::Control,
18    TouchKind::MediumHit,
19    TouchKind::HardHit,
20];
21
22impl TouchKind {
23    fn as_label(self) -> StatLabel {
24        let value = match self {
25            Self::Dribble => "dribble",
26            Self::Control => "control",
27            Self::MediumHit => "medium_hit",
28            Self::HardHit => "hard_hit",
29        };
30        StatLabel::new("kind", value)
31    }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum TouchHeightBand {
36    Ground,
37    LowAir,
38    HighAir,
39}
40
41const ALL_TOUCH_HEIGHT_BANDS: [TouchHeightBand; 3] = [
42    TouchHeightBand::Ground,
43    TouchHeightBand::LowAir,
44    TouchHeightBand::HighAir,
45];
46
47impl TouchHeightBand {
48    fn as_label(self) -> StatLabel {
49        let value = match self {
50            Self::Ground => "ground",
51            Self::LowAir => "low_air",
52            Self::HighAir => "high_air",
53        };
54        StatLabel::new("height_band", value)
55    }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59struct TouchClassification {
60    kind: TouchKind,
61    height_band: TouchHeightBand,
62}
63
64impl TouchClassification {
65    fn labels(self) -> [StatLabel; 2] {
66        [self.kind.as_label(), self.height_band.as_label()]
67    }
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Serialize)]
71pub struct TouchStats {
72    pub touch_count: u32,
73    pub dribble_touch_count: u32,
74    pub control_touch_count: u32,
75    pub medium_hit_count: u32,
76    pub hard_hit_count: u32,
77    pub aerial_touch_count: u32,
78    pub high_aerial_touch_count: u32,
79    pub is_last_touch: bool,
80    pub last_touch_time: Option<f32>,
81    pub last_touch_frame: Option<usize>,
82    pub time_since_last_touch: Option<f32>,
83    pub frames_since_last_touch: Option<usize>,
84    pub last_ball_speed_change: Option<f32>,
85    pub max_ball_speed_change: f32,
86    pub cumulative_ball_speed_change: f32,
87    #[serde(skip_serializing_if = "LabeledCounts::is_empty")]
88    pub labeled_touch_counts: LabeledCounts,
89}
90
91impl TouchStats {
92    pub fn average_ball_speed_change(&self) -> f32 {
93        if self.touch_count == 0 {
94            0.0
95        } else {
96            self.cumulative_ball_speed_change / self.touch_count as f32
97        }
98    }
99
100    pub fn touch_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
101        self.labeled_touch_counts.count_matching(labels)
102    }
103
104    pub fn complete_labeled_touch_counts(&self) -> LabeledCounts {
105        let mut entries: Vec<_> = ALL_TOUCH_HEIGHT_BANDS
106            .into_iter()
107            .flat_map(|height_band| {
108                ALL_TOUCH_KINDS.into_iter().map(move |kind| {
109                    let mut labels = vec![kind.as_label(), height_band.as_label()];
110                    labels.sort();
111                    LabeledCountEntry {
112                        count: self.labeled_touch_counts.count_exact(&labels),
113                        labels,
114                    }
115                })
116            })
117            .collect();
118
119        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
120
121        LabeledCounts { entries }
122    }
123
124    pub fn with_complete_labeled_touch_counts(mut self) -> Self {
125        self.labeled_touch_counts = self.complete_labeled_touch_counts();
126        self
127    }
128}
129
130#[derive(Debug, Clone, Default, PartialEq)]
131pub struct TouchReducer {
132    player_stats: HashMap<PlayerId, TouchStats>,
133    current_last_touch_player: Option<PlayerId>,
134    previous_ball_velocity: Option<glam::Vec3>,
135    live_play_tracker: LivePlayTracker,
136}
137
138impl TouchReducer {
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    pub fn player_stats(&self) -> &HashMap<PlayerId, TouchStats> {
144        &self.player_stats
145    }
146
147    fn ball_speed_change(sample: &StatsSample, previous_ball_velocity: Option<glam::Vec3>) -> f32 {
148        const BALL_GRAVITY_Z: f32 = -650.0;
149
150        let Some(ball) = sample.ball.as_ref() else {
151            return 0.0;
152        };
153        let Some(previous_ball_velocity) = previous_ball_velocity else {
154            return 0.0;
155        };
156
157        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * sample.dt.max(0.0));
158        let residual_linear_impulse =
159            ball.velocity() - previous_ball_velocity - expected_linear_delta;
160        residual_linear_impulse.length()
161    }
162
163    fn classify_touch(player_height: Option<f32>, ball_speed_change: f32) -> TouchClassification {
164        let player_height = player_height.unwrap_or(0.0);
165        let height_band = if player_height >= HIGH_AIR_Z_THRESHOLD {
166            TouchHeightBand::HighAir
167        } else if player_height >= AERIAL_TOUCH_Z_THRESHOLD {
168            TouchHeightBand::LowAir
169        } else {
170            TouchHeightBand::Ground
171        };
172
173        let kind = if ball_speed_change <= SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
174            if height_band != TouchHeightBand::Ground {
175                TouchKind::Control
176            } else {
177                TouchKind::Dribble
178            }
179        } else if ball_speed_change < HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
180            TouchKind::MediumHit
181        } else {
182            TouchKind::HardHit
183        };
184
185        TouchClassification { kind, height_band }
186    }
187
188    fn apply_touch_classification(stats: &mut TouchStats, classification: TouchClassification) {
189        match classification.height_band {
190            TouchHeightBand::Ground => {}
191            TouchHeightBand::LowAir => stats.aerial_touch_count += 1,
192            TouchHeightBand::HighAir => {
193                stats.aerial_touch_count += 1;
194                stats.high_aerial_touch_count += 1;
195            }
196        }
197
198        match classification.kind {
199            TouchKind::Dribble => stats.dribble_touch_count += 1,
200            TouchKind::Control => stats.control_touch_count += 1,
201            TouchKind::MediumHit => stats.medium_hit_count += 1,
202            TouchKind::HardHit => stats.hard_hit_count += 1,
203        }
204
205        stats
206            .labeled_touch_counts
207            .increment(classification.labels());
208    }
209
210    fn begin_sample(&mut self, sample: &StatsSample) {
211        for stats in self.player_stats.values_mut() {
212            stats.is_last_touch = false;
213            stats.time_since_last_touch = stats
214                .last_touch_time
215                .map(|time| (sample.time - time).max(0.0));
216            stats.frames_since_last_touch = stats
217                .last_touch_frame
218                .map(|frame| sample.frame_number.saturating_sub(frame));
219        }
220    }
221
222    fn apply_touch_events(&mut self, sample: &StatsSample, touch_events: &[TouchEvent]) {
223        let ball_speed_change = Self::ball_speed_change(sample, self.previous_ball_velocity);
224
225        for touch_event in touch_events {
226            let Some(player_id) = touch_event.player.as_ref() else {
227                continue;
228            };
229            let player_height = sample
230                .players
231                .iter()
232                .find(|player| player.player_id == *player_id)
233                .and_then(PlayerSample::position)
234                .map(|position| position.z);
235            let classification = Self::classify_touch(player_height, ball_speed_change);
236            let stats = self.player_stats.entry(player_id.clone()).or_default();
237            stats.touch_count += 1;
238            Self::apply_touch_classification(stats, classification);
239            stats.last_touch_time = Some(touch_event.time);
240            stats.last_touch_frame = Some(touch_event.frame);
241            stats.time_since_last_touch = Some((sample.time - touch_event.time).max(0.0));
242            stats.frames_since_last_touch =
243                Some(sample.frame_number.saturating_sub(touch_event.frame));
244            stats.last_ball_speed_change = Some(ball_speed_change);
245            stats.max_ball_speed_change = stats.max_ball_speed_change.max(ball_speed_change);
246            stats.cumulative_ball_speed_change += ball_speed_change;
247        }
248
249        if let Some(last_touch) = touch_events.last() {
250            self.current_last_touch_player = last_touch.player.clone();
251        }
252
253        if let Some(player_id) = self.current_last_touch_player.as_ref() {
254            if let Some(stats) = self.player_stats.get_mut(player_id) {
255                stats.is_last_touch = true;
256            }
257        }
258    }
259}
260
261impl StatsReducer for TouchReducer {
262    fn on_sample(&mut self, sample: &StatsSample) -> SubtrActorResult<()> {
263        if !self.live_play_tracker.is_live_play(sample) {
264            self.current_last_touch_player = None;
265            self.previous_ball_velocity = sample.ball.as_ref().map(BallSample::velocity);
266            return Ok(());
267        }
268
269        self.begin_sample(sample);
270        self.apply_touch_events(sample, &sample.touch_events);
271        self.previous_ball_velocity = sample.ball.as_ref().map(BallSample::velocity);
272
273        Ok(())
274    }
275
276    fn on_sample_with_context(
277        &mut self,
278        sample: &StatsSample,
279        ctx: &AnalysisContext,
280    ) -> SubtrActorResult<()> {
281        if !self.live_play_tracker.is_live_play(sample) {
282            self.current_last_touch_player = None;
283            self.previous_ball_velocity = sample.ball.as_ref().map(BallSample::velocity);
284            return Ok(());
285        }
286
287        let touch_state = ctx
288            .get::<TouchState>(TOUCH_STATE_SIGNAL_ID)
289            .cloned()
290            .unwrap_or_default();
291
292        self.begin_sample(sample);
293        self.apply_touch_events(sample, &touch_state.touch_events);
294        self.previous_ball_velocity = sample.ball.as_ref().map(BallSample::velocity);
295
296        if let Some(player_id) = touch_state.last_touch_player.as_ref() {
297            self.current_last_touch_player = Some(player_id.clone());
298        }
299
300        if let Some(player_id) = self.current_last_touch_player.as_ref() {
301            if let Some(stats) = self.player_stats.get_mut(player_id) {
302                stats.is_last_touch = true;
303            }
304        }
305
306        Ok(())
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use boxcars::RemoteId;
313
314    use super::*;
315
316    fn rigid_body(x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32) -> boxcars::RigidBody {
317        boxcars::RigidBody {
318            sleeping: false,
319            location: boxcars::Vector3f { x, y, z },
320            rotation: boxcars::Quaternion {
321                x: 0.0,
322                y: 0.0,
323                z: 0.0,
324                w: 1.0,
325            },
326            linear_velocity: Some(boxcars::Vector3f {
327                x: vx,
328                y: vy,
329                z: vz,
330            }),
331            angular_velocity: Some(boxcars::Vector3f {
332                x: 0.0,
333                y: 0.0,
334                z: 0.0,
335            }),
336        }
337    }
338
339    fn sample(
340        frame_number: usize,
341        time: f32,
342        player_z: f32,
343        ball_velocity_x: f32,
344        touch: bool,
345    ) -> StatsSample {
346        StatsSample {
347            frame_number,
348            time,
349            dt: 1.0 / 120.0,
350            seconds_remaining: None,
351            game_state: None,
352            ball_has_been_hit: None,
353            kickoff_countdown_time: None,
354            team_zero_score: None,
355            team_one_score: None,
356            possession_team_is_team_0: Some(true),
357            scored_on_team_is_team_0: None,
358            current_in_game_team_player_counts: Some([1, 1]),
359            ball: Some(BallSample {
360                rigid_body: rigid_body(0.0, 0.0, 120.0, ball_velocity_x, 0.0, 0.0),
361            }),
362            players: vec![
363                PlayerSample {
364                    player_id: RemoteId::Steam(1),
365                    is_team_0: true,
366                    rigid_body: Some(rigid_body(0.0, 0.0, player_z, 0.0, 0.0, 0.0)),
367                    boost_amount: None,
368                    last_boost_amount: None,
369                    boost_active: false,
370                    powerslide_active: false,
371                    match_goals: None,
372                    match_assists: None,
373                    match_saves: None,
374                    match_shots: None,
375                    match_score: None,
376                },
377                PlayerSample {
378                    player_id: RemoteId::Steam(2),
379                    is_team_0: false,
380                    rigid_body: Some(rigid_body(4000.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
381                    boost_amount: None,
382                    last_boost_amount: None,
383                    boost_active: false,
384                    powerslide_active: false,
385                    match_goals: None,
386                    match_assists: None,
387                    match_saves: None,
388                    match_shots: None,
389                    match_score: None,
390                },
391            ],
392            active_demos: Vec::new(),
393            demo_events: Vec::new(),
394            boost_pad_events: Vec::new(),
395            touch_events: if touch {
396                vec![TouchEvent {
397                    time,
398                    frame: frame_number,
399                    team_is_team_0: true,
400                    player: Some(RemoteId::Steam(1)),
401                    closest_approach_distance: Some(0.0),
402                }]
403            } else {
404                Vec::new()
405            },
406            dodge_refreshed_events: Vec::new(),
407            player_stat_events: Vec::new(),
408            goal_events: Vec::new(),
409        }
410    }
411
412    #[test]
413    fn touch_reducer_classifies_touch_strength_and_height_bands() {
414        let mut reducer = TouchReducer::new();
415
416        let baseline = sample(0, 0.0, 0.0, 0.0, false);
417        reducer.on_sample(&baseline).unwrap();
418
419        let dribble = sample(1, 1.0 / 120.0, 0.0, 120.0, true);
420        reducer.on_sample(&dribble).unwrap();
421
422        let control = sample(2, 2.0 / 120.0, 240.0, 220.0, true);
423        reducer.on_sample(&control).unwrap();
424
425        let medium = sample(3, 3.0 / 120.0, 0.0, 720.0, true);
426        reducer.on_sample(&medium).unwrap();
427
428        let hard_high_aerial = sample(4, 4.0 / 120.0, 900.0, 1900.0, true);
429        reducer.on_sample(&hard_high_aerial).unwrap();
430
431        let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
432        assert_eq!(stats.touch_count, 4);
433        assert_eq!(stats.dribble_touch_count, 1);
434        assert_eq!(stats.control_touch_count, 1);
435        assert_eq!(stats.medium_hit_count, 1);
436        assert_eq!(stats.hard_hit_count, 1);
437        assert_eq!(stats.aerial_touch_count, 2);
438        assert_eq!(stats.high_aerial_touch_count, 1);
439        assert_eq!(
440            stats.touch_count_with_labels(&[StatLabel::new("kind", "dribble")]),
441            1
442        );
443        assert_eq!(
444            stats.touch_count_with_labels(&[StatLabel::new("height_band", "low_air")]),
445            1
446        );
447        assert_eq!(
448            stats.touch_count_with_labels(&[StatLabel::new("height_band", "high_air")]),
449            1
450        );
451        assert_eq!(
452            stats.touch_count_with_labels(&[
453                StatLabel::new("kind", "hard_hit"),
454                StatLabel::new("height_band", "high_air"),
455            ]),
456            1
457        );
458        assert!(stats.last_ball_speed_change.is_some());
459        assert!(stats.max_ball_speed_change >= stats.average_ball_speed_change());
460    }
461
462    #[test]
463    fn touch_stats_complete_labeled_touch_counts_adds_zero_entries() {
464        let mut stats = TouchStats::default();
465        stats.labeled_touch_counts.increment([
466            StatLabel::new("kind", "hard_hit"),
467            StatLabel::new("height_band", "high_air"),
468        ]);
469
470        let completed = stats.complete_labeled_touch_counts();
471
472        assert_eq!(completed.entries.len(), 12);
473        assert_eq!(
474            completed.count_exact(&[
475                StatLabel::new("kind", "hard_hit"),
476                StatLabel::new("height_band", "high_air"),
477            ]),
478            1
479        );
480        assert_eq!(
481            completed.count_exact(&[
482                StatLabel::new("kind", "dribble"),
483                StatLabel::new("height_band", "ground"),
484            ]),
485            0
486        );
487    }
488}