Skip to main content

subtr_actor/collector/stats/
playback.rs

1use serde::Serialize;
2use serde::de::DeserializeOwned;
3use serde_json::{Map, Value};
4
5use crate::*;
6
7use super::types::serialize_to_json_value;
8
9#[path = "playback_event_parsers.rs"]
10mod playback_event_parsers;
11#[path = "playback_events.rs"]
12mod playback_events;
13#[path = "playback_frames.rs"]
14mod playback_frames;
15#[path = "playback_json.rs"]
16mod playback_json;
17use playback_event_parsers::*;
18use playback_json::*;
19
20#[derive(Debug, Clone, PartialEq, Serialize)]
21pub struct CapturedStatsFrame<Modules> {
22    pub frame_number: usize,
23    pub time: f32,
24    pub dt: f32,
25    pub seconds_remaining: Option<i32>,
26    pub game_state: Option<i32>,
27    pub ball_has_been_hit: Option<bool>,
28    pub kickoff_countdown_time: Option<i32>,
29    pub gameplay_phase: GameplayPhase,
30    pub is_live_play: bool,
31    pub modules: Modules,
32}
33
34pub type StatsSnapshotFrame = CapturedStatsFrame<Map<String, Value>>;
35
36#[derive(Debug, Clone, PartialEq, Serialize)]
37pub struct CapturedStatsData<Frame> {
38    pub replay_meta: ReplayMeta,
39    pub config: Map<String, Value>,
40    pub modules: Map<String, Value>,
41    pub frames: Vec<Frame>,
42}
43
44pub type StatsSnapshotData = CapturedStatsData<StatsSnapshotFrame>;
45
46impl<Modules> CapturedStatsFrame<Modules> {
47    pub fn map_modules<Mapped, F>(
48        self,
49        transform: F,
50    ) -> SubtrActorResult<CapturedStatsFrame<Mapped>>
51    where
52        F: FnOnce(Modules) -> SubtrActorResult<Mapped>,
53    {
54        Ok(CapturedStatsFrame {
55            frame_number: self.frame_number,
56            time: self.time,
57            dt: self.dt,
58            seconds_remaining: self.seconds_remaining,
59            game_state: self.game_state,
60            ball_has_been_hit: self.ball_has_been_hit,
61            kickoff_countdown_time: self.kickoff_countdown_time,
62            gameplay_phase: self.gameplay_phase,
63            is_live_play: self.is_live_play,
64            modules: transform(self.modules)?,
65        })
66    }
67}
68
69impl CapturedStatsData<StatsSnapshotFrame> {
70    pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
71        self.to_legacy_replay_stats_timeline()
72    }
73
74    #[deprecated(
75        note = "use into_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
76    )]
77    pub fn into_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
78        self.into_legacy_replay_stats_timeline()
79    }
80
81    pub fn into_legacy_replay_stats_timeline_with_progress<F>(
82        self,
83        frame_interval: usize,
84        mut on_progress: F,
85    ) -> SubtrActorResult<ReplayStatsTimeline>
86    where
87        F: FnMut(usize, usize) -> SubtrActorResult<()>,
88    {
89        let frame_interval = frame_interval.max(1);
90        let total_frames = self.frames.len();
91        on_progress(0, total_frames)?;
92        let frames = self
93            .frames
94            .iter()
95            .enumerate()
96            .map(|(frame_index, frame)| {
97                let replay_frame = self.replay_stats_frame(frame)?;
98                let processed_frames = frame_index + 1;
99                if processed_frames == total_frames
100                    || processed_frames.is_multiple_of(frame_interval)
101                {
102                    on_progress(processed_frames, total_frames)?;
103                }
104                Ok(replay_frame)
105            })
106            .collect::<SubtrActorResult<Vec<_>>>()?;
107        self.to_replay_stats_timeline_with_frames(frames)
108    }
109
110    #[deprecated(
111        note = "use into_legacy_replay_stats_timeline_with_progress for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
112    )]
113    pub fn into_stats_timeline_with_progress<F>(
114        self,
115        frame_interval: usize,
116        on_progress: F,
117    ) -> SubtrActorResult<ReplayStatsTimeline>
118    where
119        F: FnMut(usize, usize) -> SubtrActorResult<()>,
120    {
121        self.into_legacy_replay_stats_timeline_with_progress(frame_interval, on_progress)
122    }
123
124    pub fn to_legacy_replay_stats_timeline(&self) -> SubtrActorResult<ReplayStatsTimeline> {
125        self.to_replay_stats_timeline_with_frames(
126            self.frames
127                .iter()
128                .map(|frame| self.replay_stats_frame(frame))
129                .collect::<SubtrActorResult<Vec<_>>>()?,
130        )
131    }
132
133    #[deprecated(
134        note = "use to_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
135    )]
136    pub fn to_stats_timeline(&self) -> SubtrActorResult<ReplayStatsTimeline> {
137        self.to_legacy_replay_stats_timeline()
138    }
139
140    pub(crate) fn into_replay_stats_timeline_with_frames(
141        self,
142        frames: Vec<ReplayStatsFrame>,
143    ) -> SubtrActorResult<ReplayStatsTimeline> {
144        self.to_replay_stats_timeline_with_frames(frames)
145    }
146
147    fn to_replay_stats_timeline_with_frames(
148        &self,
149        frames: Vec<ReplayStatsFrame>,
150    ) -> SubtrActorResult<ReplayStatsTimeline> {
151        Ok(ReplayStatsTimeline {
152            config: self.timeline_config(),
153            replay_meta: self.replay_meta.clone(),
154            events: self.timeline_event_sets_typed()?,
155            frames,
156        })
157    }
158
159    pub fn into_legacy_stats_timeline_value(self) -> SubtrActorResult<Value> {
160        self.to_legacy_stats_timeline_value()
161    }
162
163    #[deprecated(
164        note = "use into_legacy_stats_timeline_value for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
165    )]
166    pub fn into_stats_timeline_value(self) -> SubtrActorResult<Value> {
167        self.into_legacy_stats_timeline_value()
168    }
169
170    pub fn to_legacy_stats_timeline_value(&self) -> SubtrActorResult<Value> {
171        let mut timeline = Map::new();
172        timeline.insert("config".to_owned(), self.timeline_config_value()?);
173        timeline.insert(
174            "replay_meta".to_owned(),
175            serialize_to_json_value(&self.replay_meta)?,
176        );
177        timeline.insert("events".to_owned(), self.timeline_event_sets_value()?);
178        timeline.insert(
179            "frames".to_owned(),
180            Value::Array(
181                self.frames
182                    .iter()
183                    .map(|frame| self.timeline_frame_value(frame))
184                    .collect::<SubtrActorResult<Vec<_>>>()?,
185            ),
186        );
187        Ok(Value::Object(timeline))
188    }
189
190    #[deprecated(
191        note = "use to_legacy_stats_timeline_value for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
192    )]
193    pub fn to_stats_timeline_value(&self) -> SubtrActorResult<Value> {
194        self.to_legacy_stats_timeline_value()
195    }
196
197    fn timeline_config(&self) -> StatsTimelineConfig {
198        let positioning_config = self.config.get("positioning").and_then(Value::as_object);
199        let ball_half_config = self.config.get("ball_half").and_then(Value::as_object);
200        let territorial_pressure_config = self
201            .config
202            .get("territorial_pressure")
203            .and_then(Value::as_object);
204        let territorial_pressure_defaults = TerritorialPressureCalculatorConfig::default();
205        let rotation_config = self.config.get("rotation").and_then(Value::as_object);
206        let rotation_defaults = RotationCalculatorConfig::default();
207        let rush_config = self.config.get("rush").and_then(Value::as_object);
208        let rush_defaults = RushCalculatorConfig::default();
209        let aerial_goal_config = self.config.get("aerial_goal").and_then(Value::as_object);
210        let high_aerial_goal_config = self
211            .config
212            .get("high_aerial_goal")
213            .and_then(Value::as_object);
214        let long_distance_goal_config = self
215            .config
216            .get("long_distance_goal")
217            .and_then(Value::as_object);
218        let own_half_goal_config = self.config.get("own_half_goal").and_then(Value::as_object);
219        let empty_net_goal_config = self.config.get("empty_net_goal").and_then(Value::as_object);
220        let flick_goal_config = self.config.get("flick_goal").and_then(Value::as_object);
221        let ceiling_shot_goal_config = self
222            .config
223            .get("ceiling_shot_goal")
224            .and_then(Value::as_object);
225        let double_tap_goal_config = self
226            .config
227            .get("double_tap_goal")
228            .and_then(Value::as_object);
229        let one_timer_goal_config = self.config.get("one_timer_goal").and_then(Value::as_object);
230        let air_dribble_goal_config = self
231            .config
232            .get("air_dribble_goal")
233            .and_then(Value::as_object);
234        let flip_reset_goal_config = self
235            .config
236            .get("flip_reset_goal")
237            .and_then(Value::as_object);
238        let flip_into_ball_goal_config = self
239            .config
240            .get("flip_into_ball_goal")
241            .and_then(Value::as_object);
242        let bump_goal_config = self.config.get("bump_goal").and_then(Value::as_object);
243        let demo_goal_config = self.config.get("demo_goal").and_then(Value::as_object);
244        let half_volley_config = self.config.get("half_volley").and_then(Value::as_object);
245        let half_volley_goal_config = self
246            .config
247            .get("half_volley_goal")
248            .and_then(Value::as_object);
249
250        StatsTimelineConfig {
251            most_back_forward_threshold_y: positioning_config
252                .and_then(|config| config.get("most_back_forward_threshold_y"))
253                .and_then(json_f32)
254                .unwrap_or(PositioningCalculatorConfig::default().most_back_forward_threshold_y),
255            level_ball_depth_margin: positioning_config
256                .and_then(|config| config.get("level_ball_depth_margin"))
257                .and_then(json_f32)
258                .unwrap_or(PositioningCalculatorConfig::default().level_ball_depth_margin),
259            closest_to_ball_switch_margin: positioning_config
260                .and_then(|config| config.get("closest_to_ball_switch_margin"))
261                .and_then(json_f32)
262                .unwrap_or(PositioningCalculatorConfig::default().closest_to_ball_switch_margin),
263            closest_to_ball_switch_min_seconds: positioning_config
264                .and_then(|config| config.get("closest_to_ball_switch_min_seconds"))
265                .and_then(json_f32)
266                .unwrap_or(
267                    PositioningCalculatorConfig::default().closest_to_ball_switch_min_seconds,
268                ),
269            ball_half_neutral_zone_half_width_y: ball_half_config
270                .and_then(|config| config.get("ball_half_neutral_zone_half_width_y"))
271                .and_then(json_f32)
272                .unwrap_or(BallHalfCalculatorConfig::default().neutral_zone_half_width_y),
273            territorial_pressure_neutral_zone_half_width_y: territorial_pressure_config
274                .and_then(|config| config.get("territorial_pressure_neutral_zone_half_width_y"))
275                .and_then(json_f32)
276                .unwrap_or(territorial_pressure_defaults.neutral_zone_half_width_y),
277            territorial_pressure_min_establish_seconds: territorial_pressure_config
278                .and_then(|config| config.get("territorial_pressure_min_establish_seconds"))
279                .and_then(json_f32)
280                .unwrap_or(territorial_pressure_defaults.min_establish_seconds),
281            territorial_pressure_min_establish_third_seconds: territorial_pressure_config
282                .and_then(|config| config.get("territorial_pressure_min_establish_third_seconds"))
283                .and_then(json_f32)
284                .unwrap_or(territorial_pressure_defaults.min_establish_third_seconds),
285            territorial_pressure_relief_grace_seconds: territorial_pressure_config
286                .and_then(|config| config.get("territorial_pressure_relief_grace_seconds"))
287                .and_then(json_f32)
288                .unwrap_or(territorial_pressure_defaults.relief_grace_seconds),
289            territorial_pressure_confirmed_relief_grace_seconds: territorial_pressure_config
290                .and_then(|config| {
291                    config.get("territorial_pressure_confirmed_relief_grace_seconds")
292                })
293                .and_then(json_f32)
294                .unwrap_or(territorial_pressure_defaults.confirmed_relief_grace_seconds),
295            rotation_role_depth_margin: rotation_config
296                .and_then(|config| config.get("role_depth_margin"))
297                .and_then(json_f32)
298                .unwrap_or(rotation_defaults.role_depth_margin),
299            rotation_first_man_ambiguity_margin: rotation_config
300                .and_then(|config| config.get("first_man_ambiguity_margin"))
301                .and_then(json_f32)
302                .unwrap_or(rotation_defaults.first_man_ambiguity_margin),
303            rotation_first_man_debounce_seconds: rotation_config
304                .and_then(|config| config.get("first_man_debounce_seconds"))
305                .and_then(json_f32)
306                .unwrap_or(rotation_defaults.first_man_debounce_seconds),
307            rotation_first_man_stint_end_grace_seconds: rotation_config
308                .and_then(|config| config.get("first_man_stint_end_grace_seconds"))
309                .and_then(json_f32)
310                .unwrap_or(rotation_defaults.first_man_stint_end_grace_seconds),
311            rush_max_start_y: rush_config
312                .and_then(|config| config.get("rush_max_start_y"))
313                .and_then(json_f32)
314                .unwrap_or(rush_defaults.max_start_y),
315            rush_attack_support_distance_y: rush_config
316                .and_then(|config| config.get("rush_attack_support_distance_y"))
317                .and_then(json_f32)
318                .unwrap_or(rush_defaults.attack_support_distance_y),
319            rush_defender_distance_y: rush_config
320                .and_then(|config| config.get("rush_defender_distance_y"))
321                .and_then(json_f32)
322                .unwrap_or(rush_defaults.defender_distance_y),
323            rush_min_possession_retained_seconds: rush_config
324                .and_then(|config| config.get("rush_min_possession_retained_seconds"))
325                .and_then(json_f32)
326                .unwrap_or(rush_defaults.min_possession_retained_seconds),
327            aerial_goal_min_ball_z: aerial_goal_config
328                .and_then(|config| config.get("aerial_goal_min_ball_z"))
329                .and_then(json_f32)
330                .unwrap_or(AerialGoalCalculatorConfig::default().min_ball_z),
331            high_aerial_goal_min_ball_z: high_aerial_goal_config
332                .and_then(|config| config.get("high_aerial_goal_min_ball_z"))
333                .and_then(json_f32)
334                .unwrap_or(HighAerialGoalCalculatorConfig::default().min_ball_z),
335            long_distance_goal_max_attacking_y: long_distance_goal_config
336                .and_then(|config| config.get("long_distance_goal_max_attacking_y"))
337                .and_then(json_f32)
338                .unwrap_or(LongDistanceGoalCalculatorConfig::default().max_attacking_y),
339            own_half_goal_max_attacking_y: own_half_goal_config
340                .and_then(|config| config.get("own_half_goal_max_attacking_y"))
341                .and_then(json_f32)
342                .unwrap_or(OwnHalfGoalCalculatorConfig::default().max_attacking_y),
343            empty_net_min_defender_y_margin: empty_net_goal_config
344                .and_then(|config| config.get("empty_net_min_defender_y_margin"))
345                .and_then(json_f32)
346                .unwrap_or(EmptyNetGoalCalculatorConfig::default().min_defender_y_margin),
347            empty_net_min_defender_distance: empty_net_goal_config
348                .and_then(|config| config.get("empty_net_min_defender_distance"))
349                .and_then(json_f32)
350                .unwrap_or(EmptyNetGoalCalculatorConfig::default().min_defender_distance),
351            empty_net_max_touch_attacking_y: empty_net_goal_config
352                .and_then(|config| config.get("empty_net_max_touch_attacking_y"))
353                .and_then(json_f32)
354                .unwrap_or(EmptyNetGoalCalculatorConfig::default().max_touch_attacking_y),
355            flick_goal_max_event_to_goal_seconds: json_config_f32(
356                flick_goal_config,
357                "flick_goal_max_event_to_goal_seconds",
358                "flick_goal_max_event_to_touch_seconds",
359            )
360            .unwrap_or(FlickGoalCalculatorConfig::default().max_event_to_goal_seconds),
361            ceiling_shot_goal_max_event_to_goal_seconds: json_config_f32(
362                ceiling_shot_goal_config,
363                "ceiling_shot_goal_max_event_to_goal_seconds",
364                "ceiling_shot_goal_max_event_to_touch_seconds",
365            )
366            .unwrap_or(CeilingShotGoalCalculatorConfig::default().max_event_to_goal_seconds),
367            double_tap_goal_max_event_to_goal_seconds: json_config_f32(
368                double_tap_goal_config,
369                "double_tap_goal_max_event_to_goal_seconds",
370                "double_tap_goal_max_event_to_touch_seconds",
371            )
372            .unwrap_or(DoubleTapGoalCalculatorConfig::default().max_event_to_goal_seconds),
373            one_timer_goal_max_event_to_goal_seconds: json_config_f32(
374                one_timer_goal_config,
375                "one_timer_goal_max_event_to_goal_seconds",
376                "one_timer_goal_max_event_to_touch_seconds",
377            )
378            .unwrap_or(OneTimerGoalCalculatorConfig::default().max_event_to_goal_seconds),
379            air_dribble_goal_max_end_to_goal_seconds: json_config_f32(
380                air_dribble_goal_config,
381                "air_dribble_goal_max_end_to_goal_seconds",
382                "air_dribble_goal_max_end_to_touch_seconds",
383            )
384            .unwrap_or(AirDribbleGoalCalculatorConfig::default().max_end_to_goal_seconds),
385            flip_reset_goal_max_event_to_goal_seconds: json_config_f32(
386                flip_reset_goal_config,
387                "flip_reset_goal_max_event_to_goal_seconds",
388                "flip_reset_goal_max_event_to_touch_seconds",
389            )
390            .unwrap_or(FlipResetGoalCalculatorConfig::default().max_event_to_goal_seconds),
391            flip_into_ball_goal_max_touch_to_goal_seconds: flip_into_ball_goal_config
392                .and_then(|config| config.get("flip_into_ball_goal_max_touch_to_goal_seconds"))
393                .and_then(json_f32)
394                .unwrap_or(FlipIntoBallGoalCalculatorConfig::default().max_touch_to_goal_seconds),
395            bump_goal_max_event_to_goal_seconds: json_config_f32(
396                bump_goal_config,
397                "bump_goal_max_event_to_goal_seconds",
398                "bump_goal_max_event_to_touch_seconds",
399            )
400            .unwrap_or(BumpGoalCalculatorConfig::default().max_event_to_goal_seconds),
401            demo_goal_max_event_to_goal_seconds: json_config_f32(
402                demo_goal_config,
403                "demo_goal_max_event_to_goal_seconds",
404                "demo_goal_max_event_to_touch_seconds",
405            )
406            .unwrap_or(DemoGoalCalculatorConfig::default().max_event_to_goal_seconds),
407            half_volley_max_bounce_to_touch_seconds: half_volley_config
408                .and_then(|config| config.get("half_volley_max_bounce_to_touch_seconds"))
409                .and_then(json_f32)
410                .unwrap_or(HalfVolleyCalculatorConfig::default().max_bounce_to_touch_seconds),
411            half_volley_min_ball_speed: half_volley_config
412                .and_then(|config| config.get("half_volley_min_ball_speed"))
413                .and_then(json_f32)
414                .unwrap_or(HalfVolleyCalculatorConfig::default().min_ball_speed),
415            half_volley_goal_max_touch_to_goal_seconds: half_volley_goal_config
416                .and_then(|config| config.get("half_volley_goal_max_touch_to_goal_seconds"))
417                .and_then(json_f32)
418                .unwrap_or(HalfVolleyGoalCalculatorConfig::default().max_touch_to_goal_seconds),
419            half_volley_goal_min_goal_alignment: half_volley_goal_config
420                .and_then(|config| config.get("half_volley_goal_min_goal_alignment"))
421                .and_then(json_f32)
422                .unwrap_or(HalfVolleyGoalCalculatorConfig::default().min_goal_alignment),
423        }
424    }
425
426    fn timeline_config_value(&self) -> SubtrActorResult<Value> {
427        let positioning_config = self.config.get("positioning").and_then(Value::as_object);
428        let ball_half_config = self.config.get("ball_half").and_then(Value::as_object);
429        let territorial_pressure_config = self
430            .config
431            .get("territorial_pressure")
432            .and_then(Value::as_object);
433        let rotation_config = self.config.get("rotation").and_then(Value::as_object);
434        let rush_config = self.config.get("rush").and_then(Value::as_object);
435        let aerial_goal_config = self.config.get("aerial_goal").and_then(Value::as_object);
436        let high_aerial_goal_config = self
437            .config
438            .get("high_aerial_goal")
439            .and_then(Value::as_object);
440        let long_distance_goal_config = self
441            .config
442            .get("long_distance_goal")
443            .and_then(Value::as_object);
444        let own_half_goal_config = self.config.get("own_half_goal").and_then(Value::as_object);
445        let empty_net_goal_config = self.config.get("empty_net_goal").and_then(Value::as_object);
446        let flick_goal_config = self.config.get("flick_goal").and_then(Value::as_object);
447        let double_tap_goal_config = self
448            .config
449            .get("double_tap_goal")
450            .and_then(Value::as_object);
451        let one_timer_goal_config = self.config.get("one_timer_goal").and_then(Value::as_object);
452        let air_dribble_goal_config = self
453            .config
454            .get("air_dribble_goal")
455            .and_then(Value::as_object);
456        let flip_reset_goal_config = self
457            .config
458            .get("flip_reset_goal")
459            .and_then(Value::as_object);
460        let flip_into_ball_goal_config = self
461            .config
462            .get("flip_into_ball_goal")
463            .and_then(Value::as_object);
464        let bump_goal_config = self.config.get("bump_goal").and_then(Value::as_object);
465        let demo_goal_config = self.config.get("demo_goal").and_then(Value::as_object);
466        let half_volley_config = self.config.get("half_volley").and_then(Value::as_object);
467        let half_volley_goal_config = self
468            .config
469            .get("half_volley_goal")
470            .and_then(Value::as_object);
471
472        let mut config = Map::new();
473        config.insert(
474            "most_back_forward_threshold_y".to_owned(),
475            serialize_to_json_value(
476                &positioning_config
477                    .and_then(|config| config.get("most_back_forward_threshold_y"))
478                    .and_then(Value::as_f64)
479                    .unwrap_or(
480                        PositioningCalculatorConfig::default().most_back_forward_threshold_y as f64,
481                    ),
482            )?,
483        );
484        config.insert(
485            "level_ball_depth_margin".to_owned(),
486            serialize_to_json_value(
487                &positioning_config
488                    .and_then(|config| config.get("level_ball_depth_margin"))
489                    .and_then(Value::as_f64)
490                    .unwrap_or(
491                        PositioningCalculatorConfig::default().level_ball_depth_margin as f64,
492                    ),
493            )?,
494        );
495        config.insert(
496            "closest_to_ball_switch_margin".to_owned(),
497            serialize_to_json_value(
498                &positioning_config
499                    .and_then(|config| config.get("closest_to_ball_switch_margin"))
500                    .and_then(Value::as_f64)
501                    .unwrap_or(
502                        PositioningCalculatorConfig::default().closest_to_ball_switch_margin as f64,
503                    ),
504            )?,
505        );
506        config.insert(
507            "closest_to_ball_switch_min_seconds".to_owned(),
508            serialize_to_json_value(
509                &positioning_config
510                    .and_then(|config| config.get("closest_to_ball_switch_min_seconds"))
511                    .and_then(Value::as_f64)
512                    .unwrap_or(
513                        PositioningCalculatorConfig::default().closest_to_ball_switch_min_seconds
514                            as f64,
515                    ),
516            )?,
517        );
518        config.insert(
519            "ball_half_neutral_zone_half_width_y".to_owned(),
520            serialize_to_json_value(
521                &ball_half_config
522                    .and_then(|config| config.get("ball_half_neutral_zone_half_width_y"))
523                    .and_then(Value::as_f64)
524                    .unwrap_or(
525                        BallHalfCalculatorConfig::default().neutral_zone_half_width_y as f64,
526                    ),
527            )?,
528        );
529        let territorial_pressure_defaults = TerritorialPressureCalculatorConfig::default();
530        for (key, default_value) in [
531            (
532                "territorial_pressure_neutral_zone_half_width_y",
533                territorial_pressure_defaults.neutral_zone_half_width_y,
534            ),
535            (
536                "territorial_pressure_min_establish_seconds",
537                territorial_pressure_defaults.min_establish_seconds,
538            ),
539            (
540                "territorial_pressure_min_establish_third_seconds",
541                territorial_pressure_defaults.min_establish_third_seconds,
542            ),
543            (
544                "territorial_pressure_relief_grace_seconds",
545                territorial_pressure_defaults.relief_grace_seconds,
546            ),
547            (
548                "territorial_pressure_confirmed_relief_grace_seconds",
549                territorial_pressure_defaults.confirmed_relief_grace_seconds,
550            ),
551        ] {
552            config.insert(
553                key.to_owned(),
554                serialize_to_json_value(
555                    &territorial_pressure_config
556                        .and_then(|config| config.get(key))
557                        .and_then(Value::as_f64)
558                        .unwrap_or(default_value as f64),
559                )?,
560            );
561        }
562        let rotation_defaults = RotationCalculatorConfig::default();
563        for (key, default_value) in [
564            (
565                "rotation_role_depth_margin",
566                rotation_defaults.role_depth_margin,
567            ),
568            (
569                "rotation_first_man_ambiguity_margin",
570                rotation_defaults.first_man_ambiguity_margin,
571            ),
572            (
573                "rotation_first_man_debounce_seconds",
574                rotation_defaults.first_man_debounce_seconds,
575            ),
576            (
577                "rotation_first_man_stint_end_grace_seconds",
578                rotation_defaults.first_man_stint_end_grace_seconds,
579            ),
580        ] {
581            let source_key = key.strip_prefix("rotation_").unwrap_or(key);
582            config.insert(
583                key.to_owned(),
584                serialize_to_json_value(
585                    &rotation_config
586                        .and_then(|config| config.get(source_key))
587                        .and_then(Value::as_f64)
588                        .unwrap_or(default_value as f64),
589                )?,
590            );
591        }
592        let rush_defaults = RushCalculatorConfig::default();
593        config.insert(
594            "rush_max_start_y".to_owned(),
595            serialize_to_json_value(
596                &rush_config
597                    .and_then(|config| config.get("rush_max_start_y"))
598                    .and_then(Value::as_f64)
599                    .unwrap_or(rush_defaults.max_start_y as f64),
600            )?,
601        );
602        config.insert(
603            "rush_attack_support_distance_y".to_owned(),
604            serialize_to_json_value(
605                &rush_config
606                    .and_then(|config| config.get("rush_attack_support_distance_y"))
607                    .and_then(Value::as_f64)
608                    .unwrap_or(rush_defaults.attack_support_distance_y as f64),
609            )?,
610        );
611        config.insert(
612            "rush_defender_distance_y".to_owned(),
613            serialize_to_json_value(
614                &rush_config
615                    .and_then(|config| config.get("rush_defender_distance_y"))
616                    .and_then(Value::as_f64)
617                    .unwrap_or(rush_defaults.defender_distance_y as f64),
618            )?,
619        );
620        config.insert(
621            "rush_min_possession_retained_seconds".to_owned(),
622            serialize_to_json_value(
623                &rush_config
624                    .and_then(|config| config.get("rush_min_possession_retained_seconds"))
625                    .and_then(Value::as_f64)
626                    .unwrap_or(rush_defaults.min_possession_retained_seconds as f64),
627            )?,
628        );
629        for (module_config, key, default_value) in [
630            (
631                aerial_goal_config,
632                "aerial_goal_min_ball_z",
633                AerialGoalCalculatorConfig::default().min_ball_z,
634            ),
635            (
636                high_aerial_goal_config,
637                "high_aerial_goal_min_ball_z",
638                HighAerialGoalCalculatorConfig::default().min_ball_z,
639            ),
640            (
641                long_distance_goal_config,
642                "long_distance_goal_max_attacking_y",
643                LongDistanceGoalCalculatorConfig::default().max_attacking_y,
644            ),
645            (
646                own_half_goal_config,
647                "own_half_goal_max_attacking_y",
648                OwnHalfGoalCalculatorConfig::default().max_attacking_y,
649            ),
650            (
651                empty_net_goal_config,
652                "empty_net_min_defender_y_margin",
653                EmptyNetGoalCalculatorConfig::default().min_defender_y_margin,
654            ),
655            (
656                empty_net_goal_config,
657                "empty_net_min_defender_distance",
658                EmptyNetGoalCalculatorConfig::default().min_defender_distance,
659            ),
660            (
661                empty_net_goal_config,
662                "empty_net_max_touch_attacking_y",
663                EmptyNetGoalCalculatorConfig::default().max_touch_attacking_y,
664            ),
665            (
666                flick_goal_config,
667                "flick_goal_max_event_to_goal_seconds",
668                FlickGoalCalculatorConfig::default().max_event_to_goal_seconds,
669            ),
670            (
671                double_tap_goal_config,
672                "double_tap_goal_max_event_to_goal_seconds",
673                DoubleTapGoalCalculatorConfig::default().max_event_to_goal_seconds,
674            ),
675            (
676                one_timer_goal_config,
677                "one_timer_goal_max_event_to_goal_seconds",
678                OneTimerGoalCalculatorConfig::default().max_event_to_goal_seconds,
679            ),
680            (
681                air_dribble_goal_config,
682                "air_dribble_goal_max_end_to_goal_seconds",
683                AirDribbleGoalCalculatorConfig::default().max_end_to_goal_seconds,
684            ),
685            (
686                flip_reset_goal_config,
687                "flip_reset_goal_max_event_to_goal_seconds",
688                FlipResetGoalCalculatorConfig::default().max_event_to_goal_seconds,
689            ),
690            (
691                flip_into_ball_goal_config,
692                "flip_into_ball_goal_max_touch_to_goal_seconds",
693                FlipIntoBallGoalCalculatorConfig::default().max_touch_to_goal_seconds,
694            ),
695            (
696                bump_goal_config,
697                "bump_goal_max_event_to_goal_seconds",
698                BumpGoalCalculatorConfig::default().max_event_to_goal_seconds,
699            ),
700            (
701                demo_goal_config,
702                "demo_goal_max_event_to_goal_seconds",
703                DemoGoalCalculatorConfig::default().max_event_to_goal_seconds,
704            ),
705            (
706                half_volley_config,
707                "half_volley_max_bounce_to_touch_seconds",
708                HalfVolleyCalculatorConfig::default().max_bounce_to_touch_seconds,
709            ),
710            (
711                half_volley_config,
712                "half_volley_min_ball_speed",
713                HalfVolleyCalculatorConfig::default().min_ball_speed,
714            ),
715            (
716                half_volley_goal_config,
717                "half_volley_goal_max_touch_to_goal_seconds",
718                HalfVolleyGoalCalculatorConfig::default().max_touch_to_goal_seconds,
719            ),
720            (
721                half_volley_goal_config,
722                "half_volley_goal_min_goal_alignment",
723                HalfVolleyGoalCalculatorConfig::default().min_goal_alignment,
724            ),
725        ] {
726            config.insert(
727                key.to_owned(),
728                serialize_to_json_value(
729                    &module_config
730                        .and_then(|config| config.get(key))
731                        .and_then(Value::as_f64)
732                        .unwrap_or(default_value as f64),
733                )?,
734            );
735        }
736        Ok(Value::Object(config))
737    }
738}
739
740impl CapturedStatsData<ReplayStatsFrame> {
741    pub fn into_legacy_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
742        let CapturedStatsData {
743            replay_meta,
744            config,
745            modules,
746            frames,
747        } = self;
748        CapturedStatsData::<StatsSnapshotFrame> {
749            replay_meta,
750            config,
751            modules,
752            frames: Vec::new(),
753        }
754        .into_replay_stats_timeline_with_frames(frames)
755    }
756
757    #[deprecated(
758        note = "use into_legacy_replay_stats_timeline for full partial-sum snapshots, or StatsTimelineEventCollector for compact event-backed timelines"
759    )]
760    pub fn into_replay_stats_timeline(self) -> SubtrActorResult<ReplayStatsTimeline> {
761        self.into_legacy_replay_stats_timeline()
762    }
763}