Skip to main content

subtr_actor/stats/analysis_graph/nodes/
stats_projection.rs

1use std::collections::{HashMap, HashSet};
2
3use super::*;
4use crate::stats::calculators::*;
5use crate::{PlayerId, SubtrActorResult};
6
7/// Aggregated per-stat accumulators forming the incremental stats projection.
8#[derive(Debug, Clone, Default)]
9pub struct StatsProjectionState {
10    pub core: CoreStatsAccumulator,
11    pub backboard: BackboardStatsAccumulator,
12    pub ceiling_shot: CeilingShotStatsAccumulator,
13    pub wall_aerial: WallAerialStatsAccumulator,
14    pub wall_aerial_shot: WallAerialShotStatsAccumulator,
15    pub double_tap: DoubleTapStatsAccumulator,
16    pub one_timer: OneTimerStatsAccumulator,
17    pub pass: PassStatsAccumulator,
18    pub fifty_fifty: FiftyFiftyStatsAccumulator,
19    pub kickoff: KickoffStatsAccumulator,
20    pub possession: PossessionStatsAccumulator,
21    pub ball_half: BallHalfStatsAccumulator,
22    pub ball_third: BallThirdStatsAccumulator,
23    pub territorial_pressure: TerritorialPressureStatsAccumulator,
24    pub rotation: RotationStatsAccumulator,
25    pub rush: RushStatsAccumulator,
26    pub touch: TouchStatsAccumulator,
27    pub whiff: WhiffStatsAccumulator,
28    pub wavedash: WavedashStatsAccumulator,
29    pub speed_flip: SpeedFlipStatsAccumulator,
30    pub half_flip: HalfFlipStatsAccumulator,
31    pub flick: FlickStatsAccumulator,
32    pub dodge_reset: DodgeResetStatsAccumulator,
33    pub flip_reset: FlipResetStatsAccumulator,
34    pub ball_carry: BallCarryStatsAccumulator,
35    pub boost: BoostStatsAccumulator,
36    pub bump: BumpStatsAccumulator,
37    pub half_volley: HalfVolleyStatsAccumulator,
38    pub movement: MovementStatsAccumulator,
39    pub positioning: PositioningStatsAccumulator,
40    pub powerslide: PowerslideStatsAccumulator,
41    pub demo: DemoStatsAccumulator,
42    pub center: CenterStatsAccumulator,
43    pub controlled_play: ControlledPlayStatsAccumulator,
44}
45
46#[derive(Debug, Clone, Default)]
47struct PowerslideProjectionState {
48    active_players: HashMap<PlayerId, bool>,
49    player_teams: HashMap<PlayerId, bool>,
50}
51
52impl PowerslideProjectionState {
53    fn apply_frame(
54        &mut self,
55        stats: &mut PowerslideStatsAccumulator,
56        frame: &FrameInfo,
57        events: &[PowerslideEvent],
58        counts_toward_motion: bool,
59    ) {
60        let mut started_this_frame = HashSet::new();
61        for event in events {
62            self.player_teams
63                .insert(event.player.clone(), event.is_team_0);
64            if event.active {
65                stats.apply_sample(
66                    &event.player,
67                    event.is_team_0,
68                    true,
69                    false,
70                    frame.dt,
71                    counts_toward_motion,
72                );
73                self.active_players.insert(event.player.clone(), true);
74                started_this_frame.insert(event.player.clone());
75            } else {
76                self.active_players.insert(event.player.clone(), false);
77            }
78        }
79
80        let active_players = self
81            .active_players
82            .iter()
83            .filter(|(player_id, active)| **active && !started_this_frame.contains(*player_id))
84            .map(|(player_id, _)| player_id.clone())
85            .collect::<Vec<_>>();
86        for player_id in active_players {
87            let Some(is_team_0) = self.player_teams.get(&player_id).copied() else {
88                continue;
89            };
90            stats.apply_sample(
91                &player_id,
92                is_team_0,
93                true,
94                true,
95                frame.dt,
96                counts_toward_motion,
97            );
98        }
99    }
100}
101
102/// Incrementally maintained movement-stats projection.
103///
104/// The movement calculator coalesces samples into a small set of in-progress
105/// *pending* events (one per active player) that keep mutating until a player's
106/// classification changes, at which point they finalize into the immutable,
107/// append-only committed stream. The published per-frame snapshot has to
108/// reflect committed + pending, but re-accumulating the entire committed
109/// history every frame is O(n^2) over a replay and stalls on long,
110/// movement-heavy replays.
111///
112/// Instead, each committed event is folded into a persistent `base` exactly
113/// once (tracked by `committed_cursor`), and the bounded pending set is overlaid
114/// on a clone of that base to produce the frame snapshot. `MovementStats`
115/// accumulation is purely additive, so this is identical to a full rebuild while
116/// keeping per-frame work proportional to (newly committed events + players).
117#[derive(Debug, Clone, Default)]
118struct IncrementalMovementProjection {
119    base: MovementStatsAccumulator,
120    committed_cursor: usize,
121    /// Total committed events folded into `base` over this projection's
122    /// lifetime. Tests assert this stays equal to the committed event count
123    /// (each folded exactly once), which is what distinguishes the incremental
124    /// fold from the previous quadratic per-frame rebuild.
125    #[cfg(test)]
126    committed_folds: usize,
127}
128
129impl IncrementalMovementProjection {
130    /// Fold any newly committed events into `base`, then return the published
131    /// snapshot as `base` plus the (bounded) pending overlay.
132    fn project(
133        &mut self,
134        committed: &[MovementEvent],
135        pending: &[MovementEvent],
136    ) -> MovementStatsAccumulator {
137        for event in committed.get(self.committed_cursor..).unwrap_or(&[]) {
138            self.base.apply_event(event);
139            #[cfg(test)]
140            {
141                self.committed_folds += 1;
142            }
143        }
144        self.committed_cursor = committed.len();
145
146        let mut snapshot = self.base.clone();
147        for event in pending {
148            snapshot.apply_event(event);
149        }
150        snapshot
151    }
152}
153
154/// Folds every mechanic/state calculator's events into per-frame cumulative stat accumulators.
155#[derive(Debug, Clone, Default)]
156pub struct StatsProjectionNode {
157    state: StatsProjectionState,
158    cursors: StatsProjectionCursors,
159    movement_projection: IncrementalMovementProjection,
160    powerslide: PowerslideProjectionState,
161    boost_current_amount_consistency: BoostCurrentAmountConsistencyTracker,
162    last_powerslide_sample_frame: Option<usize>,
163    last_possession_sample_frame: Option<usize>,
164    /// Live frames awaiting a possession label. Backdated loss means a frame's
165    /// owning team is only known once a later touch (or timeout) resolves it, so
166    /// these are accumulated when the resolver finalizes the segment that covers
167    /// them — not eagerly on the live frame itself.
168    possession_frame_buffer: Vec<PossessionFrameSample>,
169    territorial_pressure_tracked_time: f32,
170    previous_live_play: Option<bool>,
171}
172
173/// A buffered live frame's possession-zone sample, held until the resolver
174/// decides which team (if any) owned the frame.
175#[derive(Debug, Clone, Default)]
176struct PossessionFrameSample {
177    frame: usize,
178    dt: f32,
179    field_third: Option<String>,
180    field_half: Option<String>,
181}
182
183#[derive(Debug, Clone, Default)]
184struct StatsProjectionCursors {
185    core_player: usize,
186    core_player_goal_context: usize,
187    backboard: usize,
188    ceiling_shot: usize,
189    wall_aerial: usize,
190    wall_aerial_shot: usize,
191    double_tap: usize,
192    one_timer: usize,
193    pass: usize,
194    fifty_fifty: usize,
195    kickoff: usize,
196    ball_half: usize,
197    ball_third: usize,
198    rush: usize,
199    touch: usize,
200    whiff: usize,
201    wavedash: usize,
202    speed_flip: usize,
203    half_flip: usize,
204    flick: usize,
205    dodge_reset: usize,
206    flip_reset: usize,
207    dodge_reset_flip_reset_outcome: usize,
208    ball_carry: usize,
209    air_dribble: usize,
210    bump: usize,
211    half_volley: usize,
212    powerslide: usize,
213    demo_timeline: usize,
214    center: usize,
215    controlled_play: usize,
216}
217
218impl StatsProjectionNode {
219    pub fn new() -> Self {
220        Self::default()
221    }
222
223    /// Fold buffered possession frames up to (and including) `end_frame` into the
224    /// stats under `label`, the team the resolver assigned them.
225    fn drain_possession_buffer_through(&mut self, end_frame: usize, label: &str) {
226        let mut drained = 0;
227        while drained < self.possession_frame_buffer.len() {
228            if self.possession_frame_buffer[drained].frame > end_frame {
229                break;
230            }
231            let (dt, field_third, field_half) = {
232                let sample = &self.possession_frame_buffer[drained];
233                (
234                    sample.dt,
235                    sample.field_third.clone(),
236                    sample.field_half.clone(),
237                )
238            };
239            self.state.possession.apply_frame(
240                label,
241                field_third.as_deref(),
242                field_half.as_deref(),
243                dt,
244            );
245            drained += 1;
246        }
247        self.possession_frame_buffer.drain(0..drained);
248    }
249
250    /// Flush any still-unresolved possession frames as neutral. The trailing
251    /// open segment never got a follow-up, so on the model its time is neutral.
252    fn flush_possession_buffer_as_neutral(&mut self) {
253        if self.possession_frame_buffer.is_empty() {
254            return;
255        }
256        self.drain_possession_buffer_through(usize::MAX, "neutral");
257    }
258
259    fn begin_sample(&mut self, frame: &FrameInfo, live_play: bool) {
260        self.state.backboard.begin_sample(frame);
261        self.state.center.begin_sample(frame);
262        self.state.double_tap.begin_sample(frame);
263        self.state.half_volley.begin_sample(frame);
264        self.state.one_timer.begin_sample(frame);
265        self.state.pass.begin_sample(frame);
266        self.state.wall_aerial.begin_sample(frame);
267        self.state.wall_aerial_shot.begin_sample(frame);
268
269        if !live_play {
270            self.state.center.clear_current_last();
271            self.state.one_timer.clear_current_last();
272            self.state.pass.clear_current_last();
273            self.state.half_volley.reset_current_last_event_marker();
274            self.state.touch.set_current_last_touch_player(None);
275            self.state.wall_aerial.reset_current_last_event_marker();
276            self.state
277                .wall_aerial_shot
278                .reset_current_last_event_marker();
279        }
280
281        if live_play && self.previous_live_play == Some(false) {
282            self.state.ceiling_shot.reset_current_last_event_marker();
283            self.state.flick.reset_current_last_event_marker();
284            self.state.half_flip.reset_current_last_event_marker();
285            self.state.wavedash.reset_current_last_event_marker();
286            self.state.whiff.reset_current_last_event_marker();
287        }
288
289        if live_play {
290            self.state.ceiling_shot.begin_sample(frame);
291            self.state.flick.begin_sample(frame);
292            self.state.half_flip.begin_sample(frame);
293            self.state.touch.begin_sample(frame);
294            self.state.wavedash.begin_sample(frame);
295            self.state.whiff.begin_sample(frame);
296        }
297    }
298
299    fn finish_sample(&mut self) {
300        self.state.center.finish_sample();
301        self.state.double_tap.finish_sample();
302        self.state.one_timer.finish_sample();
303        self.state.pass.finish_sample();
304        self.state.half_volley.restore_current_last_event_marker();
305        self.state.touch.restore_current_last_touch_marker();
306        self.state.wall_aerial.restore_current_last_event_marker();
307        self.state
308            .wall_aerial_shot
309            .restore_current_last_event_marker();
310        self.state.whiff.restore_current_last_event_marker();
311    }
312
313    fn events_since<'a, E>(cursor: &mut usize, events: &'a [E]) -> &'a [E] {
314        let start = (*cursor).min(events.len());
315        *cursor = events.len();
316        &events[start..]
317    }
318
319    fn check_boost_current_amount_consistency(
320        &mut self,
321        frame: &FrameInfo,
322        players: &PlayerFrameState,
323    ) {
324        for player in &players.players {
325            if player.boost_active {
326                continue;
327            }
328            let Some(observed_byte) = player
329                .last_boost_amount
330                .map(|amount| amount.round().clamp(0.0, BOOST_MAX_AMOUNT) as u8)
331            else {
332                continue;
333            };
334            let stats = self.state.boost.player_stats_for(&player.player_id);
335            self.boost_current_amount_consistency.observe(
336                frame.frame_number,
337                frame.time,
338                &player.player_id,
339                &stats,
340                observed_byte,
341            );
342        }
343    }
344
345    fn warn_for_unresolved_boost_current_amount_drift(&self) {
346        for warning in self.boost_current_amount_consistency.unresolved_warnings() {
347            log::warn!(
348                "Boost invariant violation for player {:?} at frame {} (t={:.3}): {}",
349                warning.player_id,
350                warning.frame,
351                warning.time,
352                warning.message(),
353            );
354        }
355    }
356
357    fn project_frame(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
358        let frame = ctx.get::<FrameInfo>()?;
359        let players = ctx.get::<PlayerFrameState>()?;
360        let live_play_state = ctx.get::<LivePlayState>()?;
361        let live_play = live_play_state.is_live_play;
362        let counts_toward_powerslide_motion = matches!(
363            live_play_state.gameplay_phase,
364            GameplayPhase::ActivePlay | GameplayPhase::KickoffWaitingForTouch
365        );
366        let gameplay = ctx.get::<GameplayState>()?;
367        let speed_flip_stats_advance = live_play || gameplay.ball_has_been_hit == Some(false);
368        let should_sample_powerslide =
369            self.last_powerslide_sample_frame != Some(frame.frame_number);
370        self.begin_sample(frame, live_play);
371
372        let match_stats = ctx.get::<MatchStatsCalculator>()?;
373        for event in Self::events_since(
374            &mut self.cursors.core_player,
375            match_stats.core_player_events(),
376        ) {
377            self.state.core.apply_scoreboard_event(event);
378        }
379        for event in Self::events_since(
380            &mut self.cursors.core_player_goal_context,
381            match_stats.core_player_goal_context_events(),
382        ) {
383            self.state.core.apply_goal_context_event(event);
384        }
385
386        let backboard = ctx.get::<BackboardCalculator>()?;
387        self.state.backboard.apply_events(
388            frame,
389            Self::events_since(&mut self.cursors.backboard, backboard.events()),
390        );
391
392        let ceiling_shot = ctx.get::<CeilingShotCalculator>()?;
393        if live_play {
394            for event in Self::events_since(&mut self.cursors.ceiling_shot, ceiling_shot.events()) {
395                self.state.ceiling_shot.apply_event(event, frame);
396            }
397        }
398        let wall_aerial = ctx.get::<WallAerialCalculator>()?;
399        if live_play {
400            for event in Self::events_since(&mut self.cursors.wall_aerial, wall_aerial.events()) {
401                self.state.wall_aerial.apply_event(event, frame);
402            }
403        }
404        let wall_aerial_shot = ctx.get::<WallAerialShotCalculator>()?;
405        if live_play {
406            for event in Self::events_since(
407                &mut self.cursors.wall_aerial_shot,
408                wall_aerial_shot.events(),
409            ) {
410                self.state.wall_aerial_shot.apply_event(event, frame);
411            }
412        }
413        let double_tap = ctx.get::<DoubleTapCalculator>()?;
414        for event in Self::events_since(&mut self.cursors.double_tap, double_tap.events()) {
415            self.state.double_tap.apply_event(frame, event);
416        }
417        let one_timer = ctx.get::<OneTimerCalculator>()?;
418        if live_play {
419            for event in Self::events_since(&mut self.cursors.one_timer, one_timer.events()) {
420                self.state.one_timer.apply_event(frame, event);
421            }
422        }
423        let pass = ctx.get::<PassCalculator>()?;
424        if live_play {
425            for event in Self::events_since(&mut self.cursors.pass, pass.events()) {
426                self.state.pass.apply_event(frame, event);
427            }
428        }
429        let fifty_fifty = ctx.get::<FiftyFiftyCalculator>()?;
430        for event in Self::events_since(&mut self.cursors.fifty_fifty, fifty_fifty.events()) {
431            self.state.fifty_fifty.apply_event(event);
432        }
433        let kickoff = ctx.get::<KickoffCalculator>()?;
434        for event in Self::events_since(&mut self.cursors.kickoff, kickoff.events()) {
435            self.state.kickoff.apply_event(event);
436        }
437        // Possession's zone cross-tab is a per-frame join: possession events
438        // carry only who-has-the-ball, and the ball's third/half come from the
439        // canonical ball_third / ball_half streams. The accumulator is
440        // cumulative (not rebuilt each frame), so guard against finish()
441        // re-processing the final frame.
442        //
443        // Backdated loss means a live frame's owning team is not known when the
444        // frame is processed: a possession's tail only becomes that team's credit
445        // once they re-touch, and goes neutral otherwise. So buffer each live
446        // frame's zone sample and only fold it into the stats when the resolver
447        // finalizes the segment that covers it.
448        let possession = ctx.get::<PossessionCalculator>()?;
449        let ball_third = ctx.get::<BallThirdCalculator>()?;
450        let ball_half_calculator = ctx.get::<BallHalfCalculator>()?;
451        if live_play && self.last_possession_sample_frame != Some(frame.frame_number) {
452            let field_third = ball_third
453                .current_event()
454                .filter(|event| event.active)
455                .map(|event| event.field_third.clone());
456            let field_half = ball_half_calculator
457                .current_event()
458                .filter(|event| event.active)
459                .map(|event| event.field_half.clone());
460            self.possession_frame_buffer.push(PossessionFrameSample {
461                frame: frame.frame_number,
462                dt: frame.dt,
463                field_third,
464                field_half,
465            });
466        }
467        self.last_possession_sample_frame = Some(frame.frame_number);
468        for segment in possession.new_resolved() {
469            self.drain_possession_buffer_through(segment.end_frame, segment.label.as_label_value());
470        }
471        let ball_half = ctx.get::<BallHalfCalculator>()?;
472        let projected_ball_half_events = ball_half.projected_events();
473        self.state.ball_half = BallHalfStatsAccumulator::default();
474        for event in projected_ball_half_events.iter() {
475            self.state.ball_half.apply_event(event);
476        }
477        self.cursors.ball_half = ball_half.events().len();
478        let ball_third = ctx.get::<BallThirdCalculator>()?;
479        let projected_ball_third_events = ball_third.projected_events();
480        self.state.ball_third = BallThirdStatsAccumulator::default();
481        for event in projected_ball_third_events.iter() {
482            self.state.ball_third.apply_event(event);
483        }
484        self.cursors.ball_third = ball_third.events().len();
485        let territorial_pressure = ctx.get::<TerritorialPressureCalculator>()?;
486        if live_play {
487            self.territorial_pressure_tracked_time += frame.dt;
488        }
489        let projected_territorial_pressure_events = territorial_pressure.projected_events();
490        self.state.territorial_pressure = TerritorialPressureStatsAccumulator::default();
491        self.state
492            .territorial_pressure
493            .set_tracked_time(self.territorial_pressure_tracked_time);
494        for event in projected_territorial_pressure_events.iter() {
495            self.state.territorial_pressure.apply_event(event);
496        }
497        let rotation = ctx.get::<RotationCalculator>()?;
498        self.state.rotation = RotationStatsAccumulator::with_first_man_stint_end_grace_seconds(
499            rotation.config().first_man_stint_end_grace_seconds,
500        );
501        for event in rotation.role_events().iter() {
502            self.state.rotation.apply_role_event(event);
503        }
504        for event in rotation.first_man_change_events() {
505            self.state.rotation.apply_first_man_change_event(event);
506        }
507        let rush = ctx.get::<RushCalculator>()?;
508        for event in Self::events_since(&mut self.cursors.rush, rush.events()) {
509            self.state.rush.apply_event(event);
510        }
511        let touch = ctx.get::<TouchCalculator>()?;
512        if live_play || self.cursors.touch != touch.events().len() {
513            self.state.touch = TouchStatsAccumulator::default();
514            for event in touch.events() {
515                self.state.touch.apply_touch_event(event, frame);
516            }
517            self.cursors.touch = touch.events().len();
518        }
519        let whiff = ctx.get::<WhiffCalculator>()?;
520        if live_play {
521            for event in Self::events_since(&mut self.cursors.whiff, whiff.events()) {
522                self.state.whiff.apply_event(event, frame);
523            }
524        }
525        let wavedash = ctx.get::<WavedashCalculator>()?;
526        if live_play {
527            for event in Self::events_since(&mut self.cursors.wavedash, wavedash.events()) {
528                self.state.wavedash.apply_event(event);
529            }
530        }
531        let speed_flip = ctx.get::<SpeedFlipCalculator>()?;
532        if speed_flip_stats_advance {
533            self.state.speed_flip.begin_sample(frame);
534            for event in Self::events_since(&mut self.cursors.speed_flip, speed_flip.events()) {
535                self.state.speed_flip.apply_event(event);
536            }
537        }
538        let half_flip = ctx.get::<HalfFlipCalculator>()?;
539        if live_play {
540            for event in Self::events_since(&mut self.cursors.half_flip, half_flip.events()) {
541                self.state.half_flip.apply_event(event);
542            }
543        }
544        let flick = ctx.get::<FlickCalculator>()?;
545        if live_play {
546            for event in Self::events_since(&mut self.cursors.flick, flick.events()) {
547                self.state.flick.apply_event(event, frame);
548            }
549        }
550        let dodge_reset = ctx.get::<DodgeResetCalculator>()?;
551        for event in Self::events_since(&mut self.cursors.dodge_reset, dodge_reset.events()) {
552            self.state.dodge_reset.apply_event(event);
553        }
554        for event in Self::events_since(
555            &mut self.cursors.flip_reset,
556            dodge_reset.confirmed_flip_reset_events(),
557        ) {
558            self.state.flip_reset.apply_event(event);
559        }
560        for event in Self::events_since(
561            &mut self.cursors.dodge_reset_flip_reset_outcome,
562            dodge_reset.flip_reset_outcome_events(),
563        ) {
564            self.state.dodge_reset.apply_flip_reset_outcome_event(event);
565        }
566        let ball_carry = ctx.get::<BallCarryCalculator>()?;
567        for event in Self::events_since(&mut self.cursors.ball_carry, ball_carry.carry_events()) {
568            self.state.ball_carry.apply_event(event);
569        }
570        let air_dribble = ctx.get::<AirDribbleCalculator>()?;
571        for event in Self::events_since(&mut self.cursors.air_dribble, air_dribble.events()) {
572            self.state.ball_carry.apply_event(event);
573        }
574        let boost = ctx.get::<BoostCalculator>()?;
575        // The boost calculator now accumulates BoostStats directly as it processes frames, so we
576        // mirror its accumulator instead of replaying projected ledger/state events.
577        self.state.boost = boost.boost_stats().clone();
578        if live_play {
579            self.check_boost_current_amount_consistency(frame, players);
580        }
581        let bump = ctx.get::<BumpCalculator>()?;
582        for event in Self::events_since(&mut self.cursors.bump, bump.events()) {
583            self.state.bump.apply_event(event);
584        }
585        let half_volley = ctx.get::<HalfVolleyCalculator>()?;
586        if live_play {
587            for event in Self::events_since(&mut self.cursors.half_volley, half_volley.events()) {
588                self.state.half_volley.apply_event(event, frame);
589            }
590        }
591        let movement = ctx.get::<MovementCalculator>()?;
592        self.state.movement = self
593            .movement_projection
594            .project(movement.events(), &movement.pending_events());
595        let positioning = ctx.get::<PositioningCalculator>()?;
596        self.state.positioning = PositioningStatsAccumulator::default();
597        for event in positioning.activity_events().iter() {
598            self.state.positioning.apply_activity_event(event);
599        }
600        for event in positioning.field_third_events().iter() {
601            self.state.positioning.apply_field_third_event(event);
602        }
603        for event in positioning.field_half_events().iter() {
604            self.state.positioning.apply_field_half_event(event);
605        }
606        for event in positioning.ball_depth_events().iter() {
607            self.state.positioning.apply_ball_depth_event(event);
608        }
609        for event in positioning.depth_role_events().iter() {
610            self.state.positioning.apply_depth_role_event(event);
611        }
612        for event in positioning.ball_proximity_events().iter() {
613            self.state.positioning.apply_ball_proximity_event(event);
614        }
615        for event in positioning.shadow_defense_events().iter() {
616            self.state.positioning.apply_shadow_defense_event(event);
617        }
618        for (player, signal) in positioning.signals() {
619            self.state.positioning.apply_signal(player, signal);
620        }
621        let powerslide = ctx.get::<PowerslideCalculator>()?;
622        let powerslide_events =
623            Self::events_since(&mut self.cursors.powerslide, powerslide.events());
624        if should_sample_powerslide {
625            self.powerslide.apply_frame(
626                &mut self.state.powerslide,
627                frame,
628                powerslide_events,
629                counts_toward_powerslide_motion,
630            );
631            self.last_powerslide_sample_frame = Some(frame.frame_number);
632        }
633        let demo = ctx.get::<DemoCalculator>()?;
634        for event in Self::events_since(&mut self.cursors.demo_timeline, demo.events()) {
635            self.state.demo.apply_demolition_event(event);
636        }
637        let center = ctx.get::<CenterCalculator>()?;
638        for event in Self::events_since(&mut self.cursors.center, center.events()) {
639            self.state.center.apply_event(frame, event);
640        }
641        let controlled_play = ctx.get::<ControlledPlayCalculator>()?;
642        for event in Self::events_since(&mut self.cursors.controlled_play, controlled_play.events())
643        {
644            self.state.controlled_play.apply_event(event);
645        }
646
647        self.finish_sample();
648        self.previous_live_play = Some(live_play);
649        Ok(())
650    }
651}
652
653impl AnalysisNode for StatsProjectionNode {
654    type State = StatsProjectionState;
655
656    fn name(&self) -> &'static str {
657        "stats_projection"
658    }
659
660    fn dependencies(&self) -> NodeDependencies {
661        vec![
662            frame_info_dependency(),
663            gameplay_state_dependency(),
664            live_play_dependency(),
665            player_frame_state_dependency(),
666            match_stats_dependency(),
667            backboard_dependency(),
668            ceiling_shot_dependency(),
669            wall_aerial_dependency(),
670            wall_aerial_shot_dependency(),
671            double_tap_dependency(),
672            one_timer_dependency(),
673            pass_dependency(),
674            fifty_fifty_dependency(),
675            kickoff_dependency(),
676            possession_dependency(),
677            ball_half_dependency(),
678            ball_third_dependency(),
679            territorial_pressure_dependency(),
680            rotation_dependency(),
681            rush_dependency(),
682            touch_dependency(),
683            whiff_dependency(),
684            wavedash_dependency(),
685            speed_flip_dependency(),
686            half_flip_dependency(),
687            flick_dependency(),
688            dodge_reset_dependency(),
689            ball_carry_dependency(),
690            air_dribble_dependency(),
691            boost_dependency(),
692            bump_dependency(),
693            half_volley_dependency(),
694            movement_dependency(),
695            positioning_dependency(),
696            powerslide_dependency(),
697            demo_dependency(),
698            center_dependency(),
699            controlled_play_dependency(),
700        ]
701    }
702
703    fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
704        self.project_frame(ctx)
705    }
706
707    fn finish(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
708        self.project_frame(ctx)?;
709        self.flush_possession_buffer_as_neutral();
710        self.warn_for_unresolved_boost_current_amount_drift();
711        Ok(())
712    }
713
714    fn state(&self) -> &Self::State {
715        &self.state
716    }
717}
718
719pub(crate) fn boxed_default() -> Box<dyn AnalysisNodeDyn> {
720    Box::new(StatsProjectionNode::new())
721}
722
723#[cfg(test)]
724#[path = "stats_projection_tests.rs"]
725mod tests;