1use super::*;
2
3pub(crate) const FIFTY_FIFTY_CONTINUATION_TOUCH_WINDOW_SECONDS: f32 = 0.2;
4pub(crate) const FIFTY_FIFTY_RESOLUTION_DELAY_SECONDS: f32 = 0.35;
5pub(crate) const FIFTY_FIFTY_MAX_DURATION_SECONDS: f32 = 1.25;
6pub(crate) const FIFTY_FIFTY_MIN_EXIT_DISTANCE: f32 = 180.0;
7pub(crate) const FIFTY_FIFTY_MIN_EXIT_SPEED: f32 = 220.0;
8
9#[derive(Debug, Clone, Default, PartialEq)]
10pub struct FiftyFiftyState {
11 pub active_event: Option<ActiveFiftyFifty>,
12 pub resolved_events: Vec<FiftyFiftyEvent>,
13 pub last_resolved_event: Option<FiftyFiftyEvent>,
14}
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct ActiveFiftyFifty {
18 pub start_time: f32,
19 pub start_frame: usize,
20 pub last_touch_time: f32,
21 pub last_touch_frame: usize,
22 pub is_kickoff: bool,
23 pub team_zero_player: Option<PlayerId>,
24 pub team_one_player: Option<PlayerId>,
25 pub team_zero_position: [f32; 3],
26 pub team_one_position: [f32; 3],
27 pub midpoint: [f32; 3],
28 pub plane_normal: [f32; 3],
29}
30
31impl ActiveFiftyFifty {
32 pub fn midpoint_vec(&self) -> glam::Vec3 {
33 glam::Vec3::from_array(self.midpoint)
34 }
35
36 pub fn plane_normal_vec(&self) -> glam::Vec3 {
37 glam::Vec3::from_array(self.plane_normal)
38 }
39
40 pub fn contains_team_touch(&self, touch_events: &[TouchEvent]) -> bool {
41 touch_events.iter().any(|touch| {
42 (touch.team_is_team_0 && self.team_zero_player.is_some())
43 || (!touch.team_is_team_0 && self.team_one_player.is_some())
44 })
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
49#[ts(export)]
50pub struct FiftyFiftyEvent {
51 pub start_time: f32,
52 pub start_frame: usize,
53 pub resolve_time: f32,
54 pub resolve_frame: usize,
55 pub is_kickoff: bool,
56 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
57 pub team_zero_player: Option<PlayerId>,
58 #[ts(as = "Option<crate::ts_bindings::RemoteIdTs>")]
59 pub team_one_player: Option<PlayerId>,
60 pub team_zero_position: [f32; 3],
61 pub team_one_position: [f32; 3],
62 pub midpoint: [f32; 3],
63 pub plane_normal: [f32; 3],
64 pub winning_team_is_team_0: Option<bool>,
65 pub possession_team_is_team_0: Option<bool>,
66}
67
68const FIFTY_FIFTY_PHASE_LABELS: [StatLabel; 2] = [
69 StatLabel::new("phase", "open_play"),
70 StatLabel::new("phase", "kickoff"),
71];
72const FIFTY_FIFTY_TEAM_OUTCOME_LABELS: [StatLabel; 3] = [
73 StatLabel::new("winning_team", "team_zero"),
74 StatLabel::new("winning_team", "team_one"),
75 StatLabel::new("winning_team", "neutral"),
76];
77const FIFTY_FIFTY_POSSESSION_LABELS: [StatLabel; 3] = [
78 StatLabel::new("possession_after", "team_zero"),
79 StatLabel::new("possession_after", "team_one"),
80 StatLabel::new("possession_after", "neutral"),
81];
82const FIFTY_FIFTY_PLAYER_OUTCOME_LABELS: [StatLabel; 3] = [
83 StatLabel::new("outcome", "win"),
84 StatLabel::new("outcome", "loss"),
85 StatLabel::new("outcome", "neutral"),
86];
87const FIFTY_FIFTY_PLAYER_POSSESSION_LABELS: [StatLabel; 3] = [
88 StatLabel::new("possession_after", "self"),
89 StatLabel::new("possession_after", "opponent"),
90 StatLabel::new("possession_after", "neutral"),
91];
92
93fn fifty_fifty_phase_label(is_kickoff: bool) -> StatLabel {
94 if is_kickoff {
95 StatLabel::new("phase", "kickoff")
96 } else {
97 StatLabel::new("phase", "open_play")
98 }
99}
100
101fn fifty_fifty_team_outcome_label(team_is_team_0: Option<bool>) -> StatLabel {
102 match team_is_team_0 {
103 Some(true) => StatLabel::new("winning_team", "team_zero"),
104 Some(false) => StatLabel::new("winning_team", "team_one"),
105 None => StatLabel::new("winning_team", "neutral"),
106 }
107}
108
109fn fifty_fifty_possession_label(team_is_team_0: Option<bool>) -> StatLabel {
110 match team_is_team_0 {
111 Some(true) => StatLabel::new("possession_after", "team_zero"),
112 Some(false) => StatLabel::new("possession_after", "team_one"),
113 None => StatLabel::new("possession_after", "neutral"),
114 }
115}
116
117fn fifty_fifty_player_outcome_label(
118 player_team_is_team_0: bool,
119 winning_team_is_team_0: Option<bool>,
120) -> StatLabel {
121 match winning_team_is_team_0 {
122 Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
123 StatLabel::new("outcome", "win")
124 }
125 Some(_) => StatLabel::new("outcome", "loss"),
126 None => StatLabel::new("outcome", "neutral"),
127 }
128}
129
130fn fifty_fifty_player_possession_label(
131 player_team_is_team_0: bool,
132 possession_team_is_team_0: Option<bool>,
133) -> StatLabel {
134 match possession_team_is_team_0 {
135 Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
136 StatLabel::new("possession_after", "self")
137 }
138 Some(_) => StatLabel::new("possession_after", "opponent"),
139 None => StatLabel::new("possession_after", "neutral"),
140 }
141}
142
143impl FiftyFiftyEvent {
144 fn labels(&self) -> [StatLabel; 3] {
145 [
146 fifty_fifty_phase_label(self.is_kickoff),
147 fifty_fifty_team_outcome_label(self.winning_team_is_team_0),
148 fifty_fifty_possession_label(self.possession_team_is_team_0),
149 ]
150 }
151
152 fn player_labels(&self, player_team_is_team_0: bool) -> [StatLabel; 3] {
153 [
154 fifty_fifty_phase_label(self.is_kickoff),
155 fifty_fifty_player_outcome_label(player_team_is_team_0, self.winning_team_is_team_0),
156 fifty_fifty_player_possession_label(
157 player_team_is_team_0,
158 self.possession_team_is_team_0,
159 ),
160 ]
161 }
162}
163
164#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
165#[ts(export)]
166pub struct FiftyFiftyStats {
167 pub count: u32,
168 pub team_zero_wins: u32,
169 pub team_one_wins: u32,
170 pub neutral_outcomes: u32,
171 pub kickoff_count: u32,
172 pub kickoff_team_zero_wins: u32,
173 pub kickoff_team_one_wins: u32,
174 pub kickoff_neutral_outcomes: u32,
175 pub team_zero_possession_after_count: u32,
176 pub team_one_possession_after_count: u32,
177 pub neutral_possession_after_count: u32,
178 pub kickoff_team_zero_possession_after_count: u32,
179 pub kickoff_team_one_possession_after_count: u32,
180 pub kickoff_neutral_possession_after_count: u32,
181 #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
182 pub labeled_event_counts: LabeledCounts,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
186#[ts(export)]
187pub struct FiftyFiftyPlayerStats {
188 pub count: u32,
189 pub wins: u32,
190 pub losses: u32,
191 pub neutral_outcomes: u32,
192 pub kickoff_count: u32,
193 pub kickoff_wins: u32,
194 pub kickoff_losses: u32,
195 pub kickoff_neutral_outcomes: u32,
196 pub possession_after_count: u32,
197 pub kickoff_possession_after_count: u32,
198 #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
199 pub labeled_event_counts: LabeledCounts,
200}
201
202impl FiftyFiftyStats {
203 fn record_event(&mut self, event: &FiftyFiftyEvent) {
204 self.labeled_event_counts.increment(event.labels());
205 self.sync_legacy_counts();
206 }
207
208 pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
209 self.labeled_event_counts.count_matching(labels)
210 }
211
212 pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
213 LabeledCounts::complete_from_label_sets(
214 &[
215 &FIFTY_FIFTY_PHASE_LABELS,
216 &FIFTY_FIFTY_TEAM_OUTCOME_LABELS,
217 &FIFTY_FIFTY_POSSESSION_LABELS,
218 ],
219 &self.labeled_event_counts,
220 )
221 }
222
223 fn sync_legacy_counts(&mut self) {
224 self.count = self.labeled_event_counts.total();
225 self.team_zero_wins =
226 self.event_count_with_labels(&[fifty_fifty_team_outcome_label(Some(true))]);
227 self.team_one_wins =
228 self.event_count_with_labels(&[fifty_fifty_team_outcome_label(Some(false))]);
229 self.neutral_outcomes =
230 self.event_count_with_labels(&[fifty_fifty_team_outcome_label(None)]);
231 self.kickoff_count = self.event_count_with_labels(&[fifty_fifty_phase_label(true)]);
232 self.kickoff_team_zero_wins = self.event_count_with_labels(&[
233 fifty_fifty_phase_label(true),
234 fifty_fifty_team_outcome_label(Some(true)),
235 ]);
236 self.kickoff_team_one_wins = self.event_count_with_labels(&[
237 fifty_fifty_phase_label(true),
238 fifty_fifty_team_outcome_label(Some(false)),
239 ]);
240 self.kickoff_neutral_outcomes = self.event_count_with_labels(&[
241 fifty_fifty_phase_label(true),
242 fifty_fifty_team_outcome_label(None),
243 ]);
244 self.team_zero_possession_after_count =
245 self.event_count_with_labels(&[fifty_fifty_possession_label(Some(true))]);
246 self.team_one_possession_after_count =
247 self.event_count_with_labels(&[fifty_fifty_possession_label(Some(false))]);
248 self.neutral_possession_after_count =
249 self.event_count_with_labels(&[fifty_fifty_possession_label(None)]);
250 self.kickoff_team_zero_possession_after_count = self.event_count_with_labels(&[
251 fifty_fifty_phase_label(true),
252 fifty_fifty_possession_label(Some(true)),
253 ]);
254 self.kickoff_team_one_possession_after_count = self.event_count_with_labels(&[
255 fifty_fifty_phase_label(true),
256 fifty_fifty_possession_label(Some(false)),
257 ]);
258 self.kickoff_neutral_possession_after_count = self.event_count_with_labels(&[
259 fifty_fifty_phase_label(true),
260 fifty_fifty_possession_label(None),
261 ]);
262 }
263
264 pub fn team_zero_win_pct(&self) -> f32 {
265 if self.count == 0 {
266 0.0
267 } else {
268 self.team_zero_wins as f32 * 100.0 / self.count as f32
269 }
270 }
271
272 pub fn team_one_win_pct(&self) -> f32 {
273 if self.count == 0 {
274 0.0
275 } else {
276 self.team_one_wins as f32 * 100.0 / self.count as f32
277 }
278 }
279
280 pub fn kickoff_team_zero_win_pct(&self) -> f32 {
281 if self.kickoff_count == 0 {
282 0.0
283 } else {
284 self.kickoff_team_zero_wins as f32 * 100.0 / self.kickoff_count as f32
285 }
286 }
287
288 pub fn kickoff_team_one_win_pct(&self) -> f32 {
289 if self.kickoff_count == 0 {
290 0.0
291 } else {
292 self.kickoff_team_one_wins as f32 * 100.0 / self.kickoff_count as f32
293 }
294 }
295}
296
297impl FiftyFiftyPlayerStats {
298 fn record_event(&mut self, player_team_is_team_0: bool, event: &FiftyFiftyEvent) {
299 self.labeled_event_counts
300 .increment(event.player_labels(player_team_is_team_0));
301 self.sync_legacy_counts();
302 }
303
304 pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
305 self.labeled_event_counts.count_matching(labels)
306 }
307
308 pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
309 LabeledCounts::complete_from_label_sets(
310 &[
311 &FIFTY_FIFTY_PHASE_LABELS,
312 &FIFTY_FIFTY_PLAYER_OUTCOME_LABELS,
313 &FIFTY_FIFTY_PLAYER_POSSESSION_LABELS,
314 ],
315 &self.labeled_event_counts,
316 )
317 }
318
319 fn sync_legacy_counts(&mut self) {
320 self.count = self.labeled_event_counts.total();
321 self.wins = self.event_count_with_labels(&[StatLabel::new("outcome", "win")]);
322 self.losses = self.event_count_with_labels(&[StatLabel::new("outcome", "loss")]);
323 self.neutral_outcomes =
324 self.event_count_with_labels(&[StatLabel::new("outcome", "neutral")]);
325 self.kickoff_count = self.event_count_with_labels(&[fifty_fifty_phase_label(true)]);
326 self.kickoff_wins = self.event_count_with_labels(&[
327 fifty_fifty_phase_label(true),
328 StatLabel::new("outcome", "win"),
329 ]);
330 self.kickoff_losses = self.event_count_with_labels(&[
331 fifty_fifty_phase_label(true),
332 StatLabel::new("outcome", "loss"),
333 ]);
334 self.kickoff_neutral_outcomes = self.event_count_with_labels(&[
335 fifty_fifty_phase_label(true),
336 StatLabel::new("outcome", "neutral"),
337 ]);
338 self.possession_after_count =
339 self.event_count_with_labels(&[StatLabel::new("possession_after", "self")]);
340 self.kickoff_possession_after_count = self.event_count_with_labels(&[
341 fifty_fifty_phase_label(true),
342 StatLabel::new("possession_after", "self"),
343 ]);
344 }
345
346 pub fn win_pct(&self) -> f32 {
347 if self.count == 0 {
348 0.0
349 } else {
350 self.wins as f32 * 100.0 / self.count as f32
351 }
352 }
353
354 pub fn kickoff_win_pct(&self) -> f32 {
355 if self.kickoff_count == 0 {
356 0.0
357 } else {
358 self.kickoff_wins as f32 * 100.0 / self.kickoff_count as f32
359 }
360 }
361}
362
363#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
364#[ts(export)]
365pub struct FiftyFiftyTeamStats {
366 pub count: u32,
367 pub wins: u32,
368 pub losses: u32,
369 pub neutral_outcomes: u32,
370 pub kickoff_count: u32,
371 pub kickoff_wins: u32,
372 pub kickoff_losses: u32,
373 pub kickoff_neutral_outcomes: u32,
374 pub possession_after_count: u32,
375 pub opponent_possession_after_count: u32,
376 pub neutral_possession_after_count: u32,
377 pub kickoff_possession_after_count: u32,
378 pub kickoff_opponent_possession_after_count: u32,
379 pub kickoff_neutral_possession_after_count: u32,
380}
381
382impl FiftyFiftyStats {
383 pub fn for_team(&self, is_team_zero: bool) -> FiftyFiftyTeamStats {
384 let (
385 wins,
386 losses,
387 kickoff_wins,
388 kickoff_losses,
389 possession_after_count,
390 opponent_possession_after_count,
391 kickoff_possession_after_count,
392 kickoff_opponent_possession_after_count,
393 ) = if is_team_zero {
394 (
395 self.team_zero_wins,
396 self.team_one_wins,
397 self.kickoff_team_zero_wins,
398 self.kickoff_team_one_wins,
399 self.team_zero_possession_after_count,
400 self.team_one_possession_after_count,
401 self.kickoff_team_zero_possession_after_count,
402 self.kickoff_team_one_possession_after_count,
403 )
404 } else {
405 (
406 self.team_one_wins,
407 self.team_zero_wins,
408 self.kickoff_team_one_wins,
409 self.kickoff_team_zero_wins,
410 self.team_one_possession_after_count,
411 self.team_zero_possession_after_count,
412 self.kickoff_team_one_possession_after_count,
413 self.kickoff_team_zero_possession_after_count,
414 )
415 };
416
417 FiftyFiftyTeamStats {
418 count: self.count,
419 wins,
420 losses,
421 neutral_outcomes: self.neutral_outcomes,
422 kickoff_count: self.kickoff_count,
423 kickoff_wins,
424 kickoff_losses,
425 kickoff_neutral_outcomes: self.kickoff_neutral_outcomes,
426 possession_after_count,
427 opponent_possession_after_count,
428 neutral_possession_after_count: self.neutral_possession_after_count,
429 kickoff_possession_after_count,
430 kickoff_opponent_possession_after_count,
431 kickoff_neutral_possession_after_count: self.kickoff_neutral_possession_after_count,
432 }
433 }
434}
435
436#[derive(Debug, Clone, Default, PartialEq)]
437pub struct FiftyFiftyCalculator {
438 stats: FiftyFiftyStats,
439 player_stats: HashMap<PlayerId, FiftyFiftyPlayerStats>,
440 events: Vec<FiftyFiftyEvent>,
441}
442
443impl FiftyFiftyCalculator {
444 pub fn new() -> Self {
445 Self::default()
446 }
447
448 pub fn stats(&self) -> &FiftyFiftyStats {
449 &self.stats
450 }
451
452 pub fn player_stats(&self) -> &HashMap<PlayerId, FiftyFiftyPlayerStats> {
453 &self.player_stats
454 }
455
456 pub fn events(&self) -> &[FiftyFiftyEvent] {
457 &self.events
458 }
459
460 fn apply_event(&mut self, event: &FiftyFiftyEvent) {
461 self.stats.record_event(event);
462
463 if let Some(player_id) = event.team_zero_player.as_ref() {
464 let stats = self.player_stats.entry(player_id.clone()).or_default();
465 stats.record_event(true, event);
466 }
467 if let Some(player_id) = event.team_one_player.as_ref() {
468 let stats = self.player_stats.entry(player_id.clone()).or_default();
469 stats.record_event(false, event);
470 }
471
472 self.events.push(event.clone());
473 }
474
475 pub(crate) fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
476 gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
477 || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
478 || gameplay.ball_has_been_hit == Some(false)
479 }
480
481 pub(crate) fn contested_touch(
482 frame: &FrameInfo,
483 players: &PlayerFrameState,
484 touch_events: &[TouchEvent],
485 is_kickoff: bool,
486 ) -> Option<ActiveFiftyFifty> {
487 let team_zero_touch = touch_events.iter().find(|touch| touch.team_is_team_0)?;
488 let team_one_touch = touch_events.iter().find(|touch| !touch.team_is_team_0)?;
489 let team_zero_position = team_zero_touch.player.as_ref().and_then(|player_id| {
490 players
491 .players
492 .iter()
493 .find(|player| &player.player_id == player_id)
494 .and_then(PlayerSample::position)
495 })?;
496 let team_one_position = team_one_touch.player.as_ref().and_then(|player_id| {
497 players
498 .players
499 .iter()
500 .find(|player| &player.player_id == player_id)
501 .and_then(PlayerSample::position)
502 })?;
503 let midpoint = (team_zero_position + team_one_position) * 0.5;
504 let mut plane_normal = team_one_position - team_zero_position;
505 plane_normal.z = 0.0;
506 if plane_normal.length_squared() <= f32::EPSILON {
507 plane_normal = glam::Vec3::Y;
508 } else {
509 plane_normal = plane_normal.normalize();
510 }
511
512 Some(ActiveFiftyFifty {
513 start_time: frame.time,
514 start_frame: frame.frame_number,
515 last_touch_time: frame.time,
516 last_touch_frame: frame.frame_number,
517 is_kickoff,
518 team_zero_player: team_zero_touch.player.clone(),
519 team_one_player: team_one_touch.player.clone(),
520 team_zero_position: team_zero_position.to_array(),
521 team_one_position: team_one_position.to_array(),
522 midpoint: midpoint.to_array(),
523 plane_normal: plane_normal.to_array(),
524 })
525 }
526
527 pub(crate) fn winning_team_from_ball(
528 active: &ActiveFiftyFifty,
529 ball: &BallFrameState,
530 ) -> Option<bool> {
531 let ball = ball.sample()?;
532 let midpoint = active.midpoint_vec();
533 let plane_normal = active.plane_normal_vec();
534 let displacement = ball.position() - midpoint;
535 let signed_distance = displacement.dot(plane_normal);
536 if signed_distance.abs() >= FIFTY_FIFTY_MIN_EXIT_DISTANCE {
537 return Some(signed_distance > 0.0);
538 }
539
540 let signed_speed = ball.velocity().dot(plane_normal);
541 if signed_speed.abs() >= FIFTY_FIFTY_MIN_EXIT_SPEED {
542 return Some(signed_speed > 0.0);
543 }
544
545 None
546 }
547
548 pub fn update(&mut self, fifty_fifty_state: &FiftyFiftyState) -> SubtrActorResult<()> {
549 for event in &fifty_fifty_state.resolved_events {
550 self.apply_event(event);
551 }
552 Ok(())
553 }
554}