subtr-actor 1.0.0

Rocket League replay transformer
Documentation
use super::*;
use crate::collector::ndarray::traits::{
    dynamic_analysis_feature_adder, dynamic_analysis_player_feature_adder,
};
use crate::stats::analysis_graph::{AnalysisNode, AnalysisStateContext};
use crate::{Collector, FrameRateDecorator};
use std::path::Path;

const NDARRAY_ANALYSIS_FIXTURE: &str = "assets/post-eac-ranked-duel-2026-04-28-a.replay";

#[derive(Default)]
struct SharedAnalysisState {
    evaluations: usize,
}

#[derive(Default)]
struct SharedAnalysisNode {
    state: SharedAnalysisState,
}

impl AnalysisNode for SharedAnalysisNode {
    type State = SharedAnalysisState;

    fn name(&self) -> &'static str {
        "ndarray_test_shared_analysis"
    }

    fn dependencies(&self) -> Vec<AnalysisDependency> {
        vec![AnalysisDependency::required::<FrameInput>()]
    }

    fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
        let _frame_input = ctx.get::<FrameInput>()?;
        self.state.evaluations += 1;
        Ok(())
    }

    fn state(&self) -> &Self::State {
        &self.state
    }
}

fn boxed_shared_analysis_node() -> Box<dyn crate::stats::analysis_graph::AnalysisNodeDyn> {
    Box::<SharedAnalysisNode>::default()
}

fn shared_analysis_dependency() -> Vec<AnalysisDependency> {
    vec![AnalysisDependency::with_default::<SharedAnalysisState>(
        boxed_shared_analysis_node,
    )]
}

build_analysis_global_feature_adder!(
    MacroSharedAnalysisCount,
    |_self_: &MacroSharedAnalysisCount<F>| shared_analysis_dependency(),
    |_self_: &MacroSharedAnalysisCount<F>,
     context: &AnalysisFeatureContext<'_>,
     _processor: &dyn ProcessorView,
     _frame: &boxcars::Frame,
     _frame_count: usize,
     _current_time: f32| {
        let state = context.state::<SharedAnalysisState>()?;
        convert_all_floats!(state.evaluations as f32)
    },
    "macro shared analysis count",
);

fn parse_replay(path: &str) -> boxcars::Replay {
    let replay_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(path);
    let data = std::fs::read(&replay_path)
        .unwrap_or_else(|_| panic!("Failed to read replay file: {}", replay_path.display()));
    boxcars::ParserBuilder::new(&data[..])
        .always_check_crc()
        .must_parse_network_data()
        .parse()
        .unwrap_or_else(|_| panic!("Failed to parse replay: {}", replay_path.display()))
}

#[test]
fn analysis_feature_adders_share_one_graph_evaluation_per_ndarray_row() {
    static DYNAMIC_HEADERS: [&str; 1] = ["dynamic shared analysis count"];
    static PLAYER_HEADERS: [&str; 1] = ["player shared analysis count"];

    let replay = parse_replay(NDARRAY_ANALYSIS_FIXTURE);
    let mut collector = NDArrayCollector::<f32>::new(
        vec![
            NDArrayFeatureAdder::analysis(MacroSharedAnalysisCount::arc_new()),
            NDArrayFeatureAdder::analysis(dynamic_analysis_feature_adder(
                &DYNAMIC_HEADERS,
                shared_analysis_dependency(),
                |context, _processor, _frame, _frame_count, _current_time| {
                    let state = context.state::<SharedAnalysisState>()?;
                    Ok([state.evaluations as f32])
                },
            )),
        ],
        vec![NDArrayPlayerFeatureAdder::analysis(
            dynamic_analysis_player_feature_adder(
                &PLAYER_HEADERS,
                shared_analysis_dependency(),
                |context, _player_id, _processor, _frame, _frame_count, _current_time| {
                    let state = context.state::<SharedAnalysisState>()?;
                    Ok([state.evaluations as f32])
                },
            ),
        )],
    );
    FrameRateDecorator::new_from_fps(30.0, &mut collector)
        .process_replay(&replay)
        .expect("collector should process replay");

    let (meta, ndarray) = collector
        .get_meta_and_ndarray()
        .expect("collector should produce ndarray");

    assert_eq!(
        meta.column_headers.global_headers,
        vec![
            "macro shared analysis count".to_owned(),
            "dynamic shared analysis count".to_owned()
        ]
    );
    assert_eq!(
        meta.column_headers.player_headers,
        vec!["player shared analysis count".to_owned()]
    );
    assert!(ndarray.nrows() > 5);
    assert_eq!(ndarray.ncols(), 2 + meta.replay_meta.player_count());

    for (row_index, row) in ndarray.outer_iter().take(5).enumerate() {
        let expected = (row_index + 1) as f32;
        assert_eq!(row[0], expected);
        assert_eq!(row[1], expected);
        for player_column in 2..row.len() {
            assert_eq!(row[player_column], expected);
        }
    }
}

#[test]
fn string_feature_names_can_create_analysis_backed_touch_adders() {
    let replay = parse_replay(NDARRAY_ANALYSIS_FIXTURE);
    let mut collector = NDArrayCollector::<f32>::from_strings(&[], &["PlayerEvent:touch"])
        .expect("analysis-backed feature names should resolve");
    FrameRateDecorator::new_from_fps(30.0, &mut collector)
        .process_replay(&replay)
        .expect("collector should process replay");

    let (meta, ndarray) = collector
        .get_meta_and_ndarray()
        .expect("collector should produce ndarray");

    assert_eq!(meta.column_headers.global_headers, Vec::<String>::new());
    assert_eq!(
        meta.column_headers.player_headers,
        vec!["analysis touch event".to_owned()]
    );
    assert!(ndarray.nrows() > 5);
    assert_eq!(ndarray.ncols(), meta.replay_meta.player_count());

    let mut touch_event_count = 0usize;
    for value in ndarray.iter().copied() {
        assert!(
            value == 0.0 || value == 1.0,
            "player touch event feature should be binary"
        );
        if value == 1.0 {
            touch_event_count += 1;
        }
    }
    assert!(
        touch_event_count > 0,
        "fixture should produce at least one string-created player touch event"
    );
}

#[test]
fn player_event_names_create_registered_analysis_indicators() {
    let event_names = [
        "PlayerEvent:touch",
        "PlayerEvent:center",
        "PlayerEvent:double_tap",
        "PlayerEvent:one_timer",
        "PlayerEvent:wall_aerial",
        "PlayerEvent:wall_aerial_shot",
        "PlayerEvent:ceiling_shot",
        "PlayerEvent:flick",
        "PlayerEvent:musty_flick",
        "PlayerEvent:dodge_reset",
        "PlayerEvent:flip_reset_dodge",
        "PlayerEvent:half_flip",
        "PlayerEvent:half_volley",
        "PlayerEvent:wavedash",
        "PlayerEvent:whiff",
        "PlayerEvent:speed_flip",
        "PlayerEvent:dodge",
        "PlayerEvent:powerslide",
        "PlayerEvent:ball_carry",
        "PlayerEvent:boost_pickup",
        "PlayerEvent:boost_respawn",
        "PlayerEvent:bump",
        "PlayerEvent:pass",
        "PlayerEvent:rotation",
        "PlayerEvent:movement",
        "PlayerEvent:positioning",
    ];
    let collector = NDArrayCollector::<f32>::from_strings(&[], &event_names)
        .expect("registered event feature names should resolve");
    let headers = collector.get_column_headers();

    assert_eq!(headers.global_headers, Vec::<String>::new());
    assert_eq!(headers.player_headers.len(), event_names.len());
    assert_eq!(headers.player_headers[0], "analysis touch event");
    assert_eq!(
        headers.player_headers.last(),
        Some(&"analysis positioning event".to_owned())
    );
}