Skip to main content

mlb_api/requests/game/
plays.rs

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/// A collection of plays, often a whole game's worth.
13#[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    /// Unlinked from the `plays` list
26    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    /// Gives a mutable refernece to the underlying plays.
55    ///
56    /// # Safety
57    /// [`Self::scroing_plays`], [`Self::by_inning`] and [`Self::by_inning_halves`] use caches for these plays, if mutated, these caches will be outdated.
58    #[must_use]
59    pub const unsafe fn plays_mut(&mut self) -> &mut Vec<Play> {
60        &mut self.plays
61    }
62
63    /// Reduces this type into the underlying [`Vec<Play>`]
64    #[must_use]
65    pub fn into_plays(self) -> Vec<Play> {
66        self.plays
67    }
68
69    /// Iterator over a list of scoring plays.
70    ///
71    /// ## Examples
72    /// ```no_run
73    /// let plays: Plays = ...;
74    ///
75    /// for play in plays.scoring_plays() {
76    ///     dbg!(play);
77    /// }
78    /// ```
79    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    /// Iterator of plays by inning
85    ///
86    /// ## Examples
87    /// ```no_run
88    /// let plays: Plays = ...;
89    ///
90    /// for plays in plays.by_inning() {
91    ///     for play in plays {
92    ///         dbg!(play);
93    ///     }
94    /// }
95    /// ```
96    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    /// Iterator of plays by inning halves. (top then bottom)
103    ///
104    /// ## Examples
105    /// ```no_run
106    /// let plays: Plays = ...;
107    ///
108    /// for (top, bottom) in plays.by_inning_halves() {
109    ///     for play in top {
110    ///         dbg!(play);
111    ///     }
112    ///     for play in bottom {
113    ///         dbg!(play);
114    ///     }
115    /// }
116    /// ```
117    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/// The play(s) within an "At-Bat"
144///
145/// For individual "plays" and actions, look to [`PlayEvent`]s
146#[derive(Debug, Deserialize, PartialEq, Clone)]
147#[serde(rename_all = "camelCase")]
148#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
149pub struct Play {
150    /// See [`PlayDetails`].
151    pub result: PlayDetails,
152    /// See [`PlayAbout`].
153    pub about: PlayAbout,
154    /// Active count in the at-bat.
155    pub count: AtBatCount,
156    /// See [`PlayMatchup`].
157    pub matchup: PlayMatchup,
158    /// See [`PlayEvent`].
159    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    /// Timestamp at which the [`Play`] is called complete.
165    #[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/// The result of a play, such as a Strikeout, Home Run, etc.
183#[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    /// Score as of the end of the play
191    pub away_score: usize,
192    /// Score as of the end of the play
193    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/// Information supplied to [`PlayDetails`] when the play is complete
205#[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    /// Shohei Ohtani strikes out swinging.
212    pub description: String,
213    /// Runs batted in
214    pub rbi: usize,
215    /// Whether the batter in the play is out
216    pub is_out: bool,
217}
218
219/// Miscallaneous data regarding a play
220#[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    /// Ordinal at bat of the game (starts at 0)
226    #[serde(rename = "atBatIndex")]
227    pub at_bat_idx: usize,
228
229    /// The inning half this play is in
230    #[serde(rename = "halfInning")]
231    pub inning_half: InningHalf,
232
233    /// The inning this play is in
234    pub inning: Inning,
235
236    /// The timestamp that this play begins; includes milliseconds
237    #[serde(rename = "startTime", deserialize_with = "crate::deserialize_datetime")]
238    pub start_timestamp: DateTime<Utc>,
239
240    /// The timestamp that this play ends at; includes milliseconds
241    #[serde(rename = "endTime", deserialize_with = "crate::deserialize_datetime")]
242    pub end_timestamp: DateTime<Utc>,
243
244    /// Whether the play is "complete" or not, i.e. opposite of ongoing.
245    ///
246    /// Once a play is complete it cannot be edited
247    pub is_complete: bool,
248
249    /// Whether the play itself is scoring, such as a Home Run.
250    ///
251    /// Note that [`Play`]s that include [`PlayEvent`]s that score runs that are not part of the [`Play`] (such as stealing home) do not indicate this as true.
252    ///
253    /// This is the predicate for [`Plays::scoring_plays`]
254    pub is_scoring_play: Option<bool>,
255
256    /// Whether the play has a replay review occur. Note that At-Bats can have multiple challenges occur.
257    #[serde(default)]
258    pub has_review: bool,
259
260    /// Whether the play has counted towards an out so far.
261    ///
262    /// Note this does not account for pickoffs, caught stealing, etc, it only counts for the current [`PlayEvent`].
263    #[serde(default)]
264    pub has_out: bool,
265
266    /// Ordinal ranking for +/- WPA effect.
267    ///
268    /// `1` means largest effect on WPA,
269    /// `2` means second most, etc.
270    #[serde(default)]
271    pub captivating_index: usize,
272
273    #[doc(hidden)]
274    #[serde(rename = "isTopInning")]
275    pub __is_top_inning: IgnoredAny,
276}
277
278/// Hitter & Pitcher matchup information
279#[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    /// Cannot change during the play
286    pub bat_side: Handedness,
287    /// Cannot change during the play
288    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 batter_hot_cold_zones: HittingHotColdZones,
307    // pub pitcher_hot_cold_zones: PitchingHotColdZones,
308
309    pub splits: ApplicablePlayMatchupSplits,
310}
311
312/// Batter, Pitcher, and Men-On-Base splits; unknown type.
313#[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/// Data regarding a baserunner.
323#[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/// Data regarding the basepath of a runner
334#[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    /// The base the runner begins the play at. `None` if they do not start on-base at the beginning of the play.
340    pub origin_base: Option<Base>,
341
342    /// Unsure how it is different from ``origin_base``
343    #[serde(rename = "start")]
344    pub start_base: Option<Base>,
345
346    /// The latest base the runner is called "safe" at. `None` if the runner was never safe at any base.
347    #[serde(rename = "end")]
348    pub end_base: Option<Base>,
349
350    /// The base the runner was called out at. `None` if the runner was never called out.
351    pub out_base: Option<Base>,
352
353    /// Identical to `out_base.is_some()`
354    #[serde_as(deserialize_as = "DefaultOnNull")]
355    #[serde(default)]
356    pub is_out: bool,
357    
358    /// Ordinal of out in the game. `None` if the runner was not called out. Otherwise 1, 2, or 3.
359    pub out_number: Option<usize>,
360}
361
362/// Details about the runner's movement
363#[derive(Debug, Deserialize, PartialEq, Clone)]
364#[serde(rename_all = "camelCase")]
365#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
366pub struct RunnerDetails {
367    /// `None` represents completely unforced movement, such as hitting a single with no-one on.
368    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    // Same as [`PlayDetails`].event
377    #[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/// Reasons for baserunner movement
399#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
400pub enum MovementReason {
401    //// Unforced base advancement, such as going first to third on a single.
402    #[display("Unforced Base Advancement")]
403    #[serde(rename = "r_adv_play")]
404    AdvancementUnforced,
405    
406    /// Forced base advancement, such as moving up one base on a single.
407    #[display("Forced Base Advancement")]
408    #[serde(rename = "r_adv_force")]
409    AdancementForced,
410
411    /// Advancement from a choice in throwing, such as throwing home and allowing this runner to move up a base instead.
412    #[display("Advancement from Throw")]
413    #[serde(rename = "r_adv_throw")]
414    AdvancementThrow,
415
416    /// Runner fails to tag up and is forced out.
417    #[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    /// Standard force-out.
430    #[display("Forced Out")]
431    #[serde(rename = "r_force_out")]
432    ForceOut,
433
434    /// Deviation from the basepath, etc.
435    #[display("Runner Called Out")]
436    #[serde(rename = "r_runner_out")]
437    RunnerCalledOut,
438
439    /// A Stolen Base with no throw.
440    #[display("Defensive Indifference")]
441    #[serde(rename = "r_defensive_indiff")]
442    DefensiveIndifference,
443
444    #[display("Rundown")]
445    #[serde(rename = "r_rundown")]
446    Rundown,
447
448    /// Stretching a single into a double.
449    #[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    /// Successful pickoff
478    #[display("Pickoff (1B)")]
479    #[serde(rename = "r_pickoff_1b")]
480    Pickoff1B,
481    
482    /// Successful pickoff
483    #[display("Pickoff (2B)")]
484    #[serde(rename = "r_pickoff_2b")]
485    Pickoff2B,
486    
487    /// Successful pickoff
488    #[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    /// General interference
517    #[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    /// If the movement reason is a pickoff
528    #[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    /// If the movement reason is a stolen base attempt
534    #[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    /// If the movement reason is a stolen base
540    #[must_use]
541    pub const fn is_stolen_base(self) -> bool {
542        matches!(self, Self::StolenBase2B | Self::StolenBase3B | Self::StolenBaseHome)
543    }
544}
545
546/// Fielder credits to outs
547#[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/// Statistical credits to fielders; putouts, assists, etc.
557#[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    /// The fielder just, fielded the ball, no outs or anything.
572    #[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    /// They literally touched it, no deflection, just a tap.
589    #[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
610/// # Errors
611/// See `D::Error`, likely [`serde_json::Error`]
612pub 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/// Data regarding replay reviews; present on [`Play`], not [`PlayEvent`].
629#[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    /// If `None`, then a crew-chief review.
638    #[serde(alias = "challengeTeamId")]
639    pub challenging_team: Option<TeamId>,
640    /// For ABS challenges
641    pub player: Option<NamedPerson>,
642}
643
644/// An "indivisible" play, such as pickoff, pitch, stolen base, etc.
645#[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        /// Starts at 1
665        #[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        /// Starts at 1
685        #[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    /// At the end of the play event
722    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    /// Pitching Subsitution, Defensive Switches, Pinch Hitting, etc.
731    #[serde(default)]
732    pub is_substitution: bool,
733
734    /// A player involved in the play.
735    pub player: Option<PersonId>,
736    /// An umpire involved in the play, ex: Ejection
737    pub umpire: Option<PersonId>,
738    /// Position (typically a complement of ``player``)
739    pub position: Option<NamedPosition>,
740    /// Also not always present, check by the [`EventType`]; [`PitchingSubsitution`](EventType::PitchingSubstitution)s don't have it.
741    pub replaced_player: Option<PersonId>,
742    /// Batting Order Index, typically supplied with a [`DefensiveSwitch`](EventType::DefensiveSwitch) or [`OffensiveSubstitution`](EventType::OffensiveSubstitution)
743    #[serde(rename = "battingOrder")]
744    pub batting_order_index: Option<BattingOrderIndex>,
745    /// Base correlated with play, such as a stolen base
746    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    /// Shohei Ohtani strikes out swinging.
763    pub description: String,
764
765    /// Score as of the end of the play
766    pub away_score: usize,
767    /// Score as of the end of the play
768    pub home_score: usize,
769
770    /// Whether the batter in the play is out
771    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    // redundant
788    #[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    // redundant
831    #[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    /// Typically "PSO" - "Pitcher Step Off"
843    pub code: PitchCodeId,
844    pub is_out: bool,
845    #[serde(default)]
846    pub has_review: bool,
847    /// Catcher-called mound disengagement.
848    pub from_catcher: bool,
849    #[serde(rename = "disengagementNum", default)]
850    pub disengagements: Option<NonZeroUsize>,
851
852    // redundant
853    #[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    // redundant
890    #[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    /// Typically "1" - "Pickoff Attempt 1B", "2" - "Pickoff Attempt 2B", "3" - "Pickoff Attempt 3B"
902    pub code: PitchCodeId,
903    pub is_out: bool,
904    #[serde(default)]
905    pub has_review: bool,
906    /// Catcher-called pickoff.
907    pub from_catcher: bool,
908
909    #[serde(rename = "disengagementNum", default)]
910    pub disengagements: Option<NonZeroUsize>,
911
912    // redundant
913    #[doc(hidden)]
914    #[serde(rename = "violation", default)]
915    pub __violation: IgnoredAny,
916}
917
918/// Statistical data regarding a pitch.
919///
920/// Some acronyms are an existing spec, best to keep with that.
921#[allow(non_snake_case, reason = "spec")]
922#[derive(Debug, PartialEq, Copy, Clone)]
923pub struct PitchData {
924    /// Velocity measured at release, measured in mph
925    pub release_speed: f64,
926    /// Velocity measured crossing home plate, measured in mph
927    pub plate_speed: f64,
928    
929    /// Height above home plate for the bottom of the hitter's strike zone
930    ///
931    /// Measured in feet.
932    pub sz_bot: f64,
933    /// Height above home plate for the top of the hitter's strike zone
934    ///
935    /// Measured in feet.
936    pub sz_top: f64,
937    /// Width of the strike zone
938    ///
939    /// Measured in inches.
940    pub sz_wid: f64,
941    /// Depth of the strike zone
942    ///
943    /// Measured in inches.
944    pub sz_dep: f64,
945    
946    /// Acceleration of the pitch near release, horizontal movement axis,
947    /// catchers perspective (positive means RHP sweep)
948    /// 
949    /// Measured in feet/s^2.
950    pub aX: f64,
951    /// Acceleration of the pitch near release, depth axis,
952    /// catchers perspective (positive means deceleration)
953    /// 
954    /// Measured in feet/s^2.
955    pub aY: f64,
956    /// Acceleration of the pitch near release, vertical movement axis,
957    /// catchers perspective (positive means literal carry)
958    ///
959    /// Measured in feet/s^2.
960    pub aZ: f64,
961    
962    /// Might be broken, use ``horizontal_movement`` instead
963    ///
964    /// Horizontal movement of the pitch between the release point and home plate,
965    /// catchers perspective (positive means RHP sweep)
966    /// as compared to a theoretical pitch thrown at with the same velocity vector
967    /// and no spin-induced movement.
968    /// This parameter is measured at y=40 feet regardless of the y0 value.
969    /// 
970    /// Measured in inches.
971    ///
972    /// Does not account for seam-shifted wake!
973    pub pfxX: f64,
974    /// Might be broken, use ``vertical_drop`` instead
975    ///
976    /// Vertical movement of the pitch between the release point and home plate,
977    /// catchers perspective (positive means literal rise),
978    /// as compared to a theoretical pitch thrown at with the same velocity vector
979    /// and no spin-induced movement.
980    /// This parameter is measured at y=40 feet regardless of the y0 value.
981    /// 
982    /// Measured in inches.
983    ///
984    /// Does not account for seam-shifted wake!
985    pub pfxZ: f64,
986
987    /// Horizontal coordinate of the pitch as it crosses home plate, 0 is the middle of the plate.
988    /// Catchers perspective, positive means arm-side for a RHP, negative means glove-side for a RHP.
989    /// 
990    /// Measured in feet.
991    pub pX: f64,
992    /// Vertical coordinate of the pitch as it crosses home plate, 0 is the plate itself
993    /// 
994    /// Measured in feet.
995    pub pZ: f64,
996
997    /// Horizontal component of velocity out of the hand, catchers perspective, positive means RHP glove-side.
998    ///
999    /// Measured in feet per second.
1000    pub vX0: f64,
1001    /// Depth component of velocity out of the hand, catchers perspective, positive means the ball isn't going into centerfield.
1002    ///
1003    /// Measured in feet per second.
1004    pub vY0: f64,
1005    /// Vertical component of velocity out of the hand, measured in feet per second.
1006    ///
1007    /// Measured in feet per second.
1008    pub vZ0: f64,
1009
1010    /// X coordinate of pitch at release
1011    ///
1012    /// Measured in feet
1013    pub x0: f64,
1014    /// Y coordinate of pitch at release, typically as close to 50 as possible.
1015    ///
1016    /// Measured in feet
1017    pub y0: f64,
1018    /// Z coordinate of pitch at release
1019    ///
1020    /// Measured in feet
1021    pub z0: f64,
1022
1023    /// No clue.
1024    pub x: f64,
1025    /// No clue.
1026    pub y: f64,
1027
1028    /// No clue. Does not match theta angle of induced break vector. Consistently 36.0. Strange.
1029    pub break_angle: f64,
1030    /// No clue. Does not match length of induced break vector.
1031    pub break_length: f64,
1032
1033    /// Standard metric, amount of vertical movement induced.
1034    ///
1035    /// Measured in inches
1036    pub induced_vertical_movement: f64,
1037    /// Standard metric, amount of vertical movement the pitch has (including gravity).
1038    ///
1039    /// Measured in inches
1040    pub vertical_drop: f64,
1041    /// Standard metric, amount of horizontal movement the pitch has.
1042    ///
1043    /// Measured in inches
1044    pub horizontal_movement: f64,
1045    /// No clue. Thought to be the amount of depth-based movement (acceleration), but it's consistently 24.0. Strange.
1046    pub depth_break: f64,
1047    
1048    /// RPMs out of the hand
1049    pub spin_rate: f64,
1050
1051    /// Measured in degrees.
1052    ///
1053    /// 0 means complete topspin.
1054    /// 180 means complete backspin.
1055    /// 90 means complete sidespin (RHP sweeper).
1056    /// 270 means complete sidespin (elite RHP changeup).
1057    /// 
1058    /// ~225 is your average RHP fastball.
1059    pub spin_axis: f64,
1060
1061    pub zone: StrikeZoneSection,
1062
1063    /// AI model confidence about pitch type designation.
1064    ///
1065    /// Sometimes greater than 1.0
1066    pub type_confidence: f64,
1067
1068    /// Time from out of the hand to crossing the plate.
1069    ///
1070    /// Measured in seconds
1071    pub time_to_plate: f64,
1072
1073    /// Extension
1074    ///
1075    /// Measured in feet
1076    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/// Data regarding batted-balls
1270#[derive(Debug, Deserialize, PartialEq, Clone)]
1271#[serde(from = "__HitDataStruct")]
1272pub struct HitData {
1273    /// sometimes just takes a second to be present
1274    pub hit_trajectory: Option<HitTrajectory>,
1275    /// sometimes just takes a second to be present
1276    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/// Statcast data regarding batted balls, only sometimes present.
1315#[derive(Debug, Deserialize, PartialEq, Clone)]
1316#[serde(rename_all = "camelCase")]
1317#[cfg_attr(feature = "_debug", serde(deny_unknown_fields))]
1318pub struct StatcastHitData {
1319    /// Speed of the ball as it leaves the bat
1320    ///
1321    /// Measured in mph
1322    #[serde(rename = "launchSpeed")]
1323    pub exit_velocity: f64,
1324    /// Vertical Angle in degrees at which the ball leaves the bat.
1325    ///
1326    /// Measured in degrees
1327    pub launch_angle: f64,
1328
1329    /// Distance the ball travels before being caught or rolling.
1330    ///
1331    /// Measured in feet
1332    #[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}