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    LoosePossessionNode,
131    PossessionNode,
132    PossessionStateNode,
133    BallHalfNode,
134    BallThirdNode,
135    TerritorialPressureNode,
136    RotationNode,
137    RushNode,
138    TouchNode,
139    TouchStateNode,
140    WallAerialNode,
141    WallAerialShotNode,
142    WhiffNode,
143    WavedashNode,
144    FlipImpulseNode,
145    SpeedFlipNode,
146    HalfFlipNode,
147    HalfVolleyNode,
148    FlickNode,
149    AerialGoalNode,
150    HighAerialGoalNode,
151    LongDistanceGoalNode,
152    OwnHalfGoalNode,
153    EmptyNetGoalNode,
154    CounterAttackGoalNode,
155    SustainedPressureGoalNode,
156    KickoffGoalNode,
157    FlickGoalNode,
158    CeilingShotGoalNode,
159    DoubleTapGoalNode,
160    OneTimerGoalNode,
161    PassingGoalNode,
162    AirDribbleGoalNode,
163    FlipResetGoalNode,
164    FlipIntoBallGoalNode,
165    BumpGoalNode,
166    DemoGoalNode,
167    HalfVolleyGoalNode, OneTimerNode,
168    PassNode,
169    DodgeResetNode,
170    BallCarryNode,
171    AirDribbleNode,
172    BoostNode,
173    BumpNode,
174    MovementNode,
175    PositioningNode,
176    PowerslideNode,
177    PlayerVerticalStateNode,
178    DemoNode,
179    SettingsNode,
180    StatsProjectionNode,
181    StatsTimelineFrameNode,
182    StatsTimelineEventsNode,
183}
184
185/// `(name, constructor)` for every builtin node, with the name read once from a
186/// throwaway default instance. Built lazily and cached.
187fn builtin_analysis_node_registry() -> &'static [(&'static str, BuiltinNodeCtor)] {
188    static REGISTRY: OnceLock<Vec<(&'static str, BuiltinNodeCtor)>> = OnceLock::new();
189    REGISTRY.get_or_init(|| {
190        BUILTIN_ANALYSIS_NODE_CTORS
191            .iter()
192            .map(|&ctor| (ctor().name(), ctor))
193            .collect()
194    })
195}
196
197pub fn builtin_analysis_node_names() -> &'static [&'static str] {
198    static NAMES: OnceLock<Vec<&'static str>> = OnceLock::new();
199    NAMES.get_or_init(|| {
200        builtin_analysis_node_registry()
201            .iter()
202            .map(|(name, _)| *name)
203            .collect()
204    })
205}
206
207pub(crate) fn boxed_analysis_node_by_name(name: &str) -> Option<Box<dyn AnalysisNodeDyn>> {
208    builtin_analysis_node_registry()
209        .iter()
210        .find(|(candidate, _)| *candidate == name)
211        .map(|(_, ctor)| ctor())
212}
213
214pub fn graph_with_builtin_analysis_nodes<I, S>(names: I) -> SubtrActorResult<AnalysisGraph>
215where
216    I: IntoIterator<Item = S>,
217    S: AsRef<str>,
218{
219    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
220    let mut seen = HashSet::new();
221    for name in names {
222        let name = name.as_ref();
223        let node = boxed_analysis_node_by_name(name).ok_or_else(|| {
224            SubtrActorError::new(SubtrActorErrorVariant::UnknownStatsModuleName(
225                name.to_owned(),
226            ))
227        })?;
228        if !seen.insert(node.name()) {
229            continue;
230        }
231        graph.push_boxed_node(node);
232    }
233    Ok(graph)
234}
235
236pub fn collect_analysis_graph_for_replay(
237    replay: &boxcars::Replay,
238    graph: AnalysisGraph,
239) -> SubtrActorResult<AnalysisGraph> {
240    let collector = collector::AnalysisNodeCollector::new(graph).process_replay(replay)?;
241    Ok(collector.into_graph())
242}
243
244pub fn collect_builtin_analysis_graph_for_replay<I, S>(
245    replay: &boxcars::Replay,
246    names: I,
247) -> SubtrActorResult<AnalysisGraph>
248where
249    I: IntoIterator<Item = S>,
250    S: AsRef<str>,
251{
252    collect_analysis_graph_for_replay(replay, graph_with_builtin_analysis_nodes(names)?)
253}
254
255pub fn all_analysis_nodes() -> Vec<Box<dyn AnalysisNodeDyn>> {
256    builtin_analysis_node_registry()
257        .iter()
258        .map(|(_, ctor)| ctor())
259        .collect()
260}
261
262pub fn graph_with_all_analysis_nodes() -> AnalysisGraph {
263    let mut graph = AnalysisGraph::new().with_input_state_type::<FrameInput>();
264    for node in all_analysis_nodes() {
265        graph.push_boxed_node(node);
266    }
267    graph
268}
269
270#[cfg(test)]
271#[path = "module_tests.rs"]
272mod tests;
273
274#[cfg(test)]
275#[path = "possession_invariant_tests.rs"]
276mod possession_invariant_tests;