Skip to main content

subtr_actor/collector/stats/
collector.rs

1use std::collections::HashSet;
2use std::marker::PhantomData;
3
4use serde_json::{Map, Value};
5
6use crate::collector::frame_resolution::{
7    FinalStatsFrameAction, StatsFramePersistenceController, StatsFrameResolution,
8};
9use crate::stats::analysis_graph::{AnalysisGraph, graph_with_builtin_analysis_nodes};
10use crate::stats::calculators::ReplayFrameInputBuilder;
11use crate::*;
12
13use super::builtins::{
14    builtin_module_json, builtin_snapshot_config_json, builtin_snapshot_frame_json,
15    builtin_stats_module_names,
16};
17use super::playback::{
18    CapturedStatsData, CapturedStatsFrame, StatsSnapshotData, StatsSnapshotFrame,
19};
20use super::types::{CollectedStats, CollectedStatsModule, serialize_to_json_value};
21
22#[derive(Default)]
23enum SampleMode {
24    #[default]
25    Aggregate,
26    Timeline,
27}
28
29/// Map a stats-module name to the analysis node that provides its state.
30///
31/// Most modules share their providing node's name. The exceptions are modules
32/// that are a second view onto another node's calculator: `core` is served by
33/// the `match_stats` node, and `flip_reset` is served by `dodge_reset`. This is
34/// the only place that translation lives — there is no global node-name alias
35/// table.
36fn stats_module_analysis_node_name(module_name: &str) -> &str {
37    match module_name {
38        "core" => "match_stats",
39        "flip_reset" => "dodge_reset",
40        other => other,
41    }
42}
43
44struct BuiltinModuleSelection {
45    module_names: Vec<&'static str>,
46}
47
48impl BuiltinModuleSelection {
49    fn all() -> Self {
50        Self {
51            module_names: builtin_stats_module_names().to_vec(),
52        }
53    }
54
55    fn from_names<I, S>(module_names: I) -> SubtrActorResult<Self>
56    where
57        I: IntoIterator<Item = S>,
58        S: AsRef<str>,
59    {
60        let mut selected = Vec::new();
61        let mut seen = HashSet::new();
62        for module_name in module_names {
63            let module_name = module_name.as_ref();
64            let resolved_name = builtin_stats_module_names()
65                .iter()
66                .copied()
67                .find(|candidate| *candidate == module_name)
68                .ok_or_else(|| {
69                    SubtrActorError::new(SubtrActorErrorVariant::UnknownStatsModuleName(
70                        module_name.to_owned(),
71                    ))
72                })?;
73            if seen.insert(resolved_name) {
74                selected.push(resolved_name);
75            }
76        }
77        Ok(Self {
78            module_names: selected,
79        })
80    }
81
82    fn graph(&self) -> SubtrActorResult<AnalysisGraph> {
83        if self.module_names == builtin_stats_module_names() {
84            return Ok(build_legacy_timeline_graph());
85        }
86        let mut node_names: Vec<&str> = self
87            .module_names
88            .iter()
89            .map(|module_name| stats_module_analysis_node_name(module_name))
90            .collect();
91        node_names.push("stats_projection");
92        graph_with_builtin_analysis_nodes(node_names)
93    }
94
95    fn collected_modules(
96        &self,
97        graph: &AnalysisGraph,
98    ) -> SubtrActorResult<Vec<CollectedStatsModule>> {
99        self.module_names
100            .iter()
101            .copied()
102            .map(|module_name| {
103                Ok(CollectedStatsModule {
104                    name: module_name,
105                    value: builtin_module_json(module_name, graph)?,
106                })
107            })
108            .collect()
109    }
110
111    fn modules_json(&self, graph: &AnalysisGraph) -> SubtrActorResult<Map<String, Value>> {
112        let mut modules = Map::new();
113        for module_name in self.module_names.iter().copied() {
114            modules.insert(
115                module_name.to_owned(),
116                builtin_module_json(module_name, graph)?,
117            );
118        }
119        Ok(modules)
120    }
121
122    fn frame_modules_json(
123        &self,
124        graph: &AnalysisGraph,
125        replay_meta: &ReplayMeta,
126    ) -> SubtrActorResult<Map<String, Value>> {
127        let mut modules = Map::new();
128        for module_name in self.module_names.iter().copied() {
129            if let Some(snapshot) = builtin_snapshot_frame_json(module_name, graph, replay_meta)? {
130                modules.insert(module_name.to_owned(), snapshot);
131            }
132            if module_name == "ball_carry" {
133                if let Some(snapshot) =
134                    builtin_snapshot_frame_json("air_dribble", graph, replay_meta)?
135                {
136                    modules.insert("air_dribble".to_owned(), snapshot);
137                }
138            }
139        }
140        Ok(modules)
141    }
142
143    fn snapshot_config_json(&self, graph: &AnalysisGraph) -> SubtrActorResult<Map<String, Value>> {
144        let mut config = Map::new();
145        for module_name in self.module_names.iter().copied() {
146            if let Some(module_config) = builtin_snapshot_config_json(module_name, graph)? {
147                config.insert(module_name.to_owned(), module_config);
148            }
149        }
150        Ok(config)
151    }
152
153    fn snapshot_frame(
154        &self,
155        graph: &AnalysisGraph,
156        replay_meta: &ReplayMeta,
157    ) -> SubtrActorResult<StatsSnapshotFrame> {
158        let frame = graph.state::<FrameInfo>().ok_or_else(|| {
159            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
160                "missing FrameInfo state while snapshotting stats frame".to_owned(),
161            ))
162        })?;
163        let gameplay = graph.state::<GameplayState>().ok_or_else(|| {
164            SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
165                "missing GameplayState state while snapshotting stats frame".to_owned(),
166            ))
167        })?;
168        let live_play_state = graph.state::<LivePlayState>().cloned().unwrap_or_default();
169        Ok(StatsSnapshotFrame {
170            frame_number: frame.frame_number,
171            time: frame.time,
172            dt: frame.dt,
173            seconds_remaining: frame.seconds_remaining,
174            game_state: gameplay.game_state,
175            ball_has_been_hit: gameplay.ball_has_been_hit,
176            kickoff_countdown_time: gameplay.kickoff_countdown_time,
177            gameplay_phase: live_play_state.gameplay_phase,
178            is_live_play: live_play_state.is_live_play,
179            modules: self.frame_modules_json(graph, replay_meta)?,
180        })
181    }
182}
183
184pub fn builtin_stats_graph_snapshot_json(
185    graph: &AnalysisGraph,
186    replay_meta: Option<&ReplayMeta>,
187) -> SubtrActorResult<Value> {
188    let modules = BuiltinModuleSelection::all();
189    let frame = if let Some(replay_meta) = replay_meta {
190        if graph.state::<FrameInfo>().is_some() && graph.state::<GameplayState>().is_some() {
191            serialize_to_json_value(&modules.snapshot_frame(graph, replay_meta)?)?
192        } else {
193            Value::Null
194        }
195    } else {
196        Value::Null
197    };
198
199    let mut payload = Map::new();
200    payload.insert(
201        "module_names".to_owned(),
202        serialize_to_json_value(&modules.module_names)?,
203    );
204    payload.insert(
205        "config".to_owned(),
206        Value::Object(modules.snapshot_config_json(graph)?),
207    );
208    payload.insert(
209        "modules".to_owned(),
210        Value::Object(modules.modules_json(graph)?),
211    );
212    payload.insert("frame".to_owned(), frame);
213    Ok(Value::Object(payload))
214}
215
216pub trait FrameTransform {
217    type Output;
218
219    fn transform(
220        &mut self,
221        replay_meta: &ReplayMeta,
222        frame: StatsSnapshotFrame,
223    ) -> SubtrActorResult<Self::Output>;
224}
225
226impl<F, T> FrameTransform for F
227where
228    F: FnMut(&ReplayMeta, StatsSnapshotFrame) -> SubtrActorResult<T>,
229{
230    type Output = T;
231
232    fn transform(
233        &mut self,
234        replay_meta: &ReplayMeta,
235        frame: StatsSnapshotFrame,
236    ) -> SubtrActorResult<Self::Output> {
237        self(replay_meta, frame)
238    }
239}
240
241#[derive(Default, Clone, Copy)]
242pub struct IdentityFrameTransform;
243
244impl FrameTransform for IdentityFrameTransform {
245    type Output = StatsSnapshotFrame;
246
247    fn transform(
248        &mut self,
249        _replay_meta: &ReplayMeta,
250        frame: StatsSnapshotFrame,
251    ) -> SubtrActorResult<Self::Output> {
252        Ok(frame)
253    }
254}
255
256pub struct ModuleFrameTransform<F> {
257    transform: F,
258}
259
260impl<F> ModuleFrameTransform<F> {
261    fn new(transform: F) -> Self {
262        Self { transform }
263    }
264}
265
266impl<F, Modules> FrameTransform for ModuleFrameTransform<F>
267where
268    F: FnMut(Map<String, Value>) -> SubtrActorResult<Modules>,
269{
270    type Output = CapturedStatsFrame<Modules>;
271
272    fn transform(
273        &mut self,
274        _replay_meta: &ReplayMeta,
275        frame: StatsSnapshotFrame,
276    ) -> SubtrActorResult<Self::Output> {
277        frame.map_modules(&mut self.transform)
278    }
279}
280
281struct ReplayStatsFrameTransform;
282
283impl FrameTransform for ReplayStatsFrameTransform {
284    type Output = ReplayStatsFrame;
285
286    fn transform(
287        &mut self,
288        replay_meta: &ReplayMeta,
289        frame: StatsSnapshotFrame,
290    ) -> SubtrActorResult<Self::Output> {
291        CapturedStatsData::<StatsSnapshotFrame> {
292            replay_meta: replay_meta.clone(),
293            config: Map::new(),
294            modules: Map::new(),
295            frames: Vec::new(),
296        }
297        .replay_stats_frame(&frame)
298    }
299}
300
301pub struct StatsCollector<T = StatsSnapshotFrame, F = IdentityFrameTransform> {
302    modules: BuiltinModuleSelection,
303    graph: AnalysisGraph,
304    replay_meta: Option<ReplayMeta>,
305    frame_transform: F,
306    captured_frames: Option<Vec<T>>,
307    sample_mode: SampleMode,
308    frame_input_builder: ReplayFrameInputBuilder,
309    last_replay_meta_player_count: Option<usize>,
310    last_sample_time: Option<f32>,
311    frame_persistence: StatsFramePersistenceController,
312    _marker: PhantomData<T>,
313}
314
315impl Default for StatsCollector<StatsSnapshotFrame, IdentityFrameTransform> {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321impl StatsCollector<StatsSnapshotFrame, IdentityFrameTransform> {
322    pub fn new() -> Self {
323        Self::with_selection_and_frame_transform(
324            BuiltinModuleSelection::all(),
325            IdentityFrameTransform,
326        )
327        .expect("builtin stats modules should resolve without conflicts")
328    }
329
330    pub fn only_modules<I>(modules: I) -> Self
331    where
332        I: IntoIterator,
333        I::Item: AsRef<str>,
334    {
335        Self::try_only_modules(modules).expect("builtin stats module names should be valid")
336    }
337
338    pub fn try_only_modules<I>(modules: I) -> SubtrActorResult<Self>
339    where
340        I: IntoIterator,
341        I::Item: AsRef<str>,
342    {
343        Self::with_builtin_module_names(modules)
344    }
345
346    pub fn with_builtin_module_names<I, S>(module_names: I) -> SubtrActorResult<Self>
347    where
348        I: IntoIterator<Item = S>,
349        S: AsRef<str>,
350    {
351        Self::with_selection_and_frame_transform(
352            BuiltinModuleSelection::from_names(module_names)?,
353            IdentityFrameTransform,
354        )
355    }
356
357    pub fn get_snapshot_data(self, replay: &boxcars::Replay) -> SubtrActorResult<StatsSnapshotData>
358    where
359        IdentityFrameTransform: FrameTransform<Output = StatsSnapshotFrame>,
360    {
361        self.capture_frames().get_captured_data(replay)
362    }
363
364    /// Collect the legacy full per-frame stats timeline as JSON.
365    ///
366    /// This serializes cumulative team/player partial sums on every captured
367    /// frame. Prefer `StatsTimelineEventCollector` for compact event-backed
368    /// timeline transfer.
369    pub fn get_legacy_stats_timeline_value(
370        self,
371        replay: &boxcars::Replay,
372    ) -> SubtrActorResult<Value> {
373        serialize_to_json_value(&self.get_legacy_replay_stats_timeline(replay)?)
374    }
375
376    /// Collect the legacy full per-frame stats timeline.
377    ///
378    /// This preserves the pre-event-transfer snapshot shape for compatibility
379    /// and parity checks.
380    pub fn get_legacy_replay_stats_timeline(
381        self,
382        replay: &boxcars::Replay,
383    ) -> SubtrActorResult<ReplayStatsTimeline> {
384        self.with_frame_transform(ReplayStatsFrameTransform)
385            .capture_frames()
386            .get_captured_data(replay)?
387            .into_legacy_replay_stats_timeline()
388    }
389
390    #[deprecated(
391        note = "use get_legacy_stats_timeline_value for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
392    )]
393    pub fn get_stats_timeline_value(self, replay: &boxcars::Replay) -> SubtrActorResult<Value> {
394        self.get_legacy_stats_timeline_value(replay)
395    }
396
397    #[deprecated(
398        note = "use get_legacy_stats_timeline_value for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
399    )]
400    pub fn get_replay_stats_timeline(
401        self,
402        replay: &boxcars::Replay,
403    ) -> SubtrActorResult<ReplayStatsTimeline> {
404        self.get_legacy_replay_stats_timeline(replay)
405    }
406
407    pub fn into_snapshot_data(self) -> SubtrActorResult<StatsSnapshotData> {
408        self.into_captured_data()
409    }
410
411    pub fn into_legacy_stats_timeline_value(self) -> SubtrActorResult<Value> {
412        self.into_snapshot_data()?.to_legacy_stats_timeline_value()
413    }
414
415    pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
416        self.into_snapshot_data()?
417            .into_legacy_replay_stats_timeline()
418    }
419
420    #[deprecated(
421        note = "use into_legacy_stats_timeline_value for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
422    )]
423    pub fn into_stats_timeline_value(self) -> SubtrActorResult<Value> {
424        self.into_legacy_stats_timeline_value()
425    }
426
427    #[deprecated(
428        note = "use into_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
429    )]
430    pub fn into_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
431        self.into_legacy_replay_stats_timeline()
432    }
433}
434
435impl<T, F> StatsCollector<T, F> {
436    fn with_selection_and_frame_transform(
437        modules: BuiltinModuleSelection,
438        frame_transform: F,
439    ) -> SubtrActorResult<Self> {
440        Ok(Self {
441            graph: modules.graph()?,
442            modules,
443            replay_meta: None,
444            frame_transform,
445            captured_frames: None,
446            sample_mode: SampleMode::Aggregate,
447            frame_input_builder: ReplayFrameInputBuilder::default(),
448            last_replay_meta_player_count: None,
449            last_sample_time: None,
450            frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
451            _marker: PhantomData,
452        })
453    }
454
455    pub fn capture_frames(mut self) -> Self {
456        self.captured_frames = Some(Vec::new());
457        self.sample_mode = SampleMode::Timeline;
458        self
459    }
460
461    pub fn with_frame_transform<U, G>(self, frame_transform: G) -> StatsCollector<U, G> {
462        let StatsCollector {
463            modules,
464            graph,
465            replay_meta,
466            captured_frames,
467            sample_mode,
468            frame_input_builder,
469            last_replay_meta_player_count,
470            last_sample_time,
471            frame_persistence,
472            ..
473        } = self;
474        StatsCollector {
475            modules,
476            graph,
477            replay_meta,
478            frame_transform,
479            captured_frames: captured_frames.map(|_| Vec::new()),
480            sample_mode,
481            frame_input_builder,
482            last_replay_meta_player_count,
483            last_sample_time,
484            frame_persistence,
485            _marker: PhantomData,
486        }
487    }
488
489    pub fn with_module_transform<Modules, G>(
490        self,
491        transform: G,
492    ) -> StatsCollector<CapturedStatsFrame<Modules>, ModuleFrameTransform<G>>
493    where
494        G: FnMut(Map<String, Value>) -> SubtrActorResult<Modules>,
495    {
496        self.with_frame_transform(ModuleFrameTransform::new(transform))
497    }
498
499    pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
500        self.frame_persistence = StatsFramePersistenceController::new(resolution);
501        self
502    }
503
504    pub fn get_stats(mut self, replay: &boxcars::Replay) -> SubtrActorResult<CollectedStats>
505    where
506        F: FrameTransform<Output = T>,
507    {
508        self.sample_mode = SampleMode::Aggregate;
509        let mut processor = ReplayProcessor::new(replay)?;
510        processor.process(&mut self)?;
511        if self.replay_meta.is_none() {
512            self.replay_meta = Some(processor.get_replay_meta()?);
513        }
514        self.into_stats()
515    }
516
517    pub fn get_captured_data(
518        mut self,
519        replay: &boxcars::Replay,
520    ) -> SubtrActorResult<CapturedStatsData<T>>
521    where
522        F: FrameTransform<Output = T>,
523    {
524        let mut processor = ReplayProcessor::new(replay)?;
525        processor.process(&mut self)?;
526        if self.replay_meta.is_none() {
527            self.replay_meta = Some(processor.get_replay_meta()?);
528        }
529        self.into_captured_data()
530    }
531
532    pub fn into_stats(self) -> SubtrActorResult<CollectedStats> {
533        let replay_meta = self
534            .replay_meta
535            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
536        Ok(CollectedStats {
537            replay_meta,
538            modules: self.modules.collected_modules(&self.graph)?,
539        })
540    }
541
542    pub fn into_captured_data(self) -> SubtrActorResult<CapturedStatsData<T>> {
543        let replay_meta = self
544            .replay_meta
545            .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
546        Ok(CapturedStatsData {
547            replay_meta: replay_meta.clone(),
548            config: self.modules.snapshot_config_json(&self.graph)?,
549            modules: self.modules.modules_json(&self.graph)?,
550            frames: self.captured_frames.unwrap_or_default(),
551        })
552    }
553
554    fn capture_frame_snapshot(
555        &mut self,
556        replay_meta: &ReplayMeta,
557        frame: StatsSnapshotFrame,
558    ) -> SubtrActorResult<()>
559    where
560        F: FrameTransform<Output = T>,
561    {
562        if let Some(frames) = &mut self.captured_frames {
563            frames.push(self.frame_transform.transform(replay_meta, frame)?);
564        }
565        Ok(())
566    }
567
568    fn replace_last_frame_snapshot(
569        &mut self,
570        replay_meta: &ReplayMeta,
571        frame: StatsSnapshotFrame,
572    ) -> SubtrActorResult<()>
573    where
574        F: FrameTransform<Output = T>,
575    {
576        if let Some(frames) = &mut self.captured_frames {
577            if let Some(last_frame) = frames.last_mut() {
578                *last_frame = self.frame_transform.transform(replay_meta, frame)?;
579            }
580        }
581        Ok(())
582    }
583
584    fn refresh_replay_meta(&mut self, processor: &dyn ProcessorView) -> SubtrActorResult<()> {
585        let player_count = processor.player_count();
586        if self.last_replay_meta_player_count == Some(player_count) {
587            return Ok(());
588        }
589
590        let replay_meta = processor.get_replay_meta()?;
591        self.graph.on_replay_meta(&replay_meta)?;
592        self.replay_meta = Some(replay_meta);
593        self.last_replay_meta_player_count = Some(player_count);
594        Ok(())
595    }
596}
597
598impl<T, F> Collector for StatsCollector<T, F>
599where
600    F: FrameTransform<Output = T>,
601{
602    fn process_frame(
603        &mut self,
604        processor: &dyn ProcessorView,
605        _frame: &boxcars::Frame,
606        frame_number: usize,
607        current_time: f32,
608    ) -> SubtrActorResult<TimeAdvance> {
609        self.refresh_replay_meta(processor)?;
610
611        let dt = self
612            .last_sample_time
613            .map(|last_time| (current_time - last_time).max(0.0))
614            .unwrap_or(0.0);
615        let frame_input = match self.sample_mode {
616            SampleMode::Aggregate => {
617                self.frame_input_builder
618                    .aggregate(processor, frame_number, current_time, dt)
619            }
620            SampleMode::Timeline => {
621                self.frame_input_builder
622                    .timeline(processor, frame_number, current_time, dt)
623            }
624        };
625        self.graph.evaluate_with_state(&frame_input)?;
626
627        if self.captured_frames.is_some() {
628            let replay_meta = self
629                .replay_meta
630                .as_ref()
631                .expect("replay metadata should be initialized before snapshotting")
632                .clone();
633            if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
634                let mut frame = self.modules.snapshot_frame(&self.graph, &replay_meta)?;
635                frame.dt = emitted_dt;
636                self.capture_frame_snapshot(&replay_meta, frame)?;
637            }
638        }
639        self.last_sample_time = Some(current_time);
640
641        Ok(TimeAdvance::NextFrame)
642    }
643
644    fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
645        self.graph.finish()?;
646        let Some(replay_meta) = self.replay_meta.as_ref().cloned() else {
647            return Ok(());
648        };
649        let Some(_) = self.graph.state::<FrameInfo>() else {
650            return Ok(());
651        };
652        let mut final_snapshot = self.modules.snapshot_frame(&self.graph, &replay_meta)?;
653        if self.captured_frames.is_some() {
654            match self
655                .frame_persistence
656                .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
657            {
658                Some(FinalStatsFrameAction::Append { dt }) => {
659                    final_snapshot.dt = dt;
660                    self.capture_frame_snapshot(&replay_meta, final_snapshot)?;
661                }
662                Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
663                    final_snapshot.dt = dt;
664                    self.replace_last_frame_snapshot(&replay_meta, final_snapshot)?;
665                }
666                None => {}
667            }
668        }
669        Ok(())
670    }
671}
672
673#[cfg(test)]
674#[path = "collector_tests.rs"]
675mod tests;