Skip to main content

subtr_actor/stats/calculators/
fifty_fifty.rs

1use super::*;
2
3pub(crate) const FIFTY_FIFTY_CONTINUATION_TOUCH_WINDOW_SECONDS: f32 = 0.2;
4pub(crate) const FIFTY_FIFTY_RESOLUTION_DELAY_SECONDS: f32 = 0.35;
5pub(crate) const FIFTY_FIFTY_MAX_DURATION_SECONDS: f32 = 1.25;
6pub(crate) const FIFTY_FIFTY_MIN_EXIT_DISTANCE: f32 = 180.0;
7pub(crate) const FIFTY_FIFTY_MIN_EXIT_SPEED: f32 = 220.0;
8
9/// Shared state of in-progress and resolved 50/50 contests.
10#[derive(Debug, Clone, Default, PartialEq)]
11pub struct FiftyFiftyState {
12    pub active_event: Option<ActiveFiftyFifty>,
13    pub resolved_events: Vec<FiftyFiftyEvent>,
14    pub last_resolved_event: Option<FiftyFiftyEvent>,
15}
16
17/// An in-progress 50/50 contest being tracked.
18#[derive(Debug, Clone, PartialEq)]
19pub struct ActiveFiftyFifty {
20    pub start_time: f32,
21    pub start_frame: usize,
22    pub last_touch_time: f32,
23    pub last_touch_frame: usize,
24    pub is_kickoff: bool,
25    pub team_zero_player: Option<PlayerId>,
26    pub team_one_player: Option<PlayerId>,
27    pub team_zero_touch_time: Option<f32>,
28    pub team_zero_touch_frame: Option<usize>,
29    pub team_zero_dodge_contact: bool,
30    pub team_one_touch_time: Option<f32>,
31    pub team_one_touch_frame: Option<usize>,
32    pub team_one_dodge_contact: bool,
33    pub team_zero_position: [f32; 3],
34    pub team_one_position: [f32; 3],
35    pub midpoint: [f32; 3],
36    pub plane_normal: [f32; 3],
37}
38
39impl ActiveFiftyFifty {
40    pub fn midpoint_vec(&self) -> glam::Vec3 {
41        glam::Vec3::from_array(self.midpoint)
42    }
43
44    pub fn plane_normal_vec(&self) -> glam::Vec3 {
45        glam::Vec3::from_array(self.plane_normal)
46    }
47
48    pub fn contains_team_touch(&self, touch_events: &[TouchEvent]) -> bool {
49        self.latest_continuation_touch(touch_events).is_some()
50    }
51
52    pub fn latest_continuation_touch<'a>(
53        &self,
54        touch_events: &'a [TouchEvent],
55    ) -> Option<&'a TouchEvent> {
56        touch_events
57            .iter()
58            .filter(|touch| {
59                (touch.team_is_team_0 && self.team_zero_player.is_some())
60                    || (!touch.team_is_team_0 && self.team_one_player.is_some())
61            })
62            .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))
63    }
64}
65
66#[cfg(test)]
67#[path = "fifty_fifty_tests.rs"]
68mod tests;
69
70/// A contested ball interaction with touches/pressure from both teams in a short window.
71#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
72#[ts(export)]
73pub struct FiftyFiftyEvent {
74    pub start_time: f32,
75    pub start_frame: usize,
76    pub resolve_time: f32,
77    pub resolve_frame: usize,
78    pub is_kickoff: bool,
79    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
80    pub team_zero_player: Option<PlayerId>,
81    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
82    pub team_one_player: Option<PlayerId>,
83    pub team_zero_touch_time: Option<f32>,
84    pub team_zero_touch_frame: Option<usize>,
85    pub team_zero_dodge_contact: bool,
86    pub team_one_touch_time: Option<f32>,
87    pub team_one_touch_frame: Option<usize>,
88    pub team_one_dodge_contact: bool,
89    pub team_zero_position: [f32; 3],
90    pub team_one_position: [f32; 3],
91    pub midpoint: [f32; 3],
92    pub plane_normal: [f32; 3],
93    pub winning_team_is_team_0: Option<bool>,
94    pub possession_team_is_team_0: Option<bool>,
95}
96
97pub(crate) const FIFTY_FIFTY_PHASE_LABELS: [StatLabel; 2] = [
98    StatLabel::new("phase", "open_play"),
99    StatLabel::new("phase", "kickoff"),
100];
101pub(crate) const FIFTY_FIFTY_TEAM_OUTCOME_LABELS: [StatLabel; 3] = [
102    StatLabel::new("winning_team", "team_zero"),
103    StatLabel::new("winning_team", "team_one"),
104    StatLabel::new("winning_team", "neutral"),
105];
106pub(crate) const FIFTY_FIFTY_POSSESSION_LABELS: [StatLabel; 3] = [
107    StatLabel::new("possession_after", "team_zero"),
108    StatLabel::new("possession_after", "team_one"),
109    StatLabel::new("possession_after", "neutral"),
110];
111pub(crate) const FIFTY_FIFTY_PLAYER_OUTCOME_LABELS: [StatLabel; 3] = [
112    StatLabel::new("outcome", "win"),
113    StatLabel::new("outcome", "loss"),
114    StatLabel::new("outcome", "neutral"),
115];
116pub(crate) const FIFTY_FIFTY_PLAYER_POSSESSION_LABELS: [StatLabel; 3] = [
117    StatLabel::new("possession_after", "self"),
118    StatLabel::new("possession_after", "opponent"),
119    StatLabel::new("possession_after", "neutral"),
120];
121pub(crate) const FIFTY_FIFTY_TOUCH_DODGE_STATE_LABELS: [StatLabel; 2] = [
122    StatLabel::new("dodge_state", "no_dodge"),
123    StatLabel::new("dodge_state", "dodge"),
124];
125pub(crate) const FIFTY_FIFTY_TEAM_ZERO_DODGE_STATE_LABELS: [StatLabel; 2] = [
126    StatLabel::new("team_zero_dodge_state", "no_dodge"),
127    StatLabel::new("team_zero_dodge_state", "dodge"),
128];
129pub(crate) const FIFTY_FIFTY_TEAM_ONE_DODGE_STATE_LABELS: [StatLabel; 2] = [
130    StatLabel::new("team_one_dodge_state", "no_dodge"),
131    StatLabel::new("team_one_dodge_state", "dodge"),
132];
133
134pub(crate) fn fifty_fifty_phase_label(is_kickoff: bool) -> StatLabel {
135    if is_kickoff {
136        StatLabel::new("phase", "kickoff")
137    } else {
138        StatLabel::new("phase", "open_play")
139    }
140}
141
142pub(crate) fn fifty_fifty_team_outcome_label(team_is_team_0: Option<bool>) -> StatLabel {
143    match team_is_team_0 {
144        Some(true) => StatLabel::new("winning_team", "team_zero"),
145        Some(false) => StatLabel::new("winning_team", "team_one"),
146        None => StatLabel::new("winning_team", "neutral"),
147    }
148}
149
150pub(crate) fn fifty_fifty_possession_label(team_is_team_0: Option<bool>) -> StatLabel {
151    match team_is_team_0 {
152        Some(true) => StatLabel::new("possession_after", "team_zero"),
153        Some(false) => StatLabel::new("possession_after", "team_one"),
154        None => StatLabel::new("possession_after", "neutral"),
155    }
156}
157
158pub(crate) fn fifty_fifty_player_outcome_label(
159    player_team_is_team_0: bool,
160    winning_team_is_team_0: Option<bool>,
161) -> StatLabel {
162    match winning_team_is_team_0 {
163        Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
164            StatLabel::new("outcome", "win")
165        }
166        Some(_) => StatLabel::new("outcome", "loss"),
167        None => StatLabel::new("outcome", "neutral"),
168    }
169}
170
171pub(crate) fn fifty_fifty_player_possession_label(
172    player_team_is_team_0: bool,
173    possession_team_is_team_0: Option<bool>,
174) -> StatLabel {
175    match possession_team_is_team_0 {
176        Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
177            StatLabel::new("possession_after", "self")
178        }
179        Some(_) => StatLabel::new("possession_after", "opponent"),
180        None => StatLabel::new("possession_after", "neutral"),
181    }
182}
183
184pub(crate) fn fifty_fifty_touch_dodge_state_label(dodge_contact: bool) -> StatLabel {
185    if dodge_contact {
186        StatLabel::new("dodge_state", "dodge")
187    } else {
188        StatLabel::new("dodge_state", "no_dodge")
189    }
190}
191
192pub(crate) fn fifty_fifty_team_zero_dodge_state_label(dodge_contact: bool) -> StatLabel {
193    if dodge_contact {
194        StatLabel::new("team_zero_dodge_state", "dodge")
195    } else {
196        StatLabel::new("team_zero_dodge_state", "no_dodge")
197    }
198}
199
200pub(crate) fn fifty_fifty_team_one_dodge_state_label(dodge_contact: bool) -> StatLabel {
201    if dodge_contact {
202        StatLabel::new("team_one_dodge_state", "dodge")
203    } else {
204        StatLabel::new("team_one_dodge_state", "no_dodge")
205    }
206}
207
208impl FiftyFiftyEvent {
209    pub(crate) fn labels(&self) -> Vec<StatLabel> {
210        vec![
211            fifty_fifty_phase_label(self.is_kickoff),
212            fifty_fifty_team_outcome_label(self.winning_team_is_team_0),
213            fifty_fifty_possession_label(self.possession_team_is_team_0),
214            fifty_fifty_team_zero_dodge_state_label(self.team_zero_dodge_contact),
215            fifty_fifty_team_one_dodge_state_label(self.team_one_dodge_contact),
216        ]
217    }
218
219    pub(crate) fn player_labels(&self, player_team_is_team_0: bool) -> Vec<StatLabel> {
220        let dodge_contact = if player_team_is_team_0 {
221            self.team_zero_dodge_contact
222        } else {
223            self.team_one_dodge_contact
224        };
225        vec![
226            fifty_fifty_phase_label(self.is_kickoff),
227            fifty_fifty_player_outcome_label(player_team_is_team_0, self.winning_team_is_team_0),
228            fifty_fifty_player_possession_label(
229                player_team_is_team_0,
230                self.possession_team_is_team_0,
231            ),
232            fifty_fifty_touch_dodge_state_label(dodge_contact),
233        ]
234    }
235}
236
237/// Detects 50/50 contests and their outcomes.
238#[derive(Debug, Clone, Default, PartialEq)]
239pub struct FiftyFiftyCalculator {
240    events: EventStream<FiftyFiftyEvent>,
241}
242
243impl FiftyFiftyCalculator {
244    pub fn new() -> Self {
245        Self::default()
246    }
247
248    pub fn events(&self) -> &[FiftyFiftyEvent] {
249        self.events.all()
250    }
251
252    pub fn new_events(&self) -> &[FiftyFiftyEvent] {
253        self.events.new_events()
254    }
255
256    fn apply_event(&mut self, event: &FiftyFiftyEvent) {
257        self.events.push(event.clone());
258    }
259
260    pub(crate) fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
261        gameplay.kickoff_phase_active()
262    }
263
264    fn latest_touch_for_team(
265        touch_events: &[TouchEvent],
266        team_is_team_0: bool,
267    ) -> Option<&TouchEvent> {
268        touch_events
269            .iter()
270            .filter(|touch| touch.team_is_team_0 == team_is_team_0)
271            .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))
272    }
273
274    pub(crate) fn contested_touch(
275        frame: &FrameInfo,
276        players: &PlayerFrameState,
277        touch_events: &[TouchEvent],
278        is_kickoff: bool,
279    ) -> Option<ActiveFiftyFifty> {
280        let team_zero_touch = Self::latest_touch_for_team(touch_events, true)?;
281        let team_one_touch = Self::latest_touch_for_team(touch_events, false)?;
282        let team_zero_position = team_zero_touch.player.as_ref().and_then(|player_id| {
283            players
284                .players
285                .iter()
286                .find(|player| &player.player_id == player_id)
287                .and_then(PlayerSample::position)
288        })?;
289        let team_one_position = team_one_touch.player.as_ref().and_then(|player_id| {
290            players
291                .players
292                .iter()
293                .find(|player| &player.player_id == player_id)
294                .and_then(PlayerSample::position)
295        })?;
296        let midpoint = (team_zero_position + team_one_position) * 0.5;
297        let mut plane_normal = team_one_position - team_zero_position;
298        plane_normal.z = 0.0;
299        if plane_normal.length_squared() <= f32::EPSILON {
300            plane_normal = glam::Vec3::Y;
301        } else {
302            plane_normal = plane_normal.normalize();
303        }
304
305        let last_touch = [team_zero_touch, team_one_touch]
306            .into_iter()
307            .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))?;
308
309        Some(ActiveFiftyFifty {
310            start_time: frame.time,
311            start_frame: frame.frame_number,
312            last_touch_time: last_touch.time,
313            last_touch_frame: last_touch.frame,
314            is_kickoff,
315            team_zero_player: team_zero_touch.player.clone(),
316            team_one_player: team_one_touch.player.clone(),
317            team_zero_touch_time: Some(team_zero_touch.time),
318            team_zero_touch_frame: Some(team_zero_touch.frame),
319            team_zero_dodge_contact: team_zero_touch.dodge_contact,
320            team_one_touch_time: Some(team_one_touch.time),
321            team_one_touch_frame: Some(team_one_touch.frame),
322            team_one_dodge_contact: team_one_touch.dodge_contact,
323            team_zero_position: team_zero_position.to_array(),
324            team_one_position: team_one_position.to_array(),
325            midpoint: midpoint.to_array(),
326            plane_normal: plane_normal.to_array(),
327        })
328    }
329
330    pub(crate) fn winning_team_from_ball(
331        active: &ActiveFiftyFifty,
332        ball: &BallFrameState,
333    ) -> Option<bool> {
334        let ball = ball.sample()?;
335        let midpoint = active.midpoint_vec();
336        let plane_normal = active.plane_normal_vec();
337        let displacement = ball.position() - midpoint;
338        let signed_distance = displacement.dot(plane_normal);
339        if signed_distance.abs() >= FIFTY_FIFTY_MIN_EXIT_DISTANCE {
340            return Some(signed_distance > 0.0);
341        }
342
343        let signed_speed = ball.velocity().dot(plane_normal);
344        if signed_speed.abs() >= FIFTY_FIFTY_MIN_EXIT_SPEED {
345            return Some(signed_speed > 0.0);
346        }
347
348        None
349    }
350
351    pub fn update(&mut self, fifty_fifty_state: &FiftyFiftyState) -> SubtrActorResult<()> {
352        self.events.begin_update();
353        for event in &fifty_fifty_state.resolved_events {
354            self.apply_event(event);
355        }
356        Ok(())
357    }
358}