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