import Foundation /// Fetches supplementary game data from the public MLB Stats API. actor MLBStatsAPI { private let baseURL = "https://statsapi.mlb.com/api/v1" func fetchStandings(season: String) async throws -> [Int: TeamStanding] { let response: StandingsResponse = try await fetchJSON( "\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team" ) var result: [Int: TeamStanding] = [:] for record in response.records { let divisionName = record.division?.name for tr in record.teamRecords { result[tr.team.id] = TeamStanding( divisionRank: tr.divisionRank, gamesBack: tr.gamesBack == "-" ? nil : tr.gamesBack, streak: tr.streak?.streakCode, divisionName: divisionName ) } } return result } func fetchSchedule(date: String) async throws -> [StatsGame] { let response: MLBScheduleResponse = try await fetchJSON( "\(baseURL)/schedule?sportId=1&date=\(date)&hydrate=team(record),linescore,probablePitcher,venue" ) return response.dates.flatMap(\.games) } func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] { let response: StandingsResponse = try await fetchJSON( "\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team" ) return response.records } func fetchGameFeed(gamePk: String) async throws -> LiveGameFeed { try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live") } func fetchWinProbability(gamePk: String) async throws -> [WinProbabilityEntry] { try await fetchJSON("\(baseURL).1/game/\(gamePk)/winProbability") } func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] { let response: TeamsResponse = try await fetchJSON( "\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record" ) return response.teams .filter(\.active) .sorted { $0.name < $1.name } .map(LeagueTeamSummary.init) } func fetchTeamProfile(teamID: Int, season: String) async throws -> TeamProfile { let response: TeamsResponse = try await fetchJSON( "\(baseURL)/teams/\(teamID)?sportId=1&season=\(season)&hydrate=league,division,venue,record" ) guard let team = response.teams.first else { throw StatsAPIError.notFound } return TeamProfile(team: team) } func fetchTeamRoster(teamID: Int, season: String) async throws -> [RosterPlayerSummary] { let response: TeamRosterResponse = try await fetchJSON( "\(baseURL)/teams/\(teamID)/roster?season=\(season)" ) return response.roster .sorted { lhs, rhs in lhs.person.fullName < rhs.person.fullName } .map(RosterPlayerSummary.init) } func fetchPlayerProfile(personID: Int, season: String) async throws -> PlayerProfile { async let personTask: PeopleResponse = fetchJSON("\(baseURL)/people/\(personID)") async let statsTask: StatsQueryResponse = fetchJSON( "\(baseURL)/people/\(personID)/stats?stats=yearByYear&group=hitting,pitching,fielding&sportIds=1" ) let personResponse = try await personTask let statsResponse = try await statsTask guard let person = personResponse.people.first else { throw StatsAPIError.notFound } let resolvedSeason = resolvePlayerStatsSeason( requestedSeason: season, from: statsResponse.stats ) let filteredStatGroups = statsResponse.stats.compactMap { group in group.filteredForPlayerProfile(season: resolvedSeason) } return PlayerProfile( person: person, season: resolvedSeason ?? season, statGroups: filteredStatGroups ) } private func resolvePlayerStatsSeason(requestedSeason: String, from groups: [StatsQueryGroup]) -> String? { if groups.contains(where: { $0.hasRegularSeasonData(for: requestedSeason) }) { return requestedSeason } return groups .flatMap(\.splits) .filter(\.isRegularSeason) .compactMap { split in split.season.flatMap(Int.init) } .max() .map(String.init) } private func fetchJSON(_ urlString: String) async throws -> T { guard let url = URL(string: urlString) else { throw StatsAPIError.invalidURL } let (data, response) = try await URLSession.shared.data(from: url) guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { throw StatsAPIError.requestFailed } return try JSONDecoder().decode(T.self, from: data) } } // MARK: - Codable Models struct MLBScheduleResponse: Codable, Sendable { let dates: [ScheduleDate] } struct ScheduleDate: Codable, Sendable { let date: String let games: [StatsGame] } struct StatsGame: Codable, Sendable, Identifiable { let gamePk: Int let gameDate: String? let status: StatsGameStatus let teams: StatsTeams let linescore: StatsLinescore? let venue: StatsVenue? let seriesDescription: String? let gameType: String? var id: Int { gamePk } var isLive: Bool { status.abstractGameState == "Live" } var isFinal: Bool { status.abstractGameState == "Final" } var isScheduled: Bool { status.abstractGameState == "Preview" } /// Format start time from gameDate ISO string var startTime: String? { guard let dateStr = gameDate else { return nil } let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = isoFormatter.date(from: dateStr) ?? ISO8601DateFormatter().date(from: dateStr) else { return nil } let displayFormatter = DateFormatter() displayFormatter.dateFormat = "h:mm a" displayFormatter.timeZone = .current return displayFormatter.string(from: date) } } struct StatsGameStatus: Codable, Sendable { let detailedState: String? let abstractGameState: String? let statusCode: String? } struct StatsTeams: Codable, Sendable { let away: StatsTeamGameInfo let home: StatsTeamGameInfo } struct StatsTeamGameInfo: Codable, Sendable { let team: StatsTeamBasic let score: Int? let leagueRecord: StatsLeagueRecord? let probablePitcher: StatsPitcher? } struct StatsTeamBasic: Codable, Sendable { let id: Int let name: String? let abbreviation: String? } struct StatsLeagueRecord: Codable, Sendable { let wins: Int let losses: Int let pct: String? } struct StatsPitcher: Codable, Sendable { let id: Int let fullName: String? } struct StatsLinescore: Codable, Sendable { let innings: [StatsInningScore]? let teams: StatsLinescoreTeams? let currentInning: Int? let currentInningOrdinal: String? let inningState: String? let inningHalf: String? let isTopInning: Bool? let scheduledInnings: Int? let balls: Int? let strikes: Int? let outs: Int? var currentInningDisplay: String? { guard let ordinal = currentInningOrdinal, let half = inningHalf else { return nil } return "\(half) \(ordinal)" } var hasData: Bool { guard let innings, !innings.isEmpty else { return false } return innings.contains { ($0.away?.runs != nil) || ($0.home?.runs != nil) } } } struct StatsInningScore: Codable, Sendable { let num: Int let away: StatsInningRuns? let home: StatsInningRuns? } struct StatsInningRuns: Codable, Sendable { let runs: Int? let hits: Int? let errors: Int? } struct StatsLinescoreTeams: Codable, Sendable { let away: StatsLinescoreTotals? let home: StatsLinescoreTotals? } struct StatsLinescoreTotals: Codable, Sendable { let runs: Int? let hits: Int? let errors: Int? } struct StatsVenue: Codable, Sendable { let name: String? } // MARK: - Standings Models struct TeamStanding: Sendable { let divisionRank: String? let gamesBack: String? let streak: String? let divisionName: String? } struct StandingsResponse: Codable, Sendable { let records: [StandingsDivisionRecord] } struct StandingsDivisionRecord: Codable, Sendable { let division: StandingsDivision? let teamRecords: [StandingsTeamRecord] } struct StandingsDivision: Codable, Sendable { let id: Int? let name: String? } struct StandingsTeamRecord: Codable, Sendable { let team: StatsTeamBasic let divisionRank: String? let gamesBack: String? let streak: StandingsStreak? let wins: Int? let losses: Int? } struct StandingsStreak: Codable, Sendable { let streakCode: String? } enum StatsAPIError: Error { case invalidURL case requestFailed case notFound } // MARK: - League Hub Models struct TeamsResponse: Codable, Sendable { let teams: [LeagueTeam] } struct LeagueTeam: Codable, Sendable, Identifiable { let id: Int let name: String let abbreviation: String? let teamName: String? let locationName: String? let clubName: String? let franchiseName: String? let firstYearOfPlay: String? let league: LeagueReference? let division: LeagueReference? let venue: LeagueVenue? let record: LeagueRecordSummary? let active: Bool } struct LeagueReference: Codable, Sendable { let id: Int? let name: String? let abbreviation: String? } struct LeagueVenue: Codable, Sendable { let id: Int? let name: String? } struct LeagueRecordSummary: Codable, Sendable { let gamesPlayed: Int? let divisionGamesBack: String? let wildCardGamesBack: String? let leagueRecord: LeagueRecordLine? let wins: Int? let losses: Int? let winningPercentage: String? } struct LeagueRecordLine: Codable, Sendable { let wins: Int let losses: Int let ties: Int? let pct: String? } struct LeagueTeamSummary: Identifiable, Sendable { let id: Int let name: String let abbreviation: String let locationName: String let clubName: String let leagueName: String? let divisionName: String? let venueName: String? let recordText: String? init(team: LeagueTeam) { id = team.id name = team.name abbreviation = team.abbreviation ?? team.teamName ?? "MLB" locationName = team.locationName ?? team.name clubName = team.clubName ?? team.teamName ?? team.name leagueName = team.league?.name divisionName = team.division?.name venueName = team.venue?.name if let wins = team.record?.wins, let losses = team.record?.losses { recordText = "\(wins)-\(losses)" } else { recordText = nil } } var teamInfo: TeamInfo { TeamInfo(code: abbreviation, name: name, score: nil, teamId: id, record: recordText) } } struct TeamProfile: Identifiable, Sendable { let id: Int let name: String let abbreviation: String let locationName: String let clubName: String let franchiseName: String? let firstYearOfPlay: String? let venueName: String? let leagueName: String? let divisionName: String? let recordText: String? let gamesBackText: String? init(team: LeagueTeam) { id = team.id name = team.name abbreviation = team.abbreviation ?? team.teamName ?? "MLB" locationName = team.locationName ?? team.name clubName = team.clubName ?? team.teamName ?? team.name franchiseName = team.franchiseName firstYearOfPlay = team.firstYearOfPlay venueName = team.venue?.name leagueName = team.league?.name divisionName = team.division?.name if let wins = team.record?.wins, let losses = team.record?.losses { recordText = "\(wins)-\(losses)" } else { recordText = nil } if let divisionGamesBack = team.record?.divisionGamesBack, divisionGamesBack != "-" { gamesBackText = "\(divisionGamesBack) GB" } else { gamesBackText = nil } } var teamInfo: TeamInfo { TeamInfo(code: abbreviation, name: name, score: nil, teamId: id, record: recordText) } } struct TeamRosterResponse: Codable, Sendable { let roster: [RosterEntry] } struct RosterEntry: Codable, Sendable, Identifiable { let person: RosterPerson let jerseyNumber: String? let position: RosterPosition? let status: RosterStatus? var id: Int { person.id } } struct RosterPerson: Codable, Sendable { let id: Int let fullName: String } struct RosterPosition: Codable, Sendable { let code: String? let name: String? let type: String? let abbreviation: String? } struct RosterStatus: Codable, Sendable { let code: String? let description: String? } struct RosterPlayerSummary: Identifiable, Sendable { let id: Int let fullName: String let jerseyNumber: String? let positionAbbreviation: String? let status: String? init(entry: RosterEntry) { id = entry.person.id fullName = entry.person.fullName jerseyNumber = entry.jerseyNumber positionAbbreviation = entry.position?.abbreviation status = entry.status?.description } var headshotURL: URL { URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_360,q_auto:best/v1/people/\(id)/headshot/67/current")! } } struct PeopleResponse: Codable, Sendable { let people: [PersonDetail] } struct PersonDetail: Codable, Sendable, Identifiable { let id: Int let fullName: String let firstName: String? let lastName: String? let primaryNumber: String? let birthDate: String? let currentAge: Int? let birthCity: String? let birthStateProvince: String? let birthCountry: String? let height: String? let weight: Int? let active: Bool? let primaryPosition: PersonPosition? let batSide: Handedness? let pitchHand: Handedness? let mlbDebutDate: String? } struct PersonPosition: Codable, Sendable { let code: String? let name: String? let type: String? let abbreviation: String? } struct Handedness: Codable, Sendable { let code: String? let description: String? } struct StatsQueryResponse: Codable, Sendable { let stats: [StatsQueryGroup] } struct StatsQueryGroup: Codable, Sendable, Identifiable { let type: StatsQueryDescriptor? let group: StatsQueryDescriptor? let splits: [StatsQuerySplit] var id: String { "\(group?.displayName ?? "stats")-\(type?.displayName ?? "season")" } } struct StatsQueryDescriptor: Codable, Sendable { let displayName: String? } struct StatsQuerySplit: Codable, Sendable { let season: String? let gameType: String? let stat: [String: JSONValue] } struct PlayerProfile: Identifiable, Sendable { let id: Int let fullName: String let primaryNumber: String? let primaryPosition: String? let currentAge: Int? let birthDate: String? let birthPlace: String? let height: String? let weight: Int? let bats: String? let throwsHand: String? let debutDate: String? let seasonLabel: String let statGroups: [PlayerStatSummary] init(person: PersonDetail, season: String, statGroups: [StatsQueryGroup]) { id = person.id fullName = person.fullName primaryNumber = person.primaryNumber primaryPosition = person.primaryPosition?.abbreviation ?? person.primaryPosition?.name currentAge = person.currentAge birthDate = person.birthDate let placeParts = [person.birthCity, person.birthStateProvince, person.birthCountry] .compactMap { $0?.isEmpty == false ? $0 : nil } birthPlace = placeParts.isEmpty ? nil : placeParts.joined(separator: ", ") height = person.height weight = person.weight bats = person.batSide?.description throwsHand = person.pitchHand?.description debutDate = person.mlbDebutDate seasonLabel = season self.statGroups = statGroups.compactMap(PlayerStatSummary.init) } var headshotURL: URL { URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_360,q_auto:best/v1/people/\(id)/headshot/67/current")! } } struct PlayerStatSummary: Identifiable, Sendable { let id: String let title: String let items: [PlayerStatItem] init?(group: StatsQueryGroup) { guard let split = group.splits.first else { return nil } let groupName = group.group?.displayName ?? "Stats" let title = groupName.capitalized let preferredKeys: [String] switch groupName.lowercased() { case "hitting": preferredKeys = ["avg", "obp", "slg", "ops", "homeRuns", "rbi", "hits", "runs", "baseOnBalls", "stolenBases"] case "pitching": preferredKeys = ["era", "wins", "losses", "saves", "inningsPitched", "strikeOuts", "whip", "baseOnBalls", "hits", "runs"] case "fielding": preferredKeys = ["fielding", "assists", "putOuts", "errors", "chances", "gamesStarted"] default: preferredKeys = Array(split.stat.keys.sorted().prefix(8)) } let items = preferredKeys.compactMap { key -> PlayerStatItem? in guard let value = split.stat[key]?.displayString else { return nil } return PlayerStatItem(label: key.statLabel, value: value) } guard !items.isEmpty else { return nil } id = group.id self.title = title self.items = items } } private extension StatsQueryGroup { func hasRegularSeasonData(for season: String) -> Bool { splits.contains { split in split.isRegularSeason && split.season == season } } func filteredForPlayerProfile(season: String?) -> StatsQueryGroup? { guard let season else { return nil } let filteredSplits = splits.filter { split in split.isRegularSeason && split.season == season } guard !filteredSplits.isEmpty else { return nil } return StatsQueryGroup( type: type, group: group, splits: filteredSplits ) } } private extension StatsQuerySplit { var isRegularSeason: Bool { guard let gameType else { return true } return gameType == "R" } } struct PlayerStatItem: Sendable { let label: String let value: String } enum JSONValue: Codable, Sendable { case string(String) case int(Int) case double(Double) case bool(Bool) case object([String: JSONValue]) case array([JSONValue]) case null init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null } else if let value = try? container.decode(String.self) { self = .string(value) } else if let value = try? container.decode(Int.self) { self = .int(value) } else if let value = try? container.decode(Double.self) { self = .double(value) } else if let value = try? container.decode(Bool.self) { self = .bool(value) } else if let value = try? container.decode([String: JSONValue].self) { self = .object(value) } else if let value = try? container.decode([JSONValue].self) { self = .array(value) } else { throw DecodingError.typeMismatch( JSONValue.self, .init(codingPath: decoder.codingPath, debugDescription: "Unsupported JSON value") ) } } func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .string(let value): try container.encode(value) case .int(let value): try container.encode(value) case .double(let value): try container.encode(value) case .bool(let value): try container.encode(value) case .object(let value): try container.encode(value) case .array(let value): try container.encode(value) case .null: try container.encodeNil() } } var displayString: String? { switch self { case .string(let value): return value case .int(let value): return String(value) case .double(let value): if value.rounded() == value { return String(Int(value)) } return String(format: "%.3f", value) .replacingOccurrences(of: #"(\.\d*?[1-9])0+$"#, with: "$1", options: .regularExpression) .replacingOccurrences(of: #"\.0+$"#, with: "", options: .regularExpression) case .bool(let value): return value ? "Yes" : "No" case .object, .array, .null: return nil } } } private extension String { var statLabel: String { switch self { case "avg": return "AVG" case "obp": return "OBP" case "slg": return "SLG" case "ops": return "OPS" case "era": return "ERA" case "whip": return "WHIP" case "rbi": return "RBI" default: let pattern = "([a-z0-9])([A-Z])" return replacingOccurrences(of: pattern, with: "$1 $2", options: .regularExpression) .replacingOccurrences(of: "_", with: " ") .capitalized } } } // MARK: - Live Game Feed Models struct LiveGameFeed: Codable, Sendable { let metaData: LiveFeedMetaData? let gameData: LiveFeedGameData let liveData: LiveFeedData var recentPlays: [LiveFeedPlay] { Array(liveData.plays.allPlays.suffix(12).reversed()) } var scoringPlays: [LiveFeedPlay] { liveData.plays.allPlays.filter { $0.about?.isScoringPlay == true } } var currentPlay: LiveFeedPlay? { liveData.plays.currentPlay ?? liveData.plays.allPlays.last } var currentBatter: LiveFeedPlayerReference? { liveData.linescore?.offense?.batter ?? currentPlay?.matchup?.batter } var currentPitcher: LiveFeedPlayerReference? { liveData.linescore?.defense?.pitcher ?? currentPlay?.matchup?.pitcher } var onDeckBatter: LiveFeedPlayerReference? { liveData.linescore?.offense?.onDeck } var inHoleBatter: LiveFeedPlayerReference? { liveData.linescore?.offense?.inHole } var currentCountText: String? { guard let count = currentPlay?.count else { return nil } return "\(count.balls)-\(count.strikes), \(count.outs) out\(count.outs == 1 ? "" : "s")" } var occupiedBases: [String] { var bases: [String] = [] if liveData.linescore?.offense?.first != nil { bases.append("1B") } if liveData.linescore?.offense?.second != nil { bases.append("2B") } if liveData.linescore?.offense?.third != nil { bases.append("3B") } return bases } var officialSummary: [String] { (liveData.boxscore?.officials ?? []).compactMap { official in guard let type = official.officialType, let name = official.official?.fullName else { return nil } return "\(type): \(name)" } } var weatherSummary: String? { guard let weather = gameData.weather else { return nil } return [weather.condition, weather.temp.map { "\($0)°" }, weather.wind] .compactMap { $0 } .joined(separator: " • ") } var decisionsSummary: [String] { var result: [String] = [] if let winner = liveData.decisions?.winner?.fullName { result.append("W: \(winner)") } if let loser = liveData.decisions?.loser?.fullName { result.append("L: \(loser)") } if let save = liveData.decisions?.save?.fullName { result.append("SV: \(save)") } return result } var awayLineup: [LiveFeedBoxscorePlayer] { liveData.boxscore?.teams?.away.lineupPlayers ?? [] } var homeLineup: [LiveFeedBoxscorePlayer] { liveData.boxscore?.teams?.home.lineupPlayers ?? [] } var currentAtBatPitches: [LiveFeedPlayEvent] { currentPlay?.pitches ?? [] } var allGameHits: [(play: LiveFeedPlay, hitData: LiveFeedHitData)] { liveData.plays.allPlays.compactMap { play in guard let lastEvent = play.playEvents?.last, let hitData = lastEvent.hitData, hitData.coordinates?.coordX != nil else { return nil } return (play: play, hitData: hitData) } } } struct LiveFeedMetaData: Codable, Sendable { let wait: Int? let timeStamp: String? let gameEvents: [String]? let logicalEvents: [String]? } struct LiveFeedGameData: Codable, Sendable { let game: LiveFeedGameInfo? let datetime: LiveFeedDateTime? let status: LiveFeedStatus? let teams: LiveFeedGameTeams? let probablePitchers: LiveFeedProbablePitchers? let venue: LiveFeedVenue? let weather: LiveFeedWeather? } struct LiveFeedGameInfo: Codable, Sendable { let pk: Int? let type: String? let season: String? } struct LiveFeedDateTime: Codable, Sendable { let dateTime: String? let officialDate: String? let time: String? let ampm: String? } struct LiveFeedStatus: Codable, Sendable { let abstractGameState: String? let detailedState: String? let codedGameState: String? } struct LiveFeedGameTeams: Codable, Sendable { let away: LiveFeedGameTeam? let home: LiveFeedGameTeam? } struct LiveFeedGameTeam: Codable, Sendable { let id: Int? let name: String? let abbreviation: String? let record: LeagueRecordSummary? } struct LiveFeedProbablePitchers: Codable, Sendable { let away: LiveFeedPlayerReference? let home: LiveFeedPlayerReference? } struct LiveFeedVenue: Codable, Sendable { let id: Int? let name: String? } struct LiveFeedWeather: Codable, Sendable { let condition: String? let temp: String? let wind: String? } struct LiveFeedData: Codable, Sendable { let plays: LiveFeedPlays let linescore: LiveFeedLinescore? let boxscore: LiveFeedBoxscore? let decisions: LiveFeedDecisions? } struct LiveFeedPlays: Codable, Sendable { let currentPlay: LiveFeedPlay? let allPlays: [LiveFeedPlay] } struct LiveFeedPlay: Codable, Sendable, Identifiable { let result: LiveFeedPlayResult? let about: LiveFeedPlayAbout? let count: LiveFeedPlayCount? let matchup: LiveFeedPlayMatchup? let playEvents: [LiveFeedPlayEvent]? var pitches: [LiveFeedPlayEvent] { playEvents?.filter { $0.isPitch == true } ?? [] } var id: String { let inning = about?.inning ?? -1 let half = about?.halfInning ?? "play" let atBat = about?.atBatIndex ?? -1 let event = result?.eventType ?? result?.event ?? result?.description ?? "update" return "\(inning)-\(half)-\(atBat)-\(event)" } var summaryText: String { result?.description ?? result?.event ?? "Play update" } } struct LiveFeedPlayResult: Codable, Sendable { let type: String? let event: String? let eventType: String? let description: String? } struct LiveFeedPlayAbout: Codable, Sendable { let atBatIndex: Int? let halfInning: String? let inning: Int? let isScoringPlay: Bool? } struct LiveFeedPlayCount: Codable, Sendable { let balls: Int let strikes: Int let outs: Int } struct LiveFeedPlayMatchup: Codable, Sendable { let batter: LiveFeedPlayerReference? let pitcher: LiveFeedPlayerReference? } struct LiveFeedPlayerReference: Codable, Sendable, Identifiable { let id: Int? let fullName: String? let link: String? var displayName: String { fullName ?? "TBD" } var headshotURL: URL? { guard let id else { return nil } return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_213,q_auto:best/v1/people/\(id)/headshot/67/current") } } struct LiveFeedLinescore: Codable, Sendable { let currentInning: Int? let currentInningOrdinal: String? let inningHalf: String? let inningState: String? let scheduledInnings: Int? let offense: LiveFeedOffenseState? let defense: LiveFeedDefenseState? let teams: StatsLinescoreTeams? var inningDisplay: String? { if let inningHalf, let currentInningOrdinal { return "\(inningHalf) \(currentInningOrdinal)" } if let inningState, let currentInningOrdinal { return "\(inningState) \(currentInningOrdinal)" } return nil } } struct LiveFeedOffenseState: Codable, Sendable { let batter: LiveFeedPlayerReference? let onDeck: LiveFeedPlayerReference? let inHole: LiveFeedPlayerReference? let first: LiveFeedPlayerReference? let second: LiveFeedPlayerReference? let third: LiveFeedPlayerReference? } struct LiveFeedDefenseState: Codable, Sendable { let pitcher: LiveFeedPlayerReference? } struct LiveFeedBoxscore: Codable, Sendable { let teams: LiveFeedBoxscoreTeams? let officials: [LiveFeedOfficial]? } struct LiveFeedBoxscoreTeams: Codable, Sendable { let away: LiveFeedBoxscoreTeam let home: LiveFeedBoxscoreTeam } struct LiveFeedBoxscoreTeam: Codable, Sendable { let team: StatsTeamBasic? let players: [String: LiveFeedBoxscorePlayer] let battingOrder: [JSONValue]? let batters: [Int]? var lineupPlayers: [LiveFeedBoxscorePlayer] { let orderedIDs = battingOrder?.compactMap(\.displayString) ?? batters?.map(String.init) ?? [] let players = orderedIDs.compactMap(player(forIdentifier:)) if !players.isEmpty { return players } return self.players.values.sorted { lhs, rhs in (lhs.battingOrder ?? "999") < (rhs.battingOrder ?? "999") } } private func player(forIdentifier identifier: String) -> LiveFeedBoxscorePlayer? { if let player = players[identifier] { return player } if let player = players["ID\(identifier)"] { return player } if identifier.hasPrefix("ID"), let player = players[String(identifier.dropFirst(2))] { return player } return nil } } struct LiveFeedBoxscorePlayer: Codable, Sendable, Identifiable { let person: LiveFeedPlayerReference let jerseyNumber: String? let position: PersonPosition? let battingOrder: String? var id: Int { person.id ?? -1 } } struct LiveFeedOfficial: Codable, Sendable { let official: LiveFeedPlayerReference? let officialType: String? } struct LiveFeedDecisions: Codable, Sendable { let winner: LiveFeedPlayerReference? let loser: LiveFeedPlayerReference? let save: LiveFeedPlayerReference? } // MARK: - Play Event / Pitch Data Models struct LiveFeedPlayEvent: Codable, Sendable, Identifiable { let details: LiveFeedPlayEventDetails? let count: LiveFeedPlayCount? let pitchData: LiveFeedPitchData? let hitData: LiveFeedHitData? let isPitch: Bool? let pitchNumber: Int? let index: Int? var id: Int { index ?? pitchNumber ?? 0 } var callCode: String { details?.call?.code ?? "" } var callDescription: String { details?.call?.description ?? details?.description ?? "" } var pitchTypeDescription: String { details?.type?.description ?? "Unknown" } var pitchTypeCode: String { details?.type?.code ?? "" } var speedMPH: Double? { pitchData?.startSpeed } } struct LiveFeedPlayEventDetails: Codable, Sendable { let call: LiveFeedCallInfo? let description: String? let type: LiveFeedPitchType? } struct LiveFeedCallInfo: Codable, Sendable { let code: String? let description: String? } struct LiveFeedPitchType: Codable, Sendable { let code: String? let description: String? } struct LiveFeedPitchData: Codable, Sendable { let startSpeed: Double? let endSpeed: Double? let strikeZoneTop: Double? let strikeZoneBottom: Double? let coordinates: LiveFeedPitchCoordinates? let zone: Int? } struct LiveFeedPitchCoordinates: Codable, Sendable { let pX: Double? let pZ: Double? } struct LiveFeedHitData: Codable, Sendable { let launchSpeed: Double? let launchAngle: Double? let totalDistance: Double? let coordinates: LiveFeedHitCoordinates? } struct LiveFeedHitCoordinates: Codable, Sendable { let coordX: Double? let coordY: Double? } // MARK: - Win Probability struct WinProbabilityEntry: Codable, Sendable { let atBatIndex: Int? let homeTeamWinProbability: Double? let awayTeamWinProbability: Double? }