subtr_actor/stats/calculators/
center.rs1use 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#[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#[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;