Skip to main content

subtr_actor/stats/accumulators/
boost.rs

1use super::*;
2use crate::stats::calculators::boost::{
3    boost_activity_label, boost_field_half_label, boost_pad_size_label, boost_supersonic_label,
4    boost_transaction_label,
5};
6use crate::stats::common::vertical_state_label;
7
8const BOOST_ZERO_BAND_RAW: f32 = 1.0;
9const BOOST_FULL_BAND_MIN_RAW: f32 = BOOST_MAX_AMOUNT - 1.0;
10
11/// Accumulated boost stats: time in boost bands, boost integral, and pads collected/stolen/used.
12#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
13#[ts(export)]
14pub struct BoostStats {
15    pub tracked_time: f32,
16    pub boost_integral: f32,
17    pub time_zero_boost: f32,
18    pub time_hundred_boost: f32,
19    pub time_boost_0_25: f32,
20    pub time_boost_25_50: f32,
21    pub time_boost_50_75: f32,
22    pub time_boost_75_100: f32,
23    pub amount_collected: f32,
24    pub amount_collected_inactive: f32,
25    pub big_pads_collected_inactive: u32,
26    pub small_pads_collected_inactive: u32,
27    pub amount_stolen: f32,
28    pub big_pads_collected: u32,
29    pub small_pads_collected: u32,
30    pub big_pads_stolen: u32,
31    pub small_pads_stolen: u32,
32    pub amount_collected_big: f32,
33    pub amount_stolen_big: f32,
34    pub amount_collected_small: f32,
35    pub amount_stolen_small: f32,
36    pub amount_respawned: f32,
37    pub overfill_total: f32,
38    pub overfill_from_stolen: f32,
39    pub amount_used: f32,
40    pub amount_used_while_grounded: f32,
41    pub amount_used_while_airborne: f32,
42    pub amount_used_while_supersonic: f32,
43    #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")]
44    pub labeled_amounts: LabeledFloatSums,
45    #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
46    pub labeled_counts: LabeledCounts,
47}
48
49impl BoostStats {
50    pub fn average_boost_amount(&self) -> f32 {
51        if self.tracked_time == 0.0 {
52            0.0
53        } else {
54            self.boost_integral / self.tracked_time
55        }
56    }
57
58    pub fn bpm(&self) -> f32 {
59        if self.tracked_time == 0.0 {
60            0.0
61        } else {
62            self.amount_collected * 60.0 / self.tracked_time
63        }
64    }
65
66    fn pct(&self, value: f32) -> f32 {
67        if self.tracked_time == 0.0 {
68            0.0
69        } else {
70            value * 100.0 / self.tracked_time
71        }
72    }
73
74    pub fn zero_boost_pct(&self) -> f32 {
75        self.pct(self.time_zero_boost)
76    }
77
78    pub fn hundred_boost_pct(&self) -> f32 {
79        self.pct(self.time_hundred_boost)
80    }
81
82    pub fn boost_0_25_pct(&self) -> f32 {
83        self.pct(self.time_boost_0_25)
84    }
85
86    pub fn boost_25_50_pct(&self) -> f32 {
87        self.pct(self.time_boost_25_50)
88    }
89
90    pub fn boost_50_75_pct(&self) -> f32 {
91        self.pct(self.time_boost_50_75)
92    }
93
94    pub fn boost_75_100_pct(&self) -> f32 {
95        self.pct(self.time_boost_75_100)
96    }
97
98    pub fn amount_obtained(&self) -> f32 {
99        self.amount_collected_big + self.amount_collected_small + self.amount_respawned
100    }
101
102    pub fn amount_used_by_vertical_band(&self) -> f32 {
103        self.amount_used_while_grounded + self.amount_used_while_airborne
104    }
105
106    pub(crate) fn add_labeled_amount<I>(&mut self, labels: I, amount: f32)
107    where
108        I: IntoIterator<Item = StatLabel>,
109    {
110        if amount > 0.0 {
111            self.labeled_amounts.add(labels, amount);
112        }
113    }
114
115    pub(crate) fn increment_labeled_count<I>(&mut self, labels: I)
116    where
117        I: IntoIterator<Item = StatLabel>,
118    {
119        self.labeled_counts.increment(labels);
120    }
121}
122
123#[derive(Debug, Clone, Default, PartialEq)]
124struct PlayerBoostProjection {
125    stats: BoostStats,
126    is_team_0: Option<bool>,
127}
128
129/// Accumulates [`BoostStats`] from typed boost transitions.
130///
131/// This replaces the former label-dispatched ledger projection: callers invoke the typed
132/// `apply_*` methods directly (from the boost calculator) instead of constructing string-labeled
133/// ledger events and re-parsing them here. The per-player and per-team arithmetic is identical;
134/// only the representation changed. `labeled_amounts`/`labeled_counts` are still populated so the
135/// exported labeled boost stats stay populated, now built from the clean pickup model.
136#[derive(Debug, Clone, Default, PartialEq)]
137pub struct BoostStatsAccumulator {
138    players: HashMap<PlayerId, PlayerBoostProjection>,
139    player_stats: HashMap<PlayerId, BoostStats>,
140    team_zero: BoostStats,
141    team_one: BoostStats,
142}
143
144impl BoostStatsAccumulator {
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    pub fn player_stats(&self) -> &HashMap<PlayerId, BoostStats> {
150        &self.player_stats
151    }
152
153    pub fn player_stats_for(&self, player_id: &PlayerId) -> BoostStats {
154        self.player_stats
155            .get(player_id)
156            .cloned()
157            .unwrap_or_default()
158    }
159
160    pub fn team_zero_stats(&self) -> &BoostStats {
161        &self.team_zero
162    }
163
164    pub fn team_one_stats(&self) -> &BoostStats {
165        &self.team_one
166    }
167
168    /// Apply an additive `update` to both the player's stats and their team's stats, refreshing
169    /// the cached per-player snapshot. `update` runs once per stats object.
170    fn project(&mut self, player_id: &PlayerId, is_team_0: bool, update: impl Fn(&mut BoostStats)) {
171        let player = self.players.entry(player_id.clone()).or_default();
172        player.is_team_0 = Some(is_team_0);
173        update(&mut player.stats);
174        self.player_stats
175            .insert(player_id.clone(), player.stats.clone());
176        let team = if is_team_0 {
177            &mut self.team_zero
178        } else {
179            &mut self.team_one
180        };
181        update(team);
182    }
183
184    /// Record a single boost pickup, folding the former Collected/Stolen/Overfill ledger
185    /// transactions into one transition. `collected_amount` is the full boost gained; the
186    /// per-pickup totals here sum identically to the old split ledger entries.
187    #[allow(clippy::too_many_arguments)]
188    pub fn apply_pickup(
189        &mut self,
190        player_id: &PlayerId,
191        is_team_0: bool,
192        pad_size: Option<BoostPadSize>,
193        activity: BoostPickupActivity,
194        field_half: BoostPickupFieldHalf,
195        is_steal: bool,
196        collected_amount: f32,
197        overfill_amount: f32,
198    ) {
199        self.project(player_id, is_team_0, |stats| {
200            let labels = |transaction: &'static str| -> Vec<StatLabel> {
201                vec![
202                    boost_transaction_label(transaction),
203                    boost_pad_size_label(pad_size),
204                    boost_activity_label(activity),
205                    boost_field_half_label(field_half),
206                ]
207            };
208            stats.add_labeled_amount(labels("collected"), collected_amount);
209            stats.increment_labeled_count(labels("collected"));
210            if matches!(activity, BoostPickupActivity::Inactive) {
211                stats.amount_collected_inactive += collected_amount;
212                match pad_size {
213                    Some(BoostPadSize::Big) => stats.big_pads_collected_inactive += 1,
214                    Some(BoostPadSize::Small) => stats.small_pads_collected_inactive += 1,
215                    None => {}
216                }
217            } else {
218                stats.amount_collected += collected_amount;
219                match pad_size {
220                    Some(BoostPadSize::Big) => {
221                        stats.amount_collected_big += collected_amount;
222                        stats.big_pads_collected += 1;
223                    }
224                    Some(BoostPadSize::Small) => {
225                        stats.amount_collected_small += collected_amount;
226                        stats.small_pads_collected += 1;
227                    }
228                    None => {}
229                }
230            }
231            if is_steal {
232                stats.add_labeled_amount(labels("stolen"), collected_amount);
233                stats.amount_stolen += collected_amount;
234                match pad_size {
235                    Some(BoostPadSize::Big) => {
236                        stats.big_pads_stolen += 1;
237                        stats.amount_stolen_big += collected_amount;
238                    }
239                    Some(BoostPadSize::Small) => {
240                        stats.small_pads_stolen += 1;
241                        stats.amount_stolen_small += collected_amount;
242                    }
243                    None => {}
244                }
245            }
246            if overfill_amount > 0.0 {
247                stats.add_labeled_amount(labels("overfill"), overfill_amount);
248                stats.overfill_total += overfill_amount;
249                if matches!(field_half, BoostPickupFieldHalf::Opponent) {
250                    stats.overfill_from_stolen += overfill_amount;
251                }
252            }
253        });
254    }
255
256    /// Record a respawn boost grant (kickoff or demo respawn).
257    pub fn apply_respawn(&mut self, player_id: &PlayerId, is_team_0: bool, amount: f32) {
258        if amount <= 0.0 {
259            return;
260        }
261        self.project(player_id, is_team_0, |stats| {
262            stats.add_labeled_amount(vec![boost_transaction_label("respawn")], amount);
263            stats.amount_respawned += amount;
264        });
265    }
266
267    /// Record cumulative boost usage for a frame (total drained).
268    pub fn apply_used(&mut self, player_id: &PlayerId, is_team_0: bool, amount: f32) {
269        if amount <= 0.0 {
270            return;
271        }
272        self.project(player_id, is_team_0, |stats| {
273            stats.amount_used += amount;
274        });
275    }
276
277    /// Record the vertical-band / supersonic breakdown of boost usage for a frame.
278    pub fn apply_used_allocation(
279        &mut self,
280        player_id: &PlayerId,
281        is_team_0: bool,
282        amount: f32,
283        grounded: bool,
284        supersonic: bool,
285    ) {
286        if amount <= 0.0 {
287            return;
288        }
289        self.project(player_id, is_team_0, |stats| {
290            stats.add_labeled_amount(
291                vec![
292                    boost_transaction_label("used"),
293                    vertical_state_label(!grounded),
294                    boost_supersonic_label(supersonic),
295                ],
296                amount,
297            );
298            if grounded {
299                stats.amount_used_while_grounded += amount;
300            } else {
301                stats.amount_used_while_airborne += amount;
302            }
303            if supersonic {
304                stats.amount_used_while_supersonic += amount;
305            }
306        });
307    }
308
309    /// Record a continuous per-frame boost-amount sample (drives the boost integral and the
310    /// time-in-band totals).
311    pub fn apply_boost_sample(
312        &mut self,
313        player_id: &PlayerId,
314        is_team_0: bool,
315        previous_boost_amount: f32,
316        boost_amount: f32,
317        dt: f32,
318    ) {
319        self.project(player_id, is_team_0, |stats| {
320            Self::add_continuous_boost_sample(stats, previous_boost_amount, boost_amount, dt);
321        });
322    }
323
324    fn add_continuous_boost_sample(
325        stats: &mut BoostStats,
326        previous_boost_amount: f32,
327        boost_amount: f32,
328        dt: f32,
329    ) {
330        let average_boost_amount = (previous_boost_amount + boost_amount) * 0.5;
331        stats.tracked_time += dt;
332        stats.boost_integral += average_boost_amount * dt;
333        stats.time_zero_boost += dt
334            * Self::interval_fraction_in_boost_range(
335                previous_boost_amount,
336                boost_amount,
337                0.0,
338                BOOST_ZERO_BAND_RAW,
339            );
340        stats.time_hundred_boost += dt
341            * Self::interval_fraction_in_boost_range(
342                previous_boost_amount,
343                boost_amount,
344                BOOST_FULL_BAND_MIN_RAW,
345                BOOST_MAX_AMOUNT + 1.0,
346            );
347        stats.time_boost_0_25 += dt
348            * Self::interval_fraction_in_boost_range(
349                previous_boost_amount,
350                boost_amount,
351                0.0,
352                boost_percent_to_amount(25.0),
353            );
354        stats.time_boost_25_50 += dt
355            * Self::interval_fraction_in_boost_range(
356                previous_boost_amount,
357                boost_amount,
358                boost_percent_to_amount(25.0),
359                boost_percent_to_amount(50.0),
360            );
361        stats.time_boost_50_75 += dt
362            * Self::interval_fraction_in_boost_range(
363                previous_boost_amount,
364                boost_amount,
365                boost_percent_to_amount(50.0),
366                boost_percent_to_amount(75.0),
367            );
368        stats.time_boost_75_100 += dt
369            * Self::interval_fraction_in_boost_range(
370                previous_boost_amount,
371                boost_amount,
372                boost_percent_to_amount(75.0),
373                BOOST_MAX_AMOUNT + 1.0,
374            );
375    }
376
377    fn interval_fraction_in_boost_range(
378        start_boost: f32,
379        end_boost: f32,
380        min_boost: f32,
381        max_boost: f32,
382    ) -> f32 {
383        if (end_boost - start_boost).abs() <= f32::EPSILON {
384            return ((start_boost >= min_boost) && (start_boost < max_boost)) as i32 as f32;
385        }
386
387        let t_at_min = (min_boost - start_boost) / (end_boost - start_boost);
388        let t_at_max = (max_boost - start_boost) / (end_boost - start_boost);
389        let interval_start = t_at_min.min(t_at_max).max(0.0);
390        let interval_end = t_at_min.max(t_at_max).min(1.0);
391        (interval_end - interval_start).max(0.0)
392    }
393}