Skip to main content

subtr_actor/stats/analysis_graph/
mod.rs

1//! The analysis-graph runtime: a dependency DAG of [`AnalysisNode`]s that turn
2//! raw replay frames into derived state, gameplay events, and stats.
3//!
4//! # How it works
5//!
6//! Each node implements [`AnalysisNode`]: it declares the upstream state it
7//! needs via [`dependencies`](AnalysisNode::dependencies), reads that state
8//! through an [`AnalysisStateContext`] each frame in
9//! [`evaluate`](AnalysisNode::evaluate), and exposes its own typed
10//! [`state`](AnalysisNode::state) for downstream nodes. Source nodes read the
11//! per-frame `FrameInput`; higher-level nodes build
12//! on their outputs. The graph topologically resolves dependencies, so adding a
13//! node automatically pulls in everything it needs.
14//!
15//! Most nodes are thin wrappers around a *calculator* (see [`crate::stats`]);
16//! the node handles graph plumbing while the calculator holds the detection
17//! logic.
18//!
19//! # Building a graph
20//!
21//! - [`AnalysisGraph::new`] + [`with_node`](AnalysisGraph::with_node) /
22//!   [`push_node`](AnalysisGraph::push_node) to assemble nodes by hand.
23//! - [`graph_with_builtin_analysis_nodes`] / [`graph_with_all_analysis_nodes`]
24//!   to build from the built-in registry by name.
25//! - [`collect_builtin_analysis_graph_for_replay`] to build *and* run a graph
26//!   over a replay in one call.
27//!
28//! The names accepted by the registry are listed in
29//! [`BUILTIN_ANALYSIS_NODE_NAMES`] (with aliases in
30//! [`BUILTIN_ANALYSIS_NODE_ALIASES`]).
31//!
32//! # The nodes
33//!
34//! All node types are re-exported from this module; their first-line summaries
35//! appear in the item list below, and the [`AnalysisNode`] *Implementors* list
36//! is another way to browse them. By role:
37//!
38//! - **Per-frame source state** — [`FrameInfoNode`], [`GameplayStateNode`],
39//!   [`BallFrameStateNode`], [`PlayerFrameStateNode`], [`FrameEventsStateNode`],
40//!   [`LivePlayNode`], [`SettingsNode`].
41//! - **Shared derived state** — [`TouchStateNode`], [`PossessionStateNode`],
42//!   [`PlayerPossessionNode`], [`PossessionNode`], [`BallHalfNode`],
43//!   [`PlayerVerticalStateNode`], [`PositioningNode`], [`RotationNode`],
44//!   [`BackboardBounceStateNode`], [`FiftyFiftyStateNode`],
45//!   [`ContinuousBallControlNode`].
46//! - **Mechanic detection** — [`FlickNode`], [`HalfFlipNode`],
47//!   [`SpeedFlipNode`], [`WavedashNode`], [`PowerslideNode`],
48//!   [`FlipImpulseNode`], [`DodgeResetNode`], [`WallAerialNode`],
49//!   [`WallAerialShotNode`], [`CeilingShotNode`], [`DoubleTapNode`],
50//!   [`HalfVolleyNode`], [`OneTimerNode`], [`BallCarryNode`] (carries/air
51//!   dribbles).
52//! - **Play & contest detection** — [`TouchNode`], [`PassNode`], [`CenterNode`],
53//!   [`KickoffNode`], [`BumpNode`], [`DemoNode`], [`RushNode`],
54//!   [`ControlledPlayNode`], [`TerritorialPressureNode`], [`WhiffNode`],
55//!   [`FiftyFiftyNode`], [`BackboardNode`], [`MovementNode`], [`BoostNode`].
56//! - **Match-level & projection** — [`MatchStatsNode`], goal-tag nodes (e.g.
57//!   [`HalfVolleyGoalNode`] plus the `*_goal` registry names),
58//!   [`StatsProjectionNode`], [`StatsTimelineEventsNode`],
59//!   [`StatsTimelineFrameNode`].
60//!
61//! See the [stats-runtime guide](crate::guides::calculators_and_analysis_nodes)
62//! for the full DAG map.
63
64use std::collections::HashSet;
65use std::sync::OnceLock;
66
67use crate::Collector;
68use crate::{SubtrActorError, SubtrActorErrorVariant, SubtrActorResult};
69
70pub mod graph;
71pub use graph::{
72    AnalysisDependency, AnalysisGraph, AnalysisNode, AnalysisNodeDyn, AnalysisStateContext,
73    AnalysisStateRef,
74};
75
76#[macro_use]
77mod node_macros;
78
79mod collector;
80mod nodes;
81
82use crate::stats::calculators::FrameInput;
83#[allow(unused_imports)]
84pub use collector::AnalysisNodeCollector;
85#[allow(unused_imports)]
86pub use nodes::*;
87
88/// Constructor for a builtin analysis node.
89type BuiltinNodeCtor = fn() -> Box<dyn AnalysisNodeDyn>;
90
91/// Construct a builtin node purely from its type. Every builtin node implements
92/// [`Default`], so the registry below can be a plain list of node *types*. The
93/// name each node reports through [`AnalysisNode::name`] is the single source of
94/// truth — there is no parallel string list or `name => constructor` match to
95/// keep in sync when adding a node.
96fn boxed_node<N>() -> Box<dyn AnalysisNodeDyn>
97where
98    N: AnalysisNode + Default,
99{
100    Box::new(N::default())
101}
102
103macro_rules! builtin_analysis_nodes {
104    ($($node:ty),+ $(,)?) => {
105        /// Every builtin analysis node, as a list of types. Adding a node is one
106        /// line here plus the node module itself — no name string, no match arm.
107        const BUILTIN_ANALYSIS_NODE_CTORS: &[BuiltinNodeCtor] = &[$(boxed_node::<$node>),+];
108    };
109}
110
111builtin_analysis_nodes! {
112    FrameInfoNode,
113    GameplayStateNode,
114    BallFrameStateNode,
115    PlayerFrameStateNode,
116    FrameEventsStateNode,
117    LivePlayNode,
118    MatchStatsNode,
119    BackboardNode,
120    BackboardBounceStateNode,
121    CeilingShotNode,
122    CenterNode,
123    ControlledPlayNode,
124    ContinuousBallControlNode,
125    DoubleTapNode,
126    FiftyFiftyNode,
127    FiftyFiftyStateNode,
128    KickoffNode,
129    PlayerPossessionNode,
130    PossessionNode,
131    PossessionStateNode,
132    BallHalfNode,
133    BallThirdNode,
134    TerritorialPressureNode,
135    RotationNode,
136    RushNode,
137    TouchNode,
138    TouchStateNode,
139    WallAerialNode,
140    WallAerialShotNode,
141    WhiffNode,
142    WavedashNode,
143    FlipImpulseNode,
144    SpeedFlipNode,
145    HalfFlipNode,
146    HalfVolleyNode,
147    FlickNode,
148    AerialGoalNode,
149    HighAerialGoalNode,
150    LongDistanceGoalNode,
151    OwnHalfGoalNode,
152    EmptyNetGoalNode,
153    CounterAttackGoalNode,
154    SustainedPressureGoalNode,
155    KickoffGoalNode,
156    FlickGoalNode,
157    CeilingShotGoalNode,
158    DoubleTapGoalNode,
159    OneTimerGoalNode,
160    PassingGoalNode,
161    AirDribbleGoalNode,
162    FlipResetGoalNode,
163    FlipIntoBallGoalNode,
164    BumpGoalNode,
165    DemoGoalNode,
166    HalfVolleyGoalNode, OneTimerNode,
167    PassNode,
168    DodgeResetNode,
169    BallCarryNode,
170    BoostNode,
171    BumpNode,
172    MovementNode,
173    PositioningNode,
174    PowerslideNode,
175    PlayerVerticalStateNode,
176    DemoNode,
177    SettingsNode,
178    StatsProjectionNode,
179    StatsTimelineFrameNode,
180    StatsTimelineEventsNode,
181}
182
183/// `(name, constructor)` for every builtin node, with the name read once from a
184/// throwaway default instance. Built lazily and cached.
185fn builtin_analysis_node_registry() -> &'static [(&'static str, BuiltinNodeCtor)] {
186    static REGISTRY: OnceLock<Vec<(&'static str, BuiltinNodeCtor)>> = OnceLock::new();
187    REGISTRY.get_or_init(|| {
188        BUILTIN_ANALYSIS_NODE_CTORS
189            .iter()
190            .map(|&ctor| (ctor().name(), ctor))
191            .collect()
192    })
193}
194
195pub fn builtin_analysis_node_names() -> &'static [&'static str] {
196    static NAMES: OnceLock<Vec<&'static str>> = OnceLock::new();
197    NAMES.get_or_init(|| {
198        builtin_analysis_node_registry()
199            .iter()
200            .map(|(name, _)| *name)
201            .collect()
202    })
203}
204
205pub(crate) fn boxed_analysis_node_by_name(name: &str) -> Option<Box<dyn AnalysisNodeDyn>> {
206    builtin_analysis_node_registry()
207        .iter()
208        .find(|(candidate, _)| *candidate == name)
209        .map(|(_, ctor)| ctor())
210}
211
212pub fn graph_with_builtin_analysis_nodes<I, S>(names: I) -> SubtrActorResult<AnalysisGraph>
213where
214    I: IntoIterator<Item = S>,
215    S: AsRef<str>,
216{
217    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
218    let mut seen = HashSet::new();
219    for name in names {
220        let name = name.as_ref();
221        let node = boxed_analysis_node_by_name(name).ok_or_else(|| {
222            SubtrActorError::new(SubtrActorErrorVariant::UnknownStatsModuleName(
223                name.to_owned(),
224            ))
225        })?;
226        if !seen.insert(node.name()) {
227            continue;
228        }
229        graph.push_boxed_node(node);
230    }
231    Ok(graph)
232}
233
234pub fn collect_analysis_graph_for_replay(
235    replay: &boxcars::Replay,
236    graph: AnalysisGraph,
237) -> SubtrActorResult<AnalysisGraph> {
238    let collector = collector::AnalysisNodeCollector::new(graph).process_replay(replay)?;
239    Ok(collector.into_graph())
240}
241
242pub fn collect_builtin_analysis_graph_for_replay<I, S>(
243    replay: &boxcars::Replay,
244    names: I,
245) -> SubtrActorResult<AnalysisGraph>
246where
247    I: IntoIterator<Item = S>,
248    S: AsRef<str>,
249{
250    collect_analysis_graph_for_replay(replay, graph_with_builtin_analysis_nodes(names)?)
251}
252
253pub fn all_analysis_nodes() -> Vec<Box<dyn AnalysisNodeDyn>> {
254    builtin_analysis_node_registry()
255        .iter()
256        .map(|(_, ctor)| ctor())
257        .collect()
258}
259
260pub fn graph_with_all_analysis_nodes() -> AnalysisGraph {
261    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
262    for node in all_analysis_nodes() {
263        graph.push_boxed_node(node);
264    }
265    graph
266}
267
268#[cfg(test)]
269#[path = "module_tests.rs"]
270mod tests;