Skip to main content

subtr_actor/stats/calculators/
kickoff.rs

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;
28/// The dodge/flip impulse is delivered over several frames (the car drags
29/// through the dodge), and boxcars often does not re-send the car velocity on
30/// the exact frame the dodge becomes active, so a single-frame velocity delta at
31/// dodge onset is unreliable (it is frequently exactly zero). Decompose the
32/// *peak* velocity change across this short window after the dodge instead.
33const KICKOFF_APPROACH_DODGE_DIRECTION_WINDOW_SECONDS: f32 = 0.20;
34/// Forward acceleration contributed by boost, subtracted across the dodge window
35/// so the recovered dodge direction reflects the flip impulse rather than the
36/// boost the taker is holding through it. Mirrors the speed-flip detector.
37const 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    /// State for the windowed dodge-direction recovery (see
69    /// `KICKOFF_APPROACH_DODGE_DIRECTION_WINDOW_SECONDS`). Captured at dodge
70    /// onset and consumed for a short window afterward.
71    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    /// Boost collected from pad pickups (including steals; overfill excluded)
82    /// during the approach window, summed from the `BoostCalculator`'s
83    /// deduplicated pickup events. This deliberately does NOT come from gross
84    /// frame-to-frame `boost_amount` deltas: a single pad pickup can show up as
85    /// a `+jump` one frame and a `-correction` the next as the inferred and
86    /// reported boost balances reconcile, so summing positive deltas
87    /// double-counts and can push `boost_used` past a full tank.
88    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/// Watches the play that follows a kickoff to decide who the kickoff was
142/// actually good for (see [`KickoffAdvantage`]), running through the same
143/// post-conclusion in-flight window used for late goal attribution.
144///
145/// Two competing detectors race; the first to fire settles the kickoff:
146///
147/// - **Possession run**: consecutive touches by one team — no opposing touch
148///   in between — spanning at least
149///   [`KICKOFF_ADVANTAGE_POSSESSION_MIN_RUN_SECONDS`]. This is what credits the
150///   team that *lost* the opening exchange but cleanly collected the ball,
151///   even deep in its own half.
152/// - **Anchored pressure**: the ball held beyond the neutral band in the
153///   opponent's half long enough to establish territorial pressure. The
154///   clocks only run once the attacking team has touched the ball inside that
155///   zone (the anchor); a hard poke sailing deep while the defense calmly
156///   retrieves it never anchors and so never reads as pressure. Defensive
157///   panic touches do not reset the clocks — a defense that strings real
158///   possession together wins the race through the possession run instead.
159#[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    /// Running ball-y extremes observed since the kickoff's first touch,
298    /// including frames after the kickoff's logical close. Used by the
299    /// kickoff-goal field-position gate: if the ball retreated meaningfully
300    /// into the eventual scoring team's own half, the goal came from a reset
301    /// play rather than the kickoff exchange.
302    min_ball_y_after_first_touch: Option<f32>,
303    max_ball_y_after_first_touch: Option<f32>,
304    /// Keeps watching after the kickoff's logical close (alongside goal
305    /// attribution) to decide who came out of the kickoff with the advantage;
306    /// its result is written onto the concluded event at emission.
307    advantage: KickoffAdvantageWatcher,
308    /// The fully built event, frozen at the kickoff's logical close
309    /// (`should_finish`). The item then stays in flight, awaiting goal
310    /// attribution: a goal scored within [`KICKOFF_GOAL_MAX_SECONDS`] of the
311    /// first touch still counts as a kickoff goal even though it lands after
312    /// the kickoff itself has closed.
313    concluded: Option<Box<KickoffEvent>>,
314}
315
316impl InFlightItem for ActiveKickoff {
317    fn recognition(&self) -> Recognition {
318        // A kickoff phase is always a real kickoff, so it is committed from the
319        // moment it is recognized.
320        Recognition::committed(self.start_time, self.start_frame)
321    }
322
323    fn on_boundary(&mut self, boundary: Boundary) -> Disposition {
324        // A concluded kickoff is a complete event that is merely waiting for
325        // late goal attribution; the stream ending just means no further goal
326        // can arrive, so it is emitted as-is. A kickoff that never reached its
327        // logical close has no resolution; emitting a truncated event would be
328        // misleading, so it is discarded (preserving the previous "drop the
329        // unfinished kickoff" behavior, now handled structurally rather than
330        // by omission).
331        if self.concluded.is_some() {
332            Disposition::Finalize(FinalizeReason::Boundary(boundary))
333        } else {
334            Disposition::Discard
335        }
336    }
337}
338
339/// Detects and classifies kickoffs and their outcomes.
340#[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    /// Boost pickup events newly emitted this frame by the `BoostCalculator`.
355    /// Used to attribute deduplicated boost collected to in-flight kickoff
356    /// players during their approach window.
357    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    /// Finalize any in-flight kickoff at end of stream. Routed through the
684    /// ledger so the boundary is handled uniformly; a concluded kickoff that
685    /// was only waiting for late goal attribution is emitted, while an
686    /// unresolved kickoff is discarded (see `ActiveKickoff::on_boundary`).
687    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    /// Emit a concluded kickoff, stamping the advantage watcher's verdict
694    /// onto the frozen event. The advantage usually lands after the kickoff's
695    /// logical close, so it is applied here — at emission — rather than in
696    /// `finish_event`.
697    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        // A kickoff is always armed while the countdown is still active, so the
737        // first armed frame on which the countdown is no longer active marks the
738        // "GO" — the moment players are released toward the ball.
739        //
740        // We deliberately do not also require `kickoff_phase_active()` here. On
741        // the opening kickoff of a match the engine reports
742        // `ball_has_been_hit == None` (rather than `Some(false)`) until the ball
743        // is first touched, so the kickoff phase never registers as active in the
744        // window between the countdown ending and the first touch. Requiring it
745        // left `movement_start` unset on the first kickoff, falling back to the
746        // countdown's *start* and inflating every taker's `time_to_ball` by the
747        // full countdown (~3s). Gating on the countdown alone keeps movement
748        // start aligned with "GO" on the opening kickoff and matches the
749        // behavior on every subsequent kickoff.
750        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                // Collection is tracked from pad-pickup events, not deltas; only
778                // the spent side is sampled here, as a fallback for boost_used
779                // when start/contact boost is unavailable.
780                let delta = boost_amount - previous_boost;
781                if delta < 0.0 {
782                    // Only the depletion side is sampled here, as a fallback for
783                    // `boost_used` when a player's first-touch boost is unknown.
784                    // Collected boost is sourced from deduplicated pickup events
785                    // (see `apply_boost_pickups`), not gross positive deltas.
786                    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                // Baseline is the velocity just before the flip impulse lands.
814                // Prefer the previous frame's velocity; the onset frame itself
815                // often repeats it (boxcars did not re-send it), which is exactly
816                // why the old single-frame delta read zero.
817                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        // A later, separate dodge (e.g. flipping forward into the ball at first
825        // touch) must not overwrite the first dodge's recovered direction, so
826        // close the window as soon as a new dodge begins.
827        if dodge_rising && had_first_dodge {
828            trace.dodge_direction_window_deadline = None;
829        }
830
831        // Recover the dodge direction from the peak velocity change over the
832        // window after onset, decomposed in the car's onset frame, with the
833        // forward boost contribution removed so a boosting taker's flip still
834        // reads as diagonal rather than straight-forward.
835        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    /// Attribute deduplicated boost pickups to in-flight kickoff players during
1003    /// their approach window (from movement start through their first touch).
1004    ///
1005    /// `pickups` are the events newly emitted this frame by the
1006    /// `BoostCalculator`, whose `collected_amount` already folds the former
1007    /// Collected/Stolen ledger transactions into one accurate, capped figure
1008    /// (overfill and respawn excluded). Summing these is what keeps
1009    /// `boost_used = start_boost + collected - first_touch_boost` from
1010    /// overcounting the way gross frame-to-frame `boost_amount` deltas did.
1011    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                // No tied candidate touched the ball (the team was beaten to the
1137                // kickoff). Distance and first-touch can't disambiguate, so prefer
1138                // the player who actually committed to the ball: greatest advance
1139                // toward center, then most boost burned. The static left-side
1140                // tiebreak is only a last resort for genuinely identical approaches.
1141                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    /// Boost spent during the kickoff approach (`start_boost - min_boost`). A
1157    /// player charging the ball burns boost; a teammate peeling off for a pad
1158    /// does not, so this separates the true taker from support when neither
1159    /// player touched the ball.
1160    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    /// Boost the taker has left *at the moment of contact* with the ball.
1215    ///
1216    /// This is the counterpart to [`taker_boost_used`], which measures boost
1217    /// spent over the `start -> first touch` window. The plain finish-frame
1218    /// sample (`boost_after`) is taken ~1.25s after the touch, by which point
1219    /// the taker has driven on and possibly re-collected boost, so pairing it
1220    /// with `boost_used` produces nonsensical combinations (e.g. used 94 /
1221    /// after 80). Using `first_touch_boost` keeps the two consistent:
1222    /// `start_boost + boost_collected == boost_used + boost_after`.
1223    ///
1224    /// Falls back to the finish-frame sample for takers who never touched the
1225    /// ball (fake / missed outcomes), where there is no contact moment.
1226    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    /// Decide the kickoff outcome from the resolution ball sample by projecting
1386    /// the ball's y position [`KICKOFF_WIN_PROJECTION_SECONDS`] ahead so the
1387    /// direction of travel counts: a ball barely across the center line but
1388    /// flying back toward it is not a win for the side it currently sits in.
1389    /// Strength is the projected depth as a fraction of the half-field length,
1390    /// so it lands in `0.0..=1.0`.
1391    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    /// Track the ball's y extremes from the kickoff's first touch onward,
1598    /// including frames after the kickoff's logical close while it awaits
1599    /// goal attribution.
1600    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    /// Whether a goal qualifies as a kickoff goal. The goal must land within
1621    /// [`KICKOFF_GOAL_MAX_SECONDS`] of the first touch, but proximity in time
1622    /// alone is not enough: the goal also has to flow from the kickoff
1623    /// exchange itself, so the conceding team must never have settled the ball
1624    /// in between, and the play must not have reset through the scoring
1625    /// team's own half.
1626    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    /// The conceding team "established possession" when it recorded two
1637    /// touches separated by more than the immediate-contest window with no
1638    /// scoring-team touch in between — they settled the ball rather than
1639    /// merely deflecting it during the kickoff scramble. A single conceding
1640    /// touch (a failed clear or deflection straight into punishment) does not
1641    /// break the kickoff-goal chain.
1642    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    /// Whether the ball retreated past [`KICKOFF_GOAL_MAX_DEFENSIVE_BALL_Y`]
1667    /// into the scoring team's own half between the kickoff's first touch and
1668    /// the goal. Team zero attacks positive y, so its defensive half is
1669    /// negative y.
1670    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    /// Attribute a qualifying goal (see [`Self::kickoff_goal_qualifies`]) that
1683    /// landed after the kickoff's logical close to the concluded kickoff
1684    /// event. Mirrors the attribution `finish_event` performs when the goal
1685    /// arrives while the kickoff is still open.
1686    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            // The advantage usually resolves after the kickoff's logical
1946            // close; the watcher's verdict is stamped on at emission
1947            // (`emit_concluded`).
1948            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            // Once the next kickoff phase begins, no further goal can belong
1989            // to a previous kickoff: flush any concluded kickoff still held
1990            // for goal attribution before arming the new one.
1991            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            // The frozen event no longer changes, but the kickoff-goal gates
2025            // still need the touch chain and ball-position history through the
2026            // attribution window.
2027            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        // Natural finalization happens in two stages. The kickoff *concludes*
2040        // once `should_finish` is met: its event content is frozen there
2041        // (touches, possession, exit ball state). It then stays in flight,
2042        // awaiting goal attribution, until a goal arrives, the attribution
2043        // window closes, or the next kickoff begins. This lets a goal scored
2044        // after the kickoff's logical close — but still within
2045        // `KICKOFF_GOAL_MAX_SECONDS` of the first touch — count as a kickoff
2046        // goal. The ledger keeps the in-flight kickoff queryable and
2047        // guarantees it is resolved at the end of the stream.
2048        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                    // Whether or not the goal qualified, play stops here; no
2059                    // later goal can belong to this kickoff.
2060                    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                // A goal (or goal replay) at the close frame is already
2086                // attributed by `finish_event`; nothing further can change the
2087                // event, so emit immediately.
2088                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;