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}