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)]
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#[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 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 #[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 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 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 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 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}