use boxcars::{HeaderProp, RemoteId};
use serde::Serialize;
use crate::{glam_to_vec, vec_to_glam};
pub type PlayerId = boxcars::RemoteId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DemolishFormat {
Fx,
Extended,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DemolishAttribute {
Fx(boxcars::DemolishFx),
Extended(boxcars::DemolishExtended),
}
impl DemolishAttribute {
pub fn attacker_actor_id(&self) -> boxcars::ActorId {
match self {
DemolishAttribute::Fx(fx) => fx.attacker,
DemolishAttribute::Extended(ext) => ext.attacker.actor,
}
}
pub fn victim_actor_id(&self) -> boxcars::ActorId {
match self {
DemolishAttribute::Fx(fx) => fx.victim,
DemolishAttribute::Extended(ext) => ext.victim.actor,
}
}
pub fn attacker_velocity(&self) -> boxcars::Vector3f {
match self {
DemolishAttribute::Fx(fx) => fx.attack_velocity,
DemolishAttribute::Extended(ext) => ext.attacker_velocity,
}
}
pub fn victim_velocity(&self) -> boxcars::Vector3f {
match self {
DemolishAttribute::Fx(fx) => fx.victim_velocity,
DemolishAttribute::Extended(ext) => ext.victim_velocity,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct DemolishInfo {
pub time: f32,
pub seconds_remaining: i32,
pub frame: usize,
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub attacker: PlayerId,
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub victim: PlayerId,
#[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
pub attacker_velocity: boxcars::Vector3f,
#[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
pub victim_velocity: boxcars::Vector3f,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attacker_location: Option<boxcars::Vector3f>,
#[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
pub victim_location: boxcars::Vector3f,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum BoostPadEventKind {
PickedUp { sequence: u8 },
Available,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum BoostPadSize {
Big,
Small,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct BoostPadEvent {
pub time: f32,
pub frame: usize,
pub pad_id: String,
#[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
pub player: Option<PlayerId>,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<boxcars::Vector3f>,
pub kind: BoostPadEventKind,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ResolvedBoostPad {
pub index: usize,
pub pad_id: Option<String>,
pub size: BoostPadSize,
#[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
pub position: boxcars::Vector3f,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct GoalEvent {
pub time: f32,
pub frame: usize,
pub scoring_team_is_team_0: bool,
#[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
pub player: Option<PlayerId>,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<boxcars::Vector3f>,
pub team_zero_score: Option<i32>,
pub team_one_score: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ReplayTickMark {
pub description: String,
pub frame: i32,
pub time: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum PlayerStatEventKind {
Shot,
Save,
Assist,
}
const SHOT_TARGET_GOAL_CENTER_Y: f32 = 5120.0;
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ShotSaveMetadata {
pub time: f32,
pub frame: usize,
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub player: PlayerId,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<boxcars::Vector3f>,
pub is_team_0: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ShotEventMetadata {
#[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
pub shot_touch_position: boxcars::Vector3f,
#[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
pub ball_position: boxcars::Vector3f,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
pub ball_velocity: Option<boxcars::Vector3f>,
pub ball_speed: Option<f32>,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
pub player_position: Option<boxcars::Vector3f>,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
pub player_velocity: Option<boxcars::Vector3f>,
pub player_speed: Option<f32>,
pub player_distance_to_ball: Option<f32>,
#[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
pub target_goal_position: boxcars::Vector3f,
pub distance_to_goal_center: f32,
pub distance_to_goal_line: f32,
pub ball_goal_alignment: Option<f32>,
pub ball_speed_toward_goal: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resulting_save: Option<ShotSaveMetadata>,
}
impl ShotEventMetadata {
pub fn from_rigid_bodies(
is_team_0: bool,
ball_body: &boxcars::RigidBody,
player_body: Option<&boxcars::RigidBody>,
) -> Self {
let ball_position = vec_to_glam(&ball_body.location);
let ball_velocity = ball_body.linear_velocity.as_ref().map(vec_to_glam);
let player_position = player_body.map(|body| vec_to_glam(&body.location));
let player_velocity =
player_body.and_then(|body| body.linear_velocity.as_ref().map(vec_to_glam));
let target_goal_y = if is_team_0 {
SHOT_TARGET_GOAL_CENTER_Y
} else {
-SHOT_TARGET_GOAL_CENTER_Y
};
let target_goal_position = glam::Vec3::new(0.0, target_goal_y, ball_position.z);
let goal_vector = target_goal_position - ball_position;
let goal_direction = goal_vector.normalize_or_zero();
let forward_sign = if is_team_0 { 1.0 } else { -1.0 };
let distance_to_goal_line = ((target_goal_y - ball_position.y) * forward_sign).max(0.0);
let ball_goal_alignment = ball_velocity.map(|velocity| {
if velocity.length_squared() <= f32::EPSILON {
0.0
} else {
goal_direction.dot(velocity.normalize_or_zero())
}
});
Self {
shot_touch_position: ball_body.location,
ball_position: ball_body.location,
ball_velocity: ball_body.linear_velocity,
ball_speed: ball_velocity.map(|velocity| velocity.length()),
player_position: player_body.map(|body| body.location),
player_velocity: player_body.and_then(|body| body.linear_velocity),
player_speed: player_velocity.map(|velocity| velocity.length()),
player_distance_to_ball: player_position
.map(|position| (position - ball_position).length()),
target_goal_position: glam_to_vec(&target_goal_position),
distance_to_goal_center: goal_vector.length(),
distance_to_goal_line,
ball_goal_alignment,
ball_speed_toward_goal: ball_velocity.map(|velocity| goal_direction.dot(velocity)),
resulting_save: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct PlayerStatEvent {
pub time: f32,
pub frame: usize,
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub player: PlayerId,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<boxcars::Vector3f>,
pub is_team_0: bool,
pub kind: PlayerStatEventKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shot: Option<ShotEventMetadata>,
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct TouchEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "number")]
pub touch_id: Option<u64>,
pub time: f32,
pub frame: usize,
pub team_is_team_0: bool,
#[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
pub player: Option<PlayerId>,
#[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_position: Option<boxcars::Vector3f>,
pub closest_approach_distance: Option<f32>,
pub dodge_contact: bool,
}
impl TouchEvent {
pub(crate) fn timestamp_ordering(left: &Self, right: &Self) -> std::cmp::Ordering {
left.frame
.cmp(&right.frame)
.then_with(|| left.time.total_cmp(&right.time))
}
}
pub(crate) const TOUCH_RATE_LIMIT_SECONDS: f32 = 0.25;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum ReplayGameType {
Ranked,
Casual,
Private,
Offline,
Lan,
Tournament,
#[default]
Unknown,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ReplayGameTypeDetails {
pub game_type: ReplayGameType,
pub header_match_type: Option<String>,
pub playlist_id: Option<i32>,
pub match_type_class: Option<String>,
}
impl ReplayGameTypeDetails {
pub fn from_headers(headers: &[(String, HeaderProp)]) -> Self {
let header_match_type = headers
.iter()
.find(|(key, _)| key == "MatchType")
.and_then(|(_, value)| value.as_string())
.map(ToOwned::to_owned);
Self::from_signals(header_match_type, None, None)
}
pub fn from_signals(
header_match_type: Option<String>,
playlist_id: Option<i32>,
match_type_class: Option<String>,
) -> Self {
let game_type = infer_replay_game_type(
header_match_type.as_deref(),
playlist_id,
match_type_class.as_deref(),
);
Self {
game_type,
header_match_type,
playlist_id,
match_type_class,
}
}
pub fn with_network_signals(
&self,
playlist_id: Option<i32>,
match_type_class: Option<String>,
) -> Self {
Self::from_signals(
self.header_match_type.clone(),
playlist_id.or(self.playlist_id),
match_type_class.or_else(|| self.match_type_class.clone()),
)
}
}
fn infer_replay_game_type(
header_match_type: Option<&str>,
playlist_id: Option<i32>,
match_type_class: Option<&str>,
) -> ReplayGameType {
if let Some(game_type) = match_type_class.and_then(replay_game_type_from_match_type_class) {
return game_type;
}
if let Some(game_type) = header_match_type.and_then(replay_game_type_from_header_match_type) {
return game_type;
}
if let Some(game_type) = playlist_id.and_then(replay_game_type_from_playlist_id) {
return game_type;
}
ReplayGameType::Unknown
}
fn replay_game_type_from_match_type_class(class_name: &str) -> Option<ReplayGameType> {
let normalized = class_name.to_ascii_lowercase();
if normalized.contains("publicranked") {
Some(ReplayGameType::Ranked)
} else if normalized.contains("private") {
Some(ReplayGameType::Private)
} else if normalized.contains("offline") {
Some(ReplayGameType::Offline)
} else if normalized.contains("lan") {
Some(ReplayGameType::Lan)
} else if normalized.contains("tournament") {
Some(ReplayGameType::Tournament)
} else if normalized.contains("public") {
Some(ReplayGameType::Casual)
} else {
None
}
}
fn replay_game_type_from_playlist_id(playlist_id: i32) -> Option<ReplayGameType> {
match playlist_id {
6 => Some(ReplayGameType::Private),
8 => Some(ReplayGameType::Offline),
1..=4 => Some(ReplayGameType::Casual),
10 | 11 | 13 => Some(ReplayGameType::Ranked),
22 | 34 => Some(ReplayGameType::Tournament),
23 => Some(ReplayGameType::Casual),
27..=30 => Some(ReplayGameType::Ranked),
_ => None,
}
}
fn replay_game_type_from_header_match_type(match_type: &str) -> Option<ReplayGameType> {
match match_type.to_ascii_lowercase().as_str() {
"ranked" => Some(ReplayGameType::Ranked),
"unranked" | "casual" => Some(ReplayGameType::Casual),
"private" => Some(ReplayGameType::Private),
"offline" => Some(ReplayGameType::Offline),
"lan" => Some(ReplayGameType::Lan),
"tournament" => Some(ReplayGameType::Tournament),
"online" => None,
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum SeasonEra {
Legacy,
FreeToPlay,
}
impl SeasonEra {
fn code_prefix(self) -> char {
match self {
SeasonEra::Legacy => 's',
SeasonEra::FreeToPlay => 'f',
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ReplaySeason {
pub era: SeasonEra,
pub number: u8,
}
impl ReplaySeason {
const fn new(era: SeasonEra, number: u8) -> Self {
Self { era, number }
}
pub fn code(self) -> String {
format!("{}{}", self.era.code_prefix(), self.number)
}
pub fn start(self) -> Option<SeasonStart> {
SEASON_BOUNDARIES
.iter()
.find(|(_, season)| *season == self)
.map(|(start, _)| *start)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct SeasonStart {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
}
impl SeasonStart {
const fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> Self {
Self {
year,
month,
day,
hour,
minute,
}
}
const fn date(self) -> (i32, u32, u32) {
(self.year, self.month, self.day)
}
const fn as_datetime_tuple(self) -> (i32, u32, u32, u32, u32) {
(self.year, self.month, self.day, self.hour, self.minute)
}
}
const SEASON_BOUNDARIES: &[(SeasonStart, ReplaySeason)] = &[
(
SeasonStart::new(2016, 2, 10, 17, 0),
ReplaySeason::new(SeasonEra::Legacy, 1),
),
(
SeasonStart::new(2016, 6, 20, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 2),
),
(
SeasonStart::new(2016, 9, 8, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 3),
),
(
SeasonStart::new(2017, 3, 22, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 4),
),
(
SeasonStart::new(2017, 9, 13, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 5),
),
(
SeasonStart::new(2018, 3, 7, 17, 0),
ReplaySeason::new(SeasonEra::Legacy, 6),
),
(
SeasonStart::new(2018, 9, 25, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 7),
),
(
SeasonStart::new(2019, 3, 27, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 8),
),
(
SeasonStart::new(2019, 8, 22, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 9),
),
(
SeasonStart::new(2019, 12, 4, 17, 0),
ReplaySeason::new(SeasonEra::Legacy, 10),
),
(
SeasonStart::new(2020, 4, 8, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 11),
),
(
SeasonStart::new(2020, 7, 8, 16, 0),
ReplaySeason::new(SeasonEra::Legacy, 12),
),
(
SeasonStart::new(2020, 9, 23, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 1),
),
(
SeasonStart::new(2020, 12, 9, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 2),
),
(
SeasonStart::new(2021, 4, 7, 15, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 3),
), (
SeasonStart::new(2021, 8, 11, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 4),
),
(
SeasonStart::new(2021, 11, 17, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 5),
),
(
SeasonStart::new(2022, 3, 9, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 6),
),
(
SeasonStart::new(2022, 6, 15, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 7),
),
(
SeasonStart::new(2022, 9, 7, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 8),
),
(
SeasonStart::new(2022, 12, 7, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 9),
),
(
SeasonStart::new(2023, 3, 8, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 10),
),
(
SeasonStart::new(2023, 6, 7, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 11),
),
(
SeasonStart::new(2023, 9, 6, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 12),
),
(
SeasonStart::new(2023, 12, 6, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 13),
),
(
SeasonStart::new(2024, 3, 6, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 14),
),
(
SeasonStart::new(2024, 6, 5, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 15),
),
(
SeasonStart::new(2024, 9, 4, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 16),
),
(
SeasonStart::new(2024, 12, 4, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 17),
),
(
SeasonStart::new(2025, 3, 14, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 18),
), (
SeasonStart::new(2025, 6, 18, 15, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 19),
), (
SeasonStart::new(2025, 9, 17, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 20),
), (
SeasonStart::new(2025, 12, 10, 17, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 21),
), (
SeasonStart::new(2026, 3, 11, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 22),
), (
SeasonStart::new(2026, 6, 10, 16, 0),
ReplaySeason::new(SeasonEra::FreeToPlay, 23),
), ];
pub fn season_from_headers(headers: &[(String, HeaderProp)]) -> Option<ReplaySeason> {
headers
.iter()
.find(|(key, _)| {
["Date", "ReplayDate", "RecordDate"]
.iter()
.any(|name| key.eq_ignore_ascii_case(name))
})
.and_then(|(_, value)| value.as_string())
.and_then(|s| {
parse_header_datetime_utc(s)
.and_then(season_for_datetime)
.or_else(|| parse_header_date(s).and_then(season_for_date))
})
}
fn season_for_datetime(dt: (i32, u32, u32, u32, u32)) -> Option<ReplaySeason> {
SEASON_BOUNDARIES
.iter()
.rev()
.find(|(start, _)| start.as_datetime_tuple() <= dt)
.map(|(_, season)| *season)
}
fn season_for_date(date: (i32, u32, u32)) -> Option<ReplaySeason> {
SEASON_BOUNDARIES
.iter()
.rev()
.find(|(start, _)| start.date() <= date)
.map(|(_, season)| *season)
}
fn parse_header_datetime_utc(value: &str) -> Option<(i32, u32, u32, u32, u32)> {
let s = value.trim();
if let Some(t_pos) = s.find('T') {
let (year, month, day) = parse_header_date(&s[..t_pos])?;
let rest = s.get(t_pos + 1..)?;
let hour: u32 = rest.get(..2)?.parse().ok()?;
let minute: u32 = rest.get(3..5)?.parse().ok()?;
let offset = rest.get(8..)?;
let sign: i32 = if offset.starts_with('-') { -1 } else { 1 };
let off_h: i32 = offset.get(1..3)?.parse().ok()?;
let utc_mins = hour as i32 * 60 + minute as i32 - sign * off_h * 60;
return normalize_utc_datetime(year, month, day, utc_mins);
}
let (date_part, time_part) = s.split_once(' ')?;
let (year, month, day) = parse_header_date(date_part)?;
let mut tp = time_part.split('-');
let hour: u32 = tp.next()?.parse().ok()?;
let minute: u32 = tp.next()?.parse().ok()?;
normalize_utc_datetime(year, month, day, hour as i32 * 60 + minute as i32 + 5 * 60)
}
fn normalize_utc_datetime(
year: i32,
month: u32,
day: u32,
utc_mins: i32,
) -> Option<(i32, u32, u32, u32, u32)> {
let extra_days = utc_mins.div_euclid(24 * 60);
let mins = utc_mins.rem_euclid(24 * 60);
Some((
year,
month,
(day as i32 + extra_days) as u32,
(mins / 60) as u32,
(mins % 60) as u32,
))
}
fn parse_header_date(value: &str) -> Option<(i32, u32, u32)> {
let date = value.trim().split(['T', ' ']).next()?;
let mut parts = date.split('-');
let year: i32 = parts.next()?.parse().ok()?;
let month: u32 = parts.next()?.parse().ok()?;
let day: u32 = parts.next()?.parse().ok()?;
if (1..=12).contains(&month) && (1..=31).contains(&day) {
Some((year, month, day))
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct ReplayMeta {
pub team_zero: Vec<PlayerInfo>,
pub team_one: Vec<PlayerInfo>,
pub game_type: ReplayGameTypeDetails,
pub season: Option<ReplaySeason>,
#[ts(as = "Vec<(String, crate::interop::ts_bindings::HeaderPropTs)>")]
pub all_headers: Vec<(String, HeaderProp)>,
}
impl ReplayMeta {
pub fn player_count(&self) -> usize {
self.team_one.len() + self.team_zero.len()
}
pub fn player_order(&self) -> impl Iterator<Item = &PlayerInfo> {
self.team_zero.iter().chain(self.team_one.iter())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct PlayerInfo {
#[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
pub remote_id: RemoteId,
#[ts(
as = "Option<std::collections::HashMap<String, crate::interop::ts_bindings::HeaderPropTs>>"
)]
pub stats: Option<std::collections::HashMap<String, HeaderProp>>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub car_body_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub car_body_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub car_hitbox_family: Option<String>,
}
#[cfg(test)]
#[path = "replay_model_tests.rs"]
mod tests;