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    /// Runs one tick of the hunger system.
174    /// Returns a [`FoodTickResult`] describing what happened this tick so the
175    /// caller (`Player::tick`) can apply healing or starvation damage
176    #[must_use]
177    pub fn tick(
178        &mut self,
179        difficulty: Difficulty,
180        natural_regen: bool,
181        current_health: f32,
182        max_health: f32,
183    ) -> FoodTickResult {
184        if self.exhaustion_level > EXHAUSTION_DROP {
185            self.exhaustion_level -= EXHAUSTION_DROP;
186
187            if self.saturation_level > 0.0 {
188                self.saturation_level = (self.saturation_level - 1.0).max(0.0);
189            } else if difficulty != Difficulty::Peaceful {
190                self.food_level = (self.food_level - 1).max(0);
191            }
192        }
193
194        let food = self.food_level;
195        let is_hurt = current_health > 0.0 && current_health < max_health;
196
197        // Fast regen
198        if natural_regen && self.saturation_level > 0.0 && is_hurt && food >= MAX_FOOD_LEVEL {
199            self.tick_timer += 1;
200
201            if self.tick_timer >= HEALTH_TICK_COUNT_SATURATED {
202                let saturation_spent = self.saturation_level.min(EXHAUSTION_HEAL);
203                let heal_amount = saturation_spent / EXHAUSTION_HEAL;
204                self.tick_timer = 0;
205                return FoodTickResult::Heal {
206                    amount: heal_amount,
207                    exhaustion: saturation_spent,
208                };
209            }
210        }
211        // Slow regen
212        else if natural_regen && food >= HEAL_LEVEL && is_hurt {
213            self.tick_timer += 1;
214
215            if self.tick_timer >= HEALTH_TICK_COUNT {
216                self.tick_timer = 0;
217                return FoodTickResult::Heal {
218                    amount: 1.0,
219                    exhaustion: EXHAUSTION_HEAL,
220                };
221            }
222        }
223        // Starving
224        else if food <= 0 {
225            self.tick_timer += 1;
226
227            if self.tick_timer >= HEALTH_TICK_COUNT {
228                self.tick_timer = 0;
229
230                if current_health > 10.0
231                    || difficulty == Difficulty::Hard
232                    || (current_health > 1.0 && difficulty == Difficulty::Normal)
233                {
234                    return FoodTickResult::Starve;
235                }
236            }
237        } else {
238            self.tick_timer = 0;
239        }
240
241        FoodTickResult::None
242    }
243}
244
245/// Describes what the hunger tick determined should happen this tick
246#[derive(Debug, Clone, Copy, PartialEq)]
247pub enum FoodTickResult {
248    /// Nothing happened this tick
249    None,
250    /// The player should be healed by `amount` HP and `exhaustion` should be added back
251    Heal {
252        /// Health points to restore
253        amount: f32,
254        /// Exhaustion to add as cost of this regeneration
255        exhaustion: f32,
256    },
257    /// The player should take 1 point of starvation damage
258    Starve,
259}
260
261impl Player {
262    /// Ticks food/hunger regeneration and starvation.
263    pub(super) fn tick_regeneration(&self) {
264        let world = self.get_world();
265        let difficulty = world.level_data.read().data().difficulty;
266        let natural_regen = world.get_game_rule(&NATURAL_HEALTH_REGENERATION);
267        let tick = self.tick_count();
268
269        if difficulty == Difficulty::Peaceful && natural_regen {
270            if tick % 20 == 0 {
271                if self.is_hurt() {
272                    self.heal(1.0);
273                }
274
275                let mut food = self.food_data.lock();
276                if food.saturation_level < food_constants::MAX_SATURATION {
277                    food.saturation_level += 1.0;
278                }
279            }
280
281            if tick % 10 == 0 {
282                let mut food = self.food_data.lock();
283                if food.needs_food() {
284                    food.food_level += 1;
285                }
286            }
287        }
288
289        let current_health = self.get_health();
290        let max_health = self.get_max_health();
291
292        let mut food = self.food_data.lock();
293        let result = food.tick(difficulty, natural_regen, current_health, max_health);
294
295        match result {
296            FoodTickResult::Heal { amount, exhaustion } => {
297                food.add_exhaustion(exhaustion);
298
299                drop(food);
300                self.heal(amount);
301            }
302            FoodTickResult::Starve => {
303                drop(food);
304
305                self.hurt(
306                    &world,
307                    &DamageSource::environment(&vanilla_damage_types::STARVE),
308                    1.0,
309                );
310            }
311            FoodTickResult::None => {}
312        }
313    }
314
315    /// Adds food exhaustion, gated by invulnerability.
316    pub fn cause_food_exhaustion(&self, amount: f32) {
317        if !self.abilities.lock().invulnerable {
318            self.food_data.lock().add_exhaustion(amount);
319        }
320    }
321
322    /// Returns `true` if the player is alive but below max health.
323    pub fn is_hurt(&self) -> bool {
324        let health = self.get_health();
325        health > 0.0 && health < self.get_max_health()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn exhaustion_drains_saturation_then_food() {
335        let mut food = FoodData::new();
336        food.saturation_level = 3.0;
337        food.add_exhaustion(4.5);
338
339        // First drain: saturation drops by 1
340        let _ = food.tick(Difficulty::Normal, false, 20.0, 20.0);
341        assert!((food.saturation_level - 2.0).abs() < f32::EPSILON);
342        assert_eq!(food.food_level, MAX_FOOD_LEVEL);
343
344        // Now empty saturation and trigger again — food level should drop
345        food.saturation_level = 0.0;
346        food.exhaustion_level = 5.0;
347        let _ = food.tick(Difficulty::Normal, false, 20.0, 20.0);
348        assert_eq!(food.food_level, MAX_FOOD_LEVEL - 1);
349    }
350
351    /// Fast regen: food=20, saturation>0, hurt → heal every 10 ticks.
352    #[test]
353    fn fast_regen() {
354        let mut food = FoodData::new(); // food=20, sat=5.0
355
356        let mut result = FoodTickResult::None;
357        for _ in 0..HEALTH_TICK_COUNT_SATURATED {
358            result = food.tick(Difficulty::Normal, true, 15.0, 20.0);
359        }
360
361        match result {
362            FoodTickResult::Heal { amount, exhaustion } => {
363                assert!((exhaustion - 5.0).abs() < f32::EPSILON);
364                assert!((amount - 5.0 / 6.0).abs() < f32::EPSILON);
365            }
366            other => panic!("Expected Heal, got {other:?}"),
367        }
368    }
369
370    /// Slow regen: food>=18, sat=0, hurt → heal 1 HP every 80 ticks.
371    #[test]
372    fn slow_regen() {
373        let mut food = FoodData::new();
374        food.food_level = 18;
375        food.saturation_level = 0.0;
376
377        let mut result = FoodTickResult::None;
378        for _ in 0..HEALTH_TICK_COUNT {
379            result = food.tick(Difficulty::Normal, true, 10.0, 20.0);
380        }
381
382        match result {
383            FoodTickResult::Heal { amount, exhaustion } => {
384                assert!((amount - 1.0).abs() < f32::EPSILON);
385                assert!((exhaustion - EXHAUSTION_HEAL).abs() < f32::EPSILON);
386            }
387            other => panic!("Expected Heal, got {other:?}"),
388        }
389    }
390
391    /// Starvation thresholds per difficulty:
392    /// - Hard: always starves
393    /// - Normal: stops at 1 HP
394    /// - Easy/Peaceful: stops at 10 HP
395    #[test]
396    fn starvation_by_difficulty() {
397        let run = |diff, health| {
398            let mut food = FoodData::new();
399            food.food_level = 0;
400            food.saturation_level = 0.0;
401            let mut result = FoodTickResult::None;
402            for _ in 0..HEALTH_TICK_COUNT {
403                result = food.tick(diff, false, health, 20.0);
404            }
405            result
406        };
407
408        assert_eq!(run(Difficulty::Hard, 1.0), FoodTickResult::Starve);
409        assert_eq!(run(Difficulty::Normal, 1.0), FoodTickResult::None);
410        assert_eq!(run(Difficulty::Normal, 2.0), FoodTickResult::Starve);
411        assert_eq!(run(Difficulty::Easy, 10.0), FoodTickResult::None);
412        assert_eq!(run(Difficulty::Easy, 11.0), FoodTickResult::Starve);
413    }
414
415    /// Vanilla `Player.isHurt()` returns false when health <= 0,
416    /// so dead players must not regenerate.
417    #[test]
418    fn no_regen_when_dead() {
419        let mut food = FoodData::new(); // food=20, sat=5.0
420
421        let mut result = FoodTickResult::None;
422        for _ in 0..HEALTH_TICK_COUNT_SATURATED {
423            result = food.tick(Difficulty::Normal, true, 0.0, 20.0);
424        }
425
426        assert_eq!(result, FoodTickResult::None);
427    }
428
429    /// Peaceful never drains food from exhaustion (only saturation).
430    #[test]
431    fn peaceful_never_drains_food() {
432        let mut food = FoodData::new();
433        food.saturation_level = 0.0;
434        food.exhaustion_level = 5.0;
435
436        let _ = food.tick(Difficulty::Peaceful, false, 20.0, 20.0);
437
438        assert_eq!(food.food_level, MAX_FOOD_LEVEL);
439    }
440}