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