use super::*;
const FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS: f32 = 0.05;
const FLIP_RESET_MAX_DODGE_TOUCH_DELAY_SECONDS: f32 = 2.0;
const FLIP_RESET_GROUNDED_Z: f32 = 80.0;
const FALLBACK_RESET_MIN_PLAYER_HEIGHT: f32 = 95.0;
const FALLBACK_RESET_MAX_LOCAL_VERTICAL_OFFSET: f32 = 10.0;
const FALLBACK_RESET_MAX_LOCAL_FORWARD_OFFSET: f32 = 240.0;
const FALLBACK_RESET_MAX_LOCAL_LATERAL_OFFSET: f32 = 240.0;
const FLIP_RESET_DODGE_TOUCH_LAG_TOLERANCE_SECONDS: f32 = DODGE_ACTIVE_BYTE_LAG_TOLERANCE_SECONDS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, rename_all = "snake_case")]
pub enum FlipResetOutcome {
Used,
Landed,
Expired,
Superseded,
PlayEnded,
GoalScored,
ReplayEnded,
}
impl FlipResetOutcome {
pub fn is_used(self) -> bool {
matches!(self, Self::Used)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct DodgeResetEvent {
pub time: f32,
pub frame: usize,
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub player: PlayerId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<[f32; 3]>,
pub is_team_0: bool,
pub counter_value: i32,
pub on_ball: bool,
#[serde(default)]
pub used: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<FlipResetOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_to_use: Option<f32>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FlipResetOutcomeEvent {
pub time: f32,
pub frame: usize,
pub reset_time: f32,
pub reset_frame: usize,
pub player: PlayerId,
pub is_team_0: bool,
pub counter_value: i32,
pub outcome: FlipResetOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_to_use: Option<f32>,
}
#[derive(Debug, Clone, PartialEq)]
struct RecentResetTouch {
time: f32,
frame: usize,
team_is_team_0: bool,
player_position: Option<[f32; 3]>,
}
#[derive(Debug, Clone, PartialEq)]
struct PendingOnBallReset {
reset: DodgeRefreshedEvent,
event_index: usize,
}
impl InFlightItem for PendingOnBallReset {
fn recognition(&self) -> Recognition {
Recognition::committed(self.reset.time, self.reset.frame)
}
fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
Disposition::Finalize(FinalizeReason::Boundary(boundary))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct FlipResetEvent {
pub time: f32,
pub frame: usize,
pub reset_time: f32,
pub reset_frame: usize,
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub player: PlayerId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<[f32; 3]>,
pub is_team_0: bool,
pub counter_value: i32,
pub time_since_reset: f32,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DodgeResetCalculator {
events: EventStream<DodgeResetEvent>,
confirmed_flip_reset_events: EventStream<FlipResetEvent>,
flip_reset_outcome_events: EventStream<FlipResetOutcomeEvent>,
pending_on_ball_resets: KeyedInFlightLedger<PlayerId, PendingOnBallReset>,
pending_reset_dodge_onset: HashMap<PlayerId, f32>,
recent_confirmable_touch: HashMap<PlayerId, RecentResetTouch>,
previous_dodge_active: HashMap<PlayerId, bool>,
previous_live_play: Option<bool>,
last_frame: Option<(f32, usize)>,
}
impl DodgeResetCalculator {
pub fn new() -> Self {
Self::default()
}
pub fn events(&self) -> &[DodgeResetEvent] {
self.events.all()
}
pub fn new_events(&self) -> &[DodgeResetEvent] {
self.events.new_events()
}
pub fn confirmed_flip_reset_events(&self) -> &[FlipResetEvent] {
self.confirmed_flip_reset_events.all()
}
pub fn new_confirmed_flip_reset_events(&self) -> &[FlipResetEvent] {
self.confirmed_flip_reset_events.new_events()
}
pub fn flip_reset_outcome_events(&self) -> &[FlipResetOutcomeEvent] {
self.flip_reset_outcome_events.all()
}
pub fn new_flip_reset_outcome_events(&self) -> &[FlipResetOutcomeEvent] {
self.flip_reset_outcome_events.new_events()
}
fn player<'a>(players: &'a PlayerFrameState, player_id: &PlayerId) -> Option<&'a PlayerSample> {
players
.players
.iter()
.find(|player| &player.player_id == player_id)
}
fn player_is_grounded(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
Self::player(players, player_id)
.and_then(PlayerSample::position)
.is_some_and(|position| position.z <= FLIP_RESET_GROUNDED_Z)
}
fn player_dodge_active(players: &PlayerFrameState, player_id: &PlayerId) -> bool {
Self::player(players, player_id).is_some_and(|player| player.dodge_active)
}
fn on_ball_dodge_reset(
ball: &BallFrameState,
players: &PlayerFrameState,
player_id: &PlayerId,
) -> bool {
const MIN_PLAYER_HEIGHT: f32 = 95.0;
const MIN_BALL_HEIGHT: f32 = 80.0;
const MAX_CENTER_DISTANCE: f32 = 180.0;
const MAX_LOCAL_VERTICAL_OFFSET: f32 = 140.0;
let Some(ball) = ball.sample() else {
return false;
};
let Some(player) = Self::player(players, player_id) else {
return false;
};
let Some(player_rigid_body) = &player.rigid_body else {
return false;
};
let ball_position = vec_to_glam(&ball.rigid_body.location);
let player_position = vec_to_glam(&player_rigid_body.location);
if player_position.z < MIN_PLAYER_HEIGHT || ball_position.z < MIN_BALL_HEIGHT {
return false;
}
let relative_ball_position = ball_position - player_position;
let center_distance = relative_ball_position.length();
if !center_distance.is_finite() || center_distance > MAX_CENTER_DISTANCE {
return false;
}
let player_rotation = quat_to_glam(&player_rigid_body.rotation);
let local_ball_position = player_rotation.inverse() * relative_ball_position;
local_ball_position.z <= MAX_LOCAL_VERTICAL_OFFSET
}
fn boundary_outcome(boundary: Boundary) -> FlipResetOutcome {
match boundary {
Boundary::LivePlayEnded => FlipResetOutcome::PlayEnded,
Boundary::GoalScored => FlipResetOutcome::GoalScored,
Boundary::ReplayEnded => FlipResetOutcome::ReplayEnded,
}
}
fn record_outcome(
&mut self,
pending: PendingOnBallReset,
outcome: FlipResetOutcome,
time: f32,
frame: usize,
time_to_use: Option<f32>,
) {
if let Some(reset) = self.events.get_mut(pending.event_index) {
reset.outcome = Some(outcome);
reset.time_to_use = time_to_use;
if outcome.is_used() {
reset.used = true;
}
}
self.flip_reset_outcome_events.push(FlipResetOutcomeEvent {
time,
frame,
reset_time: pending.reset.time,
reset_frame: pending.reset.frame,
player: pending.reset.player.clone(),
is_team_0: pending.reset.is_team_0,
counter_value: pending.reset.counter_value,
outcome,
time_to_use,
});
}
fn resolve_pending(
&mut self,
player_id: &PlayerId,
reason: FinalizeReason,
outcome: FlipResetOutcome,
time: f32,
frame: usize,
time_to_use: Option<f32>,
) {
let Some(pending) = self.pending_on_ball_resets.finalize(player_id, reason) else {
return;
};
self.clear_pending_reset_tracking(player_id);
self.record_outcome(pending, outcome, time, frame, time_to_use);
}
fn apply_ledger_boundary(&mut self, boundary: Boundary, time: f32, frame: usize) {
for (player_id, pending, _reason) in self.pending_on_ball_resets.apply_boundary(boundary) {
self.clear_pending_reset_tracking(&player_id);
self.record_outcome(pending, Self::boundary_outcome(boundary), time, frame, None);
}
}
fn prune_pending_resets(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
let grounded_players = self
.pending_on_ball_resets
.keys()
.filter(|player_id| Self::player_is_grounded(players, player_id))
.cloned()
.collect::<Vec<_>>();
for player_id in grounded_players {
self.resolve_pending(
&player_id,
FinalizeReason::Completed,
FlipResetOutcome::Landed,
frame.time,
frame.frame_number,
None,
);
}
}
fn clear_pending_reset_tracking(&mut self, player_id: &PlayerId) {
self.pending_reset_dodge_onset.remove(player_id);
self.recent_confirmable_touch.remove(player_id);
}
fn fallback_on_ball_reset(touch_event: &TouchEvent) -> bool {
if touch_event.player.is_none() || touch_event.dodge_contact {
return false;
}
if touch_event
.closest_approach_distance
.is_none_or(|gap| gap > TouchCandidateScoring::DEFAULT.relaxed_contact_gap_threshold)
{
return false;
}
if touch_event
.player_position
.is_none_or(|position| position.z < FALLBACK_RESET_MIN_PLAYER_HEIGHT)
{
return false;
}
let Some(local_ball_position) = touch_event.contact_local_ball_position else {
return false;
};
local_ball_position[0].abs() <= FALLBACK_RESET_MAX_LOCAL_FORWARD_OFFSET
&& local_ball_position[1].abs() <= FALLBACK_RESET_MAX_LOCAL_LATERAL_OFFSET
&& local_ball_position[2] <= FALLBACK_RESET_MAX_LOCAL_VERTICAL_OFFSET
}
fn arm_fallback_on_ball_reset(&mut self, touch_event: &TouchEvent) {
let Some(player_id) = touch_event.player.as_ref() else {
return;
};
if self.pending_on_ball_resets.contains(player_id) {
return;
}
if !Self::fallback_on_ball_reset(touch_event) {
return;
}
let reset_event = DodgeRefreshedEvent {
time: touch_event.time,
frame: touch_event.frame,
player: player_id.clone(),
player_position: touch_event
.player_position
.map(|position| vec_to_glam(&position).to_array()),
is_team_0: touch_event.team_is_team_0,
counter_value: 0,
};
let event_index = self.events.all().len();
self.pending_on_ball_resets.arm(
player_id.clone(),
PendingOnBallReset {
reset: reset_event.clone(),
event_index,
},
);
self.clear_pending_reset_tracking(player_id);
self.events.push(DodgeResetEvent {
time: reset_event.time,
frame: reset_event.frame,
player: reset_event.player,
player_position: reset_event.player_position,
is_team_0: reset_event.is_team_0,
counter_value: reset_event.counter_value,
on_ball: true,
used: false,
outcome: None,
time_to_use: None,
});
}
fn update_pending_reset_dodges(&mut self, players: &PlayerFrameState, frame_time: f32) {
let mut newly_started = Vec::new();
for player in &players.players {
let was_dodge_active = self
.previous_dodge_active
.insert(player.player_id.clone(), player.dodge_active)
.unwrap_or(false);
if player.dodge_active
&& !was_dodge_active
&& self.pending_on_ball_resets.contains(&player.player_id)
{
self.pending_reset_dodge_onset
.insert(player.player_id.clone(), frame_time);
newly_started.push(player.player_id.clone());
}
}
for player_id in newly_started {
let Some(touch) = self.recent_confirmable_touch.get(&player_id).cloned() else {
continue;
};
if frame_time - touch.time > FLIP_RESET_DODGE_TOUCH_LAG_TOLERANCE_SECONDS {
continue;
}
self.confirm_flip_reset(&player_id, &touch, frame_time);
}
}
fn process_touch_for_flip_reset(
&mut self,
players: &PlayerFrameState,
touch_event: &TouchEvent,
) {
let Some(player_id) = touch_event.player.as_ref() else {
return;
};
if !self.pending_on_ball_resets.contains(player_id) {
return;
}
let touch = RecentResetTouch {
time: touch_event.time,
frame: touch_event.frame,
team_is_team_0: touch_event.team_is_team_0,
player_position: touch_event
.player_position
.map(|position| vec_to_glam(&position).to_array())
.or_else(|| players.player_position(player_id)),
};
self.recent_confirmable_touch
.insert(player_id.clone(), touch.clone());
if let Some(&dodge_onset_time) = self.pending_reset_dodge_onset.get(player_id) {
if Self::player_dodge_active(players, player_id) {
let player_id = player_id.clone();
self.confirm_flip_reset(&player_id, &touch, dodge_onset_time);
}
}
}
fn confirm_flip_reset(
&mut self,
player_id: &PlayerId,
touch: &RecentResetTouch,
dodge_onset_time: f32,
) {
let Some(pending) = self.pending_on_ball_resets.get(player_id) else {
return;
};
let reset_event = pending.reset.clone();
let dodge_delay = dodge_onset_time - reset_event.time;
if dodge_delay < FLIP_RESET_MIN_DODGE_TOUCH_DELAY_SECONDS {
return;
}
if dodge_delay > FLIP_RESET_MAX_DODGE_TOUCH_DELAY_SECONDS {
self.resolve_pending(
player_id,
FinalizeReason::Completed,
FlipResetOutcome::Expired,
touch.time,
touch.frame,
None,
);
return;
}
let time_since_reset = touch.time - reset_event.time;
if time_since_reset < 0.0 {
return;
}
self.confirmed_flip_reset_events.push(FlipResetEvent {
time: touch.time,
frame: touch.frame,
reset_time: reset_event.time,
reset_frame: reset_event.frame,
player: player_id.clone(),
player_position: touch.player_position,
is_team_0: touch.team_is_team_0,
counter_value: reset_event.counter_value,
time_since_reset,
});
self.resolve_pending(
player_id,
FinalizeReason::Completed,
FlipResetOutcome::Used,
touch.time,
touch.frame,
Some(time_since_reset),
);
}
pub fn update(
&mut self,
frame: &FrameInfo,
ball: &BallFrameState,
players: &PlayerFrameState,
events: &FrameEventsState,
touch_state: &TouchState,
live_play_state: &LivePlayState,
) -> SubtrActorResult<()> {
self.events.begin_update();
self.confirmed_flip_reset_events.begin_update();
self.flip_reset_outcome_events.begin_update();
self.last_frame = Some((frame.time, frame.frame_number));
if !events.goal_events.is_empty() {
self.apply_ledger_boundary(Boundary::GoalScored, frame.time, frame.frame_number);
}
let live_play_just_ended =
!live_play_state.is_live_play && self.previous_live_play.unwrap_or(true);
if live_play_just_ended {
self.apply_ledger_boundary(Boundary::LivePlayEnded, frame.time, frame.frame_number);
}
self.previous_live_play = Some(live_play_state.is_live_play);
self.prune_pending_resets(frame, players);
for event in &events.dodge_refreshed_events {
let on_ball = Self::on_ball_dodge_reset(ball, players, &event.player);
let reset_event = event.clone();
let event = DodgeResetEvent {
time: event.time,
frame: event.frame,
player: event.player.clone(),
player_position: players.player_position(&event.player),
is_team_0: event.is_team_0,
counter_value: event.counter_value,
on_ball,
used: false,
outcome: None,
time_to_use: None,
};
if on_ball {
self.resolve_pending(
&event.player,
FinalizeReason::Superseded,
FlipResetOutcome::Superseded,
reset_event.time,
reset_event.frame,
None,
);
let event_index = self.events.all().len();
self.pending_on_ball_resets.arm(
event.player.clone(),
PendingOnBallReset {
reset: reset_event,
event_index,
},
);
self.clear_pending_reset_tracking(&event.player);
}
self.events.push(event);
}
self.update_pending_reset_dodges(players, frame.time);
for touch_event in chronological_touch_events(&touch_state.touch_events) {
if !events.dodge_refreshed_counter_available {
self.arm_fallback_on_ball_reset(touch_event);
}
self.process_touch_for_flip_reset(players, touch_event);
}
Ok(())
}
pub fn finish(&mut self) {
let (time, frame) = self.last_frame.unwrap_or((0.0, 0));
for (player_id, pending, _reason) in self.pending_on_ball_resets.finish() {
self.clear_pending_reset_tracking(&player_id);
self.record_outcome(pending, FlipResetOutcome::ReplayEnded, time, frame, None);
}
}
}
#[cfg(test)]
#[path = "dodge_reset_tests.rs"]
mod tests;