1use super::*;
2
3const PLAYER_POSSESSION_MERGE_GAP_SECONDS: f32 = 2.0;
7const DISTINCT_TOUCH_GAP_SECONDS: f32 = 0.12;
10const MIN_POSSESSION_TOUCHES: u32 = 2;
17const MAX_POSSESSION_BALL_DISTANCE: f32 = 2500.0;
24
25#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
30#[ts(export)]
31pub struct PlayerPossessionEvent {
32 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
33 pub player_id: PlayerId,
34 pub is_team_0: bool,
35 pub start_frame: usize,
36 pub end_frame: usize,
37 pub start_time: f32,
38 pub end_time: f32,
39 pub duration: f32,
45 pub touch_count: u32,
46 pub aerial_touch_count: u32,
47 pub wall_touch_count: u32,
48 pub advance_distance: f32,
50 pub retreat_distance: f32,
52 pub carry_time: f32,
54 pub air_dribble_time: f32,
56 pub carry_count: u32,
57 pub air_dribble_count: u32,
58 pub close_time: f32,
61 pub sustained_control: bool,
66 pub start_field_third: Option<String>,
67 pub end_field_third: Option<String>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Default)]
75struct PossessedTotals {
76 duration: f32,
77 close_time: f32,
78 advance_distance: f32,
79 retreat_distance: f32,
80 carry_time: f32,
81 air_dribble_time: f32,
82}
83
84#[derive(Debug, Clone, PartialEq)]
85struct ActivePlayerPossession {
86 player_id: PlayerId,
87 is_team_0: bool,
88 start_frame: usize,
89 end_frame: usize,
90 start_time: f32,
91 end_time: f32,
92 running: PossessedTotals,
93 at_last_touch: PossessedTotals,
95 touch_count: u32,
96 aerial_touch_count: u32,
97 wall_touch_count: u32,
98 first_touch_time: Option<f32>,
99 last_touch_time: Option<f32>,
100 carry_count: u32,
101 air_dribble_count: u32,
102 last_carry_kind: Option<BallCarryKind>,
103 start_field_third: Option<String>,
104 end_field_third: Option<String>,
105}
106
107impl ActivePlayerPossession {
108 fn open(
109 frame: &FrameInfo,
110 player_id: PlayerId,
111 is_team_0: bool,
112 field_third: Option<String>,
113 ) -> Self {
114 Self {
115 player_id,
116 is_team_0,
117 start_frame: frame.frame_number.saturating_sub(1),
121 end_frame: frame.frame_number,
122 start_time: (frame.time - frame.dt).max(0.0),
123 end_time: frame.time,
124 running: PossessedTotals::default(),
125 at_last_touch: PossessedTotals::default(),
126 touch_count: 0,
127 aerial_touch_count: 0,
128 wall_touch_count: 0,
129 first_touch_time: None,
130 last_touch_time: None,
131 carry_count: 0,
132 air_dribble_count: 0,
133 last_carry_kind: None,
134 start_field_third: field_third.clone(),
135 end_field_third: field_third,
136 }
137 }
138
139 fn record_frame(&mut self, frame: &FrameInfo, field_third: Option<String>) {
140 self.running.duration += frame.dt.max(0.0);
141 self.end_frame = frame.frame_number;
142 self.end_time = frame.time;
143 if field_third.is_some() {
144 self.end_field_third = field_third;
145 }
146 }
147
148 fn record_touch(&mut self, touch: &TouchEvent) {
149 self.at_last_touch = self.running;
152 if self
153 .last_touch_time
154 .is_some_and(|last| touch.time - last < DISTINCT_TOUCH_GAP_SECONDS)
155 {
156 return;
157 }
158 if self.first_touch_time.is_none() {
159 self.first_touch_time = Some(touch.time);
160 }
161 self.last_touch_time = Some(touch.time);
162 self.touch_count += 1;
163 let Some(position) = touch.player_position.as_ref().map(vec_to_glam) else {
164 return;
165 };
166 if player_is_on_wall(position) {
167 self.wall_touch_count += 1;
168 } else if AirDribblePolicy::is_air_touch_position(position) {
169 self.aerial_touch_count += 1;
170 }
171 }
172
173 fn record_ball_movement(&mut self, previous_ball_y: f32, ball_y: f32) {
174 let team_forward_sign = if self.is_team_0 { 1.0 } else { -1.0 };
175 let advance = (ball_y - previous_ball_y) * team_forward_sign;
176 if advance >= 0.0 {
177 self.running.advance_distance += advance;
178 } else {
179 self.running.retreat_distance -= advance;
180 }
181 }
182
183 fn record_proximity_sample(
184 &mut self,
185 frame: &FrameInfo,
186 ball: &BallFrameState,
187 players: &PlayerFrameState,
188 ) {
189 let Some(ball_position) = ball.position() else {
190 return;
191 };
192 let close = players
193 .player(&self.player_id)
194 .and_then(PlayerSample::position)
195 .is_some_and(|player_position| {
196 player_position.distance(ball_position) <= controlled_play::CLOSE_DISTANCE_3D
197 });
198 if close {
199 self.running.close_time += frame.dt.max(0.0);
200 }
201 }
202
203 fn touch_span(&self) -> f32 {
204 match (self.first_touch_time, self.last_touch_time) {
205 (Some(first), Some(last)) => (last - first).max(0.0),
206 _ => 0.0,
207 }
208 }
209
210 fn is_sustained_control(&self) -> bool {
214 self.touch_count >= controlled_play::MIN_TOUCHES
215 && self.at_last_touch.duration >= controlled_play::MIN_EPISODE_DURATION_SECONDS
216 && self.touch_span() >= controlled_play::MIN_FIRST_TO_LAST_TOUCH_DURATION_SECONDS
217 && self.at_last_touch.close_time >= controlled_play::MIN_CLOSE_DURATION_SECONDS
218 }
219
220 fn record_carry_sample(&mut self, frame: &FrameInfo, kind: Option<BallCarryKind>) {
221 if let Some(kind) = kind {
222 if self.last_carry_kind != Some(kind) {
223 match kind {
224 BallCarryKind::Carry => self.carry_count += 1,
225 BallCarryKind::AirDribble => self.air_dribble_count += 1,
226 }
227 }
228 match kind {
229 BallCarryKind::Carry => self.running.carry_time += frame.dt.max(0.0),
230 BallCarryKind::AirDribble => self.running.air_dribble_time += frame.dt.max(0.0),
231 }
232 }
233 self.last_carry_kind = kind;
234 }
235
236 fn into_event(self) -> PlayerPossessionEvent {
237 let sustained_control = self.is_sustained_control();
238 PlayerPossessionEvent {
239 player_id: self.player_id,
240 is_team_0: self.is_team_0,
241 start_frame: self.start_frame,
242 end_frame: self.end_frame,
243 start_time: self.start_time,
244 end_time: self.end_time,
245 duration: self.at_last_touch.duration,
246 touch_count: self.touch_count,
247 aerial_touch_count: self.aerial_touch_count,
248 wall_touch_count: self.wall_touch_count,
249 advance_distance: self.at_last_touch.advance_distance,
250 retreat_distance: self.at_last_touch.retreat_distance,
251 carry_time: self.at_last_touch.carry_time,
252 air_dribble_time: self.at_last_touch.air_dribble_time,
253 carry_count: self.carry_count,
254 air_dribble_count: self.air_dribble_count,
255 close_time: self.at_last_touch.close_time,
256 sustained_control,
257 start_field_third: self.start_field_third,
258 end_field_third: self.end_field_third,
259 }
260 }
261}
262
263#[derive(Debug, Clone, Default, PartialEq)]
272pub struct PlayerPossessionCalculator {
273 events: EventStream<PlayerPossessionEvent>,
274 active: Option<ActivePlayerPossession>,
275 suspended: Option<(ActivePlayerPossession, f32)>,
276 previous_ball_y: Option<f32>,
277}
278
279impl PlayerPossessionCalculator {
280 pub fn new() -> Self {
281 Self::default()
282 }
283
284 pub fn events(&self) -> &[PlayerPossessionEvent] {
285 self.events.all()
286 }
287
288 pub fn new_events(&self) -> &[PlayerPossessionEvent] {
289 self.events.new_events()
290 }
291
292 fn finalize(&mut self, span: ActivePlayerPossession) {
293 if span.touch_count < MIN_POSSESSION_TOUCHES {
298 return;
299 }
300 self.events.push(span.into_event());
301 }
302
303 fn finalize_all(&mut self) {
304 if let Some(active) = self.active.take() {
305 self.finalize(active);
306 }
307 if let Some((suspended, _)) = self.suspended.take() {
308 self.finalize(suspended);
309 }
310 }
311
312 fn expire_suspended(&mut self, time: f32) {
313 let expired = self.suspended.as_ref().is_some_and(|(_, suspended_at)| {
314 time - suspended_at > PLAYER_POSSESSION_MERGE_GAP_SECONDS
315 });
316 if expired {
317 if let Some((suspended, _)) = self.suspended.take() {
318 self.finalize(suspended);
319 }
320 }
321 }
322
323 fn field_third(ball: &BallFrameState) -> Option<String> {
324 ball.sample().map(|sample| {
325 BallThirdLabel::from_ball(sample)
326 .as_label_value()
327 .to_owned()
328 })
329 }
330
331 fn holder_within_reach(
338 current_player: Option<PlayerId>,
339 ball: &BallFrameState,
340 players: &PlayerFrameState,
341 ) -> Option<PlayerId> {
342 let player_id = current_player?;
343 let within = match (
344 ball.position(),
345 players.player(&player_id).and_then(PlayerSample::position),
346 ) {
347 (Some(ball_position), Some(player_position)) => {
348 player_position.distance(ball_position) <= MAX_POSSESSION_BALL_DISTANCE
349 }
350 _ => true,
352 };
353 within.then_some(player_id)
354 }
355
356 fn carry_sample_kind(
357 player_id: &PlayerId,
358 ball: &BallFrameState,
359 players: &PlayerFrameState,
360 ) -> Option<BallCarryKind> {
361 let ball = ball.sample()?;
362 let player = players.player(player_id)?;
363 BallCarryCalculator::carry_frame_sample(player, ball).map(|sample| sample.kind)
364 }
365
366 pub fn update(
367 &mut self,
368 frame: &FrameInfo,
369 ball: &BallFrameState,
370 players: &PlayerFrameState,
371 possession_state: &PossessionState,
372 touch_state: &TouchState,
373 live_play_state: &LivePlayState,
374 ) -> SubtrActorResult<()> {
375 self.events.begin_update();
376 let ball_y = ball.position().map(|position| position.y);
377 if !live_play_state.is_live_play {
378 self.finalize_all();
379 self.previous_ball_y = ball_y;
380 return Ok(());
381 }
382
383 self.expire_suspended(frame.time);
384
385 let current_player =
386 Self::holder_within_reach(possession_state.current_player.clone(), ball, players);
387 let field_third = Self::field_third(ball);
388
389 if let Some(active) = self.active.as_ref() {
390 if current_player.as_ref() != Some(&active.player_id) {
391 let mut active = self.active.take().expect("active span checked above");
392 if current_player.is_some() {
395 self.finalize(active);
396 } else {
397 active.last_carry_kind = None;
398 active.running = active.at_last_touch;
402 self.suspended = Some((active, frame.time));
403 }
404 }
405 }
406
407 if self.active.is_none() {
408 if let Some(player_id) = current_player.clone() {
409 let resumes_suspended = self
410 .suspended
411 .as_ref()
412 .is_some_and(|(suspended, _)| suspended.player_id == player_id);
413 if resumes_suspended {
414 self.active = self.suspended.take().map(|(suspended, _)| suspended);
415 } else {
416 if let Some((suspended, _)) = self.suspended.take() {
417 self.finalize(suspended);
418 }
419 let is_team_0 = possession_state
420 .current_team_is_team_0
421 .or_else(|| players.player(&player_id).map(|player| player.is_team_0))
422 .unwrap_or(true);
423 self.active = Some(ActivePlayerPossession::open(
424 frame,
425 player_id,
426 is_team_0,
427 field_third.clone(),
428 ));
429 }
430 }
431 }
432
433 let Some(active) = self.active.as_mut() else {
434 self.previous_ball_y = ball_y;
435 return Ok(());
436 };
437
438 active.record_frame(frame, field_third);
439 active.record_proximity_sample(frame, ball, players);
440 if let (Some(previous_ball_y), Some(ball_y)) = (self.previous_ball_y, ball_y) {
441 active.record_ball_movement(previous_ball_y, ball_y);
442 }
443 let carry_kind = Self::carry_sample_kind(&active.player_id, ball, players);
444 active.record_carry_sample(frame, carry_kind);
445 for touch in touch_state.touch_events.iter() {
448 if touch.player.as_ref() == Some(&active.player_id) {
449 active.record_touch(touch);
450 }
451 }
452 self.previous_ball_y = ball_y;
453
454 Ok(())
455 }
456
457 pub fn finish(&mut self) {
458 self.finalize_all();
459 }
460}
461
462#[cfg(test)]
463#[path = "player_possession_tests.rs"]
464mod tests;