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
31fn default_stats_timeline_config() -> StatsTimelineConfig {
32 let rotation_defaults = RotationCalculatorConfig::default();
33 StatsTimelineConfig {
34 most_back_forward_threshold_y: PositioningCalculatorConfig::default()
35 .most_back_forward_threshold_y,
36 level_ball_depth_margin: PositioningCalculatorConfig::default().level_ball_depth_margin,
37 pressure_neutral_zone_half_width_y: PressureCalculatorConfig::default()
38 .neutral_zone_half_width_y,
39 rotation_role_depth_margin: rotation_defaults.role_depth_margin,
40 rotation_first_man_ambiguity_margin: rotation_defaults.first_man_ambiguity_margin,
41 rotation_first_man_debounce_seconds: rotation_defaults.first_man_debounce_seconds,
42 rush_max_start_y: RushCalculatorConfig::default().max_start_y,
43 rush_attack_support_distance_y: RushCalculatorConfig::default().attack_support_distance_y,
44 rush_defender_distance_y: RushCalculatorConfig::default().defender_distance_y,
45 rush_min_possession_retained_seconds: RushCalculatorConfig::default()
46 .min_possession_retained_seconds,
47 aerial_goal_min_ball_z: AerialGoalCalculatorConfig::default().min_ball_z,
48 high_aerial_goal_min_ball_z: HighAerialGoalCalculatorConfig::default().min_ball_z,
49 long_distance_goal_max_attacking_y: LongDistanceGoalCalculatorConfig::default()
50 .max_attacking_y,
51 own_half_goal_max_attacking_y: OwnHalfGoalCalculatorConfig::default().max_attacking_y,
52 empty_net_min_defender_y_margin: EmptyNetGoalCalculatorConfig::default()
53 .min_defender_y_margin,
54 empty_net_min_defender_distance: EmptyNetGoalCalculatorConfig::default()
55 .min_defender_distance,
56 empty_net_max_touch_attacking_y: EmptyNetGoalCalculatorConfig::default()
57 .max_touch_attacking_y,
58 flick_goal_max_event_to_goal_seconds: FlickGoalCalculatorConfig::default()
59 .max_event_to_goal_seconds,
60 double_tap_goal_max_event_to_goal_seconds: DoubleTapGoalCalculatorConfig::default()
61 .max_event_to_goal_seconds,
62 one_timer_goal_max_event_to_goal_seconds: OneTimerGoalCalculatorConfig::default()
63 .max_event_to_goal_seconds,
64 air_dribble_goal_max_end_to_goal_seconds: AirDribbleGoalCalculatorConfig::default()
65 .max_end_to_goal_seconds,
66 flip_reset_goal_max_event_to_goal_seconds: FlipResetGoalCalculatorConfig::default()
67 .max_event_to_goal_seconds,
68 half_volley_max_bounce_to_touch_seconds: HalfVolleyCalculatorConfig::default()
69 .max_bounce_to_touch_seconds,
70 half_volley_min_ball_speed: HalfVolleyCalculatorConfig::default().min_ball_speed,
71 half_volley_goal_max_touch_to_goal_seconds: HalfVolleyGoalCalculatorConfig::default()
72 .max_touch_to_goal_seconds,
73 half_volley_goal_min_goal_alignment: HalfVolleyGoalCalculatorConfig::default()
74 .min_goal_alignment,
75 }
76}
77
78pub struct StatsTimelineCollector {
79 graph: AnalysisGraph,
80 replay_meta: Option<ReplayMeta>,
81 last_replay_meta_player_count: Option<usize>,
82 frames: Vec<ReplayStatsFrame>,
83 last_sample_time: Option<f32>,
84 frame_persistence: StatsFramePersistenceController,
85}
86
87impl Default for StatsTimelineCollector {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl StatsTimelineCollector {
94 pub fn new() -> Self {
100 let graph = build_legacy_timeline_graph();
101 Self {
102 graph,
103 replay_meta: None,
104 last_replay_meta_player_count: None,
105 frames: Vec::new(),
106 last_sample_time: None,
107 frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()),
108 }
109 }
110
111 fn timeline_config(&self) -> StatsTimelineConfig {
112 default_stats_timeline_config()
113 }
114
115 fn snapshot_frame(&self) -> SubtrActorResult<ReplayStatsFrame> {
116 self.graph
117 .state::<StatsTimelineFrameState>()
118 .and_then(|state| state.frame.clone())
119 .ok_or_else(|| {
120 SubtrActorError::new(SubtrActorErrorVariant::CallbackError(
121 "missing StatsTimelineFrame state while building timeline frame".to_owned(),
122 ))
123 })
124 }
125
126 pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
127 let replay_meta = self
128 .replay_meta
129 .clone()
130 .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::CouldNotBuildReplayMeta))?;
131 let mut events = self
132 .graph
133 .state::<StatsTimelineEventsState>()
134 .map(|state| state.events.clone())
135 .unwrap_or_default();
136 if let Some(boost) = self.graph.state::<BoostCalculator>() {
137 events.boost_pickups = boost.pickup_comparison_events().to_vec();
138 events.boost_ledger = boost.ledger_events().to_vec();
139 events.boost_state = boost.state_events().to_vec();
140 }
141 Ok(ReplayStatsTimeline {
142 config: self.timeline_config(),
143 replay_meta,
144 events,
145 frames: self.frames,
146 })
147 }
148
149 #[deprecated(
150 note = "use into_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
151 )]
152 pub fn into_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
153 self.into_legacy_replay_stats_timeline()
154 }
155
156 pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self {
157 self.frame_persistence = StatsFramePersistenceController::new(resolution);
158 self
159 }
160
161 pub fn get_legacy_replay_stats_timeline(
162 mut self,
163 replay: &boxcars::Replay,
164 ) -> SubtrActorResult<ReplayStatsTimeline> {
165 let mut processor = ReplayProcessor::new(replay)?;
166 processor.process(&mut self)?;
167 self.into_legacy_replay_stats_timeline()
168 }
169
170 #[deprecated(
171 note = "use get_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
172 )]
173 pub fn get_replay_data(
174 self,
175 replay: &boxcars::Replay,
176 ) -> SubtrActorResult<ReplayStatsTimeline> {
177 self.get_legacy_replay_stats_timeline(replay)
178 }
179
180 #[deprecated(
181 note = "use into_legacy_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
182 )]
183 pub fn into_timeline(self) -> ReplayStatsTimeline {
184 self.into_legacy_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 last_replay_meta_player_count: Option<usize>,
197 frames: Vec<ReplayStatsFrameScaffold>,
198 last_sample_time: Option<f32>,
199 frame_persistence: StatsFramePersistenceController,
200}
201
202impl Default for StatsTimelineEventCollector {
203 fn default() -> Self {
204 Self::new()
205 }
206}
207
208impl StatsTimelineEventCollector {
209 pub fn new() -> Self {
210 Self {
211 graph: build_timeline_event_graph(),
212 replay_meta: None,
213 last_replay_meta_player_count: None,
214 frames: Vec::new(),
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 Ok(ReplayStatsTimelineScaffold {
294 config: default_stats_timeline_config(),
295 replay_meta,
296 events,
297 frames: self.frames,
298 })
299 }
300
301 pub fn get_replay_stats_timeline_scaffold(
302 mut self,
303 replay: &boxcars::Replay,
304 ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
305 let mut processor = ReplayProcessor::new(replay)?;
306 processor.process(&mut self)?;
307 self.into_replay_stats_timeline_scaffold()
308 }
309
310 #[deprecated(
311 note = "use get_replay_stats_timeline_scaffold for compact event-backed timelines"
312 )]
313 pub fn get_replay_data(
314 self,
315 replay: &boxcars::Replay,
316 ) -> SubtrActorResult<ReplayStatsTimelineScaffold> {
317 self.get_replay_stats_timeline_scaffold(replay)
318 }
319}
320
321impl Collector for StatsTimelineCollector {
322 fn process_frame(
323 &mut self,
324 processor: &ReplayProcessor,
325 _frame: &boxcars::Frame,
326 frame_number: usize,
327 current_time: f32,
328 ) -> SubtrActorResult<TimeAdvance> {
329 let player_count = processor.player_count();
330 if self.last_replay_meta_player_count != Some(player_count) {
331 let replay_meta = processor.get_replay_meta()?;
332 self.graph.on_replay_meta(&replay_meta)?;
333 self.replay_meta = Some(replay_meta);
334 self.last_replay_meta_player_count = Some(player_count);
335 }
336
337 let dt = self
338 .last_sample_time
339 .map(|last_time| (current_time - last_time).max(0.0))
340 .unwrap_or(0.0);
341 let frame_input = FrameInput::timeline(processor, frame_number, current_time, dt);
342 self.graph.evaluate_with_state(&frame_input)?;
343 self.last_sample_time = Some(current_time);
344
345 if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
346 let mut frame = self.snapshot_frame()?;
347 frame.dt = emitted_dt;
348 self.frames.push(frame);
349 }
350
351 Ok(TimeAdvance::NextFrame)
352 }
353
354 fn finish_replay(&mut self, _processor: &ReplayProcessor) -> SubtrActorResult<()> {
355 self.graph.finish()?;
356 let Some(_) = self.replay_meta.as_ref() else {
357 return Ok(());
358 };
359 let Some(_) = self.graph.state::<StatsTimelineFrameState>() else {
360 return Ok(());
361 };
362 let mut final_snapshot = self.snapshot_frame()?;
363 match self
364 .frame_persistence
365 .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
366 {
367 Some(FinalStatsFrameAction::Append { dt }) => {
368 final_snapshot.dt = dt;
369 self.frames.push(final_snapshot);
370 }
371 Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
372 final_snapshot.dt = dt;
373 if let Some(last_frame) = self.frames.last_mut() {
374 *last_frame = final_snapshot;
375 }
376 }
377 None => {}
378 }
379 Ok(())
380 }
381}
382
383impl Collector for StatsTimelineEventCollector {
384 fn process_frame(
385 &mut self,
386 processor: &ReplayProcessor,
387 _frame: &boxcars::Frame,
388 frame_number: usize,
389 current_time: f32,
390 ) -> SubtrActorResult<TimeAdvance> {
391 let player_count = processor.player_count();
392 if self.last_replay_meta_player_count != Some(player_count) {
393 let replay_meta = processor.get_replay_meta()?;
394 self.graph.on_replay_meta(&replay_meta)?;
395 self.replay_meta = Some(replay_meta);
396 self.last_replay_meta_player_count = Some(player_count);
397 }
398
399 let dt = self
400 .last_sample_time
401 .map(|last_time| (current_time - last_time).max(0.0))
402 .unwrap_or(0.0);
403 let frame_input = FrameInput::timeline(processor, frame_number, current_time, dt);
404 self.graph.evaluate_with_state(&frame_input)?;
405 self.last_sample_time = Some(current_time);
406
407 if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) {
408 let mut frame = self.snapshot_frame_scaffold()?;
409 frame.dt = emitted_dt;
410 self.frames.push(frame);
411 }
412
413 Ok(TimeAdvance::NextFrame)
414 }
415
416 fn finish_replay(&mut self, _processor: &ReplayProcessor) -> SubtrActorResult<()> {
417 self.graph.finish()?;
418 let Some(_) = self.replay_meta.as_ref() else {
419 return Ok(());
420 };
421 let Some(_) = self.graph.state::<FrameInfo>() else {
422 return Ok(());
423 };
424 let mut final_snapshot = self.snapshot_frame_scaffold()?;
425 match self
426 .frame_persistence
427 .final_frame_action(final_snapshot.frame_number, final_snapshot.time)
428 {
429 Some(FinalStatsFrameAction::Append { dt }) => {
430 final_snapshot.dt = dt;
431 self.frames.push(final_snapshot);
432 }
433 Some(FinalStatsFrameAction::ReplaceLast { dt }) => {
434 final_snapshot.dt = dt;
435 if let Some(last_frame) = self.frames.last_mut() {
436 *last_frame = final_snapshot;
437 }
438 }
439 None => {}
440 }
441 Ok(())
442 }
443}
444
445#[cfg(test)]
446#[path = "collector_tests.rs"]
447mod collector_tests;