Skip to main content

subtr_actor/stats/calculators/
player_possession.rs

1use super::*;
2
3/// How long a player's possession may be interrupted (pending turnover,
4/// contested challenge, brief neutral window) before the span is finalized
5/// instead of resumed when the same player re-establishes control.
6const PLAYER_POSSESSION_MERGE_GAP_SECONDS: f32 = 2.0;
7/// Minimum spacing between touches for them to count as distinct touches
8/// within a possession span (mirrors the controlled-play touch chaining).
9const DISTINCT_TOUCH_GAP_SECONDS: f32 = 0.12;
10/// Distinct touches a player must make for a span to count as possession at
11/// all. A single glancing contact — a kickoff poke the player never follows up
12/// on, say — is not possession, even though that player stayed the last to
13/// touch until an opponent took over. Consecutive touches are the primary
14/// possession signal; proximity is only a loose sanity bound (see
15/// `MAX_POSSESSION_BALL_DISTANCE`).
16const MIN_POSSESSION_TOUCHES: u32 = 2;
17/// A generous ceiling on how far the ball may drift from the holder before the
18/// span is treated as loose. Deliberately far looser than the controlled-play
19/// close radius (`controlled_play::CLOSE_DISTANCE_3D`, 700uu): proximity is not
20/// a primary signal, but once the ball is clearly gone the holder no longer
21/// has it, so the span suspends (and eventually expires) instead of riding the
22/// last touch until an opponent finally intervenes.
23const MAX_POSSESSION_BALL_DISTANCE: f32 = 2500.0;
24
25/// A contiguous single-player possession span, merged across field-third
26/// changes and brief contested interruptions, enriched with the touch, ball
27/// movement, and sustained-control activity that happened while the player
28/// owned the ball.
29#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
30#[ts(export)]
31pub struct PlayerPossessionEvent {
32    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
33    pub player_id: PlayerId,
34    pub is_team_0: bool,
35    pub start_frame: usize,
36    pub end_frame: usize,
37    pub start_time: f32,
38    pub end_time: f32,
39    /// Seconds the player actually held possession. Excludes contested gap
40    /// time inside a merged span and the loose tail after the player's final
41    /// touch (once the ball is hit away, the remaining flight time is nobody's
42    /// possession — mirroring how team possession backdates a loss to the last
43    /// touch), so it can be less than `end_time - start_time`.
44    pub duration: f32,
45    pub touch_count: u32,
46    pub aerial_touch_count: u32,
47    pub wall_touch_count: u32,
48    /// Ball travel toward the opponent goal while the player had possession (uu).
49    pub advance_distance: f32,
50    /// Ball travel toward the player's own goal while the player had possession (uu).
51    pub retreat_distance: f32,
52    /// Seconds of the span spent in a grounded ball-carry sample.
53    pub carry_time: f32,
54    /// Seconds of the span spent in an air-dribble sample.
55    pub air_dribble_time: f32,
56    pub carry_count: u32,
57    pub air_dribble_count: u32,
58    /// Seconds of the span the owner spent within close range of the ball
59    /// (same proximity signal as controlled play).
60    pub close_time: f32,
61    /// True when the span meets the controlled-play qualifying criteria
62    /// (touch count, possessed duration, first-to-last touch span, and close
63    /// time). Controlled play is conceptually a labeled subset of player
64    /// possession; this label lets consumers treat it that way.
65    pub sustained_control: bool,
66    pub start_field_third: Option<String>,
67    pub end_field_third: Option<String>,
68}
69
70/// The per-frame accumulators that only count while the ball is possessed.
71/// The running copy accrues every active frame; a snapshot is taken at each
72/// ball contact, and the snapshot is what the emitted event reports, so the
73/// provisional loose tail after the final touch is never credited.
74#[derive(Debug, Clone, Copy, PartialEq, Default)]
75struct PossessedTotals {
76    duration: f32,
77    close_time: f32,
78    advance_distance: f32,
79    retreat_distance: f32,
80    carry_time: f32,
81    air_dribble_time: f32,
82}
83
84#[derive(Debug, Clone, PartialEq)]
85struct ActivePlayerPossession {
86    player_id: PlayerId,
87    is_team_0: bool,
88    start_frame: usize,
89    end_frame: usize,
90    start_time: f32,
91    end_time: f32,
92    running: PossessedTotals,
93    /// [`Self::running`] as of the player's most recent ball contact.
94    at_last_touch: PossessedTotals,
95    touch_count: u32,
96    aerial_touch_count: u32,
97    wall_touch_count: u32,
98    first_touch_time: Option<f32>,
99    last_touch_time: Option<f32>,
100    carry_count: u32,
101    air_dribble_count: u32,
102    last_carry_kind: Option<BallCarryKind>,
103    start_field_third: Option<String>,
104    end_field_third: Option<String>,
105}
106
107impl ActivePlayerPossession {
108    fn open(
109        frame: &FrameInfo,
110        player_id: PlayerId,
111        is_team_0: bool,
112        field_third: Option<String>,
113    ) -> Self {
114        Self {
115            player_id,
116            is_team_0,
117            // The opening frame's dt is credited to the span, so the span
118            // window starts one frame earlier (mirrors continuous ball
119            // control sequences).
120            start_frame: frame.frame_number.saturating_sub(1),
121            end_frame: frame.frame_number,
122            start_time: (frame.time - frame.dt).max(0.0),
123            end_time: frame.time,
124            running: PossessedTotals::default(),
125            at_last_touch: PossessedTotals::default(),
126            touch_count: 0,
127            aerial_touch_count: 0,
128            wall_touch_count: 0,
129            first_touch_time: None,
130            last_touch_time: None,
131            carry_count: 0,
132            air_dribble_count: 0,
133            last_carry_kind: None,
134            start_field_third: field_third.clone(),
135            end_field_third: field_third,
136        }
137    }
138
139    fn record_frame(&mut self, frame: &FrameInfo, field_third: Option<String>) {
140        self.running.duration += frame.dt.max(0.0);
141        self.end_frame = frame.frame_number;
142        self.end_time = frame.time;
143        if field_third.is_some() {
144            self.end_field_third = field_third;
145        }
146    }
147
148    fn record_touch(&mut self, touch: &TouchEvent) {
149        // Any contact — even one too close to the previous touch to count as
150        // distinct — extends the possessed totals to this frame.
151        self.at_last_touch = self.running;
152        if self
153            .last_touch_time
154            .is_some_and(|last| touch.time - last < DISTINCT_TOUCH_GAP_SECONDS)
155        {
156            return;
157        }
158        if self.first_touch_time.is_none() {
159            self.first_touch_time = Some(touch.time);
160        }
161        self.last_touch_time = Some(touch.time);
162        self.touch_count += 1;
163        let Some(position) = touch.player_position.as_ref().map(vec_to_glam) else {
164            return;
165        };
166        if player_is_on_wall(position) {
167            self.wall_touch_count += 1;
168        } else if AirDribblePolicy::is_air_touch_position(position) {
169            self.aerial_touch_count += 1;
170        }
171    }
172
173    fn record_ball_movement(&mut self, previous_ball_y: f32, ball_y: f32) {
174        let team_forward_sign = if self.is_team_0 { 1.0 } else { -1.0 };
175        let advance = (ball_y - previous_ball_y) * team_forward_sign;
176        if advance >= 0.0 {
177            self.running.advance_distance += advance;
178        } else {
179            self.running.retreat_distance -= advance;
180        }
181    }
182
183    fn record_proximity_sample(
184        &mut self,
185        frame: &FrameInfo,
186        ball: &BallFrameState,
187        players: &PlayerFrameState,
188    ) {
189        let Some(ball_position) = ball.position() else {
190            return;
191        };
192        let close = players
193            .player(&self.player_id)
194            .and_then(PlayerSample::position)
195            .is_some_and(|player_position| {
196                player_position.distance(ball_position) <= controlled_play::CLOSE_DISTANCE_3D
197            });
198        if close {
199            self.running.close_time += frame.dt.max(0.0);
200        }
201    }
202
203    fn touch_span(&self) -> f32 {
204        match (self.first_touch_time, self.last_touch_time) {
205            (Some(first), Some(last)) => (last - first).max(0.0),
206            _ => 0.0,
207        }
208    }
209
210    /// Controlled play's qualifying criteria, applied to this span. Kept in
211    /// lockstep via the shared constants in `controlled_play`, and judged on
212    /// the same possessed totals the emitted event reports.
213    fn is_sustained_control(&self) -> bool {
214        self.touch_count >= controlled_play::MIN_TOUCHES
215            && self.at_last_touch.duration >= controlled_play::MIN_EPISODE_DURATION_SECONDS
216            && self.touch_span() >= controlled_play::MIN_FIRST_TO_LAST_TOUCH_DURATION_SECONDS
217            && self.at_last_touch.close_time >= controlled_play::MIN_CLOSE_DURATION_SECONDS
218    }
219
220    fn record_carry_sample(&mut self, frame: &FrameInfo, kind: Option<BallCarryKind>) {
221        if let Some(kind) = kind {
222            if self.last_carry_kind != Some(kind) {
223                match kind {
224                    BallCarryKind::Carry => self.carry_count += 1,
225                    BallCarryKind::AirDribble => self.air_dribble_count += 1,
226                }
227            }
228            match kind {
229                BallCarryKind::Carry => self.running.carry_time += frame.dt.max(0.0),
230                BallCarryKind::AirDribble => self.running.air_dribble_time += frame.dt.max(0.0),
231            }
232        }
233        self.last_carry_kind = kind;
234    }
235
236    fn into_event(self) -> PlayerPossessionEvent {
237        let sustained_control = self.is_sustained_control();
238        PlayerPossessionEvent {
239            player_id: self.player_id,
240            is_team_0: self.is_team_0,
241            start_frame: self.start_frame,
242            end_frame: self.end_frame,
243            start_time: self.start_time,
244            end_time: self.end_time,
245            duration: self.at_last_touch.duration,
246            touch_count: self.touch_count,
247            aerial_touch_count: self.aerial_touch_count,
248            wall_touch_count: self.wall_touch_count,
249            advance_distance: self.at_last_touch.advance_distance,
250            retreat_distance: self.at_last_touch.retreat_distance,
251            carry_time: self.at_last_touch.carry_time,
252            air_dribble_time: self.at_last_touch.air_dribble_time,
253            carry_count: self.carry_count,
254            air_dribble_count: self.air_dribble_count,
255            close_time: self.at_last_touch.close_time,
256            sustained_control,
257            start_field_third: self.start_field_third,
258            end_field_third: self.end_field_third,
259        }
260    }
261}
262
263/// Builds per-player possession spans from the shared possession tracker state.
264///
265/// The raw `possession` stream slices spans whenever the labeled state changes
266/// (including field-third moves) and drops the player during pending-turnover
267/// windows. This calculator instead emits one event per continuous stretch of
268/// player control, bridging contested interruptions shorter than
269/// `PLAYER_POSSESSION_MERGE_GAP_SECONDS`, so consumers get a stable
270/// "possession" unit for duration, touch, and ball-progress stats.
271#[derive(Debug, Clone, Default, PartialEq)]
272pub struct PlayerPossessionCalculator {
273    events: EventStream<PlayerPossessionEvent>,
274    active: Option<ActivePlayerPossession>,
275    suspended: Option<(ActivePlayerPossession, f32)>,
276    previous_ball_y: Option<f32>,
277}
278
279impl PlayerPossessionCalculator {
280    pub fn new() -> Self {
281        Self::default()
282    }
283
284    pub fn events(&self) -> &[PlayerPossessionEvent] {
285        self.events.all()
286    }
287
288    pub fn new_events(&self) -> &[PlayerPossessionEvent] {
289        self.events.new_events()
290    }
291
292    fn finalize(&mut self, span: ActivePlayerPossession) {
293        // Possession requires the holder to have actually played the ball more
294        // than once. A span finalizes only when an opponent takes over or the
295        // ball is lost, so this is the retroactive "there was never really any
296        // possession here" check: a lone touch is dropped, never emitted.
297        if span.touch_count < MIN_POSSESSION_TOUCHES {
298            return;
299        }
300        self.events.push(span.into_event());
301    }
302
303    fn finalize_all(&mut self) {
304        if let Some(active) = self.active.take() {
305            self.finalize(active);
306        }
307        if let Some((suspended, _)) = self.suspended.take() {
308            self.finalize(suspended);
309        }
310    }
311
312    fn expire_suspended(&mut self, time: f32) {
313        let expired = self.suspended.as_ref().is_some_and(|(_, suspended_at)| {
314            time - suspended_at > PLAYER_POSSESSION_MERGE_GAP_SECONDS
315        });
316        if expired {
317            if let Some((suspended, _)) = self.suspended.take() {
318                self.finalize(suspended);
319            }
320        }
321    }
322
323    fn field_third(ball: &BallFrameState) -> Option<String> {
324        ball.sample().map(|sample| {
325            BallThirdLabel::from_ball(sample)
326                .as_label_value()
327                .to_owned()
328        })
329    }
330
331    /// Demotes the touch-based holder to "no holder" when the ball has drifted
332    /// far from them. Possession is touch-led, so proximity is deliberately
333    /// loose — but once the ball is well out of reach the holder no longer has
334    /// it. Returning `None` makes the open span suspend (resumable if the same
335    /// player gets back to it within the merge gap) rather than ride the last
336    /// touch until an opponent finally intervenes.
337    fn holder_within_reach(
338        current_player: Option<PlayerId>,
339        ball: &BallFrameState,
340        players: &PlayerFrameState,
341    ) -> Option<PlayerId> {
342        let player_id = current_player?;
343        let within = match (
344            ball.position(),
345            players.player(&player_id).and_then(PlayerSample::position),
346        ) {
347            (Some(ball_position), Some(player_position)) => {
348                player_position.distance(ball_position) <= MAX_POSSESSION_BALL_DISTANCE
349            }
350            // Missing geometry: trust the touch-based holder rather than guess.
351            _ => true,
352        };
353        within.then_some(player_id)
354    }
355
356    fn carry_sample_kind(
357        player_id: &PlayerId,
358        ball: &BallFrameState,
359        players: &PlayerFrameState,
360    ) -> Option<BallCarryKind> {
361        let ball = ball.sample()?;
362        let player = players.player(player_id)?;
363        BallCarryCalculator::carry_frame_sample(player, ball).map(|sample| sample.kind)
364    }
365
366    pub fn update(
367        &mut self,
368        frame: &FrameInfo,
369        ball: &BallFrameState,
370        players: &PlayerFrameState,
371        possession_state: &PossessionState,
372        touch_state: &TouchState,
373        live_play_state: &LivePlayState,
374    ) -> SubtrActorResult<()> {
375        self.events.begin_update();
376        let ball_y = ball.position().map(|position| position.y);
377        if !live_play_state.is_live_play {
378            self.finalize_all();
379            self.previous_ball_y = ball_y;
380            return Ok(());
381        }
382
383        self.expire_suspended(frame.time);
384
385        let current_player =
386            Self::holder_within_reach(possession_state.current_player.clone(), ball, players);
387        let field_third = Self::field_third(ball);
388
389        if let Some(active) = self.active.as_ref() {
390            if current_player.as_ref() != Some(&active.player_id) {
391                let mut active = self.active.take().expect("active span checked above");
392                // A different player taking over ends the span outright; a
393                // neutral window only suspends it for possible resumption.
394                if current_player.is_some() {
395                    self.finalize(active);
396                } else {
397                    active.last_carry_kind = None;
398                    // The loose tail since the last touch was provisional; the
399                    // hold lapsed, so it is not possession even if the same
400                    // player re-establishes control and the span resumes.
401                    active.running = active.at_last_touch;
402                    self.suspended = Some((active, frame.time));
403                }
404            }
405        }
406
407        if self.active.is_none() {
408            if let Some(player_id) = current_player.clone() {
409                let resumes_suspended = self
410                    .suspended
411                    .as_ref()
412                    .is_some_and(|(suspended, _)| suspended.player_id == player_id);
413                if resumes_suspended {
414                    self.active = self.suspended.take().map(|(suspended, _)| suspended);
415                } else {
416                    if let Some((suspended, _)) = self.suspended.take() {
417                        self.finalize(suspended);
418                    }
419                    let is_team_0 = possession_state
420                        .current_team_is_team_0
421                        .or_else(|| players.player(&player_id).map(|player| player.is_team_0))
422                        .unwrap_or(true);
423                    self.active = Some(ActivePlayerPossession::open(
424                        frame,
425                        player_id,
426                        is_team_0,
427                        field_third.clone(),
428                    ));
429                }
430            }
431        }
432
433        let Some(active) = self.active.as_mut() else {
434            self.previous_ball_y = ball_y;
435            return Ok(());
436        };
437
438        active.record_frame(frame, field_third);
439        active.record_proximity_sample(frame, ball, players);
440        if let (Some(previous_ball_y), Some(ball_y)) = (self.previous_ball_y, ball_y) {
441            active.record_ball_movement(previous_ball_y, ball_y);
442        }
443        let carry_kind = Self::carry_sample_kind(&active.player_id, ball, players);
444        active.record_carry_sample(frame, carry_kind);
445        // Touches last: a touch snapshots the running possessed totals, which
446        // must already include this frame's samples.
447        for touch in touch_state.touch_events.iter() {
448            if touch.player.as_ref() == Some(&active.player_id) {
449                active.record_touch(touch);
450            }
451        }
452        self.previous_ball_y = ball_y;
453
454        Ok(())
455    }
456
457    pub fn finish(&mut self) {
458        self.finalize_all();
459    }
460}
461
462#[cfg(test)]
463#[path = "player_possession_tests.rs"]
464mod tests;