1use super::*;
2
3const SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS: f32 = 1.1;
4const SPEED_FLIP_EVALUATION_SECONDS: f32 = 0.32;
5const SPEED_FLIP_MAX_CANDIDATE_SECONDS: f32 = 0.55;
6const SPEED_FLIP_MAX_GROUND_Z: f32 = 80.0;
7const SPEED_FLIP_KICKOFF_MOTION_SPEED: f32 = 100.0;
8const SPEED_FLIP_MIN_ALIGNMENT: f32 = 0.72;
9const SPEED_FLIP_DODGE_ACCELERATION_SAMPLE_SECONDS: f32 = 0.18;
10const SPEED_FLIP_MIN_FORWARD_DODGE_DELTA: f32 = 80.0;
11const SPEED_FLIP_MIN_FORWARD_DODGE_DELTA_ALIGNMENT: f32 = 0.35;
12const SPEED_FLIP_MIN_CONFIDENCE: f32 = 0.45;
13const SPEED_FLIP_HIGH_CONFIDENCE: f32 = 0.75;
14
15#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
16#[ts(export)]
17pub struct SpeedFlipEvent {
18 pub time: f32,
19 pub frame: usize,
20 pub resolved_time: f32,
21 pub resolved_frame: usize,
22 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
23 pub player: PlayerId,
24 pub is_team_0: bool,
25 pub time_since_kickoff_start: f32,
26 pub start_position: [f32; 3],
27 pub end_position: [f32; 3],
28 pub start_speed: f32,
29 pub max_speed: f32,
30 pub best_alignment: f32,
31 pub diagonal_score: f32,
32 pub cancel_score: f32,
33 pub speed_score: f32,
34 pub confidence: f32,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
38#[ts(export)]
39pub struct SpeedFlipStats {
40 pub count: u32,
41 pub high_confidence_count: u32,
42 pub is_last_speed_flip: bool,
43 pub last_speed_flip_time: Option<f32>,
44 pub last_speed_flip_frame: Option<usize>,
45 pub time_since_last_speed_flip: Option<f32>,
46 pub frames_since_last_speed_flip: Option<usize>,
47 pub last_quality: Option<f32>,
48 pub best_quality: f32,
49 pub cumulative_quality: f32,
50 #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
51 pub labeled_event_counts: LabeledCounts,
52}
53
54impl SpeedFlipStats {
55 pub fn average_quality(&self) -> f32 {
56 if self.count == 0 {
57 0.0
58 } else {
59 self.cumulative_quality / self.count as f32
60 }
61 }
62
63 fn record_event(&mut self, event: &SpeedFlipEvent) {
64 self.labeled_event_counts.increment([confidence_band_label(
65 event.confidence >= SPEED_FLIP_HIGH_CONFIDENCE,
66 )]);
67 self.sync_legacy_counts();
68 self.last_speed_flip_time = Some(event.time);
69 self.last_speed_flip_frame = Some(event.frame);
70 self.last_quality = Some(event.confidence);
71 self.best_quality = self.best_quality.max(event.confidence);
72 self.cumulative_quality += event.confidence;
73 }
74
75 pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
76 self.labeled_event_counts.count_matching(labels)
77 }
78
79 pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
80 LabeledCounts::complete_from_label_sets(
81 &[&CONFIDENCE_BAND_LABELS],
82 &self.labeled_event_counts,
83 )
84 }
85
86 fn sync_legacy_counts(&mut self) {
87 self.count = self.labeled_event_counts.total();
88 self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
89 }
90}
91
92#[derive(Debug, Clone, PartialEq)]
93struct ActiveSpeedFlipCandidate {
94 is_team_0: bool,
95 is_kickoff: bool,
96 kickoff_start_time: Option<f32>,
97 start_time: f32,
98 start_frame: usize,
99 start_position: [f32; 3],
100 end_position: [f32; 3],
101 start_velocity_xy: glam::Vec2,
102 start_forward_xy: glam::Vec2,
103 start_speed: f32,
104 max_speed: f32,
105 best_alignment: f32,
106 best_boost_alignment: f32,
107 boost_alignment_sample_count: u32,
108 best_dodge_forward_delta: f32,
109 best_dodge_delta_alignment: f32,
110 dodge_acceleration_sample_count: u32,
111 best_diagonal_score: f32,
112 min_forward_z: f32,
113 latest_forward_z: f32,
114 latest_time: f32,
115 latest_frame: usize,
116}
117
118#[derive(Debug, Clone, Default, PartialEq)]
119pub struct SpeedFlipCalculator {
120 player_stats: HashMap<PlayerId, SpeedFlipStats>,
121 events: Vec<SpeedFlipEvent>,
122 active_candidates: HashMap<PlayerId, ActiveSpeedFlipCandidate>,
123 previous_dodge_active: HashMap<PlayerId, bool>,
124 kickoff_approach_active_last_frame: bool,
125 current_kickoff_start_time: Option<f32>,
126 current_last_speed_flip_player: Option<PlayerId>,
127}
128
129impl SpeedFlipCalculator {
130 pub fn new() -> Self {
131 Self::default()
132 }
133
134 pub fn player_stats(&self) -> &HashMap<PlayerId, SpeedFlipStats> {
135 &self.player_stats
136 }
137
138 pub fn events(&self) -> &[SpeedFlipEvent] {
139 &self.events
140 }
141
142 fn kickoff_approach_active(gameplay: &GameplayState) -> bool {
143 gameplay.ball_has_been_hit == Some(false)
144 }
145
146 fn player_by_id<'a>(
147 players: &'a PlayerFrameState,
148 player_id: &PlayerId,
149 ) -> Option<&'a PlayerSample> {
150 players
151 .players
152 .iter()
153 .find(|player| &player.player_id == player_id)
154 }
155
156 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
157 if max_value <= min_value {
158 return 0.0;
159 }
160
161 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
162 }
163
164 fn diagonal_score(local_angular_velocity: glam::Vec3) -> f32 {
165 let pitch_rate = local_angular_velocity.y.abs();
166 let side_spin = local_angular_velocity
167 .x
168 .abs()
169 .max(local_angular_velocity.z.abs());
170 if pitch_rate <= f32::EPSILON || side_spin <= f32::EPSILON {
171 return 0.0;
172 }
173
174 let pitch_score = Self::normalize_score(pitch_rate, 35.0, 180.0);
175 let side_score = Self::normalize_score(side_spin, 60.0, 260.0);
176 let balance = pitch_rate.min(side_spin) / pitch_rate.max(side_spin);
177 let balance_score = Self::normalize_score(balance, 0.18, 0.65);
178
179 (pitch_score * side_score).sqrt() * (0.75 + 0.25 * balance_score)
180 }
181
182 fn forward_speed_alignment(player: &PlayerSample) -> Option<f32> {
183 let velocity = player.velocity()?;
184 let rigid_body = player.rigid_body.as_ref()?;
185 let velocity_xy = velocity.truncate().normalize_or_zero();
186 if velocity_xy.length_squared() <= f32::EPSILON {
187 return None;
188 }
189
190 let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
191 .truncate()
192 .normalize_or_zero();
193 if forward_xy.length_squared() <= f32::EPSILON {
194 return None;
195 }
196
197 Some(forward_xy.dot(velocity_xy))
198 }
199
200 fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
201 let rigid_body = player.rigid_body.as_ref()?;
202 let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
203 .truncate()
204 .normalize_or_zero();
205 (forward_xy.length_squared() > f32::EPSILON).then_some(forward_xy)
206 }
207
208 fn boost_alignment(player: &PlayerSample) -> Option<f32> {
209 player
210 .boost_active
211 .then(|| Self::forward_speed_alignment(player))
212 .flatten()
213 }
214
215 fn candidate_alignment(
216 _ball: &BallFrameState,
217 player: &PlayerSample,
218 _is_kickoff: bool,
219 ) -> Option<f32> {
220 Self::forward_speed_alignment(player)
221 }
222
223 fn apply_event(&mut self, event: SpeedFlipEvent) {
224 for stats in self.player_stats.values_mut() {
225 stats.is_last_speed_flip = false;
226 }
227
228 let stats = self.player_stats.entry(event.player.clone()).or_default();
229 stats.record_event(&event);
230 stats.is_last_speed_flip = true;
231 stats.time_since_last_speed_flip = Some(0.0);
232 stats.frames_since_last_speed_flip = Some(0);
233
234 self.current_last_speed_flip_player = Some(event.player.clone());
235 self.events.push(event);
236 }
237
238 fn begin_sample(&mut self, frame: &FrameInfo) {
239 for stats in self.player_stats.values_mut() {
240 stats.is_last_speed_flip = false;
241 stats.time_since_last_speed_flip = stats
242 .last_speed_flip_time
243 .map(|time| (frame.time - time).max(0.0));
244 stats.frames_since_last_speed_flip = stats
245 .last_speed_flip_frame
246 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
247 }
248
249 if let Some(player_id) = self.current_last_speed_flip_player.as_ref() {
250 if let Some(stats) = self.player_stats.get_mut(player_id) {
251 stats.is_last_speed_flip = true;
252 }
253 }
254 }
255
256 fn reset_kickoff_state(&mut self) {
257 self.active_candidates.clear();
258 self.current_kickoff_start_time = None;
259 }
260
261 fn kickoff_motion_started(players: &PlayerFrameState) -> bool {
262 players.players.iter().any(|player| {
263 player.dodge_active
264 || player
265 .speed()
266 .is_some_and(|speed| speed >= SPEED_FLIP_KICKOFF_MOTION_SPEED)
267 })
268 }
269
270 fn update_kickoff_start_time(
271 &mut self,
272 frame: &FrameInfo,
273 kickoff_approach_active: bool,
274 players: &PlayerFrameState,
275 ) {
276 if !kickoff_approach_active {
277 self.current_kickoff_start_time = None;
278 return;
279 }
280
281 if self.current_kickoff_start_time.is_none() && Self::kickoff_motion_started(players) {
282 self.current_kickoff_start_time = Some(frame.time);
283 }
284 }
285
286 fn maybe_start_candidate(
287 &mut self,
288 frame: &FrameInfo,
289 gameplay: &GameplayState,
290 ball: &BallFrameState,
291 player: &PlayerSample,
292 _live_play: bool,
293 ) {
294 let was_dodge_active = self
295 .previous_dodge_active
296 .insert(player.player_id.clone(), player.dodge_active)
297 .unwrap_or(false);
298 if !player.dodge_active || was_dodge_active {
299 return;
300 }
301
302 let is_kickoff = Self::kickoff_approach_active(gameplay);
303 let kickoff_start_time = if is_kickoff {
304 let Some(kickoff_start_time) = self.current_kickoff_start_time else {
305 return;
306 };
307 if frame.time - kickoff_start_time > SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS {
308 return;
309 }
310 Some(kickoff_start_time)
311 } else {
312 None
313 };
314
315 let Some(rigid_body) = player.rigid_body.as_ref() else {
316 return;
317 };
318 let Some(player_position) = player.position() else {
319 return;
320 };
321 if player_position.z > SPEED_FLIP_MAX_GROUND_Z {
322 return;
323 }
324
325 let start_speed = player.speed().unwrap_or(0.0);
326
327 let Some(best_alignment) = Self::candidate_alignment(ball, player, is_kickoff) else {
328 return;
329 };
330 if best_alignment < SPEED_FLIP_MIN_ALIGNMENT {
331 return;
332 }
333 let Some(start_velocity_xy) = player.velocity().map(|velocity| velocity.truncate()) else {
334 return;
335 };
336 let Some(start_forward_xy) = Self::forward_xy(player) else {
337 return;
338 };
339
340 let rotation = quat_to_glam(&rigid_body.rotation);
341 let local_angular_velocity = rigid_body
342 .angular_velocity
343 .as_ref()
344 .map(vec_to_glam)
345 .map(|angular_velocity| rotation.inverse() * angular_velocity)
346 .unwrap_or(glam::Vec3::ZERO);
347 let best_diagonal_score = Self::diagonal_score(local_angular_velocity);
348 let forward_z = (rotation * glam::Vec3::X).z;
349
350 self.active_candidates.insert(
351 player.player_id.clone(),
352 ActiveSpeedFlipCandidate {
353 is_team_0: player.is_team_0,
354 is_kickoff,
355 kickoff_start_time,
356 start_time: frame.time,
357 start_frame: frame.frame_number,
358 start_position: player_position.to_array(),
359 end_position: player_position.to_array(),
360 start_velocity_xy,
361 start_forward_xy,
362 start_speed,
363 max_speed: start_speed,
364 best_alignment,
365 best_boost_alignment: Self::boost_alignment(player).unwrap_or(best_alignment),
366 boost_alignment_sample_count: u32::from(player.boost_active),
367 best_dodge_forward_delta: 0.0,
368 best_dodge_delta_alignment: -1.0,
369 dodge_acceleration_sample_count: 0,
370 best_diagonal_score,
371 min_forward_z: forward_z,
372 latest_forward_z: forward_z,
373 latest_time: frame.time,
374 latest_frame: frame.frame_number,
375 },
376 );
377 }
378
379 fn update_candidate(
380 candidate: &mut ActiveSpeedFlipCandidate,
381 frame: &FrameInfo,
382 ball: &BallFrameState,
383 player: &PlayerSample,
384 ) {
385 let Some(rigid_body) = player.rigid_body.as_ref() else {
386 return;
387 };
388
389 if let Some(player_position) = player.position() {
390 candidate.end_position = player_position.to_array();
391 }
392 candidate.max_speed = candidate.max_speed.max(player.speed().unwrap_or(0.0));
393 if let Some(alignment) = Self::candidate_alignment(ball, player, candidate.is_kickoff) {
394 candidate.best_alignment = candidate.best_alignment.max(alignment);
395 }
396 if let Some(boost_alignment) = Self::boost_alignment(player) {
397 candidate.best_boost_alignment = candidate.best_boost_alignment.max(boost_alignment);
398 candidate.boost_alignment_sample_count += 1;
399 }
400 if frame.time > candidate.start_time
401 && frame.time - candidate.start_time <= SPEED_FLIP_DODGE_ACCELERATION_SAMPLE_SECONDS
402 {
403 if let Some(velocity) = player.velocity() {
404 let velocity_delta = velocity.truncate() - candidate.start_velocity_xy;
405 let delta_length = velocity_delta.length();
406 if delta_length > f32::EPSILON {
407 let forward_delta = velocity_delta.dot(candidate.start_forward_xy);
408 candidate.best_dodge_forward_delta =
409 candidate.best_dodge_forward_delta.max(forward_delta);
410 candidate.best_dodge_delta_alignment = candidate
411 .best_dodge_delta_alignment
412 .max(forward_delta / delta_length);
413 candidate.dodge_acceleration_sample_count += 1;
414 }
415 }
416 }
417
418 let rotation = quat_to_glam(&rigid_body.rotation);
419 let local_angular_velocity = rigid_body
420 .angular_velocity
421 .as_ref()
422 .map(vec_to_glam)
423 .map(|angular_velocity| rotation.inverse() * angular_velocity)
424 .unwrap_or(glam::Vec3::ZERO);
425 candidate.best_diagonal_score = candidate
426 .best_diagonal_score
427 .max(Self::diagonal_score(local_angular_velocity));
428
429 let forward_z = (rotation * glam::Vec3::X).z;
430 candidate.min_forward_z = candidate.min_forward_z.min(forward_z);
431 candidate.latest_forward_z = forward_z;
432 candidate.latest_time = frame.time;
433 candidate.latest_frame = frame.frame_number;
434 }
435
436 fn candidate_event(
437 player_id: &PlayerId,
438 candidate: ActiveSpeedFlipCandidate,
439 ) -> Option<SpeedFlipEvent> {
440 let time_since_kickoff_start = candidate
441 .kickoff_start_time
442 .map(|kickoff_start_time| (candidate.start_time - kickoff_start_time).max(0.0))
443 .unwrap_or(0.0);
444 let timeliness_score = if candidate.is_kickoff {
445 1.0 - Self::normalize_score(time_since_kickoff_start, 0.55, 1.1)
446 } else {
447 1.0
448 };
449 let cancel_recovery = candidate.latest_forward_z - candidate.min_forward_z;
450 let level_recovery_score =
451 1.0 - Self::normalize_score(candidate.latest_forward_z.abs(), 0.05, 0.55);
452 let cancel_score = 0.25 * Self::normalize_score(-candidate.min_forward_z, 0.05, 0.35)
453 + 0.35 * Self::normalize_score(cancel_recovery, 0.08, 0.5)
454 + 0.40 * level_recovery_score;
455 let speed_score = 0.55 * Self::normalize_score(candidate.max_speed, 1450.0, 1900.0)
456 + 0.45
457 * Self::normalize_score(candidate.max_speed - candidate.start_speed, 180.0, 650.0);
458 let alignment_score = Self::normalize_score(candidate.best_alignment, 0.78, 0.98);
459 if candidate.boost_alignment_sample_count == 0 {
460 return None;
461 }
462 if candidate.dodge_acceleration_sample_count == 0
463 || candidate.best_dodge_forward_delta < SPEED_FLIP_MIN_FORWARD_DODGE_DELTA
464 || candidate.best_dodge_delta_alignment < SPEED_FLIP_MIN_FORWARD_DODGE_DELTA_ALIGNMENT
465 {
466 return None;
467 }
468 let boost_alignment_score =
469 Self::normalize_score(candidate.best_boost_alignment, 0.82, 0.99);
470 let confidence = 0.30 * candidate.best_diagonal_score
471 + 0.30 * cancel_score
472 + 0.15 * speed_score
473 + 0.15 * alignment_score
474 + 0.05 * boost_alignment_score
475 + 0.05 * timeliness_score;
476
477 if boost_alignment_score < 0.25 {
478 return None;
479 }
480 if cancel_score < 0.35 || confidence < SPEED_FLIP_MIN_CONFIDENCE {
481 return None;
482 }
483
484 Some(SpeedFlipEvent {
485 time: candidate.start_time,
486 frame: candidate.start_frame,
487 resolved_time: candidate.latest_time,
488 resolved_frame: candidate.latest_frame,
489 player: player_id.clone(),
490 is_team_0: candidate.is_team_0,
491 time_since_kickoff_start,
492 start_position: candidate.start_position,
493 end_position: candidate.end_position,
494 start_speed: candidate.start_speed,
495 max_speed: candidate.max_speed,
496 best_alignment: candidate.best_alignment,
497 diagonal_score: candidate.best_diagonal_score,
498 cancel_score,
499 speed_score,
500 confidence,
501 })
502 }
503
504 fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
505 let mut finished_candidates = Vec::new();
506
507 for (player_id, candidate) in &self.active_candidates {
508 let duration = frame.time - candidate.start_time;
509 if force_all || duration >= SPEED_FLIP_EVALUATION_SECONDS {
510 finished_candidates.push((
511 candidate.start_time,
512 candidate.start_frame,
513 format!("{player_id:?}"),
514 player_id.clone(),
515 ));
516 }
517 }
518
519 finished_candidates.sort_by(|left, right| {
520 left.0
521 .total_cmp(&right.0)
522 .then_with(|| left.1.cmp(&right.1))
523 .then_with(|| left.2.cmp(&right.2))
524 });
525
526 for (_, _, _, player_id) in finished_candidates {
527 let Some(candidate) = self.active_candidates.remove(&player_id) else {
528 continue;
529 };
530 if let Some(event) = Self::candidate_event(&player_id, candidate) {
531 self.apply_event(event);
532 }
533 }
534 }
535
536 pub fn update_parts(
537 &mut self,
538 frame: &FrameInfo,
539 gameplay: &GameplayState,
540 ball: &BallFrameState,
541 players: &PlayerFrameState,
542 live_play: bool,
543 ) -> SubtrActorResult<()> {
544 let kickoff_approach_active = Self::kickoff_approach_active(gameplay);
545 if !live_play && !kickoff_approach_active {
546 self.active_candidates.clear();
547 self.current_kickoff_start_time = None;
548 self.kickoff_approach_active_last_frame = false;
549 return Ok(());
550 }
551
552 self.begin_sample(frame);
553
554 if kickoff_approach_active && !self.kickoff_approach_active_last_frame {
555 self.reset_kickoff_state();
556 }
557
558 self.update_kickoff_start_time(frame, kickoff_approach_active, players);
559
560 for player in &players.players {
561 self.maybe_start_candidate(frame, gameplay, ball, player, live_play);
562 }
563
564 for (player_id, candidate) in &mut self.active_candidates {
565 let Some(player) = Self::player_by_id(players, player_id) else {
566 continue;
567 };
568 Self::update_candidate(candidate, frame, ball, player);
569 }
570
571 self.finalize_candidates(frame, false);
572
573 self.active_candidates.retain(|_, candidate| {
574 frame.time - candidate.start_time <= SPEED_FLIP_MAX_CANDIDATE_SECONDS
575 });
576
577 if !kickoff_approach_active {
578 self.current_kickoff_start_time = None;
579 }
580
581 self.kickoff_approach_active_last_frame = kickoff_approach_active;
582 Ok(())
583 }
584
585 pub fn finalize_parts(&mut self, frame: &FrameInfo) {
586 self.finalize_candidates(frame, true);
587 }
588}
589
590#[cfg(test)]
591#[path = "speed_flip_tests.rs"]
592mod tests;