subtr_actor/stats/calculators/
musty_flick.rs1use super::*;
2
3const MUSTY_MAX_DODGE_TO_TOUCH_SECONDS: f32 = 0.22;
4const MUSTY_MIN_PLAYER_HEIGHT: f32 = 80.0;
5const MUSTY_AERIAL_HEIGHT: f32 = 180.0;
6const MUSTY_MIN_FORWARD_APPROACH_SPEED: f32 = 150.0;
7const MUSTY_MIN_BALL_SPEED_CHANGE: f32 = 150.0;
8const MUSTY_MIN_REAR_ALIGNMENT: f32 = 0.15;
9const MUSTY_MIN_TOP_ALIGNMENT: f32 = 0.10;
10const MUSTY_MIN_LOCAL_Z: f32 = 5.0;
11const MUSTY_MAX_LOCAL_X: f32 = 60.0;
12const MUSTY_MAX_LOCAL_Y: f32 = 170.0;
13const MUSTY_MIN_PITCH_RATE: f32 = 2.5;
14const MUSTY_MIN_PITCH_DOMINANCE_RATIO: f32 = 1.1;
15const MUSTY_MIN_DODGE_START_FORWARD_Z: f32 = -0.25;
16const MUSTY_MIN_CONFIDENCE: f32 = 0.55;
17const MUSTY_HIGH_CONFIDENCE: f32 = 0.80;
18
19#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
20#[ts(export)]
21pub struct MustyFlickEvent {
22 pub time: f32,
23 pub frame: usize,
24 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
25 pub player: PlayerId,
26 pub is_team_0: bool,
27 pub dodge_time: f32,
28 pub dodge_frame: usize,
29 pub time_since_dodge: f32,
30 pub confidence: f32,
31 pub local_ball_position: [f32; 3],
32 pub rear_alignment: f32,
33 pub top_alignment: f32,
34 pub forward_approach_speed: f32,
35 pub pitch_rate: f32,
36 pub ball_speed_change: f32,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
40#[ts(export)]
41pub struct MustyFlickStats {
42 pub count: u32,
43 pub aerial_count: u32,
44 pub high_confidence_count: u32,
45 pub is_last_musty: bool,
46 pub last_musty_time: Option<f32>,
47 pub last_musty_frame: Option<usize>,
48 pub time_since_last_musty: Option<f32>,
49 pub frames_since_last_musty: Option<usize>,
50 pub last_confidence: Option<f32>,
51 pub best_confidence: f32,
52 pub cumulative_confidence: f32,
53}
54
55impl MustyFlickStats {
56 pub fn average_confidence(&self) -> f32 {
57 if self.count == 0 {
58 0.0
59 } else {
60 self.cumulative_confidence / self.count as f32
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
66struct RecentDodgeStart {
67 time: f32,
68 frame: usize,
69 forward_z: f32,
70}
71
72#[derive(Debug, Clone, Default, PartialEq)]
73pub struct MustyFlickCalculator {
74 player_stats: HashMap<PlayerId, MustyFlickStats>,
75 events: Vec<MustyFlickEvent>,
76 recent_dodge_starts: HashMap<PlayerId, RecentDodgeStart>,
77 previous_dodge_active: HashMap<PlayerId, bool>,
78 previous_ball_velocity: Option<glam::Vec3>,
79 current_last_musty_player: Option<PlayerId>,
80}
81
82impl MustyFlickCalculator {
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn player_stats(&self) -> &HashMap<PlayerId, MustyFlickStats> {
88 &self.player_stats
89 }
90
91 pub fn events(&self) -> &[MustyFlickEvent] {
92 &self.events
93 }
94
95 fn begin_sample(&mut self, frame: &FrameInfo) {
96 for stats in self.player_stats.values_mut() {
97 stats.is_last_musty = false;
98 stats.time_since_last_musty = stats
99 .last_musty_time
100 .map(|time| (frame.time - time).max(0.0));
101 stats.frames_since_last_musty = stats
102 .last_musty_frame
103 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
104 }
105 }
106
107 fn ball_speed_change(
108 frame: &FrameInfo,
109 ball: &BallFrameState,
110 previous_ball_velocity: Option<glam::Vec3>,
111 ) -> f32 {
112 const BALL_GRAVITY_Z: f32 = -650.0;
113
114 let Some(ball) = ball.sample() else {
115 return 0.0;
116 };
117 let Some(previous_ball_velocity) = previous_ball_velocity else {
118 return 0.0;
119 };
120
121 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
122 let residual_linear_impulse =
123 ball.velocity() - previous_ball_velocity - expected_linear_delta;
124 residual_linear_impulse.length()
125 }
126
127 fn track_dodge_starts(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
128 for player in &players.players {
129 let was_dodge_active = self
130 .previous_dodge_active
131 .insert(player.player_id.clone(), player.dodge_active)
132 .unwrap_or(false);
133 if !player.dodge_active || was_dodge_active {
134 continue;
135 }
136
137 let Some(rigid_body) = player.rigid_body.as_ref() else {
138 continue;
139 };
140 let forward = quat_to_glam(&rigid_body.rotation) * glam::Vec3::X;
141 self.recent_dodge_starts.insert(
142 player.player_id.clone(),
143 RecentDodgeStart {
144 time: frame.time,
145 frame: frame.frame_number,
146 forward_z: forward.z,
147 },
148 );
149 }
150 }
151
152 fn prune_recent_dodge_starts(&mut self, current_time: f32) {
153 self.recent_dodge_starts
154 .retain(|_, dodge| current_time - dodge.time <= MUSTY_MAX_DODGE_TO_TOUCH_SECONDS);
155 }
156
157 fn musty_candidate(
158 &self,
159 ball: &BallFrameState,
160 player: &PlayerSample,
161 touch_event: &TouchEvent,
162 dodge_start: RecentDodgeStart,
163 ball_speed_change: f32,
164 ) -> Option<MustyFlickEvent> {
165 let ball = ball.sample()?;
166 let player_rigid_body = player.rigid_body.as_ref()?;
167 let player_position = player.position()?;
168 if player_position.z < MUSTY_MIN_PLAYER_HEIGHT {
169 return None;
170 }
171
172 let time_since_dodge = touch_event.time - dodge_start.time;
173 if !(0.0..=MUSTY_MAX_DODGE_TO_TOUCH_SECONDS).contains(&time_since_dodge) {
174 return None;
175 }
176 if dodge_start.forward_z < MUSTY_MIN_DODGE_START_FORWARD_Z {
177 return None;
178 }
179
180 let player_rotation = quat_to_glam(&player_rigid_body.rotation);
181 let relative_ball_position = ball.position() - player_position;
182 let to_ball = relative_ball_position.normalize_or_zero();
183 if to_ball.length_squared() <= f32::EPSILON {
184 return None;
185 }
186
187 let local_ball_position = player_rotation.inverse() * relative_ball_position;
188 if local_ball_position.x > MUSTY_MAX_LOCAL_X
189 || local_ball_position.y.abs() > MUSTY_MAX_LOCAL_Y
190 || local_ball_position.z < MUSTY_MIN_LOCAL_Z
191 {
192 return None;
193 }
194
195 let forward = player_rotation * glam::Vec3::X;
196 let up = player_rotation * glam::Vec3::Z;
197 let rear_alignment = (-forward).dot(to_ball);
198 let top_alignment = up.dot(to_ball);
199 if rear_alignment < MUSTY_MIN_REAR_ALIGNMENT || top_alignment < MUSTY_MIN_TOP_ALIGNMENT {
200 return None;
201 }
202
203 let forward_approach_speed = player.velocity().unwrap_or(glam::Vec3::ZERO).dot(to_ball);
204 if forward_approach_speed < MUSTY_MIN_FORWARD_APPROACH_SPEED {
205 return None;
206 }
207 if ball_speed_change < MUSTY_MIN_BALL_SPEED_CHANGE {
208 return None;
209 }
210
211 let angular_velocity = player_rigid_body
212 .angular_velocity
213 .as_ref()
214 .map(vec_to_glam)
215 .unwrap_or(glam::Vec3::ZERO);
216 let local_angular_velocity = player_rotation.inverse() * angular_velocity;
217 let pitch_rate = local_angular_velocity.y.abs();
218 let other_spin = local_angular_velocity
219 .x
220 .abs()
221 .max(local_angular_velocity.z.abs());
222 if pitch_rate < MUSTY_MIN_PITCH_RATE
223 || pitch_rate < other_spin * MUSTY_MIN_PITCH_DOMINANCE_RATIO
224 {
225 return None;
226 }
227
228 let timing_score =
229 (1.0 - time_since_dodge / MUSTY_MAX_DODGE_TO_TOUCH_SECONDS).clamp(0.0, 1.0);
230 let rear_score = ((rear_alignment - MUSTY_MIN_REAR_ALIGNMENT) / 0.70).clamp(0.0, 1.0);
231 let top_score = ((top_alignment - MUSTY_MIN_TOP_ALIGNMENT) / 0.70).clamp(0.0, 1.0);
232 let approach_score =
233 ((forward_approach_speed - MUSTY_MIN_FORWARD_APPROACH_SPEED) / 900.0).clamp(0.0, 1.0);
234 let pitch_score = ((pitch_rate - MUSTY_MIN_PITCH_RATE) / 8.0).clamp(0.0, 1.0);
235 let pitch_dominance_ratio = if other_spin <= f32::EPSILON {
236 pitch_rate
237 } else {
238 pitch_rate / other_spin
239 };
240 let pitch_dominance_score =
241 ((pitch_dominance_ratio - MUSTY_MIN_PITCH_DOMINANCE_RATIO) / 2.5).clamp(0.0, 1.0);
242 let impulse_score =
243 ((ball_speed_change - MUSTY_MIN_BALL_SPEED_CHANGE) / 900.0).clamp(0.0, 1.0);
244 let setup_score =
245 ((dodge_start.forward_z - MUSTY_MIN_DODGE_START_FORWARD_Z) / 1.25).clamp(0.0, 1.0);
246
247 let confidence = 0.17 * timing_score
248 + 0.17 * rear_score
249 + 0.14 * top_score
250 + 0.15 * approach_score
251 + 0.12 * pitch_score
252 + 0.08 * pitch_dominance_score
253 + 0.10 * impulse_score
254 + 0.07 * setup_score;
255 if confidence < MUSTY_MIN_CONFIDENCE {
256 return None;
257 }
258
259 Some(MustyFlickEvent {
260 time: touch_event.time,
261 frame: touch_event.frame,
262 player: player.player_id.clone(),
263 is_team_0: player.is_team_0,
264 dodge_time: dodge_start.time,
265 dodge_frame: dodge_start.frame,
266 time_since_dodge,
267 confidence,
268 local_ball_position: local_ball_position.to_array(),
269 rear_alignment,
270 top_alignment,
271 forward_approach_speed,
272 pitch_rate,
273 ball_speed_change,
274 })
275 }
276
277 fn apply_touch_events(
278 &mut self,
279 frame: &FrameInfo,
280 ball: &BallFrameState,
281 players: &PlayerFrameState,
282 touch_events: &[TouchEvent],
283 ) {
284 let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
285
286 for touch_event in touch_events {
287 let Some(player_id) = touch_event.player.as_ref() else {
288 continue;
289 };
290 let Some(player) = players
291 .players
292 .iter()
293 .find(|player| &player.player_id == player_id)
294 else {
295 continue;
296 };
297 let Some(dodge_start) = self.recent_dodge_starts.get(player_id).copied() else {
298 continue;
299 };
300 let Some(event) =
301 self.musty_candidate(ball, player, touch_event, dodge_start, ball_speed_change)
302 else {
303 continue;
304 };
305
306 let stats = self.player_stats.entry(player_id.clone()).or_default();
307 stats.count += 1;
308 if player
309 .position()
310 .is_some_and(|position| position.z >= MUSTY_AERIAL_HEIGHT)
311 {
312 stats.aerial_count += 1;
313 }
314 if event.confidence >= MUSTY_HIGH_CONFIDENCE {
315 stats.high_confidence_count += 1;
316 }
317 stats.is_last_musty = true;
318 stats.last_musty_time = Some(event.time);
319 stats.last_musty_frame = Some(event.frame);
320 stats.time_since_last_musty = Some((frame.time - event.time).max(0.0));
321 stats.frames_since_last_musty = Some(frame.frame_number.saturating_sub(event.frame));
322 stats.last_confidence = Some(event.confidence);
323 stats.best_confidence = stats.best_confidence.max(event.confidence);
324 stats.cumulative_confidence += event.confidence;
325
326 self.current_last_musty_player = Some(player_id.clone());
327 self.events.push(event);
328 }
329
330 if let Some(player_id) = self.current_last_musty_player.as_ref() {
331 if let Some(stats) = self.player_stats.get_mut(player_id) {
332 stats.is_last_musty = true;
333 }
334 }
335 }
336
337 fn reset_live_play_state(&mut self, ball: &BallFrameState) {
338 self.current_last_musty_player = None;
339 self.recent_dodge_starts.clear();
340 self.previous_dodge_active.clear();
341 self.previous_ball_velocity = ball.velocity();
342 }
343
344 pub fn update_parts(
345 &mut self,
346 frame: &FrameInfo,
347 ball: &BallFrameState,
348 players: &PlayerFrameState,
349 touch_events: &[TouchEvent],
350 live_play: bool,
351 ) -> SubtrActorResult<()> {
352 if !live_play {
353 self.reset_live_play_state(ball);
354 return Ok(());
355 }
356
357 self.begin_sample(frame);
358 self.prune_recent_dodge_starts(frame.time);
359 self.track_dodge_starts(frame, players);
360 self.apply_touch_events(frame, ball, players, touch_events);
361 self.previous_ball_velocity = ball.velocity();
362 Ok(())
363 }
364}