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#[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#[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#[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#[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}