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#[derive(Debug, Clone, Default, PartialEq)]
10pub struct FiftyFiftyState {
11    pub active_event: Option<ActiveFiftyFifty>,
12    pub resolved_events: Vec<FiftyFiftyEvent>,
13    pub last_resolved_event: Option<FiftyFiftyEvent>,
14}
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct ActiveFiftyFifty {
18    pub start_time: f32,
19    pub start_frame: usize,
20    pub last_touch_time: f32,
21    pub last_touch_frame: usize,
22    pub is_kickoff: bool,
23    pub team_zero_player: Option<PlayerId>,
24    pub team_one_player: Option<PlayerId>,
25    pub team_zero_position: [f32; 3],
26    pub team_one_position: [f32; 3],
27    pub midpoint: [f32; 3],
28    pub plane_normal: [f32; 3],
29}
30
31impl ActiveFiftyFifty {
32    pub fn midpoint_vec(&self) -> glam::Vec3 {
33        glam::Vec3::from_array(self.midpoint)
34    }
35
36    pub fn plane_normal_vec(&self) -> glam::Vec3 {
37        glam::Vec3::from_array(self.plane_normal)
38    }
39
40    pub fn contains_team_touch(&self, touch_events: &[TouchEvent]) -> bool {
41        touch_events.iter().any(|touch| {
42            (touch.team_is_team_0 && self.team_zero_player.is_some())
43                || (!touch.team_is_team_0 && self.team_one_player.is_some())
44        })
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
49#[ts(export)]
50pub struct FiftyFiftyEvent {
51    pub start_time: f32,
52    pub start_frame: usize,
53    pub resolve_time: f32,
54    pub resolve_frame: usize,
55    pub is_kickoff: bool,
56    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
57    pub team_zero_player: Option<PlayerId>,
58    #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
59    pub team_one_player: Option<PlayerId>,
60    pub team_zero_position: [f32; 3],
61    pub team_one_position: [f32; 3],
62    pub midpoint: [f32; 3],
63    pub plane_normal: [f32; 3],
64    pub winning_team_is_team_0: Option<bool>,
65    pub possession_team_is_team_0: Option<bool>,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
69#[ts(export)]
70pub struct FiftyFiftyStats {
71    pub count: u32,
72    pub team_zero_wins: u32,
73    pub team_one_wins: u32,
74    pub neutral_outcomes: u32,
75    pub kickoff_count: u32,
76    pub kickoff_team_zero_wins: u32,
77    pub kickoff_team_one_wins: u32,
78    pub kickoff_neutral_outcomes: u32,
79    pub team_zero_possession_after_count: u32,
80    pub team_one_possession_after_count: u32,
81    pub neutral_possession_after_count: u32,
82    pub kickoff_team_zero_possession_after_count: u32,
83    pub kickoff_team_one_possession_after_count: u32,
84    pub kickoff_neutral_possession_after_count: u32,
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
88#[ts(export)]
89pub struct FiftyFiftyPlayerStats {
90    pub count: u32,
91    pub wins: u32,
92    pub losses: u32,
93    pub neutral_outcomes: u32,
94    pub kickoff_count: u32,
95    pub kickoff_wins: u32,
96    pub kickoff_losses: u32,
97    pub kickoff_neutral_outcomes: u32,
98    pub possession_after_count: u32,
99    pub kickoff_possession_after_count: u32,
100}
101
102impl FiftyFiftyStats {
103    pub fn team_zero_win_pct(&self) -> f32 {
104        if self.count == 0 {
105            0.0
106        } else {
107            self.team_zero_wins as f32 * 100.0 / self.count as f32
108        }
109    }
110
111    pub fn team_one_win_pct(&self) -> f32 {
112        if self.count == 0 {
113            0.0
114        } else {
115            self.team_one_wins as f32 * 100.0 / self.count as f32
116        }
117    }
118
119    pub fn kickoff_team_zero_win_pct(&self) -> f32 {
120        if self.kickoff_count == 0 {
121            0.0
122        } else {
123            self.kickoff_team_zero_wins as f32 * 100.0 / self.kickoff_count as f32
124        }
125    }
126
127    pub fn kickoff_team_one_win_pct(&self) -> f32 {
128        if self.kickoff_count == 0 {
129            0.0
130        } else {
131            self.kickoff_team_one_wins as f32 * 100.0 / self.kickoff_count as f32
132        }
133    }
134}
135
136impl FiftyFiftyPlayerStats {
137    pub fn win_pct(&self) -> f32 {
138        if self.count == 0 {
139            0.0
140        } else {
141            self.wins as f32 * 100.0 / self.count as f32
142        }
143    }
144
145    pub fn kickoff_win_pct(&self) -> f32 {
146        if self.kickoff_count == 0 {
147            0.0
148        } else {
149            self.kickoff_wins as f32 * 100.0 / self.kickoff_count as f32
150        }
151    }
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
155#[ts(export)]
156pub struct FiftyFiftyTeamStats {
157    pub count: u32,
158    pub wins: u32,
159    pub losses: u32,
160    pub neutral_outcomes: u32,
161    pub kickoff_count: u32,
162    pub kickoff_wins: u32,
163    pub kickoff_losses: u32,
164    pub kickoff_neutral_outcomes: u32,
165    pub possession_after_count: u32,
166    pub opponent_possession_after_count: u32,
167    pub neutral_possession_after_count: u32,
168    pub kickoff_possession_after_count: u32,
169    pub kickoff_opponent_possession_after_count: u32,
170    pub kickoff_neutral_possession_after_count: u32,
171}
172
173impl FiftyFiftyStats {
174    pub fn for_team(&self, is_team_zero: bool) -> FiftyFiftyTeamStats {
175        let (
176            wins,
177            losses,
178            kickoff_wins,
179            kickoff_losses,
180            possession_after_count,
181            opponent_possession_after_count,
182            kickoff_possession_after_count,
183            kickoff_opponent_possession_after_count,
184        ) = if is_team_zero {
185            (
186                self.team_zero_wins,
187                self.team_one_wins,
188                self.kickoff_team_zero_wins,
189                self.kickoff_team_one_wins,
190                self.team_zero_possession_after_count,
191                self.team_one_possession_after_count,
192                self.kickoff_team_zero_possession_after_count,
193                self.kickoff_team_one_possession_after_count,
194            )
195        } else {
196            (
197                self.team_one_wins,
198                self.team_zero_wins,
199                self.kickoff_team_one_wins,
200                self.kickoff_team_zero_wins,
201                self.team_one_possession_after_count,
202                self.team_zero_possession_after_count,
203                self.kickoff_team_one_possession_after_count,
204                self.kickoff_team_zero_possession_after_count,
205            )
206        };
207
208        FiftyFiftyTeamStats {
209            count: self.count,
210            wins,
211            losses,
212            neutral_outcomes: self.neutral_outcomes,
213            kickoff_count: self.kickoff_count,
214            kickoff_wins,
215            kickoff_losses,
216            kickoff_neutral_outcomes: self.kickoff_neutral_outcomes,
217            possession_after_count,
218            opponent_possession_after_count,
219            neutral_possession_after_count: self.neutral_possession_after_count,
220            kickoff_possession_after_count,
221            kickoff_opponent_possession_after_count,
222            kickoff_neutral_possession_after_count: self.kickoff_neutral_possession_after_count,
223        }
224    }
225}
226
227#[derive(Debug, Clone, Default, PartialEq)]
228pub struct FiftyFiftyCalculator {
229    stats: FiftyFiftyStats,
230    player_stats: HashMap<PlayerId, FiftyFiftyPlayerStats>,
231    events: Vec<FiftyFiftyEvent>,
232}
233
234impl FiftyFiftyCalculator {
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    pub fn stats(&self) -> &FiftyFiftyStats {
240        &self.stats
241    }
242
243    pub fn player_stats(&self) -> &HashMap<PlayerId, FiftyFiftyPlayerStats> {
244        &self.player_stats
245    }
246
247    pub fn events(&self) -> &[FiftyFiftyEvent] {
248        &self.events
249    }
250
251    fn apply_team_outcome(
252        stats: &mut FiftyFiftyStats,
253        winning_team_is_team_0: Option<bool>,
254        is_kickoff: bool,
255    ) {
256        match winning_team_is_team_0 {
257            Some(true) => {
258                stats.team_zero_wins += 1;
259                if is_kickoff {
260                    stats.kickoff_team_zero_wins += 1;
261                }
262            }
263            Some(false) => {
264                stats.team_one_wins += 1;
265                if is_kickoff {
266                    stats.kickoff_team_one_wins += 1;
267                }
268            }
269            None => {
270                stats.neutral_outcomes += 1;
271                if is_kickoff {
272                    stats.kickoff_neutral_outcomes += 1;
273                }
274            }
275        }
276    }
277
278    fn apply_possession_outcome(
279        stats: &mut FiftyFiftyStats,
280        possession_team_is_team_0: Option<bool>,
281        is_kickoff: bool,
282    ) {
283        match possession_team_is_team_0 {
284            Some(true) => {
285                stats.team_zero_possession_after_count += 1;
286                if is_kickoff {
287                    stats.kickoff_team_zero_possession_after_count += 1;
288                }
289            }
290            Some(false) => {
291                stats.team_one_possession_after_count += 1;
292                if is_kickoff {
293                    stats.kickoff_team_one_possession_after_count += 1;
294                }
295            }
296            None => {
297                stats.neutral_possession_after_count += 1;
298                if is_kickoff {
299                    stats.kickoff_neutral_possession_after_count += 1;
300                }
301            }
302        }
303    }
304
305    fn apply_player_outcome(
306        player_stats: &mut FiftyFiftyPlayerStats,
307        player_team_is_team_0: bool,
308        event: &FiftyFiftyEvent,
309    ) {
310        player_stats.count += 1;
311        if event.is_kickoff {
312            player_stats.kickoff_count += 1;
313        }
314
315        match event.winning_team_is_team_0 {
316            Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
317                player_stats.wins += 1;
318                if event.is_kickoff {
319                    player_stats.kickoff_wins += 1;
320                }
321            }
322            Some(_) => {
323                player_stats.losses += 1;
324                if event.is_kickoff {
325                    player_stats.kickoff_losses += 1;
326                }
327            }
328            None => {
329                player_stats.neutral_outcomes += 1;
330                if event.is_kickoff {
331                    player_stats.kickoff_neutral_outcomes += 1;
332                }
333            }
334        }
335
336        if event.possession_team_is_team_0 == Some(player_team_is_team_0) {
337            player_stats.possession_after_count += 1;
338            if event.is_kickoff {
339                player_stats.kickoff_possession_after_count += 1;
340            }
341        }
342    }
343
344    fn apply_event(&mut self, event: &FiftyFiftyEvent) {
345        self.stats.count += 1;
346        if event.is_kickoff {
347            self.stats.kickoff_count += 1;
348        }
349        Self::apply_team_outcome(
350            &mut self.stats,
351            event.winning_team_is_team_0,
352            event.is_kickoff,
353        );
354        Self::apply_possession_outcome(
355            &mut self.stats,
356            event.possession_team_is_team_0,
357            event.is_kickoff,
358        );
359
360        if let Some(player_id) = event.team_zero_player.as_ref() {
361            let stats = self.player_stats.entry(player_id.clone()).or_default();
362            Self::apply_player_outcome(stats, true, event);
363        }
364        if let Some(player_id) = event.team_one_player.as_ref() {
365            let stats = self.player_stats.entry(player_id.clone()).or_default();
366            Self::apply_player_outcome(stats, false, event);
367        }
368
369        self.events.push(event.clone());
370    }
371
372    pub(crate) fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
373        gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
374            || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
375            || gameplay.ball_has_been_hit == Some(false)
376    }
377
378    pub(crate) fn contested_touch(
379        frame: &FrameInfo,
380        players: &PlayerFrameState,
381        touch_events: &[TouchEvent],
382        is_kickoff: bool,
383    ) -> Option<ActiveFiftyFifty> {
384        let team_zero_touch = touch_events.iter().find(|touch| touch.team_is_team_0)?;
385        let team_one_touch = touch_events.iter().find(|touch| !touch.team_is_team_0)?;
386        let team_zero_position = team_zero_touch.player.as_ref().and_then(|player_id| {
387            players
388                .players
389                .iter()
390                .find(|player| &player.player_id == player_id)
391                .and_then(PlayerSample::position)
392        })?;
393        let team_one_position = team_one_touch.player.as_ref().and_then(|player_id| {
394            players
395                .players
396                .iter()
397                .find(|player| &player.player_id == player_id)
398                .and_then(PlayerSample::position)
399        })?;
400        let midpoint = (team_zero_position + team_one_position) * 0.5;
401        let mut plane_normal = team_one_position - team_zero_position;
402        plane_normal.z = 0.0;
403        if plane_normal.length_squared() <= f32::EPSILON {
404            plane_normal = glam::Vec3::Y;
405        } else {
406            plane_normal = plane_normal.normalize();
407        }
408
409        Some(ActiveFiftyFifty {
410            start_time: frame.time,
411            start_frame: frame.frame_number,
412            last_touch_time: frame.time,
413            last_touch_frame: frame.frame_number,
414            is_kickoff,
415            team_zero_player: team_zero_touch.player.clone(),
416            team_one_player: team_one_touch.player.clone(),
417            team_zero_position: team_zero_position.to_array(),
418            team_one_position: team_one_position.to_array(),
419            midpoint: midpoint.to_array(),
420            plane_normal: plane_normal.to_array(),
421        })
422    }
423
424    pub(crate) fn winning_team_from_ball(
425        active: &ActiveFiftyFifty,
426        ball: &BallFrameState,
427    ) -> Option<bool> {
428        let ball = ball.sample()?;
429        let midpoint = active.midpoint_vec();
430        let plane_normal = active.plane_normal_vec();
431        let displacement = ball.position() - midpoint;
432        let signed_distance = displacement.dot(plane_normal);
433        if signed_distance.abs() >= FIFTY_FIFTY_MIN_EXIT_DISTANCE {
434            return Some(signed_distance > 0.0);
435        }
436
437        let signed_speed = ball.velocity().dot(plane_normal);
438        if signed_speed.abs() >= FIFTY_FIFTY_MIN_EXIT_SPEED {
439            return Some(signed_speed > 0.0);
440        }
441
442        None
443    }
444
445    pub fn update(&mut self, fifty_fifty_state: &FiftyFiftyState) -> SubtrActorResult<()> {
446        for event in &fifty_fifty_state.resolved_events {
447            self.apply_event(event);
448        }
449        Ok(())
450    }
451}