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