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}