Skip to main content

subtr_actor/stats/accumulators/
positioning.rs

1use super::*;
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
4#[ts(export)]
5pub struct PositioningStats {
6    pub active_game_time: f32,
7    pub tracked_time: f32,
8    pub sum_distance_to_teammates: f32,
9    pub sum_distance_to_ball: f32,
10    pub sum_distance_to_ball_has_possession: f32,
11    pub time_has_possession: f32,
12    pub sum_distance_to_ball_no_possession: f32,
13    pub time_no_possession: f32,
14    pub time_demolished: f32,
15    pub time_no_teammates: f32,
16    pub time_most_back: f32,
17    pub time_most_forward: f32,
18    pub time_mid_role: f32,
19    pub time_other_role: f32,
20    #[serde(rename = "time_defensive_third")]
21    pub time_defensive_zone: f32,
22    #[serde(rename = "time_neutral_third")]
23    pub time_neutral_zone: f32,
24    #[serde(rename = "time_offensive_third")]
25    pub time_offensive_zone: f32,
26    pub time_defensive_half: f32,
27    pub time_offensive_half: f32,
28    pub time_closest_to_ball_team: f32,
29    pub time_closest_to_ball_absolute: f32,
30    pub time_farthest_from_ball: f32,
31    pub time_behind_ball: f32,
32    pub time_level_with_ball: f32,
33    pub time_in_front_of_ball: f32,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
37#[ts(export)]
38pub struct PositioningTeamStats {
39    pub tracked_time: f32,
40    pub time_closest_to_ball_team: f32,
41    pub time_closest_to_ball_absolute: f32,
42}
43
44impl PositioningTeamStats {
45    fn pct(&self, value: f32) -> f32 {
46        if self.tracked_time == 0.0 {
47            0.0
48        } else {
49            value * 100.0 / self.tracked_time
50        }
51    }
52
53    pub fn closest_to_ball_team_pct(&self) -> f32 {
54        self.pct(self.time_closest_to_ball_team)
55    }
56
57    pub fn closest_to_ball_absolute_pct(&self) -> f32 {
58        self.pct(self.time_closest_to_ball_absolute)
59    }
60}
61
62impl PositioningStats {
63    pub fn average_distance_to_teammates(&self) -> f32 {
64        if self.tracked_time == 0.0 {
65            0.0
66        } else {
67            self.sum_distance_to_teammates / self.tracked_time
68        }
69    }
70
71    pub fn average_distance_to_ball(&self) -> f32 {
72        if self.tracked_time == 0.0 {
73            0.0
74        } else {
75            self.sum_distance_to_ball / self.tracked_time
76        }
77    }
78
79    pub fn average_distance_to_ball_has_possession(&self) -> f32 {
80        if self.time_has_possession == 0.0 {
81            0.0
82        } else {
83            self.sum_distance_to_ball_has_possession / self.time_has_possession
84        }
85    }
86
87    pub fn average_distance_to_ball_no_possession(&self) -> f32 {
88        if self.time_no_possession == 0.0 {
89            0.0
90        } else {
91            self.sum_distance_to_ball_no_possession / self.time_no_possession
92        }
93    }
94
95    fn pct(&self, value: f32) -> f32 {
96        if self.tracked_time == 0.0 {
97            0.0
98        } else {
99            value * 100.0 / self.tracked_time
100        }
101    }
102
103    pub fn most_back_pct(&self) -> f32 {
104        self.pct(self.time_most_back)
105    }
106
107    pub fn most_forward_pct(&self) -> f32 {
108        self.pct(self.time_most_forward)
109    }
110
111    pub fn mid_role_pct(&self) -> f32 {
112        self.pct(self.time_mid_role)
113    }
114
115    pub fn other_role_pct(&self) -> f32 {
116        self.pct(self.time_other_role)
117    }
118
119    pub fn defensive_third_pct(&self) -> f32 {
120        self.pct(self.time_defensive_zone)
121    }
122
123    pub fn neutral_third_pct(&self) -> f32 {
124        self.pct(self.time_neutral_zone)
125    }
126
127    pub fn offensive_third_pct(&self) -> f32 {
128        self.pct(self.time_offensive_zone)
129    }
130
131    pub fn defensive_half_pct(&self) -> f32 {
132        self.pct(self.time_defensive_half)
133    }
134
135    pub fn offensive_half_pct(&self) -> f32 {
136        self.pct(self.time_offensive_half)
137    }
138
139    pub fn closest_to_ball_team_pct(&self) -> f32 {
140        self.pct(self.time_closest_to_ball_team)
141    }
142
143    pub fn closest_to_ball_absolute_pct(&self) -> f32 {
144        self.pct(self.time_closest_to_ball_absolute)
145    }
146
147    pub fn farthest_from_ball_pct(&self) -> f32 {
148        self.pct(self.time_farthest_from_ball)
149    }
150
151    pub fn behind_ball_pct(&self) -> f32 {
152        self.pct(self.time_behind_ball)
153    }
154
155    pub fn level_with_ball_pct(&self) -> f32 {
156        self.pct(self.time_level_with_ball)
157    }
158
159    pub fn in_front_of_ball_pct(&self) -> f32 {
160        self.pct(self.time_in_front_of_ball)
161    }
162}
163
164/// Rebuilds [`PositioningStats`] from the per-facet event streams plus the
165/// continuous distance signal. This is the only accumulation path — the native
166/// projection and event-based playback reconstruction both run it, so they
167/// agree by construction.
168#[derive(Debug, Clone, Default, PartialEq)]
169pub struct PositioningStatsAccumulator {
170    player_stats: HashMap<PlayerId, PositioningStats>,
171    team_zero_stats: PositioningTeamStats,
172    team_one_stats: PositioningTeamStats,
173}
174
175impl PositioningStatsAccumulator {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    pub fn player_stats(&self) -> &HashMap<PlayerId, PositioningStats> {
181        &self.player_stats
182    }
183
184    pub fn team_zero_stats(&self) -> &PositioningTeamStats {
185        &self.team_zero_stats
186    }
187
188    pub fn team_one_stats(&self) -> &PositioningTeamStats {
189        &self.team_one_stats
190    }
191
192    pub fn apply_activity_event(&mut self, event: &PlayerActivityEvent) {
193        let stats = self.player_stats.entry(event.player.clone()).or_default();
194        stats.active_game_time += event.duration;
195        match event.state {
196            ActivityState::Tracked => stats.tracked_time += event.duration,
197            ActivityState::Demolished => stats.time_demolished += event.duration,
198        }
199    }
200
201    pub fn apply_field_third_event(&mut self, event: &FieldThirdEvent) {
202        let stats = self.player_stats.entry(event.player.clone()).or_default();
203        match event.state {
204            FieldThirdState::Defensive => stats.time_defensive_zone += event.duration,
205            FieldThirdState::Neutral => stats.time_neutral_zone += event.duration,
206            FieldThirdState::Offensive => stats.time_offensive_zone += event.duration,
207        }
208    }
209
210    pub fn apply_field_half_event(&mut self, event: &FieldHalfEvent) {
211        let stats = self.player_stats.entry(event.player.clone()).or_default();
212        match event.state {
213            FieldHalfState::Defensive => stats.time_defensive_half += event.duration,
214            FieldHalfState::Offensive => stats.time_offensive_half += event.duration,
215        }
216    }
217
218    pub fn apply_ball_depth_event(&mut self, event: &BallDepthEvent) {
219        let stats = self.player_stats.entry(event.player.clone()).or_default();
220        match event.state {
221            BallDepthState::BehindBall => stats.time_behind_ball += event.duration,
222            BallDepthState::LevelWithBall => stats.time_level_with_ball += event.duration,
223            BallDepthState::AheadOfBall => stats.time_in_front_of_ball += event.duration,
224        }
225    }
226
227    pub fn apply_depth_role_event(&mut self, event: &DepthRoleEvent) {
228        let stats = self.player_stats.entry(event.player.clone()).or_default();
229        match event.state {
230            DepthRoleState::NoTeammates => stats.time_no_teammates += event.duration,
231            DepthRoleState::MostBack => stats.time_most_back += event.duration,
232            DepthRoleState::MostForward => stats.time_most_forward += event.duration,
233            DepthRoleState::Mid => stats.time_mid_role += event.duration,
234            DepthRoleState::Other => stats.time_other_role += event.duration,
235        }
236    }
237
238    pub fn apply_ball_proximity_event(&mut self, event: &BallProximityEvent) {
239        let team_stats = if event.is_team_0 {
240            &mut self.team_zero_stats
241        } else {
242            &mut self.team_one_stats
243        };
244        let stats = self.player_stats.entry(event.player.clone()).or_default();
245        if event.state.closest_to_ball_team {
246            // Exactly one player per team is closest at any tracked moment, so
247            // summing closest spans doubles as the team's tracked time.
248            team_stats.tracked_time += event.duration;
249            team_stats.time_closest_to_ball_team += event.duration;
250            stats.time_closest_to_ball_team += event.duration;
251        }
252        if event.state.closest_to_ball_absolute {
253            team_stats.time_closest_to_ball_absolute += event.duration;
254            stats.time_closest_to_ball_absolute += event.duration;
255        }
256        if event.state.farthest_from_ball {
257            stats.time_farthest_from_ball += event.duration;
258        }
259    }
260
261    /// Seed the distance portion of a player's stats from the cumulative
262    /// [`PositioningSignalSnapshot`]. Distance is a continuous magnitude rather than an event,
263    /// so these fields are carried directly instead of being reconstructed from events.
264    pub fn apply_signal(&mut self, player: &PlayerId, signal: &PositioningSignalSnapshot) {
265        let stats = self.player_stats.entry(player.clone()).or_default();
266        stats.sum_distance_to_teammates = signal.sum_distance_to_teammates;
267        stats.sum_distance_to_ball = signal.sum_distance_to_ball;
268        stats.sum_distance_to_ball_has_possession = signal.sum_distance_to_ball_has_possession;
269        stats.time_has_possession = signal.time_has_possession;
270        stats.sum_distance_to_ball_no_possession = signal.sum_distance_to_ball_no_possession;
271        stats.time_no_possession = signal.time_no_possession;
272    }
273}
274
275#[cfg(test)]
276#[path = "positioning_stats_tests.rs"]
277mod tests;