subtr_actor/stats/calculators/
fifty_fifty_state.rs1use super::*;
2
3#[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;