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