Skip to main content

subtr_actor/stats/calculators/
controlled_play.rs

1use super::*;
2
3const DISTINCT_TOUCH_GAP_SECONDS: f32 = 0.12;
4const MAX_TOUCH_CHAIN_GAP_SECONDS: f32 = 2.50;
5// Shared with the player_possession span stream's `sustained_control` label so
6// the two notions of "deliberate on-ball play" stay in lockstep; the plan is
7// for controlled_play to eventually become a projection of labeled
8// player_possession spans.
9pub(crate) const CLOSE_DISTANCE_3D: f32 = 700.0;
10pub(crate) const MIN_CLOSE_DURATION_SECONDS: f32 = 0.75;
11pub(crate) const MIN_EPISODE_DURATION_SECONDS: f32 = 1.00;
12pub(crate) const MIN_FIRST_TO_LAST_TOUCH_DURATION_SECONDS: f32 = 1.00;
13pub(crate) const MIN_TOUCHES: u32 = 2;
14
15/// A span of sustained controlled play with ball-progress metrics.
16#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
17#[ts(export)]
18pub struct ControlledPlayEvent {
19    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
20    pub player_id: PlayerId,
21    pub is_team_0: bool,
22    pub start_frame: usize,
23    pub end_frame: usize,
24    pub start_time: f32,
25    pub end_time: f32,
26    pub duration: f32,
27    pub first_touch_frame: usize,
28    pub last_touch_frame: usize,
29    pub first_touch_time: f32,
30    pub last_touch_time: f32,
31    pub touch_count: u32,
32    pub close_duration: f32,
33    pub total_advance_distance: f32,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37struct ActiveControlledPlay {
38    player_id: PlayerId,
39    is_team_0: bool,
40    start_frame: usize,
41    end_frame: usize,
42    start_time: f32,
43    end_time: f32,
44    first_touch_frame: usize,
45    last_touch_frame: usize,
46    first_touch_time: f32,
47    last_touch_time: f32,
48    touch_count: u32,
49    close_duration: f32,
50    total_advance_distance: f32,
51}
52
53impl ActiveControlledPlay {
54    fn from_touch(touch: &TouchEvent, player_id: PlayerId) -> Self {
55        Self {
56            player_id,
57            is_team_0: touch.team_is_team_0,
58            start_frame: touch.frame,
59            end_frame: touch.frame,
60            start_time: touch.time,
61            end_time: touch.time,
62            first_touch_frame: touch.frame,
63            last_touch_frame: touch.frame,
64            first_touch_time: touch.time,
65            last_touch_time: touch.time,
66            touch_count: 1,
67            close_duration: 0.0,
68            total_advance_distance: 0.0,
69        }
70    }
71
72    fn record_touch(&mut self, touch: &TouchEvent) {
73        if touch.time - self.last_touch_time < DISTINCT_TOUCH_GAP_SECONDS {
74            return;
75        }
76
77        self.touch_count += 1;
78        self.last_touch_frame = touch.frame;
79        self.last_touch_time = touch.time;
80        self.extend_to(touch.frame, touch.time);
81    }
82
83    fn extend_to(&mut self, frame: usize, time: f32) {
84        if time < self.end_time || (time == self.end_time && frame < self.end_frame) {
85            return;
86        }
87        self.end_frame = frame;
88        self.end_time = time;
89    }
90
91    fn duration(&self) -> f32 {
92        (self.end_time - self.start_time).max(0.0)
93    }
94
95    fn touch_span(&self) -> f32 {
96        (self.last_touch_time - self.first_touch_time).max(0.0)
97    }
98
99    fn is_valid(&self) -> bool {
100        self.touch_count >= MIN_TOUCHES
101            && self.duration() >= MIN_EPISODE_DURATION_SECONDS
102            && self.touch_span() >= MIN_FIRST_TO_LAST_TOUCH_DURATION_SECONDS
103            && self.close_duration >= MIN_CLOSE_DURATION_SECONDS
104    }
105
106    fn into_event(self) -> ControlledPlayEvent {
107        let duration = self.duration();
108        ControlledPlayEvent {
109            player_id: self.player_id,
110            is_team_0: self.is_team_0,
111            start_frame: self.start_frame,
112            end_frame: self.end_frame,
113            start_time: self.start_time,
114            end_time: self.end_time,
115            duration,
116            first_touch_frame: self.first_touch_frame,
117            last_touch_frame: self.last_touch_frame,
118            first_touch_time: self.first_touch_time,
119            last_touch_time: self.last_touch_time,
120            touch_count: self.touch_count,
121            close_duration: self.close_duration,
122            total_advance_distance: self.total_advance_distance,
123        }
124    }
125}
126
127impl InFlightItem for ActiveControlledPlay {
128    fn recognition(&self) -> Recognition {
129        // Speculative until the chain accumulates enough to be a real
130        // controlled play; only then does it count as having "happened".
131        Recognition::new(self.start_time, self.start_frame, self.is_valid())
132    }
133
134    fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
135        if self.is_valid() {
136            Disposition::Finalize(FinalizeReason::Boundary(boundary))
137        } else {
138            Disposition::Discard
139        }
140    }
141}
142
143/// Detects stretches of controlled play from ball/player positions and touches.
144#[derive(Debug, Clone, Default, PartialEq)]
145pub struct ControlledPlayCalculator {
146    events: EventStream<ControlledPlayEvent>,
147    active: InFlightLedger<ActiveControlledPlay>,
148    previous_ball_position: Option<glam::Vec3>,
149}
150
151impl ControlledPlayCalculator {
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    pub fn events(&self) -> &[ControlledPlayEvent] {
157        self.events.all()
158    }
159
160    pub fn new_events(&self) -> &[ControlledPlayEvent] {
161        self.events.new_events()
162    }
163
164    /// Natural finalization (touch-chain gap or a superseding player): emit the
165    /// run if it qualifies, otherwise discard it.
166    fn finish_active(&mut self) {
167        let valid = self
168            .active
169            .in_flight()
170            .first()
171            .is_some_and(ActiveControlledPlay::is_valid);
172        if valid {
173            for (active, _reason) in self.active.finalize_all(FinalizeReason::Completed) {
174                self.events.push(active.into_event());
175            }
176        } else {
177            self.active.clear();
178        }
179    }
180
181    /// Resolve any in-flight run against a game-flow boundary, emitting it only
182    /// if it qualifies (handled uniformly via the ledger).
183    fn finish_active_at_boundary(&mut self, boundary: Boundary) {
184        for (active, _reason) in self.active.apply_boundary(boundary) {
185            self.events.push(active.into_event());
186        }
187    }
188
189    fn player_is_close(
190        players: &PlayerFrameState,
191        ball_position: glam::Vec3,
192        player_id: &PlayerId,
193    ) -> bool {
194        players
195            .player(player_id)
196            .and_then(PlayerSample::position)
197            .is_some_and(|player_position| {
198                player_position.distance(ball_position) <= CLOSE_DISTANCE_3D
199            })
200    }
201
202    fn apply_frame_sample(
203        &mut self,
204        frame: &FrameInfo,
205        ball_position: Option<glam::Vec3>,
206        players: &PlayerFrameState,
207    ) {
208        let Some(active) = self.active.in_flight_mut().first_mut() else {
209            self.previous_ball_position = ball_position;
210            return;
211        };
212        let Some(ball_position) = ball_position else {
213            self.previous_ball_position = None;
214            return;
215        };
216
217        if Self::player_is_close(players, ball_position, &active.player_id) {
218            active.close_duration += frame.dt.max(0.0);
219        }
220
221        if let Some(previous_ball_position) = self.previous_ball_position {
222            let team_forward_sign = if active.is_team_0 { 1.0 } else { -1.0 };
223            let advance_distance = (ball_position.y - previous_ball_position.y) * team_forward_sign;
224            active.total_advance_distance += advance_distance.max(0.0);
225        }
226        active.extend_to(frame.frame_number, frame.time);
227        self.previous_ball_position = Some(ball_position);
228    }
229
230    fn expire_stale_candidate(&mut self, frame: &FrameInfo) {
231        let Some(active) = self.active.in_flight().first() else {
232            return;
233        };
234        if frame.time - active.last_touch_time > MAX_TOUCH_CHAIN_GAP_SECONDS {
235            self.finish_active();
236        }
237    }
238
239    fn apply_touch(&mut self, touch: &TouchEvent) {
240        let Some(player_id) = touch.player.clone() else {
241            return;
242        };
243
244        let same_player = self
245            .active
246            .in_flight()
247            .first()
248            .is_some_and(|active| active.player_id == player_id);
249        if same_player {
250            if let Some(active) = self.active.in_flight_mut().first_mut() {
251                active.record_touch(touch);
252            }
253            return;
254        }
255
256        self.finish_active();
257        self.active
258            .arm(ActiveControlledPlay::from_touch(touch, player_id));
259    }
260
261    pub fn update(
262        &mut self,
263        frame: &FrameInfo,
264        ball: &BallFrameState,
265        players: &PlayerFrameState,
266        touch_state: &TouchState,
267        live_play_state: &LivePlayState,
268    ) -> SubtrActorResult<()> {
269        self.events.begin_update();
270        if !live_play_state.is_live_play {
271            self.finish_active_at_boundary(Boundary::LivePlayEnded);
272            self.previous_ball_position = ball.position();
273            return Ok(());
274        }
275
276        self.expire_stale_candidate(frame);
277        self.apply_frame_sample(frame, ball.position(), players);
278        for touch in chronological_touch_events(&touch_state.touch_events) {
279            self.apply_touch(touch);
280        }
281
282        Ok(())
283    }
284
285    pub fn finish(&mut self) {
286        self.finish_active_at_boundary(Boundary::ReplayEnded);
287    }
288}
289
290#[cfg(test)]
291#[path = "controlled_play_tests.rs"]
292mod tests;