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