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::*;
9use std::collections::BTreeMap;
10
11pub fn build_legacy_timeline_graph() -> AnalysisGraph {
12 let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
13 graph.push_boxed_node(Box::new(StatsTimelineFrameNode::new()));
14 graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
15 graph
16}
17
18#[deprecated(
19 note = "use build_legacy_timeline_graph for full partial-sum snapshots, or build_timeline_event_graph for compact event-backed timelines"
20)]
21pub fn build_timeline_graph() -> AnalysisGraph {
22 build_legacy_timeline_graph()
23}
24
25pub fn build_timeline_event_graph() -> AnalysisGraph {
26 let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
27 graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new()));
28 graph
29}
30
31pub fn default_stats_timeline_config() -> StatsTimelineConfig {
32 let rotation_defaults = RotationCalculatorConfig::default();
33 let territorial_pressure_defaults = TerritorialPressureCalculatorConfig::default();
34 StatsTimelineConfig {
35 most_back_forward_threshold_y: PositioningCalculatorConfig::default()
36 .most_back_forward_threshold_y,
37 level_ball_depth_margin: PositioningCalculatorConfig::default().level_ball_depth_margin,
38 pressure_neutral_zone_half_width_y: PressureCalculatorConfig::default()
39 .neutral_zone_half_width_y,
40 territorial_pressure_neutral_zone_half_width_y: territorial_pressure_defaults
41 .neutral_zone_half_width_y,
42 territorial_pressure_min_establish_seconds: territorial_pressure_defaults
43 .min_establish_seconds,
44 territorial_pressure_min_establish_third_seconds: territorial_pressure_defaults
45 .min_establish_third_seconds,
46 territorial_pressure_relief_grace_seconds: territorial_pressure_defaults
47 .relief_grace_seconds,
48 territorial_pressure_confirmed_relief_grace_seconds: territorial_pressure_defaults
49 .confirmed_relief_grace_seconds,
50 rotation_role_depth_margin: rotation_defaults.role_depth_margin,
51 rotation_first_man_ambiguity_margin: rotation_defaults.first_man_ambiguity_margin,
52 rotation_first_man_debounce_seconds: rotation_defaults.first_man_debounce_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 double_tap_goal_max_event_to_goal_seconds: DoubleTapGoalCalculatorConfig::default()
72 .max_event_to_goal_seconds,
73 one_timer_goal_max_event_to_goal_seconds: OneTimerGoalCalculatorConfig::default()
74 .max_event_to_goal_seconds,
75 air_dribble_goal_max_end_to_goal_seconds: AirDribbleGoalCalculatorConfig::default()
76 .max_end_to_goal_seconds,
77 flip_reset_goal_max_event_to_goal_seconds: FlipResetGoalCalculatorConfig::default()
78 .max_event_to_goal_seconds,
79 half_volley_max_bounce_to_touch_seconds: HalfVolleyCalculatorConfig::default()
80 .max_bounce_to_touch_seconds,
81 half_volley_min_ball_speed: HalfVolleyCalculatorConfig::default().min_ball_speed,
82 half_volley_goal_max_touch_to_goal_seconds: HalfVolleyGoalCalculatorConfig::default()
83 .max_touch_to_goal_seconds,
84 half_volley_goal_min_goal_alignment: HalfVolleyGoalCalculatorConfig::default()
85 .min_goal_alignment,
86 }
87}
88
89pub struct StatsTimelineCollector {
90 graph: AnalysisGraph,
91 replay_meta: Option<ReplayMeta>,
92 last_replay_meta_player_count: Option<usize>,
93 frames: Vec<ReplayStatsFrame>,
94 last_sample_time: Option<f32>,
95 frame_persistence: StatsFramePersistenceController,
96}
97
98impl Default for StatsTimelineCollector {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104impl StatsTimelineCollector {
105 pub fn new() -> Self {
111 let graph = build_legacy_timeline_graph();
112 Self {
113 graph,
114 replay_meta: None,
115 last_replay_meta_player_count: None,
116 frames: Vec::new(),
117 last_sample_time: None,
118 frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
119 }
120 }
121
122 fn timeline_config(&self) -> StatsTimelineConfig {
123 default_stats_timeline_config()
124 }
125
126 fn snapshot_frame(&self) -> SubtrActorResult<ReplayStatsFrame> {
127 self.graph
128 .state::<StatsTimelineFrameState>()
129 .and_then(|state| state.frame.clone())
130 .ok_or_else(|| {
131 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
132 "missing StatsTimelineFrame state while building timeline frame".to_owned(),
133 ))
134 })
135 }
136
137 pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
138 let replay_meta = self
139 .replay_meta
140 .clone()
141 .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
142 let mut events = self
143 .graph
144 .state::<StatsTimelineEventsState>()
145 .map(|state| state.events.clone())
146 .unwrap_or_default();
147 if let Some(boost) = self.graph.state::<BoostCalculator>() {
148 events.boost_pickups = boost.pickup_comparison_events().to_vec();
149 events.boost_ledger = boost.ledger_events().to_vec();
150 events.boost_state = boost.state_events().to_vec();
151 }
152 Ok(ReplayStatsTimeline {
153 config: self.timeline_config(),
154 replay_meta,
155 events,
156 frames: self.frames,
157 })
158 }
159
160 #[deprecated(
161 note = "use into_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
162 )]
163 pub fn into_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
164 self.into_legacy_replay_stats_timeline()
165 }
166
167 pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
168 self.frame_persistence = StatsFramePersistenceController::new(resolution);
169 self
170 }
171
172 pub fn get_legacy_replay_stats_timeline(
173 mut self,
174 replay: &boxcars::Replay,
175 ) -> SubtrActorResult<ReplayStatsTimeline> {
176 let mut processor = ReplayProcessor::new(replay)?;
177 processor.process(&mut self)?;
178 self.into_legacy_replay_stats_timeline()
179 }
180
181 #[deprecated(
182 note = "use get_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
183 )]
184 pub fn get_replay_data(
185 self,
186 replay: &boxcars::Replay,
187 ) -> SubtrActorResult<ReplayStatsTimeline> {
188 self.get_legacy_replay_stats_timeline(replay)
189 }
190
191 #[deprecated(
192 note = "use into_legacy_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
193 )]
194 pub fn into_timeline(self) -> ReplayStatsTimeline {
195 self.into_legacy_timeline()
196 }
197
198 pub fn into_legacy_timeline(self) -> ReplayStatsTimeline {
199 self.into_legacy_replay_stats_timeline()
200 .expect("analysis-node timeline collector should build typed stats frames")
201 }
202}
203
204pub struct StatsTimelineEventCollector {
205 graph: AnalysisGraph,
206 replay_meta: Option<ReplayMeta>,
207 last_replay_meta_player_count: Option<usize>,
208 frames: Vec<ReplayStatsFrameScaffold>,
209 last_sample_time: Option<f32>,
210 frame_persistence: StatsFramePersistenceController,
211}
212
213impl Default for StatsTimelineEventCollector {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219impl StatsTimelineEventCollector {
220 pub fn new() -> Self {
221 Self {
222 graph: build_timeline_event_graph(),
223 replay_meta: None,
224 last_replay_meta_player_count: None,
225 frames: Vec::new(),
226 last_sample_time: None,
227 frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
228 }
229 }
230
231 pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
232 self.frame_persistence = StatsFramePersistenceController::new(resolution);
233 self
234 }
235
236 fn replay_meta(&self) -> SubtrActorResult<&ReplayMeta> {
237 self.replay_meta
238 .as_ref()
239 .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))
240 }
241
242 fn is_team_zero_player(replay_meta: &ReplayMeta, player: &PlayerInfo) -> bool {
243 replay_meta
244 .team_zero
245 .iter()
246 .any(|team_player| team_player.remote_id == player.remote_id)
247 }
248
249 fn snapshot_frame_scaffold(&self) -> SubtrActorResult<ReplayStatsFrameScaffold> {
250 let replay_meta = self.replay_meta()?;
251 let frame = self.graph.state::<FrameInfo>().ok_or_else(|| {
252 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
253 "missing FrameInfo state while building stats timeline frame scaffold".to_owned(),
254 ))
255 })?;
256 let gameplay = self.graph.state::<GameplayState>().ok_or_else(|| {
257 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
258 "missing GameplayState state while building stats timeline frame scaffold"
259 .to_owned(),
260 ))
261 })?;
262 let live_play_state = self.graph.state::<LivePlayState>().ok_or_else(|| {
263 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
264 "missing LivePlayState state while building stats timeline frame scaffold"
265 .to_owned(),
266 ))
267 })?;
268
269 Ok(ReplayStatsFrameScaffold {
270 frame_number: frame.frame_number,
271 time: frame.time,
272 dt: frame.dt,
273 seconds_remaining: frame.seconds_remaining,
274 game_state: gameplay.game_state,
275 ball_has_been_hit: gameplay.ball_has_been_hit,
276 kickoff_countdown_time: gameplay.kickoff_countdown_time,
277 gameplay_phase: live_play_state.gameplay_phase,
278 is_live_play: live_play_state.is_live_play,
279 team_zero: BTreeMap::new(),
280 team_one: BTreeMap::new(),
281 players: replay_meta
282 .player_order()
283 .map(|player| ReplayStatsPlayerIdentity {
284 player_id: player.remote_id.clone(),
285 name: player.name.clone(),
286 is_team_0: Self::is_team_zero_player(replay_meta, player),
287 })
288 .collect(),
289 })
290 }
291
292 pub fn into_replay_stats_timeline_scaffold(
293 self,
294 ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
295 let replay_meta = self
296 .replay_meta
297 .clone()
298 .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
299 let events = self
300 .graph
301 .state::<StatsTimelineEventsState>()
302 .map(|state| state.events.clone())
303 .unwrap_or_default();
304 Ok(ReplayStatsTimelineScaffold {
305 config: default_stats_timeline_config(),
306 replay_meta,
307 events,
308 frames: self.frames,
309 })
310 }
311
312 pub fn get_replay_stats_timeline_scaffold(
313 mut self,
314 replay: &boxcars::Replay,
315 ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
316 let mut processor = ReplayProcessor::new(replay)?;
317 processor.process(&mut self)?;
318 self.into_replay_stats_timeline_scaffold()
319 }
320
321 #[deprecated(
322 note = "use get_replay_stats_timeline_scaffold for compact event-backed timelines"
323 )]
324 pub fn get_replay_data(
325 self,
326 replay: &boxcars::Replay,
327 ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
328 self.get_replay_stats_timeline_scaffold(replay)
329 }
330}
331
332impl Collector for StatsTimelineCollector {
333 fn process_frame(
334 &mut self,
335 processor: &dyn ProcessorView,
336 _frame: &boxcars::Frame,
337 frame_number: usize,
338 current_time: f32,
339 ) -> SubtrActorResult<TimeAdvance> {
340 let player_count = processor.player_count();
341 if self.last_replay_meta_player_count != Some(player_count) {
342 let replay_meta = processor.get_replay_meta()?;
343 self.graph.on_replay_meta(&replay_meta)?;
344 self.replay_meta = Some(replay_meta);
345 self.last_replay_meta_player_count = Some(player_count);
346 }
347
348 let dt = self
349 .last_sample_time
350 .map(|last_time| (current_time - last_time).max(0.0))
351 .unwrap_or(0.0);
352 let frame_input = FrameInput::timeline(processor, frame_number, current_time, dt);
353 self.graph.evaluate_with_state(&frame_input)?;
354 self.last_sample_time = Some(current_time);
355
356 if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
357 let mut frame = self.snapshot_frame()?;
358 frame.dt = emitted_dt;
359 self.frames.push(frame);
360 }
361
362 Ok(TimeAdvance::NextFrame)
363 }
364
365 fn finish_replay(&mut self, _processor: &dyn ProcessorView) -> SubtrActorResult<()> {
366 self.graph.finish()?;
367 let Some(_) = self.replay_meta.as_ref() else {
368 return Ok(());
369 };
370 let Some(_) = self.graph.state::<StatsTimelineFrameState>() else {
371 return Ok(());
372 };
373 let mut final_snapshot = self.snapshot_frame()?;
374 match self
375 .frame_persistence
376 .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
377 {
378 Some(FinalStatsFrameAction::Append { dt }) => {
379 final_snapshot.dt = dt;
380 self.frames.push(final_snapshot);
381 }
382 Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
383 final_snapshot.dt = dt;
384 if let Some(last_frame) = self.frames.last_mut() {
385 *last_frame = final_snapshot;
386 }
387 }
388 None => {}
389 }
390 Ok(())
391 }
392}
393
394impl Collector for StatsTimelineEventCollector {
395 fn process_frame(
396 &mut self,
397 processor: &dyn ProcessorView,
398 _frame: &boxcars::Frame,
399 frame_number: usize,
400 current_time: f32,
401 ) -> SubtrActorResult<TimeAdvance> {
402 let player_count = processor.player_count();
403 if self.last_replay_meta_player_count != Some(player_count) {
404 let replay_meta = processor.get_replay_meta()?;
405 self.graph.on_replay_meta(&replay_meta)?;
406 self.replay_meta = Some(replay_meta);
407 self.last_replay_meta_player_count = Some(player_count);
408 }
409
410 let dt = self
411 .last_sample_time
412 .map(|last_time| (current_time - last_time).max(0.0))
413 .unwrap_or(0.0);
414 let frame_input = FrameInput::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_scaffold()?;
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::<FrameInfo>() else {
433 return Ok(());
434 };
435 let mut final_snapshot = self.snapshot_frame_scaffold()?;
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
456#[cfg(test)]
457#[path = "collector_tests.rs"]
458mod collector_tests;