subtr_actor/stats/calculators/
rotation.rs1use 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)]
94pub struct RotationCalculatorConfig {
95 pub role_depth_margin: f32,
96 pub first_man_ambiguity_margin: f32,
97 pub first_man_debounce_seconds: f32,
98}
99
100impl Default for RotationCalculatorConfig {
101 fn default() -> Self {
102 Self {
103 role_depth_margin: DEFAULT_ROLE_DEPTH_MARGIN,
104 first_man_ambiguity_margin: DEFAULT_FIRST_MAN_AMBIGUITY_MARGIN,
105 first_man_debounce_seconds: DEFAULT_FIRST_MAN_DEBOUNCE_SECONDS,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Default, PartialEq)]
111struct TeamFirstManTracker {
112 stable_first_man: Option<PlayerId>,
113 pending_first_man: Option<PlayerId>,
114 pending_seconds: f32,
115}
116
117impl TeamFirstManTracker {
118 fn reset(&mut self) {
119 self.stable_first_man = None;
120 self.pending_first_man = None;
121 self.pending_seconds = 0.0;
122 }
123
124 fn update(
125 &mut self,
126 raw_first_man: Option<&PlayerId>,
127 dt: f32,
128 debounce_seconds: f32,
129 ) -> Option<(PlayerId, PlayerId)> {
130 let Some(raw_first_man) = raw_first_man else {
131 self.pending_first_man = None;
132 self.pending_seconds = 0.0;
133 return None;
134 };
135
136 match self.stable_first_man.as_ref() {
137 None => {
138 self.stable_first_man = Some(raw_first_man.clone());
139 self.pending_first_man = None;
140 self.pending_seconds = 0.0;
141 None
142 }
143 Some(stable_first_man) if stable_first_man == raw_first_man => {
144 self.pending_first_man = None;
145 self.pending_seconds = 0.0;
146 None
147 }
148 Some(stable_first_man) => {
149 if self.pending_first_man.as_ref() == Some(raw_first_man) {
150 self.pending_seconds += dt;
151 } else {
152 self.pending_first_man = Some(raw_first_man.clone());
153 self.pending_seconds = dt;
154 }
155
156 if self.pending_seconds >= debounce_seconds {
157 let previous = stable_first_man.clone();
158 let next = raw_first_man.clone();
159 self.stable_first_man = Some(next.clone());
160 self.pending_first_man = None;
161 self.pending_seconds = 0.0;
162 Some((previous, next))
163 } else {
164 None
165 }
166 }
167 }
168 }
169}
170
171#[derive(Debug, Clone, Default)]
172pub struct RotationCalculator {
173 config: RotationCalculatorConfig,
174 player_stats: HashMap<PlayerId, RotationPlayerStats>,
175 team_zero_stats: RotationTeamStats,
176 team_one_stats: RotationTeamStats,
177 team_zero_tracker: TeamFirstManTracker,
178 team_one_tracker: TeamFirstManTracker,
179}
180
181impl RotationCalculator {
182 pub fn new() -> Self {
183 Self::default()
184 }
185
186 pub fn with_config(config: RotationCalculatorConfig) -> Self {
187 Self {
188 config,
189 ..Self::default()
190 }
191 }
192
193 pub fn config(&self) -> &RotationCalculatorConfig {
194 &self.config
195 }
196
197 pub fn player_stats(&self) -> &HashMap<PlayerId, RotationPlayerStats> {
198 &self.player_stats
199 }
200
201 pub fn team_zero_stats(&self) -> &RotationTeamStats {
202 &self.team_zero_stats
203 }
204
205 pub fn team_one_stats(&self) -> &RotationTeamStats {
206 &self.team_one_stats
207 }
208
209 pub fn update(
210 &mut self,
211 frame: &FrameInfo,
212 gameplay: &GameplayState,
213 ball: &BallFrameState,
214 players: &PlayerFrameState,
215 events: &FrameEventsState,
216 live_play: bool,
217 ) -> SubtrActorResult<()> {
218 if frame.dt == 0.0 {
219 return Ok(());
220 }
221
222 let Some(ball) = ball.sample() else {
223 self.reset_trackers();
224 return Ok(());
225 };
226
227 if !live_play || !events.goal_events.is_empty() {
228 self.reset_trackers();
229 return Ok(());
230 }
231
232 let demoed_players: HashSet<_> = events
233 .active_demos
234 .iter()
235 .map(|demo| demo.victim.clone())
236 .collect();
237 let ball_position = ball.position();
238
239 self.update_team(
240 true,
241 frame,
242 gameplay,
243 ball_position,
244 players,
245 &demoed_players,
246 );
247 self.update_team(
248 false,
249 frame,
250 gameplay,
251 ball_position,
252 players,
253 &demoed_players,
254 );
255
256 Ok(())
257 }
258
259 fn reset_trackers(&mut self) {
260 self.team_zero_tracker.reset();
261 self.team_one_tracker.reset();
262 }
263
264 fn update_team(
265 &mut self,
266 is_team_0: bool,
267 frame: &FrameInfo,
268 gameplay: &GameplayState,
269 ball_position: glam::Vec3,
270 players: &PlayerFrameState,
271 demoed_players: &HashSet<PlayerId>,
272 ) {
273 let present_team_count = players
274 .players
275 .iter()
276 .filter(|player| player.is_team_0 == is_team_0)
277 .count();
278 let team_size = gameplay
279 .current_in_game_team_player_count(is_team_0)
280 .max(present_team_count);
281
282 let team_players: Vec<_> = players
283 .players
284 .iter()
285 .filter(|player| player.is_team_0 == is_team_0)
286 .filter(|player| !demoed_players.contains(&player.player_id))
287 .filter_map(|player| player.position().map(|position| (player, position)))
288 .collect();
289
290 if !(2..=3).contains(&team_size) || team_players.len() != team_size {
291 self.team_tracker_mut(is_team_0).reset();
292 for player in players
293 .players
294 .iter()
295 .filter(|player| player.is_team_0 == is_team_0)
296 {
297 self.player_stats
298 .entry(player.player_id.clone())
299 .or_default()
300 .current_role_state = RoleState::Unknown;
301 }
302 return;
303 }
304
305 let mut scored_players: Vec<_> = team_players
306 .iter()
307 .map(|(player, position)| {
308 (
309 player.player_id.clone(),
310 first_man_score(*position, ball_position),
311 )
312 })
313 .collect();
314 scored_players.sort_by(|(_, left_score), (_, right_score)| {
315 left_score.partial_cmp(right_score).unwrap()
316 });
317
318 let raw_first_man = raw_first_man(&scored_players, self.config.first_man_ambiguity_margin);
319 let debounce_seconds = self.config.first_man_debounce_seconds;
320 let change =
321 self.team_tracker_mut(is_team_0)
322 .update(raw_first_man, frame.dt, debounce_seconds);
323 if let Some((previous, next)) = change {
324 let team_stats = self.team_stats_mut(is_team_0);
325 team_stats.first_man_changes_for_team += 1;
326 team_stats.rotation_count += 1;
327 self.player_stats
328 .entry(previous)
329 .or_default()
330 .lost_first_man_count += 1;
331 self.player_stats
332 .entry(next)
333 .or_default()
334 .became_first_man_count += 1;
335 }
336
337 let stable_first_man = raw_first_man
338 .and_then(|_| self.team_tracker(is_team_0).stable_first_man.as_ref())
339 .cloned();
340 let role_assignments = role_assignments(stable_first_man.as_ref(), &scored_players);
341
342 for (player, position) in team_players {
343 let role_state = role_assignments
344 .get(&player.player_id)
345 .copied()
346 .unwrap_or(RoleState::Ambiguous);
347 let depth_state = play_depth_state(
348 is_team_0,
349 position,
350 ball_position,
351 self.config.role_depth_margin,
352 );
353 let stats = self
354 .player_stats
355 .entry(player.player_id.clone())
356 .or_default();
357 stats.active_game_time += frame.dt;
358 stats.tracked_time += frame.dt;
359 stats.current_role_state = role_state;
360 stats.current_depth_state = depth_state;
361
362 match role_state {
363 RoleState::FirstMan => stats.time_first_man += frame.dt,
364 RoleState::SecondMan => stats.time_second_man += frame.dt,
365 RoleState::ThirdMan => stats.time_third_man += frame.dt,
366 RoleState::Ambiguous => stats.time_ambiguous_role += frame.dt,
367 RoleState::Unknown => {}
368 }
369
370 match depth_state {
371 PlayDepthState::BehindPlay => stats.time_behind_play += frame.dt,
372 PlayDepthState::LevelWithPlay => stats.time_level_with_play += frame.dt,
373 PlayDepthState::AheadOfPlay => stats.time_ahead_of_play += frame.dt,
374 PlayDepthState::Unknown => {}
375 }
376 }
377 }
378
379 fn team_tracker(&self, is_team_0: bool) -> &TeamFirstManTracker {
380 if is_team_0 {
381 &self.team_zero_tracker
382 } else {
383 &self.team_one_tracker
384 }
385 }
386
387 fn team_tracker_mut(&mut self, is_team_0: bool) -> &mut TeamFirstManTracker {
388 if is_team_0 {
389 &mut self.team_zero_tracker
390 } else {
391 &mut self.team_one_tracker
392 }
393 }
394
395 fn team_stats_mut(&mut self, is_team_0: bool) -> &mut RotationTeamStats {
396 if is_team_0 {
397 &mut self.team_zero_stats
398 } else {
399 &mut self.team_one_stats
400 }
401 }
402}
403
404fn first_man_score(player_position: glam::Vec3, ball_position: glam::Vec3) -> f32 {
405 player_position
406 .truncate()
407 .distance(ball_position.truncate())
408}
409
410fn raw_first_man(scored_players: &[(PlayerId, f32)], ambiguity_margin: f32) -> Option<&PlayerId> {
411 let [(first_id, first_score), (_, second_score), ..] = scored_players else {
412 return None;
413 };
414
415 if second_score - first_score <= ambiguity_margin {
416 None
417 } else {
418 Some(first_id)
419 }
420}
421
422fn role_assignments(
423 stable_first_man: Option<&PlayerId>,
424 scored_players: &[(PlayerId, f32)],
425) -> HashMap<PlayerId, RoleState> {
426 let mut assignments = HashMap::new();
427 let Some(stable_first_man) = stable_first_man else {
428 for (player_id, _) in scored_players {
429 assignments.insert(player_id.clone(), RoleState::Ambiguous);
430 }
431 return assignments;
432 };
433
434 assignments.insert(stable_first_man.clone(), RoleState::FirstMan);
435 let mut support_rank = 0;
436 for (player_id, _) in scored_players {
437 if player_id == stable_first_man {
438 continue;
439 }
440 support_rank += 1;
441 let role = match support_rank {
442 1 => RoleState::SecondMan,
443 2 => RoleState::ThirdMan,
444 _ => RoleState::Ambiguous,
445 };
446 assignments.insert(player_id.clone(), role);
447 }
448 assignments
449}
450
451fn play_depth_state(
452 is_team_0: bool,
453 player_position: glam::Vec3,
454 ball_position: glam::Vec3,
455 margin: f32,
456) -> PlayDepthState {
457 let player_y = normalized_y(is_team_0, player_position);
458 let ball_y = normalized_y(is_team_0, ball_position);
459 let delta = player_y - ball_y;
460 if delta < -margin {
461 PlayDepthState::BehindPlay
462 } else if delta > margin {
463 PlayDepthState::AheadOfPlay
464 } else {
465 PlayDepthState::LevelWithPlay
466 }
467}
468
469#[cfg(test)]
470#[path = "rotation_tests.rs"]
471mod tests;