Skip to main content

subtr_actor/stats/reducers/
analysis.rs

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                    powerslide_active: false,
785                    match_goals: None,
786                    match_assists: None,
787                    match_saves: None,
788                    match_shots: None,
789                    match_score: None,
790                },
791                PlayerSample {
792                    player_id: RemoteId::Steam(2),
793                    is_team_0: false,
794                    rigid_body: Some(rigid_body(3000.0, 0.0, 0.0, 0.0)),
795                    boost_amount: None,
796                    last_boost_amount: None,
797                    boost_active: false,
798                    powerslide_active: false,
799                    match_goals: None,
800                    match_assists: None,
801                    match_saves: None,
802                    match_shots: None,
803                    match_score: None,
804                },
805            ],
806            active_demos: Vec::new(),
807            demo_events: Vec::new(),
808            boost_pad_events: Vec::new(),
809            touch_events: Vec::new(),
810            dodge_refreshed_events: Vec::new(),
811            player_stat_events: Vec::new(),
812            goal_events: Vec::new(),
813        }
814    }
815
816    #[test]
817    fn touch_signal_dedupes_same_player_consecutive_touches() {
818        let mut graph = default_derived_signal_graph();
819        let mut reducer = TouchReducer::new();
820
821        let first = sample(0, 0.0, 0.0);
822        let second = sample(1, 1.0 / 120.0, 5.0);
823        let third = sample(2, 2.0 / 120.0, 10.0);
824
825        let first_ctx = graph.evaluate(&first).unwrap();
826        reducer.on_sample_with_context(&first, first_ctx).unwrap();
827
828        let second_ctx = graph.evaluate(&second).unwrap();
829        let second_touch_state = second_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
830        assert_eq!(second_touch_state.touch_events.len(), 1);
831        assert_eq!(
832            second_touch_state.last_touch_player,
833            Some(RemoteId::Steam(1))
834        );
835        reducer.on_sample_with_context(&second, second_ctx).unwrap();
836
837        let third_ctx = graph.evaluate(&third).unwrap();
838        let third_touch_state = third_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
839        assert_eq!(third_touch_state.touch_events.len(), 0);
840        assert_eq!(
841            third_touch_state.last_touch_player,
842            Some(RemoteId::Steam(1))
843        );
844        reducer.on_sample_with_context(&third, third_ctx).unwrap();
845
846        let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
847        assert_eq!(stats.touch_count, 1);
848        assert!(stats.is_last_touch);
849    }
850
851    #[test]
852    fn touch_signal_confirms_nearby_candidate_from_dodge_refresh() {
853        let mut graph = default_derived_signal_graph();
854        let mut reducer = TouchReducer::new();
855
856        let first = sample(0, 0.0, 0.0);
857        let mut second = sample(1, 1.0 / 120.0, 0.0);
858        second.dodge_refreshed_events.push(DodgeRefreshedEvent {
859            time: second.time,
860            frame: second.frame_number,
861            player: RemoteId::Steam(1),
862            is_team_0: true,
863            counter_value: 1,
864        });
865
866        let first_ctx = graph.evaluate(&first).unwrap();
867        reducer.on_sample_with_context(&first, first_ctx).unwrap();
868
869        let second_ctx = graph.evaluate(&second).unwrap();
870        let second_touch_state = second_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
871        assert_eq!(second_touch_state.touch_events.len(), 1);
872        assert_eq!(
873            second_touch_state.last_touch_player,
874            Some(RemoteId::Steam(1))
875        );
876        reducer.on_sample_with_context(&second, second_ctx).unwrap();
877
878        let stats = reducer.player_stats().get(&RemoteId::Steam(1)).unwrap();
879        assert_eq!(stats.touch_count, 1);
880        assert!(stats.is_last_touch);
881    }
882
883    #[test]
884    fn touch_signal_clears_last_touch_across_kickoff() {
885        let mut graph = default_derived_signal_graph();
886
887        let first = sample(0, 0.0, 0.0);
888        let second = sample(1, 1.0 / 120.0, 5.0);
889        let mut kickoff = sample(2, 2.0 / 120.0, 10.0);
890        kickoff.game_state = Some(55);
891        kickoff.kickoff_countdown_time = Some(3);
892        kickoff.ball_has_been_hit = Some(false);
893
894        graph.evaluate(&first).unwrap();
895        let second_ctx = graph.evaluate(&second).unwrap();
896        let second_touch_state = second_ctx.get::<TouchState>(TOUCH_STATE_SIGNAL_ID).unwrap();
897        assert_eq!(
898            second_touch_state.last_touch_player,
899            Some(RemoteId::Steam(1))
900        );
901
902        let kickoff_ctx = graph.evaluate(&kickoff).unwrap();
903        let kickoff_touch_state = kickoff_ctx
904            .get::<TouchState>(TOUCH_STATE_SIGNAL_ID)
905            .unwrap();
906        assert!(kickoff_touch_state.touch_events.is_empty());
907        assert_eq!(kickoff_touch_state.last_touch, None);
908        assert_eq!(kickoff_touch_state.last_touch_player, None);
909        assert_eq!(kickoff_touch_state.last_touch_team_is_team_0, None);
910    }
911}