1use super::possession::{OpenPossession, PossessionLabel, ResolvedPossession};
2use super::*;
3
4const LOOSE_RESOLUTION_WINDOW_SECONDS: f32 = 1.5;
11
12#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
25#[ts(export)]
26pub struct LoosePossessionEvent {
27 pub time: f32,
28 pub frame: usize,
29 pub end_time: f32,
30 pub end_frame: usize,
31 pub active: bool,
32 pub duration: f32,
33 pub possession_state: String,
34 #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
35 pub player_id: Option<PlayerId>,
36}
37
38#[derive(Debug, Clone)]
41enum LooseTouchInput {
42 None,
43 Single {
44 team_is_team_0: bool,
45 player: Option<PlayerId>,
46 },
47 Contested {
48 team_zero_player: Option<PlayerId>,
49 team_one_player: Option<PlayerId>,
50 },
51}
52
53impl LooseTouchInput {
54 fn owner_player(&self, owner_team_is_team_0: bool) -> Option<PlayerId> {
55 match self {
56 LooseTouchInput::Contested {
57 team_zero_player,
58 team_one_player,
59 } => {
60 if owner_team_is_team_0 {
61 team_zero_player.clone()
62 } else {
63 team_one_player.clone()
64 }
65 }
66 _ => None,
67 }
68 }
69
70 fn opponent_player(&self, owner_team_is_team_0: bool) -> Option<PlayerId> {
71 match self {
72 LooseTouchInput::Contested {
73 team_zero_player,
74 team_one_player,
75 } => {
76 if owner_team_is_team_0 {
77 team_one_player.clone()
78 } else {
79 team_zero_player.clone()
80 }
81 }
82 _ => None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Default)]
90enum LoosePhase {
91 #[default]
94 Neutral,
95 Owned {
98 team_is_team_0: bool,
99 player: Option<PlayerId>,
100 },
101 OwnedChallenged {
106 team_is_team_0: bool,
107 player: Option<PlayerId>,
108 challenger_first_time: f32,
109 challenger_first_frame: usize,
110 challenger_player: Option<PlayerId>,
111 },
112}
113
114#[derive(Debug, Clone, PartialEq, Default)]
118struct LoosePossessionResolver {
119 phase: LoosePhase,
120 open_start_time: f32,
121 open_start_frame: usize,
122 newly_resolved: Vec<ResolvedPossession>,
123}
124
125impl LoosePossessionResolver {
126 fn reset(&mut self) {
127 self.phase = LoosePhase::Neutral;
128 self.open_start_time = 0.0;
129 self.open_start_frame = 0;
130 self.newly_resolved.clear();
131 }
132
133 fn begin(&mut self, frame: &FrameInfo) {
134 self.reset();
135 self.open_start_time = frame.time;
136 self.open_start_frame = frame.frame_number;
137 }
138
139 fn finalize(
142 &mut self,
143 end_time: f32,
144 end_frame: usize,
145 label: PossessionLabel,
146 player: Option<PlayerId>,
147 ) {
148 if end_time > self.open_start_time {
149 self.newly_resolved.push(ResolvedPossession {
150 start_time: self.open_start_time,
151 start_frame: self.open_start_frame,
152 end_time,
153 end_frame,
154 label,
155 player,
156 });
157 }
158 self.open_start_time = end_time;
159 self.open_start_frame = end_frame;
160 }
161
162 fn within_window(now: f32, since: f32) -> bool {
163 now - since <= LOOSE_RESOLUTION_WINDOW_SECONDS
164 }
165
166 fn touch_input(
167 touched_team_zero_player: &Option<PlayerId>,
168 touched_team_one_player: &Option<PlayerId>,
169 touched_team_zero: bool,
170 touched_team_one: bool,
171 ) -> LooseTouchInput {
172 match (touched_team_zero, touched_team_one) {
173 (false, false) => LooseTouchInput::None,
174 (true, false) => LooseTouchInput::Single {
175 team_is_team_0: true,
176 player: touched_team_zero_player.clone(),
177 },
178 (false, true) => LooseTouchInput::Single {
179 team_is_team_0: false,
180 player: touched_team_one_player.clone(),
181 },
182 (true, true) => LooseTouchInput::Contested {
183 team_zero_player: touched_team_zero_player.clone(),
184 team_one_player: touched_team_one_player.clone(),
185 },
186 }
187 }
188
189 fn update(
192 &mut self,
193 frame: &FrameInfo,
194 touched_team_zero_player: &Option<PlayerId>,
195 touched_team_one_player: &Option<PlayerId>,
196 touched_team_zero: bool,
197 touched_team_one: bool,
198 ) {
199 self.newly_resolved.clear();
200 let time = frame.time;
201 let fnum = frame.frame_number;
202 let input = Self::touch_input(
203 touched_team_zero_player,
204 touched_team_one_player,
205 touched_team_zero,
206 touched_team_one,
207 );
208
209 let phase = std::mem::take(&mut self.phase);
210 self.phase = match phase {
211 LoosePhase::Neutral => self.step_neutral(time, fnum, input),
212 LoosePhase::Owned {
213 team_is_team_0,
214 player,
215 } => self.step_owned(time, fnum, input, team_is_team_0, player),
216 LoosePhase::OwnedChallenged {
217 team_is_team_0,
218 player,
219 challenger_first_time,
220 challenger_first_frame,
221 challenger_player,
222 } => self.step_owned_challenged(
223 time,
224 fnum,
225 input,
226 team_is_team_0,
227 player,
228 challenger_first_time,
229 challenger_first_frame,
230 challenger_player,
231 ),
232 };
233 }
234
235 fn step_neutral(&mut self, time: f32, fnum: usize, input: LooseTouchInput) -> LoosePhase {
236 match input {
237 LooseTouchInput::Single {
240 team_is_team_0,
241 player,
242 } => {
243 self.finalize(time, fnum, PossessionLabel::Neutral, None);
244 LoosePhase::Owned {
245 team_is_team_0,
246 player,
247 }
248 }
249 LooseTouchInput::None | LooseTouchInput::Contested { .. } => LoosePhase::Neutral,
251 }
252 }
253
254 fn step_owned(
255 &mut self,
256 time: f32,
257 fnum: usize,
258 input: LooseTouchInput,
259 team_is_team_0: bool,
260 player: Option<PlayerId>,
261 ) -> LoosePhase {
262 match input {
263 LooseTouchInput::None => LoosePhase::Owned {
265 team_is_team_0,
266 player,
267 },
268 LooseTouchInput::Single {
269 team_is_team_0: touch_team,
270 player: touch_player,
271 } => {
272 if touch_team == team_is_team_0 {
273 LoosePhase::Owned {
274 team_is_team_0,
275 player: touch_player.or(player),
276 }
277 } else {
278 LoosePhase::OwnedChallenged {
279 team_is_team_0,
280 player,
281 challenger_first_time: time,
282 challenger_first_frame: fnum,
283 challenger_player: touch_player,
284 }
285 }
286 }
287 LooseTouchInput::Contested { .. } => LoosePhase::OwnedChallenged {
288 team_is_team_0,
289 player: input.owner_player(team_is_team_0).or(player),
290 challenger_first_time: time,
291 challenger_first_frame: fnum,
292 challenger_player: input.opponent_player(team_is_team_0),
293 },
294 }
295 }
296
297 #[allow(clippy::too_many_arguments)]
298 fn step_owned_challenged(
299 &mut self,
300 time: f32,
301 _fnum: usize,
302 input: LooseTouchInput,
303 team_is_team_0: bool,
304 player: Option<PlayerId>,
305 challenger_first_time: f32,
306 challenger_first_frame: usize,
307 challenger_player: Option<PlayerId>,
308 ) -> LoosePhase {
309 let confirm_turnover = |resolver: &mut Self, new_player: Option<PlayerId>| {
312 resolver.finalize(
313 challenger_first_time,
314 challenger_first_frame,
315 PossessionLabel::team(team_is_team_0),
316 player.clone(),
317 );
318 LoosePhase::Owned {
319 team_is_team_0: !team_is_team_0,
320 player: new_player.or_else(|| challenger_player.clone()),
321 }
322 };
323
324 match input {
325 LooseTouchInput::None => {
326 if Self::within_window(time, challenger_first_time) {
327 LoosePhase::OwnedChallenged {
328 team_is_team_0,
329 player,
330 challenger_first_time,
331 challenger_first_frame,
332 challenger_player,
333 }
334 } else {
335 confirm_turnover(self, None)
338 }
339 }
340 LooseTouchInput::Single {
341 team_is_team_0: touch_team,
342 player: touch_player,
343 } => {
344 if touch_team == team_is_team_0 {
345 LoosePhase::Owned {
348 team_is_team_0,
349 player: touch_player.or(player),
350 }
351 } else {
352 confirm_turnover(self, touch_player)
353 }
354 }
355 LooseTouchInput::Contested { .. } => LoosePhase::OwnedChallenged {
357 team_is_team_0,
358 player,
359 challenger_first_time,
360 challenger_first_frame,
361 challenger_player,
362 },
363 }
364 }
365
366 fn open(&self) -> OpenPossession {
368 let (label, player) = match &self.phase {
369 LoosePhase::Neutral => (PossessionLabel::Neutral, None),
370 LoosePhase::Owned {
371 team_is_team_0,
372 player,
373 }
374 | LoosePhase::OwnedChallenged {
375 team_is_team_0,
376 player,
377 ..
378 } => (PossessionLabel::team(*team_is_team_0), player.clone()),
379 };
380 OpenPossession {
381 start_time: self.open_start_time,
382 start_frame: self.open_start_frame,
383 label,
384 player,
385 }
386 }
387
388 fn flush(&mut self, end_time: f32, end_frame: usize) {
391 let phase = std::mem::take(&mut self.phase);
392 match phase {
393 LoosePhase::Neutral => {
394 self.finalize(end_time, end_frame, PossessionLabel::Neutral, None);
395 }
396 LoosePhase::Owned {
397 team_is_team_0,
398 player,
399 }
400 | LoosePhase::OwnedChallenged {
401 team_is_team_0,
402 player,
403 ..
404 } => {
405 self.finalize(
406 end_time,
407 end_frame,
408 PossessionLabel::team(team_is_team_0),
409 player,
410 );
411 }
412 }
413 self.phase = LoosePhase::Neutral;
414 }
415}
416
417#[derive(Debug, Clone, Default, PartialEq)]
424pub struct LoosePossessionCalculator {
425 resolver: LoosePossessionResolver,
426 was_live: bool,
427 events: EventStream<LoosePossessionEvent>,
428 open_event: Option<LoosePossessionEvent>,
429 inactive_pending: Option<LoosePossessionEvent>,
430}
431
432impl LoosePossessionCalculator {
433 pub fn new() -> Self {
434 Self::default()
435 }
436
437 pub fn events(&self) -> &[LoosePossessionEvent] {
438 self.events.all()
439 }
440
441 pub fn new_events(&self) -> &[LoosePossessionEvent] {
442 self.events.new_events()
443 }
444
445 pub fn projected_events(&self) -> Vec<LoosePossessionEvent> {
446 let mut events = self.events.all().to_vec();
447 if let Some(open) = &self.open_event {
448 events.push(open.clone());
449 }
450 if let Some(inactive) = &self.inactive_pending {
451 events.push(inactive.clone());
452 }
453 events
454 }
455
456 pub fn current_event(&self) -> Option<&LoosePossessionEvent> {
457 self.open_event
458 .as_ref()
459 .or(self.inactive_pending.as_ref())
460 .or_else(|| self.events.all().last())
461 }
462
463 pub fn flush_pending_event(&mut self) {
464 if let Some(inactive) = self.inactive_pending.take() {
465 self.events.push(inactive);
466 }
467 if let Some(open) = self.open_event.take() {
468 self.events.push(open);
469 }
470 }
471
472 fn latest_touch_player_for_team(
473 touch_events: &[TouchEvent],
474 team_is_team_0: bool,
475 ) -> Option<PlayerId> {
476 touch_events
477 .iter()
478 .filter(|touch| touch.team_is_team_0 == team_is_team_0)
479 .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))
480 .and_then(|touch| touch.player.clone())
481 }
482
483 fn segment_event(segment: &ResolvedPossession) -> LoosePossessionEvent {
484 LoosePossessionEvent {
485 time: segment.start_time,
486 frame: segment.start_frame,
487 end_time: segment.end_time,
488 end_frame: segment.end_frame,
489 active: true,
490 duration: (segment.end_time - segment.start_time).max(0.0),
491 possession_state: segment.label.as_label_value().to_owned(),
492 player_id: segment.player.clone(),
493 }
494 }
495
496 fn flush_inactive(&mut self) {
497 if let Some(inactive) = self.inactive_pending.take() {
498 self.events.push(inactive);
499 }
500 }
501
502 fn commit_resolved(&mut self) {
503 let resolved = std::mem::take(&mut self.resolver.newly_resolved);
504 if resolved.is_empty() {
505 return;
506 }
507 self.flush_inactive();
508 for segment in &resolved {
509 self.events.push(Self::segment_event(segment));
510 }
511 }
512
513 pub fn update(
514 &mut self,
515 frame: &FrameInfo,
516 touch_state: &TouchState,
517 live_play_state: &LivePlayState,
518 ) -> SubtrActorResult<()> {
519 self.events.begin_update();
520
521 if !live_play_state.is_live_play {
522 if self.was_live {
523 self.resolver.flush(frame.time, frame.frame_number);
524 self.commit_resolved();
525 self.was_live = false;
526 }
527 self.open_event = None;
528 match self.inactive_pending.as_mut() {
529 Some(inactive) => {
530 inactive.end_time = frame.time;
531 inactive.end_frame = frame.frame_number;
532 inactive.duration = (inactive.end_time - inactive.time).max(0.0);
533 }
534 None => {
535 self.inactive_pending = Some(LoosePossessionEvent {
536 time: frame.time,
537 frame: frame.frame_number,
538 end_time: frame.time,
539 end_frame: frame.frame_number,
540 active: false,
541 duration: 0.0,
542 possession_state: PossessionLabel::Neutral.as_label_value().to_owned(),
543 player_id: None,
544 });
545 }
546 }
547 return Ok(());
548 }
549
550 if !self.was_live {
551 self.resolver.begin(frame);
552 self.was_live = true;
553 }
554
555 let touched_team_zero = touch_state
556 .touch_events
557 .iter()
558 .any(|touch| touch.team_is_team_0);
559 let touched_team_one = touch_state
560 .touch_events
561 .iter()
562 .any(|touch| !touch.team_is_team_0);
563 let touched_team_zero_player =
564 Self::latest_touch_player_for_team(&touch_state.touch_events, true);
565 let touched_team_one_player =
566 Self::latest_touch_player_for_team(&touch_state.touch_events, false);
567
568 self.resolver.update(
569 frame,
570 &touched_team_zero_player,
571 &touched_team_one_player,
572 touched_team_zero,
573 touched_team_one,
574 );
575 self.commit_resolved();
576
577 self.flush_inactive();
578 let open = self.resolver.open();
579 self.open_event = Some(LoosePossessionEvent {
580 time: open.start_time,
581 frame: open.start_frame,
582 end_time: frame.time,
583 end_frame: frame.frame_number,
584 active: true,
585 duration: (frame.time - open.start_time).max(0.0),
586 possession_state: open.label.as_label_value().to_owned(),
587 player_id: open.player.clone(),
588 });
589 Ok(())
590 }
591
592 pub fn finish(&mut self) {
593 self.flush_pending_event();
594 }
595}
596
597#[cfg(test)]
598#[path = "loose_possession_tests.rs"]
599mod tests;