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#[derive(Debug, Clone, Default)]
8pub struct StatsProjectionState {
9    pub core: CoreStatsAccumulator,
10    pub backboard: BackboardStatsAccumulator,
11    pub ceiling_shot: CeilingShotStatsAccumulator,
12    pub wall_aerial: WallAerialStatsAccumulator,
13    pub wall_aerial_shot: WallAerialShotStatsAccumulator,
14    pub double_tap: DoubleTapStatsAccumulator,
15    pub one_timer: OneTimerStatsAccumulator,
16    pub pass: PassStatsAccumulator,
17    pub fifty_fifty: FiftyFiftyStatsAccumulator,
18    pub kickoff: KickoffStatsAccumulator,
19    pub possession: PossessionStatsAccumulator,
20    pub ball_half: BallHalfStatsAccumulator,
21    pub territorial_pressure: TerritorialPressureStatsAccumulator,
22    pub rotation: RotationStatsAccumulator,
23    pub rush: RushStatsAccumulator,
24    pub touch: TouchStatsAccumulator,
25    pub whiff: WhiffStatsAccumulator,
26    pub wavedash: WavedashStatsAccumulator,
27    pub speed_flip: SpeedFlipStatsAccumulator,
28    pub half_flip: HalfFlipStatsAccumulator,
29    pub flick: FlickStatsAccumulator,
30    pub musty_flick: MustyFlickStatsAccumulator,
31    pub dodge_reset: DodgeResetStatsAccumulator,
32    pub ball_carry: BallCarryStatsAccumulator,
33    pub boost: BoostStatsAccumulator,
34    pub bump: BumpStatsAccumulator,
35    pub half_volley: HalfVolleyStatsAccumulator,
36    pub movement: MovementStatsAccumulator,
37    pub positioning: PositioningStatsAccumulator,
38    pub powerslide: PowerslideStatsAccumulator,
39    pub demo: DemoStatsAccumulator,
40    pub center: CenterStatsAccumulator,
41    pub controlled_play: ControlledPlayStatsAccumulator,
42}
43
44#[derive(Debug, Clone, Default)]
45struct PowerslideProjectionState {
46    active_players: HashMap<PlayerId, bool>,
47    player_teams: HashMap<PlayerId, bool>,
48}
49
50impl PowerslideProjectionState {
51    fn apply_frame(
52        &mut self,
53        stats: &mut PowerslideStatsAccumulator,
54        frame: &FrameInfo,
55        events: &[PowerslideEvent],
56        counts_toward_motion: bool,
57    ) {
58        let mut started_this_frame = HashSet::new();
59        for event in events {
60            self.player_teams
61                .insert(event.player.clone(), event.is_team_0);
62            if event.active {
63                stats.apply_sample(
64                    &event.player,
65                    event.is_team_0,
66                    true,
67                    false,
68                    frame.dt,
69                    counts_toward_motion,
70                );
71                self.active_players.insert(event.player.clone(), true);
72                started_this_frame.insert(event.player.clone());
73            } else {
74                self.active_players.insert(event.player.clone(), false);
75            }
76        }
77
78        let active_players = self
79            .active_players
80            .iter()
81            .filter(|(player_id, active)| **active && !started_this_frame.contains(*player_id))
82            .map(|(player_id, _)| player_id.clone())
83            .collect::<Vec<_>>();
84        for player_id in active_players {
85            let Some(is_team_0) = self.player_teams.get(&player_id).copied() else {
86                continue;
87            };
88            stats.apply_sample(
89                &player_id,
90                is_team_0,
91                true,
92                true,
93                frame.dt,
94                counts_toward_motion,
95            );
96        }
97    }
98}
99
100#[derive(Debug, Clone, Default)]
101pub struct StatsProjectionNode {
102    state: StatsProjectionState,
103    cursors: StatsProjectionCursors,
104    powerslide: PowerslideProjectionState,
105    boost_current_amount_consistency: BoostCurrentAmountConsistencyTracker,
106    last_powerslide_sample_frame: Option<usize>,
107    territorial_pressure_tracked_time: f32,
108    previous_live_play: Option<bool>,
109}
110
111#[derive(Debug, Clone, Default)]
112struct StatsProjectionCursors {
113    core_player: usize,
114    core_player_goal_context: usize,
115    backboard: usize,
116    ceiling_shot: usize,
117    wall_aerial: usize,
118    wall_aerial_shot: usize,
119    double_tap: usize,
120    one_timer: usize,
121    pass: usize,
122    fifty_fifty: usize,
123    kickoff: usize,
124    possession: usize,
125    ball_half: usize,
126    rush: usize,
127    touch: usize,
128    whiff: usize,
129    wavedash: usize,
130    speed_flip: usize,
131    half_flip: usize,
132    flick: usize,
133    musty_flick: usize,
134    dodge_reset: usize,
135    dodge_reset_flip_reset_outcome: usize,
136    ball_carry: usize,
137    bump: usize,
138    half_volley: usize,
139    movement: usize,
140    powerslide: usize,
141    demo_timeline: usize,
142    center: usize,
143    controlled_play: usize,
144}
145
146impl StatsProjectionNode {
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    fn begin_sample(&mut self, frame: &FrameInfo, live_play: bool) {
152        self.state.backboard.begin_sample(frame);
153        self.state.center.begin_sample(frame);
154        self.state.double_tap.begin_sample(frame);
155        self.state.half_volley.begin_sample(frame);
156        self.state.one_timer.begin_sample(frame);
157        self.state.pass.begin_sample(frame);
158        self.state.wall_aerial.begin_sample(frame);
159        self.state.wall_aerial_shot.begin_sample(frame);
160
161        if !live_play {
162            self.state.center.clear_current_last();
163            self.state.one_timer.clear_current_last();
164            self.state.pass.clear_current_last();
165            self.state.half_volley.reset_current_last_event_marker();
166            self.state.touch.set_current_last_touch_player(None);
167            self.state.wall_aerial.reset_current_last_event_marker();
168            self.state
169                .wall_aerial_shot
170                .reset_current_last_event_marker();
171        }
172
173        if live_play && self.previous_live_play == Some(false) {
174            self.state.ceiling_shot.reset_current_last_event_marker();
175            self.state.flick.reset_current_last_event_marker();
176            self.state.half_flip.reset_current_last_event_marker();
177            self.state.musty_flick.reset_current_last_event_marker();
178            self.state.wavedash.reset_current_last_event_marker();
179            self.state.whiff.reset_current_last_event_marker();
180        }
181
182        if live_play {
183            self.state.ceiling_shot.begin_sample(frame);
184            self.state.flick.begin_sample(frame);
185            self.state.half_flip.begin_sample(frame);
186            self.state.musty_flick.begin_sample(frame);
187            self.state.touch.begin_sample(frame);
188            self.state.wavedash.begin_sample(frame);
189            self.state.whiff.begin_sample(frame);
190        }
191    }
192
193    fn finish_sample(&mut self) {
194        self.state.center.finish_sample();
195        self.state.double_tap.finish_sample();
196        self.state.one_timer.finish_sample();
197        self.state.pass.finish_sample();
198        self.state.half_volley.restore_current_last_event_marker();
199        self.state.touch.restore_current_last_touch_marker();
200        self.state.wall_aerial.restore_current_last_event_marker();
201        self.state
202            .wall_aerial_shot
203            .restore_current_last_event_marker();
204        self.state.whiff.restore_current_last_event_marker();
205    }
206
207    fn events_since<'a, E>(cursor: &mut usize, events: &'a [E]) -> &'a [E] {
208        let start = (*cursor).min(events.len());
209        *cursor = events.len();
210        &events[start..]
211    }
212
213    fn check_boost_current_amount_consistency(
214        &mut self,
215        frame: &FrameInfo,
216        players: &PlayerFrameState,
217    ) {
218        for player in &players.players {
219            if player.boost_active {
220                continue;
221            }
222            let Some(observed_byte) = player
223                .last_boost_amount
224                .map(|amount| amount.round().clamp(0.0, BOOST_MAX_AMOUNT) as u8)
225            else {
226                continue;
227            };
228            let stats = self.state.boost.player_stats_for(&player.player_id);
229            self.boost_current_amount_consistency.observe(
230                frame.frame_number,
231                frame.time,
232                &player.player_id,
233                &stats,
234                observed_byte,
235            );
236        }
237    }
238
239    fn warn_for_unresolved_boost_current_amount_drift(&self) {
240        for warning in self.boost_current_amount_consistency.unresolved_warnings() {
241            log::warn!(
242                "Boost invariant violation for player {:?} at frame {} (t={:.3}): {}",
243                warning.player_id,
244                warning.frame,
245                warning.time,
246                warning.message(),
247            );
248        }
249    }
250
251    fn project_frame(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
252        let frame = ctx.get::<FrameInfo>()?;
253        let players = ctx.get::<PlayerFrameState>()?;
254        let live_play_state = ctx.get::<LivePlayState>()?;
255        let live_play = live_play_state.is_live_play;
256        let counts_toward_powerslide_motion = matches!(
257            live_play_state.gameplay_phase,
258            GameplayPhase::ActivePlay | GameplayPhase::KickoffWaitingForTouch
259        );
260        let gameplay = ctx.get::<GameplayState>()?;
261        let speed_flip_stats_advance = live_play || gameplay.ball_has_been_hit == Some(false);
262        let should_sample_powerslide =
263            self.last_powerslide_sample_frame != Some(frame.frame_number);
264        self.begin_sample(frame, live_play);
265
266        let match_stats = ctx.get::<MatchStatsCalculator>()?;
267        for event in Self::events_since(
268            &mut self.cursors.core_player,
269            match_stats.core_player_events(),
270        ) {
271            self.state.core.apply_scoreboard_event(event);
272        }
273        for event in Self::events_since(
274            &mut self.cursors.core_player_goal_context,
275            match_stats.core_player_goal_context_events(),
276        ) {
277            self.state.core.apply_goal_context_event(event);
278        }
279
280        let backboard = ctx.get::<BackboardCalculator>()?;
281        self.state.backboard.apply_events(
282            frame,
283            Self::events_since(&mut self.cursors.backboard, backboard.events()),
284        );
285
286        let ceiling_shot = ctx.get::<CeilingShotCalculator>()?;
287        if live_play {
288            for event in Self::events_since(&mut self.cursors.ceiling_shot, ceiling_shot.events()) {
289                self.state.ceiling_shot.apply_event(event, frame);
290            }
291        }
292        let wall_aerial = ctx.get::<WallAerialCalculator>()?;
293        if live_play {
294            for event in Self::events_since(&mut self.cursors.wall_aerial, wall_aerial.events()) {
295                self.state.wall_aerial.apply_event(event, frame);
296            }
297        }
298        let wall_aerial_shot = ctx.get::<WallAerialShotCalculator>()?;
299        if live_play {
300            for event in Self::events_since(
301                &mut self.cursors.wall_aerial_shot,
302                wall_aerial_shot.events(),
303            ) {
304                self.state.wall_aerial_shot.apply_event(event, frame);
305            }
306        }
307        let double_tap = ctx.get::<DoubleTapCalculator>()?;
308        for event in Self::events_since(&mut self.cursors.double_tap, double_tap.events()) {
309            self.state.double_tap.apply_event(frame, event);
310        }
311        let one_timer = ctx.get::<OneTimerCalculator>()?;
312        if live_play {
313            for event in Self::events_since(&mut self.cursors.one_timer, one_timer.events()) {
314                self.state.one_timer.apply_event(frame, event);
315            }
316        }
317        let pass = ctx.get::<PassCalculator>()?;
318        if live_play {
319            for event in Self::events_since(&mut self.cursors.pass, pass.events()) {
320                self.state.pass.apply_event(frame, event);
321            }
322        }
323        let fifty_fifty = ctx.get::<FiftyFiftyCalculator>()?;
324        for event in Self::events_since(&mut self.cursors.fifty_fifty, fifty_fifty.events()) {
325            self.state.fifty_fifty.apply_event(event);
326        }
327        let kickoff = ctx.get::<KickoffCalculator>()?;
328        for event in Self::events_since(&mut self.cursors.kickoff, kickoff.events()) {
329            self.state.kickoff.apply_event(event);
330        }
331        let possession = ctx.get::<PossessionCalculator>()?;
332        let projected_possession_events = possession.projected_events();
333        self.state.possession = PossessionStatsAccumulator::default();
334        for event in projected_possession_events.iter() {
335            self.state.possession.apply_event(event);
336        }
337        self.cursors.possession = possession.events().len();
338        let ball_half = ctx.get::<BallHalfCalculator>()?;
339        let projected_ball_half_events = ball_half.projected_events();
340        self.state.ball_half = BallHalfStatsAccumulator::default();
341        for event in projected_ball_half_events.iter() {
342            self.state.ball_half.apply_event(event);
343        }
344        self.cursors.ball_half = ball_half.events().len();
345        let territorial_pressure = ctx.get::<TerritorialPressureCalculator>()?;
346        if live_play {
347            self.territorial_pressure_tracked_time += frame.dt;
348        }
349        let projected_territorial_pressure_events = territorial_pressure.projected_events();
350        self.state.territorial_pressure = TerritorialPressureStatsAccumulator::default();
351        self.state
352            .territorial_pressure
353            .set_tracked_time(self.territorial_pressure_tracked_time);
354        for event in projected_territorial_pressure_events.iter() {
355            self.state.territorial_pressure.apply_event(event);
356        }
357        let rotation = ctx.get::<RotationCalculator>()?;
358        self.state.rotation = RotationStatsAccumulator::with_first_man_stint_end_grace_seconds(
359            rotation.config().first_man_stint_end_grace_seconds,
360        );
361        for event in rotation.role_events().iter() {
362            self.state.rotation.apply_role_event(event);
363        }
364        for event in rotation.first_man_change_events() {
365            self.state.rotation.apply_first_man_change_event(event);
366        }
367        let rush = ctx.get::<RushCalculator>()?;
368        for event in Self::events_since(&mut self.cursors.rush, rush.events()) {
369            self.state.rush.apply_event(event);
370        }
371        let touch = ctx.get::<TouchCalculator>()?;
372        if live_play || self.cursors.touch != touch.events().len() {
373            self.state.touch = TouchStatsAccumulator::default();
374            for event in touch.events() {
375                self.state.touch.apply_touch_event(event, frame);
376            }
377            self.cursors.touch = touch.events().len();
378        }
379        let whiff = ctx.get::<WhiffCalculator>()?;
380        if live_play {
381            for event in Self::events_since(&mut self.cursors.whiff, whiff.events()) {
382                self.state.whiff.apply_event(event, frame);
383            }
384        }
385        let wavedash = ctx.get::<WavedashCalculator>()?;
386        if live_play {
387            for event in Self::events_since(&mut self.cursors.wavedash, wavedash.events()) {
388                self.state.wavedash.apply_event(event);
389            }
390        }
391        let speed_flip = ctx.get::<SpeedFlipCalculator>()?;
392        if speed_flip_stats_advance {
393            self.state.speed_flip.begin_sample(frame);
394            for event in Self::events_since(&mut self.cursors.speed_flip, speed_flip.events()) {
395                self.state.speed_flip.apply_event(event);
396            }
397        }
398        let half_flip = ctx.get::<HalfFlipCalculator>()?;
399        if live_play {
400            for event in Self::events_since(&mut self.cursors.half_flip, half_flip.events()) {
401                self.state.half_flip.apply_event(event);
402            }
403        }
404        let flick = ctx.get::<FlickCalculator>()?;
405        if live_play {
406            for event in Self::events_since(&mut self.cursors.flick, flick.events()) {
407                self.state.flick.apply_event(event, frame);
408            }
409        }
410        let musty_flick = ctx.get::<MustyFlickCalculator>()?;
411        if live_play {
412            for event in Self::events_since(&mut self.cursors.musty_flick, musty_flick.events()) {
413                self.state.musty_flick.apply_event(event, frame);
414            }
415        }
416        let dodge_reset = ctx.get::<DodgeResetCalculator>()?;
417        for event in Self::events_since(&mut self.cursors.dodge_reset, dodge_reset.events()) {
418            self.state.dodge_reset.apply_event(event);
419        }
420        for event in Self::events_since(
421            &mut self.cursors.dodge_reset_flip_reset_outcome,
422            dodge_reset.flip_reset_outcome_events(),
423        ) {
424            self.state.dodge_reset.apply_flip_reset_outcome_event(event);
425        }
426        let ball_carry = ctx.get::<BallCarryCalculator>()?;
427        for event in Self::events_since(&mut self.cursors.ball_carry, ball_carry.carry_events()) {
428            self.state.ball_carry.apply_event(event);
429        }
430        let boost = ctx.get::<BoostCalculator>()?;
431        // The boost calculator now accumulates BoostStats directly as it processes frames, so we
432        // mirror its accumulator instead of replaying projected ledger/state events.
433        self.state.boost = boost.boost_stats().clone();
434        if live_play {
435            self.check_boost_current_amount_consistency(frame, players);
436        }
437        let bump = ctx.get::<BumpCalculator>()?;
438        for event in Self::events_since(&mut self.cursors.bump, bump.events()) {
439            self.state.bump.apply_event(event);
440        }
441        let half_volley = ctx.get::<HalfVolleyCalculator>()?;
442        if live_play {
443            for event in Self::events_since(&mut self.cursors.half_volley, half_volley.events()) {
444                self.state.half_volley.apply_event(event, frame);
445            }
446        }
447        let movement = ctx.get::<MovementCalculator>()?;
448        let projected_movement_events = movement.projected_events();
449        self.state.movement = MovementStatsAccumulator::default();
450        for event in projected_movement_events.iter() {
451            self.state.movement.apply_event(event);
452        }
453        self.cursors.movement = movement.events().len();
454        let positioning = ctx.get::<PositioningCalculator>()?;
455        self.state.positioning = PositioningStatsAccumulator::default();
456        for event in positioning.activity_events().iter() {
457            self.state.positioning.apply_activity_event(event);
458        }
459        for event in positioning.field_third_events().iter() {
460            self.state.positioning.apply_field_third_event(event);
461        }
462        for event in positioning.field_half_events().iter() {
463            self.state.positioning.apply_field_half_event(event);
464        }
465        for event in positioning.ball_depth_events().iter() {
466            self.state.positioning.apply_ball_depth_event(event);
467        }
468        for event in positioning.depth_role_events().iter() {
469            self.state.positioning.apply_depth_role_event(event);
470        }
471        for event in positioning.ball_proximity_events().iter() {
472            self.state.positioning.apply_ball_proximity_event(event);
473        }
474        for (player, signal) in positioning.signals() {
475            self.state.positioning.apply_signal(player, signal);
476        }
477        let powerslide = ctx.get::<PowerslideCalculator>()?;
478        let powerslide_events =
479            Self::events_since(&mut self.cursors.powerslide, powerslide.events());
480        if should_sample_powerslide {
481            self.powerslide.apply_frame(
482                &mut self.state.powerslide,
483                frame,
484                powerslide_events,
485                counts_toward_powerslide_motion,
486            );
487            self.last_powerslide_sample_frame = Some(frame.frame_number);
488        }
489        let demo = ctx.get::<DemoCalculator>()?;
490        for event in Self::events_since(&mut self.cursors.demo_timeline, demo.timeline()) {
491            self.state.demo.apply_timeline_event(event);
492        }
493        let center = ctx.get::<CenterCalculator>()?;
494        for event in Self::events_since(&mut self.cursors.center, center.events()) {
495            self.state.center.apply_event(frame, event);
496        }
497        let controlled_play = ctx.get::<ControlledPlayCalculator>()?;
498        for event in Self::events_since(&mut self.cursors.controlled_play, controlled_play.events())
499        {
500            self.state.controlled_play.apply_event(event);
501        }
502
503        self.finish_sample();
504        self.previous_live_play = Some(live_play);
505        Ok(())
506    }
507}
508
509impl AnalysisNode for StatsProjectionNode {
510    type State = StatsProjectionState;
511
512    fn name(&self) -> &'static str {
513        "stats_projection"
514    }
515
516    fn dependencies(&self) -> NodeDependencies {
517        vec![
518            frame_info_dependency(),
519            gameplay_state_dependency(),
520            live_play_dependency(),
521            player_frame_state_dependency(),
522            match_stats_dependency(),
523            backboard_dependency(),
524            ceiling_shot_dependency(),
525            wall_aerial_dependency(),
526            wall_aerial_shot_dependency(),
527            double_tap_dependency(),
528            one_timer_dependency(),
529            pass_dependency(),
530            fifty_fifty_dependency(),
531            kickoff_dependency(),
532            possession_dependency(),
533            ball_half_dependency(),
534            territorial_pressure_dependency(),
535            rotation_dependency(),
536            rush_dependency(),
537            touch_dependency(),
538            whiff_dependency(),
539            wavedash_dependency(),
540            speed_flip_dependency(),
541            half_flip_dependency(),
542            flick_dependency(),
543            musty_flick_dependency(),
544            dodge_reset_dependency(),
545            ball_carry_dependency(),
546            boost_dependency(),
547            bump_dependency(),
548            half_volley_dependency(),
549            movement_dependency(),
550            positioning_dependency(),
551            powerslide_dependency(),
552            demo_dependency(),
553            center_dependency(),
554            controlled_play_dependency(),
555        ]
556    }
557
558    fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
559        self.project_frame(ctx)
560    }
561
562    fn finish(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
563        self.project_frame(ctx)?;
564        self.warn_for_unresolved_boost_current_amount_drift();
565        Ok(())
566    }
567
568    fn state(&self) -> &Self::State {
569        &self.state
570    }
571}
572
573pub(crate) fn boxed_default() -> Box<dyn AnalysisNodeDyn> {
574    Box::new(StatsProjectionNode::new())
575}