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