1use std::{fmt::Display, num::NonZeroUsize, ops::{Deref, DerefMut}};
2
3use bon::Builder;
4use chrono::{DateTime, Utc};
5use derive_more::{Deref, DerefMut, Display};
6use serde::{Deserialize, Deserializer, de::IgnoredAny};
7use serde_with::{serde_as, DefaultOnNull, DefaultOnError};
8use uuid::Uuid;
9
10use crate::{Copyright, Handedness, HomeAway, game::{AtBatCount, Base, BattingOrderIndex, ContactHardness, GameId, Inning, InningHalf}, meta::{EventType, HitTrajectory, NamedPosition, PitchCodeId, PitchType, ReviewReasonId}, person::{NamedPerson, PersonId}, request::RequestURL, stats::raw::{HittingHotColdZones, PitchingHotColdZones, StrikeZoneSection}, team::TeamId};
11
12#[allow(clippy::struct_field_names, clippy::unsafe_derive_deserialize, reason = "not relevant here")]
14#[derive(Debug, Deserialize, PartialEq, Clone, Deref)]
15#[serde(rename_all = "camelCase")]
16#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
17pub struct Plays {
18 #[serde(default)]
19 pub copyright: Copyright,
20
21 #[deref]
22 #[serde(rename = "allPlays")]
23 plays: Vec<Play>,
24
25 pub current_play: Option<Play>,
27
28 #[serde(rename = "scoringPlays")]
29 pub(super) scoring_play_indices: Vec<usize>,
30
31 #[serde(rename = "playsByInning")]
32 pub(super) play_indices_by_inning: Vec<InningPlaysIndices>,
33}
34
35impl IntoIterator for Plays {
36 type Item = Play;
37 type IntoIter = <Vec<Play> as IntoIterator>::IntoIter;
38
39 fn into_iter(self) -> Self::IntoIter {
40 self.plays.into_iter()
41 }
42}
43
44impl<'a> IntoIterator for &'a Plays {
45 type Item = &'a Play;
46 type IntoIter = std::slice::Iter<'a, Play>;
47
48 fn into_iter(self) -> Self::IntoIter {
49 self.plays.iter()
50 }
51}
52
53impl Plays {
54 #[must_use]
59 pub const unsafe fn plays_mut(&mut self) -> &mut Vec<Play> {
60 &mut self.plays
61 }
62
63 #[must_use]
65 pub fn into_plays(self) -> Vec<Play> {
66 self.plays
67 }
68
69 pub fn scoring_plays(&self) -> impl Iterator<Item=&Play> {
80 self.scoring_play_indices.iter()
81 .filter_map(|&idx| self.plays.get(idx))
82 }
83
84 pub fn by_inning(&self) -> impl Iterator<Item=impl Iterator<Item=&Play>> {
97 self.play_indices_by_inning.iter()
98 .map(|inning| (inning.start..=inning.end)
99 .filter_map(|idx| self.plays.get(idx)))
100 }
101
102 pub fn by_inning_halves(&self) -> impl Iterator<Item=(impl Iterator<Item=&Play>, impl Iterator<Item=&Play>)> {
118 self.play_indices_by_inning.iter()
119 .map(|inning| (
120 inning.top_indices.iter().filter_map(|&idx| self.plays.get(idx)),
121 inning.bottom_indices.iter().filter_map(|&idx| self.plays.get(idx))
122 ))
123 }
124}
125
126#[derive(Debug, Deserialize, PartialEq, Clone)]
127#[serde(rename_all = "camelCase")]
128#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
129pub(super) struct InningPlaysIndices {
130 #[serde(rename = "startIndex")]
131 pub(super) start: usize,
132 #[serde(rename = "endIndex")]
133 pub(super) end: usize,
134 #[serde(rename = "top")]
135 pub(super) top_indices: Vec<usize>,
136 #[serde(rename = "bottom")]
137 pub(super) bottom_indices: Vec<usize>,
138 #[doc(hidden)]
139 #[serde(rename = "hits", default)]
140 pub(super) __balls_in_play: IgnoredAny,
141}
142
143#[derive(Debug, Deserialize, PartialEq, Clone)]
147#[serde(rename_all = "camelCase")]
148#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
149pub struct Play {
150 pub result: PlayDetails,
152 pub about: PlayAbout,
154 pub count: AtBatCount,
156 pub matchup: PlayMatchup,
158 pub play_events: Vec<PlayEvent>,
160 pub runners: Vec<RunnerData>,
161 #[serde(rename = "reviewDetails", default, deserialize_with = "deserialize_review_data")]
162 pub reviews: Vec<ReviewData>,
163
164 #[serde(rename = "playEndTime", deserialize_with = "crate::deserialize_datetime")]
166 pub play_end_timestamp: DateTime<Utc>,
167
168 #[doc(hidden)]
169 #[serde(rename = "pitchIndex", default)]
170 pub __pitch_indices: IgnoredAny,
171 #[doc(hidden)]
172 #[serde(rename = "actionIndex", default)]
173 pub __action_indices: IgnoredAny,
174 #[doc(hidden)]
175 #[serde(rename = "runnerIndex", default)]
176 pub __runner_indices: IgnoredAny,
177 #[doc(hidden)]
178 #[serde(rename = "atBatIndex", default)]
179 pub __at_bat_index: IgnoredAny,
180}
181
182#[derive(Debug, Deserialize, PartialEq, Clone)]
184#[serde(rename_all = "camelCase")]
185#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
186pub struct PlayDetails {
187 #[serde(flatten, default)]
188 pub completed_play_details: Option<CompletedPlayDetails>,
189
190 pub away_score: usize,
192 pub home_score: usize,
194
195 #[doc(hidden)]
196 #[serde(rename = "event", default)]
197 pub __event: IgnoredAny,
198
199 #[doc(hidden)]
200 #[serde(rename = "type", default)]
201 pub __type: IgnoredAny,
202}
203
204#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
206#[serde(rename_all = "camelCase")]
207#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
208pub struct CompletedPlayDetails {
209 #[serde(rename = "eventType")]
210 pub event: EventType,
211 pub description: String,
213 pub rbi: usize,
215 pub is_out: bool,
217}
218
219#[allow(clippy::struct_excessive_bools, reason = "inapplicable")]
221#[derive(Debug, Deserialize, PartialEq, Clone)]
222#[serde(rename_all = "camelCase")]
223#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
224pub struct PlayAbout {
225 #[serde(rename = "atBatIndex")]
227 pub at_bat_idx: usize,
228
229 #[serde(rename = "halfInning")]
231 pub inning_half: InningHalf,
232
233 pub inning: Inning,
235
236 #[serde(rename = "startTime", deserialize_with = "crate::deserialize_datetime")]
238 pub start_timestamp: DateTime<Utc>,
239
240 #[serde(rename = "endTime", deserialize_with = "crate::deserialize_datetime")]
242 pub end_timestamp: DateTime<Utc>,
243
244 pub is_complete: bool,
248
249 pub is_scoring_play: Option<bool>,
255
256 #[serde(default)]
258 pub has_review: bool,
259
260 #[serde(default)]
264 pub has_out: bool,
265
266 #[serde(default)]
271 pub captivating_index: usize,
272
273 #[doc(hidden)]
274 #[serde(rename = "isTopInning")]
275 pub __is_top_inning: IgnoredAny,
276}
277
278#[derive(Debug, Deserialize, PartialEq, Clone)]
280#[serde(rename_all = "camelCase")]
281#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
282pub struct PlayMatchup {
283 pub batter: NamedPerson,
284 pub pitcher: NamedPerson,
285 pub bat_side: Handedness,
287 pub pitch_hand: Handedness,
289 pub post_on_first: Option<NamedPerson>,
290 pub post_on_second: Option<NamedPerson>,
291 pub post_on_third: Option<NamedPerson>,
292
293 #[doc(hidden)]
294 #[serde(rename = "batterHotColdZones", default)]
295 pub __batter_hot_cold_zones: IgnoredAny,
296 #[doc(hidden)]
297 #[serde(rename = "pitcherHotColdZones", default)]
298 pub __pitcher_hot_cold_zones: IgnoredAny,
299 #[doc(hidden)]
300 #[serde(rename = "batterHotColdZoneStats", default)]
301 pub __batter_hot_cold_zone_stats: IgnoredAny,
302 #[doc(hidden)]
303 #[serde(rename = "pitcherHotColdZoneStats", default)]
304 pub __pitcher_hot_cold_zone_stats: IgnoredAny,
305
306 pub splits: ApplicablePlayMatchupSplits,
310}
311
312#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
314#[serde(rename_all = "camelCase")]
315#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
316pub struct ApplicablePlayMatchupSplits {
317 pub batter: String,
318 pub pitcher: String,
319 pub men_on_base: String,
320}
321
322#[derive(Debug, Deserialize, PartialEq, Clone)]
324#[serde(rename_all = "camelCase")]
325#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
326pub struct RunnerData {
327 pub movement: RunnerMovement,
328 pub details: RunnerDetails,
329 #[serde(default)]
330 pub credits: Vec<RunnerCredit>,
331}
332
333#[serde_as]
335#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
336#[serde(rename_all = "camelCase")]
337#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
338pub struct RunnerMovement {
339 pub origin_base: Option<Base>,
341
342 #[serde(rename = "start")]
344 pub start_base: Option<Base>,
345
346 #[serde(rename = "end")]
348 pub end_base: Option<Base>,
349
350 pub out_base: Option<Base>,
352
353 #[serde_as(deserialize_as = "DefaultOnNull")]
355 #[serde(default)]
356 pub is_out: bool,
357
358 pub out_number: Option<usize>,
360}
361
362#[derive(Debug, Deserialize, PartialEq, Clone)]
364#[serde(rename_all = "camelCase")]
365#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
366pub struct RunnerDetails {
367 pub movement_reason: Option<MovementReason>,
369 pub runner: NamedPerson,
370 pub is_scoring_event: bool,
371 #[serde(rename = "rbi")]
372 pub is_rbi: bool,
373 #[serde(rename = "earned")]
374 pub is_earned: bool,
375
376 #[doc(hidden)]
378 #[serde(rename = "eventType", default)]
379 pub __event_tyoe: IgnoredAny,
380
381 #[doc(hidden)]
382 #[serde(rename = "event", default)]
383 pub __event_type: IgnoredAny,
384
385 #[doc(hidden)]
386 #[serde(rename = "responsiblePitcher", default)]
387 pub __responsible_pitcher: IgnoredAny,
388
389 #[doc(hidden)]
390 #[serde(rename = "teamUnearned", default)]
391 pub __team_unearned: IgnoredAny,
392
393 #[doc(hidden)]
394 #[serde(rename = "playIndex", default)]
395 pub __play_index: IgnoredAny,
396}
397
398#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
400pub enum MovementReason {
401 #[display("Unforced Base Advancement")]
403 #[serde(rename = "r_adv_play")]
404 AdvancementUnforced,
405
406 #[display("Forced Base Advancement")]
408 #[serde(rename = "r_adv_force")]
409 AdancementForced,
410
411 #[display("Advancement from Throw")]
413 #[serde(rename = "r_adv_throw")]
414 AdvancementThrow,
415
416 #[display("Doubled Off")]
418 #[serde(rename = "r_doubled_off")]
419 DoubledOff,
420
421 #[display("Thrown Out")]
422 #[serde(rename = "r_thrown_out")]
423 ThrownOut,
424
425 #[display("Called Out Returning")]
426 #[serde(rename = "r_out_returning")]
427 CalledOutReturning,
428
429 #[display("Forced Out")]
431 #[serde(rename = "r_force_out")]
432 ForceOut,
433
434 #[display("Runner Called Out")]
436 #[serde(rename = "r_runner_out")]
437 RunnerCalledOut,
438
439 #[display("Defensive Indifference")]
441 #[serde(rename = "r_defensive_indiff")]
442 DefensiveIndifference,
443
444 #[display("Rundown")]
445 #[serde(rename = "r_rundown")]
446 Rundown,
447
448 #[display("Out Stretching")]
450 #[serde(rename = "r_out_stretching")]
451 OutStretching,
452
453 #[display("Stolen Base (2B)")]
454 #[serde(rename = "r_stolen_base_2b")]
455 StolenBase2B,
456
457 #[display("Stolen Base (3B)")]
458 #[serde(rename = "r_stolen_base_3b")]
459 StolenBase3B,
460
461 #[display("Stolen Base (HP)")]
462 #[serde(rename = "r_stolen_base_home")]
463 StolenBaseHome,
464
465 #[display("Caught Stealing (2B)")]
466 #[serde(rename = "r_caught_stealing_2b")]
467 CaughtStealing2B,
468
469 #[display("Caught Stealing (3B)")]
470 #[serde(rename = "r_caught_stealing_3b")]
471 CaughtStealing3B,
472
473 #[display("Caught Stealing (HP)")]
474 #[serde(rename = "r_caught_stealing_home")]
475 CaughtStealingHome,
476
477 #[display("Pickoff (1B)")]
479 #[serde(rename = "r_pickoff_1b")]
480 Pickoff1B,
481
482 #[display("Pickoff (2B)")]
484 #[serde(rename = "r_pickoff_2b")]
485 Pickoff2B,
486
487 #[display("Pickoff (3B)")]
489 #[serde(rename = "r_pickoff_3b")]
490 Pickoff3B,
491
492 #[display("Pickoff (Error) (1B)")]
493 #[serde(rename = "r_pickoff_error_1b")]
494 PickoffError1B,
495
496 #[display("Pickoff (Error) (2B)")]
497 #[serde(rename = "r_pickoff_error_2b")]
498 PickoffError2B,
499
500 #[display("Pickoff (Error) (3B)")]
501 #[serde(rename = "r_pickoff_error_3b")]
502 PickoffError3B,
503
504 #[display("Pickoff (Caught Stealing) (2B)")]
505 #[serde(rename = "r_pickoff_caught_stealing_2b")]
506 PickoffCaughtStealing2B,
507
508 #[display("Pickoff (Caught Stealing) (3B)")]
509 #[serde(rename = "r_pickoff_caught_stealing_3b")]
510 PickoffCaughtStealing3B,
511
512 #[display("Pickoff (Caught Stealing) (HP)")]
513 #[serde(rename = "r_pickoff_caught_stealing_home")]
514 PickoffCaughtStealingHome,
515
516 #[display("Interference")]
518 #[serde(rename = "r_interference")]
519 Interference,
520
521 #[display("Hit By Ball")]
522 #[serde(rename = "r_hbr")]
523 HitByBall,
524}
525
526impl MovementReason {
527 #[must_use]
529 pub const fn is_pickoff(self) -> bool {
530 matches!(self, Self::Pickoff1B | Self::Pickoff2B | Self::Pickoff3B | Self::PickoffError1B | Self::PickoffError2B | Self::PickoffError3B | Self::PickoffCaughtStealing2B | Self::PickoffCaughtStealing3B | Self::PickoffCaughtStealingHome)
531 }
532
533 #[must_use]
535 pub const fn is_stolen_base_attempt(self) -> bool {
536 matches!(self, Self::StolenBase2B | Self::StolenBase3B | Self::StolenBaseHome | Self::CaughtStealing2B | Self::CaughtStealing3B | Self::CaughtStealingHome | Self::PickoffCaughtStealing2B | Self::PickoffCaughtStealing3B | Self::PickoffCaughtStealingHome)
537 }
538
539 #[must_use]
541 pub const fn is_stolen_base(self) -> bool {
542 matches!(self, Self::StolenBase2B | Self::StolenBase3B | Self::StolenBaseHome)
543 }
544}
545
546#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
548#[serde(rename_all = "camelCase")]
549#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
550pub struct RunnerCredit {
551 pub player: PersonId,
552 pub position: NamedPosition,
553 pub credit: CreditKind,
554}
555
556#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
558pub enum CreditKind {
559 #[display("Putout")]
560 #[serde(rename = "f_putout")]
561 Putout,
562
563 #[display("Assist")]
564 #[serde(rename = "f_assist")]
565 Assist,
566
567 #[display("Outfield Assist")]
568 #[serde(rename = "f_assist_of")]
569 OutfieldAssist,
570
571 #[display("Fielded Ball")]
573 #[serde(rename = "f_fielded_ball")]
574 FieldedBall,
575
576 #[display("Fielding Error")]
577 #[serde(rename = "f_fielding_error")]
578 FieldingError,
579
580 #[display("Throwing Error")]
581 #[serde(rename = "f_throwing_error")]
582 ThrowingError,
583
584 #[display("Deflection")]
585 #[serde(rename = "f_deflection")]
586 Deflection,
587
588 #[display("Touch")]
590 #[serde(rename = "f_touch")]
591 Touch,
592
593 #[display("Dropped Ball Error")]
594 #[serde(rename = "f_error_dropped_ball")]
595 DroppedBallError,
596
597 #[display("Defensive Shift Violation")]
598 #[serde(rename = "f_defensive_shift_violation_error")]
599 DefensiveShiftViolation,
600
601 #[display("Interference")]
602 #[serde(rename = "f_interference")]
603 Interference,
604
605 #[display("Catcher's Interference")]
606 #[serde(rename = "c_catcher_interf")]
607 CatchersInterference,
608}
609
610pub fn deserialize_review_data<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<ReviewData>, D::Error> {
613 #[derive(Deserialize)]
614 #[serde(rename_all = "camelCase")]
615 #[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
616 struct RawReviewData {
617 #[serde(flatten)]
618 base: ReviewData,
619 #[serde(default)]
620 additional_reviews: Vec<ReviewData>,
621 }
622
623 let RawReviewData { base, mut additional_reviews } = RawReviewData::deserialize(deserializer)?;
624 additional_reviews.insert(0, base);
625 Ok(additional_reviews)
626}
627
628#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
630#[serde(rename_all = "camelCase")]
631#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
632pub struct ReviewData {
633 pub is_overturned: bool,
634 #[serde(rename = "inProgress")]
635 pub is_in_progress: bool,
636 pub review_type: ReviewReasonId,
637 #[serde(alias = "challengeTeamId")]
639 pub challenging_team: Option<TeamId>,
640 pub player: Option<NamedPerson>,
642}
643
644#[allow(clippy::large_enum_variant, reason = "not a problemo dw")]
646#[derive(Debug, Deserialize, PartialEq, Clone)]
647#[serde(rename_all_fields = "camelCase", tag = "type")]
648#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
649pub enum PlayEvent {
650 #[serde(rename = "action")]
651 Action {
652 details: ActionPlayDetails,
653 #[serde(rename = "actionPlayId")]
654 play_id: Option<Uuid>,
655
656 #[serde(flatten)]
657 common: PlayEventCommon,
658 },
659 #[serde(rename = "pitch")]
660 Pitch {
661 details: PitchPlayDetails,
662 pitch_data: Option<PitchData>,
663 hit_data: Option<HitData>,
664 #[serde(rename = "pitchNumber")]
666 pitch_ordinal: usize,
667 play_id: Uuid,
668
669 #[serde(flatten)]
670 common: PlayEventCommon,
671 },
672 #[serde(rename = "stepoff")]
673 Stepoff {
674 details: StepoffPlayDetails,
675 play_id: Option<Uuid>,
676
677 #[serde(flatten)]
678 common: PlayEventCommon,
679 },
680 #[serde(rename = "no_pitch")]
681 NoPitch {
682 details: NoPitchPlayDetails,
683 play_id: Option<Uuid>,
684 #[serde(rename = "pitchNumber", default)]
686 pitch_ordinal: usize,
687
688 #[serde(flatten)]
689 common: PlayEventCommon,
690 },
691 #[serde(rename = "pickoff")]
692 Pickoff {
693 details: PickoffPlayDetails,
694 #[serde(alias = "actionPlayId", default)]
695 play_id: Option<Uuid>,
696
697 #[serde(flatten)]
698 common: PlayEventCommon,
699 }
700}
701
702impl Deref for PlayEvent {
703 type Target = PlayEventCommon;
704
705 fn deref(&self) -> &Self::Target {
706 let (Self::Action { common, .. } | Self::Pitch { common, .. } | Self::Stepoff { common, .. } | Self::NoPitch { common, .. } | Self::Pickoff { common, .. }) = self;
707 common
708 }
709}
710
711impl DerefMut for PlayEvent {
712 fn deref_mut(&mut self) -> &mut Self::Target {
713 let (Self::Action { common, .. } | Self::Pitch { common, .. } | Self::Stepoff { common, .. } | Self::NoPitch { common, .. } | Self::Pickoff { common, .. }) = self;
714 common
715 }
716}
717
718#[derive(Debug, Deserialize, PartialEq, Clone)]
719#[serde(rename_all = "camelCase")]
720pub struct PlayEventCommon {
721 pub count: AtBatCount,
723 #[serde(rename = "startTime", deserialize_with = "crate::deserialize_datetime")]
724 pub start_timestamp: DateTime<Utc>,
725 #[serde(rename = "endTime", deserialize_with = "crate::deserialize_datetime")]
726 pub end_timestamp: DateTime<Utc>,
727 pub is_pitch: bool,
728 #[serde(rename = "isBaseRunningPlay", default)]
729 pub is_baserunning_play: bool,
730 #[serde(default)]
732 pub is_substitution: bool,
733
734 pub player: Option<PersonId>,
736 pub umpire: Option<PersonId>,
738 pub position: Option<NamedPosition>,
740 pub replaced_player: Option<PersonId>,
742 #[serde(rename = "battingOrder")]
744 pub batting_order_index: Option<BattingOrderIndex>,
745 pub base: Option<Base>,
747 #[serde(rename = "reviewDetails", default, deserialize_with = "deserialize_review_data")]
748 pub reviews: Vec<ReviewData>,
749 pub injury_type: Option<String>,
750
751 #[doc(hidden)]
752 #[serde(rename = "index", default)]
753 pub __index: IgnoredAny,
754}
755
756#[derive(Debug, Deserialize, PartialEq, Clone)]
757#[serde(rename_all = "camelCase")]
758#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
759pub struct ActionPlayDetails {
760 #[serde(rename = "eventType")]
761 pub event: EventType,
762 pub description: String,
764
765 pub away_score: usize,
767 pub home_score: usize,
769
770 pub is_out: bool,
772 pub is_scoring_play: bool,
773 #[serde(default)]
774 pub has_review: bool,
775
776 #[serde(rename = "disengagementNum", default)]
777 pub disengagements: Option<NonZeroUsize>,
778
779 #[doc(hidden)]
780 #[serde(rename = "event", default)]
781 pub __event: IgnoredAny,
782
783 #[doc(hidden)]
784 #[serde(rename = "type", default)]
785 pub __type: IgnoredAny,
786
787 #[doc(hidden)]
789 #[serde(rename = "violation", default)]
790 pub __violation: IgnoredAny,
791}
792
793#[allow(clippy::struct_excessive_bools, reason = "inapplicable")]
794#[derive(Debug, Deserialize, PartialEq, Clone)]
795#[serde(rename_all = "camelCase")]
796#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
797pub struct PitchPlayDetails {
798 pub is_in_play: bool,
799 pub is_strike: bool,
800 pub is_ball: bool,
801 pub is_out: bool,
802 #[serde(default)]
803 pub has_review: bool,
804 #[serde(default)]
805 pub runner_going: bool,
806 #[serde(rename = "disengagementNum", default)]
807 pub disengagements: Option<NonZeroUsize>,
808
809 #[serde(rename = "type", deserialize_with = "crate::meta::fallback_pitch_type_deserializer", default = "crate::meta::unknown_pitch_type")]
810 pub pitch_type: PitchType,
811
812 pub call: PitchCodeId,
813
814 #[doc(hidden)]
815 #[serde(rename = "ballColor", default)]
816 pub __ball_color: IgnoredAny,
817
818 #[doc(hidden)]
819 #[serde(rename = "trailColor", default)]
820 pub __trail_color: IgnoredAny,
821
822 #[doc(hidden)]
823 #[serde(rename = "description", default)]
824 pub __description: IgnoredAny,
825
826 #[doc(hidden)]
827 #[serde(rename = "code", default)]
828 pub __code: IgnoredAny,
829
830 #[doc(hidden)]
832 #[serde(rename = "violation", default)]
833 pub __violation: IgnoredAny,
834}
835
836#[allow(clippy::struct_excessive_bools, reason = "inapplicable")]
837#[derive(Debug, Deserialize, PartialEq, Clone)]
838#[serde(rename_all = "camelCase")]
839#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
840pub struct StepoffPlayDetails {
841 pub description: String,
842 pub code: PitchCodeId,
844 pub is_out: bool,
845 #[serde(default)]
846 pub has_review: bool,
847 pub from_catcher: bool,
849 #[serde(rename = "disengagementNum", default)]
850 pub disengagements: Option<NonZeroUsize>,
851
852 #[doc(hidden)]
854 #[serde(rename = "violation", default)]
855 pub __violation: IgnoredAny,
856}
857
858#[allow(clippy::struct_excessive_bools, reason = "inapplicable")]
859#[derive(Debug, Deserialize, PartialEq, Clone)]
860#[serde(rename_all = "camelCase")]
861#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
862pub struct NoPitchPlayDetails {
863 #[serde(default)]
864 pub is_in_play: bool,
865 #[serde(default)]
866 pub is_strike: bool,
867 #[serde(default)]
868 pub is_ball: bool,
869 pub is_out: bool,
870 #[serde(default)]
871 pub has_review: bool,
872 #[serde(default)]
873 pub runner_going: bool,
874
875 #[serde(default = "crate::meta::unknown_pitch_code")]
876 pub call: PitchCodeId,
877
878 #[serde(rename = "disengagementNum", default)]
879 pub disengagements: Option<NonZeroUsize>,
880
881 #[doc(hidden)]
882 #[serde(rename = "description", default)]
883 pub __description: IgnoredAny,
884
885 #[doc(hidden)]
886 #[serde(rename = "code", default)]
887 pub __code: IgnoredAny,
888
889 #[doc(hidden)]
891 #[serde(rename = "violation", default)]
892 pub __violation: IgnoredAny,
893}
894
895#[allow(clippy::struct_excessive_bools, reason = "inapplicable")]
896#[derive(Debug, Deserialize, PartialEq, Clone)]
897#[serde(rename_all = "camelCase")]
898#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
899pub struct PickoffPlayDetails {
900 pub description: String,
901 pub code: PitchCodeId,
903 pub is_out: bool,
904 #[serde(default)]
905 pub has_review: bool,
906 pub from_catcher: bool,
908
909 #[serde(rename = "disengagementNum", default)]
910 pub disengagements: Option<NonZeroUsize>,
911
912 #[doc(hidden)]
914 #[serde(rename = "violation", default)]
915 pub __violation: IgnoredAny,
916}
917
918#[allow(non_snake_case, reason = "spec")]
922#[derive(Debug, PartialEq, Copy, Clone)]
923pub struct PitchData {
924 pub release_speed: f64,
926 pub plate_speed: f64,
928
929 pub sz_bot: f64,
933 pub sz_top: f64,
937 pub sz_wid: f64,
941 pub sz_dep: f64,
945
946 pub aX: f64,
951 pub aY: f64,
956 pub aZ: f64,
961
962 pub pfxX: f64,
974 pub pfxZ: f64,
986
987 pub pX: f64,
992 pub pZ: f64,
996
997 pub vX0: f64,
1001 pub vY0: f64,
1005 pub vZ0: f64,
1009
1010 pub x0: f64,
1014 pub y0: f64,
1018 pub z0: f64,
1022
1023 pub x: f64,
1025 pub y: f64,
1027
1028 pub break_angle: f64,
1030 pub break_length: f64,
1032
1033 pub induced_vertical_movement: f64,
1037 pub vertical_drop: f64,
1041 pub horizontal_movement: f64,
1045 pub depth_break: f64,
1047
1048 pub spin_rate: f64,
1050
1051 pub spin_axis: f64,
1060
1061 pub zone: StrikeZoneSection,
1062
1063 pub type_confidence: f64,
1067
1068 pub time_to_plate: f64,
1072
1073 pub extension: f64,
1077}
1078
1079impl<'de> Deserialize<'de> for PitchData {
1080 #[allow(clippy::too_many_lines, reason = "deserialization is hard")]
1081 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1082 where
1083 D: serde::Deserializer<'de>
1084 {
1085 #[must_use]
1086 const fn default_strike_zone_width() -> f64 {
1087 17.0
1088 }
1089
1090 #[must_use]
1091 const fn default_strike_zone_depth() -> f64 {
1092 17.0
1093 }
1094
1095 #[must_use]
1096 const fn default_nan() -> f64 {
1097 f64::NAN
1098 }
1099
1100 #[must_use]
1101 const fn default_strike_zone_section() -> StrikeZoneSection {
1102 StrikeZoneSection::MiddleMiddle
1103 }
1104
1105 #[derive(Deserialize)]
1106 #[serde(rename_all = "camelCase")]
1107 #[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
1108 struct Raw {
1109 #[serde(default = "default_nan")]
1110 start_speed: f64,
1111 #[serde(default = "default_nan")]
1112 end_speed: f64,
1113 #[serde(default = "default_nan")]
1114 strike_zone_top: f64,
1115 #[serde(default = "default_nan")]
1116 strike_zone_bottom: f64,
1117 #[serde(default = "default_strike_zone_width")]
1118 strike_zone_width: f64,
1119 #[serde(default = "default_strike_zone_depth")]
1120 strike_zone_depth: f64,
1121 coordinates: RawCoordinates,
1122 breaks: RawBreaks,
1123 #[serde(default = "default_strike_zone_section")]
1124 zone: StrikeZoneSection,
1125 #[serde(default = "default_nan")]
1126 type_confidence: f64,
1127 #[serde(default = "default_nan")]
1128 plate_time: f64,
1129 #[serde(default = "default_nan")]
1130 extension: f64,
1131 }
1132
1133 #[allow(non_snake_case, reason = "spec")]
1134 #[derive(Deserialize)]
1135 #[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
1136 struct RawCoordinates {
1137 #[serde(default = "default_nan")]
1138 aX: f64,
1139 #[serde(default = "default_nan")]
1140 aY: f64,
1141 #[serde(default = "default_nan")]
1142 aZ: f64,
1143 #[serde(default = "default_nan")]
1144 pfxX: f64,
1145 #[serde(default = "default_nan")]
1146 pfxZ: f64,
1147 #[serde(default = "default_nan")]
1148 pX: f64,
1149 #[serde(default = "default_nan")]
1150 pZ: f64,
1151 #[serde(default = "default_nan")]
1152 vX0: f64,
1153 #[serde(default = "default_nan")]
1154 vY0: f64,
1155 #[serde(default = "default_nan")]
1156 vZ0: f64,
1157 #[serde(default = "default_nan")]
1158 x: f64,
1159 #[serde(default = "default_nan")]
1160 y: f64,
1161 #[serde(default = "default_nan")]
1162 x0: f64,
1163 #[serde(default = "default_nan")]
1164 y0: f64,
1165 #[serde(default = "default_nan")]
1166 z0: f64,
1167 }
1168
1169 #[derive(Deserialize)]
1170 #[serde(rename_all = "camelCase")]
1171 #[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
1172 struct RawBreaks {
1173 #[serde(default = "default_nan")]
1174 break_angle: f64,
1175 #[serde(default = "default_nan")]
1176 break_length: f64,
1177 #[serde(default = "default_nan")]
1178 break_y: f64,
1179 #[serde(default = "default_nan")]
1180 break_vertical: f64,
1181 #[serde(default = "default_nan")]
1182 break_vertical_induced: f64,
1183 #[serde(default = "default_nan")]
1184 break_horizontal: f64,
1185 #[serde(default = "default_nan")]
1186 spin_rate: f64,
1187 #[serde(default = "default_nan")]
1188 spin_direction: f64,
1189 }
1190
1191 let Raw {
1192 start_speed,
1193 end_speed,
1194 strike_zone_top,
1195 strike_zone_bottom,
1196 strike_zone_width,
1197 strike_zone_depth,
1198 coordinates: RawCoordinates {
1199 pfxX,
1200 pfxZ,
1201 aX,
1202 aY,
1203 aZ,
1204 pX,
1205 pZ,
1206 vX0,
1207 vY0,
1208 vZ0,
1209 x,
1210 y,
1211 x0,
1212 y0,
1213 z0,
1214 },
1215 breaks: RawBreaks {
1216 break_angle,
1217 break_length,
1218 break_y,
1219 break_vertical,
1220 break_vertical_induced,
1221 break_horizontal,
1222 spin_rate,
1223 spin_direction,
1224 },
1225 zone,
1226 type_confidence,
1227 plate_time,
1228 extension,
1229 } = Raw::deserialize(deserializer)?;
1230
1231 Ok(Self {
1232 release_speed: start_speed,
1233 plate_speed: end_speed,
1234 sz_bot: strike_zone_bottom,
1235 sz_top: strike_zone_top,
1236 sz_wid: strike_zone_width,
1237 sz_dep: strike_zone_depth,
1238 aX,
1239 aY,
1240 aZ,
1241 pfxX,
1242 pfxZ,
1243 pX,
1244 pZ,
1245 vX0,
1246 vY0,
1247 vZ0,
1248 x0,
1249 y0,
1250 z0,
1251 horizontal_movement: break_horizontal,
1252 x,
1253 y,
1254 break_angle,
1255 break_length,
1256 induced_vertical_movement: break_vertical_induced,
1257 vertical_drop: break_vertical,
1258 depth_break: break_y,
1259 spin_rate,
1260 spin_axis: spin_direction,
1261 zone,
1262 type_confidence,
1263 time_to_plate: plate_time,
1264 extension,
1265 })
1266 }
1267}
1268
1269#[derive(Debug, Deserialize, PartialEq, Clone)]
1271#[serde(from = "__HitDataStruct")]
1272pub struct HitData {
1273 pub hit_trajectory: Option<HitTrajectory>,
1275 pub contact_hardness: Option<ContactHardness>,
1277 pub statcast: Option<StatcastHitData>,
1278}
1279
1280#[serde_as]
1281#[doc(hidden)]
1282#[derive(Deserialize)]
1283#[serde(rename_all = "camelCase")]
1284#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
1285struct __HitDataStruct {
1286 #[serde_as(deserialize_as = "DefaultOnError")]
1287 #[serde(rename = "trajectory", default)]
1288 hit_trajectory: Option<HitTrajectory>,
1289 #[serde(rename = "hardness", default)]
1290 contact_hardness: Option<ContactHardness>,
1291
1292 #[serde(flatten, default)]
1293 statcast: Option<StatcastHitData>,
1294
1295 #[doc(hidden)]
1296 #[serde(rename = "location", default)]
1297 __location: IgnoredAny,
1298
1299 #[doc(hidden)]
1300 #[serde(rename = "coordinates")]
1301 __coordinates: IgnoredAny,
1302}
1303
1304impl From<__HitDataStruct> for HitData {
1305 fn from(__HitDataStruct { hit_trajectory, contact_hardness, statcast, .. }: __HitDataStruct) -> Self {
1306 Self {
1307 hit_trajectory: hit_trajectory.or_else(|| statcast.as_ref().map(|statcast| statcast.launch_angle).map(HitTrajectory::from_launch_angle)),
1308 contact_hardness,
1309 statcast,
1310 }
1311 }
1312}
1313
1314#[derive(Debug, Deserialize, PartialEq, Clone)]
1316#[serde(rename_all = "camelCase")]
1317#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
1318pub struct StatcastHitData {
1319 #[serde(rename = "launchSpeed")]
1323 pub exit_velocity: f64,
1324 pub launch_angle: f64,
1328
1329 #[serde(rename = "totalDistance")]
1333 pub distance: f64,
1334}
1335
1336#[derive(Builder)]
1337#[builder(derive(Into))]
1338pub struct PlayByPlayRequest {
1339 #[builder(into)]
1340 id: GameId,
1341}
1342
1343impl<S: play_by_play_request_builder::State + play_by_play_request_builder::IsComplete> crate::request::RequestURLBuilderExt for PlayByPlayRequestBuilder<S> {
1344 type Built = PlayByPlayRequest;
1345}
1346
1347impl Display for PlayByPlayRequest {
1348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1349 write!(f, "http://statsapi.mlb.com/api/v1/game/{}/playByPlay", self.id)
1350 }
1351}
1352
1353impl RequestURL for PlayByPlayRequest {
1354 type Response = Plays;
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359 use crate::TEST_YEAR;
1360 use crate::game::PlayByPlayRequest;
1361 use crate::meta::GameType;
1362 use crate::request::RequestURLBuilderExt;
1363 use crate::schedule::ScheduleRequest;
1364 use crate::season::{Season, SeasonsRequest};
1365 use crate::sport::SportId;
1366
1367 #[tokio::test]
1368 async fn ws_gm7_2025_pbp() {
1369 let _ = PlayByPlayRequest::builder().id(813_024).build_and_get().await.unwrap();
1370 }
1371
1372 #[tokio::test]
1373 async fn postseason_pbp() {
1374 let [season]: [Season; 1] = SeasonsRequest::builder().season(TEST_YEAR).sport_id(SportId::MLB).build_and_get().await.unwrap().seasons.try_into().unwrap();
1375 let postseason = season.postseason.expect("Expected the MLB to have a postseason");
1376 let games = ScheduleRequest::<()>::builder().date_range(postseason).sport_id(SportId::MLB).build_and_get().await.unwrap();
1377 let games = games.dates.into_iter().flat_map(|date| date.games).filter(|game| game.game_type.is_postseason()).map(|game| game.game_id).collect::<Vec<_>>();
1378 let mut has_errors = false;
1379 for game in games {
1380 if let Err(e) = PlayByPlayRequest::builder().id(game).build_and_get().await {
1381 dbg!(e);
1382 has_errors = true;
1383 }
1384 }
1385 assert!(!has_errors, "Has errors.");
1386 }
1387
1388 #[cfg_attr(not(feature = "_heavy_tests"), ignore)]
1389 #[tokio::test]
1390 async fn regular_season_pbp() {
1391 let [season]: [Season; 1] = SeasonsRequest::builder().season(TEST_YEAR).sport_id(SportId::MLB).build_and_get().await.unwrap().seasons.try_into().unwrap();
1392 let regular_season = season.regular_season;
1393 let games = ScheduleRequest::<()>::builder().date_range(regular_season).sport_id(SportId::MLB).build_and_get().await.unwrap();
1394 let games = games.dates.into_iter().flat_map(|date| date.games).filter(|game| game.game_type == GameType::RegularSeason).collect::<Vec<_>>();
1395 let mut has_errors = false;
1396 for game in games {
1397 if let Err(e) = PlayByPlayRequest::builder().id(game.game_id).build_and_get().await {
1398 dbg!(e);
1399 has_errors = true;
1400 }
1401 }
1402 assert!(!has_errors, "Has errors.");
1403 }
1404}