1use std::any::Any;
2use std::collections::{HashMap, HashSet};
3
4use super::normalized_y;
5use crate::*;
6
7pub type DerivedSignalId = &'static str;
8
9pub const TOUCH_STATE_SIGNAL_ID: DerivedSignalId = "touch_state";
10pub const POSSESSION_STATE_SIGNAL_ID: DerivedSignalId = "possession_state";
11pub const BACKBOARD_BOUNCE_STATE_SIGNAL_ID: DerivedSignalId = "backboard_bounce_state";
12
13#[derive(Debug, Clone, Default)]
14pub struct TouchState {
15 pub touch_events: Vec<TouchEvent>,
16 pub last_touch: Option<TouchEvent>,
17 pub last_touch_player: Option<PlayerId>,
18 pub last_touch_team_is_team_0: Option<bool>,
19}
20
21#[derive(Debug, Clone, Default)]
22pub struct PossessionState {
23 pub active_team_before_sample: Option<bool>,
24 pub current_team_is_team_0: Option<bool>,
25 pub active_player_before_sample: Option<PlayerId>,
26 pub current_player: Option<PlayerId>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct BackboardBounceEvent {
31 pub time: f32,
32 pub frame: usize,
33 pub player: PlayerId,
34 pub is_team_0: bool,
35}
36
37#[derive(Debug, Clone, Default, PartialEq)]
38pub struct BackboardBounceState {
39 pub bounce_events: Vec<BackboardBounceEvent>,
40 pub last_bounce_event: Option<BackboardBounceEvent>,
41}
42
43#[derive(Default)]
44pub struct AnalysisContext {
45 values: HashMap<DerivedSignalId, Box<dyn Any>>,
46}
47
48impl AnalysisContext {
49 pub fn get<T: 'static>(&self, id: DerivedSignalId) -> Option<&T> {
50 self.values.get(id)?.downcast_ref::<T>()
51 }
52
53 fn insert_box(&mut self, id: DerivedSignalId, value: Box<dyn Any>) {
54 self.values.insert(id, value);
55 }
56
57 fn clear(&mut self) {
58 self.values.clear();
59 }
60}
61
62pub trait DerivedSignal {
63 fn id(&self) -> DerivedSignalId;
64
65 fn dependencies(&self) -> &'static [DerivedSignalId] {
66 &[]
67 }
68
69 fn on_replay_meta(&mut self, _meta: &ReplayMeta) -> SubtrActorResult<()> {
70 Ok(())
71 }
72
73 fn evaluate(
74 &mut self,
75 sample: &StatsSample,
76 _ctx: &AnalysisContext,
77 ) -> SubtrActorResult<Option<Box<dyn Any>>>;
78
79 fn finish(&mut self) -> SubtrActorResult<()> {
80 Ok(())
81 }
82}
83
84#[derive(Default)]
85pub struct DerivedSignalGraph {
86 nodes: Vec<Box<dyn DerivedSignal>>,
87 evaluation_order: Vec<usize>,
88 context: AnalysisContext,
89 order_dirty: bool,
90}
91
92impl DerivedSignalGraph {
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn with_signal<S: DerivedSignal + 'static>(mut self, signal: S) -> Self {
98 self.push(signal);
99 self
100 }
101
102 pub fn push<S: DerivedSignal + 'static>(&mut self, signal: S) {
103 self.nodes.push(Box::new(signal));
104 self.order_dirty = true;
105 }
106
107 pub fn on_replay_meta(&mut self, meta: &ReplayMeta) -> SubtrActorResult<()> {
108 self.rebuild_order_if_needed()?;
109 for node in &mut self.nodes {
110 node.on_replay_meta(meta)?;
111 }
112 Ok(())
113 }
114
115 pub fn evaluate(&mut self, sample: &StatsSample) -> SubtrActorResult<&AnalysisContext> {
116 self.rebuild_order_if_needed()?;
117 self.context.clear();
118
119 for node_index in &self.evaluation_order {
120 let node = &mut self.nodes[*node_index];
121 if let Some(value) = node.evaluate(sample, &self.context)? {
122 self.context.insert_box(node.id(), value);
123 }
124 }
125
126 Ok(&self.context)
127 }
128
129 pub fn finish(&mut self) -> SubtrActorResult<()> {
130 for node in &mut self.nodes {
131 node.finish()?;
132 }
133 Ok(())
134 }
135
136 fn rebuild_order_if_needed(&mut self) -> SubtrActorResult<()> {
137 if !self.order_dirty {
138 return Ok(());
139 }
140
141 let id_to_index: HashMap<_, _> = self
142 .nodes
143 .iter()
144 .enumerate()
145 .map(|(index, node)| (node.id(), index))
146 .collect();
147 let mut visiting = HashSet::new();
148 let mut visited = HashSet::new();
149 let mut order = Vec::with_capacity(self.nodes.len());
150
151 for node in &self.nodes {
152 Self::visit_node(
153 node.id(),
154 &id_to_index,
155 &self.nodes,
156 &mut visiting,
157 &mut visited,
158 &mut order,
159 )?;
160 }
161
162 self.evaluation_order = order.into_iter().map(|id| id_to_index[&id]).collect();
163 self.order_dirty = false;
164 Ok(())
165 }
166
167 fn visit_node(
168 node_id: DerivedSignalId,
169 id_to_index: &HashMap<DerivedSignalId, usize>,
170 nodes: &[Box<dyn DerivedSignal>],
171 visiting: &mut HashSet<DerivedSignalId>,
172 visited: &mut HashSet<DerivedSignalId>,
173 order: &mut Vec<DerivedSignalId>,
174 ) -> SubtrActorResult<()> {
175 if visited.contains(&node_id) {
176 return Ok(());
177 }
178 if !visiting.insert(node_id) {
179 return SubtrActorError::new_result(SubtrActorErrorVariant::DerivedSignalGraphError(
180 format!("Cycle detected in derived signal graph at {node_id}"),
181 ));
182 }
183
184 let node = &nodes[id_to_index[&node_id]];
185 for dependency in node.dependencies() {
186 if !id_to_index.contains_key(dependency) {
187 return SubtrActorError::new_result(
188 SubtrActorErrorVariant::DerivedSignalGraphError(format!(
189 "Missing derived signal dependency {dependency} for {node_id}"
190 )),
191 );
192 }
193 Self::visit_node(dependency, id_to_index, nodes, visiting, visited, order)?;
194 }
195
196 visiting.remove(&node_id);
197 visited.insert(node_id);
198 order.push(node_id);
199 Ok(())
200 }
201}
202
203#[derive(Default)]
204pub struct TouchStateSignal {
205 previous_ball_linear_velocity: Option<glam::Vec3>,
206 previous_ball_angular_velocity: Option<glam::Vec3>,
207 current_last_touch: Option<TouchEvent>,
208 recent_touch_candidates: HashMap<PlayerId, TouchEvent>,
209 live_play_tracker: LivePlayTracker,
210}
211
212impl TouchStateSignal {
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 fn should_emit_candidate(&self, candidate: &TouchEvent) -> bool {
218 const SAME_PLAYER_TOUCH_COOLDOWN_FRAMES: usize = 7;
219
220 let Some(previous_touch) = self.current_last_touch.as_ref() else {
221 return true;
222 };
223
224 let same_player =
225 previous_touch.player.is_some() && previous_touch.player == candidate.player;
226 if !same_player {
227 return true;
228 }
229
230 candidate.frame.saturating_sub(previous_touch.frame) >= SAME_PLAYER_TOUCH_COOLDOWN_FRAMES
231 }
232
233 fn prune_recent_touch_candidates(&mut self, current_frame: usize) {
234 const TOUCH_CANDIDATE_WINDOW_FRAMES: usize = 4;
235
236 self.recent_touch_candidates.retain(|_, candidate| {
237 current_frame.saturating_sub(candidate.frame) <= TOUCH_CANDIDATE_WINDOW_FRAMES
238 });
239 }
240
241 fn current_ball_angular_velocity(sample: &StatsSample) -> Option<glam::Vec3> {
242 sample
243 .ball
244 .as_ref()
245 .map(|ball| {
246 ball.rigid_body
247 .angular_velocity
248 .unwrap_or(boxcars::Vector3f {
249 x: 0.0,
250 y: 0.0,
251 z: 0.0,
252 })
253 })
254 .map(|velocity| vec_to_glam(&velocity))
255 }
256
257 fn current_ball_linear_velocity(sample: &StatsSample) -> Option<glam::Vec3> {
258 sample.ball.as_ref().map(BallSample::velocity)
259 }
260
261 fn is_touch_candidate(&self, sample: &StatsSample) -> bool {
262 const BALL_GRAVITY_Z: f32 = -650.0;
263 const TOUCH_LINEAR_IMPULSE_THRESHOLD: f32 = 120.0;
264 const TOUCH_ANGULAR_VELOCITY_DELTA_THRESHOLD: f32 = 0.5;
265
266 let Some(current_linear_velocity) = Self::current_ball_linear_velocity(sample) else {
267 return false;
268 };
269 let Some(previous_linear_velocity) = self.previous_ball_linear_velocity else {
270 return false;
271 };
272 let Some(current_angular_velocity) = Self::current_ball_angular_velocity(sample) else {
273 return false;
274 };
275 let Some(previous_angular_velocity) = self.previous_ball_angular_velocity else {
276 return false;
277 };
278
279 let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * sample.dt.max(0.0));
280 let residual_linear_impulse =
281 current_linear_velocity - previous_linear_velocity - expected_linear_delta;
282 let angular_velocity_delta = current_angular_velocity - previous_angular_velocity;
283
284 residual_linear_impulse.length() > TOUCH_LINEAR_IMPULSE_THRESHOLD
285 || angular_velocity_delta.length() > TOUCH_ANGULAR_VELOCITY_DELTA_THRESHOLD
286 }
287
288 fn proximity_touch_candidates(
289 &self,
290 sample: &StatsSample,
291 max_collision_distance: f32,
292 ) -> Vec<TouchEvent> {
293 const OCTANE_HITBOX_LENGTH: f32 = 118.01;
294 const OCTANE_HITBOX_WIDTH: f32 = 84.2;
295 const OCTANE_HITBOX_HEIGHT: f32 = 36.16;
296 const OCTANE_HITBOX_OFFSET: f32 = 13.88;
297 const OCTANE_HITBOX_ELEVATION: f32 = 17.05;
298
299 let Some(ball) = sample.ball.as_ref() else {
300 return Vec::new();
301 };
302 let ball_position = vec_to_glam(&ball.rigid_body.location);
303
304 let mut candidates = sample
305 .players
306 .iter()
307 .filter_map(|player| {
308 let rigid_body = player.rigid_body.as_ref()?;
309 let player_position = vec_to_glam(&rigid_body.location);
310 let local_ball_position = quat_to_glam(&rigid_body.rotation).inverse()
311 * (ball_position - player_position);
312
313 let x_distance = if local_ball_position.x
314 < -OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET
315 {
316 (-OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET) - local_ball_position.x
317 } else if local_ball_position.x > OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET
318 {
319 local_ball_position.x - (OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET)
320 } else {
321 0.0
322 };
323 let y_distance = if local_ball_position.y < -OCTANE_HITBOX_WIDTH / 2.0 {
324 (-OCTANE_HITBOX_WIDTH / 2.0) - local_ball_position.y
325 } else if local_ball_position.y > OCTANE_HITBOX_WIDTH / 2.0 {
326 local_ball_position.y - OCTANE_HITBOX_WIDTH / 2.0
327 } else {
328 0.0
329 };
330 let z_distance = if local_ball_position.z
331 < -OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION
332 {
333 (-OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION) - local_ball_position.z
334 } else if local_ball_position.z
335 > OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION
336 {
337 local_ball_position.z - (OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION)
338 } else {
339 0.0
340 };
341
342 let collision_distance =
343 glam::Vec3::new(x_distance, y_distance, z_distance).length();
344 if collision_distance > max_collision_distance {
345 return None;
346 }
347
348 Some(TouchEvent {
349 time: sample.time,
350 frame: sample.frame_number,
351 team_is_team_0: player.is_team_0,
352 player: Some(player.player_id.clone()),
353 closest_approach_distance: Some(collision_distance),
354 })
355 })
356 .collect::<Vec<_>>();
357
358 candidates.sort_by(|left, right| {
359 let left_distance = left.closest_approach_distance.unwrap_or(f32::INFINITY);
360 let right_distance = right.closest_approach_distance.unwrap_or(f32::INFINITY);
361 left_distance.total_cmp(&right_distance)
362 });
363 candidates
364 }
365
366 fn candidate_touch_event(&self, sample: &StatsSample) -> Option<TouchEvent> {
367 const TOUCH_COLLISION_DISTANCE_THRESHOLD: f32 = 300.0;
368
369 self.proximity_touch_candidates(sample, TOUCH_COLLISION_DISTANCE_THRESHOLD)
370 .into_iter()
371 .next()
372 }
373
374 fn update_recent_touch_candidates(&mut self, sample: &StatsSample) {
375 const PROXIMITY_CANDIDATE_DISTANCE_THRESHOLD: f32 = 220.0;
376
377 for candidate in
378 self.proximity_touch_candidates(sample, PROXIMITY_CANDIDATE_DISTANCE_THRESHOLD)
379 {
380 let Some(player_id) = candidate.player.clone() else {
381 continue;
382 };
383
384 self.recent_touch_candidates.insert(player_id, candidate);
385 }
386 }
387
388 fn candidate_for_player(&self, player_id: &PlayerId) -> Option<TouchEvent> {
389 self.recent_touch_candidates.get(player_id).cloned()
390 }
391
392 fn contested_touch_candidates(&self, primary: &TouchEvent) -> Vec<TouchEvent> {
393 const CONTESTED_TOUCH_DISTANCE_MARGIN: f32 = 80.0;
394
395 let primary_distance = primary.closest_approach_distance.unwrap_or(f32::INFINITY);
396
397 let best_opposing_candidate = self
398 .recent_touch_candidates
399 .values()
400 .filter(|candidate| candidate.team_is_team_0 != primary.team_is_team_0)
401 .filter(|candidate| {
402 candidate.closest_approach_distance.unwrap_or(f32::INFINITY)
403 <= primary_distance + CONTESTED_TOUCH_DISTANCE_MARGIN
404 })
405 .min_by(|left, right| {
406 let left_distance = left.closest_approach_distance.unwrap_or(f32::INFINITY);
407 let right_distance = right.closest_approach_distance.unwrap_or(f32::INFINITY);
408 left_distance.total_cmp(&right_distance)
409 })
410 .cloned();
411
412 best_opposing_candidate.into_iter().collect()
413 }
414
415 fn confirmed_touch_events(&self, sample: &StatsSample) -> Vec<TouchEvent> {
416 let mut touch_events = Vec::new();
417 let mut confirmed_players = HashSet::new();
418
419 if self.is_touch_candidate(sample) {
420 if let Some(candidate) = self.candidate_touch_event(sample) {
421 for contested_candidate in self.contested_touch_candidates(&candidate) {
422 if let Some(player_id) = contested_candidate.player.clone() {
423 confirmed_players.insert(player_id);
424 }
425 touch_events.push(contested_candidate);
426 }
427 if let Some(player_id) = candidate.player.clone() {
428 confirmed_players.insert(player_id);
429 }
430 touch_events.push(candidate);
431 }
432 }
433
434 for dodge_refresh in &sample.dodge_refreshed_events {
435 if !confirmed_players.insert(dodge_refresh.player.clone()) {
436 continue;
437 }
438 let Some(candidate) = self.candidate_for_player(&dodge_refresh.player) else {
439 continue;
440 };
441 touch_events.push(candidate);
442 }
443
444 touch_events
445 }
446}
447
448impl DerivedSignal for TouchStateSignal {
449 fn id(&self) -> DerivedSignalId {
450 TOUCH_STATE_SIGNAL_ID
451 }
452
453 fn evaluate(
454 &mut self,
455 sample: &StatsSample,
456 _ctx: &AnalysisContext,
457 ) -> SubtrActorResult<Option<Box<dyn Any>>> {
458 let live_play = self.live_play_tracker.is_live_play(sample);
459 let touch_events = if live_play {
460 self.prune_recent_touch_candidates(sample.frame_number);
461 self.update_recent_touch_candidates(sample);
462 self.confirmed_touch_events(sample)
463 .into_iter()
464 .filter(|candidate| self.should_emit_candidate(candidate))
465 .collect()
466 } else {
467 self.current_last_touch = None;
468 self.recent_touch_candidates.clear();
469 Vec::new()
470 };
471
472 if let Some(last_touch) = touch_events.last() {
473 self.current_last_touch = Some(last_touch.clone());
474 }
475 self.previous_ball_linear_velocity = Self::current_ball_linear_velocity(sample);
476 self.previous_ball_angular_velocity = Self::current_ball_angular_velocity(sample);
477
478 let output = TouchState {
479 touch_events,
480 last_touch: self.current_last_touch.clone(),
481 last_touch_player: self
482 .current_last_touch
483 .as_ref()
484 .and_then(|touch| touch.player.clone()),
485 last_touch_team_is_team_0: self
486 .current_last_touch
487 .as_ref()
488 .map(|touch| touch.team_is_team_0),
489 };
490 Ok(Some(Box::new(output)))
491 }
492}
493
494#[derive(Default)]
495pub struct PossessionStateSignal {
496 tracker: PossessionTracker,
497 live_play_tracker: LivePlayTracker,
498}
499
500impl PossessionStateSignal {
501 pub fn new() -> Self {
502 Self::default()
503 }
504}
505
506impl DerivedSignal for PossessionStateSignal {
507 fn id(&self) -> DerivedSignalId {
508 POSSESSION_STATE_SIGNAL_ID
509 }
510
511 fn dependencies(&self) -> &'static [DerivedSignalId] {
512 &[TOUCH_STATE_SIGNAL_ID]
513 }
514
515 fn evaluate(
516 &mut self,
517 sample: &StatsSample,
518 ctx: &AnalysisContext,
519 ) -> SubtrActorResult<Option<Box<dyn Any>>> {
520 let live_play = self.live_play_tracker.is_live_play(sample);
521 if !live_play {
522 self.tracker.reset();
523 return Ok(Some(Box::new(PossessionState {
524 active_team_before_sample: None,
525 current_team_is_team_0: None,
526 active_player_before_sample: None,
527 current_player: None,
528 })));
529 }
530
531 let touch_state = ctx
532 .get::<TouchState>(TOUCH_STATE_SIGNAL_ID)
533 .cloned()
534 .unwrap_or_default();
535 Ok(Some(Box::new(
536 self.tracker.update(sample, &touch_state.touch_events),
537 )))
538 }
539}
540
541#[derive(Default)]
542pub struct BackboardBounceStateSignal {
543 previous_ball_velocity: Option<glam::Vec3>,
544 last_touch: Option<TouchEvent>,
545 last_bounce_event: Option<BackboardBounceEvent>,
546 live_play_tracker: LivePlayTracker,
547}
548
549impl BackboardBounceStateSignal {
550 pub fn new() -> Self {
551 Self::default()
552 }
553
554 fn detect_bounce(&self, sample: &StatsSample) -> Option<BackboardBounceEvent> {
555 const BACKBOARD_MIN_BALL_Z: f32 = 500.0;
556 const BACKBOARD_MIN_NORMALIZED_Y: f32 = 4700.0;
557 const BACKBOARD_MAX_ABS_X: f32 = 1600.0;
558 const BACKBOARD_MIN_APPROACH_SPEED_Y: f32 = 350.0;
559 const BACKBOARD_MIN_REBOUND_SPEED_Y: f32 = 250.0;
560 const BACKBOARD_TOUCH_ATTRIBUTION_MAX_SECONDS: f32 = 2.5;
561
562 if !sample.touch_events.is_empty() {
563 return None;
564 }
565
566 let last_touch = self.last_touch.as_ref()?;
567 let player = last_touch.player.clone()?;
568 let current_ball = sample.ball.as_ref()?;
569 let previous_ball_velocity = self.previous_ball_velocity?;
570
571 if (sample.time - last_touch.time).max(0.0) > BACKBOARD_TOUCH_ATTRIBUTION_MAX_SECONDS {
572 return None;
573 }
574
575 let ball_position = current_ball.position();
576 if ball_position.x.abs() > BACKBOARD_MAX_ABS_X || ball_position.z < BACKBOARD_MIN_BALL_Z {
577 return None;
578 }
579
580 let normalized_position_y = normalized_y(last_touch.team_is_team_0, ball_position);
581 if normalized_position_y < BACKBOARD_MIN_NORMALIZED_Y {
582 return None;
583 }
584
585 let previous_normalized_velocity_y = if last_touch.team_is_team_0 {
586 previous_ball_velocity.y
587 } else {
588 -previous_ball_velocity.y
589 };
590 let current_normalized_velocity_y = if last_touch.team_is_team_0 {
591 current_ball.velocity().y
592 } else {
593 -current_ball.velocity().y
594 };
595
596 if previous_normalized_velocity_y < BACKBOARD_MIN_APPROACH_SPEED_Y {
597 return None;
598 }
599 if current_normalized_velocity_y > -BACKBOARD_MIN_REBOUND_SPEED_Y {
600 return None;
601 }
602
603 Some(BackboardBounceEvent {
604 time: sample.time,
605 frame: sample.frame_number,
606 player,
607 is_team_0: last_touch.team_is_team_0,
608 })
609 }
610}
611
612impl DerivedSignal for BackboardBounceStateSignal {
613 fn id(&self) -> DerivedSignalId {
614 BACKBOARD_BOUNCE_STATE_SIGNAL_ID
615 }
616
617 fn evaluate(
618 &mut self,
619 sample: &StatsSample,
620 _ctx: &AnalysisContext,
621 ) -> SubtrActorResult<Option<Box<dyn Any>>> {
622 let live_play = self.live_play_tracker.is_live_play(sample);
623 if !live_play {
624 self.previous_ball_velocity = sample.ball.as_ref().map(BallSample::velocity);
625 self.last_touch = None;
626 self.last_bounce_event = None;
627 return Ok(Some(Box::new(BackboardBounceState::default())));
628 }
629
630 let bounce_events: Vec<_> = self.detect_bounce(sample).into_iter().collect();
631 if let Some(last_bounce_event) = bounce_events.last() {
632 self.last_bounce_event = Some(last_bounce_event.clone());
633 }
634
635 if let Some(last_touch) = sample.touch_events.last() {
636 self.last_touch = Some(last_touch.clone());
637 }
638 self.previous_ball_velocity = sample.ball.as_ref().map(BallSample::velocity);
639
640 Ok(Some(Box::new(BackboardBounceState {
641 bounce_events,
642 last_bounce_event: self.last_bounce_event.clone(),
643 })))
644 }
645}
646
647#[derive(Default)]
648pub struct FiftyFiftyStateSignal {
649 active_event: Option<ActiveFiftyFifty>,
650 last_resolved_event: Option<FiftyFiftyEvent>,
651 kickoff_touch_window_open: bool,
652 live_play_tracker: LivePlayTracker,
653}
654
655impl FiftyFiftyStateSignal {
656 pub fn new() -> Self {
657 Self::default()
658 }
659
660 fn reset(&mut self) {
661 self.active_event = None;
662 }
663
664 fn maybe_resolve_active_event(
665 &mut self,
666 sample: &StatsSample,
667 possession_state: &PossessionState,
668 ) -> Option<FiftyFiftyEvent> {
669 let active = self.active_event.as_ref()?;
670 let age = (sample.time - active.last_touch_time).max(0.0);
671 if age < FIFTY_FIFTY_RESOLUTION_DELAY_SECONDS {
672 return None;
673 }
674
675 let winning_team_is_team_0 = FiftyFiftyReducer::winning_team_from_ball(active, sample);
676 let possession_team_is_team_0 = possession_state.current_team_is_team_0;
677 let should_resolve = winning_team_is_team_0.is_some()
678 || possession_team_is_team_0.is_some()
679 || age >= FIFTY_FIFTY_MAX_DURATION_SECONDS;
680 if !should_resolve {
681 return None;
682 }
683
684 let active = self.active_event.take()?;
685 let event = FiftyFiftyEvent {
686 start_time: active.start_time,
687 start_frame: active.start_frame,
688 resolve_time: sample.time,
689 resolve_frame: sample.frame_number,
690 is_kickoff: active.is_kickoff,
691 team_zero_player: active.team_zero_player,
692 team_one_player: active.team_one_player,
693 team_zero_position: active.team_zero_position,
694 team_one_position: active.team_one_position,
695 midpoint: active.midpoint,
696 plane_normal: active.plane_normal,
697 winning_team_is_team_0,
698 possession_team_is_team_0,
699 };
700 self.last_resolved_event = Some(event.clone());
701 Some(event)
702 }
703}
704
705impl DerivedSignal for FiftyFiftyStateSignal {
706 fn id(&self) -> DerivedSignalId {
707 FIFTY_FIFTY_STATE_SIGNAL_ID
708 }
709
710 fn dependencies(&self) -> &'static [DerivedSignalId] {
711 &[TOUCH_STATE_SIGNAL_ID, POSSESSION_STATE_SIGNAL_ID]
712 }
713
714 fn evaluate(
715 &mut self,
716 sample: &StatsSample,
717 ctx: &AnalysisContext,
718 ) -> SubtrActorResult<Option<Box<dyn Any>>> {
719 let live_play = self.live_play_tracker.is_live_play(sample);
720 let touch_state = ctx
721 .get::<TouchState>(TOUCH_STATE_SIGNAL_ID)
722 .cloned()
723 .unwrap_or_default();
724 let possession_state = ctx
725 .get::<PossessionState>(POSSESSION_STATE_SIGNAL_ID)
726 .cloned()
727 .unwrap_or_default();
728
729 if FiftyFiftyReducer::kickoff_phase_active(sample) {
730 self.kickoff_touch_window_open = true;
731 }
732
733 if !live_play {
734 self.reset();
735 return Ok(Some(Box::new(FiftyFiftyState {
736 active_event: None,
737 resolved_events: Vec::new(),
738 last_resolved_event: self.last_resolved_event.clone(),
739 })));
740 }
741
742 let has_touch = !touch_state.touch_events.is_empty();
743 let has_contested_touch = touch_state
744 .touch_events
745 .iter()
746 .any(|touch| touch.team_is_team_0)
747 && touch_state
748 .touch_events
749 .iter()
750 .any(|touch| !touch.team_is_team_0);
751
752 if let Some(active_event) = self.active_event.as_mut() {
753 let age = (sample.time - active_event.last_touch_time).max(0.0);
754 if age <= FIFTY_FIFTY_CONTINUATION_TOUCH_WINDOW_SECONDS
755 && active_event.contains_team_touch(&touch_state.touch_events)
756 {
757 active_event.last_touch_time = sample.time;
758 active_event.last_touch_frame = sample.frame_number;
759 }
760 }
761
762 let mut resolved_events = Vec::new();
763 if let Some(event) = self.maybe_resolve_active_event(sample, &possession_state) {
764 resolved_events.push(event);
765 }
766
767 if has_contested_touch {
768 if self.active_event.is_none() {
769 self.active_event = FiftyFiftyReducer::contested_touch(
770 sample,
771 &touch_state.touch_events,
772 self.kickoff_touch_window_open,
773 );
774 }
775 } else if has_touch {
776 if let Some(active_event) = self.active_event.as_mut() {
777 let age = (sample.time - active_event.last_touch_time).max(0.0);
778 if age <= FIFTY_FIFTY_CONTINUATION_TOUCH_WINDOW_SECONDS
779 && active_event.contains_team_touch(&touch_state.touch_events)
780 {
781 active_event.last_touch_time = sample.time;
782 active_event.last_touch_frame = sample.frame_number;
783 }
784 }
785 }
786
787 if has_touch {
788 self.kickoff_touch_window_open = false;
789 }
790
791 Ok(Some(Box::new(FiftyFiftyState {
792 active_event: self.active_event.clone(),
793 resolved_events,
794 last_resolved_event: self.last_resolved_event.clone(),
795 })))
796 }
797}
798
799pub fn default_derived_signal_graph() -> DerivedSignalGraph {
800 DerivedSignalGraph::new()
801 .with_signal(TouchStateSignal::new())
802 .with_signal(PossessionStateSignal::new())
803 .with_signal(BackboardBounceStateSignal::new())
804 .with_signal(FiftyFiftyStateSignal::new())
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use crate::stats::reducers::TouchReducer;
811 use boxcars::{Quaternion, RemoteId, RigidBody, Vector3f};
812
813 #[derive(Default)]
814 struct TestSignal {
815 id: DerivedSignalId,
816 deps: &'static [DerivedSignalId],
817 }
818
819 impl DerivedSignal for TestSignal {
820 fn id(&self) -> DerivedSignalId {
821 self.id
822 }
823
824 fn dependencies(&self) -> &'static [DerivedSignalId] {
825 self.deps
826 }
827
828 fn evaluate(
829 &mut self,
830 _sample: &StatsSample,
831 _ctx: &AnalysisContext,
832 ) -> SubtrActorResult<Option<Box<dyn Any>>> {
833 Ok(None)
834 }
835 }
836
837 #[test]
838 fn topo_sorts_dependencies_before_dependents() {
839 let mut graph = DerivedSignalGraph::new()
840 .with_signal(TestSignal {
841 id: "c",
842 deps: &["b"],
843 })
844 .with_signal(TestSignal { id: "a", deps: &[] })
845 .with_signal(TestSignal {
846 id: "b",
847 deps: &["a"],
848 });
849
850 graph.rebuild_order_if_needed().unwrap();
851 let ordered_ids: Vec<_> = graph
852 .evaluation_order
853 .iter()
854 .map(|index| graph.nodes[*index].id())
855 .collect();
856 assert_eq!(ordered_ids, vec!["a", "b", "c"]);
857 }
858
859 fn rigid_body(x: f32, y: f32, z: f32, ang_vel_z: f32) -> RigidBody {
860 RigidBody {
861 sleeping: false,
862 location: Vector3f { x, y, z },
863 rotation: Quaternion {
864 x: 0.0,
865 y: 0.0,
866 z: 0.0,
867 w: 1.0,
868 },
869 linear_velocity: Some(Vector3f {
870 x: 0.0,
871 y: 0.0,
872 z: 0.0,
873 }),
874 angular_velocity: Some(Vector3f {
875 x: 0.0,
876 y: 0.0,
877 z: ang_vel_z,
878 }),
879 }
880 }
881
882 fn sample(frame_number: usize, time: f32, ball_ang_vel_z: f32) -> StatsSample {
883 StatsSample {
884 frame_number,
885 time,
886 dt: 1.0 / 120.0,
887 seconds_remaining: None,
888 game_state: None,
889 ball_has_been_hit: None,
890 kickoff_countdown_time: None,
891 team_zero_score: None,
892 team_one_score: None,
893 possession_team_is_team_0: Some(true),
894 scored_on_team_is_team_0: None,
895 current_in_game_team_player_counts: Some([1, 1]),
896 ball: Some(BallSample {
897 rigid_body: rigid_body(70.0, 0.0, 20.0, ball_ang_vel_z),
898 }),
899 players: vec![
900 PlayerSample {
901 player_id: RemoteId::Steam(1),
902 is_team_0: true,
903 rigid_body: Some(rigid_body(0.0, 0.0, 0.0, 0.0)),
904 boost_amount: None,
905 last_boost_amount: None,
906 boost_active: false,
907 dodge_active: false,
908 powerslide_active: false,
909 match_goals: None,
910 match_assists: None,
911 match_saves: None,
912 match_shots: None,
913 match_score: None,
914 },
915 PlayerSample {
916 player_id: RemoteId::Steam(2),
917 is_team_0: false,
918 rigid_body: Some(rigid_body(3000.0, 0.0, 0.0, 0.0)),
919 boost_amount: None,
920 last_boost_amount: None,
921 boost_active: false,
922 dodge_active: false,
923 powerslide_active: false,
924 match_goals: None,
925 match_assists: None,
926 match_saves: None,
927 match_shots: None,
928 match_score: None,
929 },
930 ],
931 active_demos: Vec::new(),
932 demo_events: Vec::new(),
933 boost_pad_events: Vec::new(),
934 touch_events: Vec::new(),
935 dodge_refreshed_events: Vec::new(),
936 player_stat_events: Vec::new(),
937 goal_events: Vec::new(),
938 }
939 }
940
941 #[test]
942 fn touch_signal_dedupes_same_player_consecutive_touches() {
943 let mut graph = default_derived_signal_graph();
944 let mut reducer = TouchReducer::new();
945
946 let first = sample(0, 0.0, 0.0);
947 let second = sample(1, 1.0 / 120.0, 5.0);
948 let third = sample(2, 2.0 / 120.0, 10.0);
949
950 let first_ctx = graph.evaluate(&first).unwrap();
951 reducer.on_sample_with_context(&first, first_ctx).unwrap();
952
953 let second_ctx = graph.evaluate(&second).unwrap();
954 let second_touch_state = second_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
955 assert_eq!(second_touch_state.touch_events.len(), 1);
956 assert_eq!(
957 second_touch_state.last_touch_player,
958 Some(RemoteId::Steam(1))
959 );
960 reducer.on_sample_with_context(&second, second_ctx).unwrap();
961
962 let third_ctx = graph.evaluate(&third).unwrap();
963 let third_touch_state = third_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
964 assert_eq!(third_touch_state.touch_events.len(), 0);
965 assert_eq!(
966 third_touch_state.last_touch_player,
967 Some(RemoteId::Steam(1))
968 );
969 reducer.on_sample_with_context(&third, third_ctx).unwrap();
970
971 let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
972 assert_eq!(stats.touch_count, 1);
973 assert!(stats.is_last_touch);
974 }
975
976 #[test]
977 fn touch_signal_confirms_nearby_candidate_from_dodge_refresh() {
978 let mut graph = default_derived_signal_graph();
979 let mut reducer = TouchReducer::new();
980
981 let first = sample(0, 0.0, 0.0);
982 let mut second = sample(1, 1.0 / 120.0, 0.0);
983 second.dodge_refreshed_events.push(DodgeRefreshedEvent {
984 time: second.time,
985 frame: second.frame_number,
986 player: RemoteId::Steam(1),
987 is_team_0: true,
988 counter_value: 1,
989 });
990
991 let first_ctx = graph.evaluate(&first).unwrap();
992 reducer.on_sample_with_context(&first, first_ctx).unwrap();
993
994 let second_ctx = graph.evaluate(&second).unwrap();
995 let second_touch_state = second_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
996 assert_eq!(second_touch_state.touch_events.len(), 1);
997 assert_eq!(
998 second_touch_state.last_touch_player,
999 Some(RemoteId::Steam(1))
1000 );
1001 reducer.on_sample_with_context(&second, second_ctx).unwrap();
1002
1003 let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
1004 assert_eq!(stats.touch_count, 1);
1005 assert!(stats.is_last_touch);
1006 }
1007
1008 #[test]
1009 fn touch_signal_clears_last_touch_across_kickoff() {
1010 let mut graph = default_derived_signal_graph();
1011
1012 let first = sample(0, 0.0, 0.0);
1013 let second = sample(1, 1.0 / 120.0, 5.0);
1014 let mut kickoff = sample(2, 2.0 / 120.0, 10.0);
1015 kickoff.game_state = Some(55);
1016 kickoff.kickoff_countdown_time = Some(3);
1017 kickoff.ball_has_been_hit = Some(false);
1018
1019 graph.evaluate(&first).unwrap();
1020 let second_ctx = graph.evaluate(&second).unwrap();
1021 let second_touch_state = second_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
1022 assert_eq!(
1023 second_touch_state.last_touch_player,
1024 Some(RemoteId::Steam(1))
1025 );
1026
1027 let kickoff_ctx = graph.evaluate(&kickoff).unwrap();
1028 let kickoff_touch_state = kickoff_ctx
1029 .get::<TouchState>(TOUCH_STATE_SIGNAL_ID)
1030 .unwrap();
1031 assert!(kickoff_touch_state.touch_events.is_empty());
1032 assert_eq!(kickoff_touch_state.last_touch, None);
1033 assert_eq!(kickoff_touch_state.last_touch_player, None);
1034 assert_eq!(kickoff_touch_state.last_touch_team_is_team_0, None);
1035 }
1036}