subtr_actor/stats/calculators/
controlled_play.rs1use super::*;
2
3const DISTINCT_TOUCH_GAP_SECONDS: f32 = 0.12;
4const MAX_TOUCH_CHAIN_GAP_SECONDS: f32 = 2.50;
5pub(crate) const CLOSE_DISTANCE_3D: f32 = 700.0;
10pub(crate) const MIN_CLOSE_DURATION_SECONDS: f32 = 0.75;
11pub(crate) const MIN_EPISODE_DURATION_SECONDS: f32 = 1.00;
12pub(crate) const MIN_FIRST_TO_LAST_TOUCH_DURATION_SECONDS: f32 = 1.00;
13pub(crate) const MIN_TOUCHES: u32 = 2;
14
15#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
17#[ts(export)]
18pub struct ControlledPlayEvent {
19 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
20 pub player_id: PlayerId,
21 pub is_team_0: bool,
22 pub start_frame: usize,
23 pub end_frame: usize,
24 pub start_time: f32,
25 pub end_time: f32,
26 pub duration: f32,
27 pub first_touch_frame: usize,
28 pub last_touch_frame: usize,
29 pub first_touch_time: f32,
30 pub last_touch_time: f32,
31 pub touch_count: u32,
32 pub close_duration: f32,
33 pub total_advance_distance: f32,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37struct ActiveControlledPlay {
38 player_id: PlayerId,
39 is_team_0: bool,
40 start_frame: usize,
41 end_frame: usize,
42 start_time: f32,
43 end_time: f32,
44 first_touch_frame: usize,
45 last_touch_frame: usize,
46 first_touch_time: f32,
47 last_touch_time: f32,
48 touch_count: u32,
49 close_duration: f32,
50 total_advance_distance: f32,
51}
52
53impl ActiveControlledPlay {
54 fn from_touch(touch: &TouchEvent, player_id: PlayerId) -> Self {
55 Self {
56 player_id,
57 is_team_0: touch.team_is_team_0,
58 start_frame: touch.frame,
59 end_frame: touch.frame,
60 start_time: touch.time,
61 end_time: touch.time,
62 first_touch_frame: touch.frame,
63 last_touch_frame: touch.frame,
64 first_touch_time: touch.time,
65 last_touch_time: touch.time,
66 touch_count: 1,
67 close_duration: 0.0,
68 total_advance_distance: 0.0,
69 }
70 }
71
72 fn record_touch(&mut self, touch: &TouchEvent) {
73 if touch.time - self.last_touch_time < DISTINCT_TOUCH_GAP_SECONDS {
74 return;
75 }
76
77 self.touch_count += 1;
78 self.last_touch_frame = touch.frame;
79 self.last_touch_time = touch.time;
80 self.extend_to(touch.frame, touch.time);
81 }
82
83 fn extend_to(&mut self, frame: usize, time: f32) {
84 if time < self.end_time || (time == self.end_time && frame < self.end_frame) {
85 return;
86 }
87 self.end_frame = frame;
88 self.end_time = time;
89 }
90
91 fn duration(&self) -> f32 {
92 (self.end_time - self.start_time).max(0.0)
93 }
94
95 fn touch_span(&self) -> f32 {
96 (self.last_touch_time - self.first_touch_time).max(0.0)
97 }
98
99 fn is_valid(&self) -> bool {
100 self.touch_count >= MIN_TOUCHES
101 && self.duration() >= MIN_EPISODE_DURATION_SECONDS
102 && self.touch_span() >= MIN_FIRST_TO_LAST_TOUCH_DURATION_SECONDS
103 && self.close_duration >= MIN_CLOSE_DURATION_SECONDS
104 }
105
106 fn into_event(self) -> ControlledPlayEvent {
107 let duration = self.duration();
108 ControlledPlayEvent {
109 player_id: self.player_id,
110 is_team_0: self.is_team_0,
111 start_frame: self.start_frame,
112 end_frame: self.end_frame,
113 start_time: self.start_time,
114 end_time: self.end_time,
115 duration,
116 first_touch_frame: self.first_touch_frame,
117 last_touch_frame: self.last_touch_frame,
118 first_touch_time: self.first_touch_time,
119 last_touch_time: self.last_touch_time,
120 touch_count: self.touch_count,
121 close_duration: self.close_duration,
122 total_advance_distance: self.total_advance_distance,
123 }
124 }
125}
126
127impl InFlightItem for ActiveControlledPlay {
128 fn recognition(&self) -> Recognition {
129 Recognition::new(self.start_time, self.start_frame, self.is_valid())
132 }
133
134 fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
135 if self.is_valid() {
136 Disposition::Finalize(FinalizeReason::Boundary(boundary))
137 } else {
138 Disposition::Discard
139 }
140 }
141}
142
143#[derive(Debug, Clone, Default, PartialEq)]
145pub struct ControlledPlayCalculator {
146 events: EventStream<ControlledPlayEvent>,
147 active: InFlightLedger<ActiveControlledPlay>,
148 previous_ball_position: Option<glam::Vec3>,
149}
150
151impl ControlledPlayCalculator {
152 pub fn new() -> Self {
153 Self::default()
154 }
155
156 pub fn events(&self) -> &[ControlledPlayEvent] {
157 self.events.all()
158 }
159
160 pub fn new_events(&self) -> &[ControlledPlayEvent] {
161 self.events.new_events()
162 }
163
164 fn finish_active(&mut self) {
167 let valid = self
168 .active
169 .in_flight()
170 .first()
171 .is_some_and(ActiveControlledPlay::is_valid);
172 if valid {
173 for (active, _reason) in self.active.finalize_all(FinalizeReason::Completed) {
174 self.events.push(active.into_event());
175 }
176 } else {
177 self.active.clear();
178 }
179 }
180
181 fn finish_active_at_boundary(&mut self, boundary: Boundary) {
184 for (active, _reason) in self.active.apply_boundary(boundary) {
185 self.events.push(active.into_event());
186 }
187 }
188
189 fn player_is_close(
190 players: &PlayerFrameState,
191 ball_position: glam::Vec3,
192 player_id: &PlayerId,
193 ) -> bool {
194 players
195 .player(player_id)
196 .and_then(PlayerSample::position)
197 .is_some_and(|player_position| {
198 player_position.distance(ball_position) <= CLOSE_DISTANCE_3D
199 })
200 }
201
202 fn apply_frame_sample(
203 &mut self,
204 frame: &FrameInfo,
205 ball_position: Option<glam::Vec3>,
206 players: &PlayerFrameState,
207 ) {
208 let Some(active) = self.active.in_flight_mut().first_mut() else {
209 self.previous_ball_position = ball_position;
210 return;
211 };
212 let Some(ball_position) = ball_position else {
213 self.previous_ball_position = None;
214 return;
215 };
216
217 if Self::player_is_close(players, ball_position, &active.player_id) {
218 active.close_duration += frame.dt.max(0.0);
219 }
220
221 if let Some(previous_ball_position) = self.previous_ball_position {
222 let team_forward_sign = if active.is_team_0 { 1.0 } else { -1.0 };
223 let advance_distance = (ball_position.y - previous_ball_position.y) * team_forward_sign;
224 active.total_advance_distance += advance_distance.max(0.0);
225 }
226 active.extend_to(frame.frame_number, frame.time);
227 self.previous_ball_position = Some(ball_position);
228 }
229
230 fn expire_stale_candidate(&mut self, frame: &FrameInfo) {
231 let Some(active) = self.active.in_flight().first() else {
232 return;
233 };
234 if frame.time - active.last_touch_time > MAX_TOUCH_CHAIN_GAP_SECONDS {
235 self.finish_active();
236 }
237 }
238
239 fn apply_touch(&mut self, touch: &TouchEvent) {
240 let Some(player_id) = touch.player.clone() else {
241 return;
242 };
243
244 let same_player = self
245 .active
246 .in_flight()
247 .first()
248 .is_some_and(|active| active.player_id == player_id);
249 if same_player {
250 if let Some(active) = self.active.in_flight_mut().first_mut() {
251 active.record_touch(touch);
252 }
253 return;
254 }
255
256 self.finish_active();
257 self.active
258 .arm(ActiveControlledPlay::from_touch(touch, player_id));
259 }
260
261 pub fn update(
262 &mut self,
263 frame: &FrameInfo,
264 ball: &BallFrameState,
265 players: &PlayerFrameState,
266 touch_state: &TouchState,
267 live_play_state: &LivePlayState,
268 ) -> SubtrActorResult<()> {
269 self.events.begin_update();
270 if !live_play_state.is_live_play {
271 self.finish_active_at_boundary(Boundary::LivePlayEnded);
272 self.previous_ball_position = ball.position();
273 return Ok(());
274 }
275
276 self.expire_stale_candidate(frame);
277 self.apply_frame_sample(frame, ball.position(), players);
278 for touch in chronological_touch_events(&touch_state.touch_events) {
279 self.apply_touch(touch);
280 }
281
282 Ok(())
283 }
284
285 pub fn finish(&mut self) {
286 self.finish_active_at_boundary(Boundary::ReplayEnded);
287 }
288}
289
290#[cfg(test)]
291#[path = "controlled_play_tests.rs"]
292mod tests;