1use super::*;
2
3const SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS: f32 = 1.1;
4const SPEED_FLIP_EVALUATION_SECONDS: f32 = 0.50;
5const SPEED_FLIP_MAX_CANDIDATE_SECONDS: f32 = 0.70;
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_DIAGONAL_SCORE: f32 = 0.35;
11const SPEED_FLIP_MIN_UP_ROTATION_DEGREES: f32 = 90.0;
12const SPEED_FLIP_MAX_UP_ROTATION_DEGREES: f32 = 170.0;
18const SPEED_FLIP_MAX_CANCELLED_FORWARD_ROTATION_DEGREES: f32 = 45.0;
19const SPEED_FLIP_MIN_BOOST_ALIGNMENT: f32 = 0.80;
20const SPEED_FLIP_MIN_CONFIDENCE: f32 = 0.45;
21const BOOST_ACCELERATION_UU_PER_SECOND_SQUARED: f32 = 991.6667;
22
23#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
25#[ts(export)]
26pub struct SpeedFlipEvent {
27 pub time: f32,
28 pub frame: usize,
29 pub resolved_time: f32,
30 pub resolved_frame: usize,
31 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
32 pub player: PlayerId,
33 pub is_team_0: bool,
34 pub time_since_kickoff_start: f32,
35 pub start_position: [f32; 3],
36 pub end_position: [f32; 3],
37 pub start_speed: f32,
38 pub max_speed: f32,
39 pub best_alignment: f32,
40 #[serde(default)]
41 pub initial_boost_alignment: f32,
42 #[serde(default)]
43 pub best_boost_alignment: f32,
44 #[serde(default)]
45 pub boost_alignment_sample_count: u32,
46 #[serde(default)]
47 pub dodge_delay_after_ground_leave_seconds: f32,
48 pub diagonal_score: f32,
49 #[serde(default)]
50 pub estimated_dodge_impulse_magnitude: f32,
51 #[serde(default)]
52 pub estimated_dodge_impulse_forward_component: f32,
53 #[serde(default)]
54 pub estimated_dodge_impulse_side_component: f32,
55 #[serde(default)]
56 pub estimated_dodge_impulse_up_component: f32,
57 pub cancel_score: f32,
58 pub speed_score: f32,
59 pub confidence: f32,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63struct ActiveSpeedFlipCandidate {
64 is_team_0: bool,
65 is_kickoff: bool,
66 kickoff_start_time: Option<f32>,
67 start_time: f32,
68 start_frame: usize,
69 start_position: [f32; 3],
70 end_position: [f32; 3],
71 start_velocity: glam::Vec3,
72 start_velocity_xy: glam::Vec2,
73 start_forward_xy: glam::Vec2,
74 local_forward: glam::Vec3,
75 local_right: glam::Vec3,
76 local_up: glam::Vec3,
77 start_speed: f32,
78 max_speed: f32,
79 best_alignment: f32,
80 initial_boost_alignment: Option<f32>,
81 best_boost_alignment: f32,
82 boost_alignment_sample_count: u32,
83 dodge_delay_after_ground_leave_seconds: f32,
84 dodge_boost_compensation: glam::Vec3,
85 best_dodge_forward_delta: f32,
86 best_dodge_delta_alignment: f32,
87 best_estimated_dodge_impulse_magnitude: f32,
88 best_estimated_dodge_impulse_forward_component: f32,
89 best_estimated_dodge_impulse_side_component: f32,
90 best_estimated_dodge_impulse_up_component: f32,
91 dodge_acceleration_sample_count: u32,
92 dodge_torque: Option<glam::Vec3>,
97 best_diagonal_score: f32,
98 max_forward_rotation_degrees: f32,
99 max_up_rotation_degrees: f32,
100 min_forward_z: f32,
101 latest_forward_z: f32,
102 latest_time: f32,
103 latest_frame: usize,
104}
105
106impl InFlightItem for ActiveSpeedFlipCandidate {
107 fn recognition(&self) -> Recognition {
108 Recognition::speculative(self.start_time, self.start_frame)
111 }
112
113 fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
114 Disposition::Discard
115 }
116}
117
118#[derive(Debug, Clone, Default, PartialEq)]
120pub struct SpeedFlipCalculator {
121 events: EventStream<SpeedFlipEvent>,
122 active_candidates: KeyedInFlightLedger<PlayerId, ActiveSpeedFlipCandidate>,
123 previous_dodge_active: HashMap<PlayerId, bool>,
124 last_ground_contacts: HashMap<PlayerId, f32>,
125 kickoff_approach_active_last_frame: bool,
126 kickoff_window_open: bool,
127 current_kickoff_start_time: Option<f32>,
128}
129
130impl SpeedFlipCalculator {
131 pub fn new() -> Self {
132 Self::default()
133 }
134
135 pub fn events(&self) -> &[SpeedFlipEvent] {
136 self.events.all()
137 }
138
139 pub fn new_events(&self) -> &[SpeedFlipEvent] {
140 self.events.new_events()
141 }
142
143 fn update_kickoff_window(&mut self, gameplay: &GameplayState) -> bool {
154 self.kickoff_window_open =
155 if gameplay.kickoff_countdown_active() || gameplay.ball_has_been_hit == Some(false) {
156 true
157 } else if gameplay.ball_has_been_hit == Some(true) {
158 false
159 } else {
160 self.kickoff_window_open
163 };
164 self.kickoff_window_open
165 }
166
167 fn player_by_id<'a>(
168 players: &'a PlayerFrameState,
169 player_id: &PlayerId,
170 ) -> Option<&'a PlayerSample> {
171 players
172 .players
173 .iter()
174 .find(|player| &player.player_id == player_id)
175 }
176
177 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
178 if max_value <= min_value {
179 return 0.0;
180 }
181
182 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
183 }
184
185 fn diagonal_score(local_angular_velocity: glam::Vec3) -> f32 {
186 let pitch_rate = local_angular_velocity.y.abs();
187 let side_spin = local_angular_velocity
188 .x
189 .abs()
190 .max(local_angular_velocity.z.abs());
191 if pitch_rate <= f32::EPSILON || side_spin <= f32::EPSILON {
192 return 0.0;
193 }
194
195 let pitch_score = Self::normalize_score(pitch_rate, 35.0, 180.0);
196 let side_score = Self::normalize_score(side_spin, 60.0, 260.0);
197 let balance = pitch_rate.min(side_spin) / pitch_rate.max(side_spin);
198 let balance_score = Self::normalize_score(balance, 0.18, 0.65);
199
200 (pitch_score * side_score).sqrt() * (0.75 + 0.25 * balance_score)
201 }
202
203 fn diagonal_score_from_torque(torque: glam::Vec3) -> f32 {
218 let forward = torque.y;
219 let side = torque.x.abs();
220 if forward <= 0.0 {
221 return 0.0;
222 }
223 let magnitude = glam::Vec2::new(forward, side).length();
224 if magnitude <= f32::EPSILON {
225 return 0.0;
226 }
227 (2.0 * forward * side / (magnitude * magnitude)).clamp(0.0, 1.0)
230 }
231
232 fn forward_speed_alignment(player: &PlayerSample) -> Option<f32> {
233 let velocity = player.velocity()?;
234 let rigid_body = player.rigid_body.as_ref()?;
235 let velocity_xy = velocity.truncate().normalize_or_zero();
236 if velocity_xy.length_squared() <= f32::EPSILON {
237 return None;
238 }
239
240 let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
241 .truncate()
242 .normalize_or_zero();
243 if forward_xy.length_squared() <= f32::EPSILON {
244 return None;
245 }
246
247 Some(forward_xy.dot(velocity_xy))
248 }
249
250 fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
251 let rigid_body = player.rigid_body.as_ref()?;
252 let forward_xy = (quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
253 .truncate()
254 .normalize_or_zero();
255 (forward_xy.length_squared() > f32::EPSILON).then_some(forward_xy)
256 }
257
258 fn boost_alignment(player: &PlayerSample) -> Option<f32> {
259 player
260 .boost_active
261 .then(|| Self::forward_speed_alignment(player))
262 .flatten()
263 }
264
265 fn update_ground_contacts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
266 for player in &players.players {
267 if player
268 .position()
269 .is_some_and(|position| position.z <= PLAYER_GROUND_Z_THRESHOLD)
270 {
271 self.last_ground_contacts
272 .insert(player.player_id.clone(), frame.time);
273 }
274 }
275
276 self.last_ground_contacts
277 .retain(|_, ground_contact_time| frame.time - *ground_contact_time <= 2.0);
278 }
279
280 fn candidate_alignment(
281 _ball: &BallFrameState,
282 player: &PlayerSample,
283 _is_kickoff: bool,
284 ) -> Option<f32> {
285 Self::forward_speed_alignment(player)
286 }
287
288 fn apply_event(&mut self, event: SpeedFlipEvent) {
289 self.events.push(event);
290 }
291
292 fn reset_kickoff_state(&mut self) {
293 self.active_candidates.clear();
294 self.current_kickoff_start_time = None;
295 }
296
297 fn kickoff_motion_started(players: &PlayerFrameState) -> bool {
298 players.players.iter().any(|player| {
299 player.dodge_active
300 || player
301 .speed()
302 .is_some_and(|speed| speed >= SPEED_FLIP_KICKOFF_MOTION_SPEED)
303 })
304 }
305
306 fn update_kickoff_start_time(
307 &mut self,
308 frame: &FrameInfo,
309 kickoff_approach_active: bool,
310 players: &PlayerFrameState,
311 ) {
312 if !kickoff_approach_active {
313 self.current_kickoff_start_time = None;
314 return;
315 }
316
317 if self.current_kickoff_start_time.is_none() && Self::kickoff_motion_started(players) {
318 self.current_kickoff_start_time = Some(frame.time);
319 }
320 }
321
322 fn maybe_start_candidate(
323 &mut self,
324 frame: &FrameInfo,
325 kickoff_approach_active: bool,
326 ball: &BallFrameState,
327 player: &PlayerSample,
328 _live_play_state: &LivePlayState,
329 ) {
330 let was_dodge_active = self
331 .previous_dodge_active
332 .insert(player.player_id.clone(), player.dodge_active)
333 .unwrap_or(false);
334 if !player.dodge_active || was_dodge_active {
335 return;
336 }
337
338 let is_kickoff = kickoff_approach_active;
339 let kickoff_start_time = if is_kickoff {
340 let Some(kickoff_start_time) = self.current_kickoff_start_time else {
341 return;
342 };
343 if frame.time - kickoff_start_time > SPEED_FLIP_MAX_START_AFTER_KICKOFF_SECONDS {
344 return;
345 }
346 Some(kickoff_start_time)
347 } else {
348 None
349 };
350
351 let Some(rigid_body) = player.rigid_body.as_ref() else {
352 return;
353 };
354 let Some(player_position) = player.position() else {
355 return;
356 };
357 if player_position.z > SPEED_FLIP_MAX_GROUND_Z {
358 return;
359 }
360
361 let start_speed = player.speed().unwrap_or(0.0);
362 let Some(best_alignment) = Self::candidate_alignment(ball, player, is_kickoff) else {
363 return;
364 };
365 if best_alignment < SPEED_FLIP_MIN_ALIGNMENT {
366 return;
367 }
368 let Some(start_velocity) = player.velocity() else {
369 return;
370 };
371 let start_velocity_xy = start_velocity.truncate();
372 let Some(start_forward_xy) = Self::forward_xy(player) else {
373 return;
374 };
375 let dodge_delay_after_ground_leave_seconds = self
376 .last_ground_contacts
377 .get(&player.player_id)
378 .map(|ground_contact_time| (frame.time - *ground_contact_time).max(0.0))
379 .unwrap_or(0.0);
380
381 let rotation = quat_to_glam(&rigid_body.rotation);
382 let local_angular_velocity = rigid_body
383 .angular_velocity
384 .as_ref()
385 .map(vec_to_glam)
386 .map(|angular_velocity| rotation.inverse() * angular_velocity)
387 .unwrap_or(glam::Vec3::ZERO);
388 let best_diagonal_score = Self::diagonal_score(local_angular_velocity);
389 let forward_z = (rotation * glam::Vec3::X).z;
390 let initial_boost_alignment = Self::boost_alignment(player);
391
392 self.active_candidates.arm(
393 player.player_id.clone(),
394 ActiveSpeedFlipCandidate {
395 is_team_0: player.is_team_0,
396 is_kickoff,
397 kickoff_start_time,
398 start_time: frame.time,
399 start_frame: frame.frame_number,
400 start_position: player_position.to_array(),
401 end_position: player_position.to_array(),
402 start_velocity,
403 start_velocity_xy,
404 start_forward_xy,
405 local_forward: rotation * glam::Vec3::X,
406 local_right: rotation * glam::Vec3::Y,
407 local_up: rotation * glam::Vec3::Z,
408 start_speed,
409 max_speed: start_speed,
410 best_alignment,
411 initial_boost_alignment,
412 best_boost_alignment: initial_boost_alignment.unwrap_or(best_alignment),
413 boost_alignment_sample_count: u32::from(initial_boost_alignment.is_some()),
414 dodge_delay_after_ground_leave_seconds,
415 dodge_boost_compensation: glam::Vec3::ZERO,
416 best_dodge_forward_delta: 0.0,
417 best_dodge_delta_alignment: -1.0,
418 best_estimated_dodge_impulse_magnitude: 0.0,
419 best_estimated_dodge_impulse_forward_component: -1.0,
420 best_estimated_dodge_impulse_side_component: 0.0,
421 best_estimated_dodge_impulse_up_component: 1.0,
422 dodge_acceleration_sample_count: 0,
423 dodge_torque: player.dodge_torque,
424 best_diagonal_score,
425 max_forward_rotation_degrees: 0.0,
426 max_up_rotation_degrees: 0.0,
427 min_forward_z: forward_z,
428 latest_forward_z: forward_z,
429 latest_time: frame.time,
430 latest_frame: frame.frame_number,
431 },
432 );
433 }
434
435 fn update_candidate(
436 candidate: &mut ActiveSpeedFlipCandidate,
437 frame: &FrameInfo,
438 ball: &BallFrameState,
439 player: &PlayerSample,
440 ) {
441 let Some(rigid_body) = player.rigid_body.as_ref() else {
442 return;
443 };
444
445 if let Some(player_position) = player.position() {
446 candidate.end_position = player_position.to_array();
447 }
448 candidate.max_speed = candidate.max_speed.max(player.speed().unwrap_or(0.0));
449 if let Some(alignment) = Self::candidate_alignment(ball, player, candidate.is_kickoff) {
450 candidate.best_alignment = candidate.best_alignment.max(alignment);
451 }
452 if let Some(boost_alignment) = Self::boost_alignment(player) {
453 if candidate.initial_boost_alignment.is_none() {
454 candidate.initial_boost_alignment = Some(boost_alignment);
455 }
456 candidate.best_boost_alignment = candidate.best_boost_alignment.max(boost_alignment);
457 candidate.boost_alignment_sample_count += 1;
458 }
459 if frame.time > candidate.start_time
460 && frame.time - candidate.start_time <= SPEED_FLIP_DODGE_ACCELERATION_SAMPLE_SECONDS
461 {
462 if let Some(velocity) = player.velocity() {
463 if player.boost_active {
464 candidate.dodge_boost_compensation += candidate.local_forward
465 * BOOST_ACCELERATION_UU_PER_SECOND_SQUARED
466 * frame.dt;
467 }
468 let velocity_delta = velocity.truncate() - candidate.start_velocity_xy;
469 let delta_length = velocity_delta.length();
470 if delta_length > f32::EPSILON {
471 let forward_delta = velocity_delta.dot(candidate.start_forward_xy);
472 candidate.best_dodge_forward_delta =
473 candidate.best_dodge_forward_delta.max(forward_delta);
474 candidate.best_dodge_delta_alignment = candidate
475 .best_dodge_delta_alignment
476 .max(forward_delta / delta_length);
477 candidate.dodge_acceleration_sample_count += 1;
478 }
479
480 let estimated_delta =
481 velocity - candidate.start_velocity - candidate.dodge_boost_compensation;
482 let estimated_horizontal_magnitude = estimated_delta.truncate().length();
483 if estimated_horizontal_magnitude > f32::EPSILON {
484 let estimated_magnitude = estimated_delta.length();
485 let estimated_direction = estimated_delta / estimated_magnitude;
486 let forward_component = estimated_direction.dot(candidate.local_forward);
487 if estimated_horizontal_magnitude
488 > candidate.best_estimated_dodge_impulse_magnitude
489 {
490 candidate.best_estimated_dodge_impulse_magnitude =
491 estimated_horizontal_magnitude;
492 candidate.best_estimated_dodge_impulse_forward_component =
493 forward_component;
494 candidate.best_estimated_dodge_impulse_side_component =
495 estimated_direction.dot(candidate.local_right);
496 candidate.best_estimated_dodge_impulse_up_component =
497 estimated_direction.dot(candidate.local_up);
498 }
499 }
500 }
501 }
502
503 let rotation = quat_to_glam(&rigid_body.rotation);
504 let local_angular_velocity = rigid_body
505 .angular_velocity
506 .as_ref()
507 .map(vec_to_glam)
508 .map(|angular_velocity| rotation.inverse() * angular_velocity)
509 .unwrap_or(glam::Vec3::ZERO);
510 candidate.best_diagonal_score = candidate
511 .best_diagonal_score
512 .max(Self::diagonal_score(local_angular_velocity));
513
514 let current_forward = rotation * glam::Vec3::X;
515 let current_up = rotation * glam::Vec3::Z;
516 candidate.max_forward_rotation_degrees = candidate.max_forward_rotation_degrees.max(
517 candidate
518 .local_forward
519 .angle_between(current_forward)
520 .to_degrees(),
521 );
522 candidate.max_up_rotation_degrees = candidate
523 .max_up_rotation_degrees
524 .max(candidate.local_up.angle_between(current_up).to_degrees());
525
526 let forward_z = (rotation * glam::Vec3::X).z;
527 candidate.min_forward_z = candidate.min_forward_z.min(forward_z);
528 candidate.latest_forward_z = forward_z;
529 candidate.latest_time = frame.time;
530 candidate.latest_frame = frame.frame_number;
531 }
532
533 fn candidate_event(
534 player_id: &PlayerId,
535 candidate: ActiveSpeedFlipCandidate,
536 ) -> Option<SpeedFlipEvent> {
537 let time_since_kickoff_start = candidate
538 .kickoff_start_time
539 .map(|kickoff_start_time| (candidate.start_time - kickoff_start_time).max(0.0))
540 .unwrap_or(0.0);
541 let timeliness_score = if candidate.is_kickoff {
542 1.0 - Self::normalize_score(time_since_kickoff_start, 0.55, 1.1)
543 } else {
544 1.0
545 };
546 let cancel_recovery = candidate.latest_forward_z - candidate.min_forward_z;
547 let level_recovery_score =
548 1.0 - Self::normalize_score(candidate.latest_forward_z.abs(), 0.05, 0.55);
549 let cancel_score = 0.25 * Self::normalize_score(-candidate.min_forward_z, 0.05, 0.35)
550 + 0.35 * Self::normalize_score(cancel_recovery, 0.08, 0.5)
551 + 0.40 * level_recovery_score;
552 let speed_score = 0.55 * Self::normalize_score(candidate.max_speed, 1450.0, 1900.0)
553 + 0.45
554 * Self::normalize_score(candidate.max_speed - candidate.start_speed, 180.0, 650.0);
555 let alignment_score = Self::normalize_score(candidate.best_alignment, 0.78, 0.98);
556 if candidate.boost_alignment_sample_count == 0 {
557 return None;
558 }
559 let diagonal_score = candidate
571 .dodge_torque
572 .map(Self::diagonal_score_from_torque)
573 .unwrap_or(candidate.best_diagonal_score);
574 let has_cancelled_diagonal_dodge = candidate.max_forward_rotation_degrees
575 <= SPEED_FLIP_MAX_CANCELLED_FORWARD_ROTATION_DEGREES
576 && diagonal_score >= SPEED_FLIP_MIN_DIAGONAL_SCORE;
577 if candidate.max_up_rotation_degrees < SPEED_FLIP_MIN_UP_ROTATION_DEGREES
580 || candidate.max_up_rotation_degrees > SPEED_FLIP_MAX_UP_ROTATION_DEGREES
581 {
582 return None;
583 }
584 if !has_cancelled_diagonal_dodge {
591 return None;
592 }
593 let boost_alignment_score =
594 Self::normalize_score(candidate.best_boost_alignment, 0.82, 0.99);
595 let confidence = 0.30 * diagonal_score
596 + 0.30 * cancel_score
597 + 0.15 * speed_score
598 + 0.15 * alignment_score
599 + 0.05 * boost_alignment_score
600 + 0.05 * timeliness_score;
601
602 if candidate.best_boost_alignment < SPEED_FLIP_MIN_BOOST_ALIGNMENT {
606 return None;
607 }
608 if confidence < SPEED_FLIP_MIN_CONFIDENCE {
609 return None;
610 }
611
612 Some(SpeedFlipEvent {
613 time: candidate.start_time,
614 frame: candidate.start_frame,
615 resolved_time: candidate.latest_time,
616 resolved_frame: candidate.latest_frame,
617 player: player_id.clone(),
618 is_team_0: candidate.is_team_0,
619 time_since_kickoff_start,
620 start_position: candidate.start_position,
621 end_position: candidate.end_position,
622 start_speed: candidate.start_speed,
623 max_speed: candidate.max_speed,
624 best_alignment: candidate.best_alignment,
625 initial_boost_alignment: candidate
626 .initial_boost_alignment
627 .unwrap_or(candidate.best_boost_alignment),
628 best_boost_alignment: candidate.best_boost_alignment,
629 boost_alignment_sample_count: candidate.boost_alignment_sample_count,
630 dodge_delay_after_ground_leave_seconds: candidate
631 .dodge_delay_after_ground_leave_seconds,
632 diagonal_score,
633 estimated_dodge_impulse_magnitude: candidate.best_estimated_dodge_impulse_magnitude,
634 estimated_dodge_impulse_forward_component: candidate
635 .best_estimated_dodge_impulse_forward_component,
636 estimated_dodge_impulse_side_component: candidate
637 .best_estimated_dodge_impulse_side_component,
638 estimated_dodge_impulse_up_component: candidate
639 .best_estimated_dodge_impulse_up_component,
640 cancel_score,
641 speed_score,
642 confidence,
643 })
644 }
645
646 fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
647 let mut finished_candidates = Vec::new();
648
649 for (player_id, candidate) in self.active_candidates.iter() {
650 let duration = frame.time - candidate.start_time;
651 if force_all || duration >= SPEED_FLIP_EVALUATION_SECONDS {
652 finished_candidates.push((
653 candidate.start_time,
654 candidate.start_frame,
655 format!("{player_id:?}"),
656 player_id.clone(),
657 ));
658 }
659 }
660
661 finished_candidates.sort_by(|left, right| {
662 left.0
663 .total_cmp(&right.0)
664 .then_with(|| left.1.cmp(&right.1))
665 .then_with(|| left.2.cmp(&right.2))
666 });
667
668 for (_, _, _, player_id) in finished_candidates {
669 let Some(candidate) = self
670 .active_candidates
671 .finalize(&player_id, FinalizeReason::Completed)
672 else {
673 continue;
674 };
675 if let Some(event) = Self::candidate_event(&player_id, candidate) {
676 self.apply_event(event);
677 }
678 }
679 }
680
681 pub fn update_parts(
682 &mut self,
683 frame: &FrameInfo,
684 gameplay: &GameplayState,
685 ball: &BallFrameState,
686 players: &PlayerFrameState,
687 live_play_state: &LivePlayState,
688 ) -> SubtrActorResult<()> {
689 self.events.begin_update();
690 let kickoff_approach_active = self.update_kickoff_window(gameplay);
691 if !live_play_state.is_live_play && !kickoff_approach_active {
692 self.active_candidates
693 .apply_boundary(Boundary::LivePlayEnded);
694 self.current_kickoff_start_time = None;
695 self.kickoff_approach_active_last_frame = false;
696 self.last_ground_contacts.clear();
697 return Ok(());
698 }
699
700 if kickoff_approach_active && !self.kickoff_approach_active_last_frame {
701 self.reset_kickoff_state();
702 }
703
704 self.update_kickoff_start_time(frame, kickoff_approach_active, players);
705 self.update_ground_contacts(frame, players);
706
707 for player in &players.players {
708 self.maybe_start_candidate(
709 frame,
710 kickoff_approach_active,
711 ball,
712 player,
713 live_play_state,
714 );
715 }
716
717 for (player_id, candidate) in self.active_candidates.iter_mut() {
718 let Some(player) = Self::player_by_id(players, player_id) else {
719 continue;
720 };
721 Self::update_candidate(candidate, frame, ball, player);
722 }
723
724 self.finalize_candidates(frame, false);
725
726 self.active_candidates.retain(|_, candidate| {
727 frame.time - candidate.start_time <= SPEED_FLIP_MAX_CANDIDATE_SECONDS
728 });
729
730 if !kickoff_approach_active {
731 self.current_kickoff_start_time = None;
732 }
733
734 self.kickoff_approach_active_last_frame = kickoff_approach_active;
735 Ok(())
736 }
737
738 pub fn finalize_parts(&mut self, frame: &FrameInfo) {
739 self.finalize_candidates(frame, true);
740 }
741}
742
743#[cfg(test)]
744#[path = "speed_flip_tests.rs"]
745mod tests;