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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
7enum PossessionStateLabel {
8    TeamZero,
9    TeamOne,
10    #[default]
11    Neutral,
12}
13
14impl PossessionStateLabel {
15    fn as_label_value(self) -> &'static str {
16        match self {
17            Self::TeamZero => "team_zero",
18            Self::TeamOne => "team_one",
19            Self::Neutral => "neutral",
20        }
21    }
22
23    fn as_label(self) -> StatLabel {
24        StatLabel::new("possession_state", self.as_label_value())
25    }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[allow(clippy::enum_variant_names)]
30enum FieldThirdLabel {
31    TeamZeroThird,
32    NeutralThird,
33    TeamOneThird,
34}
35
36impl FieldThirdLabel {
37    fn from_ball(ball: &BallSample) -> Self {
38        let ball_y = ball.position().y;
39        if ball_y < -FIELD_ZONE_BOUNDARY_Y {
40            Self::TeamZeroThird
41        } else if ball_y > FIELD_ZONE_BOUNDARY_Y {
42            Self::TeamOneThird
43        } else {
44            Self::NeutralThird
45        }
46    }
47
48    fn as_label_value(self) -> &'static str {
49        match self {
50            Self::TeamZeroThird => "team_zero_third",
51            Self::NeutralThird => "neutral_third",
52            Self::TeamOneThird => "team_one_third",
53        }
54    }
55
56    fn as_label(self) -> StatLabel {
57        StatLabel::new("field_third", self.as_label_value())
58    }
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
62pub struct PossessionStats {
63    pub tracked_time: f32,
64    pub team_zero_time: f32,
65    pub team_one_time: f32,
66    pub neutral_time: f32,
67    #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
68    pub labeled_time: LabeledFloatSums,
69}
70
71impl PossessionStats {
72    pub fn team_zero_pct(&self) -> f32 {
73        if self.tracked_time == 0.0 {
74            0.0
75        } else {
76            self.team_zero_time * 100.0 / self.tracked_time
77        }
78    }
79
80    pub fn team_one_pct(&self) -> f32 {
81        if self.tracked_time == 0.0 {
82            0.0
83        } else {
84            self.team_one_time * 100.0 / self.tracked_time
85        }
86    }
87
88    pub fn neutral_pct(&self) -> f32 {
89        if self.tracked_time == 0.0 {
90            0.0
91        } else {
92            self.neutral_time * 100.0 / self.tracked_time
93        }
94    }
95
96    pub fn time_with_labels(&self, labels: &[StatLabel]) -> f32 {
97        self.labeled_time.sum_matching(labels)
98    }
99
100    pub fn for_team(&self, is_team_zero: bool) -> PossessionTeamStats {
101        let (possession_time, opponent_possession_time) = if is_team_zero {
102            (self.team_zero_time, self.team_one_time)
103        } else {
104            (self.team_one_time, self.team_zero_time)
105        };
106
107        let mut labeled_time = LabeledFloatSums::default();
108        for entry in &self.labeled_time.entries {
109            labeled_time.add(
110                entry
111                    .labels
112                    .iter()
113                    .map(|label| team_relative_possession_label(label, is_team_zero)),
114                entry.value,
115            );
116        }
117
118        PossessionTeamStats {
119            tracked_time: self.tracked_time,
120            possession_time,
121            opponent_possession_time,
122            neutral_time: self.neutral_time,
123            labeled_time,
124        }
125    }
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
129#[ts(export)]
130pub struct PossessionTeamStats {
131    pub tracked_time: f32,
132    pub possession_time: f32,
133    pub opponent_possession_time: f32,
134    pub neutral_time: f32,
135    #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
136    pub labeled_time: LabeledFloatSums,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
140#[ts(export)]
141pub struct PossessionEvent {
142    pub time: f32,
143    pub frame: usize,
144    pub active: bool,
145    pub possession_state: String,
146    pub field_third: Option<String>,
147}
148
149fn team_relative_possession_label(label: &StatLabel, is_team_zero: bool) -> StatLabel {
150    match (label.key, label.value) {
151        ("possession_state", "team_zero") => StatLabel::new(
152            "possession_state",
153            if is_team_zero { "own" } else { "opponent" },
154        ),
155        ("possession_state", "team_one") => StatLabel::new(
156            "possession_state",
157            if is_team_zero { "opponent" } else { "own" },
158        ),
159        ("field_third", "team_zero_third") => StatLabel::new(
160            "field_third",
161            if is_team_zero {
162                "defensive_third"
163            } else {
164                "offensive_third"
165            },
166        ),
167        ("field_third", "team_one_third") => StatLabel::new(
168            "field_third",
169            if is_team_zero {
170                "offensive_third"
171            } else {
172                "defensive_third"
173            },
174        ),
175        _ => label.clone(),
176    }
177}
178
179#[derive(Debug, Clone, Default, PartialEq)]
180pub(crate) struct PossessionTracker {
181    current_team_is_team_0: Option<bool>,
182    current_player: Option<PlayerId>,
183    last_possession_touch_time: Option<f32>,
184    pending_turnover_team_is_team_0: Option<bool>,
185    pending_turnover_touch_time: Option<f32>,
186}
187
188impl PossessionTracker {
189    fn clear_pending_turnover(&mut self) {
190        self.pending_turnover_team_is_team_0 = None;
191        self.pending_turnover_touch_time = None;
192    }
193
194    pub(crate) fn reset(&mut self) {
195        self.current_team_is_team_0 = None;
196        self.current_player = None;
197        self.last_possession_touch_time = None;
198        self.clear_pending_turnover();
199    }
200
201    fn expire_pending_turnover(&mut self, time: f32) {
202        let Some(pending_time) = self.pending_turnover_touch_time else {
203            return;
204        };
205        if time - pending_time < PENDING_TURNOVER_CONFIRMATION_WINDOW_SECONDS {
206            return;
207        }
208
209        self.current_team_is_team_0 = None;
210        self.current_player = None;
211        self.last_possession_touch_time = None;
212        self.clear_pending_turnover();
213    }
214
215    fn expire_loose_ball(&mut self, time: f32) {
216        if self.pending_turnover_team_is_team_0.is_some() {
217            return;
218        }
219        let Some(last_touch_time) = self.last_possession_touch_time else {
220            return;
221        };
222        if time - last_touch_time < LOOSE_BALL_TIMEOUT_SECONDS {
223            return;
224        }
225
226        self.current_team_is_team_0 = None;
227        self.current_player = None;
228        self.last_possession_touch_time = None;
229    }
230
231    fn register_single_team_touch(&mut self, team_is_team_0: bool, time: f32) {
232        if self.current_team_is_team_0 == Some(team_is_team_0) {
233            self.last_possession_touch_time = Some(time);
234            self.clear_pending_turnover();
235            return;
236        }
237
238        if self.current_team_is_team_0.is_none() {
239            self.current_team_is_team_0 = Some(team_is_team_0);
240            self.last_possession_touch_time = Some(time);
241            self.clear_pending_turnover();
242            return;
243        }
244
245        if self.pending_turnover_team_is_team_0 == Some(team_is_team_0) {
246            self.current_team_is_team_0 = Some(team_is_team_0);
247            self.last_possession_touch_time = Some(time);
248            self.clear_pending_turnover();
249            return;
250        }
251
252        self.pending_turnover_team_is_team_0 = Some(team_is_team_0);
253        self.pending_turnover_touch_time = Some(time);
254    }
255
256    fn register_contested_touch(&mut self, time: f32) {
257        let Some(current_team_is_team_0) = self.current_team_is_team_0 else {
258            self.clear_pending_turnover();
259            return;
260        };
261
262        self.last_possession_touch_time = Some(time);
263        self.pending_turnover_team_is_team_0 = Some(!current_team_is_team_0);
264        self.pending_turnover_touch_time = Some(time);
265    }
266
267    fn update_player_control(
268        &mut self,
269        active_team_before_sample: Option<bool>,
270        touched_team_zero_player: Option<&PlayerId>,
271        touched_team_one_player: Option<&PlayerId>,
272    ) {
273        let Some(current_team_is_team_0) = self.current_team_is_team_0 else {
274            self.current_player = None;
275            return;
276        };
277
278        if self.pending_turnover_team_is_team_0.is_some() {
279            self.current_player = None;
280            return;
281        }
282
283        let controlling_touch_player = if current_team_is_team_0 {
284            touched_team_zero_player
285        } else {
286            touched_team_one_player
287        };
288        if let Some(player) = controlling_touch_player {
289            self.current_player = Some(player.clone());
290            return;
291        }
292
293        if active_team_before_sample != self.current_team_is_team_0 {
294            self.current_player = None;
295        }
296    }
297
298    pub(crate) fn update(&mut self, time: f32, touch_events: &[TouchEvent]) -> PossessionState {
299        self.expire_pending_turnover(time);
300        self.expire_loose_ball(time);
301
302        let active_team_before_sample = self.current_team_is_team_0;
303        let active_player_before_sample = self.current_player.clone();
304        let touched_team_zero = touch_events.iter().any(|touch| touch.team_is_team_0);
305        let touched_team_one = touch_events.iter().any(|touch| !touch.team_is_team_0);
306        let touched_team_zero_player = touch_events
307            .iter()
308            .rev()
309            .find(|touch| touch.team_is_team_0)
310            .and_then(|touch| touch.player.clone());
311        let touched_team_one_player = touch_events
312            .iter()
313            .rev()
314            .find(|touch| !touch.team_is_team_0)
315            .and_then(|touch| touch.player.clone());
316
317        match (touched_team_zero, touched_team_one) {
318            (true, true) => self.register_contested_touch(time),
319            (true, false) => self.register_single_team_touch(true, time),
320            (false, true) => self.register_single_team_touch(false, time),
321            (false, false) => {}
322        }
323        self.update_player_control(
324            active_team_before_sample,
325            touched_team_zero_player.as_ref(),
326            touched_team_one_player.as_ref(),
327        );
328
329        PossessionState {
330            active_team_before_sample,
331            current_team_is_team_0: self.current_team_is_team_0,
332            active_player_before_sample,
333            current_player: self.current_player.clone(),
334        }
335    }
336}
337
338#[derive(Debug, Clone, Default, PartialEq)]
339pub struct PossessionCalculator {
340    stats: PossessionStats,
341    tracker: PossessionTracker,
342    events: Vec<PossessionEvent>,
343    last_emitted_event_state: Option<PossessionEventState>,
344}
345
346#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
347struct PossessionEventState {
348    active: bool,
349    possession_state: PossessionStateLabel,
350    field_third: Option<FieldThirdLabel>,
351}
352
353impl PossessionCalculator {
354    pub fn new() -> Self {
355        Self::default()
356    }
357
358    pub fn stats(&self) -> &PossessionStats {
359        &self.stats
360    }
361
362    pub fn events(&self) -> &[PossessionEvent] {
363        &self.events
364    }
365
366    fn apply_possession_time(
367        stats: &mut PossessionStats,
368        state: PossessionStateLabel,
369        field_third: Option<FieldThirdLabel>,
370        dt: f32,
371    ) {
372        match state {
373            PossessionStateLabel::TeamZero => stats.team_zero_time += dt,
374            PossessionStateLabel::TeamOne => stats.team_one_time += dt,
375            PossessionStateLabel::Neutral => stats.neutral_time += dt,
376        }
377        if let Some(field_third) = field_third {
378            stats
379                .labeled_time
380                .add([state.as_label(), field_third.as_label()], dt);
381        } else {
382            stats.labeled_time.add([state.as_label()], dt);
383        }
384    }
385
386    fn emit_event_if_changed(
387        &mut self,
388        frame: &FrameInfo,
389        active: bool,
390        possession_state: PossessionStateLabel,
391        field_third: Option<FieldThirdLabel>,
392    ) {
393        let event_state = PossessionEventState {
394            active,
395            possession_state,
396            field_third,
397        };
398        if self.last_emitted_event_state == Some(event_state) {
399            return;
400        }
401        self.events.push(PossessionEvent {
402            time: frame.time,
403            frame: frame.frame_number,
404            active,
405            possession_state: possession_state.as_label_value().to_owned(),
406            field_third: field_third.map(|label| label.as_label_value().to_owned()),
407        });
408        self.last_emitted_event_state = Some(event_state);
409    }
410
411    pub fn update(
412        &mut self,
413        frame: &FrameInfo,
414        ball: &BallFrameState,
415        possession_state: &PossessionState,
416        live_play_state: &LivePlayState,
417    ) -> SubtrActorResult<()> {
418        if !live_play_state.is_live_play {
419            self.emit_event_if_changed(frame, false, PossessionStateLabel::Neutral, None);
420            return Ok(());
421        }
422
423        self.stats.tracked_time += frame.dt;
424        let field_third = ball.sample().map(FieldThirdLabel::from_ball);
425        let state =
426            if let Some(possession_team_is_team_0) = possession_state.active_team_before_sample {
427                if possession_team_is_team_0 {
428                    PossessionStateLabel::TeamZero
429                } else {
430                    PossessionStateLabel::TeamOne
431                }
432            } else {
433                PossessionStateLabel::Neutral
434            };
435        Self::apply_possession_time(&mut self.stats, state, field_third, frame.dt);
436        self.emit_event_if_changed(frame, true, state, field_third);
437        Ok(())
438    }
439}