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