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