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;