Skip to main content

subtr_actor/stats/calculators/
rotation.rs

1use super::*;
2
3const DEFAULT_ROLE_DEPTH_MARGIN: f32 = 150.0;
4const DEFAULT_FIRST_MAN_AMBIGUITY_MARGIN: f32 = 250.0;
5const DEFAULT_FIRST_MAN_DEBOUNCE_SECONDS: f32 = 0.35;
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
8#[serde(rename_all = "snake_case")]
9#[ts(export)]
10pub enum RoleState {
11    #[default]
12    Unknown,
13    FirstMan,
14    SecondMan,
15    ThirdMan,
16    Ambiguous,
17}
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
20#[serde(rename_all = "snake_case")]
21#[ts(export)]
22pub enum PlayDepthState {
23    #[default]
24    Unknown,
25    BehindPlay,
26    LevelWithPlay,
27    AheadOfPlay,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
31#[ts(export)]
32pub struct RotationPlayerStats {
33    pub active_game_time: f32,
34    pub tracked_time: f32,
35    pub time_first_man: f32,
36    pub time_second_man: f32,
37    pub time_third_man: f32,
38    pub time_ambiguous_role: f32,
39    pub time_behind_play: f32,
40    pub time_level_with_play: f32,
41    pub time_ahead_of_play: f32,
42    pub became_first_man_count: u32,
43    pub lost_first_man_count: u32,
44    pub current_role_state: RoleState,
45    pub current_depth_state: PlayDepthState,
46}
47
48impl RotationPlayerStats {
49    fn role_pct(&self, value: f32) -> f32 {
50        if self.tracked_time == 0.0 {
51            0.0
52        } else {
53            value * 100.0 / self.tracked_time
54        }
55    }
56
57    pub fn first_man_pct(&self) -> f32 {
58        self.role_pct(self.time_first_man)
59    }
60
61    pub fn second_man_pct(&self) -> f32 {
62        self.role_pct(self.time_second_man)
63    }
64
65    pub fn third_man_pct(&self) -> f32 {
66        self.role_pct(self.time_third_man)
67    }
68
69    pub fn ambiguous_role_pct(&self) -> f32 {
70        self.role_pct(self.time_ambiguous_role)
71    }
72
73    pub fn behind_play_pct(&self) -> f32 {
74        self.role_pct(self.time_behind_play)
75    }
76
77    pub fn level_with_play_pct(&self) -> f32 {
78        self.role_pct(self.time_level_with_play)
79    }
80
81    pub fn ahead_of_play_pct(&self) -> f32 {
82        self.role_pct(self.time_ahead_of_play)
83    }
84}
85
86#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
87#[ts(export)]
88pub struct RotationTeamStats {
89    pub first_man_changes_for_team: u32,
90    pub rotation_count: u32,
91}
92
93#[derive(Debug, Clone)]
94pub struct RotationCalculatorConfig {
95    pub role_depth_margin: f32,
96    pub first_man_ambiguity_margin: f32,
97    pub first_man_debounce_seconds: f32,
98}
99
100impl Default for RotationCalculatorConfig {
101    fn default() -> Self {
102        Self {
103            role_depth_margin: DEFAULT_ROLE_DEPTH_MARGIN,
104            first_man_ambiguity_margin: DEFAULT_FIRST_MAN_AMBIGUITY_MARGIN,
105            first_man_debounce_seconds: DEFAULT_FIRST_MAN_DEBOUNCE_SECONDS,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Default, PartialEq)]
111struct TeamFirstManTracker {
112    stable_first_man: Option<PlayerId>,
113    pending_first_man: Option<PlayerId>,
114    pending_seconds: f32,
115}
116
117impl TeamFirstManTracker {
118    fn reset(&mut self) {
119        self.stable_first_man = None;
120        self.pending_first_man = None;
121        self.pending_seconds = 0.0;
122    }
123
124    fn update(
125        &mut self,
126        raw_first_man: Option<&PlayerId>,
127        dt: f32,
128        debounce_seconds: f32,
129    ) -> Option<(PlayerId, PlayerId)> {
130        let Some(raw_first_man) = raw_first_man else {
131            self.pending_first_man = None;
132            self.pending_seconds = 0.0;
133            return None;
134        };
135
136        match self.stable_first_man.as_ref() {
137            None => {
138                self.stable_first_man = Some(raw_first_man.clone());
139                self.pending_first_man = None;
140                self.pending_seconds = 0.0;
141                None
142            }
143            Some(stable_first_man) if stable_first_man == raw_first_man => {
144                self.pending_first_man = None;
145                self.pending_seconds = 0.0;
146                None
147            }
148            Some(stable_first_man) => {
149                if self.pending_first_man.as_ref() == Some(raw_first_man) {
150                    self.pending_seconds += dt;
151                } else {
152                    self.pending_first_man = Some(raw_first_man.clone());
153                    self.pending_seconds = dt;
154                }
155
156                if self.pending_seconds >= debounce_seconds {
157                    let previous = stable_first_man.clone();
158                    let next = raw_first_man.clone();
159                    self.stable_first_man = Some(next.clone());
160                    self.pending_first_man = None;
161                    self.pending_seconds = 0.0;
162                    Some((previous, next))
163                } else {
164                    None
165                }
166            }
167        }
168    }
169}
170
171#[derive(Debug, Clone, Default)]
172pub struct RotationCalculator {
173    config: RotationCalculatorConfig,
174    player_stats: HashMap<PlayerId, RotationPlayerStats>,
175    team_zero_stats: RotationTeamStats,
176    team_one_stats: RotationTeamStats,
177    team_zero_tracker: TeamFirstManTracker,
178    team_one_tracker: TeamFirstManTracker,
179}
180
181impl RotationCalculator {
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    pub fn with_config(config: RotationCalculatorConfig) -> Self {
187        Self {
188            config,
189            ..Self::default()
190        }
191    }
192
193    pub fn config(&self) -> &RotationCalculatorConfig {
194        &self.config
195    }
196
197    pub fn player_stats(&self) -> &HashMap<PlayerId, RotationPlayerStats> {
198        &self.player_stats
199    }
200
201    pub fn team_zero_stats(&self) -> &RotationTeamStats {
202        &self.team_zero_stats
203    }
204
205    pub fn team_one_stats(&self) -> &RotationTeamStats {
206        &self.team_one_stats
207    }
208
209    pub fn update(
210        &mut self,
211        frame: &FrameInfo,
212        gameplay: &GameplayState,
213        ball: &BallFrameState,
214        players: &PlayerFrameState,
215        events: &FrameEventsState,
216        live_play: bool,
217    ) -> SubtrActorResult<()> {
218        if frame.dt == 0.0 {
219            return Ok(());
220        }
221
222        let Some(ball) = ball.sample() else {
223            self.reset_trackers();
224            return Ok(());
225        };
226
227        if !live_play || !events.goal_events.is_empty() {
228            self.reset_trackers();
229            return Ok(());
230        }
231
232        let demoed_players: HashSet<_> = events
233            .active_demos
234            .iter()
235            .map(|demo| demo.victim.clone())
236            .collect();
237        let ball_position = ball.position();
238
239        self.update_team(
240            true,
241            frame,
242            gameplay,
243            ball_position,
244            players,
245            &demoed_players,
246        );
247        self.update_team(
248            false,
249            frame,
250            gameplay,
251            ball_position,
252            players,
253            &demoed_players,
254        );
255
256        Ok(())
257    }
258
259    fn reset_trackers(&mut self) {
260        self.team_zero_tracker.reset();
261        self.team_one_tracker.reset();
262    }
263
264    fn update_team(
265        &mut self,
266        is_team_0: bool,
267        frame: &FrameInfo,
268        gameplay: &GameplayState,
269        ball_position: glam::Vec3,
270        players: &PlayerFrameState,
271        demoed_players: &HashSet<PlayerId>,
272    ) {
273        let present_team_count = players
274            .players
275            .iter()
276            .filter(|player| player.is_team_0 == is_team_0)
277            .count();
278        let team_size = gameplay
279            .current_in_game_team_player_count(is_team_0)
280            .max(present_team_count);
281
282        let team_players: Vec<_> = players
283            .players
284            .iter()
285            .filter(|player| player.is_team_0 == is_team_0)
286            .filter(|player| !demoed_players.contains(&player.player_id))
287            .filter_map(|player| player.position().map(|position| (player, position)))
288            .collect();
289
290        if !(2..=3).contains(&team_size) || team_players.len() != team_size {
291            self.team_tracker_mut(is_team_0).reset();
292            for player in players
293                .players
294                .iter()
295                .filter(|player| player.is_team_0 == is_team_0)
296            {
297                self.player_stats
298                    .entry(player.player_id.clone())
299                    .or_default()
300                    .current_role_state = RoleState::Unknown;
301            }
302            return;
303        }
304
305        let mut scored_players: Vec<_> = team_players
306            .iter()
307            .map(|(player, position)| {
308                (
309                    player.player_id.clone(),
310                    first_man_score(*position, ball_position),
311                )
312            })
313            .collect();
314        scored_players.sort_by(|(_, left_score), (_, right_score)| {
315            left_score.partial_cmp(right_score).unwrap()
316        });
317
318        let raw_first_man = raw_first_man(&scored_players, self.config.first_man_ambiguity_margin);
319        let debounce_seconds = self.config.first_man_debounce_seconds;
320        let change =
321            self.team_tracker_mut(is_team_0)
322                .update(raw_first_man, frame.dt, debounce_seconds);
323        if let Some((previous, next)) = change {
324            let team_stats = self.team_stats_mut(is_team_0);
325            team_stats.first_man_changes_for_team += 1;
326            team_stats.rotation_count += 1;
327            self.player_stats
328                .entry(previous)
329                .or_default()
330                .lost_first_man_count += 1;
331            self.player_stats
332                .entry(next)
333                .or_default()
334                .became_first_man_count += 1;
335        }
336
337        let stable_first_man = raw_first_man
338            .and_then(|_| self.team_tracker(is_team_0).stable_first_man.as_ref())
339            .cloned();
340        let role_assignments = role_assignments(stable_first_man.as_ref(), &scored_players);
341
342        for (player, position) in team_players {
343            let role_state = role_assignments
344                .get(&player.player_id)
345                .copied()
346                .unwrap_or(RoleState::Ambiguous);
347            let depth_state = play_depth_state(
348                is_team_0,
349                position,
350                ball_position,
351                self.config.role_depth_margin,
352            );
353            let stats = self
354                .player_stats
355                .entry(player.player_id.clone())
356                .or_default();
357            stats.active_game_time += frame.dt;
358            stats.tracked_time += frame.dt;
359            stats.current_role_state = role_state;
360            stats.current_depth_state = depth_state;
361
362            match role_state {
363                RoleState::FirstMan => stats.time_first_man += frame.dt,
364                RoleState::SecondMan => stats.time_second_man += frame.dt,
365                RoleState::ThirdMan => stats.time_third_man += frame.dt,
366                RoleState::Ambiguous => stats.time_ambiguous_role += frame.dt,
367                RoleState::Unknown => {}
368            }
369
370            match depth_state {
371                PlayDepthState::BehindPlay => stats.time_behind_play += frame.dt,
372                PlayDepthState::LevelWithPlay => stats.time_level_with_play += frame.dt,
373                PlayDepthState::AheadOfPlay => stats.time_ahead_of_play += frame.dt,
374                PlayDepthState::Unknown => {}
375            }
376        }
377    }
378
379    fn team_tracker(&self, is_team_0: bool) -> &TeamFirstManTracker {
380        if is_team_0 {
381            &self.team_zero_tracker
382        } else {
383            &self.team_one_tracker
384        }
385    }
386
387    fn team_tracker_mut(&mut self, is_team_0: bool) -> &mut TeamFirstManTracker {
388        if is_team_0 {
389            &mut self.team_zero_tracker
390        } else {
391            &mut self.team_one_tracker
392        }
393    }
394
395    fn team_stats_mut(&mut self, is_team_0: bool) -> &mut RotationTeamStats {
396        if is_team_0 {
397            &mut self.team_zero_stats
398        } else {
399            &mut self.team_one_stats
400        }
401    }
402}
403
404fn first_man_score(player_position: glam::Vec3, ball_position: glam::Vec3) -> f32 {
405    player_position
406        .truncate()
407        .distance(ball_position.truncate())
408}
409
410fn raw_first_man(scored_players: &[(PlayerId, f32)], ambiguity_margin: f32) -> Option<&PlayerId> {
411    let [(first_id, first_score), (_, second_score), ..] = scored_players else {
412        return None;
413    };
414
415    if second_score - first_score <= ambiguity_margin {
416        None
417    } else {
418        Some(first_id)
419    }
420}
421
422fn role_assignments(
423    stable_first_man: Option<&PlayerId>,
424    scored_players: &[(PlayerId, f32)],
425) -> HashMap<PlayerId, RoleState> {
426    let mut assignments = HashMap::new();
427    let Some(stable_first_man) = stable_first_man else {
428        for (player_id, _) in scored_players {
429            assignments.insert(player_id.clone(), RoleState::Ambiguous);
430        }
431        return assignments;
432    };
433
434    assignments.insert(stable_first_man.clone(), RoleState::FirstMan);
435    let mut support_rank = 0;
436    for (player_id, _) in scored_players {
437        if player_id == stable_first_man {
438            continue;
439        }
440        support_rank += 1;
441        let role = match support_rank {
442            1 => RoleState::SecondMan,
443            2 => RoleState::ThirdMan,
444            _ => RoleState::Ambiguous,
445        };
446        assignments.insert(player_id.clone(), role);
447    }
448    assignments
449}
450
451fn play_depth_state(
452    is_team_0: bool,
453    player_position: glam::Vec3,
454    ball_position: glam::Vec3,
455    margin: f32,
456) -> PlayDepthState {
457    let player_y = normalized_y(is_team_0, player_position);
458    let ball_y = normalized_y(is_team_0, ball_position);
459    let delta = player_y - ball_y;
460    if delta < -margin {
461        PlayDepthState::BehindPlay
462    } else if delta > margin {
463        PlayDepthState::AheadOfPlay
464    } else {
465        PlayDepthState::LevelWithPlay
466    }
467}
468
469#[cfg(test)]
470#[path = "rotation_tests.rs"]
471mod tests;