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