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;
6const DEFAULT_FIRST_MAN_STINT_END_GRACE_SECONDS: f32 = 0.35;
7
8/// A player's rotational role (first/second/third man).
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
10#[serde(rename_all = "snake_case")]
11#[ts(export)]
12pub enum RoleState {
13    #[default]
14    Unknown,
15    FirstMan,
16    SecondMan,
17    ThirdMan,
18    Ambiguous,
19}
20
21pub const ALL_ROLE_STATES: [RoleState; 5] = [
22    RoleState::Unknown,
23    RoleState::FirstMan,
24    RoleState::SecondMan,
25    RoleState::ThirdMan,
26    RoleState::Ambiguous,
27];
28
29impl RoleState {
30    pub fn as_label(self) -> StatLabel {
31        let value = match self {
32            Self::Unknown => "unknown",
33            Self::FirstMan => "first_man",
34            Self::SecondMan => "second_man",
35            Self::ThirdMan => "third_man",
36            Self::Ambiguous => "ambiguous",
37        };
38        StatLabel::new("role", value)
39    }
40}
41
42/// Depth relative to the play, used to tag touches with the toucher's rotation
43/// context. This is no longer emitted as its own event stream — the unified
44/// `ball_depth` positioning facet covers depth spans on the timeline — but the
45/// rotation calculator still computes it per frame for touch classification.
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
47#[serde(rename_all = "snake_case")]
48#[ts(export)]
49pub enum PlayDepthState {
50    #[default]
51    Unknown,
52    BehindPlay,
53    LevelWithPlay,
54    AheadOfPlay,
55}
56
57pub const ALL_PLAY_DEPTH_STATES: [PlayDepthState; 4] = [
58    PlayDepthState::Unknown,
59    PlayDepthState::BehindPlay,
60    PlayDepthState::LevelWithPlay,
61    PlayDepthState::AheadOfPlay,
62];
63
64impl PlayDepthState {
65    pub fn as_label(self) -> StatLabel {
66        let value = match self {
67            Self::Unknown => "unknown",
68            Self::BehindPlay => "behind_play",
69            Self::LevelWithPlay => "level_with_play",
70            Self::AheadOfPlay => "ahead_of_play",
71        };
72        StatLabel::new("play_depth", value)
73    }
74}
75
76/// A span of game time during which a player held one rotation role. Spans are
77/// only emitted while rotation tracking is active (live play, full 2v2/3v3
78/// rosters), so they never carry [`RoleState::Unknown`].
79pub type RotationRoleEvent = PlayerStateSpan<RoleState>;
80
81/// The debounced first man for a team changed from one player to another.
82#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
83#[ts(export)]
84pub struct FirstManChangeEvent {
85    pub time: f32,
86    pub frame: usize,
87    pub is_team_0: bool,
88    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
89    pub previous_first_man: PlayerId,
90    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
91    pub next_first_man: PlayerId,
92}
93
94/// Configuration thresholds for rotation classification.
95#[derive(Debug, Clone)]
96pub struct RotationCalculatorConfig {
97    pub role_depth_margin: f32,
98    pub first_man_ambiguity_margin: f32,
99    pub first_man_debounce_seconds: f32,
100    pub first_man_stint_end_grace_seconds: f32,
101}
102
103impl Default for RotationCalculatorConfig {
104    fn default() -> Self {
105        Self {
106            role_depth_margin: DEFAULT_ROLE_DEPTH_MARGIN,
107            first_man_ambiguity_margin: DEFAULT_FIRST_MAN_AMBIGUITY_MARGIN,
108            first_man_debounce_seconds: DEFAULT_FIRST_MAN_DEBOUNCE_SECONDS,
109            first_man_stint_end_grace_seconds: DEFAULT_FIRST_MAN_STINT_END_GRACE_SECONDS,
110        }
111    }
112}
113
114#[derive(Debug, Clone, Default, PartialEq)]
115struct TeamFirstManTracker {
116    stable_first_man: Option<PlayerId>,
117    pending_first_man: Option<PlayerId>,
118    pending_seconds: f32,
119}
120
121impl TeamFirstManTracker {
122    fn reset(&mut self) {
123        self.stable_first_man = None;
124        self.pending_first_man = None;
125        self.pending_seconds = 0.0;
126    }
127
128    fn update(
129        &mut self,
130        raw_first_man: Option<&PlayerId>,
131        dt: f32,
132        debounce_seconds: f32,
133    ) -> Option<(PlayerId, PlayerId)> {
134        let Some(raw_first_man) = raw_first_man else {
135            self.pending_first_man = None;
136            self.pending_seconds = 0.0;
137            return None;
138        };
139
140        match self.stable_first_man.as_ref() {
141            None => {
142                self.stable_first_man = Some(raw_first_man.clone());
143                self.pending_first_man = None;
144                self.pending_seconds = 0.0;
145                None
146            }
147            Some(stable_first_man) if stable_first_man == raw_first_man => {
148                self.pending_first_man = None;
149                self.pending_seconds = 0.0;
150                None
151            }
152            Some(stable_first_man) => {
153                if self.pending_first_man.as_ref() == Some(raw_first_man) {
154                    self.pending_seconds += dt;
155                } else {
156                    self.pending_first_man = Some(raw_first_man.clone());
157                    self.pending_seconds = dt;
158                }
159
160                if self.pending_seconds >= debounce_seconds {
161                    let previous = stable_first_man.clone();
162                    let next = raw_first_man.clone();
163                    self.stable_first_man = Some(next.clone());
164                    self.pending_first_man = None;
165                    self.pending_seconds = 0.0;
166                    Some((previous, next))
167                } else {
168                    None
169                }
170            }
171        }
172    }
173}
174
175/// Tracks rotational roles over time.
176#[derive(Debug, Clone, Default)]
177pub struct RotationCalculator {
178    config: RotationCalculatorConfig,
179    team_zero_tracker: TeamFirstManTracker,
180    team_one_tracker: TeamFirstManTracker,
181    role_spans: PlayerSpanTracker<RoleState>,
182    first_man_changes: EventStream<FirstManChangeEvent>,
183    current_states: HashMap<PlayerId, (RoleState, PlayDepthState)>,
184}
185
186impl RotationCalculator {
187    pub fn new() -> Self {
188        Self::default()
189    }
190
191    pub fn with_config(config: RotationCalculatorConfig) -> Self {
192        Self {
193            config,
194            ..Self::default()
195        }
196    }
197
198    pub fn config(&self) -> &RotationCalculatorConfig {
199        &self.config
200    }
201
202    pub fn role_events(&self) -> Vec<RotationRoleEvent> {
203        self.role_spans.projected_events()
204    }
205
206    pub fn first_man_change_events(&self) -> &[FirstManChangeEvent] {
207        self.first_man_changes.all()
208    }
209
210    /// Role spans closed during the current frame's update.
211    pub fn new_role_events(&self) -> &[RotationRoleEvent] {
212        self.role_spans.new_events()
213    }
214
215    /// Close every open role span so the projected event stream is final.
216    pub fn flush_pending_events(&mut self) {
217        self.role_spans.close_all();
218    }
219
220    /// The rotation role and play-depth the player currently holds, as of the
221    /// most recently processed frame. Used by downstream consumers (e.g. touch
222    /// classification) to tag events with the toucher's rotation context.
223    pub fn current_role_and_depth(&self, player_id: &PlayerId) -> (RoleState, PlayDepthState) {
224        self.current_states
225            .get(player_id)
226            .copied()
227            .unwrap_or_default()
228    }
229
230    pub fn update(
231        &mut self,
232        frame: &FrameInfo,
233        gameplay: &GameplayState,
234        ball: &BallFrameState,
235        players: &PlayerFrameState,
236        events: &FrameEventsState,
237        live_play_state: &LivePlayState,
238    ) -> SubtrActorResult<()> {
239        self.role_spans.begin_update();
240        self.first_man_changes.begin_update();
241        if frame.dt == 0.0 {
242            return Ok(());
243        }
244
245        let Some(ball) = ball.sample() else {
246            self.reset_trackers();
247            self.role_spans.close_all();
248            return Ok(());
249        };
250
251        if !live_play_state.is_live_play || !events.goal_events.is_empty() {
252            self.reset_trackers();
253            self.role_spans.close_all();
254            return Ok(());
255        }
256
257        let demoed_players: HashSet<_> = events
258            .active_demos
259            .iter()
260            .map(|demo| demo.victim.clone())
261            .collect();
262        let ball_position = ball.position();
263
264        self.update_team(
265            true,
266            frame,
267            gameplay,
268            ball_position,
269            players,
270            &demoed_players,
271        );
272        self.update_team(
273            false,
274            frame,
275            gameplay,
276            ball_position,
277            players,
278            &demoed_players,
279        );
280
281        Ok(())
282    }
283
284    fn reset_trackers(&mut self) {
285        self.team_zero_tracker.reset();
286        self.team_one_tracker.reset();
287    }
288
289    fn update_team(
290        &mut self,
291        is_team_0: bool,
292        frame: &FrameInfo,
293        gameplay: &GameplayState,
294        ball_position: glam::Vec3,
295        players: &PlayerFrameState,
296        demoed_players: &HashSet<PlayerId>,
297    ) {
298        let present_team_count = players
299            .players
300            .iter()
301            .filter(|player| player.is_team_0 == is_team_0)
302            .count();
303        let team_size = gameplay
304            .current_in_game_team_player_count(is_team_0)
305            .max(present_team_count);
306
307        let mut team_players: Vec<_> = players
308            .players
309            .iter()
310            .filter(|player| player.is_team_0 == is_team_0)
311            .filter(|player| !demoed_players.contains(&player.player_id))
312            .filter_map(|player| player.position().map(|position| (player, position)))
313            .collect();
314        team_players.sort_by_key(|(player, _)| format!("{:?}", player.player_id));
315
316        if !(2..=3).contains(&team_size) || team_players.len() != team_size {
317            self.team_tracker_mut(is_team_0).reset();
318            for player in players
319                .players
320                .iter()
321                .filter(|player| player.is_team_0 == is_team_0)
322            {
323                self.role_spans.close(&player.player_id);
324                let state = self
325                    .current_states
326                    .entry(player.player_id.clone())
327                    .or_default();
328                state.0 = RoleState::Unknown;
329            }
330            return;
331        }
332
333        let mut scored_players: Vec<_> = team_players
334            .iter()
335            .map(|(player, position)| {
336                (
337                    player.player_id.clone(),
338                    first_man_score(*position, ball_position),
339                )
340            })
341            .collect();
342        scored_players.sort_by(|(_, left_score), (_, right_score)| {
343            left_score.partial_cmp(right_score).unwrap()
344        });
345
346        let raw_first_man = raw_first_man(&scored_players, self.config.first_man_ambiguity_margin);
347        let debounce_seconds = self.config.first_man_debounce_seconds;
348        let change =
349            self.team_tracker_mut(is_team_0)
350                .update(raw_first_man, frame.dt, debounce_seconds);
351        if let Some((previous, next)) = change {
352            let event = FirstManChangeEvent {
353                time: frame.time,
354                frame: frame.frame_number,
355                is_team_0,
356                previous_first_man: previous,
357                next_first_man: next,
358            };
359            self.first_man_changes.push(event);
360        }
361
362        let stable_first_man = raw_first_man
363            .and_then(|_| self.team_tracker(is_team_0).stable_first_man.as_ref())
364            .cloned();
365        let role_assignments = role_assignments(stable_first_man.as_ref(), &scored_players);
366
367        for (player, position) in team_players {
368            let role_state = role_assignments
369                .get(&player.player_id)
370                .copied()
371                .unwrap_or(RoleState::Ambiguous);
372            let depth_state = play_depth_state(
373                is_team_0,
374                position,
375                ball_position,
376                self.config.role_depth_margin,
377            );
378            self.role_spans.record(
379                frame.frame_number,
380                frame.time - frame.dt,
381                frame.time,
382                frame.dt,
383                &player.player_id,
384                player.position().map(|position| position.to_array()),
385                player.is_team_0,
386                role_state,
387            );
388            self.current_states
389                .insert(player.player_id.clone(), (role_state, depth_state));
390        }
391    }
392
393    fn team_tracker(&self, is_team_0: bool) -> &TeamFirstManTracker {
394        if is_team_0 {
395            &self.team_zero_tracker
396        } else {
397            &self.team_one_tracker
398        }
399    }
400
401    fn team_tracker_mut(&mut self, is_team_0: bool) -> &mut TeamFirstManTracker {
402        if is_team_0 {
403            &mut self.team_zero_tracker
404        } else {
405            &mut self.team_one_tracker
406        }
407    }
408}
409
410fn first_man_score(player_position: glam::Vec3, ball_position: glam::Vec3) -> f32 {
411    player_position
412        .truncate()
413        .distance(ball_position.truncate())
414}
415
416fn raw_first_man(scored_players: &[(PlayerId, f32)], ambiguity_margin: f32) -> Option<&PlayerId> {
417    let [(first_id, first_score), (_, second_score), ..] = scored_players else {
418        return None;
419    };
420
421    if second_score - first_score <= ambiguity_margin {
422        None
423    } else {
424        Some(first_id)
425    }
426}
427
428fn role_assignments(
429    stable_first_man: Option<&PlayerId>,
430    scored_players: &[(PlayerId, f32)],
431) -> HashMap<PlayerId, RoleState> {
432    let mut assignments = HashMap::new();
433    let Some(stable_first_man) = stable_first_man else {
434        for (player_id, _) in scored_players {
435            assignments.insert(player_id.clone(), RoleState::Ambiguous);
436        }
437        return assignments;
438    };
439
440    assignments.insert(stable_first_man.clone(), RoleState::FirstMan);
441    let mut support_rank = 0;
442    for (player_id, _) in scored_players {
443        if player_id == stable_first_man {
444            continue;
445        }
446        support_rank += 1;
447        let role = match support_rank {
448            1 => RoleState::SecondMan,
449            2 => RoleState::ThirdMan,
450            _ => RoleState::Ambiguous,
451        };
452        assignments.insert(player_id.clone(), role);
453    }
454    assignments
455}
456
457fn play_depth_state(
458    is_team_0: bool,
459    player_position: glam::Vec3,
460    ball_position: glam::Vec3,
461    margin: f32,
462) -> PlayDepthState {
463    let player_y = normalized_y(is_team_0, player_position);
464    let ball_y = normalized_y(is_team_0, ball_position);
465    let delta = player_y - ball_y;
466    if delta < -margin {
467        PlayDepthState::BehindPlay
468    } else if delta > margin {
469        PlayDepthState::AheadOfPlay
470    } else {
471        PlayDepthState::LevelWithPlay
472    }
473}
474
475#[cfg(test)]
476#[path = "rotation_tests.rs"]
477mod tests;