Skip to main content

rlstatsapi/
filters.rs

1use std::collections::HashSet;
2
3use crate::events::{
4    GoalScoredData, MatchEndedData, StatsEvent, UpdateStateData,
5    UpdateStatePlayer,
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum EventKind {
10    UpdateState,
11    BallHit,
12    ClockUpdatedSeconds,
13    CountdownBegin,
14    CrossbarHit,
15    GoalReplayEnd,
16    GoalReplayStart,
17    GoalReplayWillEnd,
18    GoalScored,
19    MatchCreated,
20    MatchInitialized,
21    MatchDestroyed,
22    MatchEnded,
23    MatchPaused,
24    MatchUnpaused,
25    PodiumStart,
26    ReplayCreated,
27    RoundStarted,
28    StatfeedEvent,
29    Unknown,
30}
31
32impl From<&StatsEvent> for EventKind {
33    fn from(value: &StatsEvent) -> Self {
34        match value {
35            StatsEvent::UpdateState(_) => Self::UpdateState,
36            StatsEvent::BallHit(_) => Self::BallHit,
37            StatsEvent::ClockUpdatedSeconds(_) => Self::ClockUpdatedSeconds,
38            StatsEvent::CountdownBegin(_) => Self::CountdownBegin,
39            StatsEvent::CrossbarHit(_) => Self::CrossbarHit,
40            StatsEvent::GoalReplayEnd(_) => Self::GoalReplayEnd,
41            StatsEvent::GoalReplayStart(_) => Self::GoalReplayStart,
42            StatsEvent::GoalReplayWillEnd(_) => Self::GoalReplayWillEnd,
43            StatsEvent::GoalScored(_) => Self::GoalScored,
44            StatsEvent::MatchCreated(_) => Self::MatchCreated,
45            StatsEvent::MatchInitialized(_) => Self::MatchInitialized,
46            StatsEvent::MatchDestroyed(_) => Self::MatchDestroyed,
47            StatsEvent::MatchEnded(_) => Self::MatchEnded,
48            StatsEvent::MatchPaused(_) => Self::MatchPaused,
49            StatsEvent::MatchUnpaused(_) => Self::MatchUnpaused,
50            StatsEvent::PodiumStart(_) => Self::PodiumStart,
51            StatsEvent::ReplayCreated(_) => Self::ReplayCreated,
52            StatsEvent::RoundStarted(_) => Self::RoundStarted,
53            StatsEvent::StatfeedEvent(_) => Self::StatfeedEvent,
54            StatsEvent::Unknown(_) => Self::Unknown,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Default)]
60pub struct EventFilter {
61    kinds: HashSet<EventKind>,
62    player_name: Option<String>,
63    player_primary_id: Option<String>,
64    team_num: Option<i64>,
65    match_guid: Option<String>,
66}
67
68impl EventFilter {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn include_kind(mut self, kind: EventKind) -> Self {
74        self.kinds.insert(kind);
75        self
76    }
77
78    pub fn include_kinds<I>(mut self, kinds: I) -> Self
79    where
80        I: IntoIterator<Item = EventKind>,
81    {
82        self.kinds.extend(kinds);
83        self
84    }
85
86    pub fn with_player_name(mut self, player_name: impl Into<String>) -> Self {
87        self.player_name = Some(player_name.into());
88        self
89    }
90
91    pub fn with_player_primary_id(
92        mut self,
93        player_primary_id: impl Into<String>,
94    ) -> Self {
95        self.player_primary_id = Some(player_primary_id.into());
96        self
97    }
98
99    pub fn with_team_num(mut self, team_num: i64) -> Self {
100        self.team_num = Some(team_num);
101        self
102    }
103
104    pub fn with_match_guid(mut self, match_guid: impl Into<String>) -> Self {
105        self.match_guid = Some(match_guid.into());
106        self
107    }
108
109    pub fn matches(&self, event: &StatsEvent) -> bool {
110        if !self.kinds.is_empty()
111            && !self.kinds.contains(&EventKind::from(event))
112        {
113            return false;
114        }
115
116        if let Some(expected_guid) = self.match_guid.as_deref() {
117            let matches_guid = event_match_guid(event)
118                .is_some_and(|guid| guid == expected_guid);
119            if !matches_guid {
120                return false;
121            }
122        }
123
124        if let Some(expected_team) = self.team_num {
125            if !event_has_team(event, expected_team) {
126                return false;
127            }
128        }
129
130        if let Some(expected_name) = self.player_name.as_deref() {
131            if !event_has_player_name(event, expected_name) {
132                return false;
133            }
134        }
135
136        if let Some(expected_primary_id) = self.player_primary_id.as_deref() {
137            if !event_has_player_primary_id(event, expected_primary_id) {
138                return false;
139            }
140        }
141
142        true
143    }
144}
145
146#[derive(Debug, Clone, PartialEq)]
147pub struct PlayerSnapshot {
148    pub match_guid: Option<String>,
149    pub frame: Option<i64>,
150    pub time_seconds: Option<i64>,
151    pub name: String,
152    pub primary_id: Option<String>,
153    pub team_num: Option<i64>,
154    pub score: Option<i64>,
155    pub goals: Option<i64>,
156    pub shots: Option<i64>,
157    pub assists: Option<i64>,
158    pub saves: Option<i64>,
159    pub touches: Option<i64>,
160    pub demos: Option<i64>,
161    pub speed: Option<f64>,
162    pub boost: Option<i64>,
163    pub b_boosting: Option<bool>,
164    pub b_supersonic: Option<bool>,
165}
166
167impl PlayerSnapshot {
168    fn from_update(
169        update: &UpdateStateData,
170        player: &UpdateStatePlayer,
171    ) -> Self {
172        Self {
173            match_guid: update.match_guid.clone(),
174            frame: update.game.frame,
175            time_seconds: update.game.time_seconds,
176            name: player.name.clone().unwrap_or_default(),
177            primary_id: player.primary_id.clone(),
178            team_num: player.team_num,
179            score: player.score,
180            goals: player.goals,
181            shots: player.shots,
182            assists: player.assists,
183            saves: player.saves,
184            touches: player.touches,
185            demos: player.demos,
186            speed: player.effective_speed(),
187            boost: player.effective_boost(),
188            b_boosting: player.effective_boosting(),
189            b_supersonic: player.effective_supersonic(),
190        }
191    }
192}
193
194#[derive(Debug, Clone)]
195pub struct PlayerTracker {
196    player_name: String,
197    latest: Option<PlayerSnapshot>,
198}
199
200impl PlayerTracker {
201    pub fn by_name(player_name: impl Into<String>) -> Self {
202        Self {
203            player_name: player_name.into(),
204            latest: None,
205        }
206    }
207
208    pub fn latest(&self) -> Option<&PlayerSnapshot> {
209        self.latest.as_ref()
210    }
211
212    pub fn update_from_event(
213        &mut self,
214        event: &StatsEvent,
215    ) -> Option<PlayerSnapshot> {
216        let StatsEvent::UpdateState(update) = event else {
217            return None;
218        };
219
220        let player = update.players.iter().find(|player| {
221            player.name.as_deref().is_some_and(|name| {
222                name.eq_ignore_ascii_case(&self.player_name)
223            })
224        })?;
225
226        let snapshot = PlayerSnapshot::from_update(update, player);
227        let changed = self.latest.as_ref() != Some(&snapshot);
228        self.latest = Some(snapshot.clone());
229
230        if changed { Some(snapshot) } else { None }
231    }
232}
233
234#[derive(Debug, Clone)]
235pub enum MatchSignal {
236    GoalScored(GoalScoredData),
237    MatchConcluded(MatchEndedData),
238}
239
240pub fn to_match_signal(event: &StatsEvent) -> Option<MatchSignal> {
241    match event {
242        StatsEvent::GoalScored(data) => {
243            Some(MatchSignal::GoalScored(data.clone()))
244        }
245        StatsEvent::MatchEnded(data) => {
246            Some(MatchSignal::MatchConcluded(data.clone()))
247        }
248        _ => None,
249    }
250}
251
252pub fn winner_team_num(event: &StatsEvent) -> Option<i64> {
253    match event {
254        StatsEvent::MatchEnded(data) => Some(data.winner_team_num),
255        _ => None,
256    }
257}
258
259fn event_match_guid(event: &StatsEvent) -> Option<&str> {
260    match event {
261        StatsEvent::UpdateState(data) => data.match_guid.as_deref(),
262        StatsEvent::BallHit(data) => data.match_guid.as_deref(),
263        StatsEvent::ClockUpdatedSeconds(data) => data.match_guid.as_deref(),
264        StatsEvent::CountdownBegin(data) => data.match_guid.as_deref(),
265        StatsEvent::CrossbarHit(data) => data.match_guid.as_deref(),
266        StatsEvent::GoalReplayEnd(data) => data.match_guid.as_deref(),
267        StatsEvent::GoalReplayStart(data) => data.match_guid.as_deref(),
268        StatsEvent::GoalReplayWillEnd(data) => data.match_guid.as_deref(),
269        StatsEvent::GoalScored(data) => data.match_guid.as_deref(),
270        StatsEvent::MatchCreated(data) => data.match_guid.as_deref(),
271        StatsEvent::MatchInitialized(data) => data.match_guid.as_deref(),
272        StatsEvent::MatchDestroyed(data) => data.match_guid.as_deref(),
273        StatsEvent::MatchEnded(data) => data.match_guid.as_deref(),
274        StatsEvent::MatchPaused(data) => data.match_guid.as_deref(),
275        StatsEvent::MatchUnpaused(data) => data.match_guid.as_deref(),
276        StatsEvent::PodiumStart(data) => data.match_guid.as_deref(),
277        StatsEvent::ReplayCreated(data) => data.match_guid.as_deref(),
278        StatsEvent::RoundStarted(data) => data.match_guid.as_deref(),
279        StatsEvent::StatfeedEvent(data) => data.match_guid.as_deref(),
280        StatsEvent::Unknown(data) => data
281            .data
282            .get("MatchGuid")
283            .or_else(|| data.data.get("matchGuid"))
284            .or_else(|| data.data.get("match_guid"))
285            .and_then(|value| value.as_str()),
286    }
287}
288
289fn event_has_team(event: &StatsEvent, expected_team: i64) -> bool {
290    match event {
291        StatsEvent::UpdateState(data) => {
292            data.players
293                .iter()
294                .any(|player| player.team_num == Some(expected_team))
295                || data
296                    .game
297                    .teams
298                    .iter()
299                    .any(|team| team.team_num == Some(expected_team))
300        }
301        StatsEvent::GoalScored(data) => {
302            data.scorer.team_num == expected_team
303                || data
304                    .assister
305                    .as_ref()
306                    .is_some_and(|assister| assister.team_num == expected_team)
307                || data.ball_last_touch.player.team_num == expected_team
308        }
309        StatsEvent::BallHit(data) => data
310            .players
311            .iter()
312            .any(|player| player.team_num == expected_team),
313        StatsEvent::StatfeedEvent(data) => {
314            data.main_target.team_num == expected_team
315                || data
316                    .secondary_target
317                    .as_ref()
318                    .is_some_and(|target| target.team_num == expected_team)
319        }
320        StatsEvent::MatchEnded(data) => data.winner_team_num == expected_team,
321        _ => false,
322    }
323}
324
325fn event_has_player_name(event: &StatsEvent, expected_name: &str) -> bool {
326    match event {
327        StatsEvent::UpdateState(data) => data.players.iter().any(|player| {
328            player
329                .name
330                .as_deref()
331                .is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
332        }),
333        StatsEvent::GoalScored(data) => {
334            data.scorer.name.eq_ignore_ascii_case(expected_name)
335                || data.assister.as_ref().is_some_and(|assister| {
336                    assister.name.eq_ignore_ascii_case(expected_name)
337                })
338                || data
339                    .ball_last_touch
340                    .player
341                    .name
342                    .eq_ignore_ascii_case(expected_name)
343        }
344        StatsEvent::BallHit(data) => data
345            .players
346            .iter()
347            .any(|player| player.name.eq_ignore_ascii_case(expected_name)),
348        StatsEvent::StatfeedEvent(data) => {
349            data.main_target.name.eq_ignore_ascii_case(expected_name)
350                || data.secondary_target.as_ref().is_some_and(|target| {
351                    target.name.eq_ignore_ascii_case(expected_name)
352                })
353        }
354        _ => false,
355    }
356}
357
358fn event_has_player_primary_id(
359    event: &StatsEvent,
360    expected_primary_id: &str,
361) -> bool {
362    match event {
363        StatsEvent::UpdateState(data) => data.players.iter().any(|player| {
364            player
365                .primary_id
366                .as_deref()
367                .is_some_and(|primary_id| primary_id == expected_primary_id)
368        }),
369        _ => false,
370    }
371}