subtr-actor 0.5.0

Rocket League replay transformer
Documentation
use super::{FrameEventsState, GameplayState};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum GameplayPhase {
    #[default]
    Unknown,
    KickoffCountdown,
    KickoffWaitingForTouch,
    ActivePlay,
    PostGoal,
}

impl GameplayPhase {
    pub fn is_live_play(self) -> bool {
        matches!(self, Self::ActivePlay)
    }

    pub fn counts_toward_player_motion(self) -> bool {
        matches!(self, Self::ActivePlay | Self::KickoffWaitingForTouch)
    }

    pub fn counts_toward_ball_position_stats(self) -> bool {
        self.is_live_play()
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LivePlayState {
    pub gameplay_phase: GameplayPhase,
    pub is_live_play: bool,
}

impl LivePlayState {
    pub fn counts_toward_player_motion(&self) -> bool {
        self.gameplay_phase.counts_toward_player_motion()
    }

    pub fn counts_toward_ball_position_stats(&self) -> bool {
        self.gameplay_phase.counts_toward_ball_position_stats()
    }
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct LivePlayTracker {
    post_goal_phase_active: bool,
    last_score: Option<(i32, i32)>,
}

impl LivePlayTracker {
    fn gameplay_phase_internal(
        &mut self,
        gameplay: &GameplayState,
        events: &FrameEventsState,
    ) -> GameplayPhase {
        let kickoff_phase_active = gameplay.kickoff_phase_active();
        let score_changed = gameplay.current_score().zip(self.last_score).is_some_and(
            |((team_zero_score, team_one_score), (last_team_zero, last_team_one))| {
                team_zero_score > last_team_zero || team_one_score > last_team_one
            },
        );

        if !events.goal_events.is_empty() || score_changed {
            self.post_goal_phase_active = true;
        }

        if kickoff_phase_active {
            self.post_goal_phase_active = false;
        }

        if let Some(score) = gameplay.current_score() {
            self.last_score = Some(score);
        }

        if gameplay.game_state == Some(crate::stats::calculators::GAME_STATE_KICKOFF_COUNTDOWN)
            || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
        {
            GameplayPhase::KickoffCountdown
        } else if gameplay.game_state
            == Some(crate::stats::calculators::GAME_STATE_GOAL_SCORED_REPLAY)
            || self.post_goal_phase_active
        {
            GameplayPhase::PostGoal
        } else if gameplay.ball_has_been_hit == Some(false) {
            GameplayPhase::KickoffWaitingForTouch
        } else if gameplay.is_live_play() {
            GameplayPhase::ActivePlay
        } else {
            GameplayPhase::Unknown
        }
    }

    pub fn state_parts(
        &mut self,
        gameplay: &GameplayState,
        events: &FrameEventsState,
    ) -> LivePlayState {
        let gameplay_phase = self.gameplay_phase_internal(gameplay, events);
        LivePlayState {
            gameplay_phase,
            is_live_play: gameplay_phase.is_live_play(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::GoalEvent;

    #[test]
    fn kickoff_waiting_for_first_touch_is_not_live_play() {
        let mut tracker = LivePlayTracker::default();
        let gameplay = GameplayState {
            ball_has_been_hit: Some(false),
            ..Default::default()
        };
        let state = tracker.state_parts(&gameplay, &FrameEventsState::default());

        assert_eq!(state.gameplay_phase, GameplayPhase::KickoffWaitingForTouch);
        assert!(!state.is_live_play);
        assert!(state.gameplay_phase.counts_toward_player_motion());
    }

    #[test]
    fn goal_event_enters_post_goal_phase() {
        let mut tracker = LivePlayTracker::default();
        let gameplay = GameplayState::default();
        let events = FrameEventsState {
            goal_events: vec![GoalEvent {
                time: 10.0,
                frame: 1,
                scoring_team_is_team_0: true,
                player: None,
                team_zero_score: None,
                team_one_score: None,
            }],
            ..Default::default()
        };

        let state = tracker.state_parts(&gameplay, &events);

        assert_eq!(state.gameplay_phase, GameplayPhase::PostGoal);
        assert!(!state.is_live_play);
    }
}