rlstatsapi 0.1.4

Rocket League Stats API TCP client, parser, and optional Python bindings
Documentation
use std::collections::HashSet;

use crate::events::{
    GoalScoredData, MatchEndedData, StatsEvent, UpdateStateData,
    UpdateStatePlayer,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EventKind {
    UpdateState,
    BallHit,
    ClockUpdatedSeconds,
    CountdownBegin,
    CrossbarHit,
    GoalReplayEnd,
    GoalReplayStart,
    GoalReplayWillEnd,
    GoalScored,
    MatchCreated,
    MatchInitialized,
    MatchDestroyed,
    MatchEnded,
    MatchPaused,
    MatchUnpaused,
    PodiumStart,
    ReplayCreated,
    RoundStarted,
    StatfeedEvent,
    Unknown,
}

impl From<&StatsEvent> for EventKind {
    fn from(value: &StatsEvent) -> Self {
        match value {
            StatsEvent::UpdateState(_) => Self::UpdateState,
            StatsEvent::BallHit(_) => Self::BallHit,
            StatsEvent::ClockUpdatedSeconds(_) => Self::ClockUpdatedSeconds,
            StatsEvent::CountdownBegin(_) => Self::CountdownBegin,
            StatsEvent::CrossbarHit(_) => Self::CrossbarHit,
            StatsEvent::GoalReplayEnd(_) => Self::GoalReplayEnd,
            StatsEvent::GoalReplayStart(_) => Self::GoalReplayStart,
            StatsEvent::GoalReplayWillEnd(_) => Self::GoalReplayWillEnd,
            StatsEvent::GoalScored(_) => Self::GoalScored,
            StatsEvent::MatchCreated(_) => Self::MatchCreated,
            StatsEvent::MatchInitialized(_) => Self::MatchInitialized,
            StatsEvent::MatchDestroyed(_) => Self::MatchDestroyed,
            StatsEvent::MatchEnded(_) => Self::MatchEnded,
            StatsEvent::MatchPaused(_) => Self::MatchPaused,
            StatsEvent::MatchUnpaused(_) => Self::MatchUnpaused,
            StatsEvent::PodiumStart(_) => Self::PodiumStart,
            StatsEvent::ReplayCreated(_) => Self::ReplayCreated,
            StatsEvent::RoundStarted(_) => Self::RoundStarted,
            StatsEvent::StatfeedEvent(_) => Self::StatfeedEvent,
            StatsEvent::Unknown(_) => Self::Unknown,
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct EventFilter {
    kinds: HashSet<EventKind>,
    player_name: Option<String>,
    player_primary_id: Option<String>,
    team_num: Option<i64>,
    match_guid: Option<String>,
}

impl EventFilter {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn include_kind(mut self, kind: EventKind) -> Self {
        self.kinds.insert(kind);
        self
    }

    pub fn include_kinds<I>(mut self, kinds: I) -> Self
    where
        I: IntoIterator<Item = EventKind>,
    {
        self.kinds.extend(kinds);
        self
    }

    pub fn with_player_name(mut self, player_name: impl Into<String>) -> Self {
        self.player_name = Some(player_name.into());
        self
    }

    pub fn with_player_primary_id(
        mut self,
        player_primary_id: impl Into<String>,
    ) -> Self {
        self.player_primary_id = Some(player_primary_id.into());
        self
    }

    pub fn with_team_num(mut self, team_num: i64) -> Self {
        self.team_num = Some(team_num);
        self
    }

    pub fn with_match_guid(mut self, match_guid: impl Into<String>) -> Self {
        self.match_guid = Some(match_guid.into());
        self
    }

    pub fn matches(&self, event: &StatsEvent) -> bool {
        if !self.kinds.is_empty()
            && !self.kinds.contains(&EventKind::from(event))
        {
            return false;
        }

        if let Some(expected_guid) = self.match_guid.as_deref() {
            let matches_guid = event_match_guid(event)
                .is_some_and(|guid| guid == expected_guid);
            if !matches_guid {
                return false;
            }
        }

        if let Some(expected_team) = self.team_num {
            if !event_has_team(event, expected_team) {
                return false;
            }
        }

        if let Some(expected_name) = self.player_name.as_deref() {
            if !event_has_player_name(event, expected_name) {
                return false;
            }
        }

        if let Some(expected_primary_id) = self.player_primary_id.as_deref() {
            if !event_has_player_primary_id(event, expected_primary_id) {
                return false;
            }
        }

        true
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct PlayerSnapshot {
    pub match_guid: Option<String>,
    pub frame: Option<i64>,
    pub time_seconds: Option<i64>,
    pub name: String,
    pub primary_id: Option<String>,
    pub team_num: Option<i64>,
    pub score: Option<i64>,
    pub goals: Option<i64>,
    pub shots: Option<i64>,
    pub assists: Option<i64>,
    pub saves: Option<i64>,
    pub touches: Option<i64>,
    pub demos: Option<i64>,
    pub speed: Option<f64>,
    pub boost: Option<i64>,
    pub b_boosting: Option<bool>,
    pub b_supersonic: Option<bool>,
}

impl PlayerSnapshot {
    fn from_update(
        update: &UpdateStateData,
        player: &UpdateStatePlayer,
    ) -> Self {
        Self {
            match_guid: update.match_guid.clone(),
            frame: update.game.frame,
            time_seconds: update.game.time_seconds,
            name: player.name.clone().unwrap_or_default(),
            primary_id: player.primary_id.clone(),
            team_num: player.team_num,
            score: player.score,
            goals: player.goals,
            shots: player.shots,
            assists: player.assists,
            saves: player.saves,
            touches: player.touches,
            demos: player.demos,
            speed: player.effective_speed(),
            boost: player.effective_boost(),
            b_boosting: player.effective_boosting(),
            b_supersonic: player.effective_supersonic(),
        }
    }
}

#[derive(Debug, Clone)]
pub struct PlayerTracker {
    player_name: String,
    latest: Option<PlayerSnapshot>,
}

impl PlayerTracker {
    pub fn by_name(player_name: impl Into<String>) -> Self {
        Self {
            player_name: player_name.into(),
            latest: None,
        }
    }

    pub fn latest(&self) -> Option<&PlayerSnapshot> {
        self.latest.as_ref()
    }

    pub fn update_from_event(
        &mut self,
        event: &StatsEvent,
    ) -> Option<PlayerSnapshot> {
        let StatsEvent::UpdateState(update) = event else {
            return None;
        };

        let player = update.players.iter().find(|player| {
            player.name.as_deref().is_some_and(|name| {
                name.eq_ignore_ascii_case(&self.player_name)
            })
        })?;

        let snapshot = PlayerSnapshot::from_update(update, player);
        let changed = self.latest.as_ref() != Some(&snapshot);
        self.latest = Some(snapshot.clone());

        if changed { Some(snapshot) } else { None }
    }
}

#[derive(Debug, Clone)]
pub enum MatchSignal {
    GoalScored(GoalScoredData),
    MatchConcluded(MatchEndedData),
}

pub fn to_match_signal(event: &StatsEvent) -> Option<MatchSignal> {
    match event {
        StatsEvent::GoalScored(data) => {
            Some(MatchSignal::GoalScored(data.clone()))
        }
        StatsEvent::MatchEnded(data) => {
            Some(MatchSignal::MatchConcluded(data.clone()))
        }
        _ => None,
    }
}

pub fn winner_team_num(event: &StatsEvent) -> Option<i64> {
    match event {
        StatsEvent::MatchEnded(data) => Some(data.winner_team_num),
        _ => None,
    }
}

fn event_match_guid(event: &StatsEvent) -> Option<&str> {
    match event {
        StatsEvent::UpdateState(data) => data.match_guid.as_deref(),
        StatsEvent::BallHit(data) => data.match_guid.as_deref(),
        StatsEvent::ClockUpdatedSeconds(data) => data.match_guid.as_deref(),
        StatsEvent::CountdownBegin(data) => data.match_guid.as_deref(),
        StatsEvent::CrossbarHit(data) => data.match_guid.as_deref(),
        StatsEvent::GoalReplayEnd(data) => data.match_guid.as_deref(),
        StatsEvent::GoalReplayStart(data) => data.match_guid.as_deref(),
        StatsEvent::GoalReplayWillEnd(data) => data.match_guid.as_deref(),
        StatsEvent::GoalScored(data) => data.match_guid.as_deref(),
        StatsEvent::MatchCreated(data) => data.match_guid.as_deref(),
        StatsEvent::MatchInitialized(data) => data.match_guid.as_deref(),
        StatsEvent::MatchDestroyed(data) => data.match_guid.as_deref(),
        StatsEvent::MatchEnded(data) => data.match_guid.as_deref(),
        StatsEvent::MatchPaused(data) => data.match_guid.as_deref(),
        StatsEvent::MatchUnpaused(data) => data.match_guid.as_deref(),
        StatsEvent::PodiumStart(data) => data.match_guid.as_deref(),
        StatsEvent::ReplayCreated(data) => data.match_guid.as_deref(),
        StatsEvent::RoundStarted(data) => data.match_guid.as_deref(),
        StatsEvent::StatfeedEvent(data) => data.match_guid.as_deref(),
        StatsEvent::Unknown(data) => data
            .data
            .get("MatchGuid")
            .or_else(|| data.data.get("matchGuid"))
            .or_else(|| data.data.get("match_guid"))
            .and_then(|value| value.as_str()),
    }
}

fn event_has_team(event: &StatsEvent, expected_team: i64) -> bool {
    match event {
        StatsEvent::UpdateState(data) => {
            data.players
                .iter()
                .any(|player| player.team_num == Some(expected_team))
                || data
                    .game
                    .teams
                    .iter()
                    .any(|team| team.team_num == Some(expected_team))
        }
        StatsEvent::GoalScored(data) => {
            data.scorer.team_num == expected_team
                || data
                    .assister
                    .as_ref()
                    .is_some_and(|assister| assister.team_num == expected_team)
                || data.ball_last_touch.player.team_num == expected_team
        }
        StatsEvent::BallHit(data) => data
            .players
            .iter()
            .any(|player| player.team_num == expected_team),
        StatsEvent::StatfeedEvent(data) => {
            data.main_target.team_num == expected_team
                || data
                    .secondary_target
                    .as_ref()
                    .is_some_and(|target| target.team_num == expected_team)
        }
        StatsEvent::MatchEnded(data) => data.winner_team_num == expected_team,
        _ => false,
    }
}

fn event_has_player_name(event: &StatsEvent, expected_name: &str) -> bool {
    match event {
        StatsEvent::UpdateState(data) => data.players.iter().any(|player| {
            player
                .name
                .as_deref()
                .is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
        }),
        StatsEvent::GoalScored(data) => {
            data.scorer.name.eq_ignore_ascii_case(expected_name)
                || data.assister.as_ref().is_some_and(|assister| {
                    assister.name.eq_ignore_ascii_case(expected_name)
                })
                || data
                    .ball_last_touch
                    .player
                    .name
                    .eq_ignore_ascii_case(expected_name)
        }
        StatsEvent::BallHit(data) => data
            .players
            .iter()
            .any(|player| player.name.eq_ignore_ascii_case(expected_name)),
        StatsEvent::StatfeedEvent(data) => {
            data.main_target.name.eq_ignore_ascii_case(expected_name)
                || data.secondary_target.as_ref().is_some_and(|target| {
                    target.name.eq_ignore_ascii_case(expected_name)
                })
        }
        _ => false,
    }
}

fn event_has_player_primary_id(
    event: &StatsEvent,
    expected_primary_id: &str,
) -> bool {
    match event {
        StatsEvent::UpdateState(data) => data.players.iter().any(|player| {
            player
                .primary_id
                .as_deref()
                .is_some_and(|primary_id| primary_id == expected_primary_id)
        }),
        _ => false,
    }
}