1use super::*;
2
3const KICKOFF_CENTER_MAX_ABS_X: f32 = 350.0;
4const KICKOFF_CENTER_MIN_ABS_Y: f32 = 4300.0;
5const KICKOFF_OFF_CENTER_MAX_ABS_X: f32 = 900.0;
6const KICKOFF_OFF_CENTER_MIN_ABS_Y: f32 = 3300.0;
7const KICKOFF_DIAGONAL_MIN_ABS_X: f32 = 1500.0;
8const KICKOFF_DIAGONAL_MAX_ABS_Y: f32 = 3300.0;
9const KICKOFF_RESOLUTION_AFTER_FIRST_TOUCH_SECONDS: f32 = 1.25;
10const KICKOFF_FOLLOW_UP_AFTER_FIRST_TOUCH_SECONDS: f32 = 2.0;
11const KICKOFF_GOAL_MAX_SECONDS: f32 = 12.0;
12const KICKOFF_GOAL_MAX_DEFENSIVE_BALL_Y: f32 = 1280.0;
13const KICKOFF_WIN_PROJECTION_SECONDS: f32 = 0.5;
14const KICKOFF_FIELD_HALF_LENGTH: f32 = 5120.0;
15const KICKOFF_WIN_MIN_PROJECTED_BALL_Y: f32 = 256.0;
16const KICKOFF_BALL_DIRECTION_MIN_ABS_X: f32 = 180.0;
17const KICKOFF_BALL_DIRECTION_MIN_ABS_SPEED_X: f32 = 220.0;
18const KICKOFF_CLEAR_WIN_STRENGTH: f32 = 0.25;
19const KICKOFF_STRONG_WIN_STRENGTH: f32 = 0.5;
20const KICKOFF_TAKER_DISTANCE_TIE_EPSILON: f32 = 150.0;
21const KICKOFF_POSSESSION_IMMEDIATE_CONTEST_SECONDS: f32 = 0.35;
22const KICKOFF_TOUCH_CLUSTER_MAX_GAP_SECONDS: f32 = 0.35;
23const KICKOFF_APPROACH_MIN_BOOST_USED: f32 = 3.0;
24const KICKOFF_APPROACH_MIN_FAKE_MOVE_DISTANCE: f32 = 350.0;
25const KICKOFF_APPROACH_FLIP_MIN_SECONDS_BEFORE_TOUCH: f32 = 0.5;
26const KICKOFF_APPROACH_FRONT_FLIP_FORWARD_COMPONENT: f32 = 0.45;
27const KICKOFF_APPROACH_DIAGONAL_FLIP_SIDE_COMPONENT: f32 = 0.35;
28const KICKOFF_APPROACH_DODGE_DIRECTION_WINDOW_SECONDS: f32 = 0.20;
34const KICKOFF_APPROACH_BOOST_ACCELERATION_UU_PER_SECOND_SQUARED: f32 = 991.6667;
38const KICKOFF_SUPPORT_CHEAT_MIN_CENTER_PROGRESS: f32 = 400.0;
39const KICKOFF_ADVANTAGE_POSSESSION_MIN_RUN_SECONDS: f32 = 1.25;
40const KICKOFF_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y: f32 = 200.0;
41const KICKOFF_PRESSURE_MIN_ESTABLISH_SECONDS: f32 = 2.0;
42const KICKOFF_PRESSURE_MIN_ESTABLISH_THIRD_SECONDS: f32 = 0.75;
43const KICKOFF_SUPPORT_GO_FOR_BOOST_MIN_LATERAL_MOVE: f32 = 600.0;
44const KICKOFF_SUPPORT_GO_FOR_BOOST_MIN_BOOST_GAIN: f32 = 10.0;
45const KICKOFF_SUPPORT_BACK_BIG_MAX_SECONDS_AFTER_GO: f32 = 3.0;
46
47#[derive(Debug, Clone, PartialEq)]
48struct KickoffPlayerSnapshot {
49 player: PlayerId,
50 is_team_0: bool,
51 start_position: [f32; 3],
52 spawn_position: KickoffSpawnPosition,
53 start_boost: Option<f32>,
54 first_touch_boost: Option<f32>,
55 first_touch_time: Option<f32>,
56 first_touch_frame: Option<usize>,
57 first_touch_contact: Option<KickoffContactSnapshot>,
58 approach_trace: KickoffApproachTrace,
59}
60
61#[derive(Debug, Clone, Default, PartialEq)]
62struct KickoffApproachTrace {
63 boost_active_sample_count: u32,
64 first_dodge_time: Option<f32>,
65 first_dodge_frame: Option<usize>,
66 first_dodge_forward_component: Option<f32>,
67 first_dodge_side_component: Option<f32>,
68 dodge_direction_baseline_velocity: Option<glam::Vec3>,
72 dodge_onset_forward: Option<glam::Vec3>,
73 dodge_onset_right: Option<glam::Vec3>,
74 dodge_direction_window_deadline: Option<f32>,
75 dodge_direction_boost_compensation: glam::Vec3,
76 best_dodge_direction_delta: f32,
77 max_speed: f32,
78 min_boost: Option<f32>,
79 previous_boost: Option<f32>,
80 sampled_boost_used: f32,
81 pickup_boost_collected: f32,
89 picked_up_immediate_own_back_big_boost: bool,
90 last_position: Option<[f32; 3]>,
91 previous_velocity: Option<glam::Vec3>,
92 previous_dodge_active: bool,
93}
94
95#[derive(Debug, Clone, PartialEq)]
96struct KickoffTouchSnapshot {
97 time: f32,
98 frame: usize,
99 team_is_team_0: bool,
100 player: Option<PlayerId>,
101}
102
103#[derive(Debug, Clone, PartialEq)]
104struct KickoffContactSnapshot {
105 player_position: [f32; 3],
106 player_velocity: Option<[f32; 3]>,
107 car_forward: Option<[f32; 3]>,
108 local_ball_position: Option<[f32; 3]>,
109 local_contact_point: Option<[f32; 3]>,
110 contact_gap: Option<f32>,
111 behind_ball_depth: f32,
112 lateral_offset: f32,
113 lateral_abs_offset: f32,
114 velocity_attack_alignment: Option<f32>,
115 velocity_ball_alignment: Option<f32>,
116 nose_attack_alignment: Option<f32>,
117 ball_exit_attack_alignment: Option<f32>,
118}
119
120#[derive(Debug, Clone)]
121struct KickoffResolutionSnapshot {
122 ball: BallFrameState,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126enum KickoffAdvantageKind {
127 Possession,
128 Pressure,
129 Goal,
130}
131
132#[derive(Debug, Clone)]
133struct EstablishedKickoffAdvantage {
134 kind: KickoffAdvantageKind,
135 team_is_team_0: bool,
136 time: f32,
137 frame: usize,
138 player: Option<PlayerId>,
139}
140
141#[derive(Debug, Clone, Default)]
160struct KickoffAdvantageWatcher {
161 established: Option<EstablishedKickoffAdvantage>,
162 touches_seen: usize,
163 run_team_is_team_0: Option<bool>,
164 run_start_time: f32,
165 pressure_team_is_team_0: Option<bool>,
166 pressure_anchored: bool,
167 pressure_zone_seconds: f32,
168 pressure_third_seconds: f32,
169}
170
171impl KickoffAdvantageWatcher {
172 fn zone_side(ball: &BallFrameState) -> Option<bool> {
173 let ball_y = ball.sample()?.position().y;
174 if ball_y > KICKOFF_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y {
175 Some(true)
176 } else if ball_y < -KICKOFF_PRESSURE_NEUTRAL_ZONE_HALF_WIDTH_Y {
177 Some(false)
178 } else {
179 None
180 }
181 }
182
183 fn reset_pressure(&mut self) {
184 self.pressure_team_is_team_0 = None;
185 self.pressure_anchored = false;
186 self.pressure_zone_seconds = 0.0;
187 self.pressure_third_seconds = 0.0;
188 }
189
190 fn establish_goal(&mut self, goal: &GoalEvent) {
191 if self.established.is_some() {
192 return;
193 }
194 self.established = Some(EstablishedKickoffAdvantage {
195 kind: KickoffAdvantageKind::Goal,
196 team_is_team_0: goal.scoring_team_is_team_0,
197 time: goal.time,
198 frame: goal.frame,
199 player: goal.player.clone(),
200 });
201 }
202
203 fn observe(
204 &mut self,
205 frame: &FrameInfo,
206 ball: &BallFrameState,
207 touches: &[KickoffTouchSnapshot],
208 ) {
209 if self.established.is_some() {
210 self.touches_seen = touches.len();
211 return;
212 }
213 let zone_side = Self::zone_side(ball);
214 if self.pressure_team_is_team_0 != zone_side {
215 self.reset_pressure();
216 self.pressure_team_is_team_0 = zone_side;
217 }
218
219 for touch in &touches[self.touches_seen..] {
220 if self.run_team_is_team_0 == Some(touch.team_is_team_0) {
221 if touch.time - self.run_start_time >= KICKOFF_ADVANTAGE_POSSESSION_MIN_RUN_SECONDS
222 {
223 self.established = Some(EstablishedKickoffAdvantage {
224 kind: KickoffAdvantageKind::Possession,
225 team_is_team_0: touch.team_is_team_0,
226 time: touch.time,
227 frame: touch.frame,
228 player: touch.player.clone(),
229 });
230 break;
231 }
232 } else {
233 self.run_team_is_team_0 = Some(touch.team_is_team_0);
234 self.run_start_time = touch.time;
235 }
236 if zone_side == Some(touch.team_is_team_0) {
237 self.pressure_anchored = true;
238 }
239 }
240 self.touches_seen = touches.len();
241 if self.established.is_some() {
242 return;
243 }
244
245 let Some(attacking_team_is_team_0) = zone_side else {
246 return;
247 };
248 if !self.pressure_anchored {
249 return;
250 }
251 self.pressure_zone_seconds += frame.dt;
252 let normalized_ball_y = ball
253 .sample()
254 .map(|sample| {
255 if attacking_team_is_team_0 {
256 sample.position().y
257 } else {
258 -sample.position().y
259 }
260 })
261 .unwrap_or(0.0);
262 if normalized_ball_y > FIELD_ZONE_BOUNDARY_Y {
263 self.pressure_third_seconds += frame.dt;
264 }
265 if self.pressure_zone_seconds >= KICKOFF_PRESSURE_MIN_ESTABLISH_SECONDS
266 || self.pressure_third_seconds >= KICKOFF_PRESSURE_MIN_ESTABLISH_THIRD_SECONDS
267 {
268 self.established = Some(EstablishedKickoffAdvantage {
269 kind: KickoffAdvantageKind::Pressure,
270 team_is_team_0: attacking_team_is_team_0,
271 time: frame.time,
272 frame: frame.frame_number,
273 player: None,
274 });
275 }
276 }
277}
278
279#[derive(Debug, Clone)]
280struct ActiveKickoff {
281 start_time: f32,
282 start_frame: usize,
283 live_action_start_time: Option<f32>,
284 live_action_start_frame: Option<usize>,
285 movement_start_time: Option<f32>,
286 movement_start_frame: Option<usize>,
287 players: Vec<KickoffPlayerSnapshot>,
288 first_touch_time: Option<f32>,
289 first_touch_frame: Option<usize>,
290 first_touch_team_is_team_0: Option<bool>,
291 first_touch_id: Option<u64>,
292 first_touch_ball_position: Option<[f32; 3]>,
293 first_touch_ball_velocity: Option<[f32; 3]>,
294 touches: Vec<KickoffTouchSnapshot>,
295 speed_flip_directions: HashMap<PlayerId, KickoffFlipDirection>,
296 resolution: Option<KickoffResolutionSnapshot>,
297 min_ball_y_after_first_touch: Option<f32>,
303 max_ball_y_after_first_touch: Option<f32>,
304 advantage: KickoffAdvantageWatcher,
308 concluded: Option<Box<KickoffEvent>>,
314}
315
316impl InFlightItem for ActiveKickoff {
317 fn recognition(&self) -> Recognition {
318 Recognition::committed(self.start_time, self.start_frame)
321 }
322
323 fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
324 if self.concluded.is_some() {
332 Disposition::Finalize(FinalizeReason::Boundary(boundary))
333 } else {
334 Disposition::Discard
335 }
336 }
337}
338
339#[derive(Debug, Clone, Default)]
341pub struct KickoffCalculator {
342 active: InFlightLedger<ActiveKickoff>,
343 events: EventStream<KickoffEvent>,
344}
345
346pub(crate) struct KickoffUpdateContext<'a> {
347 pub frame: &'a FrameInfo,
348 pub gameplay: &'a GameplayState,
349 pub ball: &'a BallFrameState,
350 pub players: &'a PlayerFrameState,
351 pub touch_state: &'a TouchState,
352 pub events: &'a FrameEventsState,
353 pub speed_flip_events: &'a [SpeedFlipEvent],
354 pub boost_pickups: &'a [BoostPickupEvent],
358}
359
360pub(crate) const KICKOFF_SPAWN_LABELS: [StatLabel; 6] = [
361 StatLabel::new("kickoff_spawn", "center"),
362 StatLabel::new("kickoff_spawn", "off_center_left"),
363 StatLabel::new("kickoff_spawn", "off_center_right"),
364 StatLabel::new("kickoff_spawn", "diagonal_left"),
365 StatLabel::new("kickoff_spawn", "diagonal_right"),
366 StatLabel::new("kickoff_spawn", "unknown"),
367];
368pub(crate) const KICKOFF_TYPE_LABELS: [StatLabel; 4] = [
369 StatLabel::new("kickoff_type", "diagonal"),
370 StatLabel::new("kickoff_type", "center_offset"),
371 StatLabel::new("kickoff_type", "center"),
372 StatLabel::new("kickoff_type", "unknown"),
373];
374pub(crate) const KICKOFF_DIRECTION_LABELS: [StatLabel; 4] = [
375 StatLabel::new("kickoff_direction", "left"),
376 StatLabel::new("kickoff_direction", "right"),
377 StatLabel::new("kickoff_direction", "center"),
378 StatLabel::new("kickoff_direction", "unknown"),
379];
380pub(crate) const KICKOFF_TAKER_OUTCOME_LABELS: [StatLabel; 4] = [
381 StatLabel::new("taker_outcome", "touched"),
382 StatLabel::new("taker_outcome", "fake"),
383 StatLabel::new("taker_outcome", "missed"),
384 StatLabel::new("taker_outcome", "unknown"),
385];
386pub(crate) const KICKOFF_APPROACH_LABELS: [StatLabel; 6] = [
387 StatLabel::new("kickoff_approach", "speed_flip"),
388 StatLabel::new("kickoff_approach", "boost_into_ball"),
389 StatLabel::new("kickoff_approach", "fake_go_for_boost"),
390 StatLabel::new("kickoff_approach", "front_flip"),
391 StatLabel::new("kickoff_approach", "diagonal_flip"),
392 StatLabel::new("kickoff_approach", "other"),
393];
394pub(crate) const KICKOFF_FLIP_DIRECTION_LABELS: [StatLabel; 3] = [
395 StatLabel::new("approach_flip_direction", "left"),
396 StatLabel::new("approach_flip_direction", "right"),
397 StatLabel::new("approach_flip_direction", "not_applicable"),
398];
399pub(crate) const KICKOFF_SUPPORT_BEHAVIOR_LABELS: [StatLabel; 4] = [
400 StatLabel::new("support_behavior", "go_for_boost"),
401 StatLabel::new("support_behavior", "cheat"),
402 StatLabel::new("support_behavior", "other"),
403 StatLabel::new("support_behavior", "unknown"),
404];
405pub(crate) const KICKOFF_BALL_DIRECTION_LABELS: [StatLabel; 4] = [
406 StatLabel::new("ball_direction", "left"),
407 StatLabel::new("ball_direction", "right"),
408 StatLabel::new("ball_direction", "center"),
409 StatLabel::new("ball_direction", "unknown"),
410];
411pub(crate) const KICKOFF_OUTCOME_LABELS: [StatLabel; 4] = [
412 StatLabel::new("outcome", "team_zero_win"),
413 StatLabel::new("outcome", "team_one_win"),
414 StatLabel::new("outcome", "neutral"),
415 StatLabel::new("outcome", "unknown"),
416];
417pub(crate) const KICKOFF_WIN_STRENGTH_LABELS: [StatLabel; 4] = [
418 StatLabel::new("win_strength", "narrow"),
419 StatLabel::new("win_strength", "clear"),
420 StatLabel::new("win_strength", "strong"),
421 StatLabel::new("win_strength", "unknown"),
422];
423pub(crate) const KICKOFF_POSSESSION_OUTCOME_LABELS: [StatLabel; 5] = [
424 StatLabel::new("kickoff_possession_outcome", "team_zero_possession"),
425 StatLabel::new("kickoff_possession_outcome", "team_one_possession"),
426 StatLabel::new("kickoff_possession_outcome", "team_zero_advantage"),
427 StatLabel::new("kickoff_possession_outcome", "team_one_advantage"),
428 StatLabel::new("kickoff_possession_outcome", "contested"),
429];
430pub(crate) const KICKOFF_GOAL_LABELS: [StatLabel; 2] = [
431 StatLabel::new("kickoff_goal", "false"),
432 StatLabel::new("kickoff_goal", "true"),
433];
434pub(crate) const KICKOFF_ADVANTAGE_LABELS: [StatLabel; 7] = [
435 StatLabel::new("kickoff_advantage", "team_zero_possession"),
436 StatLabel::new("kickoff_advantage", "team_one_possession"),
437 StatLabel::new("kickoff_advantage", "team_zero_pressure"),
438 StatLabel::new("kickoff_advantage", "team_one_pressure"),
439 StatLabel::new("kickoff_advantage", "team_zero_goal"),
440 StatLabel::new("kickoff_advantage", "team_one_goal"),
441 StatLabel::new("kickoff_advantage", "no_advantage"),
442];
443
444pub(crate) fn kickoff_spawn_label(spawn: KickoffSpawnPosition) -> StatLabel {
445 StatLabel::new("kickoff_spawn", spawn.as_label_value())
446}
447
448pub(crate) fn kickoff_type_label(kickoff_type: KickoffType) -> StatLabel {
449 StatLabel::new("kickoff_type", kickoff_type.as_label_value())
450}
451
452pub(crate) fn kickoff_direction_label(kickoff_direction: KickoffDirection) -> StatLabel {
453 StatLabel::new("kickoff_direction", kickoff_direction.as_label_value())
454}
455
456pub(crate) fn kickoff_taker_outcome_label(outcome: KickoffTakerOutcome) -> StatLabel {
457 StatLabel::new("taker_outcome", outcome.as_label_value())
458}
459
460pub(crate) fn kickoff_outcome_label(outcome: KickoffOutcome) -> StatLabel {
461 StatLabel::new("outcome", outcome.as_label_value())
462}
463
464pub(crate) fn kickoff_win_strength_label(band: KickoffWinStrengthBand) -> StatLabel {
465 StatLabel::new("win_strength", band.as_label_value())
466}
467
468pub(crate) fn kickoff_possession_outcome_label(outcome: KickoffPossessionOutcome) -> StatLabel {
469 StatLabel::new("kickoff_possession_outcome", outcome.as_label_value())
470}
471
472pub(crate) fn kickoff_goal_label(kickoff_goal: bool) -> StatLabel {
473 StatLabel::new("kickoff_goal", if kickoff_goal { "true" } else { "false" })
474}
475
476pub(crate) fn kickoff_advantage_label(advantage: KickoffAdvantage) -> StatLabel {
477 StatLabel::new("kickoff_advantage", advantage.as_label_value())
478}
479
480pub(crate) fn kickoff_approach_label(approach: KickoffApproach) -> StatLabel {
481 StatLabel::new("kickoff_approach", approach.as_label_value())
482}
483
484pub(crate) fn kickoff_flip_direction_label(direction: KickoffFlipDirection) -> StatLabel {
485 StatLabel::new("approach_flip_direction", direction.as_label_value())
486}
487
488pub(crate) fn kickoff_support_behavior_label(behavior: KickoffSupportBehavior) -> StatLabel {
489 StatLabel::new("support_behavior", behavior.as_label_value())
490}
491
492pub(crate) fn kickoff_ball_direction_label(direction: KickoffBallDirection) -> StatLabel {
493 StatLabel::new("ball_direction", direction.as_label_value())
494}
495
496impl KickoffTakerEvent {
497 pub(crate) fn labels(&self) -> Vec<StatLabel> {
498 vec![
499 kickoff_spawn_label(self.spawn_position),
500 kickoff_taker_outcome_label(self.outcome),
501 kickoff_approach_label(self.approach),
502 kickoff_flip_direction_label(self.approach_flip_direction),
503 kickoff_ball_direction_label(self.ball_direction),
504 ]
505 }
506}
507
508impl KickoffSupportEvent {
509 pub(crate) fn labels(&self) -> Vec<StatLabel> {
510 vec![
511 kickoff_spawn_label(self.spawn_position),
512 kickoff_support_behavior_label(self.support_behavior),
513 ]
514 }
515}
516
517pub(crate) enum KickoffPlayerEventRef<'a> {
518 Taker(&'a KickoffTakerEvent),
519 Support(&'a KickoffSupportEvent),
520}
521
522impl KickoffPlayerEventRef<'_> {
523 pub(crate) fn player(&self) -> &PlayerId {
524 match self {
525 Self::Taker(event) => &event.player,
526 Self::Support(event) => &event.player,
527 }
528 }
529
530 pub(crate) fn is_team_0(&self) -> bool {
531 match self {
532 Self::Taker(event) => event.is_team_0,
533 Self::Support(event) => event.is_team_0,
534 }
535 }
536
537 pub(crate) fn boost_after(&self) -> Option<f32> {
538 match self {
539 Self::Taker(event) => event.boost_after,
540 Self::Support(event) => event.boost_after,
541 }
542 }
543
544 pub(crate) fn labels(&self) -> Vec<StatLabel> {
545 match self {
546 Self::Taker(event) => event.labels(),
547 Self::Support(event) => event.labels(),
548 }
549 }
550
551 pub(crate) fn as_taker(&self) -> Option<&KickoffTakerEvent> {
552 match self {
553 Self::Taker(event) => Some(event),
554 Self::Support(_) => None,
555 }
556 }
557
558 pub(crate) fn as_support(&self) -> Option<&KickoffSupportEvent> {
559 match self {
560 Self::Taker(_) => None,
561 Self::Support(event) => Some(event),
562 }
563 }
564}
565
566impl KickoffEvent {
567 pub(crate) fn labels(&self) -> [StatLabel; 7] {
568 [
569 kickoff_type_label(self.kickoff_type),
570 kickoff_direction_label(self.kickoff_direction),
571 kickoff_outcome_label(self.outcome),
572 kickoff_win_strength_label(self.win_strength_band),
573 kickoff_possession_outcome_label(self.kickoff_possession_outcome),
574 kickoff_goal_label(self.kickoff_goal),
575 kickoff_advantage_label(self.advantage),
576 ]
577 }
578
579 pub(crate) fn player_events(&self) -> impl Iterator<Item = KickoffPlayerEventRef<'_>> {
580 self.team_zero_taker
581 .iter()
582 .map(KickoffPlayerEventRef::Taker)
583 .chain(self.team_one_taker.iter().map(KickoffPlayerEventRef::Taker))
584 .chain(
585 self.team_zero_non_takers
586 .iter()
587 .map(KickoffPlayerEventRef::Support),
588 )
589 .chain(
590 self.team_one_non_takers
591 .iter()
592 .map(KickoffPlayerEventRef::Support),
593 )
594 }
595}
596
597impl KickoffCalculator {
598 pub fn new() -> Self {
599 Self::default()
600 }
601
602 pub fn events(&self) -> &[KickoffEvent] {
603 self.events.all()
604 }
605
606 pub fn new_events(&self) -> &[KickoffEvent] {
607 self.events.new_events()
608 }
609
610 pub(crate) fn kickoff_spawn_position(
611 position: glam::Vec3,
612 is_team_0: bool,
613 ) -> KickoffSpawnPosition {
614 let abs_x = position.x.abs();
615 let abs_y = position.y.abs();
616 let relative_x = if is_team_0 { position.x } else { -position.x };
617
618 if abs_x <= KICKOFF_CENTER_MAX_ABS_X && abs_y >= KICKOFF_CENTER_MIN_ABS_Y {
619 return KickoffSpawnPosition::Center;
620 }
621 if abs_x <= KICKOFF_OFF_CENTER_MAX_ABS_X && abs_y >= KICKOFF_OFF_CENTER_MIN_ABS_Y {
622 return if relative_x < 0.0 {
623 KickoffSpawnPosition::OffCenterLeft
624 } else {
625 KickoffSpawnPosition::OffCenterRight
626 };
627 }
628 if abs_x >= KICKOFF_DIAGONAL_MIN_ABS_X && abs_y <= KICKOFF_DIAGONAL_MAX_ABS_Y {
629 return if relative_x < 0.0 {
630 KickoffSpawnPosition::DiagonalLeft
631 } else {
632 KickoffSpawnPosition::DiagonalRight
633 };
634 }
635 KickoffSpawnPosition::Unknown
636 }
637
638 fn kickoff_player_snapshot(player: &PlayerSample) -> Option<KickoffPlayerSnapshot> {
639 let position = player.position()?;
640 Some(KickoffPlayerSnapshot {
641 player: player.player_id.clone(),
642 is_team_0: player.is_team_0,
643 start_position: position.to_array(),
644 spawn_position: Self::kickoff_spawn_position(position, player.is_team_0),
645 start_boost: player.boost_amount.or(player.last_boost_amount),
646 first_touch_boost: None,
647 first_touch_time: None,
648 first_touch_frame: None,
649 first_touch_contact: None,
650 approach_trace: KickoffApproachTrace::default(),
651 })
652 }
653
654 fn start_kickoff(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
655 self.active.arm(ActiveKickoff {
656 start_time: frame.time,
657 start_frame: frame.frame_number,
658 live_action_start_time: None,
659 live_action_start_frame: None,
660 movement_start_time: None,
661 movement_start_frame: None,
662 players: players
663 .players
664 .iter()
665 .filter_map(Self::kickoff_player_snapshot)
666 .collect(),
667 first_touch_time: None,
668 first_touch_frame: None,
669 first_touch_team_is_team_0: None,
670 first_touch_id: None,
671 first_touch_ball_position: None,
672 first_touch_ball_velocity: None,
673 touches: Vec::new(),
674 speed_flip_directions: HashMap::new(),
675 resolution: None,
676 min_ball_y_after_first_touch: None,
677 max_ball_y_after_first_touch: None,
678 advantage: KickoffAdvantageWatcher::default(),
679 concluded: None,
680 });
681 }
682
683 pub fn finish(&mut self) {
688 for (active, _reason) in self.active.finish() {
689 Self::emit_concluded(&mut self.events, active);
690 }
691 }
692
693 fn emit_concluded(events: &mut EventStream<KickoffEvent>, active: ActiveKickoff) {
698 let ActiveKickoff {
699 concluded,
700 advantage,
701 ..
702 } = active;
703 let Some(mut event) = concluded else {
704 return;
705 };
706 Self::apply_advantage(&mut event, &advantage);
707 events.push(*event);
708 }
709
710 fn apply_advantage(event: &mut KickoffEvent, watcher: &KickoffAdvantageWatcher) {
711 let Some(established) = watcher.established.as_ref() else {
712 return;
713 };
714 event.advantage = match (established.kind, established.team_is_team_0) {
715 (KickoffAdvantageKind::Possession, true) => KickoffAdvantage::TeamZeroPossession,
716 (KickoffAdvantageKind::Possession, false) => KickoffAdvantage::TeamOnePossession,
717 (KickoffAdvantageKind::Pressure, true) => KickoffAdvantage::TeamZeroPressure,
718 (KickoffAdvantageKind::Pressure, false) => KickoffAdvantage::TeamOnePressure,
719 (KickoffAdvantageKind::Goal, true) => KickoffAdvantage::TeamZeroGoal,
720 (KickoffAdvantageKind::Goal, false) => KickoffAdvantage::TeamOneGoal,
721 };
722 event.advantage_team_is_team_0 = Some(established.team_is_team_0);
723 event.advantage_time = Some(established.time);
724 event.advantage_frame = Some(established.frame);
725 event.advantage_seconds_after_first_touch = event
726 .first_touch_time
727 .map(|first_touch_time| established.time - first_touch_time);
728 event.advantage_player = established.player.clone();
729 }
730
731 fn observe_movement_start(
732 active: &mut ActiveKickoff,
733 frame: &FrameInfo,
734 gameplay: &GameplayState,
735 ) {
736 if active.movement_start_time.is_none() && !gameplay.kickoff_countdown_active() {
751 active.movement_start_time = Some(frame.time);
752 active.movement_start_frame = Some(frame.frame_number);
753 }
754 }
755
756 fn observe_live_action_start(active: &mut ActiveKickoff, frame: &FrameInfo) {
757 if active.live_action_start_time.is_none() {
758 active.live_action_start_time = Some(frame.time);
759 active.live_action_start_frame = Some(frame.frame_number);
760 }
761 }
762
763 fn boost_amount(player: &PlayerSample) -> Option<f32> {
764 player.boost_amount.or(player.last_boost_amount)
765 }
766
767 fn observe_player_approach(
768 trace: &mut KickoffApproachTrace,
769 frame: &FrameInfo,
770 player: &PlayerSample,
771 ) {
772 if player.boost_active {
773 trace.boost_active_sample_count += 1;
774 }
775 if let Some(boost_amount) = Self::boost_amount(player) {
776 if let Some(previous_boost) = trace.previous_boost {
777 let delta = boost_amount - previous_boost;
781 if delta < 0.0 {
782 trace.sampled_boost_used += -delta;
787 }
788 }
789 trace.previous_boost = Some(boost_amount);
790 trace.min_boost = Some(
791 trace
792 .min_boost
793 .map(|current| current.min(boost_amount))
794 .unwrap_or(boost_amount),
795 );
796 }
797 if let Some(position) = player.position() {
798 trace.last_position = Some(position.to_array());
799 }
800 if let Some(speed) = player.speed() {
801 trace.max_speed = trace.max_speed.max(speed);
802 }
803
804 let dodge_rising = player.dodge_active && !trace.previous_dodge_active;
805 let had_first_dodge = trace.first_dodge_time.is_some();
806 if dodge_rising && !had_first_dodge {
807 trace.first_dodge_time = Some(frame.time);
808 trace.first_dodge_frame = Some(frame.frame_number);
809 if let Some(rigid_body) = player.rigid_body.as_ref() {
810 let rotation = quat_to_glam(&rigid_body.rotation);
811 trace.dodge_onset_forward = Some(rotation * glam::Vec3::X);
812 trace.dodge_onset_right = Some(rotation * glam::Vec3::Y);
813 trace.dodge_direction_baseline_velocity =
818 trace.previous_velocity.or_else(|| player.velocity());
819 trace.dodge_direction_window_deadline =
820 Some(frame.time + KICKOFF_APPROACH_DODGE_DIRECTION_WINDOW_SECONDS);
821 }
822 }
823
824 if dodge_rising && had_first_dodge {
828 trace.dodge_direction_window_deadline = None;
829 }
830
831 if let (Some(deadline), Some(baseline), Some(forward), Some(right)) = (
836 trace.dodge_direction_window_deadline,
837 trace.dodge_direction_baseline_velocity,
838 trace.dodge_onset_forward,
839 trace.dodge_onset_right,
840 ) {
841 if frame.time <= deadline {
842 if player.boost_active {
843 trace.dodge_direction_boost_compensation += forward
844 * KICKOFF_APPROACH_BOOST_ACCELERATION_UU_PER_SECOND_SQUARED
845 * frame.dt;
846 }
847 if let Some(velocity) = player.velocity() {
848 let delta = velocity - baseline - trace.dodge_direction_boost_compensation;
849 let horizontal = delta.truncate().length();
850 if horizontal > trace.best_dodge_direction_delta
851 && delta.length_squared() > f32::EPSILON
852 {
853 trace.best_dodge_direction_delta = horizontal;
854 let direction = delta.normalize();
855 trace.first_dodge_forward_component = Some(direction.dot(forward));
856 trace.first_dodge_side_component = Some(direction.dot(right));
857 }
858 }
859 } else {
860 trace.dodge_direction_window_deadline = None;
861 }
862 }
863
864 trace.previous_velocity = player.velocity();
865 trace.previous_dodge_active = player.dodge_active;
866 }
867
868 fn apply_player_samples(
869 active: &mut ActiveKickoff,
870 frame: &FrameInfo,
871 players: &PlayerFrameState,
872 ) {
873 for snapshot in &mut active.players {
874 if snapshot.first_touch_time.is_some() {
875 continue;
876 }
877 let Some(player) = players.player(&snapshot.player) else {
878 continue;
879 };
880 Self::observe_player_approach(&mut snapshot.approach_trace, frame, player);
881 }
882 }
883
884 fn team_attack_direction(is_team_0: bool) -> glam::Vec2 {
885 glam::Vec2::new(0.0, if is_team_0 { 1.0 } else { -1.0 })
886 }
887
888 fn team_right_direction(is_team_0: bool) -> glam::Vec2 {
889 glam::Vec2::new(if is_team_0 { 1.0 } else { -1.0 }, 0.0)
890 }
891
892 fn normalize_xy(vector: glam::Vec3) -> Option<glam::Vec2> {
893 let xy = vector.truncate();
894 if xy.length_squared() > f32::EPSILON {
895 Some(xy.normalize())
896 } else {
897 None
898 }
899 }
900
901 fn kickoff_contact_snapshot(
902 player: Option<&PlayerSample>,
903 touch: &TouchEvent,
904 ball: &BallFrameState,
905 ) -> Option<KickoffContactSnapshot> {
906 let player_body = player.and_then(|player| player.rigid_body.as_ref());
907 let player_position = player_body
908 .map(|body| vec_to_glam(&body.location))
909 .or_else(|| touch.player_position.as_ref().map(vec_to_glam))?;
910 let ball_position = ball.position()?;
911 let attack_direction = Self::team_attack_direction(touch.team_is_team_0);
912 let right_direction = Self::team_right_direction(touch.team_is_team_0);
913 let ball_from_player = ball_position - player_position;
914 let player_velocity = player.and_then(PlayerSample::velocity);
915 let car_forward = player_body.map(|body| quat_to_glam(&body.rotation) * glam::Vec3::X);
916 let contact_estimate = player_body.zip(player).and_then(|(body, player)| {
917 car_hitbox_contact_estimate(ball_position, body, player.hitbox)
918 });
919 let ball_velocity = ball.velocity();
920
921 Some(KickoffContactSnapshot {
922 player_position: player_position.to_array(),
923 player_velocity: player_velocity.map(|velocity| velocity.to_array()),
924 car_forward: car_forward.map(|forward| forward.to_array()),
925 local_ball_position: contact_estimate
926 .as_ref()
927 .map(|estimate| estimate.local_ball_position.to_array()),
928 local_contact_point: contact_estimate
929 .as_ref()
930 .map(|estimate| estimate.local_contact_point.to_array()),
931 contact_gap: contact_estimate
932 .as_ref()
933 .map(|estimate| (estimate.distance - BALL_COLLISION_RADIUS).max(0.0)),
934 behind_ball_depth: ball_from_player.truncate().dot(attack_direction),
935 lateral_offset: (player_position - ball_position)
936 .truncate()
937 .dot(right_direction),
938 lateral_abs_offset: (player_position - ball_position)
939 .truncate()
940 .dot(right_direction)
941 .abs(),
942 velocity_attack_alignment: player_velocity
943 .and_then(Self::normalize_xy)
944 .map(|velocity| velocity.dot(attack_direction)),
945 velocity_ball_alignment: player_velocity
946 .and_then(Self::normalize_xy)
947 .zip(Self::normalize_xy(ball_from_player))
948 .map(|(velocity, ball_direction)| velocity.dot(ball_direction)),
949 nose_attack_alignment: car_forward
950 .and_then(Self::normalize_xy)
951 .map(|forward| forward.dot(attack_direction)),
952 ball_exit_attack_alignment: ball_velocity
953 .and_then(Self::normalize_xy)
954 .map(|velocity| velocity.dot(attack_direction)),
955 })
956 }
957
958 fn apply_touches(
959 active: &mut ActiveKickoff,
960 touch_state: &TouchState,
961 ball: &BallFrameState,
962 players: &PlayerFrameState,
963 ) {
964 for touch in chronological_touch_events(&touch_state.touch_events) {
965 active.touches.push(KickoffTouchSnapshot {
966 time: touch.time,
967 frame: touch.frame,
968 team_is_team_0: touch.team_is_team_0,
969 player: touch.player.clone(),
970 });
971 if active.first_touch_time.is_none() {
972 active.first_touch_time = Some(touch.time);
973 active.first_touch_frame = Some(touch.frame);
974 active.first_touch_team_is_team_0 = Some(touch.team_is_team_0);
975 active.first_touch_id = touch.touch_id;
976 active.first_touch_ball_position =
977 ball.position().map(|position| position.to_array());
978 active.first_touch_ball_velocity =
979 ball.velocity().map(|velocity| velocity.to_array());
980 }
981 let Some(player_id) = touch.player.as_ref() else {
982 continue;
983 };
984 let Some(player) = active
985 .players
986 .iter_mut()
987 .find(|player| &player.player == player_id)
988 else {
989 continue;
990 };
991 if player.first_touch_time.is_none() {
992 player.first_touch_boost =
993 player.approach_trace.previous_boost.or(player.start_boost);
994 player.first_touch_time = Some(touch.time);
995 player.first_touch_frame = Some(touch.frame);
996 player.first_touch_contact =
997 Self::kickoff_contact_snapshot(players.player(player_id), touch, ball);
998 }
999 }
1000 }
1001
1002 fn apply_boost_pickups(active: &mut ActiveKickoff, pickups: &[BoostPickupEvent]) {
1012 if pickups.is_empty() {
1013 return;
1014 }
1015 let lower_bound = active.movement_start_time.unwrap_or(active.start_time);
1016 for pickup in pickups {
1017 if pickup.time < lower_bound {
1018 continue;
1019 }
1020 let Some(snapshot) = active
1021 .players
1022 .iter_mut()
1023 .find(|player| player.player == pickup.player_id)
1024 else {
1025 continue;
1026 };
1027 if snapshot
1028 .first_touch_time
1029 .is_some_and(|touch_time| pickup.time > touch_time)
1030 {
1031 continue;
1032 }
1033 snapshot.approach_trace.pickup_boost_collected += pickup.collected_amount;
1034 if Self::is_immediate_own_back_big_pickup(snapshot, pickup, lower_bound) {
1035 snapshot
1036 .approach_trace
1037 .picked_up_immediate_own_back_big_boost = true;
1038 }
1039 }
1040 }
1041
1042 fn is_immediate_own_back_big_pickup(
1043 player: &KickoffPlayerSnapshot,
1044 pickup: &BoostPickupEvent,
1045 movement_start_time: f32,
1046 ) -> bool {
1047 if pickup.pad_type != BoostPickupPadType::Big {
1048 return false;
1049 }
1050 if pickup.time < movement_start_time {
1051 return false;
1052 }
1053 if pickup.time - movement_start_time > KICKOFF_SUPPORT_BACK_BIG_MAX_SECONDS_AFTER_GO {
1054 return false;
1055 }
1056 let Some(position) = pickup.player_position else {
1057 return false;
1058 };
1059 let pickup_position = glam::Vec2::new(position[0], position[1]);
1060 let own_back_y = if player.is_team_0 {
1061 -BOOST_PAD_BACK_CORNER_Y
1062 } else {
1063 BOOST_PAD_BACK_CORNER_Y
1064 };
1065 [-BOOST_PAD_BACK_CORNER_X, BOOST_PAD_BACK_CORNER_X]
1066 .iter()
1067 .any(|x| {
1068 pickup_position.distance(glam::Vec2::new(*x, own_back_y))
1069 <= STANDARD_PAD_MATCH_RADIUS_BIG
1070 })
1071 }
1072
1073 fn apply_speed_flip_events(
1074 active: &mut ActiveKickoff,
1075 frame: &FrameInfo,
1076 speed_flip_events: &[SpeedFlipEvent],
1077 ) {
1078 for event in speed_flip_events {
1079 if event.time < active.start_time || event.resolved_time > frame.time {
1080 continue;
1081 }
1082 if active
1083 .players
1084 .iter()
1085 .any(|player| player.player == event.player)
1086 {
1087 active.speed_flip_directions.insert(
1088 event.player.clone(),
1089 KickoffFlipDirection::from_local_side_component(
1090 event.estimated_dodge_impulse_side_component,
1091 ),
1092 );
1093 }
1094 }
1095 }
1096
1097 fn kickoff_start_distance(player: &KickoffPlayerSnapshot) -> f32 {
1098 glam::Vec2::new(player.start_position[0], player.start_position[1]).length()
1099 }
1100
1101 fn relative_left_value(player: &KickoffPlayerSnapshot) -> f32 {
1102 if player.is_team_0 {
1103 player.start_position[0]
1104 } else {
1105 -player.start_position[0]
1106 }
1107 }
1108
1109 fn expected_taker_by_team(players: &[KickoffPlayerSnapshot], is_team_0: bool) -> Option<usize> {
1110 let closest_distance = players
1111 .iter()
1112 .filter(|player| player.is_team_0 == is_team_0)
1113 .map(Self::kickoff_start_distance)
1114 .min_by(|left, right| left.total_cmp(right))?;
1115
1116 let tied_candidates = players.iter().enumerate().filter(|(_, player)| {
1117 player.is_team_0 == is_team_0
1118 && (Self::kickoff_start_distance(player) - closest_distance).abs()
1119 <= KICKOFF_TAKER_DISTANCE_TIE_EPSILON
1120 });
1121
1122 tied_candidates
1123 .clone()
1124 .filter(|(_, player)| player.first_touch_time.is_some())
1125 .min_by(|(_, left), (_, right)| {
1126 left.first_touch_time
1127 .unwrap_or(f32::INFINITY)
1128 .total_cmp(&right.first_touch_time.unwrap_or(f32::INFINITY))
1129 .then_with(|| {
1130 left.first_touch_frame
1131 .unwrap_or(usize::MAX)
1132 .cmp(&right.first_touch_frame.unwrap_or(usize::MAX))
1133 })
1134 })
1135 .or_else(|| {
1136 tied_candidates.min_by(|(_, left), (_, right)| {
1142 Self::center_progress(right)
1143 .total_cmp(&Self::center_progress(left))
1144 .then_with(|| {
1145 Self::boost_committed(right).total_cmp(&Self::boost_committed(left))
1146 })
1147 .then_with(|| {
1148 Self::relative_left_value(left)
1149 .total_cmp(&Self::relative_left_value(right))
1150 })
1151 })
1152 })
1153 .map(|(index, _)| index)
1154 }
1155
1156 fn boost_committed(player: &KickoffPlayerSnapshot) -> f32 {
1161 match (player.start_boost, player.approach_trace.min_boost) {
1162 (Some(start_boost), Some(min_boost)) => (start_boost - min_boost).max(0.0),
1163 _ => 0.0,
1164 }
1165 }
1166
1167 fn taker_outcome(
1168 player: &KickoffPlayerSnapshot,
1169 expected_taker_index: Option<usize>,
1170 player_index: usize,
1171 team_touched: bool,
1172 ) -> KickoffTakerOutcome {
1173 if player.first_touch_time.is_some() {
1174 KickoffTakerOutcome::Touched
1175 } else if expected_taker_index == Some(player_index) && team_touched {
1176 KickoffTakerOutcome::Fake
1177 } else if expected_taker_index == Some(player_index) {
1178 KickoffTakerOutcome::Missed
1179 } else {
1180 KickoffTakerOutcome::Unknown
1181 }
1182 }
1183
1184 fn is_taker(player_index: usize, expected_taker_index: Option<usize>) -> bool {
1185 expected_taker_index == Some(player_index)
1186 }
1187
1188 fn boost_after(players: &PlayerFrameState, player_id: &PlayerId) -> Option<f32> {
1189 players.player(player_id).and_then(Self::boost_amount)
1190 }
1191
1192 fn boost_used(player: &KickoffPlayerSnapshot, boost_after: Option<f32>) -> f32 {
1193 let Some(start_boost) = player.start_boost else {
1194 return 0.0;
1195 };
1196 let lowest_boost = player
1197 .approach_trace
1198 .min_boost
1199 .or(boost_after)
1200 .unwrap_or(start_boost);
1201 (start_boost - lowest_boost).max(0.0)
1202 }
1203
1204 fn taker_time_to_ball(player: &KickoffPlayerSnapshot, movement_start_time: f32) -> Option<f32> {
1205 player
1206 .first_touch_time
1207 .map(|touch_time| (touch_time - movement_start_time).max(0.0))
1208 }
1209
1210 fn taker_boost_collected(player: &KickoffPlayerSnapshot) -> f32 {
1211 player.approach_trace.pickup_boost_collected
1212 }
1213
1214 fn taker_boost_after(player: &KickoffPlayerSnapshot, boost_after: Option<f32>) -> Option<f32> {
1227 player.first_touch_boost.or(boost_after)
1228 }
1229
1230 fn taker_boost_used(player: &KickoffPlayerSnapshot) -> f32 {
1231 match (player.start_boost, player.first_touch_boost) {
1232 (Some(start_boost), Some(first_touch_boost)) => {
1233 (start_boost + Self::taker_boost_collected(player) - first_touch_boost).max(0.0)
1234 }
1235 _ => player.approach_trace.sampled_boost_used,
1236 }
1237 }
1238
1239 fn moved_distance(player: &KickoffPlayerSnapshot) -> f32 {
1240 let Some(last_position) = player.approach_trace.last_position else {
1241 return 0.0;
1242 };
1243 glam::Vec3::from_array(last_position)
1244 .distance(glam::Vec3::from_array(player.start_position))
1245 }
1246
1247 fn approach_dodge_happened_before_contact(player: &KickoffPlayerSnapshot) -> bool {
1248 let Some(first_dodge_time) = player.approach_trace.first_dodge_time else {
1249 return false;
1250 };
1251 player.first_touch_time.is_none_or(|first_touch_time| {
1252 first_touch_time - first_dodge_time >= KICKOFF_APPROACH_FLIP_MIN_SECONDS_BEFORE_TOUCH
1253 })
1254 }
1255
1256 fn classify_approach(
1257 player: &KickoffPlayerSnapshot,
1258 outcome: KickoffTakerOutcome,
1259 boost_after: Option<f32>,
1260 has_speed_flip: bool,
1261 ) -> KickoffApproach {
1262 if has_speed_flip {
1263 return KickoffApproach::SpeedFlip;
1264 }
1265
1266 let boost_used = Self::boost_used(player, boost_after);
1267 let used_boost = player.approach_trace.boost_active_sample_count > 0
1268 || boost_used >= KICKOFF_APPROACH_MIN_BOOST_USED;
1269 let forward_component = player
1270 .approach_trace
1271 .first_dodge_forward_component
1272 .unwrap_or(0.0);
1273 let side_component = player
1274 .approach_trace
1275 .first_dodge_side_component
1276 .unwrap_or(0.0);
1277 if Self::approach_dodge_happened_before_contact(player) {
1278 if side_component.abs() >= KICKOFF_APPROACH_DIAGONAL_FLIP_SIDE_COMPONENT {
1279 return KickoffApproach::DiagonalFlip;
1280 }
1281 if forward_component >= KICKOFF_APPROACH_FRONT_FLIP_FORWARD_COMPONENT {
1282 return KickoffApproach::FrontFlip;
1283 }
1284 }
1285
1286 if player.first_touch_time.is_none() {
1287 let center_progress = Self::center_progress(player);
1288 let low_center_progress = center_progress < KICKOFF_SUPPORT_CHEAT_MIN_CENTER_PROGRESS;
1289 let moved_away_with_boost = used_boost
1290 && low_center_progress
1291 && Self::moved_distance(player) >= KICKOFF_APPROACH_MIN_FAKE_MOVE_DISTANCE;
1292 if matches!(
1293 outcome,
1294 KickoffTakerOutcome::Fake | KickoffTakerOutcome::Missed
1295 ) && low_center_progress
1296 && (Self::boost_gain(player, boost_after)
1297 >= KICKOFF_SUPPORT_GO_FOR_BOOST_MIN_BOOST_GAIN
1298 || Self::lateral_movement(player)
1299 >= KICKOFF_SUPPORT_GO_FOR_BOOST_MIN_LATERAL_MOVE
1300 || moved_away_with_boost)
1301 {
1302 return KickoffApproach::FakeGoForBoost;
1303 }
1304 if used_boost && center_progress > 0.0 {
1305 return KickoffApproach::BoostIntoBall;
1306 }
1307 return KickoffApproach::Other;
1308 }
1309
1310 if used_boost {
1311 return KickoffApproach::BoostIntoBall;
1312 }
1313
1314 KickoffApproach::Other
1315 }
1316
1317 fn approach_flip_direction(
1318 player: &KickoffPlayerSnapshot,
1319 approach: KickoffApproach,
1320 speed_flip_direction: Option<KickoffFlipDirection>,
1321 ) -> KickoffFlipDirection {
1322 match approach {
1323 KickoffApproach::SpeedFlip => speed_flip_direction.unwrap_or_default(),
1324 KickoffApproach::DiagonalFlip => player
1325 .approach_trace
1326 .first_dodge_side_component
1327 .map(KickoffFlipDirection::from_local_side_component)
1328 .unwrap_or_default(),
1329 _ => KickoffFlipDirection::NotApplicable,
1330 }
1331 }
1332
1333 fn center_progress(player: &KickoffPlayerSnapshot) -> f32 {
1334 let Some(last_position) = player.approach_trace.last_position else {
1335 return 0.0;
1336 };
1337 let start_distance =
1338 glam::Vec2::new(player.start_position[0], player.start_position[1]).length();
1339 let end_distance = glam::Vec2::new(last_position[0], last_position[1]).length();
1340 (start_distance - end_distance).max(0.0)
1341 }
1342
1343 fn lateral_movement(player: &KickoffPlayerSnapshot) -> f32 {
1344 let Some(last_position) = player.approach_trace.last_position else {
1345 return 0.0;
1346 };
1347 (last_position[0].abs() - player.start_position[0].abs()).max(0.0)
1348 }
1349
1350 fn boost_gain(player: &KickoffPlayerSnapshot, boost_after: Option<f32>) -> f32 {
1351 match (player.start_boost, boost_after) {
1352 (Some(start_boost), Some(boost_after)) => (boost_after - start_boost).max(0.0),
1353 _ => 0.0,
1354 }
1355 }
1356
1357 fn classify_support_behavior(
1358 player: &KickoffPlayerSnapshot,
1359 is_taker: bool,
1360 ) -> Option<KickoffSupportBehavior> {
1361 if is_taker {
1362 return None;
1363 }
1364 if player.first_touch_time.is_some()
1365 || Self::center_progress(player) >= KICKOFF_SUPPORT_CHEAT_MIN_CENTER_PROGRESS
1366 {
1367 return Some(KickoffSupportBehavior::Cheat);
1368 }
1369 if player.approach_trace.picked_up_immediate_own_back_big_boost {
1370 return Some(KickoffSupportBehavior::GoForBoost);
1371 }
1372 Some(KickoffSupportBehavior::Other)
1373 }
1374
1375 fn win_strength_band(strength: f32) -> KickoffWinStrengthBand {
1376 if strength >= KICKOFF_STRONG_WIN_STRENGTH {
1377 KickoffWinStrengthBand::Strong
1378 } else if strength >= KICKOFF_CLEAR_WIN_STRENGTH {
1379 KickoffWinStrengthBand::Clear
1380 } else {
1381 KickoffWinStrengthBand::Narrow
1382 }
1383 }
1384
1385 fn win_from_ball(ball: &BallFrameState) -> (KickoffOutcome, Option<bool>, Option<f32>) {
1392 let Some(ball) = ball.sample() else {
1393 return (KickoffOutcome::Unknown, None, None);
1394 };
1395 let projected_y = (ball.position().y + ball.velocity().y * KICKOFF_WIN_PROJECTION_SECONDS)
1396 .clamp(-KICKOFF_FIELD_HALF_LENGTH, KICKOFF_FIELD_HALF_LENGTH);
1397 if projected_y.abs() < KICKOFF_WIN_MIN_PROJECTED_BALL_Y {
1398 return (KickoffOutcome::Neutral, None, None);
1399 }
1400 let toward_team_zero_win = projected_y > 0.0;
1401 let strength = projected_y.abs() / KICKOFF_FIELD_HALF_LENGTH;
1402 (
1403 if toward_team_zero_win {
1404 KickoffOutcome::TeamZeroWin
1405 } else {
1406 KickoffOutcome::TeamOneWin
1407 },
1408 Some(toward_team_zero_win),
1409 Some(strength),
1410 )
1411 }
1412
1413 fn ball_direction(ball: &BallFrameState, is_team_0: bool) -> KickoffBallDirection {
1414 let Some(ball) = ball.sample() else {
1415 return KickoffBallDirection::Unknown;
1416 };
1417 let position_x = ball.position().x;
1418 if position_x.abs() >= KICKOFF_BALL_DIRECTION_MIN_ABS_X {
1419 return Self::ball_direction_from_global_x(position_x, is_team_0);
1420 }
1421 let velocity_x = ball.velocity().x;
1422 if velocity_x.abs() >= KICKOFF_BALL_DIRECTION_MIN_ABS_SPEED_X {
1423 return Self::ball_direction_from_global_x(velocity_x, is_team_0);
1424 }
1425 KickoffBallDirection::Center
1426 }
1427
1428 fn ball_direction_from_global_x(value: f32, is_team_0: bool) -> KickoffBallDirection {
1429 if value > 0.0 {
1430 if is_team_0 {
1431 KickoffBallDirection::Right
1432 } else {
1433 KickoffBallDirection::Left
1434 }
1435 } else if is_team_0 {
1436 KickoffBallDirection::Left
1437 } else {
1438 KickoffBallDirection::Right
1439 }
1440 }
1441
1442 fn exit_velocity(ball: &BallFrameState) -> Option<[f32; 3]> {
1443 ball.sample().map(|ball| ball.velocity().to_array())
1444 }
1445
1446 fn exit_speed(exit_velocity: Option<[f32; 3]>) -> Option<f32> {
1447 exit_velocity
1448 .map(|velocity| glam::Vec3::new(velocity[0], velocity[1], velocity[2]).length())
1449 }
1450
1451 fn first_follow_up_touch<'a>(
1452 touches: &'a [KickoffTouchSnapshot],
1453 first_touch_time: Option<f32>,
1454 first_touch_frame: Option<usize>,
1455 team_zero_taker_player: Option<&PlayerId>,
1456 team_one_taker_player: Option<&PlayerId>,
1457 ) -> Option<&'a KickoffTouchSnapshot> {
1458 let (Some(first_touch_time), Some(first_touch_frame)) =
1459 (first_touch_time, first_touch_frame)
1460 else {
1461 return None;
1462 };
1463 let mut previous_touch_time = first_touch_time;
1464 for touch in touches
1465 .iter()
1466 .filter(|touch| Self::touch_after(touch, first_touch_time, first_touch_frame))
1467 {
1468 if Self::is_non_taker_touch(touch, team_zero_taker_player, team_one_taker_player) {
1469 return Some(touch);
1470 }
1471 if touch.time - previous_touch_time > KICKOFF_TOUCH_CLUSTER_MAX_GAP_SECONDS {
1472 return Some(touch);
1473 }
1474 previous_touch_time = touch.time;
1475 }
1476 None
1477 }
1478
1479 fn first_follow_up_touch_for_active(active: &ActiveKickoff) -> Option<&KickoffTouchSnapshot> {
1480 let team_zero_taker = Self::expected_taker_by_team(&active.players, true);
1481 let team_one_taker = Self::expected_taker_by_team(&active.players, false);
1482 let team_zero_taker_player = team_zero_taker.map(|index| &active.players[index].player);
1483 let team_one_taker_player = team_one_taker.map(|index| &active.players[index].player);
1484 Self::first_follow_up_touch(
1485 &active.touches,
1486 active.first_touch_time,
1487 active.first_touch_frame,
1488 team_zero_taker_player,
1489 team_one_taker_player,
1490 )
1491 }
1492
1493 fn is_non_taker_touch(
1494 touch: &KickoffTouchSnapshot,
1495 team_zero_taker_player: Option<&PlayerId>,
1496 team_one_taker_player: Option<&PlayerId>,
1497 ) -> bool {
1498 let Some(player) = touch.player.as_ref() else {
1499 return false;
1500 };
1501 let expected_taker = if touch.team_is_team_0 {
1502 team_zero_taker_player
1503 } else {
1504 team_one_taker_player
1505 };
1506 expected_taker.is_some_and(|taker| taker != player)
1507 }
1508
1509 fn touch_after(touch: &KickoffTouchSnapshot, time: f32, frame: usize) -> bool {
1510 touch.time > time || (touch.time == time && touch.frame > frame)
1511 }
1512
1513 fn kickoff_possession_outcome(
1514 touches: &[KickoffTouchSnapshot],
1515 first_follow_up_touch: Option<&KickoffTouchSnapshot>,
1516 winning_team_is_team_0: Option<bool>,
1517 ) -> (KickoffPossessionOutcome, Option<bool>) {
1518 let Some(first_follow_up_touch) = first_follow_up_touch else {
1519 return match winning_team_is_team_0 {
1520 Some(true) => (KickoffPossessionOutcome::TeamZeroPossession, Some(true)),
1521 Some(false) => (KickoffPossessionOutcome::TeamOnePossession, Some(false)),
1522 None => (KickoffPossessionOutcome::Contested, None),
1523 };
1524 };
1525 let possession = match touches.iter().find(|touch| {
1526 Self::touch_after(
1527 touch,
1528 first_follow_up_touch.time,
1529 first_follow_up_touch.frame,
1530 )
1531 }) {
1532 Some(next_touch)
1533 if next_touch.team_is_team_0 != first_follow_up_touch.team_is_team_0
1534 && next_touch.time - first_follow_up_touch.time
1535 <= KICKOFF_POSSESSION_IMMEDIATE_CONTEST_SECONDS =>
1536 {
1537 KickoffPossessionOutcome::Contested
1538 }
1539 Some(next_touch)
1540 if next_touch.team_is_team_0 != first_follow_up_touch.team_is_team_0
1541 && first_follow_up_touch.team_is_team_0 =>
1542 {
1543 KickoffPossessionOutcome::TeamZeroAdvantage
1544 }
1545 Some(next_touch)
1546 if next_touch.team_is_team_0 != first_follow_up_touch.team_is_team_0 =>
1547 {
1548 KickoffPossessionOutcome::TeamOneAdvantage
1549 }
1550 _ if first_follow_up_touch.team_is_team_0 => {
1551 KickoffPossessionOutcome::TeamZeroPossession
1552 }
1553 _ => KickoffPossessionOutcome::TeamOnePossession,
1554 };
1555 let possession_team = match possession {
1556 KickoffPossessionOutcome::TeamZeroPossession
1557 | KickoffPossessionOutcome::TeamZeroAdvantage => Some(true),
1558 KickoffPossessionOutcome::TeamOnePossession
1559 | KickoffPossessionOutcome::TeamOneAdvantage => Some(false),
1560 _ => None,
1561 };
1562 (possession, possession_team)
1563 }
1564
1565 fn should_finish(
1566 active: &ActiveKickoff,
1567 frame: &FrameInfo,
1568 gameplay: &GameplayState,
1569 events: &FrameEventsState,
1570 ) -> bool {
1571 if !events.goal_events.is_empty() {
1572 return true;
1573 }
1574 let Some(first_touch_time) = active.first_touch_time else {
1575 return gameplay.game_state == Some(GAME_STATE_GOAL_SCORED_REPLAY);
1576 };
1577 (active.resolution.is_some() && Self::first_follow_up_touch_for_active(active).is_some())
1578 || frame.time - first_touch_time >= KICKOFF_FOLLOW_UP_AFTER_FIRST_TOUCH_SECONDS
1579 || gameplay.game_state == Some(GAME_STATE_GOAL_SCORED_REPLAY)
1580 }
1581
1582 fn should_capture_resolution(active: &ActiveKickoff, frame: &FrameInfo) -> bool {
1583 active.resolution.is_none()
1584 && active.first_touch_time.is_some_and(|first_touch_time| {
1585 frame.time - first_touch_time >= KICKOFF_RESOLUTION_AFTER_FIRST_TOUCH_SECONDS
1586 })
1587 }
1588
1589 fn earliest_goal(events: &FrameEventsState) -> Option<&GoalEvent> {
1590 events.goal_events.iter().min_by(|left, right| {
1591 left.time
1592 .total_cmp(&right.time)
1593 .then_with(|| left.frame.cmp(&right.frame))
1594 })
1595 }
1596
1597 fn observe_ball_extent(active: &mut ActiveKickoff, ball: &BallFrameState) {
1601 if active.first_touch_time.is_none() {
1602 return;
1603 }
1604 let Some(sample) = ball.sample() else {
1605 return;
1606 };
1607 let y = sample.position().y;
1608 active.min_ball_y_after_first_touch = Some(
1609 active
1610 .min_ball_y_after_first_touch
1611 .map_or(y, |current| current.min(y)),
1612 );
1613 active.max_ball_y_after_first_touch = Some(
1614 active
1615 .max_ball_y_after_first_touch
1616 .map_or(y, |current| current.max(y)),
1617 );
1618 }
1619
1620 fn kickoff_goal_qualifies(active: &ActiveKickoff, goal: &GoalEvent) -> bool {
1627 let Some(first_touch_time) = active.first_touch_time else {
1628 return false;
1629 };
1630 let time_to_goal = goal.time - first_touch_time;
1631 (0.0..KICKOFF_GOAL_MAX_SECONDS).contains(&time_to_goal)
1632 && !Self::conceding_team_established_possession(&active.touches, goal)
1633 && !Self::ball_reset_into_scoring_half(active, goal)
1634 }
1635
1636 fn conceding_team_established_possession(
1643 touches: &[KickoffTouchSnapshot],
1644 goal: &GoalEvent,
1645 ) -> bool {
1646 let conceding_is_team_0 = !goal.scoring_team_is_team_0;
1647 let mut first_conceding_touch_time: Option<f32> = None;
1648 for touch in touches.iter().filter(|touch| touch.time <= goal.time) {
1649 if touch.team_is_team_0 == conceding_is_team_0 {
1650 match first_conceding_touch_time {
1651 Some(anchor)
1652 if touch.time - anchor > KICKOFF_POSSESSION_IMMEDIATE_CONTEST_SECONDS =>
1653 {
1654 return true;
1655 }
1656 Some(_) => {}
1657 None => first_conceding_touch_time = Some(touch.time),
1658 }
1659 } else {
1660 first_conceding_touch_time = None;
1661 }
1662 }
1663 false
1664 }
1665
1666 fn ball_reset_into_scoring_half(active: &ActiveKickoff, goal: &GoalEvent) -> bool {
1671 if goal.scoring_team_is_team_0 {
1672 active
1673 .min_ball_y_after_first_touch
1674 .is_some_and(|y| y < -KICKOFF_GOAL_MAX_DEFENSIVE_BALL_Y)
1675 } else {
1676 active
1677 .max_ball_y_after_first_touch
1678 .is_some_and(|y| y > KICKOFF_GOAL_MAX_DEFENSIVE_BALL_Y)
1679 }
1680 }
1681
1682 fn attribute_goal(event: &mut KickoffEvent, goal: &GoalEvent) {
1687 let Some(first_touch_time) = event.first_touch_time else {
1688 return;
1689 };
1690 event.time_to_goal = Some(goal.time - first_touch_time);
1691 event.kickoff_goal = true;
1692 event.scoring_team_is_team_0 = Some(goal.scoring_team_is_team_0);
1693 if event.first_follow_up_touch_time.is_none() {
1694 event.kickoff_possession_outcome = if goal.scoring_team_is_team_0 {
1695 KickoffPossessionOutcome::TeamZeroPossession
1696 } else {
1697 KickoffPossessionOutcome::TeamOnePossession
1698 };
1699 event.kickoff_possession_team_is_team_0 = Some(goal.scoring_team_is_team_0);
1700 }
1701 }
1702
1703 fn finish_event(
1704 active: ActiveKickoff,
1705 frame: &FrameInfo,
1706 ball: &BallFrameState,
1707 players: &PlayerFrameState,
1708 events: &FrameEventsState,
1709 speed_flip_events: &[SpeedFlipEvent],
1710 ) -> KickoffEvent {
1711 let mut active = active;
1712 Self::apply_speed_flip_events(&mut active, frame, speed_flip_events);
1713 let resolution_ball = active
1714 .resolution
1715 .as_ref()
1716 .map(|resolution| &resolution.ball)
1717 .unwrap_or(ball);
1718 let (outcome, winning_team_is_team_0, win_strength) = Self::win_from_ball(resolution_ball);
1719 let scoring_goal = events.goal_events.iter().min_by(|left, right| {
1720 left.time
1721 .total_cmp(&right.time)
1722 .then_with(|| left.frame.cmp(&right.frame))
1723 });
1724 let time_to_goal = scoring_goal.and_then(|goal| {
1725 active
1726 .first_touch_time
1727 .map(|first_touch| goal.time - first_touch)
1728 });
1729 let kickoff_goal =
1730 scoring_goal.is_some_and(|goal| Self::kickoff_goal_qualifies(&active, goal));
1731 let win_strength_band = win_strength
1732 .map(Self::win_strength_band)
1733 .unwrap_or_default();
1734 let team_zero_taker = Self::expected_taker_by_team(&active.players, true);
1735 let team_one_taker = Self::expected_taker_by_team(&active.players, false);
1736 let kickoff_type = KickoffType::from_taker_spawns(
1737 team_zero_taker.map(|index| active.players[index].spawn_position),
1738 team_one_taker.map(|index| active.players[index].spawn_position),
1739 );
1740 let kickoff_direction = KickoffDirection::from_taker_spawns(
1741 team_zero_taker.map(|index| active.players[index].spawn_position),
1742 team_one_taker.map(|index| active.players[index].spawn_position),
1743 );
1744 let first_touch = active.touches.first();
1745 let first_touch_player = first_touch.and_then(|touch| touch.player.clone());
1746 let team_zero_taker_touch_time =
1747 team_zero_taker.and_then(|index| active.players[index].first_touch_time);
1748 let team_zero_taker_touch_frame =
1749 team_zero_taker.and_then(|index| active.players[index].first_touch_frame);
1750 let team_one_taker_touch_time =
1751 team_one_taker.and_then(|index| active.players[index].first_touch_time);
1752 let team_one_taker_touch_frame =
1753 team_one_taker.and_then(|index| active.players[index].first_touch_frame);
1754 let taker_touch_delay_seconds =
1755 match (team_zero_taker_touch_time, team_one_taker_touch_time) {
1756 (Some(team_zero_time), Some(team_one_time)) => {
1757 Some((team_one_time - team_zero_time).abs())
1758 }
1759 _ => None,
1760 };
1761 let first_touch_ball_position = active.first_touch_ball_position;
1762 let first_touch_ball_abs_x = first_touch_ball_position.map(|position| position[0].abs());
1763 let first_touch_ball_height = first_touch_ball_position.map(|position| position[2]);
1764 let exit_velocity = Self::exit_velocity(resolution_ball);
1765 let exit_speed = Self::exit_speed(exit_velocity);
1766 let exit_y_velocity = exit_velocity.map(|velocity| velocity[1]);
1767 let team_zero_taker_player = team_zero_taker.map(|index| &active.players[index].player);
1768 let team_one_taker_player = team_one_taker.map(|index| &active.players[index].player);
1769 let first_follow_up_touch = Self::first_follow_up_touch(
1770 &active.touches,
1771 active.first_touch_time,
1772 active.first_touch_frame,
1773 team_zero_taker_player,
1774 team_one_taker_player,
1775 );
1776 let first_follow_up_touch_team_is_team_0 =
1777 first_follow_up_touch.map(|touch| touch.team_is_team_0);
1778 let (mut kickoff_possession_outcome, mut kickoff_possession_team_is_team_0) =
1779 Self::kickoff_possession_outcome(
1780 &active.touches,
1781 first_follow_up_touch,
1782 winning_team_is_team_0,
1783 );
1784 if kickoff_goal && first_follow_up_touch.is_none() {
1785 if let Some(goal) = scoring_goal {
1786 kickoff_possession_outcome = if goal.scoring_team_is_team_0 {
1787 KickoffPossessionOutcome::TeamZeroPossession
1788 } else {
1789 KickoffPossessionOutcome::TeamOnePossession
1790 };
1791 kickoff_possession_team_is_team_0 = Some(goal.scoring_team_is_team_0);
1792 }
1793 }
1794 let team_zero_touched = active
1795 .players
1796 .iter()
1797 .any(|player| player.is_team_0 && player.first_touch_time.is_some());
1798 let team_one_touched = active
1799 .players
1800 .iter()
1801 .any(|player| !player.is_team_0 && player.first_touch_time.is_some());
1802 let mut team_zero_taker_event = None;
1803 let mut team_one_taker_event = None;
1804 let mut team_zero_non_takers = Vec::new();
1805 let mut team_one_non_takers = Vec::new();
1806 let movement_start_time = active.movement_start_time.unwrap_or(active.start_time);
1807 for (index, player) in active.players.iter().enumerate() {
1808 let expected_taker = if player.is_team_0 {
1809 team_zero_taker
1810 } else {
1811 team_one_taker
1812 };
1813 let boost_after = Self::boost_after(players, &player.player);
1814 let is_taker = Self::is_taker(index, expected_taker);
1815 if is_taker {
1816 let outcome = Self::taker_outcome(
1817 player,
1818 expected_taker,
1819 index,
1820 if player.is_team_0 {
1821 team_zero_touched
1822 } else {
1823 team_one_touched
1824 },
1825 );
1826 let taker_boost_after = Self::taker_boost_after(player, boost_after);
1827 let contact = player.first_touch_contact.as_ref();
1828 let speed_flip_direction =
1829 active.speed_flip_directions.get(&player.player).copied();
1830 let approach = Self::classify_approach(
1831 player,
1832 outcome,
1833 taker_boost_after,
1834 speed_flip_direction.is_some(),
1835 );
1836 let player_event = KickoffTakerEvent {
1837 player: player.player.clone(),
1838 is_team_0: player.is_team_0,
1839 start_position: player.start_position,
1840 spawn_position: player.spawn_position,
1841 start_boost: player.start_boost,
1842 boost_after: taker_boost_after,
1843 time_to_ball: Self::taker_time_to_ball(player, movement_start_time),
1844 boost_collected: Self::taker_boost_collected(player),
1845 boost_used: Self::taker_boost_used(player),
1846 ball_direction: Self::ball_direction(ball, player.is_team_0),
1847 first_touch_time: player.first_touch_time,
1848 first_touch_frame: player.first_touch_frame,
1849 contact_player_position: contact.map(|contact| contact.player_position),
1850 contact_player_velocity: contact.and_then(|contact| contact.player_velocity),
1851 contact_car_forward: contact.and_then(|contact| contact.car_forward),
1852 contact_local_ball_position: contact
1853 .and_then(|contact| contact.local_ball_position),
1854 contact_local_contact_point: contact
1855 .and_then(|contact| contact.local_contact_point),
1856 contact_gap: contact.and_then(|contact| contact.contact_gap),
1857 contact_behind_ball_depth: contact.map(|contact| contact.behind_ball_depth),
1858 contact_lateral_offset: contact.map(|contact| contact.lateral_offset),
1859 contact_lateral_abs_offset: contact.map(|contact| contact.lateral_abs_offset),
1860 contact_velocity_attack_alignment: contact
1861 .and_then(|contact| contact.velocity_attack_alignment),
1862 contact_velocity_ball_alignment: contact
1863 .and_then(|contact| contact.velocity_ball_alignment),
1864 contact_nose_attack_alignment: contact
1865 .and_then(|contact| contact.nose_attack_alignment),
1866 contact_ball_exit_attack_alignment: contact
1867 .and_then(|contact| contact.ball_exit_attack_alignment),
1868 outcome,
1869 approach,
1870 approach_flip_direction: Self::approach_flip_direction(
1871 player,
1872 approach,
1873 speed_flip_direction,
1874 ),
1875 };
1876 if player_event.is_team_0 {
1877 team_zero_taker_event = Some(player_event);
1878 } else {
1879 team_one_taker_event = Some(player_event);
1880 }
1881 } else {
1882 let player_event = KickoffSupportEvent {
1883 player: player.player.clone(),
1884 is_team_0: player.is_team_0,
1885 start_position: player.start_position,
1886 start_distance_from_center: Self::kickoff_start_distance(player),
1887 spawn_position: player.spawn_position,
1888 start_boost: player.start_boost,
1889 boost_after,
1890 first_touch_time: player.first_touch_time,
1891 first_touch_frame: player.first_touch_frame,
1892 support_behavior: Self::classify_support_behavior(player, false)
1893 .unwrap_or_default(),
1894 };
1895 if player_event.is_team_0 {
1896 team_zero_non_takers.push(player_event);
1897 } else {
1898 team_one_non_takers.push(player_event);
1899 }
1900 }
1901 }
1902
1903 KickoffEvent {
1904 start_time: active.start_time,
1905 start_frame: active.start_frame,
1906 end_time: frame.time,
1907 end_frame: frame.frame_number,
1908 live_action_start_time: active.live_action_start_time,
1909 live_action_start_frame: active.live_action_start_frame,
1910 movement_start_time,
1911 movement_start_frame: active.movement_start_frame.unwrap_or(active.start_frame),
1912 kickoff_type,
1913 kickoff_direction,
1914 first_touch_time: active.first_touch_time,
1915 first_touch_frame: active.first_touch_frame,
1916 first_touch_team_is_team_0: active.first_touch_team_is_team_0,
1917 first_touch_player,
1918 first_touch_id: active.first_touch_id,
1919 first_touch_ball_position,
1920 first_touch_ball_abs_x,
1921 first_touch_ball_height,
1922 first_touch_ball_velocity: active.first_touch_ball_velocity,
1923 team_zero_taker_touch_time,
1924 team_zero_taker_touch_frame,
1925 team_one_taker_touch_time,
1926 team_one_taker_touch_frame,
1927 taker_touch_delay_seconds,
1928 exit_velocity,
1929 exit_speed,
1930 exit_y_velocity,
1931 first_follow_up_touch_time: first_follow_up_touch.map(|touch| touch.time),
1932 first_follow_up_touch_frame: first_follow_up_touch.map(|touch| touch.frame),
1933 first_follow_up_touch_team_is_team_0,
1934 first_follow_up_touch_player: first_follow_up_touch
1935 .and_then(|touch| touch.player.clone()),
1936 outcome,
1937 winning_team_is_team_0,
1938 win_strength,
1939 win_strength_band,
1940 kickoff_possession_outcome,
1941 kickoff_possession_team_is_team_0,
1942 kickoff_goal,
1943 scoring_team_is_team_0: scoring_goal.map(|goal| goal.scoring_team_is_team_0),
1944 time_to_goal,
1945 advantage: KickoffAdvantage::NoAdvantage,
1949 advantage_team_is_team_0: None,
1950 advantage_time: None,
1951 advantage_frame: None,
1952 advantage_seconds_after_first_touch: None,
1953 advantage_player: None,
1954 team_zero_taker: team_zero_taker_event,
1955 team_one_taker: team_one_taker_event,
1956 team_zero_non_takers,
1957 team_one_non_takers,
1958 }
1959 }
1960
1961 pub fn update(
1962 &mut self,
1963 frame: &FrameInfo,
1964 gameplay: &GameplayState,
1965 ball: &BallFrameState,
1966 players: &PlayerFrameState,
1967 touch_state: &TouchState,
1968 events: &FrameEventsState,
1969 ) -> SubtrActorResult<()> {
1970 self.update_with_speed_flips(KickoffUpdateContext {
1971 frame,
1972 gameplay,
1973 ball,
1974 players,
1975 touch_state,
1976 events,
1977 speed_flip_events: &[],
1978 boost_pickups: &[],
1979 })
1980 }
1981
1982 pub(crate) fn update_with_speed_flips(
1983 &mut self,
1984 ctx: KickoffUpdateContext<'_>,
1985 ) -> SubtrActorResult<()> {
1986 self.events.begin_update();
1987 if ctx.gameplay.kickoff_phase_active() {
1988 let flushed = self.active.advance(ctx.frame.time, |active| {
1992 if active.concluded.is_some() {
1993 Disposition::Finalize(FinalizeReason::Completed)
1994 } else {
1995 Disposition::Keep
1996 }
1997 });
1998 for (active, _reason) in flushed {
1999 Self::emit_concluded(&mut self.events, active);
2000 }
2001 if self.active.is_empty() {
2002 self.start_kickoff(ctx.frame, ctx.players);
2003 }
2004 }
2005
2006 let Some(active) = self.active.in_flight_mut().first_mut() else {
2007 return Ok(());
2008 };
2009 if active.concluded.is_none() {
2010 Self::observe_movement_start(active, ctx.frame, ctx.gameplay);
2011 if !ctx.gameplay.kickoff_countdown_active() {
2012 Self::observe_live_action_start(active, ctx.frame);
2013 }
2014 Self::apply_player_samples(active, ctx.frame, ctx.players);
2015 Self::apply_touches(active, ctx.touch_state, ctx.ball, ctx.players);
2016 Self::apply_boost_pickups(active, ctx.boost_pickups);
2017 Self::apply_speed_flip_events(active, ctx.frame, ctx.speed_flip_events);
2018 if Self::should_capture_resolution(active, ctx.frame) {
2019 active.resolution = Some(KickoffResolutionSnapshot {
2020 ball: ctx.ball.clone(),
2021 });
2022 }
2023 } else {
2024 Self::apply_touches(active, ctx.touch_state, ctx.ball, ctx.players);
2028 }
2029 Self::observe_ball_extent(active, ctx.ball);
2030 if let Some(goal) = Self::earliest_goal(ctx.events) {
2031 if Self::kickoff_goal_qualifies(active, goal) {
2032 active.advantage.establish_goal(goal);
2033 }
2034 }
2035 active
2036 .advantage
2037 .observe(ctx.frame, ctx.ball, &active.touches);
2038
2039 let finished = self.active.advance(ctx.frame.time, |active| {
2049 if active.concluded.is_some() {
2050 if let Some(goal) = Self::earliest_goal(ctx.events) {
2051 if Self::kickoff_goal_qualifies(active, goal) {
2052 let event = active
2053 .concluded
2054 .as_deref_mut()
2055 .expect("concluded checked above");
2056 Self::attribute_goal(event, goal);
2057 }
2058 return Disposition::Finalize(FinalizeReason::Completed);
2061 }
2062 let attribution_window_closed = active
2063 .first_touch_time
2064 .map(|first_touch_time| {
2065 ctx.frame.time - first_touch_time >= KICKOFF_GOAL_MAX_SECONDS
2066 })
2067 .unwrap_or(true);
2068 if attribution_window_closed
2069 || ctx.gameplay.game_state == Some(GAME_STATE_GOAL_SCORED_REPLAY)
2070 {
2071 return Disposition::Finalize(FinalizeReason::Completed);
2072 }
2073 return Disposition::Keep;
2074 }
2075 if Self::should_finish(active, ctx.frame, ctx.gameplay, ctx.events) {
2076 let event = Self::finish_event(
2077 active.clone(),
2078 ctx.frame,
2079 ctx.ball,
2080 ctx.players,
2081 ctx.events,
2082 ctx.speed_flip_events,
2083 );
2084 active.concluded = Some(Box::new(event));
2085 if !ctx.events.goal_events.is_empty()
2089 || ctx.gameplay.game_state == Some(GAME_STATE_GOAL_SCORED_REPLAY)
2090 {
2091 return Disposition::Finalize(FinalizeReason::Completed);
2092 }
2093 return Disposition::Keep;
2094 }
2095 Disposition::Keep
2096 });
2097 for (active, _reason) in finished {
2098 Self::emit_concluded(&mut self.events, active);
2099 }
2100 Ok(())
2101 }
2102}
2103
2104#[cfg(test)]
2105#[path = "kickoff_tests.rs"]
2106mod tests;