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