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;