Skip to main content

subtr_actor/stats/calculators/
half_flip.rs

1use 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;