Skip to main content

subtr_actor/stats/timeline/
collector.rs

1use crate::collector::frame_resolution::{
2    FinalStatsFrameAction, StatsFramePersistenceController, StatsFrameResolution,
3};
4use crate::stats::analysis_graph::{
5    AnalysisGraph, StatsTimelineEventsNode, StatsTimelineEventsState, StatsTimelineFrameNode,
6    StatsTimelineFrameState,
7};
8use crate::*;
9use std::collections::BTreeMap;
10
11pub fn build_legacy_timeline_graph() -> AnalysisGraph {
12    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
13    graph.push_boxed_node(Box::new(StatsTimelineFrameNode::new()));
14    graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
15    graph
16}
17
18#[deprecated(
19    note = "use build_legacy_timeline_graph for full partial-sum snapshots, or build_timeline_event_graph for compact event-backed timelines"
20)]
21pub fn build_timeline_graph() -> AnalysisGraph {
22    build_legacy_timeline_graph()
23}
24
25pub fn build_timeline_event_graph() -> AnalysisGraph {
26    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
27    graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
28    graph
29}
30
31pub fn default_stats_timeline_config() -> StatsTimelineConfig {
32    let rotation_defaults = RotationCalculatorConfig::default();
33    StatsTimelineConfig {
34        most_back_forward_threshold_y: PositioningCalculatorConfig::default()
35            .most_back_forward_threshold_y,
36        level_ball_depth_margin: PositioningCalculatorConfig::default().level_ball_depth_margin,
37        pressure_neutral_zone_half_width_y: PressureCalculatorConfig::default()
38            .neutral_zone_half_width_y,
39        rotation_role_depth_margin: rotation_defaults.role_depth_margin,
40        rotation_first_man_ambiguity_margin: rotation_defaults.first_man_ambiguity_margin,
41        rotation_first_man_debounce_seconds: rotation_defaults.first_man_debounce_seconds,
42        rush_max_start_y: RushCalculatorConfig::default().max_start_y,
43        rush_attack_support_distance_y: RushCalculatorConfig::default().attack_support_distance_y,
44        rush_defender_distance_y: RushCalculatorConfig::default().defender_distance_y,
45        rush_min_possession_retained_seconds: RushCalculatorConfig::default()
46            .min_possession_retained_seconds,
47        aerial_goal_min_ball_z: AerialGoalCalculatorConfig::default().min_ball_z,
48        high_aerial_goal_min_ball_z: HighAerialGoalCalculatorConfig::default().min_ball_z,
49        long_distance_goal_max_attacking_y: LongDistanceGoalCalculatorConfig::default()
50            .max_attacking_y,
51        own_half_goal_max_attacking_y: OwnHalfGoalCalculatorConfig::default().max_attacking_y,
52        empty_net_min_defender_y_margin: EmptyNetGoalCalculatorConfig::default()
53            .min_defender_y_margin,
54        empty_net_min_defender_distance: EmptyNetGoalCalculatorConfig::default()
55            .min_defender_distance,
56        empty_net_max_touch_attacking_y: EmptyNetGoalCalculatorConfig::default()
57            .max_touch_attacking_y,
58        flick_goal_max_event_to_goal_seconds: FlickGoalCalculatorConfig::default()
59            .max_event_to_goal_seconds,
60        double_tap_goal_max_event_to_goal_seconds: DoubleTapGoalCalculatorConfig::default()
61            .max_event_to_goal_seconds,
62        one_timer_goal_max_event_to_goal_seconds: OneTimerGoalCalculatorConfig::default()
63            .max_event_to_goal_seconds,
64        air_dribble_goal_max_end_to_goal_seconds: AirDribbleGoalCalculatorConfig::default()
65            .max_end_to_goal_seconds,
66        flip_reset_goal_max_event_to_goal_seconds: FlipResetGoalCalculatorConfig::default()
67            .max_event_to_goal_seconds,
68        half_volley_max_bounce_to_touch_seconds: HalfVolleyCalculatorConfig::default()
69            .max_bounce_to_touch_seconds,
70        half_volley_min_ball_speed: HalfVolleyCalculatorConfig::default().min_ball_speed,
71        half_volley_goal_max_touch_to_goal_seconds: HalfVolleyGoalCalculatorConfig::default()
72            .max_touch_to_goal_seconds,
73        half_volley_goal_min_goal_alignment: HalfVolleyGoalCalculatorConfig::default()
74            .min_goal_alignment,
75    }
76}
77
78pub struct StatsTimelineCollector {
79    graph: AnalysisGraph,
80    replay_meta: Option<ReplayMeta>,
81    last_replay_meta_player_count: Option<usize>,
82    frames: Vec<ReplayStatsFrame>,
83    last_sample_time: Option<f32>,
84    frame_persistence: StatsFramePersistenceController,
85}
86
87impl Default for StatsTimelineCollector {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl StatsTimelineCollector {
94    /// Create the legacy full-snapshot timeline collector.
95    ///
96    /// This evaluates and stores cumulative team/player stat modules for every
97    /// captured frame. Prefer [`StatsTimelineEventCollector`] for compact
98    /// event-backed transfer.
99    pub fn new() -> Self {
100        let graph = build_legacy_timeline_graph();
101        Self {
102            graph,
103            replay_meta: None,
104            last_replay_meta_player_count: None,
105            frames: Vec::new(),
106            last_sample_time: None,
107            frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
108        }
109    }
110
111    fn timeline_config(&self) -> StatsTimelineConfig {
112        default_stats_timeline_config()
113    }
114
115    fn snapshot_frame(&self) -> SubtrActorResult<ReplayStatsFrame> {
116        self.graph
117            .state::<StatsTimelineFrameState>()
118            .and_then(|state| state.frame.clone())
119            .ok_or_else(|| {
120                SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
121                    "missing StatsTimelineFrame state while building timeline frame".to_owned(),
122                ))
123            })
124    }
125
126    pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
127        let replay_meta = self
128            .replay_meta
129            .clone()
130            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
131        let mut events = self
132            .graph
133            .state::<StatsTimelineEventsState>()
134            .map(|state| state.events.clone())
135            .unwrap_or_default();
136        if let Some(boost) = self.graph.state::<BoostCalculator>() {
137            events.boost_pickups = boost.pickup_comparison_events().to_vec();
138            events.boost_ledger = boost.ledger_events().to_vec();
139            events.boost_state = boost.state_events().to_vec();
140        }
141        Ok(ReplayStatsTimeline {
142            config: self.timeline_config(),
143            replay_meta,
144            events,
145            frames: self.frames,
146        })
147    }
148
149    #[deprecated(
150        note = "use into_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
151    )]
152    pub fn into_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
153        self.into_legacy_replay_stats_timeline()
154    }
155
156    pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
157        self.frame_persistence = StatsFramePersistenceController::new(resolution);
158        self
159    }
160
161    pub fn get_legacy_replay_stats_timeline(
162        mut self,
163        replay: &boxcars::Replay,
164    ) -> SubtrActorResult<ReplayStatsTimeline> {
165        let mut processor = ReplayProcessor::new(replay)?;
166        processor.process(&mut self)?;
167        self.into_legacy_replay_stats_timeline()
168    }
169
170    #[deprecated(
171        note = "use get_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
172    )]
173    pub fn get_replay_data(
174        self,
175        replay: &boxcars::Replay,
176    ) -> SubtrActorResult<ReplayStatsTimeline> {
177        self.get_legacy_replay_stats_timeline(replay)
178    }
179
180    #[deprecated(
181        note = "use into_legacy_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
182    )]
183    pub fn into_timeline(self) -> ReplayStatsTimeline {
184        self.into_legacy_timeline()
185    }
186
187    pub fn into_legacy_timeline(self) -> ReplayStatsTimeline {
188        self.into_legacy_replay_stats_timeline()
189            .expect("analysis-node timeline collector should build typed stats frames")
190    }
191}
192
193pub struct StatsTimelineEventCollector {
194    graph: AnalysisGraph,
195    replay_meta: Option<ReplayMeta>,
196    last_replay_meta_player_count: Option<usize>,
197    frames: Vec<ReplayStatsFrameScaffold>,
198    last_sample_time: Option<f32>,
199    frame_persistence: StatsFramePersistenceController,
200}
201
202impl Default for StatsTimelineEventCollector {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208impl StatsTimelineEventCollector {
209    pub fn new() -> Self {
210        Self {
211            graph: build_timeline_event_graph(),
212            replay_meta: None,
213            last_replay_meta_player_count: None,
214            frames: Vec::new(),
215            last_sample_time: None,
216            frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
217        }
218    }
219
220    pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
221        self.frame_persistence = StatsFramePersistenceController::new(resolution);
222        self
223    }
224
225    fn replay_meta(&self) -> SubtrActorResult<&ReplayMeta> {
226        self.replay_meta
227            .as_ref()
228            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))
229    }
230
231    fn is_team_zero_player(replay_meta: &ReplayMeta, player: &PlayerInfo) -> bool {
232        replay_meta
233            .team_zero
234            .iter()
235            .any(|team_player| team_player.remote_id == player.remote_id)
236    }
237
238    fn snapshot_frame_scaffold(&self) -> SubtrActorResult<ReplayStatsFrameScaffold> {
239        let replay_meta = self.replay_meta()?;
240        let frame = self.graph.state::<FrameInfo>().ok_or_else(|| {
241            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
242                "missing FrameInfo state while building stats timeline frame scaffold".to_owned(),
243            ))
244        })?;
245        let gameplay = self.graph.state::<GameplayState>().ok_or_else(|| {
246            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
247                "missing GameplayState state while building stats timeline frame scaffold"
248                    .to_owned(),
249            ))
250        })?;
251        let live_play_state = self.graph.state::<LivePlayState>().ok_or_else(|| {
252            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
253                "missing LivePlayState state while building stats timeline frame scaffold"
254                    .to_owned(),
255            ))
256        })?;
257
258        Ok(ReplayStatsFrameScaffold {
259            frame_number: frame.frame_number,
260            time: frame.time,
261            dt: frame.dt,
262            seconds_remaining: frame.seconds_remaining,
263            game_state: gameplay.game_state,
264            ball_has_been_hit: gameplay.ball_has_been_hit,
265            kickoff_countdown_time: gameplay.kickoff_countdown_time,
266            gameplay_phase: live_play_state.gameplay_phase,
267            is_live_play: live_play_state.is_live_play,
268            team_zero: BTreeMap::new(),
269            team_one: BTreeMap::new(),
270            players: replay_meta
271                .player_order()
272                .map(|player| ReplayStatsPlayerIdentity {
273                    player_id: player.remote_id.clone(),
274                    name: player.name.clone(),
275                    is_team_0: Self::is_team_zero_player(replay_meta, player),
276                })
277                .collect(),
278        })
279    }
280
281    pub fn into_replay_stats_timeline_scaffold(
282        self,
283    ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
284        let replay_meta = self
285            .replay_meta
286            .clone()
287            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
288        let events = self
289            .graph
290            .state::<StatsTimelineEventsState>()
291            .map(|state| state.events.clone())
292            .unwrap_or_default();
293        Ok(ReplayStatsTimelineScaffold {
294            config: default_stats_timeline_config(),
295            replay_meta,
296            events,
297            frames: self.frames,
298        })
299    }
300
301    pub fn get_replay_stats_timeline_scaffold(
302        mut self,
303        replay: &boxcars::Replay,
304    ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
305        let mut processor = ReplayProcessor::new(replay)?;
306        processor.process(&mut self)?;
307        self.into_replay_stats_timeline_scaffold()
308    }
309
310    #[deprecated(
311        note = "use get_replay_stats_timeline_scaffold for compact event-backed timelines"
312    )]
313    pub fn get_replay_data(
314        self,
315        replay: &boxcars::Replay,
316    ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
317        self.get_replay_stats_timeline_scaffold(replay)
318    }
319}
320
321impl Collector for StatsTimelineCollector {
322    fn process_frame(
323        &mut self,
324        processor: &dyn ProcessorView,
325        _frame: &boxcars::Frame,
326        frame_number: usize,
327        current_time: f32,
328    ) -> SubtrActorResult<TimeAdvance> {
329        let player_count = processor.player_count();
330        if self.last_replay_meta_player_count != Some(player_count) {
331            let replay_meta = processor.get_replay_meta()?;
332            self.graph.on_replay_meta(&replay_meta)?;
333            self.replay_meta = Some(replay_meta);
334            self.last_replay_meta_player_count = Some(player_count);
335        }
336
337        let dt = self
338            .last_sample_time
339            .map(|last_time| (current_time - last_time).max(0.0))
340            .unwrap_or(0.0);
341        let frame_input = FrameInput::timeline(processor, frame_number, current_time, dt);
342        self.graph.evaluate_with_state(&frame_input)?;
343        self.last_sample_time = Some(current_time);
344
345        if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
346            let mut frame = self.snapshot_frame()?;
347            frame.dt = emitted_dt;
348            self.frames.push(frame);
349        }
350
351        Ok(TimeAdvance::NextFrame)
352    }
353
354    fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
355        self.graph.finish()?;
356        let Some(_) = self.replay_meta.as_ref() else {
357            return Ok(());
358        };
359        let Some(_) = self.graph.state::<StatsTimelineFrameState>() else {
360            return Ok(());
361        };
362        let mut final_snapshot = self.snapshot_frame()?;
363        match self
364            .frame_persistence
365            .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
366        {
367            Some(FinalStatsFrameAction::Append { dt }) => {
368                final_snapshot.dt = dt;
369                self.frames.push(final_snapshot);
370            }
371            Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
372                final_snapshot.dt = dt;
373                if let Some(last_frame) = self.frames.last_mut() {
374                    *last_frame = final_snapshot;
375                }
376            }
377            None => {}
378        }
379        Ok(())
380    }
381}
382
383impl Collector for StatsTimelineEventCollector {
384    fn process_frame(
385        &mut self,
386        processor: &dyn ProcessorView,
387        _frame: &boxcars::Frame,
388        frame_number: usize,
389        current_time: f32,
390    ) -> SubtrActorResult<TimeAdvance> {
391        let player_count = processor.player_count();
392        if self.last_replay_meta_player_count != Some(player_count) {
393            let replay_meta = processor.get_replay_meta()?;
394            self.graph.on_replay_meta(&replay_meta)?;
395            self.replay_meta = Some(replay_meta);
396            self.last_replay_meta_player_count = Some(player_count);
397        }
398
399        let dt = self
400            .last_sample_time
401            .map(|last_time| (current_time - last_time).max(0.0))
402            .unwrap_or(0.0);
403        let frame_input = FrameInput::timeline(processor, frame_number, current_time, dt);
404        self.graph.evaluate_with_state(&frame_input)?;
405        self.last_sample_time = Some(current_time);
406
407        if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
408            let mut frame = self.snapshot_frame_scaffold()?;
409            frame.dt = emitted_dt;
410            self.frames.push(frame);
411        }
412
413        Ok(TimeAdvance::NextFrame)
414    }
415
416    fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
417        self.graph.finish()?;
418        let Some(_) = self.replay_meta.as_ref() else {
419            return Ok(());
420        };
421        let Some(_) = self.graph.state::<FrameInfo>() else {
422            return Ok(());
423        };
424        let mut final_snapshot = self.snapshot_frame_scaffold()?;
425        match self
426            .frame_persistence
427            .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
428        {
429            Some(FinalStatsFrameAction::Append { dt }) => {
430                final_snapshot.dt = dt;
431                self.frames.push(final_snapshot);
432            }
433            Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
434                final_snapshot.dt = dt;
435                if let Some(last_frame) = self.frames.last_mut() {
436                    *last_frame = final_snapshot;
437                }
438            }
439            None => {}
440        }
441        Ok(())
442    }
443}
444
445#[cfg(test)]
446#[path = "collector_tests.rs"]
447mod collector_tests;