1use boxcars::{HeaderProp, RemoteId};
2use serde::Serialize;
3
4use crate::{glam_to_vec, vec_to_glam};
5
6pub type PlayerId = boxcars::RemoteId;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DemolishFormat {
11 Fx,
13 Extended,
15}
16
17#[derive(Debug, Clone, PartialEq)]
23pub enum DemolishAttribute {
24 Fx(boxcars::DemolishFx),
25 Extended(boxcars::DemolishExtended),
26}
27
28impl DemolishAttribute {
29 pub fn attacker_actor_id(&self) -> boxcars::ActorId {
30 match self {
31 DemolishAttribute::Fx(fx) => fx.attacker,
32 DemolishAttribute::Extended(ext) => ext.attacker.actor,
33 }
34 }
35
36 pub fn victim_actor_id(&self) -> boxcars::ActorId {
37 match self {
38 DemolishAttribute::Fx(fx) => fx.victim,
39 DemolishAttribute::Extended(ext) => ext.victim.actor,
40 }
41 }
42
43 pub fn attacker_velocity(&self) -> boxcars::Vector3f {
44 match self {
45 DemolishAttribute::Fx(fx) => fx.attack_velocity,
46 DemolishAttribute::Extended(ext) => ext.attacker_velocity,
47 }
48 }
49
50 pub fn victim_velocity(&self) -> boxcars::Vector3f {
51 match self {
52 DemolishAttribute::Fx(fx) => fx.victim_velocity,
53 DemolishAttribute::Extended(ext) => ext.victim_velocity,
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
64#[ts(export)]
65pub struct DemolishInfo {
66 pub time: f32,
68 pub seconds_remaining: i32,
70 pub frame: usize,
72 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
74 pub attacker: PlayerId,
75 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
77 pub victim: PlayerId,
78 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
80 pub attacker_velocity: boxcars::Vector3f,
81 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
83 pub victim_velocity: boxcars::Vector3f,
84 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub attacker_location: Option<boxcars::Vector3f>,
88 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
90 pub victim_location: boxcars::Vector3f,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
94#[ts(export)]
95pub enum BoostPadEventKind {
96 PickedUp { sequence: u8 },
97 Available,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
101#[ts(export)]
102pub enum BoostPadSize {
103 Big,
104 Small,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
108#[ts(export)]
109pub struct BoostPadEvent {
110 pub time: f32,
111 pub frame: usize,
112 pub pad_id: String,
113 #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
114 pub player: Option<PlayerId>,
115 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub player_position: Option<boxcars::Vector3f>,
118 pub kind: BoostPadEventKind,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
122#[ts(export)]
123pub struct ResolvedBoostPad {
124 pub index: usize,
125 pub pad_id: Option<String>,
126 pub size: BoostPadSize,
127 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
128 pub position: boxcars::Vector3f,
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
132#[ts(export)]
133pub struct GoalEvent {
134 pub time: f32,
135 pub frame: usize,
136 pub scoring_team_is_team_0: bool,
137 #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
138 pub player: Option<PlayerId>,
139 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub player_position: Option<boxcars::Vector3f>,
142 pub team_zero_score: Option<i32>,
143 pub team_one_score: Option<i32>,
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
153#[ts(export)]
154pub struct ReplayTickMark {
155 pub description: String,
156 pub frame: i32,
157 pub time: Option<f32>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
161#[ts(export)]
162pub enum PlayerStatEventKind {
163 Shot,
164 Save,
165 Assist,
166}
167
168const SHOT_TARGET_GOAL_CENTER_Y: f32 = 5120.0;
169
170#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
171#[ts(export)]
172pub struct ShotSaveMetadata {
173 pub time: f32,
174 pub frame: usize,
175 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
176 pub player: PlayerId,
177 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub player_position: Option<boxcars::Vector3f>,
180 pub is_team_0: bool,
181}
182
183#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
184#[ts(export)]
185pub struct ShotEventMetadata {
186 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
187 pub shot_touch_position: boxcars::Vector3f,
188 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
189 pub ball_position: boxcars::Vector3f,
190 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
191 pub ball_velocity: Option<boxcars::Vector3f>,
192 pub ball_speed: Option<f32>,
193 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
194 pub player_position: Option<boxcars::Vector3f>,
195 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
196 pub player_velocity: Option<boxcars::Vector3f>,
197 pub player_speed: Option<f32>,
198 pub player_distance_to_ball: Option<f32>,
199 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
200 pub target_goal_position: boxcars::Vector3f,
201 pub distance_to_goal_center: f32,
202 pub distance_to_goal_line: f32,
203 pub ball_goal_alignment: Option<f32>,
204 pub ball_speed_toward_goal: Option<f32>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub resulting_save: Option<ShotSaveMetadata>,
207}
208
209impl ShotEventMetadata {
210 pub fn from_rigid_bodies(
211 is_team_0: bool,
212 ball_body: &boxcars::RigidBody,
213 player_body: Option<&boxcars::RigidBody>,
214 ) -> Self {
215 let ball_position = vec_to_glam(&ball_body.location);
216 let ball_velocity = ball_body.linear_velocity.as_ref().map(vec_to_glam);
217 let player_position = player_body.map(|body| vec_to_glam(&body.location));
218 let player_velocity =
219 player_body.and_then(|body| body.linear_velocity.as_ref().map(vec_to_glam));
220 let target_goal_y = if is_team_0 {
221 SHOT_TARGET_GOAL_CENTER_Y
222 } else {
223 -SHOT_TARGET_GOAL_CENTER_Y
224 };
225 let target_goal_position = glam::Vec3::new(0.0, target_goal_y, ball_position.z);
226 let goal_vector = target_goal_position - ball_position;
227 let goal_direction = goal_vector.normalize_or_zero();
228 let forward_sign = if is_team_0 { 1.0 } else { -1.0 };
229 let distance_to_goal_line = ((target_goal_y - ball_position.y) * forward_sign).max(0.0);
230 let ball_goal_alignment = ball_velocity.map(|velocity| {
231 if velocity.length_squared() <= f32::EPSILON {
232 0.0
233 } else {
234 goal_direction.dot(velocity.normalize_or_zero())
235 }
236 });
237
238 Self {
239 shot_touch_position: ball_body.location,
240 ball_position: ball_body.location,
241 ball_velocity: ball_body.linear_velocity,
242 ball_speed: ball_velocity.map(|velocity| velocity.length()),
243 player_position: player_body.map(|body| body.location),
244 player_velocity: player_body.and_then(|body| body.linear_velocity),
245 player_speed: player_velocity.map(|velocity| velocity.length()),
246 player_distance_to_ball: player_position
247 .map(|position| (position - ball_position).length()),
248 target_goal_position: glam_to_vec(&target_goal_position),
249 distance_to_goal_center: goal_vector.length(),
250 distance_to_goal_line,
251 ball_goal_alignment,
252 ball_speed_toward_goal: ball_velocity.map(|velocity| goal_direction.dot(velocity)),
253 resulting_save: None,
254 }
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
259#[ts(export)]
260pub struct PlayerStatEvent {
261 pub time: f32,
262 pub frame: usize,
263 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
264 pub player: PlayerId,
265 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub player_position: Option<boxcars::Vector3f>,
268 pub is_team_0: bool,
269 pub kind: PlayerStatEventKind,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub shot: Option<ShotEventMetadata>,
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
275#[ts(export)]
276pub struct TouchEvent {
277 #[serde(default, skip_serializing_if = "Option::is_none")]
283 #[ts(type = "number")]
284 pub touch_id: Option<u64>,
285 pub time: f32,
286 pub frame: usize,
287 pub team_is_team_0: bool,
288 #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
289 pub player: Option<PlayerId>,
290 #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub player_position: Option<boxcars::Vector3f>,
293 pub closest_approach_distance: Option<f32>,
299 pub dodge_contact: bool,
300}
301
302impl TouchEvent {
303 pub(crate) fn timestamp_ordering(left: &Self, right: &Self) -> std::cmp::Ordering {
304 left.frame
305 .cmp(&right.frame)
306 .then_with(|| left.time.total_cmp(&right.time))
307 }
308}
309
310pub(crate) const TOUCH_RATE_LIMIT_SECONDS: f32 = 0.25;
311
312#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
314#[ts(export)]
315pub enum ReplayGameType {
316 Ranked,
318 Casual,
320 Private,
322 Offline,
324 Lan,
326 Tournament,
328 #[default]
330 Unknown,
331}
332
333#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
335#[ts(export)]
336pub struct ReplayGameTypeDetails {
337 pub game_type: ReplayGameType,
339 pub header_match_type: Option<String>,
341 pub playlist_id: Option<i32>,
343 pub match_type_class: Option<String>,
345}
346
347impl ReplayGameTypeDetails {
348 pub fn from_headers(headers: &[(String, HeaderProp)]) -> Self {
349 let header_match_type = headers
350 .iter()
351 .find(|(key, _)| key == "MatchType")
352 .and_then(|(_, value)| value.as_string())
353 .map(ToOwned::to_owned);
354
355 Self::from_signals(header_match_type, None, None)
356 }
357
358 pub fn from_signals(
359 header_match_type: Option<String>,
360 playlist_id: Option<i32>,
361 match_type_class: Option<String>,
362 ) -> Self {
363 let game_type = infer_replay_game_type(
364 header_match_type.as_deref(),
365 playlist_id,
366 match_type_class.as_deref(),
367 );
368 Self {
369 game_type,
370 header_match_type,
371 playlist_id,
372 match_type_class,
373 }
374 }
375
376 pub fn with_network_signals(
377 &self,
378 playlist_id: Option<i32>,
379 match_type_class: Option<String>,
380 ) -> Self {
381 Self::from_signals(
382 self.header_match_type.clone(),
383 playlist_id.or(self.playlist_id),
384 match_type_class.or_else(|| self.match_type_class.clone()),
385 )
386 }
387}
388
389fn infer_replay_game_type(
390 header_match_type: Option<&str>,
391 playlist_id: Option<i32>,
392 match_type_class: Option<&str>,
393) -> ReplayGameType {
394 if let Some(game_type) = match_type_class.and_then(replay_game_type_from_match_type_class) {
395 return game_type;
396 }
397 if let Some(game_type) = header_match_type.and_then(replay_game_type_from_header_match_type) {
398 return game_type;
399 }
400 if let Some(game_type) = playlist_id.and_then(replay_game_type_from_playlist_id) {
401 return game_type;
402 }
403 ReplayGameType::Unknown
404}
405
406fn replay_game_type_from_match_type_class(class_name: &str) -> Option<ReplayGameType> {
407 let normalized = class_name.to_ascii_lowercase();
408 if normalized.contains("publicranked") {
409 Some(ReplayGameType::Ranked)
410 } else if normalized.contains("private") {
411 Some(ReplayGameType::Private)
412 } else if normalized.contains("offline") {
413 Some(ReplayGameType::Offline)
414 } else if normalized.contains("lan") {
415 Some(ReplayGameType::Lan)
416 } else if normalized.contains("tournament") {
417 Some(ReplayGameType::Tournament)
418 } else if normalized.contains("public") {
419 Some(ReplayGameType::Casual)
420 } else {
421 None
422 }
423}
424
425fn replay_game_type_from_playlist_id(playlist_id: i32) -> Option<ReplayGameType> {
426 match playlist_id {
427 6 => Some(ReplayGameType::Private),
430 8 => Some(ReplayGameType::Offline),
431 1..=4 => Some(ReplayGameType::Casual),
433 10 | 11 | 13 => Some(ReplayGameType::Ranked),
435 22 | 34 => Some(ReplayGameType::Tournament),
437 23 => Some(ReplayGameType::Casual),
439 27..=30 => Some(ReplayGameType::Ranked),
441 _ => None,
442 }
443}
444
445fn replay_game_type_from_header_match_type(match_type: &str) -> Option<ReplayGameType> {
446 match match_type.to_ascii_lowercase().as_str() {
447 "ranked" => Some(ReplayGameType::Ranked),
448 "unranked" | "casual" => Some(ReplayGameType::Casual),
449 "private" => Some(ReplayGameType::Private),
450 "offline" => Some(ReplayGameType::Offline),
451 "lan" => Some(ReplayGameType::Lan),
452 "tournament" => Some(ReplayGameType::Tournament),
453 "online" => None,
455 _ => None,
456 }
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
465#[ts(export)]
466pub enum SeasonEra {
467 Legacy,
469 FreeToPlay,
471}
472
473impl SeasonEra {
474 fn code_prefix(self) -> char {
476 match self {
477 SeasonEra::Legacy => 's',
478 SeasonEra::FreeToPlay => 'f',
479 }
480 }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
485#[ts(export)]
486pub struct ReplaySeason {
487 pub era: SeasonEra,
489 pub number: u8,
491}
492
493impl ReplaySeason {
494 const fn new(era: SeasonEra, number: u8) -> Self {
495 Self { era, number }
496 }
497
498 pub fn code(self) -> String {
501 format!("{}{}", self.era.code_prefix(), self.number)
502 }
503
504 pub fn start(self) -> Option<SeasonStart> {
510 SEASON_BOUNDARIES
511 .iter()
512 .find(|(_, season)| *season == self)
513 .map(|(start, _)| *start)
514 }
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
526#[ts(export)]
527pub struct SeasonStart {
528 pub year: i32,
530 pub month: u32,
532 pub day: u32,
534 pub hour: u32,
536 pub minute: u32,
538}
539
540impl SeasonStart {
541 const fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> Self {
542 Self {
543 year,
544 month,
545 day,
546 hour,
547 minute,
548 }
549 }
550
551 const fn date(self) -> (i32, u32, u32) {
554 (self.year, self.month, self.day)
555 }
556
557 const fn as_datetime_tuple(self) -> (i32, u32, u32, u32, u32) {
559 (self.year, self.month, self.day, self.hour, self.minute)
560 }
561}
562
563const SEASON_BOUNDARIES: &[(SeasonStart, ReplaySeason)] = &[
592 (
594 SeasonStart::new(2016, 2, 10, 17, 0),
595 ReplaySeason::new(SeasonEra::Legacy, 1),
596 ),
597 (
598 SeasonStart::new(2016, 6, 20, 16, 0),
599 ReplaySeason::new(SeasonEra::Legacy, 2),
600 ),
601 (
602 SeasonStart::new(2016, 9, 8, 16, 0),
603 ReplaySeason::new(SeasonEra::Legacy, 3),
604 ),
605 (
606 SeasonStart::new(2017, 3, 22, 16, 0),
607 ReplaySeason::new(SeasonEra::Legacy, 4),
608 ),
609 (
610 SeasonStart::new(2017, 9, 13, 16, 0),
611 ReplaySeason::new(SeasonEra::Legacy, 5),
612 ),
613 (
614 SeasonStart::new(2018, 3, 7, 17, 0),
615 ReplaySeason::new(SeasonEra::Legacy, 6),
616 ),
617 (
618 SeasonStart::new(2018, 9, 25, 16, 0),
619 ReplaySeason::new(SeasonEra::Legacy, 7),
620 ),
621 (
622 SeasonStart::new(2019, 3, 27, 16, 0),
623 ReplaySeason::new(SeasonEra::Legacy, 8),
624 ),
625 (
626 SeasonStart::new(2019, 8, 22, 16, 0),
627 ReplaySeason::new(SeasonEra::Legacy, 9),
628 ),
629 (
630 SeasonStart::new(2019, 12, 4, 17, 0),
631 ReplaySeason::new(SeasonEra::Legacy, 10),
632 ),
633 (
634 SeasonStart::new(2020, 4, 8, 16, 0),
635 ReplaySeason::new(SeasonEra::Legacy, 11),
636 ),
637 (
638 SeasonStart::new(2020, 7, 8, 16, 0),
639 ReplaySeason::new(SeasonEra::Legacy, 12),
640 ),
641 (
643 SeasonStart::new(2020, 9, 23, 16, 0),
644 ReplaySeason::new(SeasonEra::FreeToPlay, 1),
645 ),
646 (
647 SeasonStart::new(2020, 12, 9, 17, 0),
648 ReplaySeason::new(SeasonEra::FreeToPlay, 2),
649 ),
650 (
651 SeasonStart::new(2021, 4, 7, 15, 0),
652 ReplaySeason::new(SeasonEra::FreeToPlay, 3),
653 ), (
655 SeasonStart::new(2021, 8, 11, 16, 0),
656 ReplaySeason::new(SeasonEra::FreeToPlay, 4),
657 ),
658 (
659 SeasonStart::new(2021, 11, 17, 17, 0),
660 ReplaySeason::new(SeasonEra::FreeToPlay, 5),
661 ),
662 (
663 SeasonStart::new(2022, 3, 9, 17, 0),
664 ReplaySeason::new(SeasonEra::FreeToPlay, 6),
665 ),
666 (
667 SeasonStart::new(2022, 6, 15, 16, 0),
668 ReplaySeason::new(SeasonEra::FreeToPlay, 7),
669 ),
670 (
671 SeasonStart::new(2022, 9, 7, 16, 0),
672 ReplaySeason::new(SeasonEra::FreeToPlay, 8),
673 ),
674 (
675 SeasonStart::new(2022, 12, 7, 17, 0),
676 ReplaySeason::new(SeasonEra::FreeToPlay, 9),
677 ),
678 (
679 SeasonStart::new(2023, 3, 8, 17, 0),
680 ReplaySeason::new(SeasonEra::FreeToPlay, 10),
681 ),
682 (
683 SeasonStart::new(2023, 6, 7, 16, 0),
684 ReplaySeason::new(SeasonEra::FreeToPlay, 11),
685 ),
686 (
687 SeasonStart::new(2023, 9, 6, 16, 0),
688 ReplaySeason::new(SeasonEra::FreeToPlay, 12),
689 ),
690 (
691 SeasonStart::new(2023, 12, 6, 17, 0),
692 ReplaySeason::new(SeasonEra::FreeToPlay, 13),
693 ),
694 (
695 SeasonStart::new(2024, 3, 6, 17, 0),
696 ReplaySeason::new(SeasonEra::FreeToPlay, 14),
697 ),
698 (
699 SeasonStart::new(2024, 6, 5, 16, 0),
700 ReplaySeason::new(SeasonEra::FreeToPlay, 15),
701 ),
702 (
703 SeasonStart::new(2024, 9, 4, 16, 0),
704 ReplaySeason::new(SeasonEra::FreeToPlay, 16),
705 ),
706 (
707 SeasonStart::new(2024, 12, 4, 17, 0),
708 ReplaySeason::new(SeasonEra::FreeToPlay, 17),
709 ),
710 (
711 SeasonStart::new(2025, 3, 14, 16, 0),
712 ReplaySeason::new(SeasonEra::FreeToPlay, 18),
713 ), (
715 SeasonStart::new(2025, 6, 18, 15, 0),
716 ReplaySeason::new(SeasonEra::FreeToPlay, 19),
717 ), (
719 SeasonStart::new(2025, 9, 17, 16, 0),
720 ReplaySeason::new(SeasonEra::FreeToPlay, 20),
721 ), (
723 SeasonStart::new(2025, 12, 10, 17, 0),
724 ReplaySeason::new(SeasonEra::FreeToPlay, 21),
725 ), (
727 SeasonStart::new(2026, 3, 11, 16, 0),
728 ReplaySeason::new(SeasonEra::FreeToPlay, 22),
729 ), (
731 SeasonStart::new(2026, 6, 10, 16, 0),
732 ReplaySeason::new(SeasonEra::FreeToPlay, 23),
733 ), ];
735
736pub fn season_from_headers(headers: &[(String, HeaderProp)]) -> Option<ReplaySeason> {
740 headers
741 .iter()
742 .find(|(key, _)| {
743 ["Date", "ReplayDate", "RecordDate"]
744 .iter()
745 .any(|name| key.eq_ignore_ascii_case(name))
746 })
747 .and_then(|(_, value)| value.as_string())
748 .and_then(|s| {
749 parse_header_datetime_utc(s)
750 .and_then(season_for_datetime)
751 .or_else(|| parse_header_date(s).and_then(season_for_date))
752 })
753}
754
755fn season_for_datetime(dt: (i32, u32, u32, u32, u32)) -> Option<ReplaySeason> {
757 SEASON_BOUNDARIES
758 .iter()
759 .rev()
760 .find(|(start, _)| start.as_datetime_tuple() <= dt)
761 .map(|(_, season)| *season)
762}
763
764fn season_for_date(date: (i32, u32, u32)) -> Option<ReplaySeason> {
766 SEASON_BOUNDARIES
767 .iter()
768 .rev()
769 .find(|(start, _)| start.date() <= date)
770 .map(|(_, season)| *season)
771}
772
773fn parse_header_datetime_utc(value: &str) -> Option<(i32, u32, u32, u32, u32)> {
780 let s = value.trim();
781 if let Some(t_pos) = s.find('T') {
782 let (year, month, day) = parse_header_date(&s[..t_pos])?;
784 let rest = s.get(t_pos + 1..)?;
785 let hour: u32 = rest.get(..2)?.parse().ok()?;
786 let minute: u32 = rest.get(3..5)?.parse().ok()?;
787 let offset = rest.get(8..)?;
789 let sign: i32 = if offset.starts_with('-') { -1 } else { 1 };
790 let off_h: i32 = offset.get(1..3)?.parse().ok()?;
791 let utc_mins = hour as i32 * 60 + minute as i32 - sign * off_h * 60;
792 return normalize_utc_datetime(year, month, day, utc_mins);
793 }
794 let (date_part, time_part) = s.split_once(' ')?;
796 let (year, month, day) = parse_header_date(date_part)?;
797 let mut tp = time_part.split('-');
798 let hour: u32 = tp.next()?.parse().ok()?;
799 let minute: u32 = tp.next()?.parse().ok()?;
800 normalize_utc_datetime(year, month, day, hour as i32 * 60 + minute as i32 + 5 * 60)
801}
802
803fn normalize_utc_datetime(
807 year: i32,
808 month: u32,
809 day: u32,
810 utc_mins: i32,
811) -> Option<(i32, u32, u32, u32, u32)> {
812 let extra_days = utc_mins.div_euclid(24 * 60);
813 let mins = utc_mins.rem_euclid(24 * 60);
814 Some((
815 year,
816 month,
817 (day as i32 + extra_days) as u32,
818 (mins / 60) as u32,
819 (mins % 60) as u32,
820 ))
821}
822
823fn parse_header_date(value: &str) -> Option<(i32, u32, u32)> {
828 let date = value.trim().split(['T', ' ']).next()?;
829 let mut parts = date.split('-');
830 let year: i32 = parts.next()?.parse().ok()?;
831 let month: u32 = parts.next()?.parse().ok()?;
832 let day: u32 = parts.next()?.parse().ok()?;
833 if (1..=12).contains(&month) && (1..=31).contains(&day) {
834 Some((year, month, day))
835 } else {
836 None
837 }
838}
839
840#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
844#[ts(export)]
845pub struct ReplayMeta {
846 pub team_zero: Vec<PlayerInfo>,
848 pub team_one: Vec<PlayerInfo>,
850 pub game_type: ReplayGameTypeDetails,
852 pub season: Option<ReplaySeason>,
854 #[ts(as = "Vec<(String, crate::interop::ts_bindings::HeaderPropTs)>")]
856 pub all_headers: Vec<(String, HeaderProp)>,
857}
858
859impl ReplayMeta {
860 pub fn player_count(&self) -> usize {
862 self.team_one.len() + self.team_zero.len()
863 }
864
865 pub fn player_order(&self) -> impl Iterator<Item = &PlayerInfo> {
868 self.team_zero.iter().chain(self.team_one.iter())
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
876#[ts(export)]
877pub struct PlayerInfo {
878 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
880 pub remote_id: RemoteId,
881 #[ts(
885 as = "Option<std::collections::HashMap<String, crate::interop::ts_bindings::HeaderPropTs>>"
886 )]
887 pub stats: Option<std::collections::HashMap<String, HeaderProp>>,
888 pub name: String,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
892 pub car_body_id: Option<u32>,
893 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub car_body_name: Option<String>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub car_hitbox_family: Option<String>,
899}
900
901#[cfg(test)]
902#[path = "replay_model_tests.rs"]
903mod tests;