Skip to main content

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