subtr_actor/collector/replay_data.rs
1//! # Replay Data Collection Module
2//!
3//! This module provides comprehensive data structures and collection mechanisms
4//! for extracting and organizing Rocket League replay data. It offers a complete
5//! representation of ball, player, and game state information across all frames
6//! of a replay.
7//!
8//! The module is built around the [`ReplayDataCollector`] which implements the
9//! [`Collector`] trait, allowing it to process replay frames and extract
10//! detailed information about player actions, ball movement, and game state.
11//!
12//! # Key Components
13//!
14//! - [`ReplayData`] - The complete replay data structure containing all extracted information
15//! - [`FrameData`] - Frame-by-frame data including ball, player, and metadata information
16//! - [`PlayerFrame`] - Detailed player state including position, controls, and actions
17//! - [`BallFrame`] - Ball state including rigid body physics information
18//! - [`MetadataFrame`] - Game state metadata including time and score information
19//!
20//! # Example Usage
21//!
22//! ```rust
23//! use subtr_actor::collector::replay_data::ReplayDataCollector;
24//! use boxcars::ParserBuilder;
25//!
26//! let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
27//! let replay = ParserBuilder::new(&data).parse().unwrap();
28//!
29//! let collector = ReplayDataCollector::new();
30//! let replay_data = collector.get_replay_data(&replay).unwrap();
31//!
32//! // Access frame-by-frame data
33//! for metadata_frame in &replay_data.frame_data.metadata_frames {
34//! println!("Time: {:.2}s, Remaining: {}s",
35//! metadata_frame.time, metadata_frame.seconds_remaining);
36//! }
37//! ```
38
39use boxcars;
40use serde::Serialize;
41
42use crate::*;
43
44/// Represents the ball state for a single frame in a Rocket League replay.
45///
46/// The ball can either be in an empty state (when ball syncing is disabled or
47/// the rigid body is unavailable) or contain full physics data including
48/// position, rotation, and velocity information.
49///
50/// # Variants
51///
52/// - [`Empty`](BallFrame::Empty) - Indicates the ball is unavailable or ball syncing is disabled
53/// - [`Data`](BallFrame::Data) - Contains the ball's rigid body physics information
54#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
55#[ts(export)]
56pub enum BallFrame {
57 /// Empty frame indicating the ball is unavailable or ball syncing is disabled
58 Empty,
59 /// Frame containing the ball's rigid body physics data
60 Data {
61 /// The ball's rigid body containing position, rotation, and velocity information
62 #[ts(as = "crate::ts_bindings::RigidBodyTs")]
63 rigid_body: boxcars::RigidBody,
64 },
65}
66
67impl BallFrame {
68 /// Creates a new [`BallFrame`] from a [`ReplayProcessor`] at the specified time.
69 ///
70 /// This method extracts the ball's state from the replay processor, handling
71 /// cases where ball syncing is disabled or the rigid body is unavailable.
72 ///
73 /// # Arguments
74 ///
75 /// * `processor` - The [`ReplayProcessor`] containing the replay data
76 /// * `current_time` - The time in seconds at which to extract the ball state
77 ///
78 /// # Returns
79 ///
80 /// Returns a [`BallFrame`] which will be [`Empty`](BallFrame::Empty) if:
81 /// - Ball syncing is disabled in the replay
82 /// - The ball's rigid body cannot be retrieved
83 ///
84 /// Otherwise returns [`Data`](BallFrame::Data) containing the ball's rigid body.
85 fn new_from_processor(processor: &ReplayProcessor, current_time: f32) -> Self {
86 if processor.get_ignore_ball_syncing().unwrap_or(false) {
87 Self::Empty
88 } else if let Ok(rigid_body) = processor.get_interpolated_ball_rigid_body(current_time, 0.0)
89 {
90 Self::new_from_rigid_body(rigid_body)
91 } else {
92 Self::Empty
93 }
94 }
95
96 /// Creates a new [`BallFrame`] from a rigid body.
97 ///
98 /// # Arguments
99 ///
100 /// * `rigid_body` - The ball's rigid body containing physics information
101 ///
102 /// # Returns
103 ///
104 /// Returns [`Data`](BallFrame::Data) containing the rigid body even when the
105 /// ball is sleeping, so stationary kickoff frames still retain the ball's
106 /// position for downstream consumers such as the JS player.
107 fn new_from_rigid_body(rigid_body: boxcars::RigidBody) -> Self {
108 Self::Data { rigid_body }
109 }
110}
111
112/// Represents a player's state for a single frame in a Rocket League replay.
113///
114/// Contains comprehensive information about a player's position, movement,
115/// and control inputs during a specific frame of the replay.
116///
117/// # Variants
118///
119/// - [`Empty`](PlayerFrame::Empty) - Indicates the player state is unavailable
120/// - [`Data`](PlayerFrame::Data) - Contains the player's complete state information
121#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
122#[ts(export)]
123pub enum PlayerFrame {
124 /// Empty frame indicating the player state is unavailable
125 Empty,
126 /// Frame containing the player's complete state data
127 Data {
128 /// The player's rigid body containing position, rotation, and velocity information
129 #[ts(as = "crate::ts_bindings::RigidBodyTs")]
130 rigid_body: boxcars::RigidBody,
131 /// The player's current boost amount in raw replay units (0.0 to 255.0)
132 boost_amount: f32,
133 /// Whether the player is actively using boost
134 boost_active: bool,
135 /// Whether the player is actively powersliding / holding handbrake
136 powerslide_active: bool,
137 /// Whether the player is actively jumping
138 jump_active: bool,
139 /// Whether the player is performing a double jump
140 double_jump_active: bool,
141 /// Whether the player is performing a dodge maneuver
142 dodge_active: bool,
143 /// The player's name as it appears in the replay
144 player_name: Option<String>,
145 /// The team the player belongs to (0 or 1)
146 team: Option<i32>,
147 /// Whether the player is on team 0 (blue team typically)
148 is_team_0: Option<bool>,
149 },
150}
151
152impl PlayerFrame {
153 /// Creates a new [`PlayerFrame`] from a [`ReplayProcessor`] for a specific player at the specified time.
154 ///
155 /// This method extracts comprehensive player state information from the replay processor,
156 /// including position, control inputs, and team information.
157 ///
158 /// # Arguments
159 ///
160 /// * `processor` - The [`ReplayProcessor`] containing the replay data
161 /// * `player_id` - The unique identifier for the player
162 /// * `current_time` - The time in seconds at which to extract the player state
163 ///
164 /// # Returns
165 ///
166 /// Returns a [`SubtrActorResult`] containing a [`PlayerFrame::Data`] value
167 /// with the player's complete state information.
168 ///
169 /// # Errors
170 ///
171 /// Returns a [`SubtrActorError`] if:
172 /// - The player's rigid body cannot be retrieved
173 fn new_from_processor(
174 processor: &ReplayProcessor,
175 player_id: &PlayerId,
176 current_time: f32,
177 ) -> SubtrActorResult<Self> {
178 let rigid_body =
179 processor.get_interpolated_player_rigid_body(player_id, current_time, 0.0)?;
180
181 let boost_amount = processor.get_player_boost_level(player_id).unwrap_or(0.0);
182 let boost_active = processor.get_boost_active(player_id).unwrap_or(0) % 2 == 1;
183 let powerslide_active = processor.get_powerslide_active(player_id).unwrap_or(false);
184 let jump_active = processor.get_jump_active(player_id).unwrap_or(0) % 2 == 1;
185 let double_jump_active = processor.get_double_jump_active(player_id).unwrap_or(0) % 2 == 1;
186 let dodge_active = processor.get_dodge_active(player_id).unwrap_or(0) % 2 == 1;
187
188 // Extract player identity information
189 let player_name = processor.get_player_name(player_id).ok();
190 let team = processor
191 .get_player_team_key(player_id)
192 .ok()
193 .and_then(|team_key| team_key.parse::<i32>().ok());
194 let is_team_0 = processor.get_player_is_team_0(player_id).ok();
195
196 Ok(Self::from_data(
197 rigid_body,
198 boost_amount,
199 boost_active,
200 powerslide_active,
201 jump_active,
202 double_jump_active,
203 dodge_active,
204 player_name,
205 team,
206 is_team_0,
207 ))
208 }
209
210 /// Creates a [`PlayerFrame`] from the provided data components.
211 ///
212 /// # Arguments
213 ///
214 /// * `rigid_body` - The player's rigid body physics information
215 /// * `boost_amount` - The player's current boost level in raw replay units (0.0 to 255.0)
216 /// * `boost_active` - Whether the player is actively using boost
217 /// * `powerslide_active` - Whether the player is actively powersliding
218 /// * `jump_active` - Whether the player is actively jumping
219 /// * `double_jump_active` - Whether the player is performing a double jump
220 /// * `dodge_active` - Whether the player is performing a dodge maneuver
221 /// * `player_name` - The player's name, if available
222 /// * `team` - The player's team number, if available
223 /// * `is_team_0` - Whether the player is on team 0, if available
224 ///
225 /// # Returns
226 ///
227 /// Returns [`Data`](PlayerFrame::Data) with all provided information, even
228 /// when the rigid body is sleeping, so stationary kickoff and reset frames
229 /// still retain the player's position for downstream consumers such as the
230 /// JS player.
231 #[allow(clippy::too_many_arguments)]
232 fn from_data(
233 rigid_body: boxcars::RigidBody,
234 boost_amount: f32,
235 boost_active: bool,
236 powerslide_active: bool,
237 jump_active: bool,
238 double_jump_active: bool,
239 dodge_active: bool,
240 player_name: Option<String>,
241 team: Option<i32>,
242 is_team_0: Option<bool>,
243 ) -> Self {
244 Self::Data {
245 rigid_body,
246 boost_amount,
247 boost_active,
248 powerslide_active,
249 jump_active,
250 double_jump_active,
251 dodge_active,
252 player_name,
253 team,
254 is_team_0,
255 }
256 }
257}
258
259/// Contains all frame data for a single player throughout the replay.
260///
261/// This structure holds a chronological sequence of [`PlayerFrame`] instances
262/// representing the player's state at each processed frame of the replay.
263///
264/// # Fields
265///
266/// * `frames` - A vector of [`PlayerFrame`] instances in chronological order
267#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
268#[ts(export)]
269pub struct PlayerData {
270 /// Vector of player frames in chronological order
271 frames: Vec<PlayerFrame>,
272}
273
274impl PlayerData {
275 /// Creates a new empty [`PlayerData`] instance.
276 ///
277 /// # Returns
278 ///
279 /// Returns a new [`PlayerData`] with an empty frames vector.
280 fn new() -> Self {
281 Self { frames: Vec::new() }
282 }
283
284 /// Adds a player frame at the specified frame index.
285 ///
286 /// If the frame index is beyond the current length of the frames vector,
287 /// empty frames will be inserted to fill the gap before adding the new frame.
288 ///
289 /// # Arguments
290 ///
291 /// * `frame_index` - The index at which to insert the frame
292 /// * `frame` - The [`PlayerFrame`] to add
293 fn add_frame(&mut self, frame_index: usize, frame: PlayerFrame) {
294 let empty_frames_to_add = frame_index - self.frames.len();
295 if empty_frames_to_add > 0 {
296 for _ in 0..empty_frames_to_add {
297 self.frames.push(PlayerFrame::Empty)
298 }
299 }
300 self.frames.push(frame)
301 }
302
303 /// Returns a reference to the frames vector.
304 ///
305 /// # Returns
306 ///
307 /// Returns a reference to the vector of [`PlayerFrame`] instances.
308 pub fn frames(&self) -> &Vec<PlayerFrame> {
309 &self.frames
310 }
311
312 /// Returns the number of frames in this player's data.
313 ///
314 /// # Returns
315 ///
316 /// Returns the total number of frames stored for this player.
317 pub fn frame_count(&self) -> usize {
318 self.frames.len()
319 }
320}
321
322/// Contains all frame data for the ball throughout the replay.
323///
324/// This structure holds a chronological sequence of [`BallFrame`] instances
325/// representing the ball's state at each processed frame of the replay.
326///
327/// # Fields
328///
329/// * `frames` - A vector of [`BallFrame`] instances in chronological order
330#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
331#[ts(export)]
332pub struct BallData {
333 /// Vector of ball frames in chronological order
334 frames: Vec<BallFrame>,
335}
336
337impl BallData {
338 /// Creates a new empty [`BallData`] instance.
339 ///
340 /// # Returns
341 ///
342 /// Returns a new [`BallData`] with an empty frames vector.
343 fn new() -> Self {
344 Self { frames: Vec::new() }
345 }
346
347 /// Adds a ball frame at the specified frame index.
348 ///
349 /// If the frame index is beyond the current length of the frames vector,
350 /// empty frames will be inserted to fill the gap before adding the new frame.
351 ///
352 /// # Arguments
353 ///
354 /// * `frame_index` - The index at which to insert the frame
355 /// * `frame` - The [`BallFrame`] to add
356 fn add_frame(&mut self, frame_index: usize, frame: BallFrame) {
357 let empty_frames_to_add = frame_index - self.frames.len();
358 if empty_frames_to_add > 0 {
359 for _ in 0..empty_frames_to_add {
360 self.frames.push(BallFrame::Empty)
361 }
362 }
363 self.frames.push(frame)
364 }
365
366 /// Returns a reference to the frames vector.
367 ///
368 /// # Returns
369 ///
370 /// Returns a reference to the vector of [`BallFrame`] instances.
371 pub fn frames(&self) -> &Vec<BallFrame> {
372 &self.frames
373 }
374
375 /// Returns the number of frames in the ball data.
376 ///
377 /// # Returns
378 ///
379 /// Returns the total number of frames stored for the ball.
380 pub fn frame_count(&self) -> usize {
381 self.frames.len()
382 }
383}
384
385/// Represents game metadata for a single frame in a Rocket League replay.
386///
387/// Contains timing information and game state data that applies to the entire
388/// game at a specific point in time.
389///
390/// # Fields
391///
392/// * `time` - The current time in seconds since the start of the replay
393/// * `seconds_remaining` - The number of seconds remaining in the current game period
394/// * `replicated_game_state_name` - The game state enum value (indicates countdown, playing, goal, etc.)
395/// * `replicated_game_state_time_remaining` - The kickoff countdown timer, usually 3 to 0
396#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
397#[ts(export)]
398pub struct MetadataFrame {
399 /// The current time in seconds since the start of the replay
400 pub time: f32,
401 /// The number of seconds remaining in the current game period
402 pub seconds_remaining: i32,
403 /// The game state enum value (indicates countdown, playing, goal scored, etc.)
404 pub replicated_game_state_name: i32,
405 /// The kickoff countdown timer exposed by the replay metadata actor.
406 pub replicated_game_state_time_remaining: i32,
407}
408
409impl MetadataFrame {
410 /// Creates a new [`MetadataFrame`] from a [`ReplayProcessor`] at the specified time.
411 ///
412 /// # Arguments
413 ///
414 /// * `processor` - The [`ReplayProcessor`] containing the replay data
415 /// * `time` - The current time in seconds since the start of the replay
416 ///
417 /// # Returns
418 ///
419 /// Returns a [`SubtrActorResult`] containing a [`MetadataFrame`] with the
420 /// current time and remaining seconds extracted from the processor.
421 ///
422 /// # Errors
423 ///
424 /// Returns a [`SubtrActorError`] if the seconds remaining cannot be retrieved
425 /// from the processor.
426 fn new_from_processor(processor: &ReplayProcessor, time: f32) -> SubtrActorResult<Self> {
427 Ok(Self::new(
428 time,
429 processor.get_seconds_remaining()?,
430 processor.get_replicated_state_name().unwrap_or(0),
431 processor
432 .get_replicated_game_state_time_remaining()
433 .unwrap_or(0),
434 ))
435 }
436
437 /// Creates a new [`MetadataFrame`] with the specified time, seconds remaining, game state,
438 /// and kickoff countdown value.
439 ///
440 /// # Arguments
441 ///
442 /// * `time` - The current time in seconds since the start of the replay
443 /// * `seconds_remaining` - The number of seconds remaining in the current game period
444 /// * `replicated_game_state_name` - The game state enum value
445 /// * `replicated_game_state_time_remaining` - The kickoff countdown timer
446 ///
447 /// # Returns
448 ///
449 /// Returns a new [`MetadataFrame`] with the provided values.
450 fn new(
451 time: f32,
452 seconds_remaining: i32,
453 replicated_game_state_name: i32,
454 replicated_game_state_time_remaining: i32,
455 ) -> Self {
456 MetadataFrame {
457 time,
458 seconds_remaining,
459 replicated_game_state_name,
460 replicated_game_state_time_remaining,
461 }
462 }
463}
464
465/// Contains all frame-by-frame data for a Rocket League replay.
466///
467/// This structure organizes ball data, player data, and metadata for each
468/// frame of the replay, providing a complete picture of the game state
469/// throughout the match.
470///
471/// # Fields
472///
473/// * `ball_data` - All ball state information across all frames
474/// * `players` - Player data for each player, indexed by [`PlayerId`]
475/// * `metadata_frames` - Game metadata for each frame including timing information
476#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
477#[ts(export)]
478pub struct FrameData {
479 /// All ball state information across all frames
480 pub ball_data: BallData,
481 /// Player data for each player, indexed by PlayerId
482 #[ts(as = "Vec<(crate::ts_bindings::RemoteIdTs, PlayerData)>")]
483 pub players: Vec<(PlayerId, PlayerData)>,
484 /// Game metadata for each frame including timing information
485 pub metadata_frames: Vec<MetadataFrame>,
486}
487
488/// Complete replay data structure containing all extracted information from a Rocket League replay.
489///
490/// This is the top-level structure that contains all processed replay data including
491/// frame-by-frame information, replay metadata, and special events like demolitions.
492///
493/// # Fields
494///
495/// * `frame_data` - All frame-by-frame data including ball, player, and metadata information
496/// * `meta` - Replay metadata including player information, game settings, and statistics
497/// * `demolish_infos` - Information about all demolition events that occurred during the replay
498/// * `boost_pad_events` - Exact boost pad pickup/availability events detected while processing
499/// * `boost_pads` - Resolved standard boost pad layout annotated with replay pad ids when known
500/// * `touch_events` - Exact team touch events plus attributed player when available
501/// * `dodge_refreshed_events` - Exact counter-derived dodge refresh events from the replay
502/// * `player_stat_events` - Exact shot/save/assist counter increment events
503/// * `goal_events` - Exact goal explosion events with scorer and cumulative score when available
504///
505/// # Example
506///
507/// ```rust
508/// use subtr_actor::collector::replay_data::ReplayDataCollector;
509/// use boxcars::ParserBuilder;
510///
511/// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
512/// let replay = ParserBuilder::new(&data).parse().unwrap();
513/// let collector = ReplayDataCollector::new();
514/// let replay_data = collector.get_replay_data(&replay).unwrap();
515///
516/// // Access replay metadata
517/// println!("Team 0 players: {}", replay_data.meta.team_zero.len());
518///
519/// // Access frame data
520/// println!("Total frames: {}", replay_data.frame_data.metadata_frames.len());
521///
522/// // Access demolition events
523/// println!("Total demolitions: {}", replay_data.demolish_infos.len());
524/// ```
525#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
526#[ts(export)]
527pub struct ReplayData {
528 /// All frame-by-frame data including ball, player, and metadata information
529 pub frame_data: FrameData,
530 /// Replay metadata including player information, game settings, and statistics
531 pub meta: ReplayMeta,
532 /// Information about all demolition events that occurred during the replay
533 pub demolish_infos: Vec<DemolishInfo>,
534 /// Exact boost pad pickup and availability events observed during the replay
535 pub boost_pad_events: Vec<BoostPadEvent>,
536 /// Resolved standard boost pad layout annotated with replay pad ids when known
537 pub boost_pads: Vec<ResolvedBoostPad>,
538 /// Exact touch events observed during the replay
539 pub touch_events: Vec<TouchEvent>,
540 /// Exact dodge refresh events observed via the replay's refreshed-dodge counter
541 pub dodge_refreshed_events: Vec<DodgeRefreshedEvent>,
542 /// Exact player stat counter increments observed during the replay
543 pub player_stat_events: Vec<PlayerStatEvent>,
544 /// Exact goal events observed during the replay
545 pub goal_events: Vec<GoalEvent>,
546}
547
548impl ReplayData {
549 /// Serializes the replay data to a JSON string.
550 ///
551 /// # Returns
552 ///
553 /// Returns a [`Result`] containing either the JSON string representation
554 /// of the replay data or a [`serde_json::Error`] if serialization fails.
555 ///
556 /// # Example
557 ///
558 /// ```rust
559 /// use subtr_actor::collector::replay_data::ReplayDataCollector;
560 /// use boxcars::ParserBuilder;
561 ///
562 /// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
563 /// let replay = ParserBuilder::new(&data).parse().unwrap();
564 /// let collector = ReplayDataCollector::new();
565 /// let replay_data = collector.get_replay_data(&replay).unwrap();
566 ///
567 /// let json_string = replay_data.as_json().unwrap();
568 /// println!("Replay as JSON: {}", json_string);
569 /// ```
570 pub fn as_json(&self) -> Result<String, serde_json::Error> {
571 serde_json::to_string(self)
572 }
573
574 /// Serializes the replay data to a pretty-printed JSON string.
575 ///
576 /// # Returns
577 ///
578 /// Returns a [`Result`] containing either the pretty-printed JSON string
579 /// representation of the replay data or a [`serde_json::Error`] if serialization fails.
580 pub fn as_pretty_json(&self) -> Result<String, serde_json::Error> {
581 serde_json::to_string_pretty(self)
582 }
583}
584
585impl FrameData {
586 /// Creates a new empty [`FrameData`] instance.
587 ///
588 /// # Returns
589 ///
590 /// Returns a new [`FrameData`] with empty ball data, player data, and metadata frames.
591 fn new() -> Self {
592 FrameData {
593 ball_data: BallData::new(),
594 players: Vec::new(),
595 metadata_frames: Vec::new(),
596 }
597 }
598
599 /// Returns the total number of frames in this frame data.
600 ///
601 /// # Returns
602 ///
603 /// Returns the number of metadata frames, which represents the total frame count.
604 pub fn frame_count(&self) -> usize {
605 self.metadata_frames.len()
606 }
607
608 /// Returns the duration of the replay in seconds.
609 ///
610 /// # Returns
611 ///
612 /// Returns the time of the last frame, or 0.0 if no frames exist.
613 pub fn duration(&self) -> f32 {
614 self.metadata_frames.last().map(|f| f.time).unwrap_or(0.0)
615 }
616
617 /// Adds a complete frame of data to the frame data structure.
618 ///
619 /// This method adds metadata, ball data, and player data for a single frame
620 /// to their respective collections, maintaining frame synchronization across
621 /// all data types.
622 ///
623 /// # Arguments
624 ///
625 /// * `frame_metadata` - The metadata for this frame (time, game state, etc.)
626 /// * `ball_frame` - The ball state for this frame
627 /// * `player_frames` - Player state data for all players in this frame
628 ///
629 /// # Returns
630 ///
631 /// Returns a [`SubtrActorResult`] indicating success or failure of the operation.
632 ///
633 /// # Errors
634 ///
635 /// May return a [`SubtrActorError`] if frame data cannot be processed correctly.
636 fn add_frame(
637 &mut self,
638 frame_metadata: MetadataFrame,
639 ball_frame: BallFrame,
640 player_frames: Vec<(PlayerId, PlayerFrame)>,
641 ) -> SubtrActorResult<()> {
642 let frame_index = self.metadata_frames.len();
643 self.metadata_frames.push(frame_metadata);
644 self.ball_data.add_frame(frame_index, ball_frame);
645 for (player_id, frame) in player_frames {
646 self.players
647 .get_entry(player_id)
648 .or_insert_with(PlayerData::new)
649 .add_frame(frame_index, frame)
650 }
651 Ok(())
652 }
653}
654
655/// A collector that extracts comprehensive frame-by-frame data from Rocket League replays.
656///
657/// [`ReplayDataCollector`] implements the [`Collector`] trait to process replay frames
658/// and extract detailed information about ball movement, player actions, and game state.
659/// It builds a complete [`ReplayData`] structure containing all available information
660/// from the replay.
661///
662/// # Usage
663///
664/// The collector is designed to be used with the [`ReplayProcessor`] to extract
665/// comprehensive replay data:
666///
667/// ```rust
668/// use subtr_actor::collector::replay_data::ReplayDataCollector;
669/// use boxcars::ParserBuilder;
670///
671/// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
672/// let replay = ParserBuilder::new(&data).parse().unwrap();
673///
674/// let collector = ReplayDataCollector::new();
675/// let replay_data = collector.get_replay_data(&replay).unwrap();
676///
677/// // Process the extracted data
678/// for (frame_idx, metadata) in replay_data.frame_data.metadata_frames.iter().enumerate() {
679/// println!("Frame {}: Time={:.2}s, Remaining={}s",
680/// frame_idx, metadata.time, metadata.seconds_remaining);
681/// }
682/// ```
683///
684/// # Fields
685///
686/// * `frame_data` - Internal storage for frame-by-frame data during collection
687pub struct ReplayDataCollector {
688 /// Internal storage for frame-by-frame data during collection
689 frame_data: FrameData,
690}
691
692impl Default for ReplayDataCollector {
693 /// Creates a default [`ReplayDataCollector`] instance.
694 ///
695 /// This is equivalent to calling [`ReplayDataCollector::new()`].
696 fn default() -> Self {
697 Self::new()
698 }
699}
700
701impl ReplayDataCollector {
702 /// Creates a new [`ReplayDataCollector`] instance.
703 ///
704 /// # Returns
705 ///
706 /// Returns a new collector ready to process replay frames.
707 pub fn new() -> Self {
708 ReplayDataCollector {
709 frame_data: FrameData::new(),
710 }
711 }
712
713 /// Consumes the collector and returns the collected frame data.
714 ///
715 /// # Returns
716 ///
717 /// Returns the [`FrameData`] containing all processed frame information.
718 pub fn get_frame_data(self) -> FrameData {
719 self.frame_data
720 }
721
722 /// Builds replay data from this collector and an already-processed
723 /// [`ReplayProcessor`].
724 ///
725 /// This keeps replay-data collection composable: callers can run
726 /// [`ReplayDataCollector`] alongside any other collectors with
727 /// [`ReplayProcessor::process_all`] and then decide which enrichments to
728 /// merge into the final payload.
729 pub fn into_replay_data(self, processor: ReplayProcessor<'_>) -> SubtrActorResult<ReplayData> {
730 self.into_replay_data_with_boost_pads(processor, Vec::new())
731 }
732
733 pub fn into_replay_data_with_boost_pads(
734 self,
735 processor: ReplayProcessor<'_>,
736 boost_pads: Vec<ResolvedBoostPad>,
737 ) -> SubtrActorResult<ReplayData> {
738 let meta = processor.get_replay_meta()?;
739 Ok(ReplayData {
740 meta,
741 demolish_infos: processor.demolishes,
742 boost_pad_events: processor.boost_pad_events,
743 boost_pads,
744 touch_events: processor.touch_events,
745 dodge_refreshed_events: processor.dodge_refreshed_events,
746 player_stat_events: processor.player_stat_events,
747 goal_events: processor.goal_events,
748 frame_data: self.get_frame_data(),
749 })
750 }
751
752 /// Processes a replay and returns complete replay data.
753 ///
754 /// This method processes the entire replay using a [`ReplayProcessor`] and
755 /// extracts all available information including frame-by-frame data, metadata,
756 /// and special events like demolitions.
757 ///
758 /// # Arguments
759 ///
760 /// * `replay` - The parsed replay data from the [`boxcars`] library
761 ///
762 /// # Returns
763 ///
764 /// Returns a [`SubtrActorResult`] containing the complete [`ReplayData`] structure
765 /// with all extracted information.
766 ///
767 /// # Errors
768 ///
769 /// Returns a [`SubtrActorError`] if:
770 /// - The replay processor cannot be created
771 /// - Frame processing fails
772 /// - Replay metadata cannot be extracted
773 ///
774 /// # Example
775 ///
776 /// ```rust
777 /// use subtr_actor::collector::replay_data::ReplayDataCollector;
778 /// use boxcars::ParserBuilder;
779 ///
780 /// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
781 /// let replay = ParserBuilder::new(&data).parse().unwrap();
782 ///
783 /// let collector = ReplayDataCollector::new();
784 /// let replay_data = collector.get_replay_data(&replay).unwrap();
785 ///
786 /// println!("Processed {} frames", replay_data.frame_data.frame_count());
787 /// ```
788 pub fn get_replay_data(mut self, replay: &boxcars::Replay) -> SubtrActorResult<ReplayData> {
789 let mut processor = ReplayProcessor::new(replay)?;
790 let mut boost_pad_collector = ResolvedBoostPadCollector::new();
791 processor.process_all(&mut [&mut self, &mut boost_pad_collector])?;
792 self.into_replay_data_with_boost_pads(
793 processor,
794 boost_pad_collector.into_resolved_boost_pads(),
795 )
796 }
797
798 /// Extracts player frame data for all players at the specified time.
799 ///
800 /// This method iterates through all players in the replay and extracts their
801 /// state information at the given time, returning a vector of player frames
802 /// indexed by player ID.
803 ///
804 /// # Arguments
805 ///
806 /// * `processor` - The [`ReplayProcessor`] containing the replay data
807 /// * `current_time` - The time in seconds at which to extract player states
808 ///
809 /// # Returns
810 ///
811 /// Returns a [`SubtrActorResult`] containing a vector of tuples with player IDs
812 /// and their corresponding [`PlayerFrame`] data.
813 ///
814 /// # Errors
815 ///
816 /// Returns a [`SubtrActorError`] if player frame data cannot be extracted.
817 fn get_player_frames(
818 &self,
819 processor: &ReplayProcessor,
820 current_time: f32,
821 ) -> SubtrActorResult<Vec<(PlayerId, PlayerFrame)>> {
822 Ok(processor
823 .iter_player_ids_in_order()
824 .map(|player_id| {
825 (
826 player_id.clone(),
827 PlayerFrame::new_from_processor(processor, player_id, current_time)
828 .unwrap_or(PlayerFrame::Empty),
829 )
830 })
831 .collect())
832 }
833}
834
835impl Collector for ReplayDataCollector {
836 /// Processes a single frame of the replay and extracts all relevant data.
837 ///
838 /// This method is called by the [`ReplayProcessor`] for each frame in the replay.
839 /// It extracts metadata, ball state, and player state information and adds them
840 /// to the internal frame data structure.
841 ///
842 /// # Arguments
843 ///
844 /// * `processor` - The [`ReplayProcessor`] containing the replay data and context
845 /// * `_frame` - The current frame data (unused in this implementation)
846 /// * `_frame_number` - The current frame number (unused in this implementation)
847 /// * `current_time` - The current time in seconds since the start of the replay
848 ///
849 /// # Returns
850 ///
851 /// Returns a [`SubtrActorResult`] containing [`TimeAdvance::NextFrame`] to
852 /// indicate that processing should continue to the next frame.
853 ///
854 /// # Errors
855 ///
856 /// Returns a [`SubtrActorError`] if:
857 /// - Metadata frame cannot be created
858 /// - Player frame data cannot be extracted
859 /// - Frame data cannot be added to the collection
860 fn process_frame(
861 &mut self,
862 processor: &ReplayProcessor,
863 _frame: &boxcars::Frame,
864 _frame_number: usize,
865 current_time: f32,
866 ) -> SubtrActorResult<TimeAdvance> {
867 let metadata_frame = MetadataFrame::new_from_processor(processor, current_time)?;
868 let ball_frame = BallFrame::new_from_processor(processor, current_time);
869 let player_frames = self.get_player_frames(processor, current_time)?;
870 self.frame_data
871 .add_frame(metadata_frame, ball_frame, player_frames)?;
872 Ok(TimeAdvance::NextFrame)
873 }
874}