Skip to main content

subtr_actor/stats/calculators/
center.rs

1use super::*;
2
3const CENTER_MAX_DURATION_SECONDS: f32 = 3.0;
4const CENTER_MIN_BALL_TRAVEL_DISTANCE: f32 = 500.0;
5const CENTER_MIN_LATERAL_DISTANCE: f32 = 500.0;
6const CENTER_MIN_START_ABS_X: f32 = 1600.0;
7const CENTER_MAX_END_ABS_X: f32 = 1400.0;
8const CENTER_MIN_START_ATTACKING_Y: f32 = BOOST_PAD_MIDFIELD_TOLERANCE_Y;
9const CENTER_MIN_END_ATTACKING_Y: f32 = FIELD_ZONE_BOUNDARY_Y;
10
11/// A touch moving the ball from a wide position toward the central attacking area.
12#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
13#[ts(export)]
14pub struct CenterEvent {
15    pub time: f32,
16    pub frame: usize,
17    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
18    pub player: PlayerId,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub player_position: Option<[f32; 3]>,
21    pub is_team_0: bool,
22    pub start_time: f32,
23    pub start_frame: usize,
24    pub duration: f32,
25    pub start_ball_position: [f32; 3],
26    pub end_ball_position: [f32; 3],
27    pub ball_travel_distance: f32,
28    pub ball_advance_distance: f32,
29    pub lateral_centering_distance: f32,
30}
31
32#[derive(Debug, Clone)]
33struct PendingCenterTouch {
34    player: PlayerId,
35    player_position: Option<[f32; 3]>,
36    is_team_0: bool,
37    time: f32,
38    frame: usize,
39    ball_position: glam::Vec3,
40}
41
42/// Detects centering passes during live play.
43#[derive(Debug, Clone, Default)]
44pub struct CenterCalculator {
45    events: EventStream<CenterEvent>,
46    pending_touch: Option<PendingCenterTouch>,
47}
48
49impl CenterCalculator {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    pub fn events(&self) -> &[CenterEvent] {
55        self.events.all()
56    }
57
58    pub fn new_events(&self) -> &[CenterEvent] {
59        self.events.new_events()
60    }
61
62    fn center_event_for_position(
63        pending: &PendingCenterTouch,
64        frame: &FrameInfo,
65        ball_position: glam::Vec3,
66    ) -> Option<CenterEvent> {
67        let duration = frame.time - pending.time;
68        if !(0.0..=CENTER_MAX_DURATION_SECONDS).contains(&duration) {
69            return None;
70        }
71
72        let start_normalized_y = normalized_y(pending.is_team_0, pending.ball_position);
73        let end_normalized_y = normalized_y(pending.is_team_0, ball_position);
74        if start_normalized_y < CENTER_MIN_START_ATTACKING_Y
75            || end_normalized_y < CENTER_MIN_END_ATTACKING_Y
76        {
77            return None;
78        }
79
80        let start_abs_x = pending.ball_position.x.abs();
81        let end_abs_x = ball_position.x.abs();
82        let lateral_centering_distance = start_abs_x - end_abs_x;
83        if start_abs_x < CENTER_MIN_START_ABS_X
84            || end_abs_x > CENTER_MAX_END_ABS_X
85            || lateral_centering_distance < CENTER_MIN_LATERAL_DISTANCE
86        {
87            return None;
88        }
89
90        let ball_delta = ball_position - pending.ball_position;
91        let ball_travel_distance = ball_delta.length();
92        if ball_travel_distance < CENTER_MIN_BALL_TRAVEL_DISTANCE {
93            return None;
94        }
95
96        let team_forward_sign = if pending.is_team_0 { 1.0 } else { -1.0 };
97        Some(CenterEvent {
98            time: frame.time,
99            frame: frame.frame_number,
100            player: pending.player.clone(),
101            player_position: pending.player_position,
102            is_team_0: pending.is_team_0,
103            start_time: pending.time,
104            start_frame: pending.frame,
105            duration,
106            start_ball_position: pending.ball_position.to_array(),
107            end_ball_position: ball_position.to_array(),
108            ball_travel_distance,
109            ball_advance_distance: ball_delta.y * team_forward_sign,
110            lateral_centering_distance,
111        })
112    }
113
114    fn record_center(&mut self, _frame: &FrameInfo, event: CenterEvent) {
115        self.events.push(event);
116        self.pending_touch = None;
117    }
118
119    fn update_pending_center(&mut self, frame: &FrameInfo, ball_position: glam::Vec3) {
120        let Some(pending) = self.pending_touch.as_ref() else {
121            return;
122        };
123        let duration = frame.time - pending.time;
124        if duration > CENTER_MAX_DURATION_SECONDS {
125            self.pending_touch = None;
126            return;
127        }
128
129        if let Some(event) = Self::center_event_for_position(pending, frame, ball_position) {
130            self.record_center(frame, event);
131        }
132    }
133
134    fn player_has_disqualifying_event(
135        events: &FrameEventsState,
136        player: &PlayerId,
137        is_team_0: bool,
138    ) -> bool {
139        events.player_stat_events.iter().any(|event| {
140            event.kind == PlayerStatEventKind::Shot
141                && event.player == *player
142                && event.is_team_0 == is_team_0
143        }) || events
144            .goal_events
145            .iter()
146            .any(|event| match event.player.as_ref() {
147                Some(scorer) => scorer == player,
148                None => event.scoring_team_is_team_0 == is_team_0,
149            })
150    }
151
152    fn clear_disqualified_pending_center(&mut self, events: &FrameEventsState) {
153        let should_clear = self.pending_touch.as_ref().is_some_and(|pending| {
154            Self::player_has_disqualifying_event(events, &pending.player, pending.is_team_0)
155        });
156        if should_clear {
157            self.pending_touch = None;
158        }
159    }
160
161    pub fn update(
162        &mut self,
163        frame: &FrameInfo,
164        ball: &BallFrameState,
165        touch_state: &TouchState,
166        frame_events: &FrameEventsState,
167        live_play_state: &LivePlayState,
168    ) -> SubtrActorResult<()> {
169        self.events.begin_update();
170        if !live_play_state.is_live_play {
171            self.pending_touch = None;
172            return Ok(());
173        }
174
175        let Some(ball_position) = ball.position() else {
176            return Ok(());
177        };
178
179        self.clear_disqualified_pending_center(frame_events);
180        self.update_pending_center(frame, ball_position);
181
182        for touch in sequential_touch_events(&touch_state.touch_events) {
183            let Some(player) = touch.player.clone() else {
184                self.pending_touch = None;
185                continue;
186            };
187
188            if Self::player_has_disqualifying_event(frame_events, &player, touch.team_is_team_0) {
189                self.pending_touch = None;
190                continue;
191            }
192
193            self.pending_touch = Some(PendingCenterTouch {
194                player,
195                player_position: touch
196                    .player_position
197                    .map(|position| vec_to_glam(&position).to_array()),
198                is_team_0: touch.team_is_team_0,
199                time: touch.time,
200                frame: touch.frame,
201                ball_position,
202            });
203        }
204
205        Ok(())
206    }
207}
208
209#[cfg(test)]
210#[path = "center_tests.rs"]
211mod tests;