Skip to main content

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