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
68#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
69#[ts(export)]
70pub struct FiftyFiftyStats {
71 pub count: u32,
72 pub team_zero_wins: u32,
73 pub team_one_wins: u32,
74 pub neutral_outcomes: u32,
75 pub kickoff_count: u32,
76 pub kickoff_team_zero_wins: u32,
77 pub kickoff_team_one_wins: u32,
78 pub kickoff_neutral_outcomes: u32,
79 pub team_zero_possession_after_count: u32,
80 pub team_one_possession_after_count: u32,
81 pub neutral_possession_after_count: u32,
82 pub kickoff_team_zero_possession_after_count: u32,
83 pub kickoff_team_one_possession_after_count: u32,
84 pub kickoff_neutral_possession_after_count: u32,
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
88#[ts(export)]
89pub struct FiftyFiftyPlayerStats {
90 pub count: u32,
91 pub wins: u32,
92 pub losses: u32,
93 pub neutral_outcomes: u32,
94 pub kickoff_count: u32,
95 pub kickoff_wins: u32,
96 pub kickoff_losses: u32,
97 pub kickoff_neutral_outcomes: u32,
98 pub possession_after_count: u32,
99 pub kickoff_possession_after_count: u32,
100}
101
102impl FiftyFiftyStats {
103 pub fn team_zero_win_pct(&self) -> f32 {
104 if self.count == 0 {
105 0.0
106 } else {
107 self.team_zero_wins as f32 * 100.0 / self.count as f32
108 }
109 }
110
111 pub fn team_one_win_pct(&self) -> f32 {
112 if self.count == 0 {
113 0.0
114 } else {
115 self.team_one_wins as f32 * 100.0 / self.count as f32
116 }
117 }
118
119 pub fn kickoff_team_zero_win_pct(&self) -> f32 {
120 if self.kickoff_count == 0 {
121 0.0
122 } else {
123 self.kickoff_team_zero_wins as f32 * 100.0 / self.kickoff_count as f32
124 }
125 }
126
127 pub fn kickoff_team_one_win_pct(&self) -> f32 {
128 if self.kickoff_count == 0 {
129 0.0
130 } else {
131 self.kickoff_team_one_wins as f32 * 100.0 / self.kickoff_count as f32
132 }
133 }
134}
135
136impl FiftyFiftyPlayerStats {
137 pub fn win_pct(&self) -> f32 {
138 if self.count == 0 {
139 0.0
140 } else {
141 self.wins as f32 * 100.0 / self.count as f32
142 }
143 }
144
145 pub fn kickoff_win_pct(&self) -> f32 {
146 if self.kickoff_count == 0 {
147 0.0
148 } else {
149 self.kickoff_wins as f32 * 100.0 / self.kickoff_count as f32
150 }
151 }
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
155#[ts(export)]
156pub struct FiftyFiftyTeamStats {
157 pub count: u32,
158 pub wins: u32,
159 pub losses: u32,
160 pub neutral_outcomes: u32,
161 pub kickoff_count: u32,
162 pub kickoff_wins: u32,
163 pub kickoff_losses: u32,
164 pub kickoff_neutral_outcomes: u32,
165 pub possession_after_count: u32,
166 pub opponent_possession_after_count: u32,
167 pub neutral_possession_after_count: u32,
168 pub kickoff_possession_after_count: u32,
169 pub kickoff_opponent_possession_after_count: u32,
170 pub kickoff_neutral_possession_after_count: u32,
171}
172
173impl FiftyFiftyStats {
174 pub fn for_team(&self, is_team_zero: bool) -> FiftyFiftyTeamStats {
175 let (
176 wins,
177 losses,
178 kickoff_wins,
179 kickoff_losses,
180 possession_after_count,
181 opponent_possession_after_count,
182 kickoff_possession_after_count,
183 kickoff_opponent_possession_after_count,
184 ) = if is_team_zero {
185 (
186 self.team_zero_wins,
187 self.team_one_wins,
188 self.kickoff_team_zero_wins,
189 self.kickoff_team_one_wins,
190 self.team_zero_possession_after_count,
191 self.team_one_possession_after_count,
192 self.kickoff_team_zero_possession_after_count,
193 self.kickoff_team_one_possession_after_count,
194 )
195 } else {
196 (
197 self.team_one_wins,
198 self.team_zero_wins,
199 self.kickoff_team_one_wins,
200 self.kickoff_team_zero_wins,
201 self.team_one_possession_after_count,
202 self.team_zero_possession_after_count,
203 self.kickoff_team_one_possession_after_count,
204 self.kickoff_team_zero_possession_after_count,
205 )
206 };
207
208 FiftyFiftyTeamStats {
209 count: self.count,
210 wins,
211 losses,
212 neutral_outcomes: self.neutral_outcomes,
213 kickoff_count: self.kickoff_count,
214 kickoff_wins,
215 kickoff_losses,
216 kickoff_neutral_outcomes: self.kickoff_neutral_outcomes,
217 possession_after_count,
218 opponent_possession_after_count,
219 neutral_possession_after_count: self.neutral_possession_after_count,
220 kickoff_possession_after_count,
221 kickoff_opponent_possession_after_count,
222 kickoff_neutral_possession_after_count: self.kickoff_neutral_possession_after_count,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Default, PartialEq)]
228pub struct FiftyFiftyCalculator {
229 stats: FiftyFiftyStats,
230 player_stats: HashMap<PlayerId, FiftyFiftyPlayerStats>,
231 events: Vec<FiftyFiftyEvent>,
232}
233
234impl FiftyFiftyCalculator {
235 pub fn new() -> Self {
236 Self::default()
237 }
238
239 pub fn stats(&self) -> &FiftyFiftyStats {
240 &self.stats
241 }
242
243 pub fn player_stats(&self) -> &HashMap<PlayerId, FiftyFiftyPlayerStats> {
244 &self.player_stats
245 }
246
247 pub fn events(&self) -> &[FiftyFiftyEvent] {
248 &self.events
249 }
250
251 fn apply_team_outcome(
252 stats: &mut FiftyFiftyStats,
253 winning_team_is_team_0: Option<bool>,
254 is_kickoff: bool,
255 ) {
256 match winning_team_is_team_0 {
257 Some(true) => {
258 stats.team_zero_wins += 1;
259 if is_kickoff {
260 stats.kickoff_team_zero_wins += 1;
261 }
262 }
263 Some(false) => {
264 stats.team_one_wins += 1;
265 if is_kickoff {
266 stats.kickoff_team_one_wins += 1;
267 }
268 }
269 None => {
270 stats.neutral_outcomes += 1;
271 if is_kickoff {
272 stats.kickoff_neutral_outcomes += 1;
273 }
274 }
275 }
276 }
277
278 fn apply_possession_outcome(
279 stats: &mut FiftyFiftyStats,
280 possession_team_is_team_0: Option<bool>,
281 is_kickoff: bool,
282 ) {
283 match possession_team_is_team_0 {
284 Some(true) => {
285 stats.team_zero_possession_after_count += 1;
286 if is_kickoff {
287 stats.kickoff_team_zero_possession_after_count += 1;
288 }
289 }
290 Some(false) => {
291 stats.team_one_possession_after_count += 1;
292 if is_kickoff {
293 stats.kickoff_team_one_possession_after_count += 1;
294 }
295 }
296 None => {
297 stats.neutral_possession_after_count += 1;
298 if is_kickoff {
299 stats.kickoff_neutral_possession_after_count += 1;
300 }
301 }
302 }
303 }
304
305 fn apply_player_outcome(
306 player_stats: &mut FiftyFiftyPlayerStats,
307 player_team_is_team_0: bool,
308 event: &FiftyFiftyEvent,
309 ) {
310 player_stats.count += 1;
311 if event.is_kickoff {
312 player_stats.kickoff_count += 1;
313 }
314
315 match event.winning_team_is_team_0 {
316 Some(team_is_team_0) if team_is_team_0 == player_team_is_team_0 => {
317 player_stats.wins += 1;
318 if event.is_kickoff {
319 player_stats.kickoff_wins += 1;
320 }
321 }
322 Some(_) => {
323 player_stats.losses += 1;
324 if event.is_kickoff {
325 player_stats.kickoff_losses += 1;
326 }
327 }
328 None => {
329 player_stats.neutral_outcomes += 1;
330 if event.is_kickoff {
331 player_stats.kickoff_neutral_outcomes += 1;
332 }
333 }
334 }
335
336 if event.possession_team_is_team_0 == Some(player_team_is_team_0) {
337 player_stats.possession_after_count += 1;
338 if event.is_kickoff {
339 player_stats.kickoff_possession_after_count += 1;
340 }
341 }
342 }
343
344 fn apply_event(&mut self, event: &FiftyFiftyEvent) {
345 self.stats.count += 1;
346 if event.is_kickoff {
347 self.stats.kickoff_count += 1;
348 }
349 Self::apply_team_outcome(
350 &mut self.stats,
351 event.winning_team_is_team_0,
352 event.is_kickoff,
353 );
354 Self::apply_possession_outcome(
355 &mut self.stats,
356 event.possession_team_is_team_0,
357 event.is_kickoff,
358 );
359
360 if let Some(player_id) = event.team_zero_player.as_ref() {
361 let stats = self.player_stats.entry(player_id.clone()).or_default();
362 Self::apply_player_outcome(stats, true, event);
363 }
364 if let Some(player_id) = event.team_one_player.as_ref() {
365 let stats = self.player_stats.entry(player_id.clone()).or_default();
366 Self::apply_player_outcome(stats, false, event);
367 }
368
369 self.events.push(event.clone());
370 }
371
372 pub(crate) fn kickoff_phase_active(gameplay: &GameplayState) -> bool {
373 gameplay.game_state == Some(GAME_STATE_KICKOFF_COUNTDOWN)
374 || gameplay.kickoff_countdown_time.is_some_and(|time| time > 0)
375 || gameplay.ball_has_been_hit == Some(false)
376 }
377
378 pub(crate) fn contested_touch(
379 frame: &FrameInfo,
380 players: &PlayerFrameState,
381 touch_events: &[TouchEvent],
382 is_kickoff: bool,
383 ) -> Option<ActiveFiftyFifty> {
384 let team_zero_touch = touch_events.iter().find(|touch| touch.team_is_team_0)?;
385 let team_one_touch = touch_events.iter().find(|touch| !touch.team_is_team_0)?;
386 let team_zero_position = team_zero_touch.player.as_ref().and_then(|player_id| {
387 players
388 .players
389 .iter()
390 .find(|player| &player.player_id == player_id)
391 .and_then(PlayerSample::position)
392 })?;
393 let team_one_position = team_one_touch.player.as_ref().and_then(|player_id| {
394 players
395 .players
396 .iter()
397 .find(|player| &player.player_id == player_id)
398 .and_then(PlayerSample::position)
399 })?;
400 let midpoint = (team_zero_position + team_one_position) * 0.5;
401 let mut plane_normal = team_one_position - team_zero_position;
402 plane_normal.z = 0.0;
403 if plane_normal.length_squared() <= f32::EPSILON {
404 plane_normal = glam::Vec3::Y;
405 } else {
406 plane_normal = plane_normal.normalize();
407 }
408
409 Some(ActiveFiftyFifty {
410 start_time: frame.time,
411 start_frame: frame.frame_number,
412 last_touch_time: frame.time,
413 last_touch_frame: frame.frame_number,
414 is_kickoff,
415 team_zero_player: team_zero_touch.player.clone(),
416 team_one_player: team_one_touch.player.clone(),
417 team_zero_position: team_zero_position.to_array(),
418 team_one_position: team_one_position.to_array(),
419 midpoint: midpoint.to_array(),
420 plane_normal: plane_normal.to_array(),
421 })
422 }
423
424 pub(crate) fn winning_team_from_ball(
425 active: &ActiveFiftyFifty,
426 ball: &BallFrameState,
427 ) -> Option<bool> {
428 let ball = ball.sample()?;
429 let midpoint = active.midpoint_vec();
430 let plane_normal = active.plane_normal_vec();
431 let displacement = ball.position() - midpoint;
432 let signed_distance = displacement.dot(plane_normal);
433 if signed_distance.abs() >= FIFTY_FIFTY_MIN_EXIT_DISTANCE {
434 return Some(signed_distance > 0.0);
435 }
436
437 let signed_speed = ball.velocity().dot(plane_normal);
438 if signed_speed.abs() >= FIFTY_FIFTY_MIN_EXIT_SPEED {
439 return Some(signed_speed > 0.0);
440 }
441
442 None
443 }
444
445 pub fn update(&mut self, fifty_fifty_state: &FiftyFiftyState) -> SubtrActorResult<()> {
446 for event in &fifty_fifty_state.resolved_events {
447 self.apply_event(event);
448 }
449 Ok(())
450 }
451}