subtr_actor/stats/calculators/
ball_carry.rs1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
4#[ts(export)]
5pub struct BallCarryStats {
6 pub carry_count: u32,
7 pub total_carry_time: f32,
8 pub total_straight_line_distance: f32,
9 pub total_path_distance: f32,
10 pub longest_carry_time: f32,
11 pub furthest_carry_distance: f32,
12 pub fastest_carry_speed: f32,
13 pub carry_speed_sum: f32,
14 pub average_horizontal_gap_sum: f32,
15 pub average_vertical_gap_sum: f32,
16}
17
18impl BallCarryStats {
19 fn pct_count_average(&self, value: f32) -> f32 {
20 if self.carry_count == 0 {
21 0.0
22 } else {
23 value / self.carry_count as f32
24 }
25 }
26
27 pub fn average_carry_time(&self) -> f32 {
28 self.pct_count_average(self.total_carry_time)
29 }
30
31 pub fn average_straight_line_distance(&self) -> f32 {
32 self.pct_count_average(self.total_straight_line_distance)
33 }
34
35 pub fn average_path_distance(&self) -> f32 {
36 self.pct_count_average(self.total_path_distance)
37 }
38
39 pub fn average_carry_speed(&self) -> f32 {
40 self.pct_count_average(self.carry_speed_sum)
41 }
42
43 pub fn average_horizontal_gap(&self) -> f32 {
44 self.pct_count_average(self.average_horizontal_gap_sum)
45 }
46
47 pub fn average_vertical_gap(&self) -> f32 {
48 self.pct_count_average(self.average_vertical_gap_sum)
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize)]
53pub struct BallCarryEvent {
54 pub player_id: PlayerId,
55 pub is_team_0: bool,
56 pub kind: BallCarryKind,
57 pub start_frame: usize,
58 pub end_frame: usize,
59 pub start_time: f32,
60 pub end_time: f32,
61 pub duration: f32,
62 pub straight_line_distance: f32,
63 pub path_distance: f32,
64 pub average_horizontal_gap: f32,
65 pub average_vertical_gap: f32,
66 pub average_speed: f32,
67 pub touch_count: u32,
68 pub air_touch_count: u32,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub air_dribble_origin: Option<AirDribbleOrigin>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
74#[ts(export)]
75#[serde(rename_all = "snake_case")]
76pub enum BallCarryKind {
77 Carry,
78 AirDribble,
79}
80
81#[derive(Debug, Clone, Default)]
82pub struct BallCarryCalculator {
83 player_stats: HashMap<PlayerId, BallCarryStats>,
84 player_air_dribble_stats: HashMap<PlayerId, AirDribbleStats>,
85 team_zero_stats: BallCarryStats,
86 team_one_stats: BallCarryStats,
87 team_zero_air_dribble_stats: AirDribbleStats,
88 team_one_air_dribble_stats: AirDribbleStats,
89 carry_events: Vec<BallCarryEvent>,
90 processed_control_sequence_count: usize,
91}
92
93impl BallCarryCalculator {
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 pub fn player_stats(&self) -> &HashMap<PlayerId, BallCarryStats> {
99 &self.player_stats
100 }
101
102 pub fn player_air_dribble_stats(&self) -> &HashMap<PlayerId, AirDribbleStats> {
103 &self.player_air_dribble_stats
104 }
105
106 pub fn team_zero_stats(&self) -> &BallCarryStats {
107 &self.team_zero_stats
108 }
109
110 pub fn team_one_stats(&self) -> &BallCarryStats {
111 &self.team_one_stats
112 }
113
114 pub fn team_zero_air_dribble_stats(&self) -> &AirDribbleStats {
115 &self.team_zero_air_dribble_stats
116 }
117
118 pub fn team_one_air_dribble_stats(&self) -> &AirDribbleStats {
119 &self.team_one_air_dribble_stats
120 }
121
122 pub fn carry_events(&self) -> &[BallCarryEvent] {
123 &self.carry_events
124 }
125
126 pub(crate) fn carry_frame_sample(
127 player: &PlayerSample,
128 ball: &BallSample,
129 ) -> Option<ContinuousBallControlSample<BallCarryKind>> {
130 let player_position = player.position()?;
131 let ball_position = ball.position();
132 let horizontal_gap = player_position
133 .truncate()
134 .distance(ball_position.truncate());
135 let vertical_gap = ball_position.z - player_position.z;
136
137 if AirDribblePolicy::is_sample(player_position, ball_position, horizontal_gap, vertical_gap)
138 {
139 return Some(ContinuousBallControlSample {
140 player_position,
141 kind: BallCarryKind::AirDribble,
142 horizontal_gap,
143 vertical_gap,
144 speed: player.speed().unwrap_or(0.0),
145 });
146 }
147
148 if player_is_on_wall(player_position) {
149 return None;
150 }
151
152 if !(BALL_CARRY_MIN_BALL_Z..=BALL_CARRY_MAX_BALL_Z).contains(&ball_position.z) {
153 return None;
154 }
155
156 if horizontal_gap > BALL_CARRY_MAX_HORIZONTAL_GAP {
157 return None;
158 }
159
160 if !(0.0..=BALL_CARRY_MAX_VERTICAL_GAP).contains(&vertical_gap) {
161 return None;
162 }
163
164 Some(ContinuousBallControlSample {
165 player_position,
166 kind: BallCarryKind::Carry,
167 horizontal_gap,
168 vertical_gap,
169 speed: player.speed().unwrap_or(0.0),
170 })
171 }
172
173 pub(crate) fn kind_requires_airborne(kind: BallCarryKind) -> bool {
174 AirDribblePolicy::kind_requires_airborne(kind)
175 }
176
177 pub(crate) fn control_player_statuses(
178 players: &PlayerFrameState,
179 ) -> Vec<ContinuousBallControlPlayerStatus> {
180 players
181 .players
182 .iter()
183 .filter_map(|player| {
184 Some(ContinuousBallControlPlayerStatus {
185 player_id: player.player_id.clone(),
186 is_airborne: AirDribblePolicy::is_air_touch_position(player.position()?),
187 })
188 })
189 .collect()
190 }
191
192 pub(crate) fn control_touches(
193 touch_state: &TouchState,
194 players: &PlayerFrameState,
195 ) -> Vec<ContinuousBallControlTouch> {
196 touch_state
197 .touch_events
198 .iter()
199 .filter_map(|touch| {
200 let player_id = touch.player.clone()?;
201 let player = players
202 .players
203 .iter()
204 .find(|player| player.player_id == player_id)?;
205 Some(ContinuousBallControlTouch {
206 player_id,
207 is_airborne: AirDribblePolicy::is_air_touch_position(player.position()?),
208 })
209 })
210 .collect()
211 }
212
213 pub(crate) fn min_duration_for_kind(kind: BallCarryKind) -> f32 {
214 match kind {
215 BallCarryKind::Carry => BALL_CARRY_MIN_DURATION,
216 BallCarryKind::AirDribble => AIR_DRIBBLE_MIN_DURATION,
217 }
218 }
219
220 pub(crate) fn control_candidate(
221 ball: &BallFrameState,
222 players: &PlayerFrameState,
223 live_play: bool,
224 touch_state: &TouchState,
225 ) -> Option<ContinuousBallControlCandidate<BallCarryKind>> {
226 if !live_play {
227 return None;
228 }
229 let ball = ball.sample()?;
230 let player_id = touch_state.last_touch_player.as_ref()?;
231 let touch_count = touch_state
232 .touch_events
233 .iter()
234 .filter(|event| event.player.as_ref() == Some(player_id))
235 .count() as u32;
236 players
237 .players
238 .iter()
239 .find(|player| &player.player_id == player_id)
240 .and_then(|player| {
241 Self::carry_frame_sample(player, ball).map(|sample| {
242 let air_touch_count =
243 if AirDribblePolicy::is_air_touch_position(sample.player_position) {
244 touch_count
245 } else {
246 0
247 };
248 ContinuousBallControlCandidate {
249 player_id: player.player_id.clone(),
250 is_team_0: player.is_team_0,
251 touch_count,
252 air_touch_count,
253 sample,
254 }
255 })
256 })
257 }
258
259 fn event_from_sequence(
260 sequence: CompletedBallControlSequence<BallCarryKind>,
261 ) -> BallCarryEvent {
262 let air_dribble_origin = (sequence.kind == BallCarryKind::AirDribble)
263 .then(|| AirDribblePolicy::origin(sequence.start_position));
264 BallCarryEvent {
265 player_id: sequence.player_id,
266 is_team_0: sequence.is_team_0,
267 kind: sequence.kind,
268 start_frame: sequence.start_frame,
269 end_frame: sequence.end_frame,
270 start_time: sequence.start_time,
271 end_time: sequence.end_time,
272 duration: sequence.duration,
273 straight_line_distance: sequence.straight_line_distance,
274 path_distance: sequence.path_distance,
275 average_horizontal_gap: sequence.average_horizontal_gap,
276 average_vertical_gap: sequence.average_vertical_gap,
277 average_speed: sequence.average_speed,
278 touch_count: sequence.touch_count,
279 air_touch_count: sequence.air_touch_count,
280 air_dribble_origin,
281 }
282 }
283
284 fn record_carry_event(&mut self, event: BallCarryEvent) {
285 match event.kind {
286 BallCarryKind::Carry => {
287 let player_stats = self
288 .player_stats
289 .entry(event.player_id.clone())
290 .or_default();
291 Self::apply_carry_event(player_stats, &event);
292
293 let team_stats = if event.is_team_0 {
294 &mut self.team_zero_stats
295 } else {
296 &mut self.team_one_stats
297 };
298 Self::apply_carry_event(team_stats, &event);
299 }
300 BallCarryKind::AirDribble => {
301 let player_stats = self
302 .player_air_dribble_stats
303 .entry(event.player_id.clone())
304 .or_default();
305 AirDribblePolicy::apply_event(player_stats, &event);
306
307 let team_stats = if event.is_team_0 {
308 &mut self.team_zero_air_dribble_stats
309 } else {
310 &mut self.team_one_air_dribble_stats
311 };
312 AirDribblePolicy::apply_event(team_stats, &event);
313 }
314 }
315 self.carry_events.push(event);
316 }
317
318 fn apply_carry_event(stats: &mut BallCarryStats, event: &BallCarryEvent) {
319 stats.carry_count += 1;
320 stats.total_carry_time += event.duration;
321 stats.total_straight_line_distance += event.straight_line_distance;
322 stats.total_path_distance += event.path_distance;
323 stats.longest_carry_time = stats.longest_carry_time.max(event.duration);
324 stats.furthest_carry_distance = stats
325 .furthest_carry_distance
326 .max(event.straight_line_distance);
327 stats.fastest_carry_speed = stats.fastest_carry_speed.max(event.average_speed);
328 stats.carry_speed_sum += event.average_speed;
329 stats.average_horizontal_gap_sum += event.average_horizontal_gap;
330 stats.average_vertical_gap_sum += event.average_vertical_gap;
331 }
332
333 pub fn update(&mut self, control_state: &ContinuousBallControlState) -> SubtrActorResult<()> {
334 for sequence in control_state
335 .completed_sequences
336 .iter()
337 .skip(self.processed_control_sequence_count)
338 .cloned()
339 {
340 if !AirDribblePolicy::is_valid_sequence(&sequence) {
341 continue;
342 }
343 self.record_carry_event(Self::event_from_sequence(sequence));
344 }
345 self.processed_control_sequence_count = control_state.completed_sequences.len();
346 Ok(())
347 }
348}
349
350#[cfg(test)]
351#[path = "ball_carry_tests.rs"]
352mod tests;