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 longest_first_man_stint_time: f32,
43    pub first_man_stint_count: u32,
44    pub became_first_man_count: u32,
45    pub lost_first_man_count: u32,
46    pub current_role_state: RoleState,
47    pub current_depth_state: PlayDepthState,
48}
49
50impl RotationPlayerStats {
51    fn role_pct(&self, value: f32) -> f32 {
52        if self.tracked_time == 0.0 {
53            0.0
54        } else {
55            value * 100.0 / self.tracked_time
56        }
57    }
58
59    pub fn first_man_pct(&self) -> f32 {
60        self.role_pct(self.time_first_man)
61    }
62
63    pub fn second_man_pct(&self) -> f32 {
64        self.role_pct(self.time_second_man)
65    }
66
67    pub fn third_man_pct(&self) -> f32 {
68        self.role_pct(self.time_third_man)
69    }
70
71    pub fn ambiguous_role_pct(&self) -> f32 {
72        self.role_pct(self.time_ambiguous_role)
73    }
74
75    pub fn behind_play_pct(&self) -> f32 {
76        self.role_pct(self.time_behind_play)
77    }
78
79    pub fn level_with_play_pct(&self) -> f32 {
80        self.role_pct(self.time_level_with_play)
81    }
82
83    pub fn ahead_of_play_pct(&self) -> f32 {
84        self.role_pct(self.time_ahead_of_play)
85    }
86
87    pub fn average_first_man_stint_time(&self) -> f32 {
88        if self.first_man_stint_count == 0 {
89            0.0
90        } else {
91            self.time_first_man / self.first_man_stint_count as f32
92        }
93    }
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
97#[ts(export)]
98pub struct RotationTeamStats {
99    pub first_man_changes_for_team: u32,
100    pub rotation_count: u32,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
104#[ts(export)]
105pub struct RotationPlayerEvent {
106    pub time: f32,
107    pub frame: usize,
108    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
109    pub player: PlayerId,
110    pub is_team_0: bool,
111    pub active: bool,
112    pub became_first_man_count: u32,
113    pub lost_first_man_count: u32,
114    pub current_role_state: RoleState,
115    pub current_depth_state: PlayDepthState,
116}
117
118impl RotationPlayerEvent {
119    fn new(
120        frame: &FrameInfo,
121        player: PlayerId,
122        is_team_0: bool,
123        active: bool,
124        current_role_state: RoleState,
125        current_depth_state: PlayDepthState,
126    ) -> Self {
127        Self {
128            time: frame.time,
129            frame: frame.frame_number,
130            player,
131            is_team_0,
132            active,
133            became_first_man_count: 0,
134            lost_first_man_count: 0,
135            current_role_state,
136            current_depth_state,
137        }
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
142#[ts(export)]
143pub struct RotationTeamEvent {
144    pub time: f32,
145    pub frame: usize,
146    pub is_team_0: bool,
147    pub first_man_changes_for_team: u32,
148    pub rotation_count: u32,
149}
150
151#[derive(Debug, Clone)]
152pub struct RotationCalculatorConfig {
153    pub role_depth_margin: f32,
154    pub first_man_ambiguity_margin: f32,
155    pub first_man_debounce_seconds: f32,
156}
157
158impl Default for RotationCalculatorConfig {
159    fn default() -> Self {
160        Self {
161            role_depth_margin: DEFAULT_ROLE_DEPTH_MARGIN,
162            first_man_ambiguity_margin: DEFAULT_FIRST_MAN_AMBIGUITY_MARGIN,
163            first_man_debounce_seconds: DEFAULT_FIRST_MAN_DEBOUNCE_SECONDS,
164        }
165    }
166}
167
168#[derive(Debug, Clone, Default, PartialEq)]
169struct TeamFirstManTracker {
170    stable_first_man: Option<PlayerId>,
171    pending_first_man: Option<PlayerId>,
172    pending_seconds: f32,
173}
174
175impl TeamFirstManTracker {
176    fn reset(&mut self) {
177        self.stable_first_man = None;
178        self.pending_first_man = None;
179        self.pending_seconds = 0.0;
180    }
181
182    fn update(
183        &mut self,
184        raw_first_man: Option<&PlayerId>,
185        dt: f32,
186        debounce_seconds: f32,
187    ) -> Option<(PlayerId, PlayerId)> {
188        let Some(raw_first_man) = raw_first_man else {
189            self.pending_first_man = None;
190            self.pending_seconds = 0.0;
191            return None;
192        };
193
194        match self.stable_first_man.as_ref() {
195            None => {
196                self.stable_first_man = Some(raw_first_man.clone());
197                self.pending_first_man = None;
198                self.pending_seconds = 0.0;
199                None
200            }
201            Some(stable_first_man) if stable_first_man == raw_first_man => {
202                self.pending_first_man = None;
203                self.pending_seconds = 0.0;
204                None
205            }
206            Some(stable_first_man) => {
207                if self.pending_first_man.as_ref() == Some(raw_first_man) {
208                    self.pending_seconds += dt;
209                } else {
210                    self.pending_first_man = Some(raw_first_man.clone());
211                    self.pending_seconds = dt;
212                }
213
214                if self.pending_seconds >= debounce_seconds {
215                    let previous = stable_first_man.clone();
216                    let next = raw_first_man.clone();
217                    self.stable_first_man = Some(next.clone());
218                    self.pending_first_man = None;
219                    self.pending_seconds = 0.0;
220                    Some((previous, next))
221                } else {
222                    None
223                }
224            }
225        }
226    }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230struct RotationPlayerEventState {
231    active: bool,
232    current_role_state: RoleState,
233    current_depth_state: PlayDepthState,
234}
235
236#[derive(Debug, Clone, Copy, Default, PartialEq)]
237struct FirstManStintState {
238    active: bool,
239    current_first_man_time: f32,
240    non_first_man_seconds: f32,
241}
242
243#[derive(Debug, Clone, Default)]
244pub struct RotationCalculator {
245    config: RotationCalculatorConfig,
246    player_stats: HashMap<PlayerId, RotationPlayerStats>,
247    team_zero_stats: RotationTeamStats,
248    team_one_stats: RotationTeamStats,
249    team_zero_tracker: TeamFirstManTracker,
250    team_one_tracker: TeamFirstManTracker,
251    player_events: Vec<RotationPlayerEvent>,
252    team_events: Vec<RotationTeamEvent>,
253    last_emitted_player_states: HashMap<PlayerId, RotationPlayerEventState>,
254    first_man_stints: HashMap<PlayerId, FirstManStintState>,
255}
256
257impl RotationCalculator {
258    pub fn new() -> Self {
259        Self::default()
260    }
261
262    pub fn with_config(config: RotationCalculatorConfig) -> Self {
263        Self {
264            config,
265            ..Self::default()
266        }
267    }
268
269    pub fn config(&self) -> &RotationCalculatorConfig {
270        &self.config
271    }
272
273    pub fn player_stats(&self) -> &HashMap<PlayerId, RotationPlayerStats> {
274        &self.player_stats
275    }
276
277    pub fn team_zero_stats(&self) -> &RotationTeamStats {
278        &self.team_zero_stats
279    }
280
281    pub fn team_one_stats(&self) -> &RotationTeamStats {
282        &self.team_one_stats
283    }
284
285    pub fn player_events(&self) -> &[RotationPlayerEvent] {
286        &self.player_events
287    }
288
289    pub fn team_events(&self) -> &[RotationTeamEvent] {
290        &self.team_events
291    }
292
293    pub fn update(
294        &mut self,
295        frame: &FrameInfo,
296        gameplay: &GameplayState,
297        ball: &BallFrameState,
298        players: &PlayerFrameState,
299        events: &FrameEventsState,
300        live_play: bool,
301    ) -> SubtrActorResult<()> {
302        if frame.dt == 0.0 {
303            return Ok(());
304        }
305
306        let Some(ball) = ball.sample() else {
307            self.reset_trackers();
308            self.emit_inactive_player_events(frame, players);
309            return Ok(());
310        };
311
312        if !live_play || !events.goal_events.is_empty() {
313            self.reset_trackers();
314            self.emit_inactive_player_events(frame, players);
315            return Ok(());
316        }
317
318        let demoed_players: HashSet<_> = events
319            .active_demos
320            .iter()
321            .map(|demo| demo.victim.clone())
322            .collect();
323        let ball_position = ball.position();
324
325        self.update_team(
326            true,
327            frame,
328            gameplay,
329            ball_position,
330            players,
331            &demoed_players,
332        );
333        self.update_team(
334            false,
335            frame,
336            gameplay,
337            ball_position,
338            players,
339            &demoed_players,
340        );
341
342        Ok(())
343    }
344
345    fn emit_inactive_player_events(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
346        for player in &players.players {
347            self.close_first_man_stint(&player.player_id);
348            let stats = self
349                .player_stats
350                .entry(player.player_id.clone())
351                .or_default();
352            let current_role_state = stats.current_role_state;
353            let current_depth_state = stats.current_depth_state;
354            self.emit_player_event_if_changed(
355                frame,
356                &player.player_id,
357                player.is_team_0,
358                false,
359                current_role_state,
360                current_depth_state,
361                0,
362                0,
363            );
364        }
365    }
366
367    fn reset_trackers(&mut self) {
368        self.team_zero_tracker.reset();
369        self.team_one_tracker.reset();
370    }
371
372    fn close_first_man_stint(&mut self, player_id: &PlayerId) {
373        if let Some(state) = self.first_man_stints.get_mut(player_id) {
374            state.active = false;
375            state.current_first_man_time = 0.0;
376            state.non_first_man_seconds = 0.0;
377        }
378    }
379
380    fn update_first_man_stint(
381        &mut self,
382        player_id: &PlayerId,
383        stats: &mut RotationPlayerStats,
384        role_state: RoleState,
385        dt: f32,
386    ) {
387        let state = self.first_man_stints.entry(player_id.clone()).or_default();
388        if role_state == RoleState::FirstMan {
389            if !state.active {
390                state.active = true;
391                state.current_first_man_time = 0.0;
392                stats.first_man_stint_count += 1;
393            }
394            state.current_first_man_time += dt;
395            stats.longest_first_man_stint_time = stats
396                .longest_first_man_stint_time
397                .max(state.current_first_man_time);
398            state.non_first_man_seconds = 0.0;
399            return;
400        }
401
402        if state.active {
403            state.non_first_man_seconds += dt;
404            if state.non_first_man_seconds > self.config.first_man_debounce_seconds {
405                state.active = false;
406                state.current_first_man_time = 0.0;
407                state.non_first_man_seconds = 0.0;
408            }
409        }
410    }
411
412    #[allow(clippy::too_many_arguments)]
413    fn emit_player_event_if_changed(
414        &mut self,
415        frame: &FrameInfo,
416        player_id: &PlayerId,
417        is_team_0: bool,
418        active: bool,
419        current_role_state: RoleState,
420        current_depth_state: PlayDepthState,
421        became_first_man_count: u32,
422        lost_first_man_count: u32,
423    ) {
424        let state = RotationPlayerEventState {
425            active,
426            current_role_state,
427            current_depth_state,
428        };
429        let state_changed = self.last_emitted_player_states.get(player_id) != Some(&state);
430        if !state_changed && became_first_man_count == 0 && lost_first_man_count == 0 {
431            return;
432        }
433
434        let mut event = RotationPlayerEvent::new(
435            frame,
436            player_id.clone(),
437            is_team_0,
438            active,
439            current_role_state,
440            current_depth_state,
441        );
442        event.became_first_man_count = became_first_man_count;
443        event.lost_first_man_count = lost_first_man_count;
444        self.player_events.push(event);
445        self.last_emitted_player_states
446            .insert(player_id.clone(), state);
447    }
448
449    fn update_team(
450        &mut self,
451        is_team_0: bool,
452        frame: &FrameInfo,
453        gameplay: &GameplayState,
454        ball_position: glam::Vec3,
455        players: &PlayerFrameState,
456        demoed_players: &HashSet<PlayerId>,
457    ) {
458        let present_team_count = players
459            .players
460            .iter()
461            .filter(|player| player.is_team_0 == is_team_0)
462            .count();
463        let team_size = gameplay
464            .current_in_game_team_player_count(is_team_0)
465            .max(present_team_count);
466
467        let team_players: Vec<_> = players
468            .players
469            .iter()
470            .filter(|player| player.is_team_0 == is_team_0)
471            .filter(|player| !demoed_players.contains(&player.player_id))
472            .filter_map(|player| player.position().map(|position| (player, position)))
473            .collect();
474
475        if !(2..=3).contains(&team_size) || team_players.len() != team_size {
476            self.team_tracker_mut(is_team_0).reset();
477            for player in players
478                .players
479                .iter()
480                .filter(|player| player.is_team_0 == is_team_0)
481            {
482                self.close_first_man_stint(&player.player_id);
483                let (current_role_state, current_depth_state) = {
484                    let stats = self
485                        .player_stats
486                        .entry(player.player_id.clone())
487                        .or_default();
488                    stats.current_role_state = RoleState::Unknown;
489                    (stats.current_role_state, stats.current_depth_state)
490                };
491                self.emit_player_event_if_changed(
492                    frame,
493                    &player.player_id,
494                    player.is_team_0,
495                    false,
496                    current_role_state,
497                    current_depth_state,
498                    0,
499                    0,
500                );
501            }
502            return;
503        }
504
505        let mut became_first_man_counts = HashMap::<PlayerId, u32>::new();
506        let mut lost_first_man_counts = HashMap::<PlayerId, u32>::new();
507        let mut scored_players: Vec<_> = team_players
508            .iter()
509            .map(|(player, position)| {
510                (
511                    player.player_id.clone(),
512                    first_man_score(*position, ball_position),
513                )
514            })
515            .collect();
516        scored_players.sort_by(|(_, left_score), (_, right_score)| {
517            left_score.partial_cmp(right_score).unwrap()
518        });
519
520        let raw_first_man = raw_first_man(&scored_players, self.config.first_man_ambiguity_margin);
521        let debounce_seconds = self.config.first_man_debounce_seconds;
522        let change =
523            self.team_tracker_mut(is_team_0)
524                .update(raw_first_man, frame.dt, debounce_seconds);
525        if let Some((previous, next)) = change {
526            let team_stats = self.team_stats_mut(is_team_0);
527            team_stats.first_man_changes_for_team += 1;
528            team_stats.rotation_count += 1;
529            self.team_events.push(RotationTeamEvent {
530                time: frame.time,
531                frame: frame.frame_number,
532                is_team_0,
533                first_man_changes_for_team: 1,
534                rotation_count: 1,
535            });
536            let previous_stats = self.player_stats.entry(previous.clone()).or_default();
537            previous_stats.lost_first_man_count += 1;
538            *lost_first_man_counts.entry(previous).or_default() += 1;
539            let next_stats = self.player_stats.entry(next.clone()).or_default();
540            next_stats.became_first_man_count += 1;
541            *became_first_man_counts.entry(next).or_default() += 1;
542        }
543
544        let stable_first_man = raw_first_man
545            .and_then(|_| self.team_tracker(is_team_0).stable_first_man.as_ref())
546            .cloned();
547        let role_assignments = role_assignments(stable_first_man.as_ref(), &scored_players);
548
549        for (player, position) in team_players {
550            let role_state = role_assignments
551                .get(&player.player_id)
552                .copied()
553                .unwrap_or(RoleState::Ambiguous);
554            let depth_state = play_depth_state(
555                is_team_0,
556                position,
557                ball_position,
558                self.config.role_depth_margin,
559            );
560            let (current_role_state, current_depth_state) = {
561                let mut stats = self
562                    .player_stats
563                    .remove(&player.player_id)
564                    .unwrap_or_default();
565                stats.active_game_time += frame.dt;
566                stats.tracked_time += frame.dt;
567                stats.current_role_state = role_state;
568                stats.current_depth_state = depth_state;
569                self.update_first_man_stint(&player.player_id, &mut stats, role_state, frame.dt);
570
571                match role_state {
572                    RoleState::FirstMan => {
573                        stats.time_first_man += frame.dt;
574                    }
575                    RoleState::SecondMan => {
576                        stats.time_second_man += frame.dt;
577                    }
578                    RoleState::ThirdMan => {
579                        stats.time_third_man += frame.dt;
580                    }
581                    RoleState::Ambiguous => {
582                        stats.time_ambiguous_role += frame.dt;
583                    }
584                    RoleState::Unknown => {}
585                }
586
587                match depth_state {
588                    PlayDepthState::BehindPlay => {
589                        stats.time_behind_play += frame.dt;
590                    }
591                    PlayDepthState::LevelWithPlay => {
592                        stats.time_level_with_play += frame.dt;
593                    }
594                    PlayDepthState::AheadOfPlay => {
595                        stats.time_ahead_of_play += frame.dt;
596                    }
597                    PlayDepthState::Unknown => {}
598                }
599
600                let current_role_state = stats.current_role_state;
601                let current_depth_state = stats.current_depth_state;
602                self.player_stats.insert(player.player_id.clone(), stats);
603                (current_role_state, current_depth_state)
604            };
605            let became_first_man_count = became_first_man_counts
606                .remove(&player.player_id)
607                .unwrap_or_default();
608            let lost_first_man_count = lost_first_man_counts
609                .remove(&player.player_id)
610                .unwrap_or_default();
611            self.emit_player_event_if_changed(
612                frame,
613                &player.player_id,
614                player.is_team_0,
615                true,
616                current_role_state,
617                current_depth_state,
618                became_first_man_count,
619                lost_first_man_count,
620            );
621        }
622
623        for (player_id, count) in became_first_man_counts {
624            let (current_role_state, current_depth_state) = {
625                let stats = self.player_stats.entry(player_id.clone()).or_default();
626                (stats.current_role_state, stats.current_depth_state)
627            };
628            self.emit_player_event_if_changed(
629                frame,
630                &player_id,
631                is_team_0,
632                false,
633                current_role_state,
634                current_depth_state,
635                count,
636                0,
637            );
638        }
639        for (player_id, count) in lost_first_man_counts {
640            let (current_role_state, current_depth_state) = {
641                let stats = self.player_stats.entry(player_id.clone()).or_default();
642                (stats.current_role_state, stats.current_depth_state)
643            };
644            self.emit_player_event_if_changed(
645                frame,
646                &player_id,
647                is_team_0,
648                false,
649                current_role_state,
650                current_depth_state,
651                0,
652                count,
653            );
654        }
655    }
656
657    fn team_tracker(&self, is_team_0: bool) -> &TeamFirstManTracker {
658        if is_team_0 {
659            &self.team_zero_tracker
660        } else {
661            &self.team_one_tracker
662        }
663    }
664
665    fn team_tracker_mut(&mut self, is_team_0: bool) -> &mut TeamFirstManTracker {
666        if is_team_0 {
667            &mut self.team_zero_tracker
668        } else {
669            &mut self.team_one_tracker
670        }
671    }
672
673    fn team_stats_mut(&mut self, is_team_0: bool) -> &mut RotationTeamStats {
674        if is_team_0 {
675            &mut self.team_zero_stats
676        } else {
677            &mut self.team_one_stats
678        }
679    }
680}
681
682fn first_man_score(player_position: glam::Vec3, ball_position: glam::Vec3) -> f32 {
683    player_position
684        .truncate()
685        .distance(ball_position.truncate())
686}
687
688fn raw_first_man(scored_players: &[(PlayerId, f32)], ambiguity_margin: f32) -> Option<&PlayerId> {
689    let [(first_id, first_score), (_, second_score), ..] = scored_players else {
690        return None;
691    };
692
693    if second_score - first_score <= ambiguity_margin {
694        None
695    } else {
696        Some(first_id)
697    }
698}
699
700fn role_assignments(
701    stable_first_man: Option<&PlayerId>,
702    scored_players: &[(PlayerId, f32)],
703) -> HashMap<PlayerId, RoleState> {
704    let mut assignments = HashMap::new();
705    let Some(stable_first_man) = stable_first_man else {
706        for (player_id, _) in scored_players {
707            assignments.insert(player_id.clone(), RoleState::Ambiguous);
708        }
709        return assignments;
710    };
711
712    assignments.insert(stable_first_man.clone(), RoleState::FirstMan);
713    let mut support_rank = 0;
714    for (player_id, _) in scored_players {
715        if player_id == stable_first_man {
716            continue;
717        }
718        support_rank += 1;
719        let role = match support_rank {
720            1 => RoleState::SecondMan,
721            2 => RoleState::ThirdMan,
722            _ => RoleState::Ambiguous,
723        };
724        assignments.insert(player_id.clone(), role);
725    }
726    assignments
727}
728
729fn play_depth_state(
730    is_team_0: bool,
731    player_position: glam::Vec3,
732    ball_position: glam::Vec3,
733    margin: f32,
734) -> PlayDepthState {
735    let player_y = normalized_y(is_team_0, player_position);
736    let ball_y = normalized_y(is_team_0, ball_position);
737    let delta = player_y - ball_y;
738    if delta < -margin {
739        PlayDepthState::BehindPlay
740    } else if delta > margin {
741        PlayDepthState::AheadOfPlay
742    } else {
743        PlayDepthState::LevelWithPlay
744    }
745}
746
747#[cfg(test)]
748#[path = "rotation_tests.rs"]
749mod tests;