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}