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