Skip to main content

steel_core/player/
food_data.rs

1//! Player food/hunger system.
2//!
3//! Manages food level, saturation, and exhaustion — the three values that
4//! control natural health regeneration and starvation damage.
5
6use steel_registry::vanilla_damage_types;
7use steel_registry::vanilla_game_rules::NATURAL_HEALTH_REGENERATION;
8use steel_utils::types::Difficulty;
9
10use crate::entity::LivingEntity;
11use crate::entity::damage::DamageSource;
12use crate::player::Player;
13
14/// All food-system constants bundled in one place.
15pub mod food_constants {
16    /// Maximum food level of a player
17    pub const MAX_FOOD_LEVEL: i32 = 20;
18
19    /// Maximum saturation level
20    pub const MAX_SATURATION: f32 = 20.0;
21
22    /// Default saturation for a freshly spawned player
23    pub const DEFAULT_SATURATION: f32 = 5.0;
24
25    /// Saturation floor used by some food items
26    pub const SATURATION_FLOOR: f32 = 2.5;
27
28    /// Exhaustion threshold
29    pub const EXHAUSTION_DROP: f32 = 4.0;
30
31    /// Slow regeneration interval ticks
32    pub const HEALTH_TICK_COUNT: i32 = 80;
33
34    /// Fast regeneration interval ticks
35    pub const HEALTH_TICK_COUNT_SATURATED: i32 = 10;
36
37    /// Minimum food level required for slow natural regeneration
38    pub const HEAL_LEVEL: i32 = 18;
39
40    /// Sprint threshold — player can sprint when food level is above this value
41    pub const SPRINT_LEVEL: i32 = 6;
42
43    /// Poor saturation modifier
44    pub const FOOD_SATURATION_POOR: f32 = 0.1;
45
46    /// Low saturation modifier
47    pub const FOOD_SATURATION_LOW: f32 = 0.3;
48
49    /// Normal saturation modifier
50    pub const FOOD_SATURATION_NORMAL: f32 = 0.6;
51
52    /// Good saturation modifier
53    pub const FOOD_SATURATION_GOOD: f32 = 0.8;
54
55    /// Max saturation modifier
56    pub const FOOD_SATURATION_MAX: f32 = 1.0;
57
58    /// Supernatural saturation modifier
59    pub const FOOD_SATURATION_SUPERNATURAL: f32 = 1.2;
60
61    /// Exhaustion cost of regenerating health
62    pub const EXHAUSTION_HEAL: f32 = 6.0;
63
64    /// Exhaustion cost per jump
65    pub const EXHAUSTION_JUMP: f32 = 0.05;
66
67    /// Exhaustion cost per sprint-jump
68    pub const EXHAUSTION_SPRINT_JUMP: f32 = 0.2;
69
70    /// Exhaustion cost per block mined
71    pub const EXHAUSTION_MINE: f32 = 0.005;
72
73    /// Exhaustion cost per attack
74    pub const EXHAUSTION_ATTACK: f32 = 0.1;
75
76    /// Exhaustion cost per meter walked
77    pub const EXHAUSTION_WALK: f32 = 0.0;
78
79    /// Exhaustion cost per meter crouched
80    pub const EXHAUSTION_CROUCH: f32 = 0.0;
81
82    /// Exhaustion cost per meter sprinted
83    pub const EXHAUSTION_SPRINT: f32 = 0.1;
84
85    /// Exhaustion cost per meter swum
86    pub const EXHAUSTION_SWIM: f32 = 0.01;
87
88    /// Default food level for a freshly spawned player.
89    pub const DEFAULT_FOOD_LEVEL: i32 = 20;
90
91    /// Hard cap on accumulated exhaustion
92    pub const MAX_EXHAUSTION: f32 = 40.0;
93}
94
95use food_constants::{
96    DEFAULT_FOOD_LEVEL, DEFAULT_SATURATION, EXHAUSTION_DROP, EXHAUSTION_HEAL, HEAL_LEVEL,
97    HEALTH_TICK_COUNT, HEALTH_TICK_COUNT_SATURATED, MAX_EXHAUSTION, MAX_FOOD_LEVEL, SPRINT_LEVEL,
98};
99
100/// Computes the absolute saturation value from a nutrition count and a modifier.
101#[must_use]
102pub fn saturation_by_modifier(nutrition: i32, modifier: f32) -> f32 {
103    nutrition as f32 * modifier * 2.0
104}
105
106/// Tracks a player's hunger, saturation, and exhaustion state.
107/// One instance is stored per player behind a `SyncMutex`.
108#[derive(Debug, Clone)]
109pub struct FoodData {
110    /// Current food level (0–20). Displayed as the hunger bar on the client.
111    pub food_level: i32,
112    /// Saturation buffer — consumed before the food level drops.
113    pub saturation_level: f32,
114    /// Accumulated exhaustion from actions (sprinting, jumping, damage, …).
115    pub exhaustion_level: f32,
116    /// Internal tick counter shared between regeneration and starvation logic.
117    pub tick_timer: i32,
118}
119
120impl Default for FoodData {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl FoodData {
127    /// Creates a new `FoodData` with default values (full hunger bar).
128    #[must_use]
129    pub const fn new() -> Self {
130        Self {
131            food_level: DEFAULT_FOOD_LEVEL,
132            saturation_level: DEFAULT_SATURATION,
133            exhaustion_level: 0.0,
134            tick_timer: 0,
135        }
136    }
137
138    /// Returns `true` if the player's food level is below maximum and they
139    /// could benefit from eating.
140    #[must_use]
141    pub const fn needs_food(&self) -> bool {
142        self.food_level < MAX_FOOD_LEVEL
143    }
144
145    /// Returns `true` if the player has enough food to perform exhaustive
146    /// maneuvers (for example sprinting).
147    #[must_use]
148    pub const fn has_enough_food(&self) -> bool {
149        self.food_level > SPRINT_LEVEL
150    }
151
152    /// Adds exhaustion from an action (sprinting, jumping, taking damage, …).
153    /// The value is clamped so `exhaustion_level` never exceeds 40.
154    pub fn add_exhaustion(&mut self, amount: f32) {
155        self.exhaustion_level = (self.exhaustion_level + amount).min(MAX_EXHAUSTION);
156    }
157
158    /// Applies food and saturation
159    fn add(&mut self, food: i32, saturation: f32) {
160        self.food_level = (self.food_level + food).clamp(0, MAX_FOOD_LEVEL);
161        self.saturation_level =
162            (self.saturation_level + saturation).clamp(0.0, self.food_level as f32);
163    }
164
165    /// Applies the nutrition from eating food, given a **saturation modifier**.
166    pub fn eat(&mut self, nutrition: i32, saturation_modifier: f32) {
167        self.add(
168            nutrition,
169            saturation_by_modifier(nutrition, saturation_modifier),
170        );
171    }
172
173    /// Applies nutrition and an already-absolute saturation value, as stored
174    /// on `FoodProperties`. Unlike [`Self::eat`], which recomputes saturation
175    /// from a modifier, this applies the value as-is. Mirrors vanilla
176    /// `FoodData.eat(FoodProperties)`.
177    pub fn add_food(&mut self, nutrition: i32, saturation: f32) {
178        self.add(nutrition, saturation);
179    }
180
181    /// Runs one tick of the hunger system.
182    /// Returns a [`FoodTickResult`] describing what happened this tick so the
183    /// caller (`Player::tick`) can apply healing or starvation damage
184    #[must_use]
185    pub fn tick(
186        &mut self,
187        difficulty: Difficulty,
188        natural_regen: bool,
189        current_health: f32,
190        max_health: f32,
191    ) -> FoodTickResult {
192        if self.exhaustion_level > EXHAUSTION_DROP {
193            self.exhaustion_level -= EXHAUSTION_DROP;
194
195            if self.saturation_level > 0.0 {
196                self.saturation_level = (self.saturation_level - 1.0).max(0.0);
197            } else if difficulty != Difficulty::Peaceful {
198                self.food_level = (self.food_level - 1).max(0);
199            }
200        }
201
202        let food = self.food_level;
203        let is_hurt = current_health > 0.0 && current_health < max_health;
204
205        // Fast regen
206        if natural_regen && self.saturation_level > 0.0 && is_hurt && food >= MAX_FOOD_LEVEL {
207            self.tick_timer += 1;
208
209            if self.tick_timer >= HEALTH_TICK_COUNT_SATURATED {
210                let saturation_spent = self.saturation_level.min(EXHAUSTION_HEAL);
211                let heal_amount = saturation_spent / EXHAUSTION_HEAL;
212                self.tick_timer = 0;
213                return FoodTickResult::Heal {
214                    amount: heal_amount,
215                    exhaustion: saturation_spent,
216                };
217            }
218        }
219        // Slow regen
220        else if natural_regen && food >= HEAL_LEVEL && is_hurt {
221            self.tick_timer += 1;
222
223            if self.tick_timer >= HEALTH_TICK_COUNT {
224                self.tick_timer = 0;
225                return FoodTickResult::Heal {
226                    amount: 1.0,
227                    exhaustion: EXHAUSTION_HEAL,
228                };
229            }
230        }
231        // Starving
232        else if food <= 0 {
233            self.tick_timer += 1;
234
235            if self.tick_timer >= HEALTH_TICK_COUNT {
236                self.tick_timer = 0;
237
238                if current_health > 10.0
239                    || difficulty == Difficulty::Hard
240                    || (current_health > 1.0 && difficulty == Difficulty::Normal)
241                {
242                    return FoodTickResult::Starve;
243                }
244            }
245        } else {
246            self.tick_timer = 0;
247        }
248
249        FoodTickResult::None
250    }
251}
252
253/// Describes what the hunger tick determined should happen this tick
254#[derive(Debug, Clone, Copy, PartialEq)]
255pub enum FoodTickResult {
256    /// Nothing happened this tick
257    None,
258    /// The player should be healed by `amount` HP and `exhaustion` should be added back
259    Heal {
260        /// Health points to restore
261        amount: f32,
262        /// Exhaustion to add as cost of this regeneration
263        exhaustion: f32,
264    },
265    /// The player should take 1 point of starvation damage
266    Starve,
267}
268
269impl Player {
270    /// Ticks food/hunger regeneration and starvation.
271    pub(super) fn tick_regeneration(&self) {
272        let world = self.get_world();
273        let difficulty = world.level_data.read().data().difficulty;
274        let natural_regen = world.get_game_rule(&NATURAL_HEALTH_REGENERATION);
275        let tick = self.tick_count();
276
277        if difficulty == Difficulty::Peaceful && natural_regen {
278            if tick % 20 == 0 {
279                if self.is_hurt() {
280                    self.heal(1.0);
281                }
282
283                let mut food = self.food_data.lock();
284                if food.saturation_level < food_constants::MAX_SATURATION {
285                    food.saturation_level += 1.0;
286                }
287            }
288
289            if tick % 10 == 0 {
290                let mut food = self.food_data.lock();
291                if food.needs_food() {
292                    food.food_level += 1;
293                }
294            }
295        }
296
297        let current_health = self.get_health();
298        let max_health = self.get_max_health();
299
300        let mut food = self.food_data.lock();
301        let result = food.tick(difficulty, natural_regen, current_health, max_health);
302
303        match result {
304            FoodTickResult::Heal { amount, exhaustion } => {
305                food.add_exhaustion(exhaustion);
306
307                drop(food);
308                self.heal(amount);
309            }
310            FoodTickResult::Starve => {
311                drop(food);
312
313                self.hurt(
314                    &world,
315                    &DamageSource::environment(&vanilla_damage_types::STARVE),
316                    1.0,
317                );
318            }
319            FoodTickResult::None => {}
320        }
321    }
322
323    /// Adds food exhaustion, gated by invulnerability.
324    pub fn cause_food_exhaustion(&self, amount: f32) {
325        if !self.abilities.lock().invulnerable {
326            self.food_data.lock().add_exhaustion(amount);
327        }
328    }
329
330    /// Returns `true` if the player is alive but below max health.
331    pub fn is_hurt(&self) -> bool {
332        let health = self.get_health();
333        health > 0.0 && health < self.get_max_health()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn exhaustion_drains_saturation_then_food() {
343        let mut food = FoodData::new();
344        food.saturation_level = 3.0;
345        food.add_exhaustion(4.5);
346
347        // First drain: saturation drops by 1
348        let _ = food.tick(Difficulty::Normal, false, 20.0, 20.0);
349        assert!((food.saturation_level - 2.0).abs() < f32::EPSILON);
350        assert_eq!(food.food_level, MAX_FOOD_LEVEL);
351
352        // Now empty saturation and trigger again — food level should drop
353        food.saturation_level = 0.0;
354        food.exhaustion_level = 5.0;
355        let _ = food.tick(Difficulty::Normal, false, 20.0, 20.0);
356        assert_eq!(food.food_level, MAX_FOOD_LEVEL - 1);
357    }
358
359    /// `add_food` must apply saturation as-is (it is already the absolute
360    /// value stored on `FoodProperties`), unlike `eat` which recomputes it
361    /// from a modifier — regression guard against re-multiplying it.
362    #[test]
363    fn add_food_applies_absolute_saturation_without_recomputing_it() {
364        let mut food = FoodData::new();
365        food.food_level = 10;
366        food.saturation_level = 0.0;
367
368        // Vanilla apple: nutrition 4, saturation 2.4 (already absolute).
369        food.add_food(4, 2.4);
370
371        assert_eq!(food.food_level, 14);
372        assert!((food.saturation_level - 2.4).abs() < f32::EPSILON);
373    }
374
375    /// Fast regen: food=20, saturation>0, hurt → heal every 10 ticks.
376    #[test]
377    fn fast_regen() {
378        let mut food = FoodData::new(); // food=20, sat=5.0
379
380        let mut result = FoodTickResult::None;
381        for _ in 0..HEALTH_TICK_COUNT_SATURATED {
382            result = food.tick(Difficulty::Normal, true, 15.0, 20.0);
383        }
384
385        match result {
386            FoodTickResult::Heal { amount, exhaustion } => {
387                assert!((exhaustion - 5.0).abs() < f32::EPSILON);
388                assert!((amount - 5.0 / 6.0).abs() < f32::EPSILON);
389            }
390            other => panic!("Expected Heal, got {other:?}"),
391        }
392    }
393
394    /// Slow regen: food>=18, sat=0, hurt → heal 1 HP every 80 ticks.
395    #[test]
396    fn slow_regen() {
397        let mut food = FoodData::new();
398        food.food_level = 18;
399        food.saturation_level = 0.0;
400
401        let mut result = FoodTickResult::None;
402        for _ in 0..HEALTH_TICK_COUNT {
403            result = food.tick(Difficulty::Normal, true, 10.0, 20.0);
404        }
405
406        match result {
407            FoodTickResult::Heal { amount, exhaustion } => {
408                assert!((amount - 1.0).abs() < f32::EPSILON);
409                assert!((exhaustion - EXHAUSTION_HEAL).abs() < f32::EPSILON);
410            }
411            other => panic!("Expected Heal, got {other:?}"),
412        }
413    }
414
415    /// Starvation thresholds per difficulty:
416    /// - Hard: always starves
417    /// - Normal: stops at 1 HP
418    /// - Easy/Peaceful: stops at 10 HP
419    #[test]
420    fn starvation_by_difficulty() {
421        let run = |diff, health| {
422            let mut food = FoodData::new();
423            food.food_level = 0;
424            food.saturation_level = 0.0;
425            let mut result = FoodTickResult::None;
426            for _ in 0..HEALTH_TICK_COUNT {
427                result = food.tick(diff, false, health, 20.0);
428            }
429            result
430        };
431
432        assert_eq!(run(Difficulty::Hard, 1.0), FoodTickResult::Starve);
433        assert_eq!(run(Difficulty::Normal, 1.0), FoodTickResult::None);
434        assert_eq!(run(Difficulty::Normal, 2.0), FoodTickResult::Starve);
435        assert_eq!(run(Difficulty::Easy, 10.0), FoodTickResult::None);
436        assert_eq!(run(Difficulty::Easy, 11.0), FoodTickResult::Starve);
437    }
438
439    /// Vanilla `Player.isHurt()` returns false when health <= 0,
440    /// so dead players must not regenerate.
441    #[test]
442    fn no_regen_when_dead() {
443        let mut food = FoodData::new(); // food=20, sat=5.0
444
445        let mut result = FoodTickResult::None;
446        for _ in 0..HEALTH_TICK_COUNT_SATURATED {
447            result = food.tick(Difficulty::Normal, true, 0.0, 20.0);
448        }
449
450        assert_eq!(result, FoodTickResult::None);
451    }
452
453    /// Peaceful never drains food from exhaustion (only saturation).
454    #[test]
455    fn peaceful_never_drains_food() {
456        let mut food = FoodData::new();
457        food.saturation_level = 0.0;
458        food.exhaustion_level = 5.0;
459
460        let _ = food.tick(Difficulty::Peaceful, false, 20.0, 20.0);
461
462        assert_eq!(food.food_level, MAX_FOOD_LEVEL);
463    }
464}