Skip to main content

subtr_actor/stats/accumulators/
touch.rs

1use super::*;
2
3const TOUCH_KIND_LABEL_VALUES: [&str; 3] = ["control", "medium_hit", "hard_hit"];
4const TOUCH_SURFACE_LABEL_VALUES: [&str; 3] = ["ground", "air", "wall"];
5const TOUCH_DODGE_STATE_LABEL_VALUES: [&str; 2] = ["no_dodge", "dodge"];
6// Action and possession are independent tag axes, counted separately rather
7// than collapsed into one slot. A touch contributes to an axis only when it
8// carries that tag: no action tag (a loose poke) → no action count; no
9// possession outcome → no possession count. There is no "neutral" catch-all,
10// and contested is its own tag, not folded in here.
11const TOUCH_ACTION_LABEL_VALUES: [&str; 5] = ["shot", "save", "clear", "boom", "pass"];
12const TOUCH_POSSESSION_LABEL_VALUES: [&str; 2] = ["control", "advance"];
13const TOUCH_RECEPTION_LABEL_VALUES: [&str; 2] = ["first_touch", "continuation"];
14
15fn touch_kind_label(value: &str) -> StatLabel {
16    match value {
17        "medium_hit" => StatLabel::new("kind", "medium_hit"),
18        "hard_hit" => StatLabel::new("kind", "hard_hit"),
19        _ => StatLabel::new("kind", "control"),
20    }
21}
22
23fn touch_height_band_label(value: &str) -> StatLabel {
24    match value {
25        "low_air" => StatLabel::new("height_band", "low_air"),
26        "high_air" => StatLabel::new("height_band", "high_air"),
27        _ => StatLabel::new("height_band", "ground"),
28    }
29}
30
31fn touch_surface_label(value: &str) -> StatLabel {
32    match value {
33        "air" => StatLabel::new("surface", "air"),
34        "wall" => StatLabel::new("surface", "wall"),
35        _ => StatLabel::new("surface", "ground"),
36    }
37}
38
39fn touch_dodge_state_label(value: &str) -> StatLabel {
40    match value {
41        "dodge" => StatLabel::new("dodge_state", "dodge"),
42        _ => StatLabel::new("dodge_state", "no_dodge"),
43    }
44}
45
46fn touch_action_label(value: &str) -> Option<StatLabel> {
47    let label = match value {
48        "shot" => StatLabel::new("action", "shot"),
49        "save" => StatLabel::new("action", "save"),
50        "clear" => StatLabel::new("action", "clear"),
51        "boom" => StatLabel::new("action", "boom"),
52        "pass" => StatLabel::new("action", "pass"),
53        _ => return None,
54    };
55    Some(label)
56}
57
58fn touch_possession_label(value: &str) -> Option<StatLabel> {
59    let label = match value {
60        "control" => StatLabel::new("possession", "control"),
61        "advance" => StatLabel::new("possession", "advance"),
62        _ => return None,
63    };
64    Some(label)
65}
66
67fn touch_reception_label(first_touch: bool) -> StatLabel {
68    StatLabel::new(
69        "reception",
70        if first_touch {
71            "first_touch"
72        } else {
73            "continuation"
74        },
75    )
76}
77
78/// Accumulated touch stats: counts by control, hardness, and aerial context.
79#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
80#[ts(export)]
81pub struct TouchStats {
82    pub touch_count: u32,
83    pub control_touch_count: u32,
84    pub medium_hit_count: u32,
85    pub hard_hit_count: u32,
86    pub aerial_touch_count: u32,
87    pub high_aerial_touch_count: u32,
88    #[serde(default)]
89    pub wall_touch_count: u32,
90    #[serde(default)]
91    pub first_touch_count: u32,
92    pub is_last_touch: bool,
93    pub last_touch_time: Option<f32>,
94    pub last_touch_frame: Option<usize>,
95    pub time_since_last_touch: Option<f32>,
96    pub frames_since_last_touch: Option<usize>,
97    pub last_ball_speed_change: Option<f32>,
98    pub max_ball_speed_change: f32,
99    pub cumulative_ball_speed_change: f32,
100    #[serde(default)]
101    pub total_ball_travel_distance: f32,
102    #[serde(default)]
103    pub total_ball_advance_distance: f32,
104    #[serde(default)]
105    pub total_ball_retreat_distance: f32,
106    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
107    pub labeled_touch_counts: LabeledCounts,
108    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
109    pub labeled_action_counts: LabeledCounts,
110    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
111    pub labeled_possession_counts: LabeledCounts,
112    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
113    pub touch_counts_by_role: LabeledCounts,
114    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
115    pub touch_counts_by_play_depth: LabeledCounts,
116}
117
118impl TouchStats {
119    pub fn average_ball_speed_change(&self) -> f32 {
120        if self.touch_count == 0 {
121            0.0
122        } else {
123            self.cumulative_ball_speed_change / self.touch_count as f32
124        }
125    }
126
127    pub fn touch_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
128        self.labeled_touch_counts.count_matching(labels)
129    }
130
131    pub fn dodge_touch_count(&self) -> u32 {
132        self.touch_count_with_labels(&[StatLabel::new("dodge_state", "dodge")])
133    }
134
135    pub fn dodge_hit_count(&self) -> u32 {
136        self.touch_count_with_labels(&[
137            StatLabel::new("dodge_state", "dodge"),
138            StatLabel::new("kind", "medium_hit"),
139        ]) + self.touch_count_with_labels(&[
140            StatLabel::new("dodge_state", "dodge"),
141            StatLabel::new("kind", "hard_hit"),
142        ])
143    }
144
145    pub fn action_count(&self, action: &str) -> u32 {
146        let Some(label) = touch_action_label(action) else {
147            return 0;
148        };
149        self.labeled_action_counts.count_matching(&[label])
150    }
151
152    pub fn first_touch_action_count(&self, action: &str) -> u32 {
153        let Some(label) = touch_action_label(action) else {
154            return 0;
155        };
156        self.labeled_action_counts
157            .count_matching(&[label, touch_reception_label(true)])
158    }
159
160    pub fn possession_count(&self, possession: &str) -> u32 {
161        let Some(label) = touch_possession_label(possession) else {
162            return 0;
163        };
164        self.labeled_possession_counts.count_matching(&[label])
165    }
166
167    pub fn first_touch_possession_count(&self, possession: &str) -> u32 {
168        let Some(label) = touch_possession_label(possession) else {
169            return 0;
170        };
171        self.labeled_possession_counts
172            .count_matching(&[label, touch_reception_label(true)])
173    }
174
175    fn complete_labeled_counts(
176        source: &LabeledCounts,
177        key: &'static str,
178        values: &[&'static str],
179    ) -> LabeledCounts {
180        let mut entries: Vec<_> = values
181            .iter()
182            .flat_map(|&value| {
183                TOUCH_RECEPTION_LABEL_VALUES
184                    .into_iter()
185                    .map(move |reception| {
186                        let mut labels = vec![
187                            StatLabel::new(key, value),
188                            StatLabel::new("reception", reception),
189                        ];
190                        labels.sort();
191                        LabeledCountEntry {
192                            count: source.count_exact(&labels),
193                            labels,
194                        }
195                    })
196            })
197            .collect();
198
199        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
200
201        LabeledCounts { entries }
202    }
203
204    pub fn complete_labeled_action_counts(&self) -> LabeledCounts {
205        Self::complete_labeled_counts(
206            &self.labeled_action_counts,
207            "action",
208            &TOUCH_ACTION_LABEL_VALUES,
209        )
210    }
211
212    pub fn complete_labeled_possession_counts(&self) -> LabeledCounts {
213        Self::complete_labeled_counts(
214            &self.labeled_possession_counts,
215            "possession",
216            &TOUCH_POSSESSION_LABEL_VALUES,
217        )
218    }
219
220    pub fn touch_count_with_role(&self, role: RoleState) -> u32 {
221        self.touch_counts_by_role.count_exact(&[role.as_label()])
222    }
223
224    pub fn touch_count_with_play_depth(&self, play_depth: PlayDepthState) -> u32 {
225        self.touch_counts_by_play_depth
226            .count_exact(&[play_depth.as_label()])
227    }
228
229    pub fn touches_as_first_man(&self) -> u32 {
230        self.touch_count_with_role(RoleState::FirstMan)
231    }
232
233    pub fn touches_as_second_man(&self) -> u32 {
234        self.touch_count_with_role(RoleState::SecondMan)
235    }
236
237    pub fn touches_as_third_man(&self) -> u32 {
238        self.touch_count_with_role(RoleState::ThirdMan)
239    }
240
241    pub fn touches_behind_play(&self) -> u32 {
242        self.touch_count_with_play_depth(PlayDepthState::BehindPlay)
243    }
244
245    pub fn touches_ahead_of_play(&self) -> u32 {
246        self.touch_count_with_play_depth(PlayDepthState::AheadOfPlay)
247    }
248
249    pub fn complete_touch_counts_by_role(&self) -> LabeledCounts {
250        let mut entries: Vec<_> = ALL_ROLE_STATES
251            .into_iter()
252            .map(|role| {
253                let labels = vec![role.as_label()];
254                LabeledCountEntry {
255                    count: self.touch_counts_by_role.count_exact(&labels),
256                    labels,
257                }
258            })
259            .collect();
260        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
261        LabeledCounts { entries }
262    }
263
264    pub fn complete_touch_counts_by_play_depth(&self) -> LabeledCounts {
265        let mut entries: Vec<_> = ALL_PLAY_DEPTH_STATES
266            .into_iter()
267            .map(|play_depth| {
268                let labels = vec![play_depth.as_label()];
269                LabeledCountEntry {
270                    count: self.touch_counts_by_play_depth.count_exact(&labels),
271                    labels,
272                }
273            })
274            .collect();
275        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
276        LabeledCounts { entries }
277    }
278
279    pub fn complete_labeled_touch_counts(&self) -> LabeledCounts {
280        let mut entries: Vec<_> = ALL_PLAYER_VERTICAL_BANDS
281            .into_iter()
282            .flat_map(|height_band| {
283                TOUCH_SURFACE_LABEL_VALUES
284                    .into_iter()
285                    .flat_map(move |surface| {
286                        TOUCH_DODGE_STATE_LABEL_VALUES
287                            .into_iter()
288                            .flat_map(move |dodge_state| {
289                                TOUCH_KIND_LABEL_VALUES.into_iter().map(move |kind| {
290                                    let mut labels = vec![
291                                        StatLabel::new("kind", kind),
292                                        height_band.as_label(),
293                                        StatLabel::new("surface", surface),
294                                        StatLabel::new("dodge_state", dodge_state),
295                                    ];
296                                    labels.sort();
297                                    LabeledCountEntry {
298                                        count: self.labeled_touch_counts.count_exact(&labels),
299                                        labels,
300                                    }
301                                })
302                            })
303                    })
304            })
305            .collect();
306
307        entries.sort_by(|left, right| left.labels.cmp(&right.labels));
308
309        LabeledCounts { entries }
310    }
311
312    pub fn with_complete_labeled_touch_counts(mut self) -> Self {
313        self.labeled_touch_counts = self.complete_labeled_touch_counts();
314        self.labeled_action_counts = self.complete_labeled_action_counts();
315        self.labeled_possession_counts = self.complete_labeled_possession_counts();
316        self
317    }
318}
319
320/// Accumulates touch stats over the replay from touch events.
321#[derive(Debug, Clone, Default, PartialEq)]
322pub struct TouchStatsAccumulator {
323    player_stats: HashMap<PlayerId, TouchStats>,
324    current_last_touch_player: Option<PlayerId>,
325}
326
327impl TouchStatsAccumulator {
328    pub fn new() -> Self {
329        Self::default()
330    }
331
332    pub fn player_stats(&self) -> &HashMap<PlayerId, TouchStats> {
333        &self.player_stats
334    }
335
336    pub fn begin_sample(&mut self, frame: &FrameInfo) {
337        for stats in self.player_stats.values_mut() {
338            stats.is_last_touch = false;
339            stats.time_since_last_touch = stats
340                .last_touch_time
341                .map(|time| (frame.time - time).max(0.0));
342            stats.frames_since_last_touch = stats
343                .last_touch_frame
344                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
345        }
346    }
347
348    pub fn apply_touch_event(&mut self, event: &TouchClassificationEvent, frame: &FrameInfo) {
349        // The event now carries classification as a tag set; read the
350        // single-valued groups back out, defaulting to the same fallbacks the
351        // old flat fields used. The `intention` stat label preserves the
352        // each single-valued group back out, defaulting to the same fallbacks
353        // the old flat fields used. Action and possession are counted on their
354        // own axes — a touch contributes to each only when it carries that tag.
355        let kind = event.tag("kind").unwrap_or("control");
356        let height_band = event.tag("height_band").unwrap_or("ground");
357        let surface = event.tag("surface").unwrap_or("ground");
358        let dodge_state = event.tag("dodge_state").unwrap_or("no_dodge");
359        let action = event.tag("action");
360        let possession = event.tag("possession");
361        let first_touch = event.tag("reception") == Some("first_touch");
362
363        let stats = self.player_stats.entry(event.player.clone()).or_default();
364        stats.touch_count += 1;
365        match height_band {
366            "low_air" => stats.aerial_touch_count += 1,
367            "high_air" => {
368                stats.aerial_touch_count += 1;
369                stats.high_aerial_touch_count += 1;
370            }
371            _ => {}
372        }
373        match kind {
374            "control" => stats.control_touch_count += 1,
375            "medium_hit" => stats.medium_hit_count += 1,
376            "hard_hit" => stats.hard_hit_count += 1,
377            _ => {}
378        }
379        if surface == "wall" {
380            stats.wall_touch_count += 1;
381        }
382        stats.labeled_touch_counts.increment([
383            touch_kind_label(kind),
384            touch_height_band_label(height_band),
385            touch_surface_label(surface),
386            touch_dodge_state_label(dodge_state),
387        ]);
388        if first_touch {
389            stats.first_touch_count += 1;
390        }
391        if let Some(label) = action.and_then(touch_action_label) {
392            stats
393                .labeled_action_counts
394                .increment([label, touch_reception_label(first_touch)]);
395        }
396        if let Some(label) = possession.and_then(touch_possession_label) {
397            stats
398                .labeled_possession_counts
399                .increment([label, touch_reception_label(first_touch)]);
400        }
401        stats
402            .touch_counts_by_role
403            .increment([event.role.as_label()]);
404        stats
405            .touch_counts_by_play_depth
406            .increment([event.play_depth.as_label()]);
407        stats.last_touch_time = Some(event.time);
408        stats.last_touch_frame = Some(event.frame);
409        stats.time_since_last_touch = Some((frame.time - event.time).max(0.0));
410        stats.frames_since_last_touch = Some(frame.frame_number.saturating_sub(event.frame));
411        stats.last_ball_speed_change = Some(event.ball_speed_change);
412        stats.max_ball_speed_change = stats.max_ball_speed_change.max(event.ball_speed_change);
413        stats.cumulative_ball_speed_change += event.ball_speed_change;
414        if let Some(movement) = event.ball_movement.as_ref() {
415            stats.total_ball_travel_distance += movement.travel_distance;
416            stats.total_ball_advance_distance += movement.advance_distance;
417            stats.total_ball_retreat_distance += movement.retreat_distance;
418        }
419        self.current_last_touch_player = Some(event.player.clone());
420    }
421
422    pub fn set_current_last_touch_player(&mut self, player: Option<PlayerId>) {
423        self.current_last_touch_player = player;
424    }
425
426    pub fn restore_current_last_touch_marker(&mut self) {
427        if let Some(player_id) = self.current_last_touch_player.as_ref() {
428            if let Some(stats) = self.player_stats.get_mut(player_id) {
429                stats.is_last_touch = true;
430            }
431        }
432    }
433}