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}