subtr_actor/stats/calculators/
half_flip.rs1use super::*;
2
3const HALF_FLIP_EVALUATION_SECONDS: f32 = 0.65;
4const HALF_FLIP_MAX_CANDIDATE_SECONDS: f32 = 1.0;
5const HALF_FLIP_MAX_START_Z: f32 = PLAYER_GROUND_Z_THRESHOLD + 45.0;
6const HALF_FLIP_MIN_START_SPEED: f32 = 250.0;
7const HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT: f32 = 0.55;
8const HALF_FLIP_MIN_REORIENTATION_ALIGNMENT: f32 = 0.60;
9const HALF_FLIP_MIN_FORWARD_REVERSAL: f32 = 0.55;
10const HALF_FLIP_MIN_FORWARD_VERTICAL: f32 = 0.22;
11const HALF_FLIP_MIN_CONFIDENCE: f32 = 0.55;
12const HALF_FLIP_HIGH_CONFIDENCE: f32 = 0.78;
13
14#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
15#[ts(export)]
16pub struct HalfFlipEvent {
17 pub time: f32,
18 pub frame: usize,
19 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
20 pub player: PlayerId,
21 pub is_team_0: bool,
22 pub start_position: [f32; 3],
23 pub end_position: [f32; 3],
24 pub start_speed: f32,
25 pub end_speed: f32,
26 pub start_backward_alignment: f32,
27 pub best_reorientation_alignment: f32,
28 pub best_forward_reversal: f32,
29 pub max_forward_vertical: f32,
30 pub confidence: f32,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
34#[ts(export)]
35pub struct HalfFlipStats {
36 pub count: u32,
37 pub high_confidence_count: u32,
38 pub is_last_half_flip: bool,
39 pub last_half_flip_time: Option<f32>,
40 pub last_half_flip_frame: Option<usize>,
41 pub time_since_last_half_flip: Option<f32>,
42 pub frames_since_last_half_flip: Option<usize>,
43 pub last_quality: Option<f32>,
44 pub best_quality: f32,
45 pub cumulative_quality: f32,
46 #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
47 pub labeled_event_counts: LabeledCounts,
48}
49
50impl HalfFlipStats {
51 pub fn average_quality(&self) -> f32 {
52 if self.count == 0 {
53 0.0
54 } else {
55 self.cumulative_quality / self.count as f32
56 }
57 }
58
59 fn record_event(&mut self, event: &HalfFlipEvent) {
60 self.labeled_event_counts.increment([confidence_band_label(
61 event.confidence >= HALF_FLIP_HIGH_CONFIDENCE,
62 )]);
63 self.sync_legacy_counts();
64 self.last_half_flip_time = Some(event.time);
65 self.last_half_flip_frame = Some(event.frame);
66 self.last_quality = Some(event.confidence);
67 self.best_quality = self.best_quality.max(event.confidence);
68 self.cumulative_quality += event.confidence;
69 }
70
71 pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
72 self.labeled_event_counts.count_matching(labels)
73 }
74
75 pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
76 LabeledCounts::complete_from_label_sets(
77 &[&CONFIDENCE_BAND_LABELS],
78 &self.labeled_event_counts,
79 )
80 }
81
82 fn sync_legacy_counts(&mut self) {
83 self.count = self.labeled_event_counts.total();
84 self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
85 }
86}
87
88#[derive(Debug, Clone, PartialEq)]
89struct ActiveHalfFlipCandidate {
90 is_team_0: bool,
91 start_time: f32,
92 start_frame: usize,
93 latest_time: f32,
94 latest_frame: usize,
95 start_position: [f32; 3],
96 end_position: [f32; 3],
97 start_speed: f32,
98 end_speed: f32,
99 start_forward_xy: glam::Vec2,
100 start_backward_alignment: f32,
101 best_reorientation_alignment: f32,
102 best_forward_reversal: f32,
103 max_forward_vertical: f32,
104}
105
106#[derive(Debug, Clone, Default, PartialEq)]
107pub struct HalfFlipCalculator {
108 player_stats: HashMap<PlayerId, HalfFlipStats>,
109 events: Vec<HalfFlipEvent>,
110 active_candidates: HashMap<PlayerId, ActiveHalfFlipCandidate>,
111 previous_dodge_active: HashMap<PlayerId, bool>,
112 current_last_half_flip_player: Option<PlayerId>,
113}
114
115impl HalfFlipCalculator {
116 pub fn new() -> Self {
117 Self::default()
118 }
119
120 pub fn player_stats(&self) -> &HashMap<PlayerId, HalfFlipStats> {
121 &self.player_stats
122 }
123
124 pub fn events(&self) -> &[HalfFlipEvent] {
125 &self.events
126 }
127
128 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
129 if max_value <= min_value {
130 return 0.0;
131 }
132
133 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
134 }
135
136 fn horizontal_velocity(player: &PlayerSample) -> Option<glam::Vec2> {
137 let velocity = player.velocity()?.truncate();
138 if velocity.length_squared() <= f32::EPSILON {
139 return None;
140 }
141 Some(velocity)
142 }
143
144 fn forward_vector(player: &PlayerSample) -> Option<glam::Vec3> {
145 let rigid_body = player.rigid_body.as_ref()?;
146 Some(quat_to_glam(&rigid_body.rotation) * glam::Vec3::X)
147 }
148
149 fn forward_xy(player: &PlayerSample) -> Option<glam::Vec2> {
150 let forward_xy = Self::forward_vector(player)?.truncate().normalize_or_zero();
151 if forward_xy.length_squared() <= f32::EPSILON {
152 return None;
153 }
154 Some(forward_xy)
155 }
156
157 fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
158 let was_dodge_active = self
159 .previous_dodge_active
160 .insert(player.player_id.clone(), player.dodge_active)
161 .unwrap_or(false);
162 if !player.dodge_active || was_dodge_active {
163 return;
164 }
165
166 let Some(position) = player.position() else {
167 return;
168 };
169 if position.z > HALF_FLIP_MAX_START_Z {
170 return;
171 }
172
173 let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
174 let start_speed = velocity_xy.length();
175 if start_speed < HALF_FLIP_MIN_START_SPEED {
176 return;
177 }
178
179 let Some(start_forward_xy) = Self::forward_xy(player) else {
180 return;
181 };
182 let velocity_direction = velocity_xy.normalize_or_zero();
183 let start_backward_alignment = -start_forward_xy.dot(velocity_direction);
184 if start_backward_alignment < HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT {
185 return;
186 }
187
188 let max_forward_vertical =
189 Self::forward_vector(player).map_or(0.0, |forward| forward.z.abs());
190
191 self.active_candidates.insert(
192 player.player_id.clone(),
193 ActiveHalfFlipCandidate {
194 is_team_0: player.is_team_0,
195 start_time: frame.time,
196 start_frame: frame.frame_number,
197 latest_time: frame.time,
198 latest_frame: frame.frame_number,
199 start_position: position.to_array(),
200 end_position: position.to_array(),
201 start_speed,
202 end_speed: start_speed,
203 start_forward_xy,
204 start_backward_alignment,
205 best_reorientation_alignment: 0.0,
206 best_forward_reversal: 0.0,
207 max_forward_vertical,
208 },
209 );
210 }
211
212 fn update_candidate(
213 candidate: &mut ActiveHalfFlipCandidate,
214 frame: &FrameInfo,
215 player: &PlayerSample,
216 ) {
217 if let Some(position) = player.position() {
218 candidate.end_position = position.to_array();
219 }
220
221 let velocity_xy = Self::horizontal_velocity(player).unwrap_or(glam::Vec2::ZERO);
222 candidate.end_speed = velocity_xy.length();
223 let velocity_direction = velocity_xy.normalize_or_zero();
224
225 if let Some(forward) = Self::forward_vector(player) {
226 candidate.max_forward_vertical = candidate.max_forward_vertical.max(forward.z.abs());
227 let forward_xy = forward.truncate().normalize_or_zero();
228 if forward_xy.length_squared() > f32::EPSILON {
229 candidate.best_forward_reversal = candidate
230 .best_forward_reversal
231 .max((-candidate.start_forward_xy.dot(forward_xy)).clamp(-1.0, 1.0));
232 if velocity_direction.length_squared() > f32::EPSILON {
233 candidate.best_reorientation_alignment = candidate
234 .best_reorientation_alignment
235 .max(forward_xy.dot(velocity_direction));
236 }
237 }
238 }
239
240 candidate.latest_time = frame.time;
241 candidate.latest_frame = frame.frame_number;
242 }
243
244 fn candidate_event(
245 player_id: &PlayerId,
246 candidate: ActiveHalfFlipCandidate,
247 ) -> Option<HalfFlipEvent> {
248 if candidate.best_reorientation_alignment < HALF_FLIP_MIN_REORIENTATION_ALIGNMENT
249 || candidate.best_forward_reversal < HALF_FLIP_MIN_FORWARD_REVERSAL
250 || candidate.max_forward_vertical < HALF_FLIP_MIN_FORWARD_VERTICAL
251 {
252 return None;
253 }
254
255 let backward_score = Self::normalize_score(
256 candidate.start_backward_alignment,
257 HALF_FLIP_MIN_START_BACKWARD_ALIGNMENT,
258 0.95,
259 );
260 let reorientation_score = Self::normalize_score(
261 candidate.best_reorientation_alignment,
262 HALF_FLIP_MIN_REORIENTATION_ALIGNMENT,
263 0.98,
264 );
265 let reversal_score = Self::normalize_score(
266 candidate.best_forward_reversal,
267 HALF_FLIP_MIN_FORWARD_REVERSAL,
268 0.98,
269 );
270 let flip_score = Self::normalize_score(
271 candidate.max_forward_vertical,
272 HALF_FLIP_MIN_FORWARD_VERTICAL,
273 0.85,
274 );
275 let speed_score = Self::normalize_score(candidate.end_speed, 900.0, 1800.0).max(
276 Self::normalize_score(candidate.end_speed - candidate.start_speed, 100.0, 700.0) * 0.7,
277 );
278 let confidence = 0.25 * backward_score
279 + 0.30 * reorientation_score
280 + 0.25 * reversal_score
281 + 0.10 * flip_score
282 + 0.10 * speed_score;
283
284 if confidence < HALF_FLIP_MIN_CONFIDENCE {
285 return None;
286 }
287
288 Some(HalfFlipEvent {
289 time: candidate.latest_time,
290 frame: candidate.latest_frame,
291 player: player_id.clone(),
292 is_team_0: candidate.is_team_0,
293 start_position: candidate.start_position,
294 end_position: candidate.end_position,
295 start_speed: candidate.start_speed,
296 end_speed: candidate.end_speed,
297 start_backward_alignment: candidate.start_backward_alignment,
298 best_reorientation_alignment: candidate.best_reorientation_alignment,
299 best_forward_reversal: candidate.best_forward_reversal,
300 max_forward_vertical: candidate.max_forward_vertical,
301 confidence,
302 })
303 }
304
305 fn apply_event(&mut self, event: HalfFlipEvent) {
306 for stats in self.player_stats.values_mut() {
307 stats.is_last_half_flip = false;
308 }
309
310 let stats = self.player_stats.entry(event.player.clone()).or_default();
311 stats.record_event(&event);
312 stats.is_last_half_flip = true;
313 stats.time_since_last_half_flip = Some(0.0);
314 stats.frames_since_last_half_flip = Some(0);
315
316 self.current_last_half_flip_player = Some(event.player.clone());
317 self.events.push(event);
318 }
319
320 fn begin_sample(&mut self, frame: &FrameInfo) {
321 for stats in self.player_stats.values_mut() {
322 stats.is_last_half_flip = false;
323 stats.time_since_last_half_flip = stats
324 .last_half_flip_time
325 .map(|time| (frame.time - time).max(0.0));
326 stats.frames_since_last_half_flip = stats
327 .last_half_flip_frame
328 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
329 }
330
331 if let Some(player_id) = self.current_last_half_flip_player.as_ref() {
332 if let Some(stats) = self.player_stats.get_mut(player_id) {
333 stats.is_last_half_flip = true;
334 }
335 }
336 }
337
338 fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
339 let mut finished_candidates = Vec::new();
340
341 for (player_id, candidate) in &self.active_candidates {
342 let duration = frame.time - candidate.start_time;
343 if force_all || duration >= HALF_FLIP_EVALUATION_SECONDS {
344 finished_candidates.push((
345 candidate.start_time,
346 candidate.start_frame,
347 format!("{player_id:?}"),
348 player_id.clone(),
349 ));
350 }
351 }
352
353 finished_candidates.sort_by(|left, right| {
354 left.0
355 .total_cmp(&right.0)
356 .then_with(|| left.1.cmp(&right.1))
357 .then_with(|| left.2.cmp(&right.2))
358 });
359
360 for (_, _, _, player_id) in finished_candidates {
361 let Some(candidate) = self.active_candidates.remove(&player_id) else {
362 continue;
363 };
364 if let Some(event) = Self::candidate_event(&player_id, candidate) {
365 self.apply_event(event);
366 }
367 }
368 }
369
370 pub fn update(
371 &mut self,
372 frame: &FrameInfo,
373 players: &PlayerFrameState,
374 live_play: bool,
375 ) -> SubtrActorResult<()> {
376 if !live_play {
377 self.active_candidates.clear();
378 self.current_last_half_flip_player = None;
379 return Ok(());
380 }
381
382 self.begin_sample(frame);
383
384 for player in &players.players {
385 self.maybe_start_candidate(frame, player);
386 }
387
388 let mut visible_players = HashSet::new();
389 for player in &players.players {
390 visible_players.insert(player.player_id.clone());
391 if let Some(candidate) = self.active_candidates.get_mut(&player.player_id) {
392 Self::update_candidate(candidate, frame, player);
393 }
394 }
395
396 self.finalize_candidates(frame, false);
397 self.active_candidates.retain(|player_id, candidate| {
398 visible_players.contains(player_id)
399 && frame.time - candidate.start_time <= HALF_FLIP_MAX_CANDIDATE_SECONDS
400 });
401
402 Ok(())
403 }
404
405 pub fn finalize(&mut self, frame: &FrameInfo) {
406 self.finalize_candidates(frame, true);
407 }
408}
409
410#[cfg(test)]
411#[path = "half_flip_tests.rs"]
412mod tests;