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