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}