Skip to main content

subtr_actor/stats/calculators/
player_state_span.rs

1use super::*;
2
3/// A span of game time during which a single player held one categorical state of
4/// one positioning facet (field third, ball-relative depth, rotation role, ...).
5///
6/// Every player-facet event stream shares this envelope; only the `state` payload
7/// differs per facet. Spans tile the player's tracked time contiguously: a span
8/// covers `(time, end_time]` and `duration` is the exact f32 sum of the per-frame
9/// (or sub-frame) contributions, so summing `duration` per state reproduces the
10/// exported per-state time totals. Frames whose motion crosses a state boundary
11/// are split at the crossing point into sub-frame spans instead of being
12/// annotated with fraction fields.
13#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
14#[ts(export)]
15pub struct PlayerStateSpan<S> {
16    pub time: f32,
17    pub frame: usize,
18    pub end_time: f32,
19    pub end_frame: usize,
20    pub duration: f32,
21    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
22    pub player: PlayerId,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub player_position: Option<[f32; 3]>,
25    pub is_team_0: bool,
26    pub state: S,
27}
28
29fn player_sort_key(player: &PlayerId) -> String {
30    format!("{player:?}")
31}
32
33/// Builds coalesced [`PlayerStateSpan`] streams for one facet: consecutive
34/// same-state contributions per player extend the open span, a state change
35/// closes it, and an explicit `close` (the facet stopped applying to the
36/// player) ends the span so a later resumption starts fresh.
37#[derive(Debug, Clone)]
38pub struct PlayerSpanTracker<S> {
39    open: HashMap<PlayerId, PlayerStateSpan<S>>,
40    closed: EventStream<PlayerStateSpan<S>>,
41}
42
43impl<S> Default for PlayerSpanTracker<S> {
44    fn default() -> Self {
45        Self {
46            open: HashMap::new(),
47            closed: EventStream::default(),
48        }
49    }
50}
51
52impl<S: Clone + PartialEq> PlayerSpanTracker<S> {
53    pub fn begin_update(&mut self) {
54        self.closed.begin_update();
55    }
56
57    /// Record that `player` held `state` over `(start_time, end_time]` within
58    /// `frame_number`, contributing `duration` seconds.
59    #[allow(clippy::too_many_arguments)]
60    pub fn record(
61        &mut self,
62        frame_number: usize,
63        start_time: f32,
64        end_time: f32,
65        duration: f32,
66        player: &PlayerId,
67        player_position: Option<[f32; 3]>,
68        is_team_0: bool,
69        state: S,
70    ) {
71        if let Some(open) = self.open.get_mut(player) {
72            if open.state == state {
73                open.end_time = end_time;
74                open.end_frame = frame_number;
75                open.duration += duration;
76                open.player_position = player_position;
77                return;
78            }
79        }
80        let span = PlayerStateSpan {
81            time: start_time,
82            frame: frame_number,
83            end_time,
84            end_frame: frame_number,
85            duration,
86            player: player.clone(),
87            player_position,
88            is_team_0,
89            state,
90        };
91        if let Some(previous) = self.open.insert(player.clone(), span) {
92            self.closed.push(previous);
93        }
94    }
95
96    pub fn close(&mut self, player: &PlayerId) {
97        if let Some(span) = self.open.remove(player) {
98            self.closed.push(span);
99        }
100    }
101
102    pub fn close_all(&mut self) {
103        let mut spans: Vec<_> = self.open.drain().map(|(_, span)| span).collect();
104        spans.sort_by_key(|span| player_sort_key(&span.player));
105        self.closed.extend(spans);
106    }
107
108    /// Spans already closed by a state change or facet gap.
109    pub fn events(&self) -> &[PlayerStateSpan<S>] {
110        self.closed.all()
111    }
112
113    /// Spans closed during the current update (since the last `begin_update`).
114    pub fn new_events(&self) -> &[PlayerStateSpan<S>] {
115        self.closed.new_events()
116    }
117
118    /// All spans including still-open ones with their duration so far.
119    pub fn projected_events(&self) -> Vec<PlayerStateSpan<S>> {
120        let mut events = self.closed.all().to_vec();
121        let mut open: Vec<_> = self.open.values().cloned().collect();
122        open.sort_by_key(|span| player_sort_key(&span.player));
123        events.extend(open);
124        events
125    }
126}
127
128/// Ordered `(state, fraction)` segments of a frame whose scalar moves linearly
129/// from `start` to `end`, classified against the half-open regions delimited by
130/// `thresholds` (ascending): region `i` is `[thresholds[i-1], thresholds[i])`.
131/// Fractions sum to 1 so `fraction * dt` per segment tiles the frame exactly.
132pub(crate) fn scalar_state_segments<S: Copy>(
133    start: f32,
134    end: f32,
135    thresholds: &[f32],
136    states: &[S],
137) -> Vec<(S, f32)> {
138    debug_assert_eq!(states.len(), thresholds.len() + 1);
139    let region = |value: f32| -> usize { thresholds.iter().take_while(|&&t| value >= t).count() };
140    let start_region = region(start);
141    let end_region = region(end);
142    if (end - start).abs() <= f32::EPSILON || start_region == end_region {
143        return vec![(states[start_region], 1.0)];
144    }
145    let direction: isize = if end > start { 1 } else { -1 };
146    let mut segments = Vec::new();
147    let mut current_region = start_region;
148    let mut previous_t = 0.0f32;
149    while current_region != end_region {
150        let crossing = if direction > 0 {
151            thresholds[current_region]
152        } else {
153            thresholds[current_region - 1]
154        };
155        let t = ((crossing - start) / (end - start)).clamp(0.0, 1.0);
156        segments.push((states[current_region], (t - previous_t).max(0.0)));
157        previous_t = t;
158        current_region = (current_region as isize + direction) as usize;
159    }
160    segments.push((states[end_region], (1.0 - previous_t).max(0.0)));
161    segments
162}
163
164#[cfg(test)]
165#[path = "player_state_span_tests.rs"]
166mod tests;