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