Skip to main content

subtr_actor/stats/reducers/
analysis.rs

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}