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