Skip to main content

subtr_actor/stats/calculators/
possession.rs

1use super::*;
2
3const PENDING_TURNOVER_CONFIRMATION_WINDOW_SECONDS: f32 = 1.25;
4const LOOSE_BALL_TIMEOUT_SECONDS: f32 = 3.0;
5
6/// How long the resolver waits for a follow-up touch before deciding a
7/// possession's fate. A possession's credited span is backdated to its owner's
8/// last touch; the loose time after that touch stays provisional until either
9/// the same owner re-touches (kept), the opponent confirms a turnover (the gap
10/// goes neutral and the opponent is credited from their first touch), or this
11/// window elapses with no follow-up (the gap goes neutral).
12///
13/// This must match the per-player loose-ball timeout
14/// ([`LOOSE_BALL_TIMEOUT_SECONDS`], which drives the eager tracker behind
15/// per-player possession spans): both answer "how far apart may touches be
16/// before the hold is broken?". When this window was shorter (1.5s), a hold
17/// whose touches came 1.5–3s apart stayed continuous for the player stream but
18/// oscillated Acquiring→Neutral here — earning zero team-control credit — so
19/// summed player possession routinely exceeded team control.
20const POSSESSION_RESOLUTION_WINDOW_SECONDS: f32 = LOOSE_BALL_TIMEOUT_SECONDS;
21
22/// A team-or-neutral label for a resolved possession segment.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) enum PossessionLabel {
25    TeamZero,
26    TeamOne,
27    Neutral,
28}
29
30impl PossessionLabel {
31    pub(crate) fn team(team_is_team_0: bool) -> Self {
32        if team_is_team_0 {
33            Self::TeamZero
34        } else {
35            Self::TeamOne
36        }
37    }
38
39    pub(crate) fn as_label_value(self) -> &'static str {
40        match self {
41            Self::TeamZero => "team_zero",
42            Self::TeamOne => "team_one",
43            Self::Neutral => "neutral",
44        }
45    }
46}
47
48/// A finalized stretch of the possession timeline. The resolver emits these as
49/// soon as a touch or timeout decides who (if anyone) owned the stretch, so the
50/// loose time after a possession's last touch only becomes a team's credit once
51/// that team demonstrably keeps the ball.
52#[derive(Debug, Clone, PartialEq)]
53pub(crate) struct ResolvedPossession {
54    pub start_time: f32,
55    pub start_frame: usize,
56    pub end_time: f32,
57    pub end_frame: usize,
58    pub label: PossessionLabel,
59    pub player: Option<PlayerId>,
60}
61
62/// The still-open (unresolved) trailing segment of the possession timeline. Its
63/// label is the current best guess; its true extent is only known once it is
64/// resolved, but display consumers can render it as the live possession.
65#[derive(Debug, Clone, PartialEq)]
66pub(crate) struct OpenPossession {
67    pub start_time: f32,
68    pub start_frame: usize,
69    pub label: PossessionLabel,
70    pub player: Option<PlayerId>,
71}
72
73/// The touch situation for a single frame, reduced to which team(s) contacted
74/// the ball and the latest contacting player per team.
75#[derive(Debug, Clone)]
76enum TouchInput {
77    None,
78    /// Exactly one team touched.
79    Single {
80        team_is_team_0: bool,
81        player: Option<PlayerId>,
82    },
83    /// Both teams touched the same frame (contested).
84    Contested {
85        team_zero_player: Option<PlayerId>,
86        team_one_player: Option<PlayerId>,
87    },
88}
89
90impl TouchInput {
91    fn opponent_player(&self, owner_team_is_team_0: bool) -> Option<PlayerId> {
92        match self {
93            TouchInput::Contested {
94                team_zero_player,
95                team_one_player,
96            } => {
97                if owner_team_is_team_0 {
98                    team_one_player.clone()
99                } else {
100                    team_zero_player.clone()
101                }
102            }
103            _ => None,
104        }
105    }
106
107    fn owner_player(&self, owner_team_is_team_0: bool) -> Option<PlayerId> {
108        match self {
109            TouchInput::Contested {
110                team_zero_player,
111                team_one_player,
112            } => {
113                if owner_team_is_team_0 {
114                    team_zero_player.clone()
115                } else {
116                    team_one_player.clone()
117                }
118            }
119            _ => None,
120        }
121    }
122}
123
124/// The resolver's phase: who (if anyone) currently holds the ball and what
125/// follow-up we are waiting on.
126#[derive(Debug, Clone, PartialEq, Default)]
127enum ResolverPhase {
128    /// No one is credited; the open segment is neutral.
129    #[default]
130    Neutral,
131    /// One team touched a loose/neutral ball once but has not confirmed control.
132    /// The open segment is still neutral — an unconfirmed touch grants nothing.
133    Acquiring {
134        team_is_team_0: bool,
135        first_touch_time: f32,
136        first_touch_frame: usize,
137        player: Option<PlayerId>,
138    },
139    /// A team holds the ball; the open segment is that team's. `last_touch` is
140    /// the trailing edge a loss would be backdated to.
141    Held {
142        team_is_team_0: bool,
143        player: Option<PlayerId>,
144        last_touch_time: f32,
145        last_touch_frame: usize,
146    },
147    /// The holder still owns it, but an opponent has touched once. The open
148    /// segment is still the holder's; the contest resolves on the next touch.
149    HeldChallenged {
150        team_is_team_0: bool,
151        player: Option<PlayerId>,
152        held_last_touch_time: f32,
153        held_last_touch_frame: usize,
154        challenger_first_time: f32,
155        challenger_first_frame: usize,
156        challenger_player: Option<PlayerId>,
157    },
158}
159
160/// Resolves a touch timeline into possession segments with loss backdated to
161/// the loser's last touch (see [`POSSESSION_RESOLUTION_WINDOW_SECONDS`]). This
162/// is the source of truth for team `PossessionEvent`s and `PossessionStats`;
163/// the legacy eager fields on [`PossessionTracker`] still drive per-player
164/// possession.
165#[derive(Debug, Clone, PartialEq, Default)]
166pub(crate) struct PossessionResolver {
167    phase: ResolverPhase,
168    open_start_time: f32,
169    open_start_frame: usize,
170    newly_resolved: Vec<ResolvedPossession>,
171}
172
173impl PossessionResolver {
174    fn reset(&mut self) {
175        self.phase = ResolverPhase::Neutral;
176        self.open_start_time = 0.0;
177        self.open_start_frame = 0;
178        self.newly_resolved.clear();
179    }
180
181    /// Close the open segment at `(end_time, end_frame)` with `label`/`player`
182    /// and start a new open segment there.
183    fn finalize(
184        &mut self,
185        end_time: f32,
186        end_frame: usize,
187        label: PossessionLabel,
188        player: Option<PlayerId>,
189    ) {
190        if end_time > self.open_start_time {
191            self.newly_resolved.push(ResolvedPossession {
192                start_time: self.open_start_time,
193                start_frame: self.open_start_frame,
194                end_time,
195                end_frame,
196                label,
197                player,
198            });
199        }
200        self.open_start_time = end_time;
201        self.open_start_frame = end_frame;
202    }
203
204    fn within_window(now: f32, since: f32) -> bool {
205        now - since <= POSSESSION_RESOLUTION_WINDOW_SECONDS
206    }
207
208    fn touch_input(
209        touched_team_zero_player: &Option<PlayerId>,
210        touched_team_one_player: &Option<PlayerId>,
211        touched_team_zero: bool,
212        touched_team_one: bool,
213    ) -> TouchInput {
214        match (touched_team_zero, touched_team_one) {
215            (false, false) => TouchInput::None,
216            (true, false) => TouchInput::Single {
217                team_is_team_0: true,
218                player: touched_team_zero_player.clone(),
219            },
220            (false, true) => TouchInput::Single {
221                team_is_team_0: false,
222                player: touched_team_one_player.clone(),
223            },
224            (true, true) => TouchInput::Contested {
225                team_zero_player: touched_team_zero_player.clone(),
226                team_one_player: touched_team_one_player.clone(),
227            },
228        }
229    }
230
231    /// Advance the resolver one frame. Pushes any segments finalized this frame
232    /// onto `newly_resolved` (cleared at the start of every call).
233    fn update(
234        &mut self,
235        frame: &FrameInfo,
236        touched_team_zero_player: &Option<PlayerId>,
237        touched_team_one_player: &Option<PlayerId>,
238        touched_team_zero: bool,
239        touched_team_one: bool,
240    ) {
241        self.newly_resolved.clear();
242        let time = frame.time;
243        let fnum = frame.frame_number;
244        let input = Self::touch_input(
245            touched_team_zero_player,
246            touched_team_one_player,
247            touched_team_zero,
248            touched_team_one,
249        );
250
251        let phase = std::mem::take(&mut self.phase);
252        self.phase = match phase {
253            ResolverPhase::Neutral => self.step_neutral(time, fnum, input),
254            ResolverPhase::Acquiring {
255                team_is_team_0,
256                first_touch_time,
257                first_touch_frame,
258                player,
259            } => self.step_acquiring(
260                time,
261                fnum,
262                input,
263                team_is_team_0,
264                first_touch_time,
265                first_touch_frame,
266                player,
267            ),
268            ResolverPhase::Held {
269                team_is_team_0,
270                player,
271                last_touch_time,
272                last_touch_frame,
273            } => self.step_held(
274                time,
275                fnum,
276                input,
277                team_is_team_0,
278                player,
279                last_touch_time,
280                last_touch_frame,
281            ),
282            ResolverPhase::HeldChallenged {
283                team_is_team_0,
284                player,
285                held_last_touch_time,
286                held_last_touch_frame,
287                challenger_first_time,
288                challenger_first_frame,
289                challenger_player,
290            } => self.step_held_challenged(
291                time,
292                fnum,
293                input,
294                team_is_team_0,
295                player,
296                held_last_touch_time,
297                held_last_touch_frame,
298                challenger_first_time,
299                challenger_first_frame,
300                challenger_player,
301            ),
302        };
303    }
304
305    fn step_neutral(&mut self, time: f32, fnum: usize, input: TouchInput) -> ResolverPhase {
306        match input {
307            // A single touch on a neutral ball is provisional: it grants nothing
308            // until the same team confirms control with a follow-up.
309            TouchInput::Single {
310                team_is_team_0,
311                player,
312            } => ResolverPhase::Acquiring {
313                team_is_team_0,
314                first_touch_time: time,
315                first_touch_frame: fnum,
316                player,
317            },
318            // A contested neutral ball stays neutral until someone gets clear of it.
319            TouchInput::None | TouchInput::Contested { .. } => ResolverPhase::Neutral,
320        }
321    }
322
323    #[allow(clippy::too_many_arguments)]
324    fn step_acquiring(
325        &mut self,
326        time: f32,
327        fnum: usize,
328        input: TouchInput,
329        team_is_team_0: bool,
330        first_touch_time: f32,
331        first_touch_frame: usize,
332        player: Option<PlayerId>,
333    ) -> ResolverPhase {
334        let still_acquiring = ResolverPhase::Acquiring {
335            team_is_team_0,
336            first_touch_time,
337            first_touch_frame,
338            player: player.clone(),
339        };
340        match input {
341            TouchInput::None => {
342                if Self::within_window(time, first_touch_time) {
343                    still_acquiring
344                } else {
345                    // The lone touch was a deflection; the ball stays neutral.
346                    ResolverPhase::Neutral
347                }
348            }
349            TouchInput::Single {
350                team_is_team_0: touch_team,
351                player: touch_player,
352            } => {
353                if touch_team == team_is_team_0 {
354                    // Confirmed: credit the team from their first touch. The open
355                    // neutral segment ends there; a held segment opens.
356                    self.finalize(
357                        first_touch_time,
358                        first_touch_frame,
359                        PossessionLabel::Neutral,
360                        None,
361                    );
362                    ResolverPhase::Held {
363                        team_is_team_0,
364                        player: touch_player.or(player),
365                        last_touch_time: time,
366                        last_touch_frame: fnum,
367                    }
368                } else {
369                    // The other team got the latest single touch; track them now.
370                    ResolverPhase::Acquiring {
371                        team_is_team_0: touch_team,
372                        first_touch_time: time,
373                        first_touch_frame: fnum,
374                        player: touch_player,
375                    }
376                }
377            }
378            // Contested while acquiring: still nobody in clear control.
379            TouchInput::Contested { .. } => still_acquiring,
380        }
381    }
382
383    #[allow(clippy::too_many_arguments)]
384    fn step_held(
385        &mut self,
386        time: f32,
387        fnum: usize,
388        input: TouchInput,
389        team_is_team_0: bool,
390        player: Option<PlayerId>,
391        last_touch_time: f32,
392        last_touch_frame: usize,
393    ) -> ResolverPhase {
394        match input {
395            TouchInput::None => {
396                if Self::within_window(time, last_touch_time) {
397                    ResolverPhase::Held {
398                        team_is_team_0,
399                        player,
400                        last_touch_time,
401                        last_touch_frame,
402                    }
403                } else {
404                    // No follow-up came: possession ends at the last touch and the
405                    // loose tail is neutral.
406                    self.finalize(
407                        last_touch_time,
408                        last_touch_frame,
409                        PossessionLabel::team(team_is_team_0),
410                        player,
411                    );
412                    ResolverPhase::Neutral
413                }
414            }
415            TouchInput::Single {
416                team_is_team_0: touch_team,
417                player: touch_player,
418            } => {
419                if touch_team == team_is_team_0 {
420                    // Same team re-touches: the tail since the last touch is kept,
421                    // the trailing edge advances.
422                    ResolverPhase::Held {
423                        team_is_team_0,
424                        player: touch_player.or(player),
425                        last_touch_time: time,
426                        last_touch_frame: fnum,
427                    }
428                } else {
429                    ResolverPhase::HeldChallenged {
430                        team_is_team_0,
431                        player,
432                        held_last_touch_time: last_touch_time,
433                        held_last_touch_frame: last_touch_frame,
434                        challenger_first_time: time,
435                        challenger_first_frame: fnum,
436                        challenger_player: touch_player,
437                    }
438                }
439            }
440            TouchInput::Contested { .. } => {
441                // Owner and opponent both touched: treat as a fresh challenge,
442                // with the owner's trailing edge advanced to this frame.
443                ResolverPhase::HeldChallenged {
444                    team_is_team_0,
445                    player: input.owner_player(team_is_team_0).or(player),
446                    held_last_touch_time: time,
447                    held_last_touch_frame: fnum,
448                    challenger_first_time: time,
449                    challenger_first_frame: fnum,
450                    challenger_player: input.opponent_player(team_is_team_0),
451                }
452            }
453        }
454    }
455
456    #[allow(clippy::too_many_arguments)]
457    fn step_held_challenged(
458        &mut self,
459        time: f32,
460        fnum: usize,
461        input: TouchInput,
462        team_is_team_0: bool,
463        player: Option<PlayerId>,
464        held_last_touch_time: f32,
465        held_last_touch_frame: usize,
466        challenger_first_time: f32,
467        challenger_first_frame: usize,
468        challenger_player: Option<PlayerId>,
469    ) -> ResolverPhase {
470        let confirm_turnover = |resolver: &mut Self, new_player: Option<PlayerId>| {
471            // The holder's credit ends at their last touch; the loose gap up to
472            // the challenger's first touch is neutral; the challenger is credited
473            // from that first touch.
474            resolver.finalize(
475                held_last_touch_time,
476                held_last_touch_frame,
477                PossessionLabel::team(team_is_team_0),
478                player.clone(),
479            );
480            resolver.finalize(
481                challenger_first_time,
482                challenger_first_frame,
483                PossessionLabel::Neutral,
484                None,
485            );
486            ResolverPhase::Held {
487                team_is_team_0: !team_is_team_0,
488                player: new_player.or_else(|| challenger_player.clone()),
489                last_touch_time: time,
490                last_touch_frame: fnum,
491            }
492        };
493
494        match input {
495            TouchInput::None => {
496                if Self::within_window(time, challenger_first_time) {
497                    ResolverPhase::HeldChallenged {
498                        team_is_team_0,
499                        player,
500                        held_last_touch_time,
501                        held_last_touch_frame,
502                        challenger_first_time,
503                        challenger_first_frame,
504                        challenger_player,
505                    }
506                } else {
507                    // Neither side followed up: the contested ball was loose.
508                    // Backdate the holder's loss to their last touch.
509                    self.finalize(
510                        held_last_touch_time,
511                        held_last_touch_frame,
512                        PossessionLabel::team(team_is_team_0),
513                        player,
514                    );
515                    ResolverPhase::Neutral
516                }
517            }
518            TouchInput::Single {
519                team_is_team_0: touch_team,
520                player: touch_player,
521            } => {
522                if touch_team == team_is_team_0 {
523                    // Holder repelled the challenge: the challenger's lone touch
524                    // never confirmed, so it grants nothing and does not break the
525                    // holder's possession. The hold stays continuous through the
526                    // poke (loss is only backdated when the holder is genuinely
527                    // dispossessed, i.e. the opponent confirms or no follow-up
528                    // comes).
529                    ResolverPhase::Held {
530                        team_is_team_0,
531                        player: touch_player.or(player),
532                        last_touch_time: time,
533                        last_touch_frame: fnum,
534                    }
535                } else {
536                    confirm_turnover(self, touch_player)
537                }
538            }
539            TouchInput::Contested { .. } => {
540                confirm_turnover(self, input.opponent_player(team_is_team_0))
541            }
542        }
543    }
544
545    /// The current open (unresolved) segment, or `None` when neutral with no
546    /// accumulated time.
547    fn open(&self) -> OpenPossession {
548        let (label, player) = match &self.phase {
549            ResolverPhase::Neutral | ResolverPhase::Acquiring { .. } => {
550                (PossessionLabel::Neutral, None)
551            }
552            ResolverPhase::Held {
553                team_is_team_0,
554                player,
555                ..
556            }
557            | ResolverPhase::HeldChallenged {
558                team_is_team_0,
559                player,
560                ..
561            } => (PossessionLabel::team(*team_is_team_0), player.clone()),
562        };
563        OpenPossession {
564            start_time: self.open_start_time,
565            start_frame: self.open_start_frame,
566            label,
567            player,
568        }
569    }
570
571    /// Flush the open segment as resolved, backdating any held tail to the last
572    /// touch (the follow-up never came). Used when live play ends.
573    fn flush(&mut self, end_time: f32, end_frame: usize) {
574        let phase = std::mem::take(&mut self.phase);
575        match phase {
576            ResolverPhase::Neutral | ResolverPhase::Acquiring { .. } => {
577                self.finalize(end_time, end_frame, PossessionLabel::Neutral, None);
578            }
579            ResolverPhase::Held {
580                team_is_team_0,
581                player,
582                last_touch_time,
583                last_touch_frame,
584            } => {
585                self.finalize(
586                    last_touch_time,
587                    last_touch_frame,
588                    PossessionLabel::team(team_is_team_0),
589                    player,
590                );
591                self.finalize(end_time, end_frame, PossessionLabel::Neutral, None);
592            }
593            ResolverPhase::HeldChallenged {
594                team_is_team_0,
595                player,
596                held_last_touch_time,
597                held_last_touch_frame,
598                ..
599            } => {
600                self.finalize(
601                    held_last_touch_time,
602                    held_last_touch_frame,
603                    PossessionLabel::team(team_is_team_0),
604                    player,
605                );
606                self.finalize(end_time, end_frame, PossessionLabel::Neutral, None);
607            }
608        }
609        self.phase = ResolverPhase::Neutral;
610    }
611}
612
613/// A team-possession span.
614#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
615#[ts(export)]
616pub struct PossessionEvent {
617    pub time: f32,
618    pub frame: usize,
619    pub end_time: f32,
620    pub end_frame: usize,
621    pub active: bool,
622    pub duration: f32,
623    pub possession_state: String,
624    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
625    pub player_id: Option<PlayerId>,
626}
627
628#[derive(Debug, Clone, Default, PartialEq)]
629pub(crate) struct PossessionTracker {
630    current_team_is_team_0: Option<bool>,
631    current_player: Option<PlayerId>,
632    last_possession_touch_time: Option<f32>,
633    pending_turnover_team_is_team_0: Option<bool>,
634    pending_turnover_touch_time: Option<f32>,
635    /// Backdating resolver; source of truth for team possession segments. The
636    /// eager fields above remain the source for per-player possession.
637    resolver: PossessionResolver,
638}
639
640impl PossessionTracker {
641    fn clear_pending_turnover(&mut self) {
642        self.pending_turnover_team_is_team_0 = None;
643        self.pending_turnover_touch_time = None;
644    }
645
646    pub(crate) fn reset(&mut self) {
647        self.current_team_is_team_0 = None;
648        self.current_player = None;
649        self.last_possession_touch_time = None;
650        self.clear_pending_turnover();
651    }
652
653    /// Begin a fresh resolver run at the start of a live-play stretch.
654    pub(crate) fn begin_resolver(&mut self, frame: &FrameInfo) {
655        self.resolver.reset();
656        self.resolver.open_start_time = frame.time;
657        self.resolver.open_start_frame = frame.frame_number;
658    }
659
660    /// Flush the resolver's open segment when live play ends, returning the
661    /// segments finalized by the flush.
662    pub(crate) fn flush_resolver(&mut self, frame: &FrameInfo) -> Vec<ResolvedPossession> {
663        self.resolver.newly_resolved.clear();
664        self.resolver.flush(frame.time, frame.frame_number);
665        std::mem::take(&mut self.resolver.newly_resolved)
666    }
667
668    fn expire_pending_turnover(&mut self, time: f32) {
669        let Some(pending_time) = self.pending_turnover_touch_time else {
670            return;
671        };
672        if time - pending_time < PENDING_TURNOVER_CONFIRMATION_WINDOW_SECONDS {
673            return;
674        }
675
676        self.current_team_is_team_0 = None;
677        self.current_player = None;
678        self.last_possession_touch_time = None;
679        self.clear_pending_turnover();
680    }
681
682    fn expire_loose_ball(&mut self, time: f32) {
683        if self.pending_turnover_team_is_team_0.is_some() {
684            return;
685        }
686        let Some(last_touch_time) = self.last_possession_touch_time else {
687            return;
688        };
689        if time - last_touch_time < LOOSE_BALL_TIMEOUT_SECONDS {
690            return;
691        }
692
693        self.current_team_is_team_0 = None;
694        self.current_player = None;
695        self.last_possession_touch_time = None;
696    }
697
698    fn register_single_team_touch(&mut self, team_is_team_0: bool, time: f32) {
699        if self.current_team_is_team_0 == Some(team_is_team_0) {
700            self.last_possession_touch_time = Some(time);
701            self.clear_pending_turnover();
702            return;
703        }
704
705        if self.current_team_is_team_0.is_none() {
706            self.current_team_is_team_0 = Some(team_is_team_0);
707            self.last_possession_touch_time = Some(time);
708            self.clear_pending_turnover();
709            return;
710        }
711
712        if self.pending_turnover_team_is_team_0 == Some(team_is_team_0) {
713            self.current_team_is_team_0 = Some(team_is_team_0);
714            self.last_possession_touch_time = Some(time);
715            self.clear_pending_turnover();
716            return;
717        }
718
719        self.pending_turnover_team_is_team_0 = Some(team_is_team_0);
720        self.pending_turnover_touch_time = Some(time);
721    }
722
723    fn register_contested_touch(&mut self, time: f32) {
724        let Some(current_team_is_team_0) = self.current_team_is_team_0 else {
725            self.clear_pending_turnover();
726            return;
727        };
728
729        self.last_possession_touch_time = Some(time);
730        self.pending_turnover_team_is_team_0 = Some(!current_team_is_team_0);
731        self.pending_turnover_touch_time = Some(time);
732    }
733
734    fn update_player_control(
735        &mut self,
736        active_team_before_sample: Option<bool>,
737        touched_team_zero_player: Option<&PlayerId>,
738        touched_team_one_player: Option<&PlayerId>,
739    ) {
740        let Some(current_team_is_team_0) = self.current_team_is_team_0 else {
741            self.current_player = None;
742            return;
743        };
744
745        if self.pending_turnover_team_is_team_0.is_some() {
746            self.current_player = None;
747            return;
748        }
749
750        let controlling_touch_player = if current_team_is_team_0 {
751            touched_team_zero_player
752        } else {
753            touched_team_one_player
754        };
755        if let Some(player) = controlling_touch_player {
756            self.current_player = Some(player.clone());
757            return;
758        }
759
760        if active_team_before_sample != self.current_team_is_team_0 {
761            self.current_player = None;
762        }
763    }
764
765    fn latest_touch_player_for_team(
766        touch_events: &[TouchEvent],
767        team_is_team_0: bool,
768    ) -> Option<PlayerId> {
769        touch_events
770            .iter()
771            .filter(|touch| touch.team_is_team_0 == team_is_team_0)
772            .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))
773            .and_then(|touch| touch.player.clone())
774    }
775
776    pub(crate) fn update(
777        &mut self,
778        frame: &FrameInfo,
779        touch_events: &[TouchEvent],
780    ) -> PossessionState {
781        let time = frame.time;
782        self.expire_pending_turnover(time);
783        self.expire_loose_ball(time);
784
785        let active_team_before_sample = self.current_team_is_team_0;
786        let active_player_before_sample = self.current_player.clone();
787        let touched_team_zero = touch_events.iter().any(|touch| touch.team_is_team_0);
788        let touched_team_one = touch_events.iter().any(|touch| !touch.team_is_team_0);
789        let touched_team_zero_player = Self::latest_touch_player_for_team(touch_events, true);
790        let touched_team_one_player = Self::latest_touch_player_for_team(touch_events, false);
791
792        match (touched_team_zero, touched_team_one) {
793            (true, true) => self.register_contested_touch(time),
794            (true, false) => self.register_single_team_touch(true, time),
795            (false, true) => self.register_single_team_touch(false, time),
796            (false, false) => {}
797        }
798        self.update_player_control(
799            active_team_before_sample,
800            touched_team_zero_player.as_ref(),
801            touched_team_one_player.as_ref(),
802        );
803
804        self.resolver.update(
805            frame,
806            &touched_team_zero_player,
807            &touched_team_one_player,
808            touched_team_zero,
809            touched_team_one,
810        );
811
812        PossessionState {
813            active_team_before_sample,
814            current_team_is_team_0: self.current_team_is_team_0,
815            active_player_before_sample,
816            current_player: self.current_player.clone(),
817            newly_resolved: std::mem::take(&mut self.resolver.newly_resolved),
818            open_possession: Some(self.resolver.open()),
819        }
820    }
821}
822
823#[cfg(test)]
824#[path = "possession_tests.rs"]
825mod tests;
826
827/// Builds the team-possession event stream from the backdating resolver's
828/// finalized segments.
829///
830/// Finalized [`ResolvedPossession`] segments (active spans) are emitted as
831/// `PossessionEvent`s as soon as the resolver decides them; non-live stretches
832/// are emitted as coalesced inactive markers so the stream stays contiguous.
833/// The in-progress open segment is exposed via [`Self::current_event`] /
834/// [`Self::projected_events`] for display and goal tagging.
835#[derive(Debug, Clone, Default, PartialEq)]
836pub struct PossessionCalculator {
837    events: EventStream<PossessionEvent>,
838    /// Segments finalized this frame, surfaced for the stats projection's
839    /// deferred per-frame accumulation.
840    new_resolved: Vec<ResolvedPossession>,
841    /// The current open (unresolved) segment rendered up to the latest frame.
842    open_event: Option<PossessionEvent>,
843    /// Coalesced inactive (non-live) marker awaiting flush.
844    inactive_pending: Option<PossessionEvent>,
845}
846
847impl PossessionCalculator {
848    pub fn new() -> Self {
849        Self::default()
850    }
851
852    pub fn events(&self) -> &[PossessionEvent] {
853        self.events.all()
854    }
855
856    pub fn new_events(&self) -> &[PossessionEvent] {
857        self.events.new_events()
858    }
859
860    /// All committed events plus the in-progress open/inactive span. Used by
861    /// goal tagging to walk the possession that led to a goal.
862    pub fn projected_events(&self) -> Vec<PossessionEvent> {
863        let mut events = self.events.all().to_vec();
864        if let Some(open) = &self.open_event {
865            events.push(open.clone());
866        }
867        if let Some(inactive) = &self.inactive_pending {
868            events.push(inactive.clone());
869        }
870        events
871    }
872
873    /// Segments the resolver finalized on the most recent frame.
874    pub(crate) fn new_resolved(&self) -> &[ResolvedPossession] {
875        &self.new_resolved
876    }
877
878    pub fn flush_pending_event(&mut self) {
879        if let Some(inactive) = self.inactive_pending.take() {
880            self.events.push(inactive);
881        }
882        if let Some(open) = self.open_event.take() {
883            self.events.push(open);
884        }
885    }
886
887    /// The span covering the most recently processed frame (the open segment if
888    /// live, else the last committed event).
889    pub fn current_event(&self) -> Option<&PossessionEvent> {
890        self.open_event
891            .as_ref()
892            .or(self.inactive_pending.as_ref())
893            .or_else(|| self.events.all().last())
894    }
895
896    fn segment_event(segment: &ResolvedPossession) -> PossessionEvent {
897        PossessionEvent {
898            time: segment.start_time,
899            frame: segment.start_frame,
900            end_time: segment.end_time,
901            end_frame: segment.end_frame,
902            active: true,
903            duration: (segment.end_time - segment.start_time).max(0.0),
904            possession_state: segment.label.as_label_value().to_owned(),
905            player_id: segment.player.clone(),
906        }
907    }
908
909    fn flush_inactive(&mut self) {
910        if let Some(inactive) = self.inactive_pending.take() {
911            self.events.push(inactive);
912        }
913    }
914
915    pub fn update(
916        &mut self,
917        frame: &FrameInfo,
918        possession_state: &PossessionState,
919        live_play_state: &LivePlayState,
920    ) -> SubtrActorResult<()> {
921        self.events.begin_update();
922        self.new_resolved.clear();
923
924        // Commit segments the resolver finalized this frame. On the live→non-live
925        // edge these are the flushed tail of the just-ended stretch, so they must
926        // precede any inactive marker for this frame.
927        if !possession_state.newly_resolved.is_empty() {
928            self.flush_inactive();
929            for segment in &possession_state.newly_resolved {
930                self.events.push(Self::segment_event(segment));
931                self.new_resolved.push(segment.clone());
932            }
933        }
934
935        if !live_play_state.is_live_play {
936            self.open_event = None;
937            match self.inactive_pending.as_mut() {
938                Some(inactive) => {
939                    inactive.end_time = frame.time;
940                    inactive.end_frame = frame.frame_number;
941                    inactive.duration = (inactive.end_time - inactive.time).max(0.0);
942                }
943                None => {
944                    self.inactive_pending = Some(PossessionEvent {
945                        time: frame.time,
946                        frame: frame.frame_number,
947                        end_time: frame.time,
948                        end_frame: frame.frame_number,
949                        active: false,
950                        duration: 0.0,
951                        possession_state: PossessionLabel::Neutral.as_label_value().to_owned(),
952                        player_id: None,
953                    });
954                }
955            }
956            return Ok(());
957        }
958
959        self.flush_inactive();
960        self.open_event = possession_state.open_possession.as_ref().map(|open| {
961            let label = open.label.as_label_value().to_owned();
962            PossessionEvent {
963                time: open.start_time,
964                frame: open.start_frame,
965                end_time: frame.time,
966                end_frame: frame.frame_number,
967                active: true,
968                duration: (frame.time - open.start_time).max(0.0),
969                possession_state: label,
970                player_id: open.player.clone(),
971            }
972        });
973        Ok(())
974    }
975}