Skip to main content

steel_core/behavior/
item.rs

1//! Item behavior trait and registry.
2
3use crate::player::player_inventory::Prediction;
4use std::sync::Arc;
5
6use std::borrow::Cow;
7use steel_protocol::packets::game::SoundSource;
8use steel_registry::data_components::Consumable;
9use steel_registry::data_components::vanilla_components::{
10    BLOCKS_ATTACKS, CONSUMABLE, FOOD, KINETIC_WEAPON, USE_REMAINDER,
11};
12
13use steel_registry::data_components::vanilla_components::ITEM_NAME;
14use steel_registry::item_stack::ItemStack;
15use steel_registry::items::ItemRef;
16use steel_registry::stat::vanilla_stat_types;
17use steel_registry::{REGISTRY, RegistryEntry, RegistryExt, sound_events, vanilla_game_events};
18use steel_utils::types::{InteractionHand, SignTextSlot};
19use text_components::TextComponent;
20
21use crate::behavior::items::{DefaultItemBehavior, SpawnEggItem, block_transformer};
22use crate::behavior::{InteractionResult, InventoryTickContext, UseItemContext, UseOnContext};
23use crate::block_entity::entities::{SignBlockEntity, SignText};
24use crate::entity::consume_effect::apply_consume_effect;
25use crate::entity::damage::DamageSource;
26use crate::entity::{Entity, LivingEntity, LivingEntityRef, SharedEntity};
27use crate::player::{Player, player_inventory::EquipmentSwapResult};
28use crate::world::World;
29
30pub use steel_registry::data_components::vanilla_components::ItemUseAnimation;
31
32/// Item capability applied by a sign before its editor fallback.
33pub trait SignApplicator: Send + Sync {
34    /// Returns whether this item can change the selected sign text.
35    fn can_apply_to_sign(&self, text: &SignText, _item: &ItemStack, _player: &Player) -> bool {
36        text.has_message()
37    }
38
39    /// Applies the change; the sign handles consumption, statistics and updates.
40    fn try_apply_to_sign(
41        &self,
42        world: &World,
43        sign: &SignBlockEntity,
44        slot: SignTextSlot,
45        item: &ItemStack,
46        player: &Player,
47    ) -> bool;
48}
49
50/// Trait defining the behavior of an item.
51///
52/// This trait handles dynamic/functional aspects of items:
53/// - Use on blocks (placing, interacting)
54/// - Use in air
55/// - etc.
56pub trait ItemBehavior: Send + Sync {
57    /// Returns the Rust type name of the concrete behavior implementation.
58    #[cfg(feature = "test-framework")]
59    #[must_use]
60    #[expect(clippy::absolute_paths, reason = "easier for features")]
61    fn type_name(&self) -> &'static str {
62        std::any::type_name::<Self>()
63    }
64
65    /// Returns this item's display name, from the stack's `item_name`
66    /// component if present.
67    fn get_name<'a>(&self, stack: &'a ItemStack) -> Cow<'a, TextComponent> {
68        stack
69            .get(ITEM_NAME)
70            .map_or_else(|| Cow::Owned(TextComponent::new()), Cow::Borrowed)
71    }
72
73    /// Returns the canonical single-item stack for this item, including
74    /// component defaults.
75    fn get_default_instance(&self, item: ItemRef) -> ItemStack {
76        ItemStack::new(item)
77    }
78
79    /// Called when this item is used on a block.
80    fn use_on(&self, context: &mut UseOnContext) -> InteractionResult {
81        block_transformer::use_on(context)
82    }
83
84    /// Called when this item is used (e.g. right click in air).
85    fn use_item(&self, context: &mut UseItemContext) -> InteractionResult {
86        // TODO: Mirror Item.use for BLOCKS_ATTACKS and KINETIC_WEAPON so
87        // specialized behaviors inherit the complete Vanilla base path.
88        let (consume_ticks, can_eat) = context.inv.with_item(|item| {
89            let can_eat = item
90                .get(FOOD)
91                .is_none_or(|food| context.player.can_eat(food.can_always_eat()));
92            (item.get(CONSUMABLE).map(Consumable::consume_ticks), can_eat)
93        });
94        if let Some(consume_ticks) = consume_ticks {
95            if !can_eat {
96                return InteractionResult::Fail;
97            }
98            // Mirrors vanilla `Consumable.startConsuming`: an item with no
99            // consume duration is consumed immediately instead of entering
100            // the timed-use state — `start_using_item` rejects a duration of
101            // 0 outright, so going through it here would silently never
102            // finish and never run the item's effects.
103            if consume_ticks > 0 {
104                context.player.start_using_item(context.hand);
105            } else {
106                let stack = context.inv.with_item(|item| item.clone());
107                let result = finish_consuming_stack(&stack, context.world, context.player.as_ref());
108                context.inv.with_item(|item| *item = result);
109            }
110            return InteractionResult::Consume;
111        }
112
113        let Some(equippable) = context.inv.with_item(|item| item.get_equippable().cloned()) else {
114            return InteractionResult::Pass;
115        };
116
117        if !equippable.swappable || !equippable.can_be_equipped_by(context.player.entity_type()) {
118            return InteractionResult::Pass;
119        }
120
121        let slot = equippable.slot;
122        let equipped = context.inv.with_item(|item| item.copy_with_count(1));
123        let result = context.inv.with_inventory(|inventory| {
124            inventory.try_swap_with_equipment_slot(
125                context.hand,
126                slot,
127                context.player.has_infinite_materials(),
128            )
129        });
130
131        match result {
132            EquipmentSwapResult::Success(overflow) => {
133                if !overflow.is_empty() {
134                    let _ = context
135                        .player
136                        .drop_item(overflow, false, Prediction::Predicted);
137                }
138                context
139                    .player
140                    .on_equip_item(slot, &ItemStack::empty(), &equipped);
141                InteractionResult::Success
142            }
143            EquipmentSwapResult::Fail => InteractionResult::Fail,
144        }
145    }
146
147    /// Returns which animation plays while this item is actively being used,
148    /// based on its data components.
149    fn get_use_animation(&self, stack: &ItemStack) -> ItemUseAnimation {
150        if let Some(consumable) = stack.get(CONSUMABLE) {
151            consumable.animation()
152        } else if stack.has(BLOCKS_ATTACKS) {
153            ItemUseAnimation::Block
154        } else if stack.has(KINETIC_WEAPON) {
155            ItemUseAnimation::Spear
156        } else {
157            ItemUseAnimation::None
158        }
159    }
160
161    /// Returns how many ticks this item can be held in active use before it
162    /// finishes automatically.
163    fn get_use_duration(&self, stack: &ItemStack, _user: &dyn LivingEntity) -> i32 {
164        if let Some(consumable) = stack.get(CONSUMABLE) {
165            consumable.consume_ticks()
166        } else if stack.has(BLOCKS_ATTACKS) || stack.has(KINETIC_WEAPON) {
167            72000
168        } else {
169            0
170        }
171    }
172
173    /// Called every tick while a living entity is actively using this item.
174    fn on_use_tick(
175        &self,
176        _world: &Arc<World>,
177        user: LivingEntityRef<'_>,
178        stack: &mut ItemStack,
179        ticks_remaining: i32,
180    ) {
181        if let Some(consumable) = stack.get(CONSUMABLE)
182            && should_emit_consume_particles_and_sounds(consumable, ticks_remaining)
183        {
184            emit_consume_particles_and_sounds(consumable, user.living());
185        }
186    }
187
188    /// Called when active use is released before completion.
189    ///
190    /// Returns whether vanilla should apply the stack's after-use component side effects.
191    fn release_using(
192        &self,
193        _stack: &mut ItemStack,
194        _world: &Arc<World>,
195        _user: &dyn LivingEntity,
196        _time_left: i32,
197    ) -> bool {
198        false
199    }
200
201    /// Returns whether the item acts when the use key is released, rather than the use timer expiring
202    fn use_on_release(&self, _stack: &ItemStack) -> bool {
203        false
204    }
205
206    /// Called when active use reaches its full duration.
207    fn finish_using(
208        &self,
209        stack: &mut ItemStack,
210        world: &Arc<World>,
211        user: LivingEntityRef<'_>,
212    ) -> ItemStack {
213        finish_consuming_stack(stack, world, user.living())
214    }
215
216    /// Called when this item is used to interact with a living entity
217    /// (e.g. right-clicking a mob while holding it).
218    fn interact_living_entity(
219        &self,
220        _stack: &mut ItemStack,
221        _player: &Player,
222        _target: &dyn LivingEntity,
223        _hand: InteractionHand,
224    ) -> InteractionResult {
225        InteractionResult::Pass
226    }
227
228    /// Returns a custom damage source this item should inflict when its
229    /// wielder attacks, overriding the caller's default attack damage type.
230    fn get_item_damage_source(&self, _attacker: &SharedEntity) -> Option<DamageSource> {
231        None
232    }
233
234    /// Returns item-specific attack damage added by `Item.getAttackDamageBonus`.
235    fn get_attack_damage_bonus(
236        &self,
237        _attacker: &dyn LivingEntity,
238        _victim: &dyn Entity,
239        _base_damage: f32,
240        _damage_source: &DamageSource,
241    ) -> f32 {
242        0.0
243    }
244
245    /// Called immediately when this item's wielder deals a successful melee
246    /// hit, before post-attack enchantment effects and durability loss.
247    fn hurt_enemy(
248        &self,
249        _stack: &mut ItemStack,
250        _target: &dyn LivingEntity,
251        _attacker: &dyn LivingEntity,
252    ) {
253    }
254
255    /// Called after `hurt_enemy` and post-attack enchantment effects, right
256    /// before the item takes durability damage from the hit.
257    fn post_hurt_enemy(
258        &self,
259        _stack: &mut ItemStack,
260        _target: &dyn LivingEntity,
261        _attacker: &dyn LivingEntity,
262    ) {
263    }
264
265    /// Called every tick for each carried item.
266    fn inventory_tick(&self, _context: &mut InventoryTickContext<'_>) {}
267
268    /// Returns how much durability this weapon consumes after a successful entity hit.
269    fn item_damage_per_attack(&self, stack: &ItemStack) -> Option<i32> {
270        stack
271            .get_weapon()
272            .map(|weapon| weapon.item_damage_per_attack)
273    }
274
275    /// Whether this item may be stored inside container items such as shulker
276    /// boxes and bundles, which vanilla uses to stop them nesting.
277    #[must_use]
278    fn can_fit_inside_container_items(&self) -> bool {
279        true
280    }
281
282    /// Returns this item behavior as a `SpawnEggItem`.
283    fn as_spawn_egg(&self) -> Option<&SpawnEggItem> {
284        None
285    }
286
287    /// Returns the capability used by sign block interactions.
288    fn as_sign_applicator(&self) -> Option<&dyn SignApplicator> {
289        None
290    }
291}
292
293fn should_emit_consume_particles_and_sounds(consumable: &Consumable, ticks_remaining: i32) -> bool {
294    let consume_ticks = consumable.consume_ticks();
295    let ticks_used = consume_ticks - ticks_remaining;
296    let wait_ticks = (consume_ticks as f32 * 0.21875) as i32;
297    ticks_used > wait_ticks && ticks_remaining % 4 == 0
298}
299
300fn emit_consume_particles_and_sounds(consumable: &Consumable, user: &dyn LivingEntity) {
301    // TODO: spawn item-crumb particles when `has_consume_particles()` is set.
302    let (volume, pitch) = if consumable.animation() == ItemUseAnimation::Drink {
303        (0.5, 0.9 + rand::random::<f32>() * 0.1)
304    } else {
305        let volume = if rand::random::<bool>() { 0.5 } else { 1.0 };
306        let pitch = 1.0 + 0.2 * (rand::random::<f32>() - rand::random::<f32>());
307        (volume, pitch)
308    };
309    if let Some(sound) = consumable.sound().registry_ref() {
310        user.play_sound(sound, volume, pitch);
311    }
312}
313
314/// Applies vanilla `Consumable.onConsume`'s shared tail: runs
315/// `on_consume_effects`, plays the consume sound, then shrinks the stack by
316/// one (creative mode leaves it untouched).
317pub(crate) fn finish_consuming_stack(
318    stack: &ItemStack,
319    world: &Arc<World>,
320    user: &dyn LivingEntity,
321) -> ItemStack {
322    let Some(consumable) = stack.get(CONSUMABLE) else {
323        return apply_use_remainder(stack, stack.copy_with_count(stack.count()), user);
324    };
325
326    emit_consume_particles_and_sounds(consumable, user);
327
328    if let Some(food) = stack.get(FOOD) {
329        if let Some(sound) = consumable.sound().registry_ref() {
330            world.play_sound_at(
331                sound,
332                SoundSource::Neutral,
333                user.position(),
334                1.0,
335                // Java `RandomSource.triangle(1.0, 0.4)`.
336                1.0 + 0.4 * (rand::random::<f32>() - rand::random::<f32>()),
337                None,
338            );
339        }
340        if let Some(player) = user.as_player() {
341            player
342                .food_data
343                .lock()
344                .add_food(food.nutrition(), food.saturation());
345            world.play_sound_at(
346                &sound_events::ENTITY_PLAYER_BURP,
347                SoundSource::Players,
348                player.position(),
349                0.5,
350                // Java `Mth.randomBetween(random, 0.9, 1.0)`.
351                0.9 + rand::random::<f32>() * 0.1,
352                None,
353            );
354        }
355    }
356
357    for effect in consumable.on_consume_effects() {
358        apply_consume_effect(effect, world, user);
359    }
360
361    let event = if consumable.animation() == ItemUseAnimation::Drink {
362        &vanilla_game_events::DRINK
363    } else {
364        &vanilla_game_events::EAT
365    };
366    user.game_event(event);
367    if let Some(player) = user.as_player() {
368        player.award_stat(&vanilla_stat_types::ITEM_USED, stack.item());
369    }
370    // TODO: Spawn item-crumb particles when `has_consume_particles()` is set.
371    // TODO: Trigger CriteriaTriggers.CONSUME_ITEM once the advancement-criteria
372    // foundation exists.
373
374    let mut used_stack = stack.copy_with_count(stack.count());
375    if !user.has_infinite_materials() {
376        used_stack.shrink(1);
377    }
378
379    apply_use_remainder(stack, used_stack, user)
380}
381
382/// Applies vanilla `UseRemainder.convertIntoRemainder`: if the original stack
383/// had a `use_remainder` and was actually consumed, either swap the fully
384/// emptied stack for the remainder, or — for a stack that still has items
385/// left (e.g. one honey bottle out of several)
386pub(crate) fn apply_use_remainder(
387    original_stack: &ItemStack,
388    mut used_stack: ItemStack,
389    user: &dyn LivingEntity,
390) -> ItemStack {
391    let Some(remainder) = original_stack.get(USE_REMAINDER) else {
392        return used_stack;
393    };
394    if let Some(extra) = remainder.convert_into_remainder(
395        &mut used_stack,
396        original_stack.count(),
397        user.has_infinite_materials(),
398    ) {
399        user.handle_extra_items_created_on_use(extra);
400    }
401    used_stack
402}
403
404/// Registry for item behaviors.
405///
406/// Created after the main registry is frozen. Block items get `BlockItemBehavior`,
407/// other items get `DefaultItemBehavior`. Custom behaviors can be registered.
408pub struct ItemBehaviorRegistry {
409    behaviors: Vec<Box<dyn ItemBehavior>>,
410}
411
412impl ItemBehaviorRegistry {
413    /// Creates a new behavior registry with default behaviors for all items.
414    ///
415    /// Call `register_item_behaviors()` after this to set up proper behaviors.
416    #[must_use]
417    pub fn new() -> Self {
418        let item_count = REGISTRY.items.len();
419        let behaviors = (0..item_count)
420            .map(|_| Box::new(DefaultItemBehavior) as Box<dyn ItemBehavior>)
421            .collect();
422
423        Self { behaviors }
424    }
425
426    /// Sets a custom behavior for an item.
427    pub fn set_behavior(&mut self, item: ItemRef, behavior: Box<dyn ItemBehavior>) {
428        let id = item.id();
429        self.behaviors[id] = behavior;
430    }
431
432    /// Gets the behavior for an item.
433    #[must_use]
434    pub fn get_behavior(&self, item: ItemRef) -> &dyn ItemBehavior {
435        let id = item.id();
436        self.behaviors[id].as_ref()
437    }
438
439    /// Returns the default stack for `item`, dispatching to its behavior's
440    /// [`ItemBehavior::get_default_instance`].
441    #[must_use]
442    pub fn default_instance(&self, item: ItemRef) -> ItemStack {
443        self.get_behavior(item).get_default_instance(item)
444    }
445
446    /// Returns vanilla `ItemStack.getHoverName`, including item-specific
447    /// `Item.getName(stack)` overrides when no custom name is present.
448    #[must_use]
449    pub fn hover_name<'a>(&self, stack: &'a ItemStack) -> Cow<'a, TextComponent> {
450        stack
451            .custom_name()
452            .unwrap_or_else(|| self.get_behavior(stack.item()).get_name(stack))
453    }
454
455    /// Get all behaviors.
456    #[cfg(feature = "test-framework")]
457    #[must_use]
458    pub fn get_behaviors(&self) -> &[Box<dyn ItemBehavior>] {
459        &self.behaviors
460    }
461}
462
463impl Default for ItemBehaviorRegistry {
464    fn default() -> Self {
465        Self::new()
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use std::sync::Arc;
472
473    use steel_registry::data_components::{Consumable, vanilla_components};
474    use steel_registry::item_stack::ItemStack;
475    use steel_registry::stat::vanilla_stat_types;
476    use steel_registry::{init_vanilla_registry, vanilla_entities, vanilla_items, vanilla_potions};
477    use steel_utils::types::InteractionHand;
478    use steel_utils::{ChunkPos, Downcast as _, WorldAabb};
479
480    use super::finish_consuming_stack;
481    use crate::behavior::{ITEM_BEHAVIORS, InteractionResult, UseItemContext, init_behaviors};
482    use crate::entity::entities::ItemEntity;
483    use crate::inventory::container::Container as _;
484    use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
485
486    #[test]
487    fn potion_items_default_to_their_vanilla_base_potion() {
488        init_vanilla_registry();
489        init_behaviors();
490
491        for item in [
492            &vanilla_items::POTION,
493            &vanilla_items::SPLASH_POTION,
494            &vanilla_items::LINGERING_POTION,
495        ] {
496            let stack = ITEM_BEHAVIORS.default_instance(item);
497            assert!(stack.is(item));
498            assert_eq!(stack.count(), 1);
499            let contents = stack
500                .get(vanilla_components::POTION_CONTENTS)
501                .expect("potion default instance carries potion contents");
502            assert!(contents.is(&vanilla_potions::WATER));
503        }
504
505        let arrow = ITEM_BEHAVIORS.default_instance(&vanilla_items::TIPPED_ARROW);
506        let contents = arrow
507            .get(vanilla_components::POTION_CONTENTS)
508            .expect("tipped arrow default instance carries potion contents");
509        assert!(contents.is(&vanilla_potions::POISON));
510    }
511
512    #[test]
513    fn items_without_an_override_default_to_a_plain_stack() {
514        init_vanilla_registry();
515        init_behaviors();
516
517        let stack = ITEM_BEHAVIORS.default_instance(&vanilla_items::STONE);
518        assert_eq!(stack, ItemStack::new(&vanilla_items::STONE));
519    }
520
521    /// Drinking one honey bottle out of a stack must only consume one item:
522    /// the leftover bottles stay in hand, and the empty glass bottle it
523    /// produces is handed off separately rather than replacing the whole
524    /// stack. Mirrors vanilla `UseRemainder.convertIntoRemainder`.
525    #[test]
526    fn honey_bottle_stack_keeps_remaining_bottles_and_hands_off_the_remainder() {
527        init_vanilla_registry();
528        init_behaviors();
529        let world_fixture = fresh_test_world("finish_consuming_honey_bottle_stack");
530        let world = &world_fixture.world;
531        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
532        let player = TestPlayerBuilder::new(Arc::clone(world), "Test", 1).build();
533        player.set_client_loaded(true);
534
535        // Fill the inventory so the glass bottle remainder cannot be stored
536        // and must be dropped instead — the scenario the honey-bottle fix
537        // targets.
538        {
539            let mut inventory = player.inventory.lock();
540            for slot in 0..36 {
541                inventory.set_item(slot, ItemStack::with_count(&vanilla_items::STONE, 64));
542            }
543        }
544
545        let stack = ItemStack::with_count(&vanilla_items::HONEY_BOTTLE, 5);
546        let result = finish_consuming_stack(&stack, world, player.as_ref());
547
548        assert!(result.is(&vanilla_items::HONEY_BOTTLE));
549        assert_eq!(result.count(), 4);
550
551        let dropped = world.get_entities_in_aabb_matching(
552            &WorldAabb::new(-2.0, -1.0, -2.0, 2.0, 3.0, 2.0),
553            |entity| entity.entity_type() == &vanilla_entities::ITEM,
554        );
555        assert_eq!(dropped.len(), 1);
556        let Some(item) = dropped[0].as_ref().downcast_ref::<ItemEntity>() else {
557            panic!("dropped entity should retain its concrete item type");
558        };
559        assert!(item.get_item().is(&vanilla_items::GLASS_BOTTLE));
560    }
561
562    /// Regression test for a deadlock: `use_item` on a `consume_seconds:
563    /// 0.0` item must finish it immediately (mirroring vanilla `Consumable
564    /// .startConsuming`'s `consumeTicks() > 0` branch) *without* holding the
565    /// inventory lock while doing so — `finish_consuming_stack` can recurse
566    /// into the same lock via `USE_REMAINDER` (honey bottle → glass bottle)
567    /// through `handle_extra_items_created_on_use`, which used to deadlock
568    /// when called from inside the lock `use_item` originally held.
569    #[test]
570    fn instant_consumable_with_use_remainder_finishes_without_deadlocking() {
571        init_vanilla_registry();
572        init_behaviors();
573        let world_fixture = fresh_test_world("instant_consumable_no_deadlock");
574        let world = &world_fixture.world;
575        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
576        let player = TestPlayerBuilder::new(Arc::clone(world), "Test", 1).build();
577        player.set_client_loaded(true);
578
579        let mut stack = ItemStack::with_count(&vanilla_items::HONEY_BOTTLE, 2);
580        let existing = stack
581            .get(vanilla_components::CONSUMABLE)
582            .expect("honey bottle is consumable")
583            .clone();
584        let instant_consumable = Consumable::new(
585            0.0,
586            existing.animation(),
587            existing.sound().clone(),
588            existing.has_consume_particles(),
589            existing.on_consume_effects().to_vec(),
590        )
591        .expect("valid consumable");
592        stack.set(vanilla_components::CONSUMABLE, instant_consumable);
593
594        {
595            let mut inventory = player.inventory.lock();
596            inventory.set_item_in_hand(InteractionHand::MainHand, stack);
597        }
598
599        let behavior = ITEM_BEHAVIORS.get_behavior(&vanilla_items::HONEY_BOTTLE);
600        let mut context = UseItemContext::new(
601            &player,
602            InteractionHand::MainHand,
603            world,
604            Arc::clone(&player.inventory),
605        );
606
607        let result = behavior.use_item(&mut context);
608
609        assert_eq!(result, InteractionResult::Consume);
610        let remaining = {
611            let inventory = player.inventory.lock();
612            inventory
613                .get_item_in_hand(InteractionHand::MainHand)
614                .clone()
615        };
616        assert!(remaining.is(&vanilla_items::HONEY_BOTTLE));
617        assert_eq!(remaining.count(), 1);
618
619        let glass_bottle_count: i32 = {
620            let inventory = player.inventory.lock();
621            inventory
622                .items()
623                .iter()
624                .filter(|item| item.is(&vanilla_items::GLASS_BOTTLE))
625                .map(ItemStack::count)
626                .sum()
627        };
628        assert_eq!(
629            glass_bottle_count, 1,
630            "USE_REMAINDER glass bottle should land in the player's inventory"
631        );
632    }
633
634    /// Eating a food item must restore hunger/saturation by the exact
635    /// vanilla amounts from its `minecraft:food` component, applied as-is
636    /// (not recomputed from a modifier).
637    #[test]
638    fn eating_food_applies_its_nutrition_and_saturation() {
639        init_vanilla_registry();
640        let world_fixture = fresh_test_world("finish_consuming_food_applies_nutrition");
641        let world = &world_fixture.world;
642        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
643        let player = TestPlayerBuilder::new(Arc::clone(world), "Test", 1).build();
644        player.set_client_loaded(true);
645        {
646            let mut food = player.food_data.lock();
647            food.food_level = 10;
648            food.saturation_level = 0.0;
649        }
650
651        let stack = ItemStack::new(&vanilla_items::APPLE);
652        let _ = finish_consuming_stack(&stack, world, player.as_ref());
653
654        let food = player.food_data.lock();
655        // Vanilla apple: nutrition 4, saturation 2.4.
656        assert_eq!(food.food_level, 14);
657        assert!((food.saturation_level - 2.4).abs() < f32::EPSILON);
658    }
659
660    /// Consuming an item must award one count of `Stats.ITEM_USED` for that
661    /// item, matching vanilla `Consumable.onConsume`.
662    #[test]
663    fn consuming_an_item_awards_the_item_used_stat() {
664        init_vanilla_registry();
665        let world_fixture = fresh_test_world("finish_consuming_awards_item_used_stat");
666        let world = &world_fixture.world;
667        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
668        let player = TestPlayerBuilder::new(Arc::clone(world), "Test", 1).build();
669        player.set_client_loaded(true);
670
671        let stack = ItemStack::new(&vanilla_items::APPLE);
672        let _ = finish_consuming_stack(&stack, world, player.as_ref());
673
674        let apple_used = vanilla_stat_types::ITEM_USED.get(&vanilla_items::APPLE);
675        assert_eq!(
676            player
677                .stats()
678                .into_iter()
679                .find(|(stat, _)| *stat == apple_used)
680                .map(|(_, count)| count),
681            Some(1)
682        );
683    }
684}