subtr_actor/stats/accumulators/
boost.rs1use 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#[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 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 #[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 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 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 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 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}