1use super::*;
2
3const FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS: f32 = 0.05;
9const FLIP_RESET_MAX_DODGE_TOUCH_DELAY_SECONDS: f32 = 2.0;
10const FLIP_RESET_GROUNDED_Z: f32 = 80.0;
11const FALLBACK_RESET_MIN_PLAYER_HEIGHT: f32 = 95.0;
12const FALLBACK_RESET_MAX_LOCAL_VERTICAL_OFFSET: f32 = 10.0;
13const FALLBACK_RESET_MAX_LOCAL_FORWARD_OFFSET: f32 = 240.0;
14const FALLBACK_RESET_MAX_LOCAL_LATERAL_OFFSET: f32 = 240.0;
15const FLIP_RESET_DODGE_CONTACT_CONTINUATION_SECONDS: f32 = 0.35;
21const FLIP_RESET_DODGE_TOUCH_LAG_TOLERANCE_SECONDS: f32 = DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
38#[serde(rename_all = "snake_case")]
39#[ts(export, rename_all = "snake_case")]
40pub enum FlipResetOutcome {
41 Used,
43 Landed,
45 Expired,
48 Superseded,
50 PlayEnded,
52 GoalScored,
54 ReplayEnded,
56}
57
58impl FlipResetOutcome {
59 pub fn is_used(self) -> bool {
60 matches!(self, Self::Used)
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
66#[ts(export)]
67pub struct DodgeResetEvent {
68 pub time: f32,
69 pub frame: usize,
70 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
71 pub player: PlayerId,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub player_position: Option<[f32; 3]>,
74 pub is_team_0: bool,
75 pub counter_value: i32,
76 pub on_ball: bool,
78 #[serde(default)]
82 pub used: bool,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub outcome: Option<FlipResetOutcome>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub time_to_use: Option<f32>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize)]
99pub struct FlipResetOutcomeEvent {
100 pub time: f32,
103 pub frame: usize,
104 pub reset_time: f32,
105 pub reset_frame: usize,
106 pub player: PlayerId,
107 pub is_team_0: bool,
108 pub counter_value: i32,
109 pub outcome: FlipResetOutcome,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub time_to_use: Option<f32>,
114}
115
116#[derive(Debug, Clone, PartialEq)]
121struct RecentResetTouch {
122 time: f32,
123 frame: usize,
124 team_is_team_0: bool,
125 player_position: Option<[f32; 3]>,
126 dodge_contact: bool,
127}
128
129#[derive(Debug, Clone, PartialEq)]
132struct PendingOnBallReset {
133 reset: DodgeRefreshedEvent,
134 event_index: usize,
135}
136
137impl InFlightItem for PendingOnBallReset {
138 fn recognition(&self) -> Recognition {
139 Recognition::committed(self.reset.time, self.reset.frame)
142 }
143
144 fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
145 Disposition::Finalize(FinalizeReason::Boundary(boundary))
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
153#[ts(export)]
154pub struct FlipResetEvent {
155 pub time: f32,
156 pub frame: usize,
157 pub reset_time: f32,
158 pub reset_frame: usize,
159 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
160 pub player: PlayerId,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub player_position: Option<[f32; 3]>,
163 pub is_team_0: bool,
164 pub counter_value: i32,
165 pub time_since_reset: f32,
166}
167
168#[derive(Debug, Clone, Default, PartialEq)]
170pub struct DodgeResetCalculator {
171 events: EventStream<DodgeResetEvent>,
172 confirmed_flip_reset_events: EventStream<FlipResetEvent>,
173 flip_reset_outcome_events: EventStream<FlipResetOutcomeEvent>,
174 pending_on_ball_resets: KeyedInFlightLedger<PlayerId, PendingOnBallReset>,
175 pending_reset_dodge_onset: HashMap<PlayerId, f32>,
180 recent_confirmable_touch: HashMap<PlayerId, RecentResetTouch>,
183 previous_dodge_active: HashMap<PlayerId, bool>,
184 previous_live_play: Option<bool>,
185 last_frame: Option<(f32, usize)>,
186}
187
188impl DodgeResetCalculator {
189 pub fn new() -> Self {
190 Self::default()
191 }
192
193 pub fn events(&self) -> &[DodgeResetEvent] {
194 self.events.all()
195 }
196
197 pub fn new_events(&self) -> &[DodgeResetEvent] {
198 self.events.new_events()
199 }
200
201 pub fn confirmed_flip_reset_events(&self) -> &[FlipResetEvent] {
202 self.confirmed_flip_reset_events.all()
203 }
204
205 pub fn new_confirmed_flip_reset_events(&self) -> &[FlipResetEvent] {
206 self.confirmed_flip_reset_events.new_events()
207 }
208
209 pub fn flip_reset_outcome_events(&self) -> &[FlipResetOutcomeEvent] {
210 self.flip_reset_outcome_events.all()
211 }
212
213 pub fn new_flip_reset_outcome_events(&self) -> &[FlipResetOutcomeEvent] {
214 self.flip_reset_outcome_events.new_events()
215 }
216
217 fn player<'a>(players: &'a PlayerFrameState, player_id: &PlayerId) -> Option<&'a PlayerSample> {
218 players
219 .players
220 .iter()
221 .find(|player| &player.player_id == player_id)
222 }
223
224 fn player_is_grounded(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
225 Self::player(players, player_id)
226 .and_then(PlayerSample::position)
227 .is_some_and(|position| position.z <= FLIP_RESET_GROUNDED_Z)
228 }
229
230 fn player_dodge_active(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
231 Self::player(players, player_id).is_some_and(|player| player.dodge_active)
232 }
233
234 fn on_ball_dodge_reset(
235 ball: &BallFrameState,
236 players: &PlayerFrameState,
237 player_id: &PlayerId,
238 ) -> bool {
239 const MIN_PLAYER_HEIGHT: f32 = 95.0;
240 const MIN_BALL_HEIGHT: f32 = 80.0;
241 const MAX_CENTER_DISTANCE: f32 = 180.0;
242 const MAX_LOCAL_VERTICAL_OFFSET: f32 = 140.0;
243
244 let Some(ball) = ball.sample() else {
245 return false;
246 };
247 let Some(player) = Self::player(players, player_id) else {
248 return false;
249 };
250 let Some(player_rigid_body) = &player.rigid_body else {
251 return false;
252 };
253
254 let ball_position = vec_to_glam(&ball.rigid_body.location);
255 let player_position = vec_to_glam(&player_rigid_body.location);
256 if player_position.z < MIN_PLAYER_HEIGHT || ball_position.z < MIN_BALL_HEIGHT {
257 return false;
258 }
259
260 let relative_ball_position = ball_position - player_position;
261 let center_distance = relative_ball_position.length();
262 if !center_distance.is_finite() || center_distance > MAX_CENTER_DISTANCE {
263 return false;
264 }
265
266 let player_rotation = quat_to_glam(&player_rigid_body.rotation);
267 let local_ball_position = player_rotation.inverse() * relative_ball_position;
268 local_ball_position.z <= MAX_LOCAL_VERTICAL_OFFSET
269 }
270
271 fn boundary_outcome(boundary: Boundary) -> FlipResetOutcome {
272 match boundary {
273 Boundary::LivePlayEnded => FlipResetOutcome::PlayEnded,
274 Boundary::GoalScored => FlipResetOutcome::GoalScored,
275 Boundary::ReplayEnded => FlipResetOutcome::ReplayEnded,
276 }
277 }
278
279 fn record_outcome(
283 &mut self,
284 pending: PendingOnBallReset,
285 outcome: FlipResetOutcome,
286 time: f32,
287 frame: usize,
288 time_to_use: Option<f32>,
289 ) {
290 if let Some(reset) = self.events.get_mut(pending.event_index) {
291 reset.outcome = Some(outcome);
292 reset.time_to_use = time_to_use;
293 if outcome.is_used() {
294 reset.used = true;
295 }
296 }
297 self.flip_reset_outcome_events.push(FlipResetOutcomeEvent {
298 time,
299 frame,
300 reset_time: pending.reset.time,
301 reset_frame: pending.reset.frame,
302 player: pending.reset.player.clone(),
303 is_team_0: pending.reset.is_team_0,
304 counter_value: pending.reset.counter_value,
305 outcome,
306 time_to_use,
307 });
308 }
309
310 fn resolve_pending(
311 &mut self,
312 player_id: &PlayerId,
313 reason: FinalizeReason,
314 outcome: FlipResetOutcome,
315 time: f32,
316 frame: usize,
317 time_to_use: Option<f32>,
318 ) {
319 let Some(pending) = self.pending_on_ball_resets.finalize(player_id, reason) else {
320 return;
321 };
322 self.clear_pending_reset_tracking(player_id);
323 self.record_outcome(pending, outcome, time, frame, time_to_use);
324 }
325
326 fn apply_ledger_boundary(&mut self, boundary: Boundary, time: f32, frame: usize) {
327 for (player_id, pending, _reason) in self.pending_on_ball_resets.apply_boundary(boundary) {
328 self.clear_pending_reset_tracking(&player_id);
329 self.record_outcome(pending, Self::boundary_outcome(boundary), time, frame, None);
330 }
331 }
332
333 fn prune_pending_resets(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
334 let grounded_players = self
335 .pending_on_ball_resets
336 .keys()
337 .filter(|player_id| Self::player_is_grounded(players, player_id))
338 .cloned()
339 .collect::<Vec<_>>();
340 for player_id in grounded_players {
341 self.resolve_pending(
342 &player_id,
343 FinalizeReason::Completed,
344 FlipResetOutcome::Landed,
345 frame.time,
346 frame.frame_number,
347 None,
348 );
349 }
350 }
351
352 fn clear_pending_reset_tracking(&mut self, player_id: &PlayerId) {
353 self.pending_reset_dodge_onset.remove(player_id);
354 self.recent_confirmable_touch.remove(player_id);
355 }
356
357 fn fallback_on_ball_reset(touch_event: &TouchEvent) -> bool {
358 if touch_event.player.is_none() || touch_event.dodge_contact {
359 return false;
360 }
361 if touch_event
362 .closest_approach_distance
363 .is_none_or(|gap| gap > TouchCandidateScoring::DEFAULT.relaxed_contact_gap_threshold)
364 {
365 return false;
366 }
367 if touch_event
368 .player_position
369 .is_none_or(|position| position.z < FALLBACK_RESET_MIN_PLAYER_HEIGHT)
370 {
371 return false;
372 }
373 let Some(local_ball_position) = touch_event.contact_local_ball_position else {
374 return false;
375 };
376
377 local_ball_position[0].abs() <= FALLBACK_RESET_MAX_LOCAL_FORWARD_OFFSET
378 && local_ball_position[1].abs() <= FALLBACK_RESET_MAX_LOCAL_LATERAL_OFFSET
379 && local_ball_position[2] <= FALLBACK_RESET_MAX_LOCAL_VERTICAL_OFFSET
380 }
381
382 fn arm_fallback_on_ball_reset(&mut self, touch_event: &TouchEvent) {
383 let Some(player_id) = touch_event.player.as_ref() else {
384 return;
385 };
386 if self.pending_on_ball_resets.contains(player_id) {
387 return;
388 }
389 if !Self::fallback_on_ball_reset(touch_event) {
390 return;
391 }
392
393 let reset_event = DodgeRefreshedEvent {
394 time: touch_event.time,
395 frame: touch_event.frame,
396 player: player_id.clone(),
397 player_position: touch_event
398 .player_position
399 .map(|position| vec_to_glam(&position).to_array()),
400 is_team_0: touch_event.team_is_team_0,
401 counter_value: 0,
402 };
403 let event_index = self.events.all().len();
404 self.pending_on_ball_resets.arm(
405 player_id.clone(),
406 PendingOnBallReset {
407 reset: reset_event.clone(),
408 event_index,
409 },
410 );
411 self.clear_pending_reset_tracking(player_id);
412 self.events.push(DodgeResetEvent {
413 time: reset_event.time,
414 frame: reset_event.frame,
415 player: reset_event.player,
416 player_position: reset_event.player_position,
417 is_team_0: reset_event.is_team_0,
418 counter_value: reset_event.counter_value,
419 on_ball: true,
420 used: false,
421 outcome: None,
422 time_to_use: None,
423 });
424 }
425
426 fn update_pending_reset_dodges(&mut self, players: &PlayerFrameState, frame_time: f32) {
431 let mut newly_started = Vec::new();
432 for player in &players.players {
433 let was_dodge_active = self
434 .previous_dodge_active
435 .insert(player.player_id.clone(), player.dodge_active)
436 .unwrap_or(false);
437 if player.dodge_active
438 && !was_dodge_active
439 && self.pending_on_ball_resets.contains(&player.player_id)
440 {
441 self.pending_reset_dodge_onset
442 .insert(player.player_id.clone(), frame_time);
443 newly_started.push(player.player_id.clone());
444 }
445 }
446
447 for player_id in newly_started {
448 let Some(touch) = self.recent_confirmable_touch.get(&player_id).cloned() else {
449 continue;
450 };
451 if frame_time - touch.time > FLIP_RESET_DODGE_TOUCH_LAG_TOLERANCE_SECONDS {
452 continue;
453 }
454 self.confirm_flip_reset(&player_id, &touch, frame_time);
455 }
456 }
457
458 fn reset_and_touch_are_same_dodge_contact(
461 reset_event: &DodgeRefreshedEvent,
462 touch: &RecentResetTouch,
463 ) -> bool {
464 touch.dodge_contact
465 && touch.frame == reset_event.frame
466 && (touch.time - reset_event.time).abs() <= f32::EPSILON
467 }
468
469 fn touch_matches_player_frame(
470 touch_event: &TouchEvent,
471 player_id: &PlayerId,
472 time: f32,
473 frame: usize,
474 ) -> bool {
475 touch_event.player.as_ref() == Some(player_id)
476 && touch_event.frame == frame
477 && (touch_event.time - time).abs() <= f32::EPSILON
478 }
479
480 fn recent_reset_touch(
481 players: &PlayerFrameState,
482 touch_event: &TouchEvent,
483 ) -> Option<RecentResetTouch> {
484 let player_id = touch_event.player.as_ref()?;
485 let dodge_contact =
486 touch_event.dodge_contact || Self::player_dodge_active(players, player_id);
487 Some(RecentResetTouch {
488 time: touch_event.time,
489 frame: touch_event.frame,
490 team_is_team_0: touch_event.team_is_team_0,
491 player_position: touch_event
492 .player_position
493 .map(|position| vec_to_glam(&position).to_array())
494 .or_else(|| players.player_position(player_id)),
495 dodge_contact,
496 })
497 }
498
499 fn touch_within_dodge_continuation(touch_time: f32, dodge_onset_time: f32) -> bool {
500 touch_time >= dodge_onset_time
501 && touch_time - dodge_onset_time <= FLIP_RESET_DODGE_CONTACT_CONTINUATION_SECONDS
502 }
503
504 fn process_touch_for_flip_reset(
505 &mut self,
506 players: &PlayerFrameState,
507 touch_event: &TouchEvent,
508 ) -> bool {
509 let Some(player_id) = touch_event.player.as_ref() else {
510 return false;
511 };
512 if !self.pending_on_ball_resets.contains(player_id) {
513 return false;
514 }
515
516 let Some(touch) = Self::recent_reset_touch(players, touch_event) else {
517 return false;
518 };
519 self.recent_confirmable_touch
520 .insert(player_id.clone(), touch.clone());
521
522 if let Some(&dodge_onset_time) = self.pending_reset_dodge_onset.get(player_id) {
525 if touch.dodge_contact
526 || Self::touch_within_dodge_continuation(touch.time, dodge_onset_time)
527 {
528 let player_id = player_id.clone();
529 return self.confirm_flip_reset(&player_id, &touch, dodge_onset_time);
530 }
531 }
532
533 if touch.dodge_contact && Self::player_dodge_active(players, player_id) {
539 let Some(pending) = self.pending_on_ball_resets.get(player_id) else {
540 return false;
541 };
542 if Self::reset_and_touch_are_same_dodge_contact(&pending.reset, &touch) {
543 let player_id = player_id.clone();
544 return self.confirm_flip_reset(&player_id, &touch, touch.time);
545 }
546 }
547
548 false
549 }
550
551 fn confirm_flip_reset(
555 &mut self,
556 player_id: &PlayerId,
557 touch: &RecentResetTouch,
558 dodge_onset_time: f32,
559 ) -> bool {
560 let Some(pending) = self.pending_on_ball_resets.get(player_id) else {
561 return false;
562 };
563 let reset_event = pending.reset.clone();
564 let dodge_delay = dodge_onset_time - reset_event.time;
565 let immediate_dodge_reset =
566 Self::reset_and_touch_are_same_dodge_contact(&reset_event, touch);
567 let time_since_reset = touch.time - reset_event.time;
568 if dodge_delay < FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS
569 && time_since_reset < FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS
570 && !immediate_dodge_reset
571 && !touch.dodge_contact
572 {
573 return false;
575 }
576 if dodge_delay > FLIP_RESET_MAX_DODGE_TOUCH_DELAY_SECONDS {
577 self.resolve_pending(
578 player_id,
579 FinalizeReason::Completed,
580 FlipResetOutcome::Expired,
581 touch.time,
582 touch.frame,
583 None,
584 );
585 return true;
586 }
587 if time_since_reset < 0.0 {
588 return false;
589 }
590
591 self.confirmed_flip_reset_events.push(FlipResetEvent {
592 time: touch.time,
593 frame: touch.frame,
594 reset_time: reset_event.time,
595 reset_frame: reset_event.frame,
596 player: player_id.clone(),
597 player_position: touch.player_position,
598 is_team_0: touch.team_is_team_0,
599 counter_value: reset_event.counter_value,
600 time_since_reset,
601 });
602 self.resolve_pending(
603 player_id,
604 FinalizeReason::Completed,
605 FlipResetOutcome::Used,
606 touch.time,
607 touch.frame,
608 Some(time_since_reset),
609 );
610 true
611 }
612
613 fn confirm_pending_with_same_frame_touch(
614 &mut self,
615 players: &PlayerFrameState,
616 touches: &[&TouchEvent],
617 player_id: &PlayerId,
618 time: f32,
619 frame: usize,
620 ) -> Option<usize> {
621 let (touch_index, touch_event) = touches.iter().enumerate().find(|(_, touch)| {
622 let dodge_onset_time = self.pending_reset_dodge_onset.get(player_id).copied();
623 Self::touch_matches_player_frame(touch, player_id, time, frame)
624 && (touch.dodge_contact
625 || Self::player_dodge_active(players, player_id)
626 || dodge_onset_time.is_some_and(|onset| {
627 Self::touch_within_dodge_continuation(touch.time, onset)
628 }))
629 })?;
630 let touch = Self::recent_reset_touch(players, touch_event)?;
631 self.recent_confirmable_touch
632 .insert(player_id.clone(), touch.clone());
633 let dodge_onset_time = self
634 .pending_reset_dodge_onset
635 .get(player_id)
636 .copied()
637 .unwrap_or(touch.time);
638 self.confirm_flip_reset(player_id, &touch, dodge_onset_time)
639 .then_some(touch_index)
640 }
641
642 pub fn update(
643 &mut self,
644 frame: &FrameInfo,
645 ball: &BallFrameState,
646 players: &PlayerFrameState,
647 events: &FrameEventsState,
648 touch_state: &TouchState,
649 live_play_state: &LivePlayState,
650 ) -> SubtrActorResult<()> {
651 self.events.begin_update();
652 self.confirmed_flip_reset_events.begin_update();
653 self.flip_reset_outcome_events.begin_update();
654 self.last_frame = Some((frame.time, frame.frame_number));
655
656 if !events.goal_events.is_empty() {
657 self.apply_ledger_boundary(Boundary::GoalScored, frame.time, frame.frame_number);
658 }
659 let live_play_just_ended =
660 !live_play_state.is_live_play && self.previous_live_play.unwrap_or(true);
661 if live_play_just_ended {
662 self.apply_ledger_boundary(Boundary::LivePlayEnded, frame.time, frame.frame_number);
663 }
664 self.previous_live_play = Some(live_play_state.is_live_play);
665
666 self.update_pending_reset_dodges(players, frame.time);
667 let ordered_touches = chronological_touch_events(&touch_state.touch_events);
668 let mut consumed_touch_indices = vec![false; ordered_touches.len()];
669 for event in &events.dodge_refreshed_events {
670 let on_ball = Self::on_ball_dodge_reset(ball, players, &event.player);
671 let reset_event = event.clone();
672 let event = DodgeResetEvent {
673 time: event.time,
674 frame: event.frame,
675 player: event.player.clone(),
676 player_position: players.player_position(&event.player),
677 is_team_0: event.is_team_0,
678 counter_value: event.counter_value,
679 on_ball,
680 used: false,
681 outcome: None,
682 time_to_use: None,
683 };
684 if on_ball {
685 if let Some(touch_index) = self.confirm_pending_with_same_frame_touch(
686 players,
687 &ordered_touches,
688 &event.player,
689 event.time,
690 event.frame,
691 ) {
692 consumed_touch_indices[touch_index] = true;
693 }
694 self.resolve_pending(
697 &event.player,
698 FinalizeReason::Superseded,
699 FlipResetOutcome::Superseded,
700 reset_event.time,
701 reset_event.frame,
702 None,
703 );
704 let event_index = self.events.all().len();
707 self.pending_on_ball_resets.arm(
708 event.player.clone(),
709 PendingOnBallReset {
710 reset: reset_event,
711 event_index,
712 },
713 );
714 self.clear_pending_reset_tracking(&event.player);
715 }
716 self.events.push(event);
717 }
718 for (touch_index, touch_event) in ordered_touches.iter().enumerate() {
719 if consumed_touch_indices[touch_index] {
720 continue;
721 }
722 if !events.dodge_refreshed_counter_available {
723 self.arm_fallback_on_ball_reset(touch_event);
724 }
725 self.process_touch_for_flip_reset(players, touch_event);
726 }
727 self.prune_pending_resets(frame, players);
728 Ok(())
729 }
730
731 pub fn finish(&mut self) {
734 let (time, frame) = self.last_frame.unwrap_or((0.0, 0));
735 for (player_id, pending, _reason) in self.pending_on_ball_resets.finish() {
736 self.clear_pending_reset_tracking(&player_id);
737 self.record_outcome(pending, FlipResetOutcome::ReplayEnded, time, frame, None);
738 }
739 }
740}
741
742#[cfg(test)]
743#[path = "dodge_reset_tests.rs"]
744mod tests;