Skip to main content

subtr_actor/stats/calculators/
fifty_fifty_state.rs

1use super::*;
2
3/// Maintains shared 50/50 contest state for downstream consumers.
4#[derive(Default)]
5pub struct FiftyFiftyStateCalculator {
6    active_event: Option<ActiveFiftyFifty>,
7    last_resolved_event: Option<FiftyFiftyEvent>,
8    pending_initial_touch: Option<PendingFiftyFiftyTouch>,
9    kickoff_touch_window_open: bool,
10}
11
12#[derive(Clone)]
13struct PendingFiftyFiftyTouch {
14    touch: TouchEvent,
15    is_kickoff: bool,
16}
17
18impl FiftyFiftyStateCalculator {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    fn reset(&mut self) {
24        self.active_event = None;
25        self.pending_initial_touch = None;
26    }
27
28    fn maybe_resolve_active_event(
29        &mut self,
30        frame: &FrameInfo,
31        ball: &BallFrameState,
32        possession_state: &PossessionState,
33    ) -> Option<FiftyFiftyEvent> {
34        let active = self.active_event.as_ref()?;
35        let age = (frame.time - active.last_touch_time).max(0.0);
36        if age < FIFTY_FIFTY_RESOLUTION_DELAY_SECONDS {
37            return None;
38        }
39
40        let winning_team_is_team_0 = FiftyFiftyCalculator::winning_team_from_ball(active, ball);
41        let possession_team_is_team_0 = possession_state.current_team_is_team_0;
42        let should_resolve = winning_team_is_team_0.is_some()
43            || possession_team_is_team_0.is_some()
44            || age >= FIFTY_FIFTY_MAX_DURATION_SECONDS;
45        if !should_resolve {
46            return None;
47        }
48
49        let active = self.active_event.take()?;
50        let event = FiftyFiftyEvent {
51            start_time: active.start_time,
52            start_frame: active.start_frame,
53            resolve_time: frame.time,
54            resolve_frame: frame.frame_number,
55            is_kickoff: active.is_kickoff,
56            team_zero_player: active.team_zero_player,
57            team_one_player: active.team_one_player,
58            team_zero_touch_time: active.team_zero_touch_time,
59            team_zero_touch_frame: active.team_zero_touch_frame,
60            team_zero_dodge_contact: active.team_zero_dodge_contact,
61            team_one_touch_time: active.team_one_touch_time,
62            team_one_touch_frame: active.team_one_touch_frame,
63            team_one_dodge_contact: active.team_one_dodge_contact,
64            team_zero_position: active.team_zero_position,
65            team_one_position: active.team_one_position,
66            midpoint: active.midpoint,
67            plane_normal: active.plane_normal,
68            winning_team_is_team_0,
69            possession_team_is_team_0,
70        };
71        self.last_resolved_event = Some(event.clone());
72        Some(event)
73    }
74
75    fn update_active_last_touch_from_continuation(
76        frame: &FrameInfo,
77        active_event: &mut ActiveFiftyFifty,
78        touch_events: &[TouchEvent],
79    ) {
80        let age = (frame.time - active_event.last_touch_time).max(0.0);
81        if age > FIFTY_FIFTY_CONTINUATION_TOUCH_WINDOW_SECONDS {
82            return;
83        }
84        let Some(touch) = active_event.latest_continuation_touch(touch_events) else {
85            return;
86        };
87        active_event.last_touch_time = touch.time;
88        active_event.last_touch_frame = touch.frame;
89    }
90
91    fn touch_pair_in_initial_window(previous: &TouchEvent, current: &TouchEvent) -> bool {
92        current.frame >= previous.frame
93            && current.time >= previous.time
94            && current.time - previous.time <= FIFTY_FIFTY_CONTINUATION_TOUCH_WINDOW_SECONDS
95    }
96
97    fn contested_touch_from_pending(
98        frame: &FrameInfo,
99        players: &PlayerFrameState,
100        pending: &PendingFiftyFiftyTouch,
101        touch_events: &[TouchEvent],
102        is_kickoff: bool,
103    ) -> Option<ActiveFiftyFifty> {
104        let latest_opposing_touch = touch_events
105            .iter()
106            .filter(|touch| touch.team_is_team_0 != pending.touch.team_is_team_0)
107            .filter(|touch| Self::touch_pair_in_initial_window(&pending.touch, touch))
108            .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))?;
109
110        let combined_touches = vec![pending.touch.clone(), latest_opposing_touch.clone()];
111        let mut active = FiftyFiftyCalculator::contested_touch(
112            frame,
113            players,
114            &combined_touches,
115            pending.is_kickoff || is_kickoff,
116        )?;
117        active.start_time = pending.touch.time.min(latest_opposing_touch.time);
118        active.start_frame = pending.touch.frame.min(latest_opposing_touch.frame);
119        Some(active)
120    }
121
122    fn pending_touch_from_events(
123        touch_events: &[TouchEvent],
124        is_kickoff: bool,
125    ) -> Option<PendingFiftyFiftyTouch> {
126        touch_events
127            .iter()
128            .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))
129            .cloned()
130            .map(|touch| PendingFiftyFiftyTouch { touch, is_kickoff })
131    }
132
133    #[allow(clippy::too_many_arguments)]
134    pub fn update(
135        &mut self,
136        frame: &FrameInfo,
137        gameplay: &GameplayState,
138        ball: &BallFrameState,
139        players: &PlayerFrameState,
140        touch_state: &TouchState,
141        possession_state: &PossessionState,
142        live_play_state: &LivePlayState,
143    ) -> FiftyFiftyState {
144        if FiftyFiftyCalculator::kickoff_phase_active(gameplay) {
145            self.kickoff_touch_window_open = true;
146        }
147
148        if !live_play_state.counts_toward_player_motion() {
149            self.reset();
150            return FiftyFiftyState {
151                active_event: None,
152                resolved_events: Vec::new(),
153                last_resolved_event: self.last_resolved_event.clone(),
154            };
155        }
156
157        let has_touch = !touch_state.touch_events.is_empty();
158        let has_contested_touch = touch_state
159            .touch_events
160            .iter()
161            .any(|touch| touch.team_is_team_0)
162            && touch_state
163                .touch_events
164                .iter()
165                .any(|touch| !touch.team_is_team_0);
166
167        if let Some(active_event) = self.active_event.as_mut() {
168            Self::update_active_last_touch_from_continuation(
169                frame,
170                active_event,
171                &touch_state.touch_events,
172            );
173        }
174
175        let mut resolved_events = Vec::new();
176        if let Some(event) = self.maybe_resolve_active_event(frame, ball, possession_state) {
177            resolved_events.push(event);
178        }
179
180        if has_contested_touch {
181            if self.active_event.is_none() {
182                self.active_event = FiftyFiftyCalculator::contested_touch(
183                    frame,
184                    players,
185                    &touch_state.touch_events,
186                    self.kickoff_touch_window_open,
187                );
188                if self.active_event.is_some() {
189                    self.pending_initial_touch = None;
190                }
191            }
192        } else if has_touch {
193            if self.active_event.is_none() {
194                if let Some(pending) = self.pending_initial_touch.as_ref() {
195                    self.active_event = Self::contested_touch_from_pending(
196                        frame,
197                        players,
198                        pending,
199                        &touch_state.touch_events,
200                        self.kickoff_touch_window_open,
201                    );
202                }
203                if self.active_event.is_some() {
204                    self.pending_initial_touch = None;
205                } else {
206                    self.pending_initial_touch = Self::pending_touch_from_events(
207                        &touch_state.touch_events,
208                        self.kickoff_touch_window_open,
209                    );
210                }
211            }
212            if let Some(active_event) = self.active_event.as_mut() {
213                Self::update_active_last_touch_from_continuation(
214                    frame,
215                    active_event,
216                    &touch_state.touch_events,
217                );
218            }
219        }
220
221        if has_touch {
222            self.kickoff_touch_window_open = false;
223        }
224
225        FiftyFiftyState {
226            active_event: self.active_event.clone(),
227            resolved_events,
228            last_resolved_event: self.last_resolved_event.clone(),
229        }
230    }
231}
232
233#[cfg(test)]
234#[path = "fifty_fifty_state_tests.rs"]
235mod tests;