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;
6const DEFAULT_FIRST_MAN_STINT_END_GRACE_SECONDS: f32 = 0.35;
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
10#[serde(rename_all = "snake_case")]
11#[ts(export)]
12pub enum RoleState {
13 #[default]
14 Unknown,
15 FirstMan,
16 SecondMan,
17 ThirdMan,
18 Ambiguous,
19}
20
21pub const ALL_ROLE_STATES: [RoleState; 5] = [
22 RoleState::Unknown,
23 RoleState::FirstMan,
24 RoleState::SecondMan,
25 RoleState::ThirdMan,
26 RoleState::Ambiguous,
27];
28
29impl RoleState {
30 pub fn as_label(self) -> StatLabel {
31 let value = match self {
32 Self::Unknown => "unknown",
33 Self::FirstMan => "first_man",
34 Self::SecondMan => "second_man",
35 Self::ThirdMan => "third_man",
36 Self::Ambiguous => "ambiguous",
37 };
38 StatLabel::new("role", value)
39 }
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
47#[serde(rename_all = "snake_case")]
48#[ts(export)]
49pub enum PlayDepthState {
50 #[default]
51 Unknown,
52 BehindPlay,
53 LevelWithPlay,
54 AheadOfPlay,
55}
56
57pub const ALL_PLAY_DEPTH_STATES: [PlayDepthState; 4] = [
58 PlayDepthState::Unknown,
59 PlayDepthState::BehindPlay,
60 PlayDepthState::LevelWithPlay,
61 PlayDepthState::AheadOfPlay,
62];
63
64impl PlayDepthState {
65 pub fn as_label(self) -> StatLabel {
66 let value = match self {
67 Self::Unknown => "unknown",
68 Self::BehindPlay => "behind_play",
69 Self::LevelWithPlay => "level_with_play",
70 Self::AheadOfPlay => "ahead_of_play",
71 };
72 StatLabel::new("play_depth", value)
73 }
74}
75
76pub type RotationRoleEvent = PlayerStateSpan<RoleState>;
80
81#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
83#[ts(export)]
84pub struct FirstManChangeEvent {
85 pub time: f32,
86 pub frame: usize,
87 pub is_team_0: bool,
88 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
89 pub previous_first_man: PlayerId,
90 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
91 pub next_first_man: PlayerId,
92}
93
94#[derive(Debug, Clone)]
96pub struct RotationCalculatorConfig {
97 pub role_depth_margin: f32,
98 pub first_man_ambiguity_margin: f32,
99 pub first_man_debounce_seconds: f32,
100 pub first_man_stint_end_grace_seconds: f32,
101}
102
103impl Default for RotationCalculatorConfig {
104 fn default() -> Self {
105 Self {
106 role_depth_margin: DEFAULT_ROLE_DEPTH_MARGIN,
107 first_man_ambiguity_margin: DEFAULT_FIRST_MAN_AMBIGUITY_MARGIN,
108 first_man_debounce_seconds: DEFAULT_FIRST_MAN_DEBOUNCE_SECONDS,
109 first_man_stint_end_grace_seconds: DEFAULT_FIRST_MAN_STINT_END_GRACE_SECONDS,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Default, PartialEq)]
115struct TeamFirstManTracker {
116 stable_first_man: Option<PlayerId>,
117 pending_first_man: Option<PlayerId>,
118 pending_seconds: f32,
119}
120
121impl TeamFirstManTracker {
122 fn reset(&mut self) {
123 self.stable_first_man = None;
124 self.pending_first_man = None;
125 self.pending_seconds = 0.0;
126 }
127
128 fn update(
129 &mut self,
130 raw_first_man: Option<&PlayerId>,
131 dt: f32,
132 debounce_seconds: f32,
133 ) -> Option<(PlayerId, PlayerId)> {
134 let Some(raw_first_man) = raw_first_man else {
135 self.pending_first_man = None;
136 self.pending_seconds = 0.0;
137 return None;
138 };
139
140 match self.stable_first_man.as_ref() {
141 None => {
142 self.stable_first_man = Some(raw_first_man.clone());
143 self.pending_first_man = None;
144 self.pending_seconds = 0.0;
145 None
146 }
147 Some(stable_first_man) if stable_first_man == raw_first_man => {
148 self.pending_first_man = None;
149 self.pending_seconds = 0.0;
150 None
151 }
152 Some(stable_first_man) => {
153 if self.pending_first_man.as_ref() == Some(raw_first_man) {
154 self.pending_seconds += dt;
155 } else {
156 self.pending_first_man = Some(raw_first_man.clone());
157 self.pending_seconds = dt;
158 }
159
160 if self.pending_seconds >= debounce_seconds {
161 let previous = stable_first_man.clone();
162 let next = raw_first_man.clone();
163 self.stable_first_man = Some(next.clone());
164 self.pending_first_man = None;
165 self.pending_seconds = 0.0;
166 Some((previous, next))
167 } else {
168 None
169 }
170 }
171 }
172 }
173}
174
175#[derive(Debug, Clone, Default)]
177pub struct RotationCalculator {
178 config: RotationCalculatorConfig,
179 team_zero_tracker: TeamFirstManTracker,
180 team_one_tracker: TeamFirstManTracker,
181 role_spans: PlayerSpanTracker<RoleState>,
182 first_man_changes: EventStream<FirstManChangeEvent>,
183 current_states: HashMap<PlayerId, (RoleState, PlayDepthState)>,
184}
185
186impl RotationCalculator {
187 pub fn new() -> Self {
188 Self::default()
189 }
190
191 pub fn with_config(config: RotationCalculatorConfig) -> Self {
192 Self {
193 config,
194 ..Self::default()
195 }
196 }
197
198 pub fn config(&self) -> &RotationCalculatorConfig {
199 &self.config
200 }
201
202 pub fn role_events(&self) -> Vec<RotationRoleEvent> {
203 self.role_spans.projected_events()
204 }
205
206 pub fn first_man_change_events(&self) -> &[FirstManChangeEvent] {
207 self.first_man_changes.all()
208 }
209
210 pub fn new_role_events(&self) -> &[RotationRoleEvent] {
212 self.role_spans.new_events()
213 }
214
215 pub fn flush_pending_events(&mut self) {
217 self.role_spans.close_all();
218 }
219
220 pub fn current_role_and_depth(&self, player_id: &PlayerId) -> (RoleState, PlayDepthState) {
224 self.current_states
225 .get(player_id)
226 .copied()
227 .unwrap_or_default()
228 }
229
230 pub fn update(
231 &mut self,
232 frame: &FrameInfo,
233 gameplay: &GameplayState,
234 ball: &BallFrameState,
235 players: &PlayerFrameState,
236 events: &FrameEventsState,
237 live_play_state: &LivePlayState,
238 ) -> SubtrActorResult<()> {
239 self.role_spans.begin_update();
240 self.first_man_changes.begin_update();
241 if frame.dt == 0.0 {
242 return Ok(());
243 }
244
245 let Some(ball) = ball.sample() else {
246 self.reset_trackers();
247 self.role_spans.close_all();
248 return Ok(());
249 };
250
251 if !live_play_state.is_live_play || !events.goal_events.is_empty() {
252 self.reset_trackers();
253 self.role_spans.close_all();
254 return Ok(());
255 }
256
257 let demoed_players: HashSet<_> = events
258 .active_demos
259 .iter()
260 .map(|demo| demo.victim.clone())
261 .collect();
262 let ball_position = ball.position();
263
264 self.update_team(
265 true,
266 frame,
267 gameplay,
268 ball_position,
269 players,
270 &demoed_players,
271 );
272 self.update_team(
273 false,
274 frame,
275 gameplay,
276 ball_position,
277 players,
278 &demoed_players,
279 );
280
281 Ok(())
282 }
283
284 fn reset_trackers(&mut self) {
285 self.team_zero_tracker.reset();
286 self.team_one_tracker.reset();
287 }
288
289 fn update_team(
290 &mut self,
291 is_team_0: bool,
292 frame: &FrameInfo,
293 gameplay: &GameplayState,
294 ball_position: glam::Vec3,
295 players: &PlayerFrameState,
296 demoed_players: &HashSet<PlayerId>,
297 ) {
298 let present_team_count = players
299 .players
300 .iter()
301 .filter(|player| player.is_team_0 == is_team_0)
302 .count();
303 let team_size = gameplay
304 .current_in_game_team_player_count(is_team_0)
305 .max(present_team_count);
306
307 let mut team_players: Vec<_> = players
308 .players
309 .iter()
310 .filter(|player| player.is_team_0 == is_team_0)
311 .filter(|player| !demoed_players.contains(&player.player_id))
312 .filter_map(|player| player.position().map(|position| (player, position)))
313 .collect();
314 team_players.sort_by_key(|(player, _)| format!("{:?}", player.player_id));
315
316 if !(2..=3).contains(&team_size) || team_players.len() != team_size {
317 self.team_tracker_mut(is_team_0).reset();
318 for player in players
319 .players
320 .iter()
321 .filter(|player| player.is_team_0 == is_team_0)
322 {
323 self.role_spans.close(&player.player_id);
324 let state = self
325 .current_states
326 .entry(player.player_id.clone())
327 .or_default();
328 state.0 = RoleState::Unknown;
329 }
330 return;
331 }
332
333 let mut scored_players: Vec<_> = team_players
334 .iter()
335 .map(|(player, position)| {
336 (
337 player.player_id.clone(),
338 first_man_score(*position, ball_position),
339 )
340 })
341 .collect();
342 scored_players.sort_by(|(_, left_score), (_, right_score)| {
343 left_score.partial_cmp(right_score).unwrap()
344 });
345
346 let raw_first_man = raw_first_man(&scored_players, self.config.first_man_ambiguity_margin);
347 let debounce_seconds = self.config.first_man_debounce_seconds;
348 let change =
349 self.team_tracker_mut(is_team_0)
350 .update(raw_first_man, frame.dt, debounce_seconds);
351 if let Some((previous, next)) = change {
352 let event = FirstManChangeEvent {
353 time: frame.time,
354 frame: frame.frame_number,
355 is_team_0,
356 previous_first_man: previous,
357 next_first_man: next,
358 };
359 self.first_man_changes.push(event);
360 }
361
362 let stable_first_man = raw_first_man
363 .and_then(|_| self.team_tracker(is_team_0).stable_first_man.as_ref())
364 .cloned();
365 let role_assignments = role_assignments(stable_first_man.as_ref(), &scored_players);
366
367 for (player, position) in team_players {
368 let role_state = role_assignments
369 .get(&player.player_id)
370 .copied()
371 .unwrap_or(RoleState::Ambiguous);
372 let depth_state = play_depth_state(
373 is_team_0,
374 position,
375 ball_position,
376 self.config.role_depth_margin,
377 );
378 self.role_spans.record(
379 frame.frame_number,
380 frame.time - frame.dt,
381 frame.time,
382 frame.dt,
383 &player.player_id,
384 player.position().map(|position| position.to_array()),
385 player.is_team_0,
386 role_state,
387 );
388 self.current_states
389 .insert(player.player_id.clone(), (role_state, depth_state));
390 }
391 }
392
393 fn team_tracker(&self, is_team_0: bool) -> &TeamFirstManTracker {
394 if is_team_0 {
395 &self.team_zero_tracker
396 } else {
397 &self.team_one_tracker
398 }
399 }
400
401 fn team_tracker_mut(&mut self, is_team_0: bool) -> &mut TeamFirstManTracker {
402 if is_team_0 {
403 &mut self.team_zero_tracker
404 } else {
405 &mut self.team_one_tracker
406 }
407 }
408}
409
410fn first_man_score(player_position: glam::Vec3, ball_position: glam::Vec3) -> f32 {
411 player_position
412 .truncate()
413 .distance(ball_position.truncate())
414}
415
416fn raw_first_man(scored_players: &[(PlayerId, f32)], ambiguity_margin: f32) -> Option<&PlayerId> {
417 let [(first_id, first_score), (_, second_score), ..] = scored_players else {
418 return None;
419 };
420
421 if second_score - first_score <= ambiguity_margin {
422 None
423 } else {
424 Some(first_id)
425 }
426}
427
428fn role_assignments(
429 stable_first_man: Option<&PlayerId>,
430 scored_players: &[(PlayerId, f32)],
431) -> HashMap<PlayerId, RoleState> {
432 let mut assignments = HashMap::new();
433 let Some(stable_first_man) = stable_first_man else {
434 for (player_id, _) in scored_players {
435 assignments.insert(player_id.clone(), RoleState::Ambiguous);
436 }
437 return assignments;
438 };
439
440 assignments.insert(stable_first_man.clone(), RoleState::FirstMan);
441 let mut support_rank = 0;
442 for (player_id, _) in scored_players {
443 if player_id == stable_first_man {
444 continue;
445 }
446 support_rank += 1;
447 let role = match support_rank {
448 1 => RoleState::SecondMan,
449 2 => RoleState::ThirdMan,
450 _ => RoleState::Ambiguous,
451 };
452 assignments.insert(player_id.clone(), role);
453 }
454 assignments
455}
456
457fn play_depth_state(
458 is_team_0: bool,
459 player_position: glam::Vec3,
460 ball_position: glam::Vec3,
461 margin: f32,
462) -> PlayDepthState {
463 let player_y = normalized_y(is_team_0, player_position);
464 let ball_y = normalized_y(is_team_0, ball_position);
465 let delta = player_y - ball_y;
466 if delta < -margin {
467 PlayDepthState::BehindPlay
468 } else if delta > margin {
469 PlayDepthState::AheadOfPlay
470 } else {
471 PlayDepthState::LevelWithPlay
472 }
473}
474
475#[cfg(test)]
476#[path = "rotation_tests.rs"]
477mod tests;