Skip to main content

subtr_actor/collector/
stats_timeline.rs

1use serde::Serialize;
2
3use crate::*;
4
5#[derive(Debug, Clone, PartialEq, Serialize)]
6pub struct StatsTimelineConfig {
7    pub most_back_forward_threshold_y: f32,
8    pub pressure_neutral_zone_half_width_y: f32,
9}
10
11#[derive(Debug, Clone, PartialEq, Serialize)]
12pub struct ReplayStatsTimeline {
13    pub config: StatsTimelineConfig,
14    pub replay_meta: ReplayMeta,
15    pub timeline_events: Vec<TimelineEvent>,
16    pub fifty_fifty_events: Vec<FiftyFiftyEvent>,
17    pub frames: Vec<ReplayStatsFrame>,
18}
19
20impl ReplayStatsTimeline {
21    pub fn frame_by_number(&self, frame_number: usize) -> Option<&ReplayStatsFrame> {
22        self.frames
23            .iter()
24            .find(|frame| frame.frame_number == frame_number)
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize)]
29pub struct DynamicReplayStatsTimeline {
30    pub config: StatsTimelineConfig,
31    pub replay_meta: ReplayMeta,
32    pub timeline_events: Vec<TimelineEvent>,
33    pub fifty_fifty_events: Vec<FiftyFiftyEvent>,
34    pub frames: Vec<DynamicReplayStatsFrame>,
35}
36
37impl DynamicReplayStatsTimeline {
38    pub fn frame_by_number(&self, frame_number: usize) -> Option<&DynamicReplayStatsFrame> {
39        self.frames
40            .iter()
41            .find(|frame| frame.frame_number == frame_number)
42    }
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct ReplayStatsFrame {
47    pub frame_number: usize,
48    pub time: f32,
49    pub dt: f32,
50    pub seconds_remaining: Option<i32>,
51    pub game_state: Option<i32>,
52    pub is_live_play: bool,
53    pub fifty_fifty: FiftyFiftyStats,
54    pub possession: PossessionStats,
55    pub pressure: PressureStats,
56    pub rush: RushStats,
57    pub team_zero: TeamStatsSnapshot,
58    pub team_one: TeamStatsSnapshot,
59    pub players: Vec<PlayerStatsSnapshot>,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
63pub struct DynamicReplayStatsFrame {
64    pub frame_number: usize,
65    pub time: f32,
66    pub dt: f32,
67    pub seconds_remaining: Option<i32>,
68    pub game_state: Option<i32>,
69    pub is_live_play: bool,
70    pub fifty_fifty: Vec<ExportedStat>,
71    pub possession: Vec<ExportedStat>,
72    pub pressure: Vec<ExportedStat>,
73    pub rush: Vec<ExportedStat>,
74    pub team_zero: DynamicTeamStatsSnapshot,
75    pub team_one: DynamicTeamStatsSnapshot,
76    pub players: Vec<DynamicPlayerStatsSnapshot>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize)]
80pub struct TeamStatsSnapshot {
81    pub core: CoreTeamStats,
82    pub ball_carry: BallCarryStats,
83    pub boost: BoostStats,
84    pub movement: MovementStats,
85    pub powerslide: PowerslideStats,
86    pub demo: DemoTeamStats,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize)]
90pub struct DynamicTeamStatsSnapshot {
91    pub stats: Vec<ExportedStat>,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize)]
95pub struct PlayerStatsSnapshot {
96    pub player_id: PlayerId,
97    pub name: String,
98    pub is_team_0: bool,
99    pub core: CorePlayerStats,
100    pub fifty_fifty: FiftyFiftyPlayerStats,
101    pub touch: TouchStats,
102    pub dodge_reset: DodgeResetStats,
103    pub ball_carry: BallCarryStats,
104    pub boost: BoostStats,
105    pub movement: MovementStats,
106    pub positioning: PositioningStats,
107    pub powerslide: PowerslideStats,
108    pub demo: DemoPlayerStats,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize)]
112pub struct DynamicPlayerStatsSnapshot {
113    pub player_id: PlayerId,
114    pub name: String,
115    pub is_team_0: bool,
116    pub stats: Vec<ExportedStat>,
117}
118
119impl StatFieldProvider for TeamStatsSnapshot {
120    fn visit_stat_fields(&self, visitor: &mut dyn FnMut(ExportedStat)) {
121        self.core.visit_stat_fields(visitor);
122        self.ball_carry.visit_stat_fields(visitor);
123        self.boost.visit_stat_fields(visitor);
124        self.movement.visit_stat_fields(visitor);
125        self.powerslide.visit_stat_fields(visitor);
126        self.demo.visit_stat_fields(visitor);
127    }
128}
129
130impl StatFieldProvider for PlayerStatsSnapshot {
131    fn visit_stat_fields(&self, visitor: &mut dyn FnMut(ExportedStat)) {
132        self.core.visit_stat_fields(visitor);
133        self.fifty_fifty.visit_stat_fields(visitor);
134        self.touch.visit_stat_fields(visitor);
135        self.dodge_reset.visit_stat_fields(visitor);
136        self.ball_carry.visit_stat_fields(visitor);
137        self.boost.visit_stat_fields(visitor);
138        self.movement.visit_stat_fields(visitor);
139        self.positioning.visit_stat_fields(visitor);
140        self.powerslide.visit_stat_fields(visitor);
141        self.demo.visit_stat_fields(visitor);
142    }
143}
144
145impl ReplayStatsFrame {
146    pub fn into_dynamic(self) -> DynamicReplayStatsFrame {
147        DynamicReplayStatsFrame {
148            frame_number: self.frame_number,
149            time: self.time,
150            dt: self.dt,
151            seconds_remaining: self.seconds_remaining,
152            game_state: self.game_state,
153            is_live_play: self.is_live_play,
154            fifty_fifty: self.fifty_fifty.stat_fields(),
155            possession: self.possession.stat_fields(),
156            pressure: self.pressure.stat_fields(),
157            rush: self.rush.stat_fields(),
158            team_zero: DynamicTeamStatsSnapshot {
159                stats: self.team_zero.stat_fields(),
160            },
161            team_one: DynamicTeamStatsSnapshot {
162                stats: self.team_one.stat_fields(),
163            },
164            players: self
165                .players
166                .into_iter()
167                .map(|player| {
168                    let stats = player.stat_fields();
169                    DynamicPlayerStatsSnapshot {
170                        player_id: player.player_id,
171                        name: player.name,
172                        is_team_0: player.is_team_0,
173                        stats,
174                    }
175                })
176                .collect(),
177        }
178    }
179}
180
181#[derive(Debug, Clone, Default)]
182struct StatsTimelineReducers {
183    fifty_fifty: FiftyFiftyReducer,
184    possession: PossessionReducer,
185    pressure: PressureReducer,
186    rush: RushReducer,
187    match_stats: MatchStatsReducer,
188    touch: TouchReducer,
189    ball_carry: BallCarryReducer,
190    boost: BoostReducer,
191    movement: MovementReducer,
192    positioning: PositioningReducer,
193    powerslide: PowerslideReducer,
194    demo: DemoReducer,
195    dodge_reset: DodgeResetReducer,
196}
197
198impl StatsTimelineReducers {
199    fn with_positioning_config(config: PositioningReducerConfig) -> Self {
200        Self {
201            positioning: PositioningReducer::with_config(config),
202            ..Self::default()
203        }
204    }
205
206    fn with_pressure_config(config: PressureReducerConfig) -> Self {
207        Self {
208            pressure: PressureReducer::with_config(config),
209            ..Self::default()
210        }
211    }
212}
213
214impl StatsReducer for StatsTimelineReducers {
215    fn on_replay_meta(&mut self, meta: &ReplayMeta) -> SubtrActorResult<()> {
216        self.possession.on_replay_meta(meta)?;
217        self.pressure.on_replay_meta(meta)?;
218        self.rush.on_replay_meta(meta)?;
219        self.match_stats.on_replay_meta(meta)?;
220        self.fifty_fifty.on_replay_meta(meta)?;
221        self.touch.on_replay_meta(meta)?;
222        self.ball_carry.on_replay_meta(meta)?;
223        self.boost.on_replay_meta(meta)?;
224        self.movement.on_replay_meta(meta)?;
225        self.positioning.on_replay_meta(meta)?;
226        self.powerslide.on_replay_meta(meta)?;
227        self.demo.on_replay_meta(meta)?;
228        self.dodge_reset.on_replay_meta(meta)?;
229        Ok(())
230    }
231
232    fn on_sample_with_context(
233        &mut self,
234        sample: &StatsSample,
235        ctx: &AnalysisContext,
236    ) -> SubtrActorResult<()> {
237        self.possession.on_sample_with_context(sample, ctx)?;
238        self.pressure.on_sample_with_context(sample, ctx)?;
239        self.rush.on_sample_with_context(sample, ctx)?;
240        self.match_stats.on_sample_with_context(sample, ctx)?;
241        self.fifty_fifty.on_sample_with_context(sample, ctx)?;
242        self.touch.on_sample_with_context(sample, ctx)?;
243        self.ball_carry.on_sample_with_context(sample, ctx)?;
244        self.boost.on_sample_with_context(sample, ctx)?;
245        self.movement.on_sample_with_context(sample, ctx)?;
246        self.positioning.on_sample_with_context(sample, ctx)?;
247        self.powerslide.on_sample_with_context(sample, ctx)?;
248        self.demo.on_sample_with_context(sample, ctx)?;
249        self.dodge_reset.on_sample_with_context(sample, ctx)?;
250        Ok(())
251    }
252
253    fn finish(&mut self) -> SubtrActorResult<()> {
254        self.possession.finish()?;
255        self.pressure.finish()?;
256        self.rush.finish()?;
257        self.match_stats.finish()?;
258        self.fifty_fifty.finish()?;
259        self.touch.finish()?;
260        self.ball_carry.finish()?;
261        self.boost.finish()?;
262        self.movement.finish()?;
263        self.positioning.finish()?;
264        self.powerslide.finish()?;
265        self.demo.finish()?;
266        self.dodge_reset.finish()?;
267        Ok(())
268    }
269}
270
271pub struct StatsTimelineCollector {
272    reducers: StatsTimelineReducers,
273    derived_signals: DerivedSignalGraph,
274    replay_meta: Option<ReplayMeta>,
275    frames: Vec<ReplayStatsFrame>,
276    last_sample_time: Option<f32>,
277    last_sample: Option<StatsSample>,
278    last_live_play: Option<bool>,
279    live_play_tracker: LivePlayTracker,
280}
281
282impl Default for StatsTimelineCollector {
283    fn default() -> Self {
284        Self {
285            reducers: StatsTimelineReducers::default(),
286            derived_signals: default_derived_signal_graph(),
287            replay_meta: None,
288            frames: Vec::new(),
289            last_sample_time: None,
290            last_sample: None,
291            last_live_play: None,
292            live_play_tracker: LivePlayTracker::default(),
293        }
294    }
295}
296
297impl StatsTimelineCollector {
298    pub fn new() -> Self {
299        Self::default()
300    }
301
302    pub fn with_positioning_config(config: PositioningReducerConfig) -> Self {
303        Self {
304            reducers: StatsTimelineReducers::with_positioning_config(config),
305            ..Self::default()
306        }
307    }
308
309    pub fn with_pressure_config(config: PressureReducerConfig) -> Self {
310        Self {
311            reducers: StatsTimelineReducers::with_pressure_config(config),
312            ..Self::default()
313        }
314    }
315
316    pub fn get_replay_data(
317        mut self,
318        replay: &boxcars::Replay,
319    ) -> SubtrActorResult<ReplayStatsTimeline> {
320        let mut processor = ReplayProcessor::new(replay)?;
321        processor.process(&mut self)?;
322        Ok(self.into_timeline())
323    }
324
325    pub fn get_dynamic_replay_data(
326        mut self,
327        replay: &boxcars::Replay,
328    ) -> SubtrActorResult<DynamicReplayStatsTimeline> {
329        let mut processor = ReplayProcessor::new(replay)?;
330        processor.process(&mut self)?;
331        Ok(self.into_dynamic_timeline())
332    }
333
334    pub fn into_timeline(self) -> ReplayStatsTimeline {
335        let replay_meta = self
336            .replay_meta
337            .expect("replay metadata should be initialized before building a stats timeline");
338        let config = StatsTimelineConfig {
339            most_back_forward_threshold_y: self
340                .reducers
341                .positioning
342                .config()
343                .most_back_forward_threshold_y,
344            pressure_neutral_zone_half_width_y: self
345                .reducers
346                .pressure
347                .config()
348                .neutral_zone_half_width_y,
349        };
350        let mut timeline_events = self.reducers.match_stats.timeline().to_vec();
351        timeline_events.extend(self.reducers.demo.timeline().iter().cloned());
352        timeline_events.sort_by(|left, right| left.time.total_cmp(&right.time));
353        ReplayStatsTimeline {
354            config,
355            replay_meta,
356            timeline_events,
357            fifty_fifty_events: self.reducers.fifty_fifty.events().to_vec(),
358            frames: self.frames,
359        }
360    }
361
362    pub fn into_dynamic_timeline(self) -> DynamicReplayStatsTimeline {
363        let replay_meta = self
364            .replay_meta
365            .expect("replay metadata should be initialized before building a stats timeline");
366        let config = StatsTimelineConfig {
367            most_back_forward_threshold_y: self
368                .reducers
369                .positioning
370                .config()
371                .most_back_forward_threshold_y,
372            pressure_neutral_zone_half_width_y: self
373                .reducers
374                .pressure
375                .config()
376                .neutral_zone_half_width_y,
377        };
378        let mut timeline_events = self.reducers.match_stats.timeline().to_vec();
379        timeline_events.extend(self.reducers.demo.timeline().iter().cloned());
380        timeline_events.sort_by(|left, right| left.time.total_cmp(&right.time));
381        DynamicReplayStatsTimeline {
382            config,
383            replay_meta,
384            timeline_events,
385            fifty_fifty_events: self.reducers.fifty_fifty.events().to_vec(),
386            frames: self
387                .frames
388                .into_iter()
389                .map(ReplayStatsFrame::into_dynamic)
390                .collect(),
391        }
392    }
393
394    fn snapshot_frame(
395        &self,
396        sample: &StatsSample,
397        replay_meta: &ReplayMeta,
398        live_play: bool,
399    ) -> ReplayStatsFrame {
400        ReplayStatsFrame {
401            frame_number: sample.frame_number,
402            time: sample.time,
403            dt: sample.dt,
404            seconds_remaining: sample.seconds_remaining,
405            game_state: sample.game_state,
406            is_live_play: live_play,
407            fifty_fifty: self.reducers.fifty_fifty.stats().clone(),
408            possession: self.reducers.possession.stats().clone(),
409            pressure: self.reducers.pressure.stats().clone(),
410            rush: self.reducers.rush.stats().clone(),
411            team_zero: TeamStatsSnapshot {
412                core: self.reducers.match_stats.team_zero_stats(),
413                ball_carry: self.reducers.ball_carry.team_zero_stats().clone(),
414                boost: self.reducers.boost.team_zero_stats().clone(),
415                movement: self.reducers.movement.team_zero_stats().clone(),
416                powerslide: self.reducers.powerslide.team_zero_stats().clone(),
417                demo: self.reducers.demo.team_zero_stats().clone(),
418            },
419            team_one: TeamStatsSnapshot {
420                core: self.reducers.match_stats.team_one_stats(),
421                ball_carry: self.reducers.ball_carry.team_one_stats().clone(),
422                boost: self.reducers.boost.team_one_stats().clone(),
423                movement: self.reducers.movement.team_one_stats().clone(),
424                powerslide: self.reducers.powerslide.team_one_stats().clone(),
425                demo: self.reducers.demo.team_one_stats().clone(),
426            },
427            players: replay_meta
428                .player_order()
429                .map(|player| PlayerStatsSnapshot {
430                    player_id: player.remote_id.clone(),
431                    name: player.name.clone(),
432                    is_team_0: replay_meta
433                        .team_zero
434                        .iter()
435                        .any(|team_player| team_player.remote_id == player.remote_id),
436                    core: self
437                        .reducers
438                        .match_stats
439                        .player_stats()
440                        .get(&player.remote_id)
441                        .cloned()
442                        .unwrap_or_default(),
443                    fifty_fifty: self
444                        .reducers
445                        .fifty_fifty
446                        .player_stats()
447                        .get(&player.remote_id)
448                        .cloned()
449                        .unwrap_or_default(),
450                    touch: self
451                        .reducers
452                        .touch
453                        .player_stats()
454                        .get(&player.remote_id)
455                        .cloned()
456                        .unwrap_or_default()
457                        .with_complete_labeled_touch_counts(),
458                    dodge_reset: self
459                        .reducers
460                        .dodge_reset
461                        .player_stats()
462                        .get(&player.remote_id)
463                        .cloned()
464                        .unwrap_or_default(),
465                    ball_carry: self
466                        .reducers
467                        .ball_carry
468                        .player_stats()
469                        .get(&player.remote_id)
470                        .cloned()
471                        .unwrap_or_default(),
472                    boost: self
473                        .reducers
474                        .boost
475                        .player_stats()
476                        .get(&player.remote_id)
477                        .cloned()
478                        .unwrap_or_default(),
479                    movement: self
480                        .reducers
481                        .movement
482                        .player_stats()
483                        .get(&player.remote_id)
484                        .cloned()
485                        .unwrap_or_default()
486                        .with_complete_labeled_tracked_time(),
487                    positioning: self
488                        .reducers
489                        .positioning
490                        .player_stats()
491                        .get(&player.remote_id)
492                        .cloned()
493                        .unwrap_or_default(),
494                    powerslide: self
495                        .reducers
496                        .powerslide
497                        .player_stats()
498                        .get(&player.remote_id)
499                        .cloned()
500                        .unwrap_or_default(),
501                    demo: self
502                        .reducers
503                        .demo
504                        .player_stats()
505                        .get(&player.remote_id)
506                        .cloned()
507                        .unwrap_or_default(),
508                })
509                .collect(),
510        }
511    }
512}
513
514impl Collector for StatsTimelineCollector {
515    fn process_frame(
516        &mut self,
517        processor: &ReplayProcessor,
518        _frame: &boxcars::Frame,
519        frame_number: usize,
520        current_time: f32,
521    ) -> SubtrActorResult<TimeAdvance> {
522        if self.replay_meta.is_none() {
523            let replay_meta = processor.get_replay_meta()?;
524            self.derived_signals.on_replay_meta(&replay_meta)?;
525            self.reducers.on_replay_meta(&replay_meta)?;
526            self.replay_meta = Some(replay_meta);
527        }
528
529        let dt = self
530            .last_sample_time
531            .map(|last_time| (current_time - last_time).max(0.0))
532            .unwrap_or(0.0);
533        let sample = StatsSample::from_processor(processor, frame_number, current_time, dt)?;
534        let live_play = self.live_play_tracker.is_live_play(&sample);
535        let analysis_context = self.derived_signals.evaluate(&sample)?;
536        self.reducers
537            .on_sample_with_context(&sample, analysis_context)?;
538        self.last_sample_time = Some(current_time);
539        self.last_live_play = Some(live_play);
540
541        let replay_meta = self
542            .replay_meta
543            .as_ref()
544            .expect("replay metadata should be initialized before snapshotting");
545        self.frames
546            .push(self.snapshot_frame(&sample, replay_meta, live_play));
547        self.last_sample = Some(sample);
548
549        Ok(TimeAdvance::NextFrame)
550    }
551
552    fn finish_replay(&mut self, _processor: &ReplayProcessor) -> SubtrActorResult<()> {
553        self.derived_signals.finish()?;
554        self.reducers.finish()?;
555        let Some(last_sample) = self.last_sample.as_ref() else {
556            return Ok(());
557        };
558        let Some(replay_meta) = self.replay_meta.as_ref() else {
559            return Ok(());
560        };
561        let final_snapshot = self.snapshot_frame(
562            last_sample,
563            replay_meta,
564            self.last_live_play.unwrap_or(false),
565        );
566        if let Some(last_frame) = self.frames.last_mut() {
567            *last_frame = final_snapshot;
568        }
569        Ok(())
570    }
571}