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, HashMap};
11
12const ABSENT_PLAYER_MIN_MISSING_SECONDS: f32 = 30.0;
13
14pub fn build_legacy_timeline_graph() -> AnalysisGraph {
15    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
16    graph.push_boxed_node(Box::new(StatsTimelineFrameNode::new()));
17    graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
18    graph
19}
20
21pub fn build_timeline_event_graph() -> AnalysisGraph {
22    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
23    graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
24    graph
25}
26
27pub fn default_stats_timeline_config() -> StatsTimelineConfig {
28    let positioning_defaults = PositioningCalculatorConfig::default();
29    let rotation_defaults = RotationCalculatorConfig::default();
30    let territorial_pressure_defaults = TerritorialPressureCalculatorConfig::default();
31    StatsTimelineConfig {
32        most_back_forward_threshold_y: positioning_defaults.most_back_forward_threshold_y,
33        level_ball_depth_margin: positioning_defaults.level_ball_depth_margin,
34        closest_to_ball_switch_margin: positioning_defaults.closest_to_ball_switch_margin,
35        closest_to_ball_switch_min_seconds: positioning_defaults.closest_to_ball_switch_min_seconds,
36        shadow_defense_max_ball_y: positioning_defaults.shadow_defense_max_ball_y,
37        shadow_defense_min_goal_side_y: positioning_defaults.shadow_defense_min_goal_side_y,
38        shadow_defense_min_gap: positioning_defaults.shadow_defense_min_gap,
39        shadow_defense_max_gap: positioning_defaults.shadow_defense_max_gap,
40        shadow_defense_max_lateral_gap: positioning_defaults.shadow_defense_max_lateral_gap,
41        shadow_defense_min_retreat_speed: positioning_defaults.shadow_defense_min_retreat_speed,
42        shadow_defense_max_speed_delta: positioning_defaults.shadow_defense_max_speed_delta,
43        ball_half_neutral_zone_half_width_y: BallHalfCalculatorConfig::default()
44            .neutral_zone_half_width_y,
45        ball_third_boundary_y: BallThirdCalculatorConfig::default().boundary_y,
46        territorial_pressure_neutral_zone_half_width_y: territorial_pressure_defaults
47            .neutral_zone_half_width_y,
48        territorial_pressure_min_establish_seconds: territorial_pressure_defaults
49            .min_establish_seconds,
50        territorial_pressure_min_establish_third_seconds: territorial_pressure_defaults
51            .min_establish_third_seconds,
52        territorial_pressure_relief_grace_seconds: territorial_pressure_defaults
53            .relief_grace_seconds,
54        territorial_pressure_confirmed_relief_grace_seconds: territorial_pressure_defaults
55            .confirmed_relief_grace_seconds,
56        rotation_role_depth_margin: rotation_defaults.role_depth_margin,
57        rotation_first_man_ambiguity_margin: rotation_defaults.first_man_ambiguity_margin,
58        rotation_first_man_debounce_seconds: rotation_defaults.first_man_debounce_seconds,
59        rotation_first_man_stint_end_grace_seconds: rotation_defaults
60            .first_man_stint_end_grace_seconds,
61        rush_max_start_y: RushCalculatorConfig::default().max_start_y,
62        rush_attack_support_distance_y: RushCalculatorConfig::default().attack_support_distance_y,
63        rush_defender_distance_y: RushCalculatorConfig::default().defender_distance_y,
64        rush_min_possession_retained_seconds: RushCalculatorConfig::default()
65            .min_possession_retained_seconds,
66        aerial_goal_min_ball_z: AerialGoalCalculatorConfig::default().min_ball_z,
67        high_aerial_goal_min_ball_z: HighAerialGoalCalculatorConfig::default().min_ball_z,
68        long_distance_goal_max_attacking_y: LongDistanceGoalCalculatorConfig::default()
69            .max_attacking_y,
70        own_half_goal_max_attacking_y: OwnHalfGoalCalculatorConfig::default().max_attacking_y,
71        empty_net_min_defender_y_margin: EmptyNetGoalCalculatorConfig::default()
72            .min_defender_y_margin,
73        empty_net_min_defender_distance: EmptyNetGoalCalculatorConfig::default()
74            .min_defender_distance,
75        empty_net_max_touch_attacking_y: EmptyNetGoalCalculatorConfig::default()
76            .max_touch_attacking_y,
77        flick_goal_max_event_to_goal_seconds: FlickGoalCalculatorConfig::default()
78            .max_event_to_goal_seconds,
79        ceiling_shot_goal_max_event_to_goal_seconds: CeilingShotGoalCalculatorConfig::default()
80            .max_event_to_goal_seconds,
81        double_tap_goal_max_event_to_goal_seconds: DoubleTapGoalCalculatorConfig::default()
82            .max_event_to_goal_seconds,
83        one_timer_goal_max_event_to_goal_seconds: OneTimerGoalCalculatorConfig::default()
84            .max_event_to_goal_seconds,
85        air_dribble_goal_max_end_to_goal_seconds: AirDribbleGoalCalculatorConfig::default()
86            .max_end_to_goal_seconds,
87        flip_reset_goal_max_event_to_goal_seconds: FlipResetGoalCalculatorConfig::default()
88            .max_event_to_goal_seconds,
89        flip_into_ball_goal_max_touch_to_goal_seconds: FlipIntoBallGoalCalculatorConfig::default()
90            .max_touch_to_goal_seconds,
91        bump_goal_max_event_to_goal_seconds: BumpGoalCalculatorConfig::default()
92            .max_event_to_goal_seconds,
93        demo_goal_max_event_to_goal_seconds: DemoGoalCalculatorConfig::default()
94            .max_event_to_goal_seconds,
95        half_volley_max_bounce_to_touch_seconds: HalfVolleyCalculatorConfig::default()
96            .max_bounce_to_touch_seconds,
97        half_volley_min_ball_speed: HalfVolleyCalculatorConfig::default().min_ball_speed,
98        half_volley_goal_max_touch_to_goal_seconds: HalfVolleyGoalCalculatorConfig::default()
99            .max_touch_to_goal_seconds,
100        half_volley_goal_min_goal_alignment: HalfVolleyGoalCalculatorConfig::default()
101            .min_goal_alignment,
102    }
103}
104
105pub struct StatsTimelineCollector {
106    graph: AnalysisGraph,
107    replay_meta: Option<ReplayMeta>,
108    frames: Vec<ReplayStatsFrame>,
109    frame_input_builder: ReplayFrameInputBuilder,
110    last_replay_meta_player_count: Option<usize>,
111    last_sample_time: Option<f32>,
112    frame_persistence: StatsFramePersistenceController,
113}
114
115impl Default for StatsTimelineCollector {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl StatsTimelineCollector {
122    /// Create the legacy full-snapshot timeline collector.
123    ///
124    /// This evaluates and stores cumulative team/player stat modules for every
125    /// captured frame. Prefer [`StatsTimelineEventCollector`] for compact
126    /// event-backed transfer.
127    pub fn new() -> Self {
128        let graph = build_legacy_timeline_graph();
129        Self {
130            graph,
131            replay_meta: None,
132            frames: Vec::new(),
133            frame_input_builder: ReplayFrameInputBuilder::default(),
134            last_replay_meta_player_count: None,
135            last_sample_time: None,
136            frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
137        }
138    }
139
140    fn timeline_config(&self) -> StatsTimelineConfig {
141        default_stats_timeline_config()
142    }
143
144    fn snapshot_frame(&self) -> SubtrActorResult<ReplayStatsFrame> {
145        self.graph
146            .state::<StatsTimelineFrameState>()
147            .and_then(|state| state.frame.clone())
148            .ok_or_else(|| {
149                SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
150                    "missing StatsTimelineFrame state while building timeline frame".to_owned(),
151                ))
152            })
153    }
154
155    pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
156        let replay_meta = self
157            .replay_meta
158            .clone()
159            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
160        let events = self
161            .graph
162            .state::<StatsTimelineEventsState>()
163            .map(|state| state.events.clone())
164            .unwrap_or_default();
165        Ok(ReplayStatsTimeline {
166            config: self.timeline_config(),
167            replay_meta,
168            events,
169            frames: self.frames,
170        })
171    }
172
173    pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
174        self.frame_persistence = StatsFramePersistenceController::new(resolution);
175        self
176    }
177
178    pub fn get_legacy_replay_stats_timeline(
179        mut self,
180        replay: &boxcars::Replay,
181    ) -> SubtrActorResult<ReplayStatsTimeline> {
182        let mut processor = ReplayProcessor::new(replay)?;
183        processor.process(&mut self)?;
184        self.into_legacy_replay_stats_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    frames: Vec<ReplayStatsFrameScaffold>,
197    frame_input_builder: ReplayFrameInputBuilder,
198    last_replay_meta_player_count: Option<usize>,
199    last_sample_time: Option<f32>,
200    frame_persistence: StatsFramePersistenceController,
201}
202
203impl Default for StatsTimelineEventCollector {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209impl StatsTimelineEventCollector {
210    pub fn new() -> Self {
211        Self {
212            graph: build_timeline_event_graph(),
213            replay_meta: None,
214            frames: Vec::new(),
215            frame_input_builder: ReplayFrameInputBuilder::default(),
216            last_replay_meta_player_count: None,
217            last_sample_time: None,
218            frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
219        }
220    }
221
222    pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
223        self.frame_persistence = StatsFramePersistenceController::new(resolution);
224        self
225    }
226
227    fn replay_meta(&self) -> SubtrActorResult<&ReplayMeta> {
228        self.replay_meta
229            .as_ref()
230            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))
231    }
232
233    fn is_team_zero_player(replay_meta: &ReplayMeta, player: &PlayerInfo) -> bool {
234        replay_meta
235            .team_zero
236            .iter()
237            .any(|team_player| team_player.remote_id == player.remote_id)
238    }
239
240    fn snapshot_frame_scaffold(&self) -> SubtrActorResult<ReplayStatsFrameScaffold> {
241        let replay_meta = self.replay_meta()?;
242        let frame = self.graph.state::<FrameInfo>().ok_or_else(|| {
243            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
244                "missing FrameInfo state while building stats timeline frame scaffold".to_owned(),
245            ))
246        })?;
247        let gameplay = self.graph.state::<GameplayState>().ok_or_else(|| {
248            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
249                "missing GameplayState state while building stats timeline frame scaffold"
250                    .to_owned(),
251            ))
252        })?;
253        let live_play_state = self.graph.state::<LivePlayState>().ok_or_else(|| {
254            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
255                "missing LivePlayState state while building stats timeline frame scaffold"
256                    .to_owned(),
257            ))
258        })?;
259
260        Ok(ReplayStatsFrameScaffold {
261            frame_number: frame.frame_number,
262            time: frame.time,
263            dt: frame.dt,
264            seconds_remaining: frame.seconds_remaining,
265            game_state: gameplay.game_state,
266            ball_has_been_hit: gameplay.ball_has_been_hit,
267            kickoff_countdown_time: gameplay.kickoff_countdown_time,
268            gameplay_phase: live_play_state.gameplay_phase,
269            is_live_play: live_play_state.is_live_play,
270            team_zero: BTreeMap::new(),
271            team_one: BTreeMap::new(),
272            players: replay_meta
273                .player_order()
274                .map(|player| ReplayStatsPlayerIdentity {
275                    player_id: player.remote_id.clone(),
276                    name: player.name.clone(),
277                    is_team_0: Self::is_team_zero_player(replay_meta, player),
278                })
279                .collect(),
280        })
281    }
282
283    pub fn into_replay_stats_timeline_scaffold(
284        self,
285    ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
286        let replay_meta = self
287            .replay_meta
288            .clone()
289            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
290        let events = self
291            .graph
292            .state::<StatsTimelineEventsState>()
293            .map(|state| state.events.clone())
294            .unwrap_or_default();
295        let positioning = self.graph.state::<PositioningCalculator>();
296        let positioning_summary = replay_meta
297            .player_order()
298            .map(|player| ReplayStatsPositioningSummary {
299                player_id: player.remote_id.clone(),
300                is_team_0: Self::is_team_zero_player(&replay_meta, player),
301                distance: positioning
302                    .map(|calculator| calculator.player_signal(&player.remote_id))
303                    .unwrap_or_default(),
304            })
305            .collect();
306        let accumulation_tracks = self
307            .graph
308            .state::<BoostCalculator>()
309            .map(|calculator| calculator.accumulation_tracks())
310            .unwrap_or_default();
311        let activity_summary = replay_activity_summary(
312            &replay_meta,
313            &self.frames,
314            &events,
315            ABSENT_PLAYER_MIN_MISSING_SECONDS,
316        );
317        Ok(ReplayStatsTimelineScaffold {
318            config: default_stats_timeline_config(),
319            replay_meta,
320            events,
321            frames: self.frames,
322            activity_summary,
323            positioning_summary,
324            accumulation_tracks,
325        })
326    }
327
328    pub fn get_replay_stats_timeline_scaffold(
329        mut self,
330        replay: &boxcars::Replay,
331    ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
332        let mut processor = ReplayProcessor::new(replay)?;
333        processor.process(&mut self)?;
334        self.into_replay_stats_timeline_scaffold()
335    }
336}
337
338fn replay_activity_summary(
339    replay_meta: &ReplayMeta,
340    frames: &[ReplayStatsFrameScaffold],
341    events: &ReplayStatsTimelineEvents,
342    absent_player_min_missing_seconds: f32,
343) -> ReplayStatsActivitySummary {
344    let live_play_seconds = frames
345        .iter()
346        .filter(|frame| frame.is_live_play)
347        .map(|frame| frame.dt)
348        .filter(|dt| dt.is_finite() && *dt > 0.0)
349        .sum::<f32>();
350    let mut active_seconds_by_player = HashMap::<PlayerId, f32>::new();
351    for event in &events.events {
352        let EventPayload::PlayerActivity(activity) = &event.payload else {
353            continue;
354        };
355        active_seconds_by_player
356            .entry(activity.player.clone())
357            .and_modify(|seconds| *seconds += activity.duration.max(0.0))
358            .or_insert_with(|| activity.duration.max(0.0));
359    }
360
361    let players = replay_meta
362        .player_order()
363        .map(|player| {
364            let active_seconds = active_seconds_by_player
365                .get(&player.remote_id)
366                .copied()
367                .unwrap_or_default();
368            let missing_live_play_seconds = (live_play_seconds - active_seconds).max(0.0);
369            ReplayStatsPlayerActivitySummary {
370                player_id: player.remote_id.clone(),
371                name: player.name.clone(),
372                is_team_0: StatsTimelineEventCollector::is_team_zero_player(replay_meta, player),
373                active_seconds,
374                missing_live_play_seconds,
375                absent_for_extended_period: missing_live_play_seconds
376                    >= absent_player_min_missing_seconds,
377            }
378        })
379        .collect::<Vec<_>>();
380    let has_absent_player = players
381        .iter()
382        .any(|player| player.absent_for_extended_period);
383
384    ReplayStatsActivitySummary {
385        live_play_seconds,
386        absent_player_min_missing_seconds,
387        has_absent_player,
388        players,
389    }
390}
391
392impl Collector for StatsTimelineCollector {
393    fn process_frame(
394        &mut self,
395        processor: &dyn ProcessorView,
396        _frame: &boxcars::Frame,
397        frame_number: usize,
398        current_time: f32,
399    ) -> SubtrActorResult<TimeAdvance> {
400        let player_count = processor.player_count();
401        if self.last_replay_meta_player_count != Some(player_count) {
402            let replay_meta = processor.get_replay_meta()?;
403            self.graph.on_replay_meta(&replay_meta)?;
404            self.replay_meta = Some(replay_meta);
405            self.last_replay_meta_player_count = Some(player_count);
406        }
407
408        let dt = self
409            .last_sample_time
410            .map(|last_time| (current_time - last_time).max(0.0))
411            .unwrap_or(0.0);
412        let frame_input =
413            self.frame_input_builder
414                .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()?;
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::<StatsTimelineFrameState>() else {
433            return Ok(());
434        };
435        let mut final_snapshot = self.snapshot_frame()?;
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
456impl Collector for StatsTimelineEventCollector {
457    fn process_frame(
458        &mut self,
459        processor: &dyn ProcessorView,
460        _frame: &boxcars::Frame,
461        frame_number: usize,
462        current_time: f32,
463    ) -> SubtrActorResult<TimeAdvance> {
464        let player_count = processor.player_count();
465        if self.last_replay_meta_player_count != Some(player_count) {
466            let replay_meta = processor.get_replay_meta()?;
467            self.graph.on_replay_meta(&replay_meta)?;
468            self.replay_meta = Some(replay_meta);
469            self.last_replay_meta_player_count = Some(player_count);
470        }
471
472        let dt = self
473            .last_sample_time
474            .map(|last_time| (current_time - last_time).max(0.0))
475            .unwrap_or(0.0);
476        let frame_input =
477            self.frame_input_builder
478                .timeline(processor, frame_number, current_time, dt);
479        self.graph.evaluate_with_state(&frame_input)?;
480        self.last_sample_time = Some(current_time);
481
482        if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
483            let mut frame = self.snapshot_frame_scaffold()?;
484            frame.dt = emitted_dt;
485            self.frames.push(frame);
486        }
487
488        Ok(TimeAdvance::NextFrame)
489    }
490
491    fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
492        self.graph.finish()?;
493        let Some(_) = self.replay_meta.as_ref() else {
494            return Ok(());
495        };
496        let Some(_) = self.graph.state::<FrameInfo>() else {
497            return Ok(());
498        };
499        let mut final_snapshot = self.snapshot_frame_scaffold()?;
500        match self
501            .frame_persistence
502            .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
503        {
504            Some(FinalStatsFrameAction::Append { dt }) => {
505                final_snapshot.dt = dt;
506                self.frames.push(final_snapshot);
507            }
508            Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
509                final_snapshot.dt = dt;
510                if let Some(last_frame) = self.frames.last_mut() {
511                    *last_frame = final_snapshot;
512                }
513            }
514            None => {}
515        }
516        Ok(())
517    }
518}
519
520#[cfg(test)]
521#[path = "collector_tests.rs"]
522mod collector_tests;