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    bump: usize,
210    half_volley: usize,
211    powerslide: usize,
212    demo_timeline: usize,
213    center: usize,
214    controlled_play: usize,
215}
216
217impl StatsProjectionNode {
218    pub fn new() -> Self {
219        Self::default()
220    }
221
222    /// Fold buffered possession frames up to (and including) `end_frame` into the
223    /// stats under `label`, the team the resolver assigned them.
224    fn drain_possession_buffer_through(&mut self, end_frame: usize, label: &str) {
225        let mut drained = 0;
226        while drained < self.possession_frame_buffer.len() {
227            if self.possession_frame_buffer[drained].frame > end_frame {
228                break;
229            }
230            let (dt, field_third, field_half) = {
231                let sample = &self.possession_frame_buffer[drained];
232                (
233                    sample.dt,
234                    sample.field_third.clone(),
235                    sample.field_half.clone(),
236                )
237            };
238            self.state.possession.apply_frame(
239                label,
240                field_third.as_deref(),
241                field_half.as_deref(),
242                dt,
243            );
244            drained += 1;
245        }
246        self.possession_frame_buffer.drain(0..drained);
247    }
248
249    /// Flush any still-unresolved possession frames as neutral. The trailing
250    /// open segment never got a follow-up, so on the model its time is neutral.
251    fn flush_possession_buffer_as_neutral(&mut self) {
252        if self.possession_frame_buffer.is_empty() {
253            return;
254        }
255        self.drain_possession_buffer_through(usize::MAX, "neutral");
256    }
257
258    fn begin_sample(&mut self, frame: &FrameInfo, live_play: bool) {
259        self.state.backboard.begin_sample(frame);
260        self.state.center.begin_sample(frame);
261        self.state.double_tap.begin_sample(frame);
262        self.state.half_volley.begin_sample(frame);
263        self.state.one_timer.begin_sample(frame);
264        self.state.pass.begin_sample(frame);
265        self.state.wall_aerial.begin_sample(frame);
266        self.state.wall_aerial_shot.begin_sample(frame);
267
268        if !live_play {
269            self.state.center.clear_current_last();
270            self.state.one_timer.clear_current_last();
271            self.state.pass.clear_current_last();
272            self.state.half_volley.reset_current_last_event_marker();
273            self.state.touch.set_current_last_touch_player(None);
274            self.state.wall_aerial.reset_current_last_event_marker();
275            self.state
276                .wall_aerial_shot
277                .reset_current_last_event_marker();
278        }
279
280        if live_play && self.previous_live_play == Some(false) {
281            self.state.ceiling_shot.reset_current_last_event_marker();
282            self.state.flick.reset_current_last_event_marker();
283            self.state.half_flip.reset_current_last_event_marker();
284            self.state.wavedash.reset_current_last_event_marker();
285            self.state.whiff.reset_current_last_event_marker();
286        }
287
288        if live_play {
289            self.state.ceiling_shot.begin_sample(frame);
290            self.state.flick.begin_sample(frame);
291            self.state.half_flip.begin_sample(frame);
292            self.state.touch.begin_sample(frame);
293            self.state.wavedash.begin_sample(frame);
294            self.state.whiff.begin_sample(frame);
295        }
296    }
297
298    fn finish_sample(&mut self) {
299        self.state.center.finish_sample();
300        self.state.double_tap.finish_sample();
301        self.state.one_timer.finish_sample();
302        self.state.pass.finish_sample();
303        self.state.half_volley.restore_current_last_event_marker();
304        self.state.touch.restore_current_last_touch_marker();
305        self.state.wall_aerial.restore_current_last_event_marker();
306        self.state
307            .wall_aerial_shot
308            .restore_current_last_event_marker();
309        self.state.whiff.restore_current_last_event_marker();
310    }
311
312    fn events_since<'a, E>(cursor: &mut usize, events: &'a [E]) -> &'a [E] {
313        let start = (*cursor).min(events.len());
314        *cursor = events.len();
315        &events[start..]
316    }
317
318    fn check_boost_current_amount_consistency(
319        &mut self,
320        frame: &FrameInfo,
321        players: &PlayerFrameState,
322    ) {
323        for player in &players.players {
324            if player.boost_active {
325                continue;
326            }
327            let Some(observed_byte) = player
328                .last_boost_amount
329                .map(|amount| amount.round().clamp(0.0, BOOST_MAX_AMOUNT) as u8)
330            else {
331                continue;
332            };
333            let stats = self.state.boost.player_stats_for(&player.player_id);
334            self.boost_current_amount_consistency.observe(
335                frame.frame_number,
336                frame.time,
337                &player.player_id,
338                &stats,
339                observed_byte,
340            );
341        }
342    }
343
344    fn warn_for_unresolved_boost_current_amount_drift(&self) {
345        for warning in self.boost_current_amount_consistency.unresolved_warnings() {
346            log::warn!(
347                "Boost invariant violation for player {:?} at frame {} (t={:.3}): {}",
348                warning.player_id,
349                warning.frame,
350                warning.time,
351                warning.message(),
352            );
353        }
354    }
355
356    fn project_frame(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
357        let frame = ctx.get::<FrameInfo>()?;
358        let players = ctx.get::<PlayerFrameState>()?;
359        let live_play_state = ctx.get::<LivePlayState>()?;
360        let live_play = live_play_state.is_live_play;
361        let counts_toward_powerslide_motion = matches!(
362            live_play_state.gameplay_phase,
363            GameplayPhase::ActivePlay | GameplayPhase::KickoffWaitingForTouch
364        );
365        let gameplay = ctx.get::<GameplayState>()?;
366        let speed_flip_stats_advance = live_play || gameplay.ball_has_been_hit == Some(false);
367        let should_sample_powerslide =
368            self.last_powerslide_sample_frame != Some(frame.frame_number);
369        self.begin_sample(frame, live_play);
370
371        let match_stats = ctx.get::<MatchStatsCalculator>()?;
372        for event in Self::events_since(
373            &mut self.cursors.core_player,
374            match_stats.core_player_events(),
375        ) {
376            self.state.core.apply_scoreboard_event(event);
377        }
378        for event in Self::events_since(
379            &mut self.cursors.core_player_goal_context,
380            match_stats.core_player_goal_context_events(),
381        ) {
382            self.state.core.apply_goal_context_event(event);
383        }
384
385        let backboard = ctx.get::<BackboardCalculator>()?;
386        self.state.backboard.apply_events(
387            frame,
388            Self::events_since(&mut self.cursors.backboard, backboard.events()),
389        );
390
391        let ceiling_shot = ctx.get::<CeilingShotCalculator>()?;
392        if live_play {
393            for event in Self::events_since(&mut self.cursors.ceiling_shot, ceiling_shot.events()) {
394                self.state.ceiling_shot.apply_event(event, frame);
395            }
396        }
397        let wall_aerial = ctx.get::<WallAerialCalculator>()?;
398        if live_play {
399            for event in Self::events_since(&mut self.cursors.wall_aerial, wall_aerial.events()) {
400                self.state.wall_aerial.apply_event(event, frame);
401            }
402        }
403        let wall_aerial_shot = ctx.get::<WallAerialShotCalculator>()?;
404        if live_play {
405            for event in Self::events_since(
406                &mut self.cursors.wall_aerial_shot,
407                wall_aerial_shot.events(),
408            ) {
409                self.state.wall_aerial_shot.apply_event(event, frame);
410            }
411        }
412        let double_tap = ctx.get::<DoubleTapCalculator>()?;
413        for event in Self::events_since(&mut self.cursors.double_tap, double_tap.events()) {
414            self.state.double_tap.apply_event(frame, event);
415        }
416        let one_timer = ctx.get::<OneTimerCalculator>()?;
417        if live_play {
418            for event in Self::events_since(&mut self.cursors.one_timer, one_timer.events()) {
419                self.state.one_timer.apply_event(frame, event);
420            }
421        }
422        let pass = ctx.get::<PassCalculator>()?;
423        if live_play {
424            for event in Self::events_since(&mut self.cursors.pass, pass.events()) {
425                self.state.pass.apply_event(frame, event);
426            }
427        }
428        let fifty_fifty = ctx.get::<FiftyFiftyCalculator>()?;
429        for event in Self::events_since(&mut self.cursors.fifty_fifty, fifty_fifty.events()) {
430            self.state.fifty_fifty.apply_event(event);
431        }
432        let kickoff = ctx.get::<KickoffCalculator>()?;
433        for event in Self::events_since(&mut self.cursors.kickoff, kickoff.events()) {
434            self.state.kickoff.apply_event(event);
435        }
436        // Possession's zone cross-tab is a per-frame join: possession events
437        // carry only who-has-the-ball, and the ball's third/half come from the
438        // canonical ball_third / ball_half streams. The accumulator is
439        // cumulative (not rebuilt each frame), so guard against finish()
440        // re-processing the final frame.
441        //
442        // Backdated loss means a live frame's owning team is not known when the
443        // frame is processed: a possession's tail only becomes that team's credit
444        // once they re-touch, and goes neutral otherwise. So buffer each live
445        // frame's zone sample and only fold it into the stats when the resolver
446        // finalizes the segment that covers it.
447        let possession = ctx.get::<PossessionCalculator>()?;
448        let ball_third = ctx.get::<BallThirdCalculator>()?;
449        let ball_half_calculator = ctx.get::<BallHalfCalculator>()?;
450        if live_play && self.last_possession_sample_frame != Some(frame.frame_number) {
451            let field_third = ball_third
452                .current_event()
453                .filter(|event| event.active)
454                .map(|event| event.field_third.clone());
455            let field_half = ball_half_calculator
456                .current_event()
457                .filter(|event| event.active)
458                .map(|event| event.field_half.clone());
459            self.possession_frame_buffer.push(PossessionFrameSample {
460                frame: frame.frame_number,
461                dt: frame.dt,
462                field_third,
463                field_half,
464            });
465        }
466        self.last_possession_sample_frame = Some(frame.frame_number);
467        for segment in possession.new_resolved() {
468            self.drain_possession_buffer_through(segment.end_frame, segment.label.as_label_value());
469        }
470        let ball_half = ctx.get::<BallHalfCalculator>()?;
471        let projected_ball_half_events = ball_half.projected_events();
472        self.state.ball_half = BallHalfStatsAccumulator::default();
473        for event in projected_ball_half_events.iter() {
474            self.state.ball_half.apply_event(event);
475        }
476        self.cursors.ball_half = ball_half.events().len();
477        let ball_third = ctx.get::<BallThirdCalculator>()?;
478        let projected_ball_third_events = ball_third.projected_events();
479        self.state.ball_third = BallThirdStatsAccumulator::default();
480        for event in projected_ball_third_events.iter() {
481            self.state.ball_third.apply_event(event);
482        }
483        self.cursors.ball_third = ball_third.events().len();
484        let territorial_pressure = ctx.get::<TerritorialPressureCalculator>()?;
485        if live_play {
486            self.territorial_pressure_tracked_time += frame.dt;
487        }
488        let projected_territorial_pressure_events = territorial_pressure.projected_events();
489        self.state.territorial_pressure = TerritorialPressureStatsAccumulator::default();
490        self.state
491            .territorial_pressure
492            .set_tracked_time(self.territorial_pressure_tracked_time);
493        for event in projected_territorial_pressure_events.iter() {
494            self.state.territorial_pressure.apply_event(event);
495        }
496        let rotation = ctx.get::<RotationCalculator>()?;
497        self.state.rotation = RotationStatsAccumulator::with_first_man_stint_end_grace_seconds(
498            rotation.config().first_man_stint_end_grace_seconds,
499        );
500        for event in rotation.role_events().iter() {
501            self.state.rotation.apply_role_event(event);
502        }
503        for event in rotation.first_man_change_events() {
504            self.state.rotation.apply_first_man_change_event(event);
505        }
506        let rush = ctx.get::<RushCalculator>()?;
507        for event in Self::events_since(&mut self.cursors.rush, rush.events()) {
508            self.state.rush.apply_event(event);
509        }
510        let touch = ctx.get::<TouchCalculator>()?;
511        if live_play || self.cursors.touch != touch.events().len() {
512            self.state.touch = TouchStatsAccumulator::default();
513            for event in touch.events() {
514                self.state.touch.apply_touch_event(event, frame);
515            }
516            self.cursors.touch = touch.events().len();
517        }
518        let whiff = ctx.get::<WhiffCalculator>()?;
519        if live_play {
520            for event in Self::events_since(&mut self.cursors.whiff, whiff.events()) {
521                self.state.whiff.apply_event(event, frame);
522            }
523        }
524        let wavedash = ctx.get::<WavedashCalculator>()?;
525        if live_play {
526            for event in Self::events_since(&mut self.cursors.wavedash, wavedash.events()) {
527                self.state.wavedash.apply_event(event);
528            }
529        }
530        let speed_flip = ctx.get::<SpeedFlipCalculator>()?;
531        if speed_flip_stats_advance {
532            self.state.speed_flip.begin_sample(frame);
533            for event in Self::events_since(&mut self.cursors.speed_flip, speed_flip.events()) {
534                self.state.speed_flip.apply_event(event);
535            }
536        }
537        let half_flip = ctx.get::<HalfFlipCalculator>()?;
538        if live_play {
539            for event in Self::events_since(&mut self.cursors.half_flip, half_flip.events()) {
540                self.state.half_flip.apply_event(event);
541            }
542        }
543        let flick = ctx.get::<FlickCalculator>()?;
544        if live_play {
545            for event in Self::events_since(&mut self.cursors.flick, flick.events()) {
546                self.state.flick.apply_event(event, frame);
547            }
548        }
549        let dodge_reset = ctx.get::<DodgeResetCalculator>()?;
550        for event in Self::events_since(&mut self.cursors.dodge_reset, dodge_reset.events()) {
551            self.state.dodge_reset.apply_event(event);
552        }
553        for event in Self::events_since(
554            &mut self.cursors.flip_reset,
555            dodge_reset.confirmed_flip_reset_events(),
556        ) {
557            self.state.flip_reset.apply_event(event);
558        }
559        for event in Self::events_since(
560            &mut self.cursors.dodge_reset_flip_reset_outcome,
561            dodge_reset.flip_reset_outcome_events(),
562        ) {
563            self.state.dodge_reset.apply_flip_reset_outcome_event(event);
564        }
565        let ball_carry = ctx.get::<BallCarryCalculator>()?;
566        for event in Self::events_since(&mut self.cursors.ball_carry, ball_carry.carry_events()) {
567            self.state.ball_carry.apply_event(event);
568        }
569        let boost = ctx.get::<BoostCalculator>()?;
570        // The boost calculator now accumulates BoostStats directly as it processes frames, so we
571        // mirror its accumulator instead of replaying projected ledger/state events.
572        self.state.boost = boost.boost_stats().clone();
573        if live_play {
574            self.check_boost_current_amount_consistency(frame, players);
575        }
576        let bump = ctx.get::<BumpCalculator>()?;
577        for event in Self::events_since(&mut self.cursors.bump, bump.events()) {
578            self.state.bump.apply_event(event);
579        }
580        let half_volley = ctx.get::<HalfVolleyCalculator>()?;
581        if live_play {
582            for event in Self::events_since(&mut self.cursors.half_volley, half_volley.events()) {
583                self.state.half_volley.apply_event(event, frame);
584            }
585        }
586        let movement = ctx.get::<MovementCalculator>()?;
587        self.state.movement = self
588            .movement_projection
589            .project(movement.events(), &movement.pending_events());
590        let positioning = ctx.get::<PositioningCalculator>()?;
591        self.state.positioning = PositioningStatsAccumulator::default();
592        for event in positioning.activity_events().iter() {
593            self.state.positioning.apply_activity_event(event);
594        }
595        for event in positioning.field_third_events().iter() {
596            self.state.positioning.apply_field_third_event(event);
597        }
598        for event in positioning.field_half_events().iter() {
599            self.state.positioning.apply_field_half_event(event);
600        }
601        for event in positioning.ball_depth_events().iter() {
602            self.state.positioning.apply_ball_depth_event(event);
603        }
604        for event in positioning.depth_role_events().iter() {
605            self.state.positioning.apply_depth_role_event(event);
606        }
607        for event in positioning.ball_proximity_events().iter() {
608            self.state.positioning.apply_ball_proximity_event(event);
609        }
610        for event in positioning.shadow_defense_events().iter() {
611            self.state.positioning.apply_shadow_defense_event(event);
612        }
613        for (player, signal) in positioning.signals() {
614            self.state.positioning.apply_signal(player, signal);
615        }
616        let powerslide = ctx.get::<PowerslideCalculator>()?;
617        let powerslide_events =
618            Self::events_since(&mut self.cursors.powerslide, powerslide.events());
619        if should_sample_powerslide {
620            self.powerslide.apply_frame(
621                &mut self.state.powerslide,
622                frame,
623                powerslide_events,
624                counts_toward_powerslide_motion,
625            );
626            self.last_powerslide_sample_frame = Some(frame.frame_number);
627        }
628        let demo = ctx.get::<DemoCalculator>()?;
629        for event in Self::events_since(&mut self.cursors.demo_timeline, demo.events()) {
630            self.state.demo.apply_demolition_event(event);
631        }
632        let center = ctx.get::<CenterCalculator>()?;
633        for event in Self::events_since(&mut self.cursors.center, center.events()) {
634            self.state.center.apply_event(frame, event);
635        }
636        let controlled_play = ctx.get::<ControlledPlayCalculator>()?;
637        for event in Self::events_since(&mut self.cursors.controlled_play, controlled_play.events())
638        {
639            self.state.controlled_play.apply_event(event);
640        }
641
642        self.finish_sample();
643        self.previous_live_play = Some(live_play);
644        Ok(())
645    }
646}
647
648impl AnalysisNode for StatsProjectionNode {
649    type State = StatsProjectionState;
650
651    fn name(&self) -> &'static str {
652        "stats_projection"
653    }
654
655    fn dependencies(&self) -> NodeDependencies {
656        vec![
657            frame_info_dependency(),
658            gameplay_state_dependency(),
659            live_play_dependency(),
660            player_frame_state_dependency(),
661            match_stats_dependency(),
662            backboard_dependency(),
663            ceiling_shot_dependency(),
664            wall_aerial_dependency(),
665            wall_aerial_shot_dependency(),
666            double_tap_dependency(),
667            one_timer_dependency(),
668            pass_dependency(),
669            fifty_fifty_dependency(),
670            kickoff_dependency(),
671            possession_dependency(),
672            ball_half_dependency(),
673            ball_third_dependency(),
674            territorial_pressure_dependency(),
675            rotation_dependency(),
676            rush_dependency(),
677            touch_dependency(),
678            whiff_dependency(),
679            wavedash_dependency(),
680            speed_flip_dependency(),
681            half_flip_dependency(),
682            flick_dependency(),
683            dodge_reset_dependency(),
684            ball_carry_dependency(),
685            boost_dependency(),
686            bump_dependency(),
687            half_volley_dependency(),
688            movement_dependency(),
689            positioning_dependency(),
690            powerslide_dependency(),
691            demo_dependency(),
692            center_dependency(),
693            controlled_play_dependency(),
694        ]
695    }
696
697    fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
698        self.project_frame(ctx)
699    }
700
701    fn finish(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
702        self.project_frame(ctx)?;
703        self.flush_possession_buffer_as_neutral();
704        self.warn_for_unresolved_boost_current_amount_drift();
705        Ok(())
706    }
707
708    fn state(&self) -> &Self::State {
709        &self.state
710    }
711}
712
713pub(crate) fn boxed_default() -> Box<dyn AnalysisNodeDyn> {
714    Box::new(StatsProjectionNode::new())
715}
716
717#[cfg(test)]
718#[path = "stats_projection_tests.rs"]
719mod tests;