1use super::*;
2
3const FLICK_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.32;
4const FLICK_MAX_SETUP_STALE_SECONDS: f32 = 0.35;
5const FLICK_MIN_SETUP_SECONDS: f32 = 0.18;
6const FLICK_MIN_SETUP_TOUCHES: u32 = 2;
7const FLICK_MIN_BALL_SPEED_CHANGE: f32 = 450.0;
8const FLICK_HIGH_CONFIDENCE: f32 = 0.80;
9const FLICK_MIN_CONFIDENCE: f32 = 0.55;
10const FLICK_MAX_CONTROL_BALL_Z: f32 = 700.0;
11const FLICK_MAX_CONTROL_HORIZONTAL_GAP: f32 = BALL_RADIUS_Z * 1.7;
12const FLICK_MIN_CONTROL_VERTICAL_GAP: f32 = 35.0;
13const FLICK_MAX_CONTROL_VERTICAL_GAP: f32 = 280.0;
14const FLICK_MIN_LOCAL_Z: f32 = 20.0;
15const FLICK_MAX_LOCAL_X_BEHIND: f32 = 95.0;
16const FLICK_MAX_LOCAL_X_FRONT: f32 = 210.0;
17const FLICK_MAX_LOCAL_Y: f32 = 170.0;
18const FLICK_MIN_IMPULSE_AWAY_ALIGNMENT: f32 = 0.15;
19
20#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
21#[ts(export)]
22pub struct FlickEvent {
23 pub time: f32,
24 pub frame: usize,
25 pub sample_time: f32,
26 pub sample_frame: usize,
27 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
28 pub player: PlayerId,
29 pub is_team_0: bool,
30 pub dodge_time: f32,
31 pub dodge_frame: usize,
32 pub time_since_dodge: f32,
33 pub setup_start_time: f32,
34 pub setup_start_frame: usize,
35 pub setup_duration: f32,
36 pub setup_touch_count: u32,
37 pub average_horizontal_gap: f32,
38 pub average_vertical_gap: f32,
39 pub ball_speed_change: f32,
40 pub ball_impulse: [f32; 3],
41 pub impulse_away_alignment: f32,
42 pub vertical_impulse: f32,
43 pub confidence: f32,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
47#[ts(export)]
48pub struct FlickStats {
49 pub count: u32,
50 pub high_confidence_count: u32,
51 pub is_last_flick: bool,
52 pub last_flick_time: Option<f32>,
53 pub last_flick_frame: Option<usize>,
54 pub time_since_last_flick: Option<f32>,
55 pub frames_since_last_flick: Option<usize>,
56 pub last_confidence: Option<f32>,
57 pub best_confidence: f32,
58 pub cumulative_confidence: f32,
59 pub cumulative_setup_duration: f32,
60 pub cumulative_ball_speed_change: f32,
61 #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
62 pub labeled_event_counts: LabeledCounts,
63}
64
65impl FlickStats {
66 pub fn average_confidence(&self) -> f32 {
67 if self.count == 0 {
68 0.0
69 } else {
70 self.cumulative_confidence / self.count as f32
71 }
72 }
73
74 pub fn average_setup_duration(&self) -> f32 {
75 if self.count == 0 {
76 0.0
77 } else {
78 self.cumulative_setup_duration / self.count as f32
79 }
80 }
81
82 pub fn average_ball_speed_change(&self) -> f32 {
83 if self.count == 0 {
84 0.0
85 } else {
86 self.cumulative_ball_speed_change / self.count as f32
87 }
88 }
89
90 fn record_event(&mut self, event: &FlickEvent) {
91 self.labeled_event_counts.increment([confidence_band_label(
92 event.confidence >= FLICK_HIGH_CONFIDENCE,
93 )]);
94 self.sync_legacy_counts();
95 self.last_flick_time = Some(event.time);
96 self.last_flick_frame = Some(event.frame);
97 self.last_confidence = Some(event.confidence);
98 self.best_confidence = self.best_confidence.max(event.confidence);
99 self.cumulative_confidence += event.confidence;
100 self.cumulative_setup_duration += event.setup_duration;
101 self.cumulative_ball_speed_change += event.ball_speed_change;
102 }
103
104 pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
105 self.labeled_event_counts.count_matching(labels)
106 }
107
108 pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
109 LabeledCounts::complete_from_label_sets(
110 &[&CONFIDENCE_BAND_LABELS],
111 &self.labeled_event_counts,
112 )
113 }
114
115 fn sync_legacy_counts(&mut self) {
116 self.count = self.labeled_event_counts.total();
117 self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq)]
122struct FlickControlObservation {
123 horizontal_gap: f32,
124 vertical_gap: f32,
125}
126
127#[derive(Debug, Clone, PartialEq)]
128struct ActiveFlickSetup {
129 is_team_0: bool,
130 start_time: f32,
131 start_frame: usize,
132 last_time: f32,
133 last_frame: usize,
134 duration: f32,
135 horizontal_gap_integral: f32,
136 vertical_gap_integral: f32,
137 touch_count: u32,
138}
139
140#[derive(Debug, Clone, PartialEq)]
141struct FlickSetupSummary {
142 is_team_0: bool,
143 start_time: f32,
144 start_frame: usize,
145 last_time: f32,
146 last_frame: usize,
147 duration: f32,
148 average_horizontal_gap: f32,
149 average_vertical_gap: f32,
150 touch_count: u32,
151}
152
153#[derive(Debug, Clone, PartialEq)]
154struct RecentDodgeStart {
155 time: f32,
156 frame: usize,
157 setup: FlickSetupSummary,
158}
159
160#[derive(Debug, Clone, Default, PartialEq)]
161pub struct FlickCalculator {
162 player_stats: HashMap<PlayerId, FlickStats>,
163 events: Vec<FlickEvent>,
164 active_setups: HashMap<PlayerId, ActiveFlickSetup>,
165 recent_setups: HashMap<PlayerId, FlickSetupSummary>,
166 recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
167 previous_dodge_active: HashMap<PlayerId, bool>,
168 previous_ball_velocity: Option<glam::Vec3>,
169 current_last_flick_player: Option<PlayerId>,
170}
171
172impl FlickCalculator {
173 pub fn new() -> Self {
174 Self::default()
175 }
176
177 pub fn player_stats(&self) -> &HashMap<PlayerId, FlickStats> {
178 &self.player_stats
179 }
180
181 pub fn events(&self) -> &[FlickEvent] {
182 &self.events
183 }
184
185 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
186 if max_value <= min_value {
187 return 0.0;
188 }
189
190 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
191 }
192
193 fn begin_sample(&mut self, frame: &FrameInfo) {
194 for stats in self.player_stats.values_mut() {
195 stats.is_last_flick = false;
196 stats.time_since_last_flick = stats
197 .last_flick_time
198 .map(|time| (frame.time - time).max(0.0));
199 stats.frames_since_last_flick = stats
200 .last_flick_frame
201 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
202 }
203
204 if let Some(player_id) = self.current_last_flick_player.as_ref() {
205 if let Some(stats) = self.player_stats.get_mut(player_id) {
206 stats.is_last_flick = true;
207 }
208 }
209 }
210
211 fn ball_impulse(
212 frame: &FrameInfo,
213 ball: &BallFrameState,
214 previous_ball_velocity: Option<glam::Vec3>,
215 ) -> glam::Vec3 {
216 const BALL_GRAVITY_Z: f32 = -650.0;
217
218 let Some(ball) = ball.sample() else {
219 return glam::Vec3::ZERO;
220 };
221 let Some(previous_ball_velocity) = previous_ball_velocity else {
222 return glam::Vec3::ZERO;
223 };
224
225 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
226 ball.velocity() - previous_ball_velocity - expected_linear_delta
227 }
228
229 fn control_observation(
230 ball: &BallSample,
231 player: &PlayerSample,
232 controlling_player: Option<&PlayerId>,
233 ) -> Option<FlickControlObservation> {
234 if controlling_player != Some(&player.player_id) {
235 return None;
236 }
237
238 let player_rigid_body = player.rigid_body.as_ref()?;
239 let player_position = player.position()?;
240 let ball_position = ball.position();
241 if !(BALL_CARRY_MIN_BALL_Z..=FLICK_MAX_CONTROL_BALL_Z).contains(&ball_position.z) {
242 return None;
243 }
244
245 let horizontal_gap = player_position
246 .truncate()
247 .distance(ball_position.truncate());
248 if horizontal_gap > FLICK_MAX_CONTROL_HORIZONTAL_GAP {
249 return None;
250 }
251
252 let vertical_gap = ball_position.z - player_position.z;
253 if !(FLICK_MIN_CONTROL_VERTICAL_GAP..=FLICK_MAX_CONTROL_VERTICAL_GAP)
254 .contains(&vertical_gap)
255 {
256 return None;
257 }
258
259 let local_ball_position =
260 quat_to_glam(&player_rigid_body.rotation).inverse() * (ball_position - player_position);
261 if local_ball_position.x < -FLICK_MAX_LOCAL_X_BEHIND
262 || local_ball_position.x > FLICK_MAX_LOCAL_X_FRONT
263 || local_ball_position.y.abs() > FLICK_MAX_LOCAL_Y
264 || local_ball_position.z < FLICK_MIN_LOCAL_Z
265 {
266 return None;
267 }
268
269 Some(FlickControlObservation {
270 horizontal_gap,
271 vertical_gap,
272 })
273 }
274
275 fn setup_summary(setup: &ActiveFlickSetup) -> FlickSetupSummary {
276 FlickSetupSummary {
277 is_team_0: setup.is_team_0,
278 start_time: setup.start_time,
279 start_frame: setup.start_frame,
280 last_time: setup.last_time,
281 last_frame: setup.last_frame,
282 duration: setup.duration,
283 average_horizontal_gap: setup.horizontal_gap_integral
284 / setup.duration.max(f32::EPSILON),
285 average_vertical_gap: setup.vertical_gap_integral / setup.duration.max(f32::EPSILON),
286 touch_count: setup.touch_count,
287 }
288 }
289
290 fn setup_qualifies(setup: &FlickSetupSummary) -> bool {
291 setup.duration >= FLICK_MIN_SETUP_SECONDS || setup.touch_count >= FLICK_MIN_SETUP_TOUCHES
292 }
293
294 fn store_recent_setup(&mut self, player_id: PlayerId, setup: FlickSetupSummary) {
295 if Self::setup_qualifies(&setup) {
296 self.recent_setups.insert(player_id, setup);
297 }
298 }
299
300 fn finish_setup(&mut self, player_id: &PlayerId) {
301 let Some(setup) = self.active_setups.remove(player_id) else {
302 return;
303 };
304 self.store_recent_setup(player_id.clone(), Self::setup_summary(&setup));
305 }
306
307 fn recent_setup_for_player(
308 &self,
309 player_id: &PlayerId,
310 current_time: f32,
311 ) -> Option<FlickSetupSummary> {
312 if let Some(active) = self.active_setups.get(player_id) {
313 return Some(Self::setup_summary(active));
314 }
315
316 self.recent_setups
317 .get(player_id)
318 .filter(|setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS)
319 .cloned()
320 }
321
322 fn update_control_setups(
323 &mut self,
324 frame: &FrameInfo,
325 ball: &BallFrameState,
326 players: &PlayerFrameState,
327 touch_events: &[TouchEvent],
328 controlling_player: Option<&PlayerId>,
329 ) {
330 let Some(ball) = ball.sample() else {
331 let player_ids: Vec<_> = self.active_setups.keys().cloned().collect();
332 for player_id in player_ids {
333 self.finish_setup(&player_id);
334 }
335 return;
336 };
337
338 let mut observed_players = HashSet::new();
339 for player in &players.players {
340 let Some(observation) = Self::control_observation(ball, player, controlling_player)
341 else {
342 continue;
343 };
344 observed_players.insert(player.player_id.clone());
345 let setup = self
346 .active_setups
347 .entry(player.player_id.clone())
348 .or_insert_with(|| ActiveFlickSetup {
349 is_team_0: player.is_team_0,
350 start_time: (frame.time - frame.dt).max(0.0),
351 start_frame: frame.frame_number.saturating_sub(1),
352 last_time: frame.time,
353 last_frame: frame.frame_number,
354 duration: frame.dt.max(0.0),
355 horizontal_gap_integral: observation.horizontal_gap * frame.dt.max(0.0),
356 vertical_gap_integral: observation.vertical_gap * frame.dt.max(0.0),
357 touch_count: 0,
358 });
359
360 if setup.last_frame != frame.frame_number {
361 setup.last_time = frame.time;
362 setup.last_frame = frame.frame_number;
363 setup.duration += frame.dt.max(0.0);
364 setup.horizontal_gap_integral += observation.horizontal_gap * frame.dt.max(0.0);
365 setup.vertical_gap_integral += observation.vertical_gap * frame.dt.max(0.0);
366 }
367 }
368
369 for touch_event in touch_events {
370 let Some(player_id) = touch_event.player.as_ref() else {
371 continue;
372 };
373 if let Some(setup) = self.active_setups.get_mut(player_id) {
374 setup.touch_count += 1;
375 }
376 }
377
378 let active_ids: Vec<_> = self.active_setups.keys().cloned().collect();
379 for player_id in active_ids {
380 if !observed_players.contains(&player_id) {
381 self.finish_setup(&player_id);
382 }
383 }
384 }
385
386 fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
387 for player in &players.players {
388 let was_dodge_active = self
389 .previous_dodge_active
390 .insert(player.player_id.clone(), player.dodge_active)
391 .unwrap_or(false);
392 if !player.dodge_active || was_dodge_active {
393 continue;
394 }
395
396 let Some(setup) = self.recent_setup_for_player(&player.player_id, frame.time) else {
397 continue;
398 };
399 if !Self::setup_qualifies(&setup) {
400 continue;
401 }
402
403 self.recent_dodge_starts.insert(
404 player.player_id.clone(),
405 RecentDodgeStart {
406 time: frame.time,
407 frame: frame.frame_number,
408 setup,
409 },
410 );
411 }
412 }
413
414 fn prune_recent_state(&mut self, current_time: f32) {
415 self.recent_setups
416 .retain(|_, setup| current_time - setup.last_time <= FLICK_MAX_SETUP_STALE_SECONDS);
417 self.recent_dodge_starts
418 .retain(|_, dodge| current_time - dodge.time <= FLICK_MAX_DODGE_TO_TOUCH_SECONDS);
419 }
420
421 fn candidate_event(
422 &self,
423 ball: &BallFrameState,
424 player: &PlayerSample,
425 touch_event: &TouchEvent,
426 dodge_start: &RecentDodgeStart,
427 ball_impulse: glam::Vec3,
428 ) -> Option<FlickEvent> {
429 let ball = ball.sample()?;
430 let player_position = player.position()?;
431 let time_since_dodge = touch_event.time - dodge_start.time;
432 if !(0.0..=FLICK_MAX_DODGE_TO_TOUCH_SECONDS).contains(&time_since_dodge) {
433 return None;
434 }
435
436 let ball_speed_change = ball_impulse.length();
437 if ball_speed_change < FLICK_MIN_BALL_SPEED_CHANGE {
438 return None;
439 }
440
441 let to_ball = (ball.position() - player_position).normalize_or_zero();
442 let impulse_direction = ball_impulse.normalize_or_zero();
443 if to_ball.length_squared() <= f32::EPSILON
444 || impulse_direction.length_squared() <= f32::EPSILON
445 {
446 return None;
447 }
448
449 let impulse_away_alignment = impulse_direction.dot(to_ball);
450 if impulse_away_alignment < FLICK_MIN_IMPULSE_AWAY_ALIGNMENT {
451 return None;
452 }
453
454 let vertical_impulse = ball_impulse.z.max(0.0);
455 let setup = &dodge_start.setup;
456 let timing_score =
457 1.0 - (time_since_dodge / FLICK_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
458 let setup_duration_score =
459 Self::normalize_score(setup.duration, FLICK_MIN_SETUP_SECONDS, 0.75);
460 let touch_score =
461 (setup.touch_count as f32 / FLICK_MIN_SETUP_TOUCHES as f32).clamp(0.0, 1.0);
462 let horizontal_control_score =
463 1.0 - (setup.average_horizontal_gap / FLICK_MAX_CONTROL_HORIZONTAL_GAP).clamp(0.0, 1.0);
464 let vertical_control_score = 1.0
465 - ((setup.average_vertical_gap - 110.0).abs() / FLICK_MAX_CONTROL_VERTICAL_GAP)
466 .clamp(0.0, 1.0);
467 let impulse_score =
468 Self::normalize_score(ball_speed_change, FLICK_MIN_BALL_SPEED_CHANGE, 1450.0);
469 let away_score = Self::normalize_score(
470 impulse_away_alignment,
471 FLICK_MIN_IMPULSE_AWAY_ALIGNMENT,
472 0.85,
473 );
474 let vertical_score = Self::normalize_score(vertical_impulse, 100.0, 750.0);
475
476 let confidence = 0.16 * timing_score
477 + 0.19 * setup_duration_score.max(touch_score)
478 + 0.12 * horizontal_control_score
479 + 0.10 * vertical_control_score
480 + 0.22 * impulse_score
481 + 0.15 * away_score
482 + 0.06 * vertical_score;
483 if confidence < FLICK_MIN_CONFIDENCE {
484 return None;
485 }
486
487 Some(FlickEvent {
488 time: touch_event.time,
489 frame: touch_event.frame,
490 sample_time: touch_event.time,
491 sample_frame: touch_event.frame,
492 player: player.player_id.clone(),
493 is_team_0: player.is_team_0,
494 dodge_time: dodge_start.time,
495 dodge_frame: dodge_start.frame,
496 time_since_dodge,
497 setup_start_time: setup.start_time,
498 setup_start_frame: setup.start_frame,
499 setup_duration: setup.duration,
500 setup_touch_count: setup.touch_count,
501 average_horizontal_gap: setup.average_horizontal_gap,
502 average_vertical_gap: setup.average_vertical_gap,
503 ball_speed_change,
504 ball_impulse: ball_impulse.to_array(),
505 impulse_away_alignment,
506 vertical_impulse,
507 confidence,
508 })
509 }
510
511 fn apply_event(&mut self, frame: &FrameInfo, mut event: FlickEvent) {
512 event.sample_time = frame.time;
513 event.sample_frame = frame.frame_number;
514 let stats = self.player_stats.entry(event.player.clone()).or_default();
515 stats.record_event(&event);
516 stats.is_last_flick = true;
517 stats.time_since_last_flick = Some((frame.time - event.time).max(0.0));
518 stats.frames_since_last_flick = Some(frame.frame_number.saturating_sub(event.frame));
519
520 self.current_last_flick_player = Some(event.player.clone());
521 self.events.push(event);
522 }
523
524 fn apply_touch_events(
525 &mut self,
526 frame: &FrameInfo,
527 ball: &BallFrameState,
528 players: &PlayerFrameState,
529 touch_events: &[TouchEvent],
530 ) {
531 let ball_impulse = Self::ball_impulse(frame, ball, self.previous_ball_velocity);
532
533 for touch_event in touch_events {
534 let Some(player_id) = touch_event.player.as_ref() else {
535 continue;
536 };
537 let Some(player) = players
538 .players
539 .iter()
540 .find(|player| &player.player_id == player_id)
541 else {
542 continue;
543 };
544 let Some(dodge_start) = self.recent_dodge_starts.get(player_id) else {
545 continue;
546 };
547 let Some(event) =
548 self.candidate_event(ball, player, touch_event, dodge_start, ball_impulse)
549 else {
550 continue;
551 };
552
553 self.apply_event(frame, event);
554 }
555
556 if let Some(player_id) = self.current_last_flick_player.as_ref() {
557 if let Some(stats) = self.player_stats.get_mut(player_id) {
558 stats.is_last_flick = true;
559 }
560 }
561 }
562
563 fn reset_live_play_state(&mut self, ball: &BallFrameState) {
564 self.current_last_flick_player = None;
565 self.active_setups.clear();
566 self.recent_setups.clear();
567 self.recent_dodge_starts.clear();
568 self.previous_dodge_active.clear();
569 self.previous_ball_velocity = ball.velocity();
570 }
571
572 pub fn update(
573 &mut self,
574 frame: &FrameInfo,
575 ball: &BallFrameState,
576 players: &PlayerFrameState,
577 touch_state: &TouchState,
578 live_play_state: &LivePlayState,
579 ) -> SubtrActorResult<()> {
580 if !live_play_state.is_live_play {
581 self.reset_live_play_state(ball);
582 return Ok(());
583 }
584
585 self.begin_sample(frame);
586 self.prune_recent_state(frame.time);
587 self.update_control_setups(
588 frame,
589 ball,
590 players,
591 &touch_state.touch_events,
592 touch_state.last_touch_player.as_ref(),
593 );
594 self.track_dodge_starts(frame, players);
595 self.apply_touch_events(frame, ball, players, &touch_state.touch_events);
596 self.previous_ball_velocity = ball.velocity();
597 Ok(())
598 }
599}
600
601#[cfg(test)]
602#[path = "flick_tests.rs"]
603mod tests;