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