Skip to main content

subtr_actor/stats/accumulators/
positioning.rs

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