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