Skip to main content

steel_core/entity/entities/objects/items/
item_entity.rs

1//! Item entity implementation (dropped items).
2//!
3//! `ItemEntity` represents a dropped item in the world. It has physics
4//! (gravity, friction), despawns after 5 minutes, and can be picked up
5//! by players after a short delay.
6
7use std::sync::{Arc, Weak};
8
9use glam::DVec3;
10use steel_macros::entity_behavior;
11use steel_math::DEGREE_360;
12use steel_registry::entity_type::EntityTypeRef;
13use steel_registry::item_stack::ItemStack;
14use steel_registry::vanilla_entity_data::ItemEntityData;
15use steel_registry::vanilla_game_rules::MOB_GRIEFING;
16use steel_registry::{vanilla_custom_stats, vanilla_damage_types};
17use steel_utils::UuidExt;
18use steel_utils::locks::SyncMutex;
19use steel_utils::nbt::NbtValueInput as _;
20use steel_utils::{Downcast as _, DowncastType, DowncastTypeKey};
21use uuid::Uuid;
22
23use crate::entity::damage::DamageSource;
24use crate::entity::{
25    DEFAULT_AIR_DRAG, DefaultEntityPush, Entity, EntityBase, EntityBaseLoad, EntityBaseState,
26    EntityMovementEmission, EntitySyncedData, RemovalReason,
27};
28use crate::inventory::container::Container;
29use crate::physics::MoverType;
30use crate::player::Player;
31use crate::world::{Explosion, World};
32
33use simdnbt::ToNbtTag;
34use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
35use simdnbt::owned::{NbtCompound, NbtTag};
36use steel_protocol::packets::game::CTakeItemEntity;
37use steel_registry::blocks::block_state_ext::BlockStateExt;
38use steel_registry::stat::vanilla_stat_types;
39use steel_registry::vanilla_item_tags::ItemTag;
40use steel_utils::BlockPos;
41
42/// Maximum age in ticks before despawn (5 minutes = 6000 ticks).
43const LIFETIME: i32 = 6000;
44
45/// Pickup delay set by `set_default_pickup_delay()` (0.5 seconds = 10 ticks).
46/// Note: Items spawn with 0 delay by default; this is only used when explicitly set.
47const DEFAULT_PICKUP_DELAY: i32 = 10;
48
49/// Pickup delay value meaning "never pickupable".
50const INFINITE_PICKUP_DELAY: i32 = 32767;
51
52/// Age value meaning "infinite lifetime" (never despawns).
53const INFINITE_LIFETIME: i32 = -32768;
54
55/// Default health (damage resistance).
56const DEFAULT_HEALTH: i32 = 5;
57
58/// Gravity applied per tick (blocks/tick^2).
59const DEFAULT_GRAVITY: f64 = 0.04;
60
61/// Air/vertical drag multiplier per tick.
62const FLUID_VERTICAL_NUDGE: f64 = 5.0e-4;
63const ITEM_FLUID_HEIGHT_THRESHOLD: f64 = 0.1;
64const ITEM_WATER_DRAG: f64 = 0.99;
65const ITEM_LAVA_DRAG: f64 = 0.95;
66const MERGE_MAX_STACK_SIZE: i32 = 64;
67
68/// Mutable item-specific state that changes during item ticks, pickup, damage,
69/// merging, and save/load.
70struct ItemEntityState {
71    /// Age in ticks. Despawns at `LIFETIME` (6000). Special value -32768 = infinite.
72    age: i32,
73    /// Ticks until pickupable. 0 = can pickup, 32767 = never.
74    pickup_delay: i32,
75    /// Health (damage resistance). Item is destroyed when this reaches 0.
76    health: i32,
77    /// UUID of the entity that threw/dropped this item.
78    thrower: Option<Uuid>,
79    /// UUID of the only entity that can pick up this item.
80    /// If `None`, any player can pick it up. Vanilla calls this `target`.
81    owner: Option<Uuid>,
82}
83
84impl ItemEntityState {
85    const fn new() -> Self {
86        Self {
87            age: 0,
88            pickup_delay: 0,
89            health: DEFAULT_HEALTH,
90            thrower: None,
91            owner: None,
92        }
93    }
94}
95
96/// A dropped item entity.
97///
98/// Mirrors vanilla's `ItemEntity` behavior:
99/// - Falls with gravity (0.04 per tick)
100/// - Applies friction when on ground (0.98)
101/// - Despawns after 5 minutes (6000 ticks)
102/// - Has pickup delay before players can collect it
103#[entity_behavior]
104pub struct ItemEntity {
105    /// Common entity fields (id, uuid, position, etc.).
106    base: EntityBase,
107
108    /// Vanilla entity type registered for this implementation.
109    entity_type: EntityTypeRef,
110
111    /// Entity data containing the `ItemStack`.
112    entity_data: SyncMutex<ItemEntityData>,
113
114    /// Item-specific mutable state.
115    item_state: SyncMutex<ItemEntityState>,
116}
117
118// SAFETY: This key is owned by Steel and uniquely identifies `ItemEntity`.
119unsafe impl DowncastType for ItemEntity {
120    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/item");
121}
122
123impl ItemEntity {
124    /// Creates a new item entity with an empty item.
125    ///
126    /// Use `set_item()` to set the actual item after creation, or use `with_item()`.
127    #[must_use]
128    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
129        Self::with_item_and_velocity(
130            entity_type,
131            id,
132            position,
133            ItemStack::empty(),
134            DVec3::ZERO,
135            world,
136        )
137    }
138
139    /// Creates a new item entity with the specified item.
140    #[must_use]
141    pub fn with_item(
142        entity_type: EntityTypeRef,
143        id: i32,
144        position: DVec3,
145        item: ItemStack,
146        world: Weak<World>,
147    ) -> Self {
148        Self::with_item_and_velocity(
149            entity_type,
150            id,
151            position,
152            item,
153            Self::default_spawn_velocity(),
154            world,
155        )
156    }
157
158    /// Creates a new item entity with the specified item and initial velocity.
159    #[must_use]
160    pub fn with_item_and_velocity(
161        entity_type: EntityTypeRef,
162        id: i32,
163        position: DVec3,
164        item: ItemStack,
165        velocity: DVec3,
166        world: Weak<World>,
167    ) -> Self {
168        // Random yaw rotation for visual variety
169        let yaw = rand::random_range(0.0..DEGREE_360);
170
171        let mut entity_data = ItemEntityData::new();
172        entity_data.item.set(item);
173
174        Self {
175            base: EntityBase::new_with_state(
176                id,
177                EntityBaseState::new(position, entity_type.dimensions)
178                    .with_velocity(velocity)
179                    .with_rotation((yaw, 0.0)),
180                world,
181            ),
182            entity_type,
183            entity_data: SyncMutex::new(entity_data),
184            item_state: SyncMutex::new(ItemEntityState::new()),
185        }
186    }
187
188    pub(crate) fn default_spawn_velocity() -> DVec3 {
189        DVec3::new(
190            rand::random_range(-0.1..0.1),
191            0.2,
192            rand::random_range(-0.1..0.1),
193        )
194    }
195
196    /// Creates an item entity from saved data with restored base state.
197    ///
198    /// Used when loading entities from disk. Type-specific data (item, age, etc.)
199    /// is restored via `load_additional()` after this constructor.
200    #[must_use]
201    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
202        Self {
203            base: EntityBase::from_load(load, entity_type.dimensions),
204            entity_type,
205            entity_data: SyncMutex::new(ItemEntityData::new()),
206            item_state: SyncMutex::new(ItemEntityState::new()),
207        }
208    }
209
210    /// Gets a clone of the item stack.
211    #[must_use]
212    pub fn get_item(&self) -> ItemStack {
213        self.entity_data.lock().item.get().clone()
214    }
215
216    /// Sets the item stack.
217    pub fn set_item(&self, item: ItemStack) {
218        self.entity_data.lock().item.set(item);
219    }
220
221    /// Gets the current age in ticks.
222    #[must_use]
223    pub fn get_age(&self) -> i32 {
224        self.item_state.lock().age
225    }
226
227    /// Sets the age in ticks.
228    pub fn set_age(&self, age: i32) {
229        self.item_state.lock().age = age;
230    }
231
232    /// Makes this a one-tick visual pickup item that cannot be collected.
233    pub fn make_fake_item(&self) {
234        let mut state = self.item_state.lock();
235        state.pickup_delay = INFINITE_PICKUP_DELAY;
236        state.age = LIFETIME - 1;
237    }
238
239    /// Gets the pickup delay in ticks.
240    #[must_use]
241    pub fn get_pickup_delay(&self) -> i32 {
242        self.item_state.lock().pickup_delay
243    }
244
245    /// Sets the default pickup delay (10 ticks = 0.5 seconds).
246    pub fn set_default_pickup_delay(&self) {
247        self.item_state.lock().pickup_delay = DEFAULT_PICKUP_DELAY;
248    }
249
250    /// Sets the pickup delay to zero (immediately pickupable).
251    pub fn set_no_pickup_delay(&self) {
252        self.item_state.lock().pickup_delay = 0;
253    }
254
255    /// Sets a custom pickup delay in ticks.
256    pub fn set_pickup_delay(&self, delay: i32) {
257        self.item_state.lock().pickup_delay = delay;
258    }
259
260    /// Returns true if the item has a pickup delay (cannot be picked up yet).
261    #[must_use]
262    pub fn has_pickup_delay(&self) -> bool {
263        self.item_state.lock().pickup_delay > 0
264    }
265
266    /// Gets the health (damage resistance).
267    #[must_use]
268    pub fn get_health(&self) -> i32 {
269        self.item_state.lock().health
270    }
271
272    /// Sets the health.
273    pub fn set_health(&self, health: i32) {
274        self.item_state.lock().health = health;
275    }
276
277    /// Sets the entity that threw/dropped this item.
278    pub fn set_thrower(&self, uuid: Uuid) {
279        self.item_state.lock().thrower = Some(uuid);
280    }
281
282    /// Gets the UUID of the entity that threw/dropped this item.
283    #[must_use]
284    pub fn get_thrower(&self) -> Option<Uuid> {
285        self.item_state.lock().thrower
286    }
287
288    /// Sets the owner (the only entity that can pick up this item).
289    ///
290    /// Pass `None` to allow any player to pick it up.
291    /// Vanilla calls this `target`.
292    pub fn set_owner(&self, uuid: Option<Uuid>) {
293        self.item_state.lock().owner = uuid;
294    }
295
296    /// Gets the owner UUID (the only entity that can pick up this item).
297    ///
298    /// Returns `None` if any player can pick it up.
299    #[must_use]
300    pub fn get_owner(&self) -> Option<Uuid> {
301        self.item_state.lock().owner
302    }
303
304    /// Attempts to have a player pick up this item.
305    ///
306    /// Returns `true` if the item was fully picked up (and the entity should be removed),
307    /// `false` if pickup failed or was only partial.
308    pub fn try_pickup(&self, player: &Arc<Player>) -> bool {
309        // Check pickup delay
310        if self.has_pickup_delay() {
311            return false;
312        }
313
314        // Check owner restriction
315        if let Some(owner_uuid) = self.get_owner()
316            && owner_uuid != player.gameprofile.id()
317        {
318            return false;
319        }
320
321        // Get the item and try to add to inventory
322        let mut item = self.get_item();
323        let original_count = item.count();
324
325        // Try to add to player's inventory
326        let added = player.inventory.lock().add(&mut item);
327
328        // If nothing was added, bail out
329        if item.count() == original_count {
330            return false;
331        }
332
333        // Calculate how many items were picked up
334        let picked_up_count = original_count - item.count();
335
336        // Vanilla uses the stack's count to award the stat that many items picked up, which is
337        // wrong if not all the items get picked up by the player. This could be considered
338        // buggy, but it's possible that people depend on this behavior.
339        player.award_stat_with_count(
340            &vanilla_stat_types::ITEM_PICKED_UP,
341            item.item,
342            original_count,
343        );
344
345        if item.item.has_tag(&ItemTag::FISHES) {
346            player.award_custom_stat(&vanilla_custom_stats::FISH_CAUGHT);
347        }
348
349        // Send the take animation packet to nearby players
350        if let Some(world) = self.level() {
351            let pos = self.position();
352            let chunk_pos = steel_utils::ChunkPos::from_entity_pos(pos);
353
354            let take_packet = CTakeItemEntity::new(self.id(), player.id(), picked_up_count);
355            world.broadcast_to_nearby(chunk_pos, take_packet, None);
356        }
357
358        // Update or remove the item entity
359        if added {
360            // Fully picked up - mark for removal
361            self.set_removed(RemovalReason::Discarded);
362            true
363        } else {
364            // Partial pickup - update the remaining item
365            self.set_item(item);
366            false
367        }
368    }
369
370    /// Returns true if this item entity can be merged with others.
371    ///
372    /// An item is mergeable if:
373    /// - It's not removed
374    /// - It doesn't have infinite pickup delay (32767)
375    /// - It doesn't have infinite lifetime (-32768)
376    /// - Its age is less than the despawn threshold (6000)
377    /// - Its count is less than max stack size
378    #[must_use]
379    pub fn is_mergeable(&self) -> bool {
380        let item = self.get_item();
381        let state = self.item_state.lock();
382        !self.is_removed()
383            && state.pickup_delay != INFINITE_PICKUP_DELAY
384            && state.age != INFINITE_LIFETIME
385            && state.age < LIFETIME
386            && item.count() < item.max_stack_size()
387    }
388
389    /// Checks if two item stacks can be merged together.
390    ///
391    /// Returns true if the items are the same type with the same components,
392    /// and their combined count wouldn't exceed max stack size.
393    #[must_use]
394    pub fn are_mergeable(this_stack: &ItemStack, other_stack: &ItemStack) -> bool {
395        // Combined count must not exceed max stack size
396        if other_stack.count() + this_stack.count() > other_stack.max_stack_size() {
397            return false;
398        }
399        // Must be the same item with the same components
400        ItemStack::is_same_item_same_components(this_stack, other_stack)
401    }
402
403    /// Attempts to merge with another item entity.
404    ///
405    /// The item with fewer items is merged into the one with more.
406    fn try_to_merge(&self, other: &Self) {
407        let this_stack = self.get_item();
408        let other_stack = other.get_item();
409
410        // Both items must have the same owner (target)
411        if self.get_owner() != other.get_owner() {
412            return;
413        }
414
415        if !Self::are_mergeable(&this_stack, &other_stack) {
416            return;
417        }
418
419        // Merge smaller stack into larger stack
420        if other_stack.count() < this_stack.count() {
421            Self::merge_stacks(self, &this_stack, other, &other_stack);
422        } else {
423            Self::merge_stacks(other, &other_stack, self, &this_stack);
424        }
425    }
426
427    /// Merges the `from_item`'s stack into the `to_item`'s stack.
428    fn merge_stacks(
429        to_item: &Self,
430        to_stack: &ItemStack,
431        from_item: &Self,
432        from_stack: &ItemStack,
433    ) {
434        // Calculate how many items to transfer
435        let max_count = to_stack.max_stack_size().min(MERGE_MAX_STACK_SIZE);
436        if to_stack.count() >= max_count {
437            return;
438        }
439        let space_available = max_count - to_stack.count();
440        let transfer_count = space_available.min(from_stack.count());
441
442        // Create new stacks
443        let new_to_stack = to_stack.copy_with_count(to_stack.count() + transfer_count);
444        let mut new_from_stack = from_stack.clone();
445        new_from_stack.shrink(transfer_count);
446
447        // Update the destination item
448        let (from_pickup_delay, from_age) = {
449            let state = from_item.item_state.lock();
450            (state.pickup_delay, state.age)
451        };
452        to_item.set_item(new_to_stack);
453
454        // Pickup delay is the max of both so merged items do not become instantly pickable.
455        // Age is the min of both so merged items do not despawn prematurely.
456        {
457            let mut state = to_item.item_state.lock();
458            state.pickup_delay = state.pickup_delay.max(from_pickup_delay);
459            state.age = state.age.min(from_age);
460        }
461
462        if new_from_stack.is_empty() {
463            from_item.set_removed(RemovalReason::Discarded);
464        } else {
465            from_item.set_item(new_from_stack);
466        }
467    }
468
469    /// Attempts to merge this item with nearby item entities.
470    ///
471    /// Searches for other mergeable item entities within 0.5 blocks horizontally
472    /// and attempts to merge with them.
473    pub fn merge_with_neighbors(&self, world: &Arc<World>) {
474        if !self.is_mergeable() {
475            return;
476        }
477
478        // Search area: 0.5 blocks horizontal, 0 vertical (vanilla uses inflate(0.5, 0.0, 0.5))
479        let search_box = self.bounding_box().inflate_xyz(0.5, 0.0, 0.5);
480
481        // Get all entities in the search area
482        for entity in world.get_entities_in_aabb(&search_box) {
483            // Skip self
484            if entity.id() == self.id() {
485                continue;
486            }
487
488            if let Some(other_item) = entity.downcast_ref::<Self>() {
489                // Double-check mergability (might have changed)
490                if other_item.is_mergeable() {
491                    self.try_to_merge(other_item);
492
493                    // If we've been removed (merged into other), stop
494                    if self.is_removed() {
495                        break;
496                    }
497                }
498            }
499        }
500    }
501
502    fn apply_fluid_movement_or_gravity(&self) {
503        let contact = self.fluid_contact();
504        if contact.water_height() > ITEM_FLUID_HEIGHT_THRESHOLD {
505            self.apply_fluid_movement(ITEM_WATER_DRAG);
506        } else if contact.lava_height() > ITEM_FLUID_HEIGHT_THRESHOLD {
507            self.apply_fluid_movement(ITEM_LAVA_DRAG);
508        } else {
509            self.apply_gravity();
510        }
511    }
512
513    fn apply_fluid_movement(&self, horizontal_drag: f64) {
514        let movement = self.velocity();
515        self.set_velocity(DVec3::new(
516            movement.x * horizontal_drag,
517            movement.y
518                + if movement.y < 0.06 {
519                    FLUID_VERTICAL_NUDGE
520                } else {
521                    0.0
522                },
523            movement.z * horizontal_drag,
524        ));
525    }
526}
527
528impl DefaultEntityPush for ItemEntity {}
529
530impl Entity for ItemEntity {
531    fn is_hard_collision_relevant(&self) -> bool {
532        false
533    }
534
535    fn as_default_entity_push(&self) -> Option<&dyn DefaultEntityPush> {
536        Some(self)
537    }
538
539    fn base(&self) -> &EntityBase {
540        &self.base
541    }
542
543    fn entity_type(&self) -> EntityTypeRef {
544        self.entity_type
545    }
546
547    fn ignore_explosion(&self, explosion: &dyn Explosion) -> bool {
548        !explosion.should_affect_blocklike_entities()
549    }
550
551    fn movement_emission(&self) -> EntityMovementEmission {
552        EntityMovementEmission::None
553    }
554
555    fn tick(self: Arc<Self>) {
556        // Check if item is empty
557        if self.get_item().is_empty() {
558            self.set_removed(RemovalReason::Discarded);
559            return;
560        }
561
562        self.default_tick();
563
564        {
565            let mut state = self.item_state.lock();
566            if state.pickup_delay > 0 && state.pickup_delay != INFINITE_PICKUP_DELAY {
567                state.pickup_delay -= 1;
568            }
569        }
570
571        // Vanilla item tick stores previous position before applying movement.
572        self.set_old_position_to_current();
573        let old_pos = self.old_position();
574        // Store old movement for needsSync check (vanilla: ItemEntity.tick line 98)
575        let old_movement = self.velocity();
576        // Store old on_ground to detect changes (triggers immediate sync)
577        let old_on_ground = self.on_ground();
578
579        self.apply_fluid_movement_or_gravity();
580        self.update_no_physics_from_current_collision();
581
582        // Vanilla optimization: skip physics when at rest on ground.
583        // Only process physics if:
584        // 1. Not on ground, OR
585        // 2. Has significant horizontal movement, OR
586        // 3. Every 4th tick (for items that might need to fall through opened trapdoors, etc.)
587        // (vanilla: ItemEntity.tick line 121)
588        let vel = self.velocity();
589        let horizontal_movement_sq = vel.x * vel.x + vel.z * vel.z;
590        let should_move = !self.on_ground()
591            || horizontal_movement_sq > 1.0e-5
592            || (self.tick_count() + self.id()) % 4 == 0;
593
594        if should_move {
595            // Move with collision detection; movement handles velocity zeroing on collision.
596            if let Some(result) =
597                Arc::clone(&self).move_entity(MoverType::SelfMovement, self.velocity())
598            {
599                self.apply_effects_from_blocks();
600                if self.is_removed() {
601                    return;
602                }
603
604                // Get world for block queries
605                if let Some(world) = self.level() {
606                    // Apply friction (vanilla: ItemEntity.tick line 125-128)
607                    let friction = if result.on_ground
608                        && let Some(block_pos) = self.block_pos_below_that_affects_movement()
609                    {
610                        let block_state = world.get_block_state(block_pos);
611                        f64::from(block_state.get_block().config.friction) * DEFAULT_AIR_DRAG
612                    } else {
613                        DEFAULT_AIR_DRAG
614                    };
615
616                    let mut velocity = self.velocity();
617                    velocity.x *= friction;
618                    velocity.z *= friction;
619                    velocity.y *= DEFAULT_AIR_DRAG;
620
621                    // Bounce when landing on ground (vanilla: ItemEntity.tick lines 145-149)
622                    if result.on_ground && velocity.y < 0.0 {
623                        velocity.y *= -0.5;
624                    }
625
626                    self.set_velocity(velocity);
627                }
628            }
629        } else {
630            self.apply_effects_from_blocks_for_last_movements();
631            if self.is_removed() {
632                return;
633            }
634        }
635
636        // Item merging (vanilla: ItemEntity.tick lines 152-156)
637        // Merge rate depends on whether the item moved to a different block
638        let current_pos = self.position();
639        let moved_block = old_pos.x.floor() as i32 != current_pos.x.floor() as i32
640            || old_pos.y.floor() as i32 != current_pos.y.floor() as i32
641            || old_pos.z.floor() as i32 != current_pos.z.floor() as i32;
642        let merge_rate = if moved_block { 2 } else { 40 };
643
644        if self.tick_count() % merge_rate == 0
645            && self.is_mergeable()
646            && let Some(world) = self.level()
647        {
648            self.merge_with_neighbors(&world);
649        }
650
651        // Vanilla item ticks refresh fluid interaction after movement and sync
652        // the result, which also emits water-entry splashes.
653        if self.update_fluid_interaction() {
654            self.mark_velocity_sync();
655        }
656
657        // Check if velocity changed significantly -> set needsSync (vanilla: ItemEntity.tick lines 160-164)
658        let new_movement = self.velocity();
659        let diff = DVec3::new(
660            new_movement.x - old_movement.x,
661            new_movement.y - old_movement.y,
662            new_movement.z - old_movement.z,
663        );
664        let diff_sq = diff.x * diff.x + diff.y * diff.y + diff.z * diff.z;
665        if diff_sq > 0.01 {
666            self.mark_velocity_sync();
667        }
668
669        // Also set needsSync when on_ground changes - this ensures immediate sync
670        // when the item lands or becomes airborne, preventing client desync
671        if self.on_ground() != old_on_ground {
672            self.mark_velocity_sync();
673        }
674
675        let should_despawn = {
676            let mut state = self.item_state.lock();
677            if state.age == INFINITE_LIFETIME {
678                false
679            } else {
680                state.age += 1;
681                state.age >= LIFETIME
682            }
683        };
684
685        if should_despawn {
686            self.set_removed(RemovalReason::Discarded);
687        }
688    }
689
690    fn get_default_gravity(&self) -> f64 {
691        DEFAULT_GRAVITY
692    }
693
694    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
695        Some(&self.entity_data)
696    }
697
698    fn block_pos_below_that_affects_movement(&self) -> Option<BlockPos> {
699        self.on_pos(0.999_999)
700    }
701
702    fn attackable(&self) -> bool {
703        false
704    }
705
706    fn should_play_lava_hurt_sound(&self) -> bool {
707        self.get_health() <= 0 || self.tick_count() % 10 == 0
708    }
709
710    fn fire_immune(&self) -> bool {
711        !self
712            .get_item()
713            .can_be_hurt_by(&vanilla_damage_types::IN_FIRE)
714            || self.entity_type().fire_immune
715    }
716
717    fn player_touch(self: Arc<Self>, player: &Arc<Player>) {
718        self.try_pickup(player);
719    }
720
721    fn hurt(&self, world: &World, source: &DamageSource, amount: f32) -> bool {
722        // TODO: Check isInvulnerableToBase once the shared non-living entity hook is ported.
723        if !world.get_game_rule(&MOB_GRIEFING)
724            && source
725                .causing_entity()
726                .is_some_and(|entity| entity.as_mob().is_some())
727        {
728            return false;
729        }
730        if !self.get_item().can_be_hurt_by(source.damage_type) {
731            return false;
732        }
733        let new_health = {
734            let mut state = self.item_state.lock();
735            state.health = (state.health as f32 - amount) as i32;
736            state.health
737        };
738        if new_health <= 0 {
739            // TODO: Call item.onDestroyed() when implemented
740            self.set_removed(RemovalReason::Killed);
741        }
742        true
743    }
744
745    fn save_additional(&self, nbt: &mut NbtCompound) {
746        // Match vanilla's ItemEntity.addAdditionalSaveData
747        let state = self.item_state.lock();
748        nbt.insert("Health", state.health as i16);
749        nbt.insert("Age", state.age as i16);
750        nbt.insert("PickupDelay", state.pickup_delay as i16);
751
752        if let Some(thrower) = state.thrower {
753            nbt.insert("Thrower", NbtTag::IntArray(thrower.to_int_array().to_vec()));
754        }
755        if let Some(owner) = state.owner {
756            nbt.insert("Owner", NbtTag::IntArray(owner.to_int_array().to_vec()));
757        }
758        drop(state);
759
760        let item = self.get_item();
761        if !item.is_empty() {
762            nbt.insert("Item", item.to_nbt_tag());
763        }
764    }
765
766    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
767        // Match vanilla's ItemEntity.readAdditionalSaveData
768        let mut state = self.item_state.lock();
769        if let Some(health) = nbt.short_value("Health") {
770            state.health = i32::from(health);
771        }
772        if let Some(age) = nbt.short_value("Age") {
773            state.age = i32::from(age);
774        }
775        if let Some(pickup_delay) = nbt.short_value("PickupDelay") {
776            state.pickup_delay = i32::from(pickup_delay);
777        }
778
779        if let Some(thrower_arr) = nbt.int_array("Thrower")
780            && let Some(uuid) = Uuid::from_int_array(&thrower_arr)
781        {
782            state.thrower = Some(uuid);
783        }
784        if let Some(owner_arr) = nbt.int_array("Owner")
785            && let Some(uuid) = Uuid::from_int_array(&owner_arr)
786        {
787            state.owner = Some(uuid);
788        }
789        drop(state);
790
791        if let Some(item_tag) = nbt.compound("Item")
792            && let Some(item) = ItemStack::from_borrowed_compound(&item_tag)
793        {
794            self.entity_data.lock().item.set(item);
795        }
796
797        // Vanilla behavior: discard if item is empty after load
798        if self.get_item().is_empty() {
799            self.set_removed(RemovalReason::Discarded);
800        }
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use std::sync::{Arc, Weak};
807
808    use glam::DVec3;
809
810    use steel_registry::{
811        init_vanilla_registry, item_stack::ItemStack, vanilla_blocks, vanilla_damage_types,
812        vanilla_entities, vanilla_items,
813    };
814    use steel_utils::{BlockPos, ChunkPos, types::UpdateFlags};
815
816    use crate::behavior::init_behaviors;
817    use crate::entity::{Entity, damage::DamageSource};
818    use crate::test_support::{fresh_test_world, insert_ready_full_chunk, test_world};
819    use crate::world::World;
820
821    use super::ItemEntity;
822
823    #[test]
824    fn item_entities_do_not_obstruct_block_placement() {
825        let item = ItemEntity::new(
826            &vanilla_entities::ITEM,
827            1,
828            DVec3::ZERO,
829            Weak::<World>::new(),
830        );
831
832        assert!(!item.blocks_building());
833    }
834
835    #[test]
836    fn item_lava_hurt_sound_uses_vanilla_interval() {
837        let item = ItemEntity::new(
838            &vanilla_entities::ITEM,
839            1,
840            DVec3::ZERO,
841            Weak::<World>::new(),
842        );
843
844        assert!(item.should_play_lava_hurt_sound());
845        item.advance_tick_count();
846        assert!(!item.should_play_lava_hurt_sound());
847
848        for _ in 1..10 {
849            item.advance_tick_count();
850        }
851        assert!(item.should_play_lava_hurt_sound());
852
853        item.set_health(0);
854        item.advance_tick_count();
855        assert!(item.should_play_lava_hurt_sound());
856    }
857
858    #[test]
859    fn item_with_stack_uses_vanilla_default_velocity() {
860        let item = ItemEntity::with_item(
861            &vanilla_entities::ITEM,
862            1,
863            DVec3::ZERO,
864            ItemStack::new(&vanilla_items::STONE),
865            Weak::<World>::new(),
866        );
867        let velocity = item.velocity();
868
869        assert!(velocity.x >= -0.1);
870        assert!(velocity.x < 0.1);
871        assert_eq!(velocity.y.to_bits(), 0.2_f64.to_bits());
872        assert!(velocity.z >= -0.1);
873        assert!(velocity.z < 0.1);
874    }
875
876    #[test]
877    fn item_tick_refreshes_fluid_after_movement() {
878        init_vanilla_registry();
879        init_behaviors();
880        let fixture = fresh_test_world("item_post_move_fluid_update");
881        let world = &fixture.world;
882        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
883        assert!(world.set_block(
884            BlockPos::new(8, 64, 8),
885            vanilla_blocks::WATER.default_state(),
886            UpdateFlags::UPDATE_NONE,
887        ));
888        let item = Arc::new(ItemEntity::with_item(
889            &vanilla_entities::ITEM,
890            1,
891            DVec3::new(8.5, 65.1, 8.5),
892            ItemStack::new(&vanilla_items::STONE),
893            Arc::downgrade(world),
894        ));
895        item.set_velocity(DVec3::new(0.0, -0.2, 0.0));
896
897        Arc::clone(&item).tick();
898
899        assert!(item.fluid_contact().water_height() > 0.0);
900        assert!(item.needs_velocity_sync());
901    }
902
903    #[test]
904    fn fake_item_is_never_pickable_and_expires_on_its_next_tick() {
905        let item = ItemEntity::with_item(
906            &vanilla_entities::ITEM,
907            1,
908            DVec3::ZERO,
909            ItemStack::new(&vanilla_items::STONE),
910            Weak::<World>::new(),
911        );
912
913        item.make_fake_item();
914
915        assert_eq!(item.get_pickup_delay(), 32_767);
916        assert_eq!(item.get_age(), 5_999);
917    }
918
919    #[test]
920    fn item_merge_preserves_vanilla_stack_and_timing() {
921        init_vanilla_registry();
922
923        let source = ItemEntity::with_item(
924            &vanilla_entities::ITEM,
925            1,
926            DVec3::ZERO,
927            ItemStack::with_count(&vanilla_items::STONE, 10),
928            Weak::<World>::new(),
929        );
930        source.set_pickup_delay(5);
931        source.set_age(20);
932
933        let target = ItemEntity::with_item(
934            &vanilla_entities::ITEM,
935            2,
936            DVec3::ZERO,
937            ItemStack::with_count(&vanilla_items::STONE, 20),
938            Weak::<World>::new(),
939        );
940        target.set_pickup_delay(1);
941        target.set_age(50);
942
943        source.try_to_merge(&target);
944
945        assert!(source.is_removed());
946        assert_eq!(target.get_item().count(), 30);
947        assert_eq!(target.get_pickup_delay(), 5);
948        assert_eq!(target.get_age(), 20);
949    }
950
951    #[test]
952    fn item_damage_truncates_after_fractional_subtraction() {
953        let item = ItemEntity::new(
954            &vanilla_entities::ITEM,
955            1,
956            DVec3::ZERO,
957            Weak::<World>::new(),
958        );
959
960        assert!(item.hurt(
961            test_world(),
962            &DamageSource::environment(&vanilla_damage_types::GENERIC),
963            0.75
964        ));
965
966        assert_eq!(item.get_health(), 4);
967    }
968
969    #[test]
970    fn damage_resistant_item_ignores_matching_damage() {
971        init_vanilla_registry();
972
973        let item = ItemEntity::with_item(
974            &vanilla_entities::ITEM,
975            1,
976            DVec3::ZERO,
977            ItemStack::new(&vanilla_items::NETHERITE_INGOT),
978            Weak::<World>::new(),
979        );
980
981        assert!(item.fire_immune());
982        assert!(!item.hurt(
983            test_world(),
984            &DamageSource::environment(&vanilla_damage_types::IN_FIRE),
985            1.0
986        ));
987        assert_eq!(item.get_health(), 5);
988
989        assert!(item.hurt(
990            test_world(),
991            &DamageSource::environment(&vanilla_damage_types::GENERIC),
992            1.0
993        ));
994        assert_eq!(item.get_health(), 4);
995    }
996}