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