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