1use crate::collector::frame_resolution::{
2 FinalStatsFrameAction, StatsFramePersistenceController, StatsFrameResolution,
3};
4use crate::stats::analysis_graph::{
5 AnalysisGraph, StatsTimelineEventsNode, StatsTimelineEventsState, StatsTimelineFrameNode,
6 StatsTimelineFrameState,
7};
8use crate::stats::calculators::ReplayFrameInputBuilder;
9use crate::*;
10use std::collections::{BTreeMap, HashMap};
11
12const ABSENT_PLAYER_MIN_MISSING_SECONDS: f32 = 30.0;
13
14pub fn build_legacy_timeline_graph() -> AnalysisGraph {
15 let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
16 graph.push_boxed_node(Box::new(StatsTimelineFrameNode::new()));
17 graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
18 graph
19}
20
21pub fn build_timeline_event_graph() -> AnalysisGraph {
22 let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
23 graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
24 graph
25}
26
27pub fn default_stats_timeline_config() -> StatsTimelineConfig {
28 let positioning_defaults = PositioningCalculatorConfig::default();
29 let rotation_defaults = RotationCalculatorConfig::default();
30 let territorial_pressure_defaults = TerritorialPressureCalculatorConfig::default();
31 StatsTimelineConfig {
32 most_back_forward_threshold_y: positioning_defaults.most_back_forward_threshold_y,
33 level_ball_depth_margin: positioning_defaults.level_ball_depth_margin,
34 closest_to_ball_switch_margin: positioning_defaults.closest_to_ball_switch_margin,
35 closest_to_ball_switch_min_seconds: positioning_defaults.closest_to_ball_switch_min_seconds,
36 shadow_defense_max_ball_y: positioning_defaults.shadow_defense_max_ball_y,
37 shadow_defense_min_goal_side_y: positioning_defaults.shadow_defense_min_goal_side_y,
38 shadow_defense_min_gap: positioning_defaults.shadow_defense_min_gap,
39 shadow_defense_max_gap: positioning_defaults.shadow_defense_max_gap,
40 shadow_defense_max_lateral_gap: positioning_defaults.shadow_defense_max_lateral_gap,
41 shadow_defense_min_retreat_speed: positioning_defaults.shadow_defense_min_retreat_speed,
42 shadow_defense_max_speed_delta: positioning_defaults.shadow_defense_max_speed_delta,
43 ball_half_neutral_zone_half_width_y: BallHalfCalculatorConfig::default()
44 .neutral_zone_half_width_y,
45 ball_third_boundary_y: BallThirdCalculatorConfig::default().boundary_y,
46 territorial_pressure_neutral_zone_half_width_y: territorial_pressure_defaults
47 .neutral_zone_half_width_y,
48 territorial_pressure_min_establish_seconds: territorial_pressure_defaults
49 .min_establish_seconds,
50 territorial_pressure_min_establish_third_seconds: territorial_pressure_defaults
51 .min_establish_third_seconds,
52 territorial_pressure_relief_grace_seconds: territorial_pressure_defaults
53 .relief_grace_seconds,
54 territorial_pressure_confirmed_relief_grace_seconds: territorial_pressure_defaults
55 .confirmed_relief_grace_seconds,
56 rotation_role_depth_margin: rotation_defaults.role_depth_margin,
57 rotation_first_man_ambiguity_margin: rotation_defaults.first_man_ambiguity_margin,
58 rotation_first_man_debounce_seconds: rotation_defaults.first_man_debounce_seconds,
59 rotation_first_man_stint_end_grace_seconds: rotation_defaults
60 .first_man_stint_end_grace_seconds,
61 rush_max_start_y: RushCalculatorConfig::default().max_start_y,
62 rush_attack_support_distance_y: RushCalculatorConfig::default().attack_support_distance_y,
63 rush_defender_distance_y: RushCalculatorConfig::default().defender_distance_y,
64 rush_min_possession_retained_seconds: RushCalculatorConfig::default()
65 .min_possession_retained_seconds,
66 aerial_goal_min_ball_z: AerialGoalCalculatorConfig::default().min_ball_z,
67 high_aerial_goal_min_ball_z: HighAerialGoalCalculatorConfig::default().min_ball_z,
68 long_distance_goal_max_attacking_y: LongDistanceGoalCalculatorConfig::default()
69 .max_attacking_y,
70 own_half_goal_max_attacking_y: OwnHalfGoalCalculatorConfig::default().max_attacking_y,
71 empty_net_min_defender_y_margin: EmptyNetGoalCalculatorConfig::default()
72 .min_defender_y_margin,
73 empty_net_min_defender_distance: EmptyNetGoalCalculatorConfig::default()
74 .min_defender_distance,
75 empty_net_max_touch_attacking_y: EmptyNetGoalCalculatorConfig::default()
76 .max_touch_attacking_y,
77 flick_goal_max_event_to_goal_seconds: FlickGoalCalculatorConfig::default()
78 .max_event_to_goal_seconds,
79 ceiling_shot_goal_max_event_to_goal_seconds: CeilingShotGoalCalculatorConfig::default()
80 .max_event_to_goal_seconds,
81 double_tap_goal_max_event_to_goal_seconds: DoubleTapGoalCalculatorConfig::default()
82 .max_event_to_goal_seconds,
83 one_timer_goal_max_event_to_goal_seconds: OneTimerGoalCalculatorConfig::default()
84 .max_event_to_goal_seconds,
85 air_dribble_goal_max_end_to_goal_seconds: AirDribbleGoalCalculatorConfig::default()
86 .max_end_to_goal_seconds,
87 flip_reset_goal_max_event_to_goal_seconds: FlipResetGoalCalculatorConfig::default()
88 .max_event_to_goal_seconds,
89 flip_into_ball_goal_max_touch_to_goal_seconds: FlipIntoBallGoalCalculatorConfig::default()
90 .max_touch_to_goal_seconds,
91 bump_goal_max_event_to_goal_seconds: BumpGoalCalculatorConfig::default()
92 .max_event_to_goal_seconds,
93 demo_goal_max_event_to_goal_seconds: DemoGoalCalculatorConfig::default()
94 .max_event_to_goal_seconds,
95 half_volley_max_bounce_to_touch_seconds: HalfVolleyCalculatorConfig::default()
96 .max_bounce_to_touch_seconds,
97 half_volley_min_ball_speed: HalfVolleyCalculatorConfig::default().min_ball_speed,
98 half_volley_goal_max_touch_to_goal_seconds: HalfVolleyGoalCalculatorConfig::default()
99 .max_touch_to_goal_seconds,
100 half_volley_goal_min_goal_alignment: HalfVolleyGoalCalculatorConfig::default()
101 .min_goal_alignment,
102 }
103}
104
105pub struct StatsTimelineCollector {
106 graph: AnalysisGraph,
107 replay_meta: Option<ReplayMeta>,
108 frames: Vec<ReplayStatsFrame>,
109 frame_input_builder: ReplayFrameInputBuilder,
110 last_replay_meta_player_count: Option<usize>,
111 last_sample_time: Option<f32>,
112 frame_persistence: StatsFramePersistenceController,
113}
114
115impl Default for StatsTimelineCollector {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl StatsTimelineCollector {
122 pub fn new() -> Self {
128 let graph = build_legacy_timeline_graph();
129 Self {
130 graph,
131 replay_meta: None,
132 frames: Vec::new(),
133 frame_input_builder: ReplayFrameInputBuilder::default(),
134 last_replay_meta_player_count: None,
135 last_sample_time: None,
136 frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
137 }
138 }
139
140 fn timeline_config(&self) -> StatsTimelineConfig {
141 default_stats_timeline_config()
142 }
143
144 fn snapshot_frame(&self) -> SubtrActorResult<ReplayStatsFrame> {
145 self.graph
146 .state::<StatsTimelineFrameState>()
147 .and_then(|state| state.frame.clone())
148 .ok_or_else(|| {
149 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
150 "missing StatsTimelineFrame state while building timeline frame".to_owned(),
151 ))
152 })
153 }
154
155 pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
156 let replay_meta = self
157 .replay_meta
158 .clone()
159 .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
160 let events = self
161 .graph
162 .state::<StatsTimelineEventsState>()
163 .map(|state| state.events.clone())
164 .unwrap_or_default();
165 Ok(ReplayStatsTimeline {
166 config: self.timeline_config(),
167 replay_meta,
168 events,
169 frames: self.frames,
170 })
171 }
172
173 pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
174 self.frame_persistence = StatsFramePersistenceController::new(resolution);
175 self
176 }
177
178 pub fn get_legacy_replay_stats_timeline(
179 mut self,
180 replay: &boxcars::Replay,
181 ) -> SubtrActorResult<ReplayStatsTimeline> {
182 let mut processor = ReplayProcessor::new(replay)?;
183 processor.process(&mut self)?;
184 self.into_legacy_replay_stats_timeline()
185 }
186
187 pub fn into_legacy_timeline(self) -> ReplayStatsTimeline {
188 self.into_legacy_replay_stats_timeline()
189 .expect("analysis-node timeline collector should build typed stats frames")
190 }
191}
192
193pub struct StatsTimelineEventCollector {
194 graph: AnalysisGraph,
195 replay_meta: Option<ReplayMeta>,
196 frames: Vec<ReplayStatsFrameScaffold>,
197 frame_input_builder: ReplayFrameInputBuilder,
198 last_replay_meta_player_count: Option<usize>,
199 last_sample_time: Option<f32>,
200 frame_persistence: StatsFramePersistenceController,
201}
202
203impl Default for StatsTimelineEventCollector {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209impl StatsTimelineEventCollector {
210 pub fn new() -> Self {
211 Self {
212 graph: build_timeline_event_graph(),
213 replay_meta: None,
214 frames: Vec::new(),
215 frame_input_builder: ReplayFrameInputBuilder::default(),
216 last_replay_meta_player_count: None,
217 last_sample_time: None,
218 frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
219 }
220 }
221
222 pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
223 self.frame_persistence = StatsFramePersistenceController::new(resolution);
224 self
225 }
226
227 fn replay_meta(&self) -> SubtrActorResult<&ReplayMeta> {
228 self.replay_meta
229 .as_ref()
230 .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))
231 }
232
233 fn is_team_zero_player(replay_meta: &ReplayMeta, player: &PlayerInfo) -> bool {
234 replay_meta
235 .team_zero
236 .iter()
237 .any(|team_player| team_player.remote_id == player.remote_id)
238 }
239
240 fn snapshot_frame_scaffold(&self) -> SubtrActorResult<ReplayStatsFrameScaffold> {
241 let replay_meta = self.replay_meta()?;
242 let frame = self.graph.state::<FrameInfo>().ok_or_else(|| {
243 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
244 "missing FrameInfo state while building stats timeline frame scaffold".to_owned(),
245 ))
246 })?;
247 let gameplay = self.graph.state::<GameplayState>().ok_or_else(|| {
248 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
249 "missing GameplayState state while building stats timeline frame scaffold"
250 .to_owned(),
251 ))
252 })?;
253 let live_play_state = self.graph.state::<LivePlayState>().ok_or_else(|| {
254 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
255 "missing LivePlayState state while building stats timeline frame scaffold"
256 .to_owned(),
257 ))
258 })?;
259
260 Ok(ReplayStatsFrameScaffold {
261 frame_number: frame.frame_number,
262 time: frame.time,
263 dt: frame.dt,
264 seconds_remaining: frame.seconds_remaining,
265 game_state: gameplay.game_state,
266 ball_has_been_hit: gameplay.ball_has_been_hit,
267 kickoff_countdown_time: gameplay.kickoff_countdown_time,
268 gameplay_phase: live_play_state.gameplay_phase,
269 is_live_play: live_play_state.is_live_play,
270 team_zero: BTreeMap::new(),
271 team_one: BTreeMap::new(),
272 players: replay_meta
273 .player_order()
274 .map(|player| ReplayStatsPlayerIdentity {
275 player_id: player.remote_id.clone(),
276 name: player.name.clone(),
277 is_team_0: Self::is_team_zero_player(replay_meta, player),
278 })
279 .collect(),
280 })
281 }
282
283 pub fn into_replay_stats_timeline_scaffold(
284 self,
285 ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
286 let replay_meta = self
287 .replay_meta
288 .clone()
289 .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
290 let events = self
291 .graph
292 .state::<StatsTimelineEventsState>()
293 .map(|state| state.events.clone())
294 .unwrap_or_default();
295 let positioning = self.graph.state::<PositioningCalculator>();
296 let positioning_summary = replay_meta
297 .player_order()
298 .map(|player| ReplayStatsPositioningSummary {
299 player_id: player.remote_id.clone(),
300 is_team_0: Self::is_team_zero_player(&replay_meta, player),
301 distance: positioning
302 .map(|calculator| calculator.player_signal(&player.remote_id))
303 .unwrap_or_default(),
304 })
305 .collect();
306 let accumulation_tracks = self
307 .graph
308 .state::<BoostCalculator>()
309 .map(|calculator| calculator.accumulation_tracks())
310 .unwrap_or_default();
311 let activity_summary = replay_activity_summary(
312 &replay_meta,
313 &self.frames,
314 &events,
315 ABSENT_PLAYER_MIN_MISSING_SECONDS,
316 );
317 Ok(ReplayStatsTimelineScaffold {
318 config: default_stats_timeline_config(),
319 replay_meta,
320 events,
321 frames: self.frames,
322 activity_summary,
323 positioning_summary,
324 accumulation_tracks,
325 })
326 }
327
328 pub fn get_replay_stats_timeline_scaffold(
329 mut self,
330 replay: &boxcars::Replay,
331 ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
332 let mut processor = ReplayProcessor::new(replay)?;
333 processor.process(&mut self)?;
334 self.into_replay_stats_timeline_scaffold()
335 }
336}
337
338fn replay_activity_summary(
339 replay_meta: &ReplayMeta,
340 frames: &[ReplayStatsFrameScaffold],
341 events: &ReplayStatsTimelineEvents,
342 absent_player_min_missing_seconds: f32,
343) -> ReplayStatsActivitySummary {
344 let live_play_seconds = frames
345 .iter()
346 .filter(|frame| frame.is_live_play)
347 .map(|frame| frame.dt)
348 .filter(|dt| dt.is_finite() && *dt > 0.0)
349 .sum::<f32>();
350 let mut active_seconds_by_player = HashMap::<PlayerId, f32>::new();
351 for event in &events.events {
352 let EventPayload::PlayerActivity(activity) = &event.payload else {
353 continue;
354 };
355 active_seconds_by_player
356 .entry(activity.player.clone())
357 .and_modify(|seconds| *seconds += activity.duration.max(0.0))
358 .or_insert_with(|| activity.duration.max(0.0));
359 }
360
361 let players = replay_meta
362 .player_order()
363 .map(|player| {
364 let active_seconds = active_seconds_by_player
365 .get(&player.remote_id)
366 .copied()
367 .unwrap_or_default();
368 let missing_live_play_seconds = (live_play_seconds - active_seconds).max(0.0);
369 ReplayStatsPlayerActivitySummary {
370 player_id: player.remote_id.clone(),
371 name: player.name.clone(),
372 is_team_0: StatsTimelineEventCollector::is_team_zero_player(replay_meta, player),
373 active_seconds,
374 missing_live_play_seconds,
375 absent_for_extended_period: missing_live_play_seconds
376 >= absent_player_min_missing_seconds,
377 }
378 })
379 .collect::<Vec<_>>();
380 let has_absent_player = players
381 .iter()
382 .any(|player| player.absent_for_extended_period);
383
384 ReplayStatsActivitySummary {
385 live_play_seconds,
386 absent_player_min_missing_seconds,
387 has_absent_player,
388 players,
389 }
390}
391
392impl Collector for StatsTimelineCollector {
393 fn process_frame(
394 &mut self,
395 processor: &dyn ProcessorView,
396 _frame: &boxcars::Frame,
397 frame_number: usize,
398 current_time: f32,
399 ) -> SubtrActorResult<TimeAdvance> {
400 let player_count = processor.player_count();
401 if self.last_replay_meta_player_count != Some(player_count) {
402 let replay_meta = processor.get_replay_meta()?;
403 self.graph.on_replay_meta(&replay_meta)?;
404 self.replay_meta = Some(replay_meta);
405 self.last_replay_meta_player_count = Some(player_count);
406 }
407
408 let dt = self
409 .last_sample_time
410 .map(|last_time| (current_time - last_time).max(0.0))
411 .unwrap_or(0.0);
412 let frame_input =
413 self.frame_input_builder
414 .timeline(processor, frame_number, current_time, dt);
415 self.graph.evaluate_with_state(&frame_input)?;
416 self.last_sample_time = Some(current_time);
417
418 if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
419 let mut frame = self.snapshot_frame()?;
420 frame.dt = emitted_dt;
421 self.frames.push(frame);
422 }
423
424 Ok(TimeAdvance::NextFrame)
425 }
426
427 fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
428 self.graph.finish()?;
429 let Some(_) = self.replay_meta.as_ref() else {
430 return Ok(());
431 };
432 let Some(_) = self.graph.state::<StatsTimelineFrameState>() else {
433 return Ok(());
434 };
435 let mut final_snapshot = self.snapshot_frame()?;
436 match self
437 .frame_persistence
438 .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
439 {
440 Some(FinalStatsFrameAction::Append { dt }) => {
441 final_snapshot.dt = dt;
442 self.frames.push(final_snapshot);
443 }
444 Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
445 final_snapshot.dt = dt;
446 if let Some(last_frame) = self.frames.last_mut() {
447 *last_frame = final_snapshot;
448 }
449 }
450 None => {}
451 }
452 Ok(())
453 }
454}
455
456impl Collector for StatsTimelineEventCollector {
457 fn process_frame(
458 &mut self,
459 processor: &dyn ProcessorView,
460 _frame: &boxcars::Frame,
461 frame_number: usize,
462 current_time: f32,
463 ) -> SubtrActorResult<TimeAdvance> {
464 let player_count = processor.player_count();
465 if self.last_replay_meta_player_count != Some(player_count) {
466 let replay_meta = processor.get_replay_meta()?;
467 self.graph.on_replay_meta(&replay_meta)?;
468 self.replay_meta = Some(replay_meta);
469 self.last_replay_meta_player_count = Some(player_count);
470 }
471
472 let dt = self
473 .last_sample_time
474 .map(|last_time| (current_time - last_time).max(0.0))
475 .unwrap_or(0.0);
476 let frame_input =
477 self.frame_input_builder
478 .timeline(processor, frame_number, current_time, dt);
479 self.graph.evaluate_with_state(&frame_input)?;
480 self.last_sample_time = Some(current_time);
481
482 if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
483 let mut frame = self.snapshot_frame_scaffold()?;
484 frame.dt = emitted_dt;
485 self.frames.push(frame);
486 }
487
488 Ok(TimeAdvance::NextFrame)
489 }
490
491 fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
492 self.graph.finish()?;
493 let Some(_) = self.replay_meta.as_ref() else {
494 return Ok(());
495 };
496 let Some(_) = self.graph.state::<FrameInfo>() else {
497 return Ok(());
498 };
499 let mut final_snapshot = self.snapshot_frame_scaffold()?;
500 match self
501 .frame_persistence
502 .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
503 {
504 Some(FinalStatsFrameAction::Append { dt }) => {
505 final_snapshot.dt = dt;
506 self.frames.push(final_snapshot);
507 }
508 Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
509 final_snapshot.dt = dt;
510 if let Some(last_frame) = self.frames.last_mut() {
511 *last_frame = final_snapshot;
512 }
513 }
514 None => {}
515 }
516 Ok(())
517 }
518}
519
520#[cfg(test)]
521#[path = "collector_tests.rs"]
522mod collector_tests;