Skip to main content

subtr_actor/stats/calculators/
loose_possession.rs

1use super::possession::{OpenPossession, PossessionLabel, ResolvedPossession};
2use super::*;
3
4/// How long the loose resolver waits for a follow-up touch before deciding a
5/// contested ball's fate. Unlike the strict resolver's window (which also
6/// bounds touch spacing within a hold, so it matches the loose-ball timeout),
7/// this only governs how long an opponent's unconfirmed challenge touch stays
8/// pending: the loose resolver keeps crediting the last team to touch through
9/// loose balls instead of letting them lapse to neutral.
10const LOOSE_RESOLUTION_WINDOW_SECONDS: f32 = 1.5;
11
12/// A team-possession span under the *loose* definition: the team that last
13/// touched the ball owns it until the opponent demonstrably takes it away.
14///
15/// Unlike the strict [`super::possession::PossessionEvent`] (which only credits
16/// firmly-controlled time and sends loose tails / unconfirmed touches to
17/// neutral), loose possession is sticky: it survives loose balls after a team's
18/// last touch, survives teammate passes, and survives repelled 50-50s. On a
19/// turnover the boundary is backdated to the opponent's takeover touch, so the
20/// losing team keeps credit right up to the moment the opponent wins it and
21/// there is no neutral gap. Consequently loose possession is almost always
22/// `team_zero` or `team_one` (neutral only before the first touch of a live
23/// stretch, or during a contested scramble off a neutral ball).
24#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
25#[ts(export)]
26pub struct LoosePossessionEvent {
27    pub time: f32,
28    pub frame: usize,
29    pub end_time: f32,
30    pub end_frame: usize,
31    pub active: bool,
32    pub duration: f32,
33    pub possession_state: String,
34    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
35    pub player_id: Option<PlayerId>,
36}
37
38/// The touch situation for a single frame, reduced to which team(s) contacted
39/// the ball and the latest contacting player per team.
40#[derive(Debug, Clone)]
41enum LooseTouchInput {
42    None,
43    Single {
44        team_is_team_0: bool,
45        player: Option<PlayerId>,
46    },
47    Contested {
48        team_zero_player: Option<PlayerId>,
49        team_one_player: Option<PlayerId>,
50    },
51}
52
53impl LooseTouchInput {
54    fn owner_player(&self, owner_team_is_team_0: bool) -> Option<PlayerId> {
55        match self {
56            LooseTouchInput::Contested {
57                team_zero_player,
58                team_one_player,
59            } => {
60                if owner_team_is_team_0 {
61                    team_zero_player.clone()
62                } else {
63                    team_one_player.clone()
64                }
65            }
66            _ => None,
67        }
68    }
69
70    fn opponent_player(&self, owner_team_is_team_0: bool) -> Option<PlayerId> {
71        match self {
72            LooseTouchInput::Contested {
73                team_zero_player,
74                team_one_player,
75            } => {
76                if owner_team_is_team_0 {
77                    team_one_player.clone()
78                } else {
79                    team_zero_player.clone()
80                }
81            }
82            _ => None,
83        }
84    }
85}
86
87/// The loose resolver's phase: who (if anyone) is the last team to touch and
88/// what follow-up we are waiting on.
89#[derive(Debug, Clone, PartialEq, Default)]
90enum LoosePhase {
91    /// No one has touched yet (start of a live stretch) or the ball is loose off
92    /// a contested neutral scramble. The open segment is neutral.
93    #[default]
94    Neutral,
95    /// A team owns the ball (was the last to touch and has not been dispossessed).
96    /// The open segment is that team's, and stays so through loose balls.
97    Owned {
98        team_is_team_0: bool,
99        player: Option<PlayerId>,
100    },
101    /// The owner still owns it, but an opponent has touched once. The open
102    /// segment is still the owner's; the contest resolves on the next touch
103    /// (owner re-touch keeps it; opponent confirm or timeout turns it over,
104    /// backdated to the opponent's first touch).
105    OwnedChallenged {
106        team_is_team_0: bool,
107        player: Option<PlayerId>,
108        challenger_first_time: f32,
109        challenger_first_frame: usize,
110        challenger_player: Option<PlayerId>,
111    },
112}
113
114/// Resolves a touch timeline into *loose* possession segments: the last team to
115/// touch owns the ball until the opponent takes it away, with the turnover
116/// backdated to the opponent's takeover touch. See [`LoosePossessionEvent`].
117#[derive(Debug, Clone, PartialEq, Default)]
118struct LoosePossessionResolver {
119    phase: LoosePhase,
120    open_start_time: f32,
121    open_start_frame: usize,
122    newly_resolved: Vec<ResolvedPossession>,
123}
124
125impl LoosePossessionResolver {
126    fn reset(&mut self) {
127        self.phase = LoosePhase::Neutral;
128        self.open_start_time = 0.0;
129        self.open_start_frame = 0;
130        self.newly_resolved.clear();
131    }
132
133    fn begin(&mut self, frame: &FrameInfo) {
134        self.reset();
135        self.open_start_time = frame.time;
136        self.open_start_frame = frame.frame_number;
137    }
138
139    /// Close the open segment at `(end_time, end_frame)` with `label`/`player`
140    /// and start a new open segment there.
141    fn finalize(
142        &mut self,
143        end_time: f32,
144        end_frame: usize,
145        label: PossessionLabel,
146        player: Option<PlayerId>,
147    ) {
148        if end_time > self.open_start_time {
149            self.newly_resolved.push(ResolvedPossession {
150                start_time: self.open_start_time,
151                start_frame: self.open_start_frame,
152                end_time,
153                end_frame,
154                label,
155                player,
156            });
157        }
158        self.open_start_time = end_time;
159        self.open_start_frame = end_frame;
160    }
161
162    fn within_window(now: f32, since: f32) -> bool {
163        now - since <= LOOSE_RESOLUTION_WINDOW_SECONDS
164    }
165
166    fn touch_input(
167        touched_team_zero_player: &Option<PlayerId>,
168        touched_team_one_player: &Option<PlayerId>,
169        touched_team_zero: bool,
170        touched_team_one: bool,
171    ) -> LooseTouchInput {
172        match (touched_team_zero, touched_team_one) {
173            (false, false) => LooseTouchInput::None,
174            (true, false) => LooseTouchInput::Single {
175                team_is_team_0: true,
176                player: touched_team_zero_player.clone(),
177            },
178            (false, true) => LooseTouchInput::Single {
179                team_is_team_0: false,
180                player: touched_team_one_player.clone(),
181            },
182            (true, true) => LooseTouchInput::Contested {
183                team_zero_player: touched_team_zero_player.clone(),
184                team_one_player: touched_team_one_player.clone(),
185            },
186        }
187    }
188
189    /// Advance the resolver one frame, pushing any finalized segments onto
190    /// `newly_resolved` (cleared at the start of every call).
191    fn update(
192        &mut self,
193        frame: &FrameInfo,
194        touched_team_zero_player: &Option<PlayerId>,
195        touched_team_one_player: &Option<PlayerId>,
196        touched_team_zero: bool,
197        touched_team_one: bool,
198    ) {
199        self.newly_resolved.clear();
200        let time = frame.time;
201        let fnum = frame.frame_number;
202        let input = Self::touch_input(
203            touched_team_zero_player,
204            touched_team_one_player,
205            touched_team_zero,
206            touched_team_one,
207        );
208
209        let phase = std::mem::take(&mut self.phase);
210        self.phase = match phase {
211            LoosePhase::Neutral => self.step_neutral(time, fnum, input),
212            LoosePhase::Owned {
213                team_is_team_0,
214                player,
215            } => self.step_owned(time, fnum, input, team_is_team_0, player),
216            LoosePhase::OwnedChallenged {
217                team_is_team_0,
218                player,
219                challenger_first_time,
220                challenger_first_frame,
221                challenger_player,
222            } => self.step_owned_challenged(
223                time,
224                fnum,
225                input,
226                team_is_team_0,
227                player,
228                challenger_first_time,
229                challenger_first_frame,
230                challenger_player,
231            ),
232        };
233    }
234
235    fn step_neutral(&mut self, time: f32, fnum: usize, input: LooseTouchInput) -> LoosePhase {
236        match input {
237            // A single touch grants the loose ball immediately: the team that
238            // touched is now the last to touch, so it owns from this frame.
239            LooseTouchInput::Single {
240                team_is_team_0,
241                player,
242            } => {
243                self.finalize(time, fnum, PossessionLabel::Neutral, None);
244                LoosePhase::Owned {
245                    team_is_team_0,
246                    player,
247                }
248            }
249            // No clear last-toucher: the ball stays neutral.
250            LooseTouchInput::None | LooseTouchInput::Contested { .. } => LoosePhase::Neutral,
251        }
252    }
253
254    fn step_owned(
255        &mut self,
256        time: f32,
257        fnum: usize,
258        input: LooseTouchInput,
259        team_is_team_0: bool,
260        player: Option<PlayerId>,
261    ) -> LoosePhase {
262        match input {
263            // Sticky: a loose ball after the owner's last touch stays the owner's.
264            LooseTouchInput::None => LoosePhase::Owned {
265                team_is_team_0,
266                player,
267            },
268            LooseTouchInput::Single {
269                team_is_team_0: touch_team,
270                player: touch_player,
271            } => {
272                if touch_team == team_is_team_0 {
273                    LoosePhase::Owned {
274                        team_is_team_0,
275                        player: touch_player.or(player),
276                    }
277                } else {
278                    LoosePhase::OwnedChallenged {
279                        team_is_team_0,
280                        player,
281                        challenger_first_time: time,
282                        challenger_first_frame: fnum,
283                        challenger_player: touch_player,
284                    }
285                }
286            }
287            LooseTouchInput::Contested { .. } => LoosePhase::OwnedChallenged {
288                team_is_team_0,
289                player: input.owner_player(team_is_team_0).or(player),
290                challenger_first_time: time,
291                challenger_first_frame: fnum,
292                challenger_player: input.opponent_player(team_is_team_0),
293            },
294        }
295    }
296
297    #[allow(clippy::too_many_arguments)]
298    fn step_owned_challenged(
299        &mut self,
300        time: f32,
301        _fnum: usize,
302        input: LooseTouchInput,
303        team_is_team_0: bool,
304        player: Option<PlayerId>,
305        challenger_first_time: f32,
306        challenger_first_frame: usize,
307        challenger_player: Option<PlayerId>,
308    ) -> LoosePhase {
309        // Turn the ball over to the challenger, backdated to their first touch:
310        // the owner keeps credit up to that touch, then the challenger owns.
311        let confirm_turnover = |resolver: &mut Self, new_player: Option<PlayerId>| {
312            resolver.finalize(
313                challenger_first_time,
314                challenger_first_frame,
315                PossessionLabel::team(team_is_team_0),
316                player.clone(),
317            );
318            LoosePhase::Owned {
319                team_is_team_0: !team_is_team_0,
320                player: new_player.or_else(|| challenger_player.clone()),
321            }
322        };
323
324        match input {
325            LooseTouchInput::None => {
326                if Self::within_window(time, challenger_first_time) {
327                    LoosePhase::OwnedChallenged {
328                        team_is_team_0,
329                        player,
330                        challenger_first_time,
331                        challenger_first_frame,
332                        challenger_player,
333                    }
334                } else {
335                    // No follow-up: the challenger was the last to touch, so the
336                    // ball is theirs from their touch.
337                    confirm_turnover(self, None)
338                }
339            }
340            LooseTouchInput::Single {
341                team_is_team_0: touch_team,
342                player: touch_player,
343            } => {
344                if touch_team == team_is_team_0 {
345                    // Owner re-takes: the challenge was repelled, so possession
346                    // stays continuous (no boundary) — survives the 50-50.
347                    LoosePhase::Owned {
348                        team_is_team_0,
349                        player: touch_player.or(player),
350                    }
351                } else {
352                    confirm_turnover(self, touch_player)
353                }
354            }
355            // Still contested: defer until a clean touch or the window elapses.
356            LooseTouchInput::Contested { .. } => LoosePhase::OwnedChallenged {
357                team_is_team_0,
358                player,
359                challenger_first_time,
360                challenger_first_frame,
361                challenger_player,
362            },
363        }
364    }
365
366    /// The current open (unresolved) segment.
367    fn open(&self) -> OpenPossession {
368        let (label, player) = match &self.phase {
369            LoosePhase::Neutral => (PossessionLabel::Neutral, None),
370            LoosePhase::Owned {
371                team_is_team_0,
372                player,
373            }
374            | LoosePhase::OwnedChallenged {
375                team_is_team_0,
376                player,
377                ..
378            } => (PossessionLabel::team(*team_is_team_0), player.clone()),
379        };
380        OpenPossession {
381            start_time: self.open_start_time,
382            start_frame: self.open_start_frame,
383            label,
384            player,
385        }
386    }
387
388    /// Flush the open segment when live play ends. The owner keeps its sticky
389    /// tail all the way to the end (the opponent never took it away).
390    fn flush(&mut self, end_time: f32, end_frame: usize) {
391        let phase = std::mem::take(&mut self.phase);
392        match phase {
393            LoosePhase::Neutral => {
394                self.finalize(end_time, end_frame, PossessionLabel::Neutral, None);
395            }
396            LoosePhase::Owned {
397                team_is_team_0,
398                player,
399            }
400            | LoosePhase::OwnedChallenged {
401                team_is_team_0,
402                player,
403                ..
404            } => {
405                self.finalize(
406                    end_time,
407                    end_frame,
408                    PossessionLabel::team(team_is_team_0),
409                    player,
410                );
411            }
412        }
413        self.phase = LoosePhase::Neutral;
414    }
415}
416
417/// Builds the loose team-possession event stream. Self-contained: it derives the
418/// per-frame touch input from `TouchState` and drives its own
419/// [`LoosePossessionResolver`], so it does not perturb the strict possession
420/// path. Finalized segments are emitted as soon as the resolver decides them;
421/// non-live stretches are coalesced into inactive markers so the stream stays
422/// contiguous.
423#[derive(Debug, Clone, Default, PartialEq)]
424pub struct LoosePossessionCalculator {
425    resolver: LoosePossessionResolver,
426    was_live: bool,
427    events: EventStream<LoosePossessionEvent>,
428    open_event: Option<LoosePossessionEvent>,
429    inactive_pending: Option<LoosePossessionEvent>,
430}
431
432impl LoosePossessionCalculator {
433    pub fn new() -> Self {
434        Self::default()
435    }
436
437    pub fn events(&self) -> &[LoosePossessionEvent] {
438        self.events.all()
439    }
440
441    pub fn new_events(&self) -> &[LoosePossessionEvent] {
442        self.events.new_events()
443    }
444
445    pub fn projected_events(&self) -> Vec<LoosePossessionEvent> {
446        let mut events = self.events.all().to_vec();
447        if let Some(open) = &self.open_event {
448            events.push(open.clone());
449        }
450        if let Some(inactive) = &self.inactive_pending {
451            events.push(inactive.clone());
452        }
453        events
454    }
455
456    pub fn current_event(&self) -> Option<&LoosePossessionEvent> {
457        self.open_event
458            .as_ref()
459            .or(self.inactive_pending.as_ref())
460            .or_else(|| self.events.all().last())
461    }
462
463    pub fn flush_pending_event(&mut self) {
464        if let Some(inactive) = self.inactive_pending.take() {
465            self.events.push(inactive);
466        }
467        if let Some(open) = self.open_event.take() {
468            self.events.push(open);
469        }
470    }
471
472    fn latest_touch_player_for_team(
473        touch_events: &[TouchEvent],
474        team_is_team_0: bool,
475    ) -> Option<PlayerId> {
476        touch_events
477            .iter()
478            .filter(|touch| touch.team_is_team_0 == team_is_team_0)
479            .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))
480            .and_then(|touch| touch.player.clone())
481    }
482
483    fn segment_event(segment: &ResolvedPossession) -> LoosePossessionEvent {
484        LoosePossessionEvent {
485            time: segment.start_time,
486            frame: segment.start_frame,
487            end_time: segment.end_time,
488            end_frame: segment.end_frame,
489            active: true,
490            duration: (segment.end_time - segment.start_time).max(0.0),
491            possession_state: segment.label.as_label_value().to_owned(),
492            player_id: segment.player.clone(),
493        }
494    }
495
496    fn flush_inactive(&mut self) {
497        if let Some(inactive) = self.inactive_pending.take() {
498            self.events.push(inactive);
499        }
500    }
501
502    fn commit_resolved(&mut self) {
503        let resolved = std::mem::take(&mut self.resolver.newly_resolved);
504        if resolved.is_empty() {
505            return;
506        }
507        self.flush_inactive();
508        for segment in &resolved {
509            self.events.push(Self::segment_event(segment));
510        }
511    }
512
513    pub fn update(
514        &mut self,
515        frame: &FrameInfo,
516        touch_state: &TouchState,
517        live_play_state: &LivePlayState,
518    ) -> SubtrActorResult<()> {
519        self.events.begin_update();
520
521        if !live_play_state.is_live_play {
522            if self.was_live {
523                self.resolver.flush(frame.time, frame.frame_number);
524                self.commit_resolved();
525                self.was_live = false;
526            }
527            self.open_event = None;
528            match self.inactive_pending.as_mut() {
529                Some(inactive) => {
530                    inactive.end_time = frame.time;
531                    inactive.end_frame = frame.frame_number;
532                    inactive.duration = (inactive.end_time - inactive.time).max(0.0);
533                }
534                None => {
535                    self.inactive_pending = Some(LoosePossessionEvent {
536                        time: frame.time,
537                        frame: frame.frame_number,
538                        end_time: frame.time,
539                        end_frame: frame.frame_number,
540                        active: false,
541                        duration: 0.0,
542                        possession_state: PossessionLabel::Neutral.as_label_value().to_owned(),
543                        player_id: None,
544                    });
545                }
546            }
547            return Ok(());
548        }
549
550        if !self.was_live {
551            self.resolver.begin(frame);
552            self.was_live = true;
553        }
554
555        let touched_team_zero = touch_state
556            .touch_events
557            .iter()
558            .any(|touch| touch.team_is_team_0);
559        let touched_team_one = touch_state
560            .touch_events
561            .iter()
562            .any(|touch| !touch.team_is_team_0);
563        let touched_team_zero_player =
564            Self::latest_touch_player_for_team(&touch_state.touch_events, true);
565        let touched_team_one_player =
566            Self::latest_touch_player_for_team(&touch_state.touch_events, false);
567
568        self.resolver.update(
569            frame,
570            &touched_team_zero_player,
571            &touched_team_one_player,
572            touched_team_zero,
573            touched_team_one,
574        );
575        self.commit_resolved();
576
577        self.flush_inactive();
578        let open = self.resolver.open();
579        self.open_event = Some(LoosePossessionEvent {
580            time: open.start_time,
581            frame: open.start_frame,
582            end_time: frame.time,
583            end_frame: frame.frame_number,
584            active: true,
585            duration: (frame.time - open.start_time).max(0.0),
586            possession_state: open.label.as_label_value().to_owned(),
587            player_id: open.player.clone(),
588        });
589        Ok(())
590    }
591
592    pub fn finish(&mut self) {
593        self.flush_pending_event();
594    }
595}
596
597#[cfg(test)]
598#[path = "loose_possession_tests.rs"]
599mod tests;