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