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