Skip to main content

steel_core/player/
experience.rs

1use steel_protocol::packets::game::SoundSource;
2use steel_registry::sound_events;
3
4use crate::entity::Entity;
5
6use super::Player;
7
8/// Player experience state.
9///
10/// Level, progress, and total points are independent: commands and save data
11/// can change one without recomputing the others. The player's score belongs
12/// to [`super::Player`]'s synchronized entity data.
13#[derive(Default, Copy, Clone, Debug)]
14pub struct Experience {
15    level: i32,
16    progress: f32,
17    total_points: i32,
18    enchantment_seed: i32,
19    /// Whether the client experience packet needs to be sent.
20    pub dirty: bool,
21}
22
23impl Experience {
24    /// Builds a coherent experience state from a total point count.
25    #[must_use]
26    pub fn new(total_points: i32) -> Self {
27        let total_points = total_points.max(0);
28        let level = level_for_total_points(total_points);
29        let points_into_level = total_points - Self::total_points_at_level(level);
30        let points_for_level = Self::points_for_level(level);
31        let progress = if points_for_level > 0 {
32            points_into_level as f32 / points_for_level as f32
33        } else {
34            0.0
35        };
36
37        Self {
38            level,
39            progress,
40            total_points,
41            enchantment_seed: 0,
42            dirty: true,
43        }
44    }
45
46    /// Restores the three independent vanilla experience fields without normalizing them.
47    #[must_use]
48    pub const fn from_parts(level: i32, progress: f32, total_points: i32) -> Self {
49        Self {
50            level,
51            progress,
52            total_points,
53            enchantment_seed: 0,
54            dirty: true,
55        }
56    }
57
58    /// Points required to advance from `level` to `level + 1`.
59    #[must_use]
60    pub const fn points_for_level(level: i32) -> i32 {
61        if level >= 30 {
62            level.wrapping_sub(30).wrapping_mul(9).wrapping_add(112)
63        } else if level >= 15 {
64            level.wrapping_sub(15).wrapping_mul(5).wrapping_add(37)
65        } else {
66            level.wrapping_mul(2).wrapping_add(7)
67        }
68    }
69
70    /// Returns a coherent cumulative point count at the start of `level`.
71    ///
72    /// This is a Steel construction helper, not a field vanilla derives at runtime.
73    #[must_use]
74    pub const fn total_points_at_level(level: i32) -> i32 {
75        if level <= 0 {
76            return 0;
77        }
78
79        let level = level as i128;
80        let points = if level <= 15 {
81            level * level + 6 * level
82        } else if level <= 30 {
83            360 + level * (5 * level - 81) / 2
84        } else {
85            level * (9 * level - 325) / 2 + 2220
86        };
87        if points > i32::MAX as i128 {
88            i32::MAX
89        } else {
90            points as i32
91        }
92    }
93
94    /// Current experience level.
95    #[must_use]
96    pub const fn level(&self) -> i32 {
97        self.level
98    }
99
100    /// Experience points within the current level, matching `Mth.floor`.
101    #[must_use]
102    pub fn points(&self) -> i32 {
103        (self.progress * Self::points_for_level(self.level) as f32).floor() as i32
104    }
105
106    /// Total experience points earned, independent of the current level or progress.
107    #[must_use]
108    pub const fn total_points(&self) -> i32 {
109        self.total_points
110    }
111
112    /// Progress toward the next level.
113    #[must_use]
114    pub const fn progress(&self) -> f32 {
115        self.progress
116    }
117
118    /// Returns the enchantment seed; zero until loaded or the player first enchants
119    #[must_use]
120    pub const fn enchantment_seed(&self) -> i32 {
121        self.enchantment_seed
122    }
123
124    /// Returns this state with the given enchantment seed, used when restoring saves.
125    #[must_use]
126    pub const fn with_enchantment_seed(mut self, seed: i32) -> Self {
127        self.enchantment_seed = seed;
128        self
129    }
130
131    /// Replaces a missing or zero saved `XpSeed` with a seed drawn from `reroll`
132    #[must_use]
133    pub fn with_loaded_enchantment_seed(self, saved: i32, reroll: impl FnOnce() -> i32) -> Self {
134        let seed = if saved == 0 { reroll() } else { saved };
135        self.with_enchantment_seed(seed)
136    }
137
138    /// Adds levels with saturation, clearing progress and points if the result is negative.
139    pub const fn add_levels(&mut self, additional_levels: i32) {
140        if additional_levels == 0 {
141            return;
142        }
143
144        self.level = self.level.saturating_add(additional_levels);
145        if self.level < 0 {
146            self.level = 0;
147            self.progress = 0.0;
148            self.total_points = 0;
149        }
150        self.dirty = true;
151    }
152
153    /// Spends `enchantment_cost` levels and installs `next_seed`
154    pub const fn on_enchantment_performed(&mut self, enchantment_cost: i32, next_seed: i32) {
155        self.level = self.level.wrapping_sub(enchantment_cost);
156        if self.level < 0 {
157            self.level = 0;
158            self.progress = 0.0;
159            self.total_points = 0;
160        }
161        self.enchantment_seed = next_seed;
162        self.dirty = true;
163    }
164
165    /// Adds raw points, updating progress and level as thresholds are crossed.
166    #[expect(
167        clippy::cast_precision_loss,
168        reason = "vanilla performs these calculations with Java float precision"
169    )]
170    pub fn add_points(&mut self, additional_points: i32) {
171        if additional_points == 0 {
172            return;
173        }
174
175        self.progress += additional_points as f32 / Self::points_for_level(self.level) as f32;
176        self.total_points = self.total_points.wrapping_add(additional_points).max(0);
177
178        while self.progress < 0.0 {
179            let remaining = self.progress * Self::points_for_level(self.level) as f32;
180            if self.level > 0 {
181                self.add_levels(-1);
182                self.progress = 1.0 + remaining / Self::points_for_level(self.level) as f32;
183            } else {
184                self.add_levels(-1);
185                self.progress = 0.0;
186            }
187        }
188
189        while self.progress >= 1.0 {
190            self.progress = (self.progress - 1.0) * Self::points_for_level(self.level) as f32;
191            self.add_levels(1);
192            self.progress /= Self::points_for_level(self.level) as f32;
193        }
194
195        self.dirty = true;
196    }
197
198    /// Sets the current level without changing progress or total experience.
199    pub const fn set_levels(&mut self, level: i32) {
200        if self.level != level {
201            self.level = level;
202            self.dirty = true;
203        }
204    }
205
206    /// Whether `/experience set ... points` accepts `points` at the current level.
207    #[must_use]
208    pub const fn can_set_points(&self, points: i32) -> bool {
209        points >= 0 && points < Self::points_for_level(self.level)
210    }
211
212    /// Sets points within the current level like `ServerPlayer.setExperiencePoints`.
213    #[expect(
214        clippy::cast_precision_loss,
215        reason = "vanilla performs these calculations with Java float precision"
216    )]
217    pub fn set_points(&mut self, points: i32) {
218        let limit = Self::points_for_level(self.level) as f32;
219        let maximum = (limit - 1.0) / limit;
220        let requested = points as f32 / limit;
221        let progress = if requested < 0.0 {
222            0.0
223        } else {
224            requested.min(maximum)
225        };
226        if self.progress.to_bits() != progress.to_bits() {
227            self.progress = progress;
228            self.dirty = true;
229        }
230    }
231
232    /// Clears level, progress, and total experience.
233    pub const fn clear(&mut self) {
234        self.level = 0;
235        self.progress = 0.0;
236        self.total_points = 0;
237        self.dirty = true;
238    }
239
240    /// Base XP reward dropped on death: `min(level * 7, 100)`.
241    #[must_use]
242    pub const fn death_xp_reward(&self) -> i32 {
243        let reward = self.level.wrapping_mul(7);
244        if reward < 100 { reward } else { 100 }
245    }
246}
247
248fn level_for_total_points(total_points: i32) -> i32 {
249    let points = f64::from(total_points);
250    if points <= 315.0 {
251        return f64::midpoint(-6.0, f64::sqrt(36.0 + 4.0 * points)) as i32;
252    }
253    if points <= 1507.0 {
254        return ((40.5 + f64::sqrt(-1959.75 + 10.0 * points)) / 5.0) as i32;
255    }
256    ((162.5 + f64::sqrt(-13553.75 + 18.0 * points)) / 9.0) as i32
257}
258
259impl Player {
260    /// Returns the player's vanilla death-screen score.
261    #[must_use]
262    pub fn score(&self) -> i32 {
263        *self.entity_data.lock().score.get()
264    }
265
266    /// Sets the player's vanilla death-screen score.
267    pub fn set_score(&self, score: i32) {
268        self.entity_data.lock().score.set(score);
269    }
270
271    fn increase_score(&self, amount: i32) {
272        let mut entity_data = self.entity_data.lock();
273        let score = entity_data.score.get().wrapping_add(amount);
274        entity_data.score.set(score);
275    }
276
277    /// Gives raw experience points to this player.
278    pub(crate) fn give_experience_points(&self, points: i32) {
279        if points == 0 {
280            return;
281        }
282        self.increase_score(points);
283        let level_up_sound = {
284            let mut experience = self.experience.lock();
285            let old_level = experience.level();
286            experience.add_points(points);
287            first_point_level_up_sound(old_level, experience.level(), points)
288        };
289        if let Some(level) = level_up_sound {
290            self.play_experience_level_up_sound(level);
291        }
292    }
293
294    /// Gives experience levels to this player.
295    pub(crate) fn give_experience_levels(&self, levels: i32) {
296        let level_up_sound = {
297            let mut experience = self.experience.lock();
298            experience.add_levels(levels);
299            (levels > 0 && experience.level() % 5 == 0).then_some(experience.level())
300        };
301        if let Some(level) = level_up_sound {
302            self.play_experience_level_up_sound(level);
303        }
304    }
305
306    fn play_experience_level_up_sound(&self, level: i32) {
307        if !self.tick_state.lock().mark_level_up_sound_if_due() {
308            return;
309        }
310        let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
311        // Vanilla emits this directly through the level, regardless of the player's silent flag.
312        self.get_world().play_sound_at(
313            &sound_events::ENTITY_PLAYER_LEVELUP,
314            SoundSource::Players,
315            self.position(),
316            volume * 0.75,
317            1.0,
318            None,
319        );
320    }
321
322    /// Advances this player's local server tick count.
323    pub(super) fn advance_tick(&self) {
324        self.tick_state.lock().advance_tick();
325    }
326}
327
328pub(super) fn first_point_level_up_sound(
329    old_level: i32,
330    new_level: i32,
331    points: i32,
332) -> Option<i32> {
333    if points <= 0 || new_level <= old_level {
334        return None;
335    }
336    let first_multiple = (i64::from(old_level).div_euclid(5) + 1) * 5;
337    if first_multiple > i64::from(new_level) {
338        return None;
339    }
340    i32::try_from(first_multiple).ok()
341}