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 positioning_defaults = PositioningCalculatorConfig::default();
27    let rotation_defaults = RotationCalculatorConfig::default();
28    let territorial_pressure_defaults = TerritorialPressureCalculatorConfig::default();
29    StatsTimelineConfig {
30        most_back_forward_threshold_y: positioning_defaults.most_back_forward_threshold_y,
31        level_ball_depth_margin: positioning_defaults.level_ball_depth_margin,
32        closest_to_ball_switch_margin: positioning_defaults.closest_to_ball_switch_margin,
33        closest_to_ball_switch_min_seconds: positioning_defaults.closest_to_ball_switch_min_seconds,
34        shadow_defense_max_ball_y: positioning_defaults.shadow_defense_max_ball_y,
35        shadow_defense_min_goal_side_y: positioning_defaults.shadow_defense_min_goal_side_y,
36        shadow_defense_min_gap: positioning_defaults.shadow_defense_min_gap,
37        shadow_defense_max_gap: positioning_defaults.shadow_defense_max_gap,
38        shadow_defense_max_lateral_gap: positioning_defaults.shadow_defense_max_lateral_gap,
39        shadow_defense_min_retreat_speed: positioning_defaults.shadow_defense_min_retreat_speed,
40        shadow_defense_max_speed_delta: positioning_defaults.shadow_defense_max_speed_delta,
41        ball_half_neutral_zone_half_width_y: BallHalfCalculatorConfig::default()
42            .neutral_zone_half_width_y,
43        ball_third_boundary_y: BallThirdCalculatorConfig::default().boundary_y,
44        territorial_pressure_neutral_zone_half_width_y: territorial_pressure_defaults
45            .neutral_zone_half_width_y,
46        territorial_pressure_min_establish_seconds: territorial_pressure_defaults
47            .min_establish_seconds,
48        territorial_pressure_min_establish_third_seconds: territorial_pressure_defaults
49            .min_establish_third_seconds,
50        territorial_pressure_relief_grace_seconds: territorial_pressure_defaults
51            .relief_grace_seconds,
52        territorial_pressure_confirmed_relief_grace_seconds: territorial_pressure_defaults
53            .confirmed_relief_grace_seconds,
54        rotation_role_depth_margin: rotation_defaults.role_depth_margin,
55        rotation_first_man_ambiguity_margin: rotation_defaults.first_man_ambiguity_margin,
56        rotation_first_man_debounce_seconds: rotation_defaults.first_man_debounce_seconds,
57        rotation_first_man_stint_end_grace_seconds: rotation_defaults
58            .first_man_stint_end_grace_seconds,
59        rush_max_start_y: RushCalculatorConfig::default().max_start_y,
60        rush_attack_support_distance_y: RushCalculatorConfig::default().attack_support_distance_y,
61        rush_defender_distance_y: RushCalculatorConfig::default().defender_distance_y,
62        rush_min_possession_retained_seconds: RushCalculatorConfig::default()
63            .min_possession_retained_seconds,
64        aerial_goal_min_ball_z: AerialGoalCalculatorConfig::default().min_ball_z,
65        high_aerial_goal_min_ball_z: HighAerialGoalCalculatorConfig::default().min_ball_z,
66        long_distance_goal_max_attacking_y: LongDistanceGoalCalculatorConfig::default()
67            .max_attacking_y,
68        own_half_goal_max_attacking_y: OwnHalfGoalCalculatorConfig::default().max_attacking_y,
69        empty_net_min_defender_y_margin: EmptyNetGoalCalculatorConfig::default()
70            .min_defender_y_margin,
71        empty_net_min_defender_distance: EmptyNetGoalCalculatorConfig::default()
72            .min_defender_distance,
73        empty_net_max_touch_attacking_y: EmptyNetGoalCalculatorConfig::default()
74            .max_touch_attacking_y,
75        flick_goal_max_event_to_goal_seconds: FlickGoalCalculatorConfig::default()
76            .max_event_to_goal_seconds,
77        ceiling_shot_goal_max_event_to_goal_seconds: CeilingShotGoalCalculatorConfig::default()
78            .max_event_to_goal_seconds,
79        double_tap_goal_max_event_to_goal_seconds: DoubleTapGoalCalculatorConfig::default()
80            .max_event_to_goal_seconds,
81        one_timer_goal_max_event_to_goal_seconds: OneTimerGoalCalculatorConfig::default()
82            .max_event_to_goal_seconds,
83        air_dribble_goal_max_end_to_goal_seconds: AirDribbleGoalCalculatorConfig::default()
84            .max_end_to_goal_seconds,
85        flip_reset_goal_max_event_to_goal_seconds: FlipResetGoalCalculatorConfig::default()
86            .max_event_to_goal_seconds,
87        flip_into_ball_goal_max_touch_to_goal_seconds: FlipIntoBallGoalCalculatorConfig::default()
88            .max_touch_to_goal_seconds,
89        bump_goal_max_event_to_goal_seconds: BumpGoalCalculatorConfig::default()
90            .max_event_to_goal_seconds,
91        demo_goal_max_event_to_goal_seconds: DemoGoalCalculatorConfig::default()
92            .max_event_to_goal_seconds,
93        half_volley_max_bounce_to_touch_seconds: HalfVolleyCalculatorConfig::default()
94            .max_bounce_to_touch_seconds,
95        half_volley_min_ball_speed: HalfVolleyCalculatorConfig::default().min_ball_speed,
96        half_volley_goal_max_touch_to_goal_seconds: HalfVolleyGoalCalculatorConfig::default()
97            .max_touch_to_goal_seconds,
98        half_volley_goal_min_goal_alignment: HalfVolleyGoalCalculatorConfig::default()
99            .min_goal_alignment,
100    }
101}
102
103pub struct StatsTimelineCollector {
104    graph: AnalysisGraph,
105    replay_meta: Option<ReplayMeta>,
106    frames: Vec<ReplayStatsFrame>,
107    frame_input_builder: ReplayFrameInputBuilder,
108    last_replay_meta_player_count: Option<usize>,
109    last_sample_time: Option<f32>,
110    frame_persistence: StatsFramePersistenceController,
111}
112
113impl Default for StatsTimelineCollector {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl StatsTimelineCollector {
120    /// Create the legacy full-snapshot timeline collector.
121    ///
122    /// This evaluates and stores cumulative team/player stat modules for every
123    /// captured frame. Prefer [`StatsTimelineEventCollector`] for compact
124    /// event-backed transfer.
125    pub fn new() -> Self {
126        let graph = build_legacy_timeline_graph();
127        Self {
128            graph,
129            replay_meta: None,
130            frames: Vec::new(),
131            frame_input_builder: ReplayFrameInputBuilder::default(),
132            last_replay_meta_player_count: None,
133            last_sample_time: None,
134            frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
135        }
136    }
137
138    fn timeline_config(&self) -> StatsTimelineConfig {
139        default_stats_timeline_config()
140    }
141
142    fn snapshot_frame(&self) -> SubtrActorResult<ReplayStatsFrame> {
143        self.graph
144            .state::<StatsTimelineFrameState>()
145            .and_then(|state| state.frame.clone())
146            .ok_or_else(|| {
147                SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
148                    "missing StatsTimelineFrame state while building timeline frame".to_owned(),
149                ))
150            })
151    }
152
153    pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
154        let replay_meta = self
155            .replay_meta
156            .clone()
157            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
158        let events = self
159            .graph
160            .state::<StatsTimelineEventsState>()
161            .map(|state| state.events.clone())
162            .unwrap_or_default();
163        Ok(ReplayStatsTimeline {
164            config: self.timeline_config(),
165            replay_meta,
166            events,
167            frames: self.frames,
168        })
169    }
170
171    pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
172        self.frame_persistence = StatsFramePersistenceController::new(resolution);
173        self
174    }
175
176    pub fn get_legacy_replay_stats_timeline(
177        mut self,
178        replay: &boxcars::Replay,
179    ) -> SubtrActorResult<ReplayStatsTimeline> {
180        let mut processor = ReplayProcessor::new(replay)?;
181        processor.process(&mut self)?;
182        self.into_legacy_replay_stats_timeline()
183    }
184
185    pub fn into_legacy_timeline(self) -> ReplayStatsTimeline {
186        self.into_legacy_replay_stats_timeline()
187            .expect("analysis-node timeline collector should build typed stats frames")
188    }
189}
190
191pub struct StatsTimelineEventCollector {
192    graph: AnalysisGraph,
193    replay_meta: Option<ReplayMeta>,
194    frames: Vec<ReplayStatsFrameScaffold>,
195    frame_input_builder: ReplayFrameInputBuilder,
196    last_replay_meta_player_count: Option<usize>,
197    last_sample_time: Option<f32>,
198    frame_persistence: StatsFramePersistenceController,
199}
200
201impl Default for StatsTimelineEventCollector {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207impl StatsTimelineEventCollector {
208    pub fn new() -> Self {
209        Self {
210            graph: build_timeline_event_graph(),
211            replay_meta: None,
212            frames: Vec::new(),
213            frame_input_builder: ReplayFrameInputBuilder::default(),
214            last_replay_meta_player_count: None,
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        let positioning = self.graph.state::<PositioningCalculator>();
294        let positioning_summary = replay_meta
295            .player_order()
296            .map(|player| ReplayStatsPositioningSummary {
297                player_id: player.remote_id.clone(),
298                is_team_0: Self::is_team_zero_player(&replay_meta, player),
299                distance: positioning
300                    .map(|calculator| calculator.player_signal(&player.remote_id))
301                    .unwrap_or_default(),
302            })
303            .collect();
304        let accumulation_tracks = self
305            .graph
306            .state::<BoostCalculator>()
307            .map(|calculator| calculator.accumulation_tracks())
308            .unwrap_or_default();
309        Ok(ReplayStatsTimelineScaffold {
310            config: default_stats_timeline_config(),
311            replay_meta,
312            events,
313            frames: self.frames,
314            positioning_summary,
315            accumulation_tracks,
316        })
317    }
318
319    pub fn get_replay_stats_timeline_scaffold(
320        mut self,
321        replay: &boxcars::Replay,
322    ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
323        let mut processor = ReplayProcessor::new(replay)?;
324        processor.process(&mut self)?;
325        self.into_replay_stats_timeline_scaffold()
326    }
327}
328
329impl Collector for StatsTimelineCollector {
330    fn process_frame(
331        &mut self,
332        processor: &dyn ProcessorView,
333        _frame: &boxcars::Frame,
334        frame_number: usize,
335        current_time: f32,
336    ) -> SubtrActorResult<TimeAdvance> {
337        let player_count = processor.player_count();
338        if self.last_replay_meta_player_count != Some(player_count) {
339            let replay_meta = processor.get_replay_meta()?;
340            self.graph.on_replay_meta(&replay_meta)?;
341            self.replay_meta = Some(replay_meta);
342            self.last_replay_meta_player_count = Some(player_count);
343        }
344
345        let dt = self
346            .last_sample_time
347            .map(|last_time| (current_time - last_time).max(0.0))
348            .unwrap_or(0.0);
349        let frame_input =
350            self.frame_input_builder
351                .timeline(processor, frame_number, current_time, dt);
352        self.graph.evaluate_with_state(&frame_input)?;
353        self.last_sample_time = Some(current_time);
354
355        if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
356            let mut frame = self.snapshot_frame()?;
357            frame.dt = emitted_dt;
358            self.frames.push(frame);
359        }
360
361        Ok(TimeAdvance::NextFrame)
362    }
363
364    fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
365        self.graph.finish()?;
366        let Some(_) = self.replay_meta.as_ref() else {
367            return Ok(());
368        };
369        let Some(_) = self.graph.state::<StatsTimelineFrameState>() else {
370            return Ok(());
371        };
372        let mut final_snapshot = self.snapshot_frame()?;
373        match self
374            .frame_persistence
375            .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
376        {
377            Some(FinalStatsFrameAction::Append { dt }) => {
378                final_snapshot.dt = dt;
379                self.frames.push(final_snapshot);
380            }
381            Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
382                final_snapshot.dt = dt;
383                if let Some(last_frame) = self.frames.last_mut() {
384                    *last_frame = final_snapshot;
385                }
386            }
387            None => {}
388        }
389        Ok(())
390    }
391}
392
393impl Collector for StatsTimelineEventCollector {
394    fn process_frame(
395        &mut self,
396        processor: &dyn ProcessorView,
397        _frame: &boxcars::Frame,
398        frame_number: usize,
399        current_time: f32,
400    ) -> SubtrActorResult<TimeAdvance> {
401        let player_count = processor.player_count();
402        if self.last_replay_meta_player_count != Some(player_count) {
403            let replay_meta = processor.get_replay_meta()?;
404            self.graph.on_replay_meta(&replay_meta)?;
405            self.replay_meta = Some(replay_meta);
406            self.last_replay_meta_player_count = Some(player_count);
407        }
408
409        let dt = self
410            .last_sample_time
411            .map(|last_time| (current_time - last_time).max(0.0))
412            .unwrap_or(0.0);
413        let frame_input =
414            self.frame_input_builder
415                .timeline(processor, frame_number, current_time, dt);
416        self.graph.evaluate_with_state(&frame_input)?;
417        self.last_sample_time = Some(current_time);
418
419        if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
420            let mut frame = self.snapshot_frame_scaffold()?;
421            frame.dt = emitted_dt;
422            self.frames.push(frame);
423        }
424
425        Ok(TimeAdvance::NextFrame)
426    }
427
428    fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
429        self.graph.finish()?;
430        let Some(_) = self.replay_meta.as_ref() else {
431            return Ok(());
432        };
433        let Some(_) = self.graph.state::<FrameInfo>() else {
434            return Ok(());
435        };
436        let mut final_snapshot = self.snapshot_frame_scaffold()?;
437        match self
438            .frame_persistence
439            .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
440        {
441            Some(FinalStatsFrameAction::Append { dt }) => {
442                final_snapshot.dt = dt;
443                self.frames.push(final_snapshot);
444            }
445            Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
446                final_snapshot.dt = dt;
447                if let Some(last_frame) = self.frames.last_mut() {
448                    *last_frame = final_snapshot;
449                }
450            }
451            None => {}
452        }
453        Ok(())
454    }
455}
456
457#[cfg(test)]
458#[path = "collector_tests.rs"]
459mod collector_tests;