subtr_actor/stats/calculators/
one_timer.rs1use super::*;
2
3const ONE_TIMER_MIN_BALL_SPEED: f32 = 1000.0;
4const ONE_TIMER_MIN_GOAL_ALIGNMENT_COSINE: f32 = 0.65;
5const GOAL_CENTER_Y: f32 = 5120.0;
6
7#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
8#[ts(export)]
9pub struct OneTimerEvent {
10 pub time: f32,
11 pub frame: usize,
12 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
13 pub player: PlayerId,
14 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
15 pub passer: PlayerId,
16 pub is_team_0: bool,
17 pub pass_start_time: f32,
18 pub pass_start_frame: usize,
19 pub pass_duration: f32,
20 pub pass_travel_distance: f32,
21 pub pass_advance_distance: f32,
22 pub ball_speed: f32,
23 pub goal_alignment: f32,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
27#[ts(export)]
28pub struct OneTimerPlayerStats {
29 pub count: u32,
30 pub total_ball_speed: f32,
31 pub fastest_ball_speed: f32,
32 pub total_pass_distance: f32,
33 pub is_last_one_timer: bool,
34 pub last_one_timer_time: Option<f32>,
35 pub last_one_timer_frame: Option<usize>,
36 pub time_since_last_one_timer: Option<f32>,
37 pub frames_since_last_one_timer: Option<usize>,
38}
39
40impl OneTimerPlayerStats {
41 pub fn average_ball_speed(&self) -> f32 {
42 if self.count == 0 {
43 0.0
44 } else {
45 self.total_ball_speed / self.count as f32
46 }
47 }
48
49 pub fn average_pass_distance(&self) -> f32 {
50 if self.count == 0 {
51 0.0
52 } else {
53 self.total_pass_distance / self.count as f32
54 }
55 }
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
59#[ts(export)]
60pub struct OneTimerTeamStats {
61 pub count: u32,
62 pub total_ball_speed: f32,
63 pub fastest_ball_speed: f32,
64}
65
66impl OneTimerTeamStats {
67 pub fn average_ball_speed(&self) -> f32 {
68 if self.count == 0 {
69 0.0
70 } else {
71 self.total_ball_speed / self.count as f32
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default)]
77pub struct OneTimerCalculator {
78 player_stats: HashMap<PlayerId, OneTimerPlayerStats>,
79 team_zero_stats: OneTimerTeamStats,
80 team_one_stats: OneTimerTeamStats,
81 events: Vec<OneTimerEvent>,
82 processed_pass_events: usize,
83 current_last_one_timer_player: Option<PlayerId>,
84}
85
86impl OneTimerCalculator {
87 pub fn new() -> Self {
88 Self::default()
89 }
90
91 pub fn player_stats(&self) -> &HashMap<PlayerId, OneTimerPlayerStats> {
92 &self.player_stats
93 }
94
95 pub fn team_zero_stats(&self) -> &OneTimerTeamStats {
96 &self.team_zero_stats
97 }
98
99 pub fn team_one_stats(&self) -> &OneTimerTeamStats {
100 &self.team_one_stats
101 }
102
103 pub fn events(&self) -> &[OneTimerEvent] {
104 &self.events
105 }
106
107 fn begin_sample(&mut self, frame: &FrameInfo) {
108 for stats in self.player_stats.values_mut() {
109 stats.is_last_one_timer = false;
110 stats.time_since_last_one_timer = stats
111 .last_one_timer_time
112 .map(|time| (frame.time - time).max(0.0));
113 stats.frames_since_last_one_timer = stats
114 .last_one_timer_frame
115 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
116 }
117 }
118
119 fn one_timer_event_for_pass(pass: &PassEvent, ball: &BallFrameState) -> Option<OneTimerEvent> {
120 let ball = ball.sample()?;
121 let ball_position = ball.position();
122 let ball_velocity = ball.velocity();
123 let ball_speed = ball_velocity.length();
124 if ball_speed < ONE_TIMER_MIN_BALL_SPEED {
125 return None;
126 }
127
128 let target_y = if pass.is_team_0 {
129 GOAL_CENTER_Y
130 } else {
131 -GOAL_CENTER_Y
132 };
133 let goal_direction = glam::Vec3::new(0.0, target_y, ball_position.z) - ball_position;
134 let goal_alignment = goal_direction
135 .normalize_or_zero()
136 .dot(ball_velocity.normalize_or_zero());
137 if goal_alignment < ONE_TIMER_MIN_GOAL_ALIGNMENT_COSINE {
138 return None;
139 }
140
141 Some(OneTimerEvent {
142 time: pass.time,
143 frame: pass.frame,
144 player: pass.receiver.clone(),
145 passer: pass.passer.clone(),
146 is_team_0: pass.is_team_0,
147 pass_start_time: pass.start_time,
148 pass_start_frame: pass.start_frame,
149 pass_duration: pass.duration,
150 pass_travel_distance: pass.ball_travel_distance,
151 pass_advance_distance: pass.ball_advance_distance,
152 ball_speed,
153 goal_alignment,
154 })
155 }
156
157 fn record_one_timer(&mut self, frame: &FrameInfo, event: OneTimerEvent) {
158 let player_stats = self.player_stats.entry(event.player.clone()).or_default();
159 player_stats.count += 1;
160 player_stats.total_ball_speed += event.ball_speed;
161 player_stats.fastest_ball_speed = player_stats.fastest_ball_speed.max(event.ball_speed);
162 player_stats.total_pass_distance += event.pass_travel_distance;
163 player_stats.last_one_timer_time = Some(event.time);
164 player_stats.last_one_timer_frame = Some(event.frame);
165 player_stats.time_since_last_one_timer = Some((frame.time - event.time).max(0.0));
166 player_stats.frames_since_last_one_timer =
167 Some(frame.frame_number.saturating_sub(event.frame));
168
169 let team_stats = if event.is_team_0 {
170 &mut self.team_zero_stats
171 } else {
172 &mut self.team_one_stats
173 };
174 team_stats.count += 1;
175 team_stats.total_ball_speed += event.ball_speed;
176 team_stats.fastest_ball_speed = team_stats.fastest_ball_speed.max(event.ball_speed);
177
178 self.current_last_one_timer_player = Some(event.player.clone());
179 self.events.push(event);
180 }
181
182 pub fn update(
183 &mut self,
184 frame: &FrameInfo,
185 ball: &BallFrameState,
186 pass_calculator: &PassCalculator,
187 live_play: bool,
188 ) -> SubtrActorResult<()> {
189 self.begin_sample(frame);
190 if !live_play {
191 self.current_last_one_timer_player = None;
192 self.processed_pass_events = pass_calculator.events().len();
193 return Ok(());
194 }
195
196 for pass in &pass_calculator.events()[self.processed_pass_events..] {
197 if pass.frame != frame.frame_number {
198 continue;
199 }
200 if let Some(event) = Self::one_timer_event_for_pass(pass, ball) {
201 self.record_one_timer(frame, event);
202 }
203 }
204 self.processed_pass_events = pass_calculator.events().len();
205
206 if let Some(player_id) = self.current_last_one_timer_player.as_ref() {
207 if let Some(stats) = self.player_stats.get_mut(player_id) {
208 stats.is_last_one_timer = true;
209 }
210 }
211
212 Ok(())
213 }
214}
215
216#[cfg(test)]
217#[path = "one_timer_tests.rs"]
218mod tests;