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;