Skip to main content

subtr_actor/stats/calculators/
bump.rs

1use super::*;
2
3const BUMP_MAX_SAMPLE_DT: f32 = 0.18;
4const BUMP_MAX_CONTACT_DISTANCE: f32 = 230.0;
5const BUMP_MAX_VERTICAL_GAP: f32 = 190.0;
6const BUMP_MIN_CLOSING_SPEED: f32 = 420.0;
7const BUMP_MIN_VICTIM_IMPULSE: f32 = 180.0;
8const BUMP_MIN_DIRECTIONAL_SCORE: f32 = 650.0;
9const BUMP_MIN_SCORE_MARGIN: f32 = 175.0;
10const BUMP_REPEAT_FRAME_WINDOW: usize = 10;
11const BUMP_FIFTY_FIFTY_SUPPRESSION_WINDOW_SECONDS: f32 = 0.35;
12
13#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
14#[ts(export)]
15pub struct BumpEvent {
16    pub time: f32,
17    pub frame: usize,
18    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
19    pub initiator: PlayerId,
20    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
21    pub victim: PlayerId,
22    pub initiator_is_team_0: bool,
23    pub victim_is_team_0: bool,
24    pub is_team_bump: bool,
25    pub strength: f32,
26    pub confidence: f32,
27    pub contact_distance: f32,
28    pub closing_speed: f32,
29    pub victim_impulse: f32,
30    pub initiator_position: [f32; 3],
31    pub victim_position: [f32; 3],
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
35#[ts(export)]
36pub struct BumpPlayerStats {
37    pub bumps_inflicted: u32,
38    pub bumps_taken: u32,
39    pub team_bumps_inflicted: u32,
40    pub team_bumps_taken: u32,
41    pub last_bump_time: Option<f32>,
42    pub last_bump_frame: Option<usize>,
43    pub last_bump_strength: Option<f32>,
44    pub max_bump_strength: f32,
45    pub cumulative_bump_strength: f32,
46}
47
48impl BumpPlayerStats {
49    pub fn average_bump_strength(&self) -> f32 {
50        if self.bumps_inflicted == 0 {
51            0.0
52        } else {
53            self.cumulative_bump_strength / self.bumps_inflicted as f32
54        }
55    }
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
59#[ts(export)]
60pub struct BumpTeamStats {
61    pub bumps_inflicted: u32,
62    pub team_bumps_inflicted: u32,
63}
64
65#[derive(Debug, Clone)]
66struct PreviousPlayerSample {
67    rigid_body: boxcars::RigidBody,
68}
69
70#[derive(Debug, Clone, Copy)]
71struct DirectionalBumpCandidate {
72    score: f32,
73    closing_speed: f32,
74    victim_impulse: f32,
75}
76
77#[derive(Debug, Clone, Default)]
78pub struct BumpCalculator {
79    player_stats: HashMap<PlayerId, BumpPlayerStats>,
80    player_teams: HashMap<PlayerId, bool>,
81    team_zero_stats: BumpTeamStats,
82    team_one_stats: BumpTeamStats,
83    events: Vec<BumpEvent>,
84    previous_players: HashMap<PlayerId, PreviousPlayerSample>,
85    last_seen_pair_frame: HashMap<(PlayerId, PlayerId), usize>,
86}
87
88impl BumpCalculator {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    pub fn player_stats(&self) -> &HashMap<PlayerId, BumpPlayerStats> {
94        &self.player_stats
95    }
96
97    pub fn team_zero_stats(&self) -> &BumpTeamStats {
98        &self.team_zero_stats
99    }
100
101    pub fn team_one_stats(&self) -> &BumpTeamStats {
102        &self.team_one_stats
103    }
104
105    pub fn events(&self) -> &[BumpEvent] {
106        &self.events
107    }
108
109    pub fn update(
110        &mut self,
111        frame: &FrameInfo,
112        players: &PlayerFrameState,
113        events: &FrameEventsState,
114        live_play: bool,
115    ) -> SubtrActorResult<()> {
116        self.update_with_fifty_fifty_state(
117            frame,
118            players,
119            events,
120            &FiftyFiftyState::default(),
121            live_play,
122        )
123    }
124
125    pub fn update_with_fifty_fifty_state(
126        &mut self,
127        frame: &FrameInfo,
128        players: &PlayerFrameState,
129        events: &FrameEventsState,
130        fifty_fifty_state: &FiftyFiftyState,
131        live_play: bool,
132    ) -> SubtrActorResult<()> {
133        for player in &players.players {
134            self.player_teams
135                .insert(player.player_id.clone(), player.is_team_0);
136        }
137
138        if !live_play {
139            self.previous_players.clear();
140            return Ok(());
141        }
142
143        if frame.dt > 0.0 && frame.dt <= BUMP_MAX_SAMPLE_DT {
144            self.detect_bumps(frame, players, events, fifty_fifty_state);
145        }
146
147        self.previous_players = players
148            .players
149            .iter()
150            .filter_map(|player| {
151                Some((
152                    player.player_id.clone(),
153                    PreviousPlayerSample {
154                        rigid_body: player.rigid_body?,
155                    },
156                ))
157            })
158            .collect();
159
160        Ok(())
161    }
162
163    fn detect_bumps(
164        &mut self,
165        frame: &FrameInfo,
166        players: &PlayerFrameState,
167        frame_events: &FrameEventsState,
168        fifty_fifty_state: &FiftyFiftyState,
169    ) {
170        let current_players: Vec<_> = players
171            .players
172            .iter()
173            .filter_map(|player| {
174                Some((
175                    player,
176                    player.rigid_body.as_ref()?,
177                    self.previous_players.get(&player.player_id)?.rigid_body,
178                ))
179            })
180            .collect();
181
182        for left_index in 0..current_players.len() {
183            for right_index in (left_index + 1)..current_players.len() {
184                let (left, left_body, previous_left_body) = current_players[left_index];
185                let (right, right_body, previous_right_body) = current_players[right_index];
186
187                if self.is_recent_demo_pair(frame_events, &left.player_id, &right.player_id) {
188                    continue;
189                }
190
191                if Self::is_recent_fifty_fifty_pair(
192                    frame,
193                    fifty_fifty_state,
194                    &left.player_id,
195                    &right.player_id,
196                ) {
197                    continue;
198                }
199
200                let Some(event) = Self::evaluate_pair(
201                    frame,
202                    left,
203                    left_body,
204                    &previous_left_body,
205                    right,
206                    right_body,
207                    &previous_right_body,
208                ) else {
209                    continue;
210                };
211
212                if self.should_count_bump(&event.initiator, &event.victim, frame.frame_number) {
213                    self.record_bump(event);
214                }
215            }
216        }
217    }
218
219    fn evaluate_pair(
220        frame: &FrameInfo,
221        left: &PlayerSample,
222        left_body: &boxcars::RigidBody,
223        previous_left_body: &boxcars::RigidBody,
224        right: &PlayerSample,
225        right_body: &boxcars::RigidBody,
226        previous_right_body: &boxcars::RigidBody,
227    ) -> Option<BumpEvent> {
228        let left_previous_position = vec_to_glam(&previous_left_body.location);
229        let right_previous_position = vec_to_glam(&previous_right_body.location);
230        let left_position = vec_to_glam(&left_body.location);
231        let right_position = vec_to_glam(&right_body.location);
232
233        let contact_distance = swept_horizontal_distance(
234            left_previous_position,
235            left_position,
236            right_previous_position,
237            right_position,
238        );
239        if contact_distance > BUMP_MAX_CONTACT_DISTANCE {
240            return None;
241        }
242
243        let vertical_gap = (left_position.z - right_position.z)
244            .abs()
245            .min((left_previous_position.z - right_previous_position.z).abs());
246        if vertical_gap > BUMP_MAX_VERTICAL_GAP {
247            return None;
248        }
249
250        let normal_left_to_right = contact_normal(
251            left_previous_position,
252            left_position,
253            right_previous_position,
254            right_position,
255        )?;
256        let left_to_right = directional_candidate(
257            previous_left_body,
258            left_body,
259            previous_right_body,
260            right_body,
261            normal_left_to_right,
262        )?;
263        let right_to_left = directional_candidate(
264            previous_right_body,
265            right_body,
266            previous_left_body,
267            left_body,
268            -normal_left_to_right,
269        )?;
270
271        let (initiator, victim, initiator_body, victim_body, candidate, reverse_score) =
272            if left_to_right.score >= right_to_left.score {
273                (
274                    left,
275                    right,
276                    left_body,
277                    right_body,
278                    left_to_right,
279                    right_to_left.score,
280                )
281            } else {
282                (
283                    right,
284                    left,
285                    right_body,
286                    left_body,
287                    right_to_left,
288                    left_to_right.score,
289                )
290            };
291
292        if candidate.score < BUMP_MIN_DIRECTIONAL_SCORE
293            || candidate.score - reverse_score < BUMP_MIN_SCORE_MARGIN
294            || candidate.closing_speed < BUMP_MIN_CLOSING_SPEED
295            || candidate.victim_impulse < BUMP_MIN_VICTIM_IMPULSE
296        {
297            return None;
298        }
299
300        let distance_factor =
301            (1.0 - (contact_distance / BUMP_MAX_CONTACT_DISTANCE)).clamp(0.0, 1.0);
302        let score_factor = ((candidate.score - BUMP_MIN_DIRECTIONAL_SCORE) / 900.0).clamp(0.0, 1.0);
303        let margin_factor =
304            ((candidate.score - reverse_score - BUMP_MIN_SCORE_MARGIN) / 500.0).clamp(0.0, 1.0);
305        let confidence = (0.35 + 0.3 * distance_factor + 0.25 * score_factor + 0.1 * margin_factor)
306            .clamp(0.0, 1.0);
307
308        Some(BumpEvent {
309            time: frame.time,
310            frame: frame.frame_number,
311            initiator: initiator.player_id.clone(),
312            victim: victim.player_id.clone(),
313            initiator_is_team_0: initiator.is_team_0,
314            victim_is_team_0: victim.is_team_0,
315            is_team_bump: initiator.is_team_0 == victim.is_team_0,
316            strength: candidate.score,
317            confidence,
318            contact_distance,
319            closing_speed: candidate.closing_speed,
320            victim_impulse: candidate.victim_impulse,
321            initiator_position: vec3_to_array(vec_to_glam(&initiator_body.location)),
322            victim_position: vec3_to_array(vec_to_glam(&victim_body.location)),
323        })
324    }
325
326    fn is_recent_demo_pair(
327        &self,
328        frame_events: &FrameEventsState,
329        left: &PlayerId,
330        right: &PlayerId,
331    ) -> bool {
332        frame_events.demo_events.iter().any(|demo| {
333            (&demo.attacker == left && &demo.victim == right)
334                || (&demo.attacker == right && &demo.victim == left)
335        }) || frame_events.active_demos.iter().any(|demo| {
336            (&demo.attacker == left && &demo.victim == right)
337                || (&demo.attacker == right && &demo.victim == left)
338        })
339    }
340
341    fn is_recent_fifty_fifty_pair(
342        frame: &FrameInfo,
343        fifty_fifty_state: &FiftyFiftyState,
344        left: &PlayerId,
345        right: &PlayerId,
346    ) -> bool {
347        if fifty_fifty_state
348            .active_event
349            .as_ref()
350            .is_some_and(|event| Self::active_fifty_fifty_matches_pair(event, left, right))
351        {
352            return true;
353        }
354
355        fifty_fifty_state
356            .resolved_events
357            .iter()
358            .any(|event| Self::resolved_fifty_fifty_matches_pair(event, left, right))
359            || fifty_fifty_state
360                .last_resolved_event
361                .as_ref()
362                .is_some_and(|event| {
363                    frame.time - event.resolve_time <= BUMP_FIFTY_FIFTY_SUPPRESSION_WINDOW_SECONDS
364                        && Self::resolved_fifty_fifty_matches_pair(event, left, right)
365                })
366    }
367
368    fn active_fifty_fifty_matches_pair(
369        event: &ActiveFiftyFifty,
370        left: &PlayerId,
371        right: &PlayerId,
372    ) -> bool {
373        Self::optional_player_pair_matches(
374            event.team_zero_player.as_ref(),
375            event.team_one_player.as_ref(),
376            left,
377            right,
378        )
379    }
380
381    fn resolved_fifty_fifty_matches_pair(
382        event: &FiftyFiftyEvent,
383        left: &PlayerId,
384        right: &PlayerId,
385    ) -> bool {
386        Self::optional_player_pair_matches(
387            event.team_zero_player.as_ref(),
388            event.team_one_player.as_ref(),
389            left,
390            right,
391        )
392    }
393
394    fn optional_player_pair_matches(
395        team_zero_player: Option<&PlayerId>,
396        team_one_player: Option<&PlayerId>,
397        left: &PlayerId,
398        right: &PlayerId,
399    ) -> bool {
400        matches!(
401            (team_zero_player, team_one_player),
402            (Some(team_zero_player), Some(team_one_player))
403                if (team_zero_player == left && team_one_player == right)
404                    || (team_zero_player == right && team_one_player == left)
405        )
406    }
407
408    fn should_count_bump(
409        &mut self,
410        initiator: &PlayerId,
411        victim: &PlayerId,
412        frame_number: usize,
413    ) -> bool {
414        let key = (initiator.clone(), victim.clone());
415        let already_counted = self
416            .last_seen_pair_frame
417            .get(&key)
418            .map(|previous_frame| {
419                frame_number.saturating_sub(*previous_frame) <= BUMP_REPEAT_FRAME_WINDOW
420            })
421            .unwrap_or(false);
422        self.last_seen_pair_frame.insert(key, frame_number);
423        !already_counted
424    }
425
426    fn record_bump(&mut self, event: BumpEvent) {
427        let initiator_stats = self
428            .player_stats
429            .entry(event.initiator.clone())
430            .or_default();
431        initiator_stats.bumps_inflicted += 1;
432        if event.is_team_bump {
433            initiator_stats.team_bumps_inflicted += 1;
434        }
435        initiator_stats.last_bump_time = Some(event.time);
436        initiator_stats.last_bump_frame = Some(event.frame);
437        initiator_stats.last_bump_strength = Some(event.strength);
438        initiator_stats.max_bump_strength = initiator_stats.max_bump_strength.max(event.strength);
439        initiator_stats.cumulative_bump_strength += event.strength;
440
441        let victim_stats = self.player_stats.entry(event.victim.clone()).or_default();
442        victim_stats.bumps_taken += 1;
443        if event.is_team_bump {
444            victim_stats.team_bumps_taken += 1;
445        }
446
447        match event.initiator_is_team_0 {
448            true => {
449                self.team_zero_stats.bumps_inflicted += 1;
450                if event.is_team_bump {
451                    self.team_zero_stats.team_bumps_inflicted += 1;
452                }
453            }
454            false => {
455                self.team_one_stats.bumps_inflicted += 1;
456                if event.is_team_bump {
457                    self.team_one_stats.team_bumps_inflicted += 1;
458                }
459            }
460        }
461
462        self.events.push(event);
463    }
464}
465
466fn vec3_to_array(v: glam::Vec3) -> [f32; 3] {
467    [v.x, v.y, v.z]
468}
469
470fn horizontal(v: glam::Vec3) -> glam::Vec2 {
471    glam::Vec2::new(v.x, v.y)
472}
473
474fn swept_horizontal_distance(
475    left_previous: glam::Vec3,
476    left_current: glam::Vec3,
477    right_previous: glam::Vec3,
478    right_current: glam::Vec3,
479) -> f32 {
480    let relative_start = horizontal(left_previous - right_previous);
481    let relative_delta =
482        horizontal((left_current - left_previous) - (right_current - right_previous));
483    let closest_t = if relative_delta.length_squared() > f32::EPSILON {
484        (-relative_start.dot(relative_delta) / relative_delta.length_squared()).clamp(0.0, 1.0)
485    } else {
486        0.0
487    };
488    (relative_start + relative_delta * closest_t).length()
489}
490
491fn contact_normal(
492    left_previous: glam::Vec3,
493    left_current: glam::Vec3,
494    right_previous: glam::Vec3,
495    right_current: glam::Vec3,
496) -> Option<glam::Vec3> {
497    let relative_current = right_current - left_current;
498    let current_horizontal = glam::Vec3::new(relative_current.x, relative_current.y, 0.0);
499    if current_horizontal.length_squared() > 1.0 {
500        return Some(current_horizontal.normalize());
501    }
502
503    let relative_previous = right_previous - left_previous;
504    let previous_horizontal = glam::Vec3::new(relative_previous.x, relative_previous.y, 0.0);
505    (previous_horizontal.length_squared() > 1.0).then(|| previous_horizontal.normalize())
506}
507
508fn directional_candidate(
509    initiator_previous: &boxcars::RigidBody,
510    initiator_current: &boxcars::RigidBody,
511    victim_previous: &boxcars::RigidBody,
512    victim_current: &boxcars::RigidBody,
513    normal: glam::Vec3,
514) -> Option<DirectionalBumpCandidate> {
515    let initiator_previous_velocity = rigid_body_velocity(initiator_previous);
516    let initiator_current_velocity = rigid_body_velocity(initiator_current);
517    let victim_previous_velocity = rigid_body_velocity(victim_previous);
518    let victim_current_velocity = rigid_body_velocity(victim_current);
519
520    let closing_speed = (initiator_previous_velocity - victim_previous_velocity).dot(normal);
521    let victim_impulse = (victim_current_velocity - victim_previous_velocity).dot(normal);
522    let initiator_slowdown = (initiator_previous_velocity - initiator_current_velocity).dot(normal);
523    let speed_advantage =
524        initiator_previous_velocity.dot(normal) - victim_previous_velocity.dot(normal);
525    let forward_alignment = (quat_to_glam(&initiator_previous.rotation) * glam::Vec3::X)
526        .dot(normal)
527        .max(0.0);
528
529    if !closing_speed.is_finite() || !victim_impulse.is_finite() {
530        return None;
531    }
532
533    let score = closing_speed
534        + 1.35 * victim_impulse.max(0.0)
535        + 0.35 * initiator_slowdown.max(0.0)
536        + 220.0 * forward_alignment
537        + 0.15 * speed_advantage.max(0.0);
538
539    Some(DirectionalBumpCandidate {
540        score,
541        closing_speed,
542        victim_impulse,
543    })
544}
545
546fn rigid_body_velocity(rigid_body: &boxcars::RigidBody) -> glam::Vec3 {
547    rigid_body
548        .linear_velocity
549        .as_ref()
550        .map(vec_to_glam)
551        .unwrap_or(glam::Vec3::ZERO)
552}
553
554#[cfg(test)]
555#[path = "bump_tests.rs"]
556mod tests;