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 =
433 TouchDodgeState::from_dodge_active(Self::player_dodge_active(players, player_id));
434 let controlled_touch_kind = Self::controlled_touch_kind(ball, players, player_id);
435 let classification = Self::classify_touch(
436 height_band,
437 surface,
438 dodge_state,
439 ball_speed_change,
440 controlled_touch_kind,
441 );
442 self.events.push(TouchStatsEvent {
443 time: touch_event.time,
444 frame: touch_event.frame,
445 sample_time: frame.time,
446 sample_frame: frame.frame_number,
447 player: player_id.clone(),
448 is_team_0: touch_event.team_is_team_0,
449 kind: classification.kind.as_label_value().to_owned(),
450 height_band: classification.height_band.as_label().value.to_owned(),
451 surface: classification.surface.as_label_value().to_owned(),
452 dodge_state: classification.dodge_state.as_label_value().to_owned(),
453 ball_speed_change,
454 });
455 let stats = self.player_stats.entry(player_id.clone()).or_default();
456 stats.touch_count += 1;
457 Self::apply_touch_classification(stats, classification);
458 stats.last_touch_time = Some(touch_event.time);
459 stats.last_touch_frame = Some(touch_event.frame);
460 stats.time_since_last_touch = Some((frame.time - touch_event.time).max(0.0));
461 stats.frames_since_last_touch =
462 Some(frame.frame_number.saturating_sub(touch_event.frame));
463 stats.last_ball_speed_change = Some(ball_speed_change);
464 stats.max_ball_speed_change = stats.max_ball_speed_change.max(ball_speed_change);
465 stats.cumulative_ball_speed_change += ball_speed_change;
466 }
467
468 if let Some(last_touch) = touch_events.last() {
469 self.last_touch_events.push(TouchLastTouchEvent {
470 time: last_touch.time,
471 frame: last_touch.frame,
472 sample_time: frame.time,
473 sample_frame: frame.frame_number,
474 is_team_0: last_touch.team_is_team_0,
475 player: last_touch.player.clone(),
476 });
477 self.current_last_touch_player = last_touch.player.clone();
478 }
479
480 if let Some(player_id) = self.current_last_touch_player.as_ref() {
481 if let Some(stats) = self.player_stats.get_mut(player_id) {
482 stats.is_last_touch = true;
483 }
484 }
485 }
486
487 fn apply_ball_movement_credit(
488 &mut self,
489 frame: usize,
490 time: f32,
491 player_id: &PlayerId,
492 team_is_team_0: bool,
493 delta: glam::Vec3,
494 travel_distance: f32,
495 ) {
496 let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
497 let advance_distance = delta.y * team_forward_sign;
498 let (advance_distance, retreat_distance) = if advance_distance >= 0.0 {
499 (advance_distance, 0.0)
500 } else {
501 (0.0, -advance_distance)
502 };
503 self.ball_movement_events.push(TouchBallMovementEvent {
504 time,
505 frame,
506 player: player_id.clone(),
507 is_team_0: team_is_team_0,
508 travel_distance,
509 advance_distance,
510 retreat_distance,
511 });
512 let stats = self.player_stats.entry(player_id.clone()).or_default();
513 stats.total_ball_travel_distance += travel_distance;
514 stats.total_ball_advance_distance += advance_distance;
515 stats.total_ball_retreat_distance += retreat_distance;
516 }
517
518 fn resolved_fifty_fifty_winner(event: &FiftyFiftyEvent) -> Option<(&PlayerId, bool)> {
519 let winning_team_is_team_0 = event.winning_team_is_team_0?;
520 let player = if winning_team_is_team_0 {
521 event.team_zero_player.as_ref()
522 } else {
523 event.team_one_player.as_ref()
524 }?;
525 Some((player, winning_team_is_team_0))
526 }
527
528 fn buffer_fifty_fifty_movement(
529 &mut self,
530 start_frame: usize,
531 delta: glam::Vec3,
532 travel_distance: f32,
533 ) {
534 let pending = self
535 .pending_fifty_fifty_movement
536 .get_or_insert(PendingFiftyFiftyMovement {
537 start_frame,
538 travel_distance: 0.0,
539 y_delta: 0.0,
540 });
541 if pending.start_frame != start_frame {
542 *pending = PendingFiftyFiftyMovement {
543 start_frame,
544 travel_distance: 0.0,
545 y_delta: 0.0,
546 };
547 }
548 pending.travel_distance += travel_distance;
549 pending.y_delta += delta.y;
550 }
551
552 fn flush_fifty_fifty_movement(&mut self, event: &FiftyFiftyEvent) {
553 let Some(pending) = self.pending_fifty_fifty_movement.take() else {
554 return;
555 };
556 if pending.start_frame != event.start_frame {
557 return;
558 }
559 let Some((player_id, team_is_team_0)) = Self::resolved_fifty_fifty_winner(event) else {
560 return;
561 };
562
563 let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
564 let advance_distance = pending.y_delta * team_forward_sign;
565 let (advance_distance, retreat_distance) = if advance_distance >= 0.0 {
566 (advance_distance, 0.0)
567 } else {
568 (0.0, -advance_distance)
569 };
570 self.ball_movement_events.push(TouchBallMovementEvent {
571 time: event.resolve_time,
572 frame: event.resolve_frame,
573 player: player_id.clone(),
574 is_team_0: team_is_team_0,
575 travel_distance: pending.travel_distance,
576 advance_distance,
577 retreat_distance,
578 });
579 let stats = self.player_stats.entry(player_id.clone()).or_default();
580 stats.total_ball_travel_distance += pending.travel_distance;
581 stats.total_ball_advance_distance += advance_distance;
582 stats.total_ball_retreat_distance += retreat_distance;
583 }
584
585 fn credit_ball_movement(
586 &mut self,
587 frame: &FrameInfo,
588 ball: &BallFrameState,
589 possession_state: &PossessionState,
590 fifty_fifty_state: &FiftyFiftyState,
591 live_play: bool,
592 ) {
593 let current_ball_position = ball.position();
594 if !live_play {
595 self.previous_ball_position = current_ball_position;
596 self.pending_fifty_fifty_movement = None;
597 return;
598 }
599
600 let Some(current_ball_position) = current_ball_position else {
601 self.previous_ball_position = None;
602 self.pending_fifty_fifty_movement = None;
603 return;
604 };
605 let Some(previous_ball_position) = self.previous_ball_position else {
606 self.previous_ball_position = Some(current_ball_position);
607 return;
608 };
609 self.previous_ball_position = Some(current_ball_position);
610
611 let delta = current_ball_position - previous_ball_position;
612 let travel_distance = delta.length();
613 if travel_distance <= f32::EPSILON {
614 return;
615 }
616
617 if let Some(active_event) = fifty_fifty_state.active_event.as_ref() {
618 self.buffer_fifty_fifty_movement(active_event.start_frame, delta, travel_distance);
619 return;
620 }
621
622 if let Some(event) = fifty_fifty_state.resolved_events.last() {
623 self.buffer_fifty_fifty_movement(event.start_frame, delta, travel_distance);
624 self.flush_fifty_fifty_movement(event);
625 return;
626 }
627
628 self.pending_fifty_fifty_movement = None;
629
630 let (Some(player_id), Some(team_is_team_0)) = (
631 possession_state.active_player_before_sample.as_ref(),
632 possession_state.active_team_before_sample,
633 ) else {
634 return;
635 };
636
637 self.apply_ball_movement_credit(
638 frame.frame_number,
639 frame.time,
640 player_id,
641 team_is_team_0,
642 delta,
643 travel_distance,
644 );
645 }
646
647 #[allow(clippy::too_many_arguments)]
648 pub fn update(
649 &mut self,
650 frame: &FrameInfo,
651 ball: &BallFrameState,
652 players: &PlayerFrameState,
653 vertical_state: &PlayerVerticalState,
654 touch_state: &TouchState,
655 possession_state: &PossessionState,
656 fifty_fifty_state: &FiftyFiftyState,
657 live_play: bool,
658 ) -> SubtrActorResult<()> {
659 if !live_play {
660 self.current_last_touch_player = None;
661 self.previous_ball_velocity = ball.velocity();
662 self.previous_ball_position = ball.position();
663 self.pending_fifty_fifty_movement = None;
664 return Ok(());
665 }
666
667 self.begin_sample(frame);
668 self.apply_touch_events(
669 frame,
670 ball,
671 players,
672 vertical_state,
673 &touch_state.touch_events,
674 );
675 self.credit_ball_movement(frame, ball, possession_state, fifty_fifty_state, live_play);
676 self.previous_ball_velocity = ball.velocity();
677
678 if let Some(player_id) = touch_state.last_touch_player.as_ref() {
679 self.current_last_touch_player = Some(player_id.clone());
680 }
681
682 if let Some(player_id) = self.current_last_touch_player.as_ref() {
683 if let Some(stats) = self.player_stats.get_mut(player_id) {
684 stats.is_last_touch = true;
685 }
686 }
687
688 Ok(())
689 }
690}
691
692#[cfg(test)]
693#[path = "touch_tests.rs"]
694mod tests;