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
7const DODGE_LAG_TOLERANCE_SECONDS: f32 = DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS;
16
17const STAT_EVENT_OUTCOME_WINDOW_SECONDS: f32 = 0.75;
21
22const GOAL_SHOT_ATTRIBUTION_WINDOW_SECONDS: f32 = 10.0;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum TouchKind {
29 Control,
30 MediumHit,
31 HardHit,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum TouchSurface {
36 Ground,
37 Air,
38 Wall,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum TouchDodgeState {
43 NoDodge,
44 Dodge,
45}
46
47impl TouchKind {
48 fn as_label_value(self) -> &'static str {
49 match self {
50 Self::Control => "control",
51 Self::MediumHit => "medium_hit",
52 Self::HardHit => "hard_hit",
53 }
54 }
55}
56
57impl TouchSurface {
58 fn as_label_value(self) -> &'static str {
59 match self {
60 Self::Ground => "ground",
61 Self::Air => "air",
62 Self::Wall => "wall",
63 }
64 }
65}
66
67impl TouchDodgeState {
68 fn from_dodge_active(dodge_active: bool) -> Self {
69 if dodge_active {
70 Self::Dodge
71 } else {
72 Self::NoDodge
73 }
74 }
75
76 fn as_label_value(self) -> &'static str {
77 match self {
78 Self::NoDodge => "no_dodge",
79 Self::Dodge => "dodge",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85struct TouchClassification {
86 kind: TouchKind,
87 height_band: PlayerVerticalBand,
88 surface: TouchSurface,
89 dodge_state: TouchDodgeState,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
100#[ts(export)]
101pub struct TouchTag {
102 pub group: String,
103 pub value: String,
104}
105
106impl TouchTag {
107 fn new(group: &str, value: &str) -> Self {
108 Self {
109 group: group.to_owned(),
110 value: value.to_owned(),
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
119#[ts(export)]
120pub struct TouchClassificationEvent {
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 #[ts(type = "number")]
126 pub touch_id: Option<u64>,
127 pub time: f32,
128 pub frame: usize,
129 pub sample_time: f32,
130 pub sample_frame: usize,
131 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
132 pub player: PlayerId,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub player_position: Option<[f32; 3]>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub ball_position: Option<[f32; 3]>,
141 pub is_team_0: bool,
142 #[serde(default)]
146 pub tags: Vec<TouchTag>,
147 #[serde(default)]
148 pub role: RoleState,
149 #[serde(default)]
150 pub play_depth: PlayDepthState,
151 pub ball_speed_change: f32,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub ball_movement: Option<TouchBallMovement>,
154}
155
156impl TouchClassificationEvent {
157 pub(crate) fn classification_tags(
163 kind: &str,
164 height_band: &str,
165 surface: &str,
166 dodge_state: &str,
167 action: Option<&str>,
168 first_touch: bool,
169 contested: bool,
170 ) -> Vec<TouchTag> {
171 let mut tags = vec![
172 TouchTag::new("kind", kind),
173 TouchTag::new("height_band", height_band),
174 TouchTag::new("surface", surface),
175 TouchTag::new("dodge_state", dodge_state),
176 TouchTag::new(
177 "reception",
178 if first_touch {
179 "first_touch"
180 } else {
181 "continuation"
182 },
183 ),
184 ];
185 if let Some(action) = action {
186 tags.push(TouchTag::new("action", action));
187 }
188 if contested {
189 tags.push(TouchTag::new("contested", "contested"));
190 }
191 tags
192 }
193
194 pub fn tag(&self, group: &str) -> Option<&str> {
196 self.tags
197 .iter()
198 .find(|tag| tag.group == group)
199 .map(|tag| tag.value.as_str())
200 }
201
202 pub fn has_tag(&self, group: &str, value: &str) -> bool {
204 self.tags
205 .iter()
206 .any(|tag| tag.group == group && tag.value == value)
207 }
208
209 fn set_tag(&mut self, group: &str, value: &str) {
213 if let Some(tag) = self.tags.iter_mut().find(|tag| tag.group == group) {
214 tag.value = value.to_owned();
215 } else {
216 self.tags.push(TouchTag::new(group, value));
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
223#[ts(export)]
224pub struct TouchBallMovement {
225 pub start_time: f32,
226 pub start_frame: usize,
227 pub end_time: f32,
228 pub end_frame: usize,
229 pub duration: f32,
230 pub travel_distance: f32,
231 pub advance_distance: f32,
232 pub retreat_distance: f32,
233 pub finalized: bool,
234}
235
236impl TouchBallMovement {
237 fn absorb_delta(&mut self, event: Self) {
238 self.end_time = event.end_time;
239 self.end_frame = event.end_frame;
240 self.duration += event.duration;
241 self.travel_distance += event.travel_distance;
242 self.advance_distance += event.advance_distance;
243 self.retreat_distance += event.retreat_distance;
244 }
245
246 fn finalized(mut self) -> Self {
247 self.finalized = true;
248 self
249 }
250}
251
252#[derive(Debug, Clone, PartialEq)]
256struct PendingDodgeUpgrade {
257 touch_index: usize,
258 player_id: PlayerId,
259 touch_time: f32,
260}
261
262#[derive(Debug, Clone, Default, PartialEq)]
263struct PendingFiftyFiftyMovement {
264 start_frame: usize,
265 travel_distance: f32,
266 y_delta: f32,
267}
268
269#[derive(Debug, Clone, PartialEq)]
270struct PendingTouchBallMovementCredit {
271 touch_index: usize,
272 movement: TouchBallMovement,
273}
274
275impl InFlightItem for PendingTouchBallMovementCredit {
276 fn recognition(&self) -> Recognition {
277 Recognition::committed(self.movement.start_time, self.movement.start_frame)
280 }
281
282 fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
283 Disposition::Finalize(FinalizeReason::Boundary(boundary))
286 }
287}
288
289#[derive(Debug, Clone, Default, PartialEq)]
291pub struct TouchCalculator {
292 events: EventStream<TouchClassificationEvent>,
293 ball_movement: InFlightLedger<PendingTouchBallMovementCredit>,
294 active_touch_index_by_player: HashMap<PlayerId, usize>,
295 previous_ball_velocity: Option<glam::Vec3>,
296 previous_ball_position: Option<glam::Vec3>,
297 pending_fifty_fifty_movement: Option<PendingFiftyFiftyMovement>,
298 pending_dodge_upgrades: Vec<PendingDodgeUpgrade>,
299 intention_classifier: TouchIntentionClassifier,
300 control_follow: ControlFollowTracker,
301 shot_projection: ShotProjectionTracker,
302}
303
304fn action_rank(action: Option<&str>) -> u8 {
311 match action {
312 Some("save") => 5,
313 Some("shot") => 4,
314 Some("clear") => 3,
315 Some("pass") => 2,
316 Some("boom") => 1,
317 _ => 0,
318 }
319}
320
321impl TouchCalculator {
322 pub fn new() -> Self {
323 Self::default()
324 }
325
326 pub fn events(&self) -> &[TouchClassificationEvent] {
327 self.events.all()
328 }
329
330 pub fn new_events(&self) -> &[TouchClassificationEvent] {
331 self.events.new_events()
332 }
333
334 pub fn flush_pending_ball_movement_credit(&mut self) {
335 let finalized = self.ball_movement.finalize_all(FinalizeReason::Completed);
336 self.write_finalized_ball_movement(finalized);
337 }
338
339 pub fn finish(&mut self) {
343 let finalized = self.ball_movement.finish();
344 self.write_finalized_ball_movement(finalized);
345 let resolution = self.control_follow.flush();
346 self.apply_possession_resolution(resolution);
347 let shot_resolution = self.shot_projection.flush();
348 self.apply_shot_projection_resolution(shot_resolution);
349 }
350
351 fn finalize_ball_movement_at_boundary(&mut self, boundary: Boundary) {
352 let finalized = self.ball_movement.apply_boundary(boundary);
353 self.write_finalized_ball_movement(finalized);
354 }
355
356 fn write_finalized_ball_movement(
357 &mut self,
358 finalized: Vec<(PendingTouchBallMovementCredit, FinalizeReason)>,
359 ) {
360 for (pending, _reason) in finalized {
361 if let Some(event) = self.events.get_mut(pending.touch_index) {
362 event.ball_movement = Some(pending.movement.finalized());
363 }
364 }
365 }
366
367 fn ball_speed_change(
368 frame: &FrameInfo,
369 ball: &BallFrameState,
370 previous_ball_velocity: Option<glam::Vec3>,
371 ) -> f32 {
372 const BALL_GRAVITY_Z: f32 = -650.0;
373
374 let Some(ball) = ball.sample() else {
375 return 0.0;
376 };
377 let Some(previous_ball_velocity) = previous_ball_velocity else {
378 return 0.0;
379 };
380
381 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
382 let residual_linear_impulse =
383 ball.velocity() - previous_ball_velocity - expected_linear_delta;
384 residual_linear_impulse.length()
385 }
386
387 fn classify_touch(
388 height_band: PlayerVerticalBand,
389 surface: TouchSurface,
390 dodge_state: TouchDodgeState,
391 ball_speed_change: f32,
392 controlled_touch_kind: Option<BallCarryKind>,
393 ) -> TouchClassification {
394 let kind = if controlled_touch_kind.is_some()
395 || ball_speed_change <= SOFT_TOUCH_BALL_SPEED_CHANGE_THRESHOLD
396 {
397 TouchKind::Control
398 } else if ball_speed_change < HARD_TOUCH_BALL_SPEED_CHANGE_THRESHOLD {
399 TouchKind::MediumHit
400 } else {
401 TouchKind::HardHit
402 };
403
404 TouchClassification {
405 kind,
406 height_band,
407 surface,
408 dodge_state,
409 }
410 }
411
412 fn height_band_for_touch(sample: Option<&PlayerVerticalSample>) -> PlayerVerticalBand {
413 let Some(sample) = sample else {
414 return PlayerVerticalBand::Ground;
415 };
416
417 if sample.height < AERIAL_TOUCH_MIN_PLAYER_Z {
418 PlayerVerticalBand::Ground
419 } else {
420 sample.band
421 }
422 }
423
424 fn surface_for_touch(
425 player_position: Option<glam::Vec3>,
426 height_band: PlayerVerticalBand,
427 ) -> TouchSurface {
428 if player_position.is_some_and(player_is_on_wall) {
429 TouchSurface::Wall
430 } else if height_band.is_grounded() {
431 TouchSurface::Ground
432 } else {
433 TouchSurface::Air
434 }
435 }
436
437 fn controlled_touch_kind(
438 ball: &BallFrameState,
439 players: &PlayerFrameState,
440 player_id: &PlayerId,
441 ) -> Option<BallCarryKind> {
442 let ball = ball.sample()?;
443 players
444 .players
445 .iter()
446 .find(|player| &player.player_id == player_id)
447 .and_then(|player| {
448 BallCarryCalculator::carry_frame_sample(player, ball).map(|sample| sample.kind)
449 })
450 }
451
452 fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
453 players
454 .players
455 .iter()
456 .find(|player| &player.player_id == player_id)
457 .and_then(PlayerSample::position)
458 }
459
460 fn player_dodge_active(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
461 players
462 .players
463 .iter()
464 .find(|player| &player.player_id == player_id)
465 .is_some_and(|player| player.dodge_active)
466 }
467
468 fn teammate_positions(
469 players: &PlayerFrameState,
470 player_id: &PlayerId,
471 is_team_0: bool,
472 ) -> Vec<glam::Vec3> {
473 players
474 .players
475 .iter()
476 .filter(|player| player.is_team_0 == is_team_0 && &player.player_id != player_id)
477 .filter_map(PlayerSample::position)
478 .collect()
479 }
480
481 #[allow(clippy::too_many_arguments)]
482 fn apply_touch_events(
483 &mut self,
484 frame: &FrameInfo,
485 ball: &BallFrameState,
486 players: &PlayerFrameState,
487 vertical_state: &PlayerVerticalState,
488 rotation: &RotationCalculator,
489 touch_events: &[TouchEvent],
490 fifty_fifty_state: &FiftyFiftyState,
491 ) {
492 let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
493 let contested_frame = touch_events.iter().any(|touch| touch.team_is_team_0)
494 && touch_events.iter().any(|touch| !touch.team_is_team_0);
495
496 for touch_event in touch_events {
497 let Some(player_id) = touch_event.player.as_ref() else {
498 continue;
499 };
500 let height_band = Self::height_band_for_touch(vertical_state.sample(player_id));
501 let surface =
502 Self::surface_for_touch(Self::player_position(players, player_id), height_band);
503 let dodge_state = TouchDodgeState::from_dodge_active(
504 touch_event.dodge_contact || Self::player_dodge_active(players, player_id),
505 );
506 let controlled_touch_kind = Self::controlled_touch_kind(ball, players, player_id);
507 let (role, play_depth) = rotation.current_role_and_depth(player_id);
508 let classification = Self::classify_touch(
509 height_band,
510 surface,
511 dodge_state,
512 ball_speed_change,
513 controlled_touch_kind,
514 );
515 let contested = contested_frame
516 || fifty_fifty_state
517 .active_event
518 .as_ref()
519 .is_some_and(|active| {
520 fifty_fifty_involves_player(active, player_id, touch_event.team_is_team_0)
521 });
522 let teammate_positions =
523 Self::teammate_positions(players, player_id, touch_event.team_is_team_0);
524 let possession_resolution = self
525 .control_follow
526 .observe_touch(player_id, touch_event.time);
527 self.apply_possession_resolution(possession_resolution);
528 let shot_resolution = self.shot_projection.observe_touch(touch_event.time);
531 self.apply_shot_projection_resolution(shot_resolution);
532 let resolution = self.intention_classifier.classify(
533 touch_event,
534 player_id,
535 &TouchIntentionFrameContext {
536 ball_position: ball.position(),
537 ball_velocity: ball.velocity(),
538 previous_ball_position: self.previous_ball_position,
539 previous_ball_velocity: self.previous_ball_velocity,
540 teammate_positions: &teammate_positions,
541 contested,
542 },
543 );
544 let tags = TouchClassificationEvent::classification_tags(
545 classification.kind.as_label_value(),
546 classification.height_band.as_label().value,
547 classification.surface.as_label_value(),
548 classification.dodge_state.as_label_value(),
549 resolution.action.map(TouchAction::as_label_value),
550 resolution.first_touch,
551 resolution.contested,
552 );
553 let event = TouchClassificationEvent {
554 touch_id: touch_event.touch_id,
555 time: touch_event.time,
556 frame: touch_event.frame,
557 sample_time: frame.time,
558 sample_frame: frame.frame_number,
559 player: player_id.clone(),
560 player_position: touch_event
561 .player_position
562 .map(|position| vec_to_glam(&position).to_array())
563 .or_else(|| {
564 Self::player_position(players, player_id)
565 .map(|position| position.to_array())
566 }),
567 ball_position: ball.position().map(|position| position.to_array()),
568 is_team_0: touch_event.team_is_team_0,
569 tags,
570 role,
571 play_depth,
572 ball_speed_change,
573 ball_movement: None,
574 };
575 let touch_index = self.events.len();
576 self.events.push(event);
577 self.active_touch_index_by_player
578 .insert(player_id.clone(), touch_index);
579 if matches!(classification.dodge_state, TouchDodgeState::NoDodge) {
583 self.pending_dodge_upgrades.push(PendingDodgeUpgrade {
584 touch_index,
585 player_id: player_id.clone(),
586 touch_time: touch_event.time,
587 });
588 }
589 if resolution
593 .action
594 .is_none_or(TouchAction::watches_possession)
595 {
596 self.control_follow
597 .open(touch_index, player_id, touch_event.time);
598 }
599 self.shot_projection
603 .open(touch_index, touch_event.team_is_team_0, touch_event.time);
604 }
605 }
606
607 fn apply_possession_resolution(&mut self, resolution: Option<PossessionResolution>) {
612 let Some(resolution) = resolution else {
613 return;
614 };
615 let Some(possession) = resolution.possession else {
616 return;
617 };
618 if let Some(event) = self.events.get_mut(resolution.touch_index) {
619 event.set_tag("possession", possession.as_label_value());
620 }
621 }
622
623 fn apply_shot_projection_resolution(&mut self, resolution: Option<ShotProjectionResolution>) {
629 let Some(resolution) = resolution else {
630 return;
631 };
632 if !resolution.is_shot {
633 return;
634 }
635 self.raise_action(resolution.touch_index, TouchAction::Shot);
636 }
637
638 fn raise_action(&mut self, touch_index: usize, target: TouchAction) {
641 if let Some(event) = self.events.get_mut(touch_index) {
642 let target_label = target.as_label_value();
643 if action_rank(Some(target_label)) > action_rank(event.tag("action")) {
644 event.set_tag("action", target_label);
645 }
646 }
647 }
648
649 fn confirm_outcome_action(
653 &mut self,
654 player: &PlayerId,
655 signal_time: f32,
656 window: f32,
657 target: TouchAction,
658 ) {
659 let Some(&touch_index) = self.active_touch_index_by_player.get(player) else {
660 return;
661 };
662 let Some(event) = self.events.get(touch_index) else {
663 return;
664 };
665 if signal_time < event.time || signal_time - event.time > window {
666 return;
667 }
668 self.raise_action(touch_index, target);
669 }
670
671 fn confirm_goal_shots(&mut self, events_state: &FrameEventsState) {
675 for goal in &events_state.goal_events {
676 let Some(scorer) = goal.player.as_ref() else {
677 continue;
678 };
679 self.confirm_outcome_action(
680 scorer,
681 goal.time,
682 GOAL_SHOT_ATTRIBUTION_WINDOW_SECONDS,
683 TouchAction::Shot,
684 );
685 }
686 }
687
688 fn confirm_stat_event_actions(&mut self, events_state: &FrameEventsState) {
693 for stat in &events_state.player_stat_events {
694 let target = match stat.kind {
695 PlayerStatEventKind::Shot => TouchAction::Shot,
696 PlayerStatEventKind::Save => TouchAction::Save,
697 PlayerStatEventKind::Assist => continue,
698 };
699 self.confirm_outcome_action(
700 &stat.player,
701 stat.time,
702 STAT_EVENT_OUTCOME_WINDOW_SECONDS,
703 target,
704 );
705 }
706 }
707
708 fn advance_dodge_upgrades(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
715 if self.pending_dodge_upgrades.is_empty() {
716 return;
717 }
718 let mut resolved = Vec::new();
719 self.pending_dodge_upgrades.retain(|pending| {
720 if frame.time - pending.touch_time > DODGE_LAG_TOLERANCE_SECONDS {
721 return false;
722 }
723 if Self::player_dodge_active(players, &pending.player_id) {
724 resolved.push(pending.touch_index);
725 return false;
726 }
727 true
728 });
729 for touch_index in resolved {
730 if let Some(event) = self.events.get_mut(touch_index) {
731 event.set_tag("dodge_state", TouchDodgeState::Dodge.as_label_value());
732 }
733 }
734 }
735
736 fn advance_control_follow(
739 &mut self,
740 frame: &FrameInfo,
741 ball: &BallFrameState,
742 players: &PlayerFrameState,
743 ) {
744 let Some(window_player) = self.control_follow.window_player().cloned() else {
745 return;
746 };
747 let player_sample = players
748 .players
749 .iter()
750 .find(|player| player.player_id == window_player);
751 let resolution = self.control_follow.advance(
752 frame,
753 ball.position(),
754 ball.velocity(),
755 player_sample.and_then(PlayerSample::position),
756 player_sample.and_then(PlayerSample::velocity),
757 );
758 self.apply_possession_resolution(resolution);
759 }
760
761 fn advance_shot_projection(&mut self, frame: &FrameInfo, ball: &BallFrameState) {
764 let resolution = self
765 .shot_projection
766 .advance(frame, ball.position(), ball.velocity());
767 self.apply_shot_projection_resolution(resolution);
768 }
769
770 #[allow(clippy::too_many_arguments)]
771 fn apply_ball_movement_credit(
772 &mut self,
773 frame: usize,
774 time: f32,
775 duration: f32,
776 player_id: &PlayerId,
777 team_is_team_0: bool,
778 player_position: Option<[f32; 3]>,
779 delta: glam::Vec3,
780 travel_distance: f32,
781 ) {
782 let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
783 let advance_distance = delta.y * team_forward_sign;
784 let (advance_distance, retreat_distance) = if advance_distance >= 0.0 {
785 (advance_distance, 0.0)
786 } else {
787 (0.0, -advance_distance)
788 };
789 let movement = TouchBallMovement {
790 start_time: time,
791 start_frame: frame,
792 end_time: time,
793 end_frame: frame,
794 duration,
795 travel_distance,
796 advance_distance,
797 retreat_distance,
798 finalized: false,
799 };
800 self.record_ball_movement_credit(player_id, player_position, movement);
801 }
802
803 fn record_ball_movement_credit(
804 &mut self,
805 player_id: &PlayerId,
806 player_position: Option<[f32; 3]>,
807 movement: TouchBallMovement,
808 ) {
809 let Some(&touch_index) = self.active_touch_index_by_player.get(player_id) else {
810 return;
811 };
812 let pending_index = self
813 .ball_movement
814 .in_flight()
815 .first()
816 .map(|pending| pending.touch_index);
817
818 if pending_index == Some(touch_index) {
819 let merged = {
821 let pending = self
822 .ball_movement
823 .in_flight_mut()
824 .first_mut()
825 .expect("pending credit present");
826 pending.movement.absorb_delta(movement);
827 pending.movement.clone()
828 };
829 if let Some(event) = self.events.get_mut(touch_index) {
830 event.player_position = player_position.or(event.player_position);
831 event.ball_movement = Some(merged);
832 }
833 return;
834 }
835
836 if pending_index.is_some() {
839 let finalized = self.ball_movement.finalize_all(FinalizeReason::Superseded);
840 self.write_finalized_ball_movement(finalized);
841 }
842 if let Some(event) = self.events.get_mut(touch_index) {
843 event.player_position = player_position.or(event.player_position);
844 event.ball_movement = Some(movement.clone());
845 }
846 self.ball_movement.arm(PendingTouchBallMovementCredit {
847 touch_index,
848 movement,
849 });
850 }
851
852 fn resolved_fifty_fifty_winner(event: &FiftyFiftyEvent) -> Option<(&PlayerId, bool)> {
853 let winning_team_is_team_0 = event.winning_team_is_team_0?;
854 let player = if winning_team_is_team_0 {
855 event.team_zero_player.as_ref()
856 } else {
857 event.team_one_player.as_ref()
858 }?;
859 Some((player, winning_team_is_team_0))
860 }
861
862 fn buffer_fifty_fifty_movement(
863 &mut self,
864 start_frame: usize,
865 delta: glam::Vec3,
866 travel_distance: f32,
867 ) {
868 let pending = self
869 .pending_fifty_fifty_movement
870 .get_or_insert(PendingFiftyFiftyMovement {
871 start_frame,
872 travel_distance: 0.0,
873 y_delta: 0.0,
874 });
875 if pending.start_frame != start_frame {
876 *pending = PendingFiftyFiftyMovement {
877 start_frame,
878 travel_distance: 0.0,
879 y_delta: 0.0,
880 };
881 }
882 pending.travel_distance += travel_distance;
883 pending.y_delta += delta.y;
884 }
885
886 fn flush_fifty_fifty_movement(&mut self, event: &FiftyFiftyEvent) {
887 let Some(pending) = self.pending_fifty_fifty_movement.take() else {
888 return;
889 };
890 if pending.start_frame != event.start_frame {
891 return;
892 }
893 let Some((player_id, team_is_team_0)) = Self::resolved_fifty_fifty_winner(event) else {
894 return;
895 };
896
897 let team_forward_sign = if team_is_team_0 { 1.0 } else { -1.0 };
898 let advance_distance = pending.y_delta * team_forward_sign;
899 let (advance_distance, retreat_distance) = if advance_distance >= 0.0 {
900 (advance_distance, 0.0)
901 } else {
902 (0.0, -advance_distance)
903 };
904 let movement = TouchBallMovement {
905 start_time: event.resolve_time,
906 start_frame: event.resolve_frame,
907 end_time: event.resolve_time,
908 end_frame: event.resolve_frame,
909 duration: 0.0,
910 travel_distance: pending.travel_distance,
911 advance_distance,
912 retreat_distance,
913 finalized: false,
914 };
915 self.flush_pending_ball_movement_credit();
916 self.record_ball_movement_credit(
917 player_id,
918 if team_is_team_0 {
919 Some(event.team_zero_position)
920 } else {
921 Some(event.team_one_position)
922 },
923 movement,
924 );
925 self.flush_pending_ball_movement_credit();
926 }
927
928 fn credit_ball_movement(
929 &mut self,
930 frame: &FrameInfo,
931 ball: &BallFrameState,
932 players: &PlayerFrameState,
933 possession_state: &PossessionState,
934 fifty_fifty_state: &FiftyFiftyState,
935 live_play_state: &LivePlayState,
936 ) {
937 let current_ball_position = ball.position();
938 if !live_play_state.is_live_play {
939 self.finalize_ball_movement_at_boundary(Boundary::LivePlayEnded);
940 self.previous_ball_position = current_ball_position;
941 self.pending_fifty_fifty_movement = None;
942 return;
943 }
944
945 let Some(current_ball_position) = current_ball_position else {
946 self.flush_pending_ball_movement_credit();
947 self.previous_ball_position = None;
948 self.pending_fifty_fifty_movement = None;
949 return;
950 };
951 let Some(previous_ball_position) = self.previous_ball_position else {
952 self.previous_ball_position = Some(current_ball_position);
953 return;
954 };
955 self.previous_ball_position = Some(current_ball_position);
956
957 let delta = current_ball_position - previous_ball_position;
958 let travel_distance = delta.length();
959 if travel_distance <= f32::EPSILON {
960 return;
961 }
962
963 if let Some(active_event) = fifty_fifty_state.active_event.as_ref() {
964 self.flush_pending_ball_movement_credit();
965 self.buffer_fifty_fifty_movement(active_event.start_frame, delta, travel_distance);
966 return;
967 }
968
969 if let Some(event) = fifty_fifty_state.resolved_events.last() {
970 self.buffer_fifty_fifty_movement(event.start_frame, delta, travel_distance);
971 self.flush_fifty_fifty_movement(event);
972 return;
973 }
974
975 self.pending_fifty_fifty_movement = None;
976
977 let (Some(player_id), Some(team_is_team_0)) = (
978 possession_state.active_player_before_sample.as_ref(),
979 possession_state.active_team_before_sample,
980 ) else {
981 self.flush_pending_ball_movement_credit();
982 return;
983 };
984
985 self.apply_ball_movement_credit(
986 frame.frame_number,
987 frame.time,
988 frame.dt,
989 player_id,
990 team_is_team_0,
991 players.player_position(player_id),
992 delta,
993 travel_distance,
994 );
995 }
996
997 #[allow(clippy::too_many_arguments)]
998 pub fn update(
999 &mut self,
1000 frame: &FrameInfo,
1001 ball: &BallFrameState,
1002 players: &PlayerFrameState,
1003 vertical_state: &PlayerVerticalState,
1004 rotation: &RotationCalculator,
1005 touch_state: &TouchState,
1006 possession_state: &PossessionState,
1007 fifty_fifty_state: &FiftyFiftyState,
1008 events_state: &FrameEventsState,
1009 live_play_state: &LivePlayState,
1010 ) -> SubtrActorResult<()> {
1011 self.events.begin_update();
1012 self.confirm_goal_shots(events_state);
1016 if !live_play_state.is_live_play {
1017 self.finalize_ball_movement_at_boundary(Boundary::LivePlayEnded);
1018 self.previous_ball_velocity = ball.velocity();
1019 self.previous_ball_position = ball.position();
1020 self.pending_fifty_fifty_movement = None;
1021 self.pending_dodge_upgrades.clear();
1022 self.intention_classifier.reset();
1023 let resolution = self.control_follow.flush();
1024 self.apply_possession_resolution(resolution);
1025 let shot_resolution = self.shot_projection.flush();
1026 self.apply_shot_projection_resolution(shot_resolution);
1027 return Ok(());
1028 }
1029 self.intention_classifier
1030 .begin_frame(frame, &events_state.player_stat_events);
1031 self.apply_touch_events(
1032 frame,
1033 ball,
1034 players,
1035 vertical_state,
1036 rotation,
1037 &touch_state.touch_events,
1038 fifty_fifty_state,
1039 );
1040 self.confirm_stat_event_actions(events_state);
1043 self.advance_dodge_upgrades(frame, players);
1044 self.advance_control_follow(frame, ball, players);
1045 self.advance_shot_projection(frame, ball);
1046 self.credit_ball_movement(
1047 frame,
1048 ball,
1049 players,
1050 possession_state,
1051 fifty_fifty_state,
1052 live_play_state,
1053 );
1054 self.previous_ball_velocity = ball.velocity();
1055
1056 Ok(())
1057 }
1058}
1059
1060#[cfg(test)]
1061#[path = "touch_tests.rs"]
1062mod tests;