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
68const FIFTY_FIFTY_PHASE_LABELS: [StatLabel; 2] = [
69    StatLabel::new("phase", "open_play"),
70    StatLabel::new("phase", "kickoff"),
71];
72const FIFTY_FIFTY_TEAM_OUTCOME_LABELS: [StatLabel; 3] = [
73    StatLabel::new("winning_team", "team_zero"),
74    StatLabel::new("winning_team", "team_one"),
75    StatLabel::new("winning_team", "neutral"),
76];
77const FIFTY_FIFTY_POSSESSION_LABELS: [StatLabel; 3] = [
78    StatLabel::new("possession_after", "team_zero"),
79    StatLabel::new("possession_after", "team_one"),
80    StatLabel::new("possession_after", "neutral"),
81];
82const FIFTY_FIFTY_PLAYER_OUTCOME_LABELS: [StatLabel; 3] = [
83    StatLabel::new("outcome", "win"),
84    StatLabel::new("outcome", "loss"),
85    StatLabel::new("outcome", "neutral"),
86];
87const FIFTY_FIFTY_PLAYER_POSSESSION_LABELS: [StatLabel; 3] = [
88    StatLabel::new("possession_after", "self"),
89    StatLabel::new("possession_after", "opponent"),
90    StatLabel::new("possession_after", "neutral"),
91];
92
93fn fifty_fifty_phase_label(is_kickoff: bool) -> StatLabel {
94    if is_kickoff {
95        StatLabel::new("phase", "kickoff")
96    } else {
97        StatLabel::new("phase", "open_play")
98    }
99}
100
101fn fifty_fifty_team_outcome_label(team_is_team_0: Option<bool>) -> StatLabel {
102    match team_is_team_0 {
103        Some(true) => StatLabel::new("winning_team", "team_zero"),
104        Some(false) => StatLabel::new("winning_team", "team_one"),
105        None => StatLabel::new("winning_team", "neutral"),
106    }
107}
108
109fn fifty_fifty_possession_label(team_is_team_0: Option<bool>) -> StatLabel {
110    match team_is_team_0 {
111        Some(true) => StatLabel::new("possession_after", "team_zero"),
112        Some(false) => StatLabel::new("possession_after", "team_one"),
113        None => StatLabel::new("possession_after", "neutral"),
114    }
115}
116
117fn fifty_fifty_player_outcome_label(
118    player_team_is_team_0: bool,
119    winning_team_is_team_0: Option<bool>,
120) -> StatLabel {
121    match winning_team_is_team_0 {
122        Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
123            StatLabel::new("outcome", "win")
124        }
125        Some(_) => StatLabel::new("outcome", "loss"),
126        None => StatLabel::new("outcome", "neutral"),
127    }
128}
129
130fn fifty_fifty_player_possession_label(
131    player_team_is_team_0: bool,
132    possession_team_is_team_0: Option<bool>,
133) -> StatLabel {
134    match possession_team_is_team_0 {
135        Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
136            StatLabel::new("possession_after", "self")
137        }
138        Some(_) => StatLabel::new("possession_after", "opponent"),
139        None => StatLabel::new("possession_after", "neutral"),
140    }
141}
142
143impl FiftyFiftyEvent {
144    fn labels(&self) -> [StatLabel; 3] {
145        [
146            fifty_fifty_phase_label(self.is_kickoff),
147            fifty_fifty_team_outcome_label(self.winning_team_is_team_0),
148            fifty_fifty_possession_label(self.possession_team_is_team_0),
149        ]
150    }
151
152    fn player_labels(&self, player_team_is_team_0: bool) -> [StatLabel; 3] {
153        [
154            fifty_fifty_phase_label(self.is_kickoff),
155            fifty_fifty_player_outcome_label(player_team_is_team_0, self.winning_team_is_team_0),
156            fifty_fifty_player_possession_label(
157                player_team_is_team_0,
158                self.possession_team_is_team_0,
159            ),
160        ]
161    }
162}
163
164#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
165#[ts(export)]
166pub struct FiftyFiftyStats {
167    pub count: u32,
168    pub team_zero_wins: u32,
169    pub team_one_wins: u32,
170    pub neutral_outcomes: u32,
171    pub kickoff_count: u32,
172    pub kickoff_team_zero_wins: u32,
173    pub kickoff_team_one_wins: u32,
174    pub kickoff_neutral_outcomes: u32,
175    pub team_zero_possession_after_count: u32,
176    pub team_one_possession_after_count: u32,
177    pub neutral_possession_after_count: u32,
178    pub kickoff_team_zero_possession_after_count: u32,
179    pub kickoff_team_one_possession_after_count: u32,
180    pub kickoff_neutral_possession_after_count: u32,
181    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
182    pub labeled_event_counts: LabeledCounts,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
186#[ts(export)]
187pub struct FiftyFiftyPlayerStats {
188    pub count: u32,
189    pub wins: u32,
190    pub losses: u32,
191    pub neutral_outcomes: u32,
192    pub kickoff_count: u32,
193    pub kickoff_wins: u32,
194    pub kickoff_losses: u32,
195    pub kickoff_neutral_outcomes: u32,
196    pub possession_after_count: u32,
197    pub kickoff_possession_after_count: u32,
198    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
199    pub labeled_event_counts: LabeledCounts,
200}
201
202impl FiftyFiftyStats {
203    fn record_event(&mut self, event: &FiftyFiftyEvent) {
204        self.labeled_event_counts.increment(event.labels());
205        self.sync_legacy_counts();
206    }
207
208    pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
209        self.labeled_event_counts.count_matching(labels)
210    }
211
212    pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
213        LabeledCounts::complete_from_label_sets(
214            &[
215                &FIFTY_FIFTY_PHASE_LABELS,
216                &FIFTY_FIFTY_TEAM_OUTCOME_LABELS,
217                &FIFTY_FIFTY_POSSESSION_LABELS,
218            ],
219            &self.labeled_event_counts,
220        )
221    }
222
223    fn sync_legacy_counts(&mut self) {
224        self.count = self.labeled_event_counts.total();
225        self.team_zero_wins =
226            self.event_count_with_labels(&[fifty_fifty_team_outcome_label(Some(true))]);
227        self.team_one_wins =
228            self.event_count_with_labels(&[fifty_fifty_team_outcome_label(Some(false))]);
229        self.neutral_outcomes =
230            self.event_count_with_labels(&[fifty_fifty_team_outcome_label(None)]);
231        self.kickoff_count = self.event_count_with_labels(&[fifty_fifty_phase_label(true)]);
232        self.kickoff_team_zero_wins = self.event_count_with_labels(&[
233            fifty_fifty_phase_label(true),
234            fifty_fifty_team_outcome_label(Some(true)),
235        ]);
236        self.kickoff_team_one_wins = self.event_count_with_labels(&[
237            fifty_fifty_phase_label(true),
238            fifty_fifty_team_outcome_label(Some(false)),
239        ]);
240        self.kickoff_neutral_outcomes = self.event_count_with_labels(&[
241            fifty_fifty_phase_label(true),
242            fifty_fifty_team_outcome_label(None),
243        ]);
244        self.team_zero_possession_after_count =
245            self.event_count_with_labels(&[fifty_fifty_possession_label(Some(true))]);
246        self.team_one_possession_after_count =
247            self.event_count_with_labels(&[fifty_fifty_possession_label(Some(false))]);
248        self.neutral_possession_after_count =
249            self.event_count_with_labels(&[fifty_fifty_possession_label(None)]);
250        self.kickoff_team_zero_possession_after_count = self.event_count_with_labels(&[
251            fifty_fifty_phase_label(true),
252            fifty_fifty_possession_label(Some(true)),
253        ]);
254        self.kickoff_team_one_possession_after_count = self.event_count_with_labels(&[
255            fifty_fifty_phase_label(true),
256            fifty_fifty_possession_label(Some(false)),
257        ]);
258        self.kickoff_neutral_possession_after_count = self.event_count_with_labels(&[
259            fifty_fifty_phase_label(true),
260            fifty_fifty_possession_label(None),
261        ]);
262    }
263
264    pub fn team_zero_win_pct(&self) -> f32 {
265        if self.count == 0 {
266            0.0
267        } else {
268            self.team_zero_wins as f32 * 100.0 / self.count as f32
269        }
270    }
271
272    pub fn team_one_win_pct(&self) -> f32 {
273        if self.count == 0 {
274            0.0
275        } else {
276            self.team_one_wins as f32 * 100.0 / self.count as f32
277        }
278    }
279
280    pub fn kickoff_team_zero_win_pct(&self) -> f32 {
281        if self.kickoff_count == 0 {
282            0.0
283        } else {
284            self.kickoff_team_zero_wins as f32 * 100.0 / self.kickoff_count as f32
285        }
286    }
287
288    pub fn kickoff_team_one_win_pct(&self) -> f32 {
289        if self.kickoff_count == 0 {
290            0.0
291        } else {
292            self.kickoff_team_one_wins as f32 * 100.0 / self.kickoff_count as f32
293        }
294    }
295}
296
297impl FiftyFiftyPlayerStats {
298    fn record_event(&mut self, player_team_is_team_0: bool, event: &FiftyFiftyEvent) {
299        self.labeled_event_counts
300            .increment(event.player_labels(player_team_is_team_0));
301        self.sync_legacy_counts();
302    }
303
304    pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
305        self.labeled_event_counts.count_matching(labels)
306    }
307
308    pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
309        LabeledCounts::complete_from_label_sets(
310            &[
311                &FIFTY_FIFTY_PHASE_LABELS,
312                &FIFTY_FIFTY_PLAYER_OUTCOME_LABELS,
313                &FIFTY_FIFTY_PLAYER_POSSESSION_LABELS,
314            ],
315            &self.labeled_event_counts,
316        )
317    }
318
319    fn sync_legacy_counts(&mut self) {
320        self.count = self.labeled_event_counts.total();
321        self.wins = self.event_count_with_labels(&[StatLabel::new("outcome", "win")]);
322        self.losses = self.event_count_with_labels(&[StatLabel::new("outcome", "loss")]);
323        self.neutral_outcomes =
324            self.event_count_with_labels(&[StatLabel::new("outcome", "neutral")]);
325        self.kickoff_count = self.event_count_with_labels(&[fifty_fifty_phase_label(true)]);
326        self.kickoff_wins = self.event_count_with_labels(&[
327            fifty_fifty_phase_label(true),
328            StatLabel::new("outcome", "win"),
329        ]);
330        self.kickoff_losses = self.event_count_with_labels(&[
331            fifty_fifty_phase_label(true),
332            StatLabel::new("outcome", "loss"),
333        ]);
334        self.kickoff_neutral_outcomes = self.event_count_with_labels(&[
335            fifty_fifty_phase_label(true),
336            StatLabel::new("outcome", "neutral"),
337        ]);
338        self.possession_after_count =
339            self.event_count_with_labels(&[StatLabel::new("possession_after", "self")]);
340        self.kickoff_possession_after_count = self.event_count_with_labels(&[
341            fifty_fifty_phase_label(true),
342            StatLabel::new("possession_after", "self"),
343        ]);
344    }
345
346    pub fn win_pct(&self) -> f32 {
347        if self.count == 0 {
348            0.0
349        } else {
350            self.wins as f32 * 100.0 / self.count as f32
351        }
352    }
353
354    pub fn kickoff_win_pct(&self) -> f32 {
355        if self.kickoff_count == 0 {
356            0.0
357        } else {
358            self.kickoff_wins as f32 * 100.0 / self.kickoff_count as f32
359        }
360    }
361}
362
363#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
364#[ts(export)]
365pub struct FiftyFiftyTeamStats {
366    pub count: u32,
367    pub wins: u32,
368    pub losses: u32,
369    pub neutral_outcomes: u32,
370    pub kickoff_count: u32,
371    pub kickoff_wins: u32,
372    pub kickoff_losses: u32,
373    pub kickoff_neutral_outcomes: u32,
374    pub possession_after_count: u32,
375    pub opponent_possession_after_count: u32,
376    pub neutral_possession_after_count: u32,
377    pub kickoff_possession_after_count: u32,
378    pub kickoff_opponent_possession_after_count: u32,
379    pub kickoff_neutral_possession_after_count: u32,
380}
381
382impl FiftyFiftyStats {
383    pub fn for_team(&self, is_team_zero: bool) -> FiftyFiftyTeamStats {
384        let (
385            wins,
386            losses,
387            kickoff_wins,
388            kickoff_losses,
389            possession_after_count,
390            opponent_possession_after_count,
391            kickoff_possession_after_count,
392            kickoff_opponent_possession_after_count,
393        ) = if is_team_zero {
394            (
395                self.team_zero_wins,
396                self.team_one_wins,
397                self.kickoff_team_zero_wins,
398                self.kickoff_team_one_wins,
399                self.team_zero_possession_after_count,
400                self.team_one_possession_after_count,
401                self.kickoff_team_zero_possession_after_count,
402                self.kickoff_team_one_possession_after_count,
403            )
404        } else {
405            (
406                self.team_one_wins,
407                self.team_zero_wins,
408                self.kickoff_team_one_wins,
409                self.kickoff_team_zero_wins,
410                self.team_one_possession_after_count,
411                self.team_zero_possession_after_count,
412                self.kickoff_team_one_possession_after_count,
413                self.kickoff_team_zero_possession_after_count,
414            )
415        };
416
417        FiftyFiftyTeamStats {
418            count: self.count,
419            wins,
420            losses,
421            neutral_outcomes: self.neutral_outcomes,
422            kickoff_count: self.kickoff_count,
423            kickoff_wins,
424            kickoff_losses,
425            kickoff_neutral_outcomes: self.kickoff_neutral_outcomes,
426            possession_after_count,
427            opponent_possession_after_count,
428            neutral_possession_after_count: self.neutral_possession_after_count,
429            kickoff_possession_after_count,
430            kickoff_opponent_possession_after_count,
431            kickoff_neutral_possession_after_count: self.kickoff_neutral_possession_after_count,
432        }
433    }
434}
435
436#[derive(Debug, Clone, Default, PartialEq)]
437pub struct FiftyFiftyCalculator {
438    stats: FiftyFiftyStats,
439    player_stats: HashMap<PlayerId, FiftyFiftyPlayerStats>,
440    events: Vec<FiftyFiftyEvent>,
441}
442
443impl FiftyFiftyCalculator {
444    pub fn new() -> Self {
445        Self::default()
446    }
447
448    pub fn stats(&self) -> &FiftyFiftyStats {
449        &self.stats
450    }
451
452    pub fn player_stats(&self) -> &HashMap<PlayerId, FiftyFiftyPlayerStats> {
453        &self.player_stats
454    }
455
456    pub fn events(&self) -> &[FiftyFiftyEvent] {
457        &self.events
458    }
459
460    fn apply_event(&mut self, event: &FiftyFiftyEvent) {
461        self.stats.record_event(event);
462
463        if let Some(player_id) = event.team_zero_player.as_ref() {
464            let stats = self.player_stats.entry(player_id.clone()).or_default();
465            stats.record_event(true, event);
466        }
467        if let Some(player_id) = event.team_one_player.as_ref() {
468            let stats = self.player_stats.entry(player_id.clone()).or_default();
469            stats.record_event(false, event);
470        }
471
472        self.events.push(event.clone());
473    }
474
475    pub(crate) fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
476        gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
477            || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
478            || gameplay.ball_has_been_hit == Some(false)
479    }
480
481    pub(crate) fn contested_touch(
482        frame: &FrameInfo,
483        players: &PlayerFrameState,
484        touch_events: &[TouchEvent],
485        is_kickoff: bool,
486    ) -> Option<ActiveFiftyFifty> {
487        let team_zero_touch = touch_events.iter().find(|touch| touch.team_is_team_0)?;
488        let team_one_touch = touch_events.iter().find(|touch| !touch.team_is_team_0)?;
489        let team_zero_position = team_zero_touch.player.as_ref().and_then(|player_id| {
490            players
491                .players
492                .iter()
493                .find(|player| &player.player_id == player_id)
494                .and_then(PlayerSample::position)
495        })?;
496        let team_one_position = team_one_touch.player.as_ref().and_then(|player_id| {
497            players
498                .players
499                .iter()
500                .find(|player| &player.player_id == player_id)
501                .and_then(PlayerSample::position)
502        })?;
503        let midpoint = (team_zero_position + team_one_position) * 0.5;
504        let mut plane_normal = team_one_position - team_zero_position;
505        plane_normal.z = 0.0;
506        if plane_normal.length_squared() <= f32::EPSILON {
507            plane_normal = glam::Vec3::Y;
508        } else {
509            plane_normal = plane_normal.normalize();
510        }
511
512        Some(ActiveFiftyFifty {
513            start_time: frame.time,
514            start_frame: frame.frame_number,
515            last_touch_time: frame.time,
516            last_touch_frame: frame.frame_number,
517            is_kickoff,
518            team_zero_player: team_zero_touch.player.clone(),
519            team_one_player: team_one_touch.player.clone(),
520            team_zero_position: team_zero_position.to_array(),
521            team_one_position: team_one_position.to_array(),
522            midpoint: midpoint.to_array(),
523            plane_normal: plane_normal.to_array(),
524        })
525    }
526
527    pub(crate) fn winning_team_from_ball(
528        active: &ActiveFiftyFifty,
529        ball: &BallFrameState,
530    ) -> Option<bool> {
531        let ball = ball.sample()?;
532        let midpoint = active.midpoint_vec();
533        let plane_normal = active.plane_normal_vec();
534        let displacement = ball.position() - midpoint;
535        let signed_distance = displacement.dot(plane_normal);
536        if signed_distance.abs() >= FIFTY_FIFTY_MIN_EXIT_DISTANCE {
537            return Some(signed_distance > 0.0);
538        }
539
540        let signed_speed = ball.velocity().dot(plane_normal);
541        if signed_speed.abs() >= FIFTY_FIFTY_MIN_EXIT_SPEED {
542            return Some(signed_speed > 0.0);
543        }
544
545        None
546    }
547
548    pub fn update(&mut self, fifty_fifty_state: &FiftyFiftyState) -> SubtrActorResult<()> {
549        for event in &fifty_fifty_state.resolved_events {
550            self.apply_event(event);
551        }
552        Ok(())
553    }
554}