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;
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 longest_first_man_stint_time: f32,
43 pub first_man_stint_count: u32,
44 pub became_first_man_count: u32,
45 pub lost_first_man_count: u32,
46 pub current_role_state: RoleState,
47 pub current_depth_state: PlayDepthState,
48}
49
50impl RotationPlayerStats {
51 fn role_pct(&self, value: f32) -> f32 {
52 if self.tracked_time == 0.0 {
53 0.0
54 } else {
55 value * 100.0 / self.tracked_time
56 }
57 }
58
59 pub fn first_man_pct(&self) -> f32 {
60 self.role_pct(self.time_first_man)
61 }
62
63 pub fn second_man_pct(&self) -> f32 {
64 self.role_pct(self.time_second_man)
65 }
66
67 pub fn third_man_pct(&self) -> f32 {
68 self.role_pct(self.time_third_man)
69 }
70
71 pub fn ambiguous_role_pct(&self) -> f32 {
72 self.role_pct(self.time_ambiguous_role)
73 }
74
75 pub fn behind_play_pct(&self) -> f32 {
76 self.role_pct(self.time_behind_play)
77 }
78
79 pub fn level_with_play_pct(&self) -> f32 {
80 self.role_pct(self.time_level_with_play)
81 }
82
83 pub fn ahead_of_play_pct(&self) -> f32 {
84 self.role_pct(self.time_ahead_of_play)
85 }
86
87 pub fn average_first_man_stint_time(&self) -> f32 {
88 if self.first_man_stint_count == 0 {
89 0.0
90 } else {
91 self.time_first_man / self.first_man_stint_count as f32
92 }
93 }
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
97#[ts(export)]
98pub struct RotationTeamStats {
99 pub first_man_changes_for_team: u32,
100 pub rotation_count: u32,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
104#[ts(export)]
105pub struct RotationPlayerEvent {
106 pub time: f32,
107 pub frame: usize,
108 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
109 pub player: PlayerId,
110 pub is_team_0: bool,
111 pub active: bool,
112 pub became_first_man_count: u32,
113 pub lost_first_man_count: u32,
114 pub current_role_state: RoleState,
115 pub current_depth_state: PlayDepthState,
116}
117
118impl RotationPlayerEvent {
119 fn new(
120 frame: &FrameInfo,
121 player: PlayerId,
122 is_team_0: bool,
123 active: bool,
124 current_role_state: RoleState,
125 current_depth_state: PlayDepthState,
126 ) -> Self {
127 Self {
128 time: frame.time,
129 frame: frame.frame_number,
130 player,
131 is_team_0,
132 active,
133 became_first_man_count: 0,
134 lost_first_man_count: 0,
135 current_role_state,
136 current_depth_state,
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
142#[ts(export)]
143pub struct RotationTeamEvent {
144 pub time: f32,
145 pub frame: usize,
146 pub is_team_0: bool,
147 pub first_man_changes_for_team: u32,
148 pub rotation_count: u32,
149}
150
151#[derive(Debug, Clone)]
152pub struct RotationCalculatorConfig {
153 pub role_depth_margin: f32,
154 pub first_man_ambiguity_margin: f32,
155 pub first_man_debounce_seconds: f32,
156}
157
158impl Default for RotationCalculatorConfig {
159 fn default() -> Self {
160 Self {
161 role_depth_margin: DEFAULT_ROLE_DEPTH_MARGIN,
162 first_man_ambiguity_margin: DEFAULT_FIRST_MAN_AMBIGUITY_MARGIN,
163 first_man_debounce_seconds: DEFAULT_FIRST_MAN_DEBOUNCE_SECONDS,
164 }
165 }
166}
167
168#[derive(Debug, Clone, Default, PartialEq)]
169struct TeamFirstManTracker {
170 stable_first_man: Option<PlayerId>,
171 pending_first_man: Option<PlayerId>,
172 pending_seconds: f32,
173}
174
175impl TeamFirstManTracker {
176 fn reset(&mut self) {
177 self.stable_first_man = None;
178 self.pending_first_man = None;
179 self.pending_seconds = 0.0;
180 }
181
182 fn update(
183 &mut self,
184 raw_first_man: Option<&PlayerId>,
185 dt: f32,
186 debounce_seconds: f32,
187 ) -> Option<(PlayerId, PlayerId)> {
188 let Some(raw_first_man) = raw_first_man else {
189 self.pending_first_man = None;
190 self.pending_seconds = 0.0;
191 return None;
192 };
193
194 match self.stable_first_man.as_ref() {
195 None => {
196 self.stable_first_man = Some(raw_first_man.clone());
197 self.pending_first_man = None;
198 self.pending_seconds = 0.0;
199 None
200 }
201 Some(stable_first_man) if stable_first_man == raw_first_man => {
202 self.pending_first_man = None;
203 self.pending_seconds = 0.0;
204 None
205 }
206 Some(stable_first_man) => {
207 if self.pending_first_man.as_ref() == Some(raw_first_man) {
208 self.pending_seconds += dt;
209 } else {
210 self.pending_first_man = Some(raw_first_man.clone());
211 self.pending_seconds = dt;
212 }
213
214 if self.pending_seconds >= debounce_seconds {
215 let previous = stable_first_man.clone();
216 let next = raw_first_man.clone();
217 self.stable_first_man = Some(next.clone());
218 self.pending_first_man = None;
219 self.pending_seconds = 0.0;
220 Some((previous, next))
221 } else {
222 None
223 }
224 }
225 }
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230struct RotationPlayerEventState {
231 active: bool,
232 current_role_state: RoleState,
233 current_depth_state: PlayDepthState,
234}
235
236#[derive(Debug, Clone, Copy, Default, PartialEq)]
237struct FirstManStintState {
238 active: bool,
239 current_first_man_time: f32,
240 non_first_man_seconds: f32,
241}
242
243#[derive(Debug, Clone, Default)]
244pub struct RotationCalculator {
245 config: RotationCalculatorConfig,
246 player_stats: HashMap<PlayerId, RotationPlayerStats>,
247 team_zero_stats: RotationTeamStats,
248 team_one_stats: RotationTeamStats,
249 team_zero_tracker: TeamFirstManTracker,
250 team_one_tracker: TeamFirstManTracker,
251 player_events: Vec<RotationPlayerEvent>,
252 team_events: Vec<RotationTeamEvent>,
253 last_emitted_player_states: HashMap<PlayerId, RotationPlayerEventState>,
254 first_man_stints: HashMap<PlayerId, FirstManStintState>,
255}
256
257impl RotationCalculator {
258 pub fn new() -> Self {
259 Self::default()
260 }
261
262 pub fn with_config(config: RotationCalculatorConfig) -> Self {
263 Self {
264 config,
265 ..Self::default()
266 }
267 }
268
269 pub fn config(&self) -> &RotationCalculatorConfig {
270 &self.config
271 }
272
273 pub fn player_stats(&self) -> &HashMap<PlayerId, RotationPlayerStats> {
274 &self.player_stats
275 }
276
277 pub fn team_zero_stats(&self) -> &RotationTeamStats {
278 &self.team_zero_stats
279 }
280
281 pub fn team_one_stats(&self) -> &RotationTeamStats {
282 &self.team_one_stats
283 }
284
285 pub fn player_events(&self) -> &[RotationPlayerEvent] {
286 &self.player_events
287 }
288
289 pub fn team_events(&self) -> &[RotationTeamEvent] {
290 &self.team_events
291 }
292
293 pub fn update(
294 &mut self,
295 frame: &FrameInfo,
296 gameplay: &GameplayState,
297 ball: &BallFrameState,
298 players: &PlayerFrameState,
299 events: &FrameEventsState,
300 live_play: bool,
301 ) -> SubtrActorResult<()> {
302 if frame.dt == 0.0 {
303 return Ok(());
304 }
305
306 let Some(ball) = ball.sample() else {
307 self.reset_trackers();
308 self.emit_inactive_player_events(frame, players);
309 return Ok(());
310 };
311
312 if !live_play || !events.goal_events.is_empty() {
313 self.reset_trackers();
314 self.emit_inactive_player_events(frame, players);
315 return Ok(());
316 }
317
318 let demoed_players: HashSet<_> = events
319 .active_demos
320 .iter()
321 .map(|demo| demo.victim.clone())
322 .collect();
323 let ball_position = ball.position();
324
325 self.update_team(
326 true,
327 frame,
328 gameplay,
329 ball_position,
330 players,
331 &demoed_players,
332 );
333 self.update_team(
334 false,
335 frame,
336 gameplay,
337 ball_position,
338 players,
339 &demoed_players,
340 );
341
342 Ok(())
343 }
344
345 fn emit_inactive_player_events(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
346 for player in &players.players {
347 self.close_first_man_stint(&player.player_id);
348 let stats = self
349 .player_stats
350 .entry(player.player_id.clone())
351 .or_default();
352 let current_role_state = stats.current_role_state;
353 let current_depth_state = stats.current_depth_state;
354 self.emit_player_event_if_changed(
355 frame,
356 &player.player_id,
357 player.is_team_0,
358 false,
359 current_role_state,
360 current_depth_state,
361 0,
362 0,
363 );
364 }
365 }
366
367 fn reset_trackers(&mut self) {
368 self.team_zero_tracker.reset();
369 self.team_one_tracker.reset();
370 }
371
372 fn close_first_man_stint(&mut self, player_id: &PlayerId) {
373 if let Some(state) = self.first_man_stints.get_mut(player_id) {
374 state.active = false;
375 state.current_first_man_time = 0.0;
376 state.non_first_man_seconds = 0.0;
377 }
378 }
379
380 fn update_first_man_stint(
381 &mut self,
382 player_id: &PlayerId,
383 stats: &mut RotationPlayerStats,
384 role_state: RoleState,
385 dt: f32,
386 ) {
387 let state = self.first_man_stints.entry(player_id.clone()).or_default();
388 if role_state == RoleState::FirstMan {
389 if !state.active {
390 state.active = true;
391 state.current_first_man_time = 0.0;
392 stats.first_man_stint_count += 1;
393 }
394 state.current_first_man_time += dt;
395 stats.longest_first_man_stint_time = stats
396 .longest_first_man_stint_time
397 .max(state.current_first_man_time);
398 state.non_first_man_seconds = 0.0;
399 return;
400 }
401
402 if state.active {
403 state.non_first_man_seconds += dt;
404 if state.non_first_man_seconds > self.config.first_man_debounce_seconds {
405 state.active = false;
406 state.current_first_man_time = 0.0;
407 state.non_first_man_seconds = 0.0;
408 }
409 }
410 }
411
412 #[allow(clippy::too_many_arguments)]
413 fn emit_player_event_if_changed(
414 &mut self,
415 frame: &FrameInfo,
416 player_id: &PlayerId,
417 is_team_0: bool,
418 active: bool,
419 current_role_state: RoleState,
420 current_depth_state: PlayDepthState,
421 became_first_man_count: u32,
422 lost_first_man_count: u32,
423 ) {
424 let state = RotationPlayerEventState {
425 active,
426 current_role_state,
427 current_depth_state,
428 };
429 let state_changed = self.last_emitted_player_states.get(player_id) != Some(&state);
430 if !state_changed && became_first_man_count == 0 && lost_first_man_count == 0 {
431 return;
432 }
433
434 let mut event = RotationPlayerEvent::new(
435 frame,
436 player_id.clone(),
437 is_team_0,
438 active,
439 current_role_state,
440 current_depth_state,
441 );
442 event.became_first_man_count = became_first_man_count;
443 event.lost_first_man_count = lost_first_man_count;
444 self.player_events.push(event);
445 self.last_emitted_player_states
446 .insert(player_id.clone(), state);
447 }
448
449 fn update_team(
450 &mut self,
451 is_team_0: bool,
452 frame: &FrameInfo,
453 gameplay: &GameplayState,
454 ball_position: glam::Vec3,
455 players: &PlayerFrameState,
456 demoed_players: &HashSet<PlayerId>,
457 ) {
458 let present_team_count = players
459 .players
460 .iter()
461 .filter(|player| player.is_team_0 == is_team_0)
462 .count();
463 let team_size = gameplay
464 .current_in_game_team_player_count(is_team_0)
465 .max(present_team_count);
466
467 let team_players: Vec<_> = players
468 .players
469 .iter()
470 .filter(|player| player.is_team_0 == is_team_0)
471 .filter(|player| !demoed_players.contains(&player.player_id))
472 .filter_map(|player| player.position().map(|position| (player, position)))
473 .collect();
474
475 if !(2..=3).contains(&team_size) || team_players.len() != team_size {
476 self.team_tracker_mut(is_team_0).reset();
477 for player in players
478 .players
479 .iter()
480 .filter(|player| player.is_team_0 == is_team_0)
481 {
482 self.close_first_man_stint(&player.player_id);
483 let (current_role_state, current_depth_state) = {
484 let stats = self
485 .player_stats
486 .entry(player.player_id.clone())
487 .or_default();
488 stats.current_role_state = RoleState::Unknown;
489 (stats.current_role_state, stats.current_depth_state)
490 };
491 self.emit_player_event_if_changed(
492 frame,
493 &player.player_id,
494 player.is_team_0,
495 false,
496 current_role_state,
497 current_depth_state,
498 0,
499 0,
500 );
501 }
502 return;
503 }
504
505 let mut became_first_man_counts = HashMap::<PlayerId, u32>::new();
506 let mut lost_first_man_counts = HashMap::<PlayerId, u32>::new();
507 let mut scored_players: Vec<_> = team_players
508 .iter()
509 .map(|(player, position)| {
510 (
511 player.player_id.clone(),
512 first_man_score(*position, ball_position),
513 )
514 })
515 .collect();
516 scored_players.sort_by(|(_, left_score), (_, right_score)| {
517 left_score.partial_cmp(right_score).unwrap()
518 });
519
520 let raw_first_man = raw_first_man(&scored_players, self.config.first_man_ambiguity_margin);
521 let debounce_seconds = self.config.first_man_debounce_seconds;
522 let change =
523 self.team_tracker_mut(is_team_0)
524 .update(raw_first_man, frame.dt, debounce_seconds);
525 if let Some((previous, next)) = change {
526 let team_stats = self.team_stats_mut(is_team_0);
527 team_stats.first_man_changes_for_team += 1;
528 team_stats.rotation_count += 1;
529 self.team_events.push(RotationTeamEvent {
530 time: frame.time,
531 frame: frame.frame_number,
532 is_team_0,
533 first_man_changes_for_team: 1,
534 rotation_count: 1,
535 });
536 let previous_stats = self.player_stats.entry(previous.clone()).or_default();
537 previous_stats.lost_first_man_count += 1;
538 *lost_first_man_counts.entry(previous).or_default() += 1;
539 let next_stats = self.player_stats.entry(next.clone()).or_default();
540 next_stats.became_first_man_count += 1;
541 *became_first_man_counts.entry(next).or_default() += 1;
542 }
543
544 let stable_first_man = raw_first_man
545 .and_then(|_| self.team_tracker(is_team_0).stable_first_man.as_ref())
546 .cloned();
547 let role_assignments = role_assignments(stable_first_man.as_ref(), &scored_players);
548
549 for (player, position) in team_players {
550 let role_state = role_assignments
551 .get(&player.player_id)
552 .copied()
553 .unwrap_or(RoleState::Ambiguous);
554 let depth_state = play_depth_state(
555 is_team_0,
556 position,
557 ball_position,
558 self.config.role_depth_margin,
559 );
560 let (current_role_state, current_depth_state) = {
561 let mut stats = self
562 .player_stats
563 .remove(&player.player_id)
564 .unwrap_or_default();
565 stats.active_game_time += frame.dt;
566 stats.tracked_time += frame.dt;
567 stats.current_role_state = role_state;
568 stats.current_depth_state = depth_state;
569 self.update_first_man_stint(&player.player_id, &mut stats, role_state, frame.dt);
570
571 match role_state {
572 RoleState::FirstMan => {
573 stats.time_first_man += frame.dt;
574 }
575 RoleState::SecondMan => {
576 stats.time_second_man += frame.dt;
577 }
578 RoleState::ThirdMan => {
579 stats.time_third_man += frame.dt;
580 }
581 RoleState::Ambiguous => {
582 stats.time_ambiguous_role += frame.dt;
583 }
584 RoleState::Unknown => {}
585 }
586
587 match depth_state {
588 PlayDepthState::BehindPlay => {
589 stats.time_behind_play += frame.dt;
590 }
591 PlayDepthState::LevelWithPlay => {
592 stats.time_level_with_play += frame.dt;
593 }
594 PlayDepthState::AheadOfPlay => {
595 stats.time_ahead_of_play += frame.dt;
596 }
597 PlayDepthState::Unknown => {}
598 }
599
600 let current_role_state = stats.current_role_state;
601 let current_depth_state = stats.current_depth_state;
602 self.player_stats.insert(player.player_id.clone(), stats);
603 (current_role_state, current_depth_state)
604 };
605 let became_first_man_count = became_first_man_counts
606 .remove(&player.player_id)
607 .unwrap_or_default();
608 let lost_first_man_count = lost_first_man_counts
609 .remove(&player.player_id)
610 .unwrap_or_default();
611 self.emit_player_event_if_changed(
612 frame,
613 &player.player_id,
614 player.is_team_0,
615 true,
616 current_role_state,
617 current_depth_state,
618 became_first_man_count,
619 lost_first_man_count,
620 );
621 }
622
623 for (player_id, count) in became_first_man_counts {
624 let (current_role_state, current_depth_state) = {
625 let stats = self.player_stats.entry(player_id.clone()).or_default();
626 (stats.current_role_state, stats.current_depth_state)
627 };
628 self.emit_player_event_if_changed(
629 frame,
630 &player_id,
631 is_team_0,
632 false,
633 current_role_state,
634 current_depth_state,
635 count,
636 0,
637 );
638 }
639 for (player_id, count) in lost_first_man_counts {
640 let (current_role_state, current_depth_state) = {
641 let stats = self.player_stats.entry(player_id.clone()).or_default();
642 (stats.current_role_state, stats.current_depth_state)
643 };
644 self.emit_player_event_if_changed(
645 frame,
646 &player_id,
647 is_team_0,
648 false,
649 current_role_state,
650 current_depth_state,
651 0,
652 count,
653 );
654 }
655 }
656
657 fn team_tracker(&self, is_team_0: bool) -> &TeamFirstManTracker {
658 if is_team_0 {
659 &self.team_zero_tracker
660 } else {
661 &self.team_one_tracker
662 }
663 }
664
665 fn team_tracker_mut(&mut self, is_team_0: bool) -> &mut TeamFirstManTracker {
666 if is_team_0 {
667 &mut self.team_zero_tracker
668 } else {
669 &mut self.team_one_tracker
670 }
671 }
672
673 fn team_stats_mut(&mut self, is_team_0: bool) -> &mut RotationTeamStats {
674 if is_team_0 {
675 &mut self.team_zero_stats
676 } else {
677 &mut self.team_one_stats
678 }
679 }
680}
681
682fn first_man_score(player_position: glam::Vec3, ball_position: glam::Vec3) -> f32 {
683 player_position
684 .truncate()
685 .distance(ball_position.truncate())
686}
687
688fn raw_first_man(scored_players: &[(PlayerId, f32)], ambiguity_margin: f32) -> Option<&PlayerId> {
689 let [(first_id, first_score), (_, second_score), ..] = scored_players else {
690 return None;
691 };
692
693 if second_score - first_score <= ambiguity_margin {
694 None
695 } else {
696 Some(first_id)
697 }
698}
699
700fn role_assignments(
701 stable_first_man: Option<&PlayerId>,
702 scored_players: &[(PlayerId, f32)],
703) -> HashMap<PlayerId, RoleState> {
704 let mut assignments = HashMap::new();
705 let Some(stable_first_man) = stable_first_man else {
706 for (player_id, _) in scored_players {
707 assignments.insert(player_id.clone(), RoleState::Ambiguous);
708 }
709 return assignments;
710 };
711
712 assignments.insert(stable_first_man.clone(), RoleState::FirstMan);
713 let mut support_rank = 0;
714 for (player_id, _) in scored_players {
715 if player_id == stable_first_man {
716 continue;
717 }
718 support_rank += 1;
719 let role = match support_rank {
720 1 => RoleState::SecondMan,
721 2 => RoleState::ThirdMan,
722 _ => RoleState::Ambiguous,
723 };
724 assignments.insert(player_id.clone(), role);
725 }
726 assignments
727}
728
729fn play_depth_state(
730 is_team_0: bool,
731 player_position: glam::Vec3,
732 ball_position: glam::Vec3,
733 margin: f32,
734) -> PlayDepthState {
735 let player_y = normalized_y(is_team_0, player_position);
736 let ball_y = normalized_y(is_team_0, ball_position);
737 let delta = player_y - ball_y;
738 if delta < -margin {
739 PlayDepthState::BehindPlay
740 } else if delta > margin {
741 PlayDepthState::AheadOfPlay
742 } else {
743 PlayDepthState::LevelWithPlay
744 }
745}
746
747#[cfg(test)]
748#[path = "rotation_tests.rs"]
749mod tests;