Skip to main content

steel_core/entity/entities/objects/projectiles/
ender_pearl.rs

1//! Thrown ender pearl projectile entity (`ThrownEnderpearl`).
2//!
3//! Mirrors vanilla `ThrownEnderpearl` (yarn `EnderPearlEntity`) on the Steel
4//! `Projectile → ThrowableProjectile → ThrowableItemProjectile` trait stack.
5//! On collision it teleports its owning player to the pearl's pre-move position,
6//! deals 5.0 `ender_pearl` damage, plays the teleport sound, and discards itself.
7//!
8//! The pearl refreshes a timeout chunk ticket (`TicketType.ENDER_PEARL`) each tick
9//! so it keeps flying across the simulation border, and is registered on its owning
10//! player so it persists with them and re-spawns on login (vanilla
11//! `ServerPlayer.enderPearls`).
12
13use std::sync::{Arc, Weak};
14
15use glam::DVec3;
16use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
17use simdnbt::owned::NbtCompound;
18use steel_macros::entity_behavior;
19use steel_protocol::packets::game::{RelativeMovement, SoundSource};
20use steel_registry::entity_type::EntityTypeRef;
21use steel_registry::item_stack::ItemStack;
22use steel_registry::items::ItemRef;
23use steel_registry::vanilla_entity_data::EnderPearlEntityData;
24use steel_registry::vanilla_game_rules::{ENDER_PEARLS_VANISH_ON_DEATH, SPAWN_MOBS};
25use steel_registry::{sound_events, vanilla_damage_types, vanilla_entities, vanilla_items};
26use steel_utils::ChunkPos;
27use steel_utils::locks::SyncMutex;
28use steel_utils::{BlockPos, Downcast as _, DowncastType, DowncastTypeKey};
29
30use crate::chunk::chunk_map::ENDER_PEARL_TICKET_TIMEOUT;
31use crate::entity::damage::DamageSource;
32use crate::entity::entities::EndermiteEntity;
33use crate::entity::{
34    ENTITIES, Entity, EntityBase, EntityBaseLoad, EntitySyncedData, LivingEntity, Projectile,
35    ProjectileBase, ProjectileHit, RemovalReason, SharedEntity, ThrowableItemProjectile,
36    ThrowableProjectile, change_entity_world, next_entity_id,
37};
38use crate::player::Player;
39use crate::portal::{TeleportPostTransition, TeleportTransition};
40use crate::world::World;
41
42/// Fall-style damage dealt to the teleporting owner (vanilla `enderPearl()`, 5.0).
43const TELEPORT_DAMAGE: f32 = 5.0;
44
45/// Spawn chance for an endermite on impact (5% in vanilla `ThrownEnderpearl`).
46const ENDERMITE_SPAWN_CHANCE: f32 = 0.05;
47
48/// A thrown ender pearl.
49#[entity_behavior(class = "ThrownEnderpearl")]
50pub struct EnderPearlEntity {
51    /// Common entity fields (id, uuid, position, etc.).
52    base: EntityBase,
53    /// Vanilla entity type registered for this implementation.
54    entity_type: EntityTypeRef,
55    /// Synced data carrying the rendered item stack.
56    entity_data: SyncMutex<EnderPearlEntityData>,
57    /// Shared `Projectile` state (owner / left-owner / has-been-shot).
58    projectile_base: ProjectileBase,
59    /// Countdown until the chunk-loading ticket is refreshed (vanilla `ticketTimer`).
60    ticket_timer: SyncMutex<i64>,
61}
62
63// SAFETY: This key is owned by Steel and uniquely identifies `EnderPearlEntity`.
64unsafe impl DowncastType for EnderPearlEntity {
65    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/ender_pearl");
66}
67
68impl EnderPearlEntity {
69    /// Creates a new ender pearl with no owner and the default rendered item.
70    #[must_use]
71    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
72        Self {
73            base: EntityBase::new(id, position, entity_type.dimensions, world),
74            entity_type,
75            entity_data: SyncMutex::new(EnderPearlEntityData::new()),
76            projectile_base: ProjectileBase::new(),
77            ticket_timer: SyncMutex::new(0),
78        }
79    }
80
81    /// Creates an ender pearl from saved base data.
82    #[must_use]
83    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
84        Self {
85            base: EntityBase::from_load(load, entity_type.dimensions),
86            entity_type,
87            entity_data: SyncMutex::new(EnderPearlEntityData::new()),
88            projectile_base: ProjectileBase::new(),
89            ticket_timer: SyncMutex::new(0),
90        }
91    }
92
93    /// Resolves the owner as an online player, including cached cross-world refs.
94    fn owner_player(&self) -> Option<SharedEntity> {
95        let owner = self.get_owner()?;
96        owner.as_player()?;
97        Some(owner)
98    }
99
100    /// Removes this pearl from its owner's persistence set when it hits or is
101    /// discarded (vanilla `ThrownEnderpearl.onRemoval` deregistration).
102    fn deregister_from_owner(&self) {
103        if let Some(owner) = self.owner_player()
104            && let Some(player) = owner.as_player()
105        {
106            player.deregister_ender_pearl(self.uuid());
107        }
108    }
109
110    /// Refreshes the chunk-loading ticket so the pearl keeps flying across the
111    /// simulation border (vanilla `ThrownEnderpearl.tick` →
112    /// `registerAndUpdateEnderPearlTicket`).
113    ///
114    /// Re-places the ticket when the countdown lapses or the pearl crosses a chunk
115    /// border, but only while owned by an online player.
116    fn update_ender_pearl_ticket(&self, world: &Arc<World>) {
117        let current_chunk = ChunkPos::from_entity_pos(self.position());
118        let crossed_border = ChunkPos::from_entity_pos(self.old_position()) != current_chunk;
119
120        let mut timer = self.ticket_timer.lock();
121        *timer -= 1;
122        if *timer > 0 && !crossed_border {
123            return;
124        }
125
126        let Some(owner) = self.owner_player() else {
127            return;
128        };
129        let Some(player) = owner.as_player() else {
130            return;
131        };
132        let Some(pearl) = world.get_entity_by_uuid(&self.uuid()) else {
133            return;
134        };
135        if pearl.generation() != self.generation() {
136            return;
137        }
138
139        player.register_ender_pearl(&pearl);
140        world.chunk_map.place_ender_pearl_ticket(current_chunk);
141        // Vanilla `registerAndUpdateEnderPearlTicket` returns `timeout - 1`.
142        *timer = ENDER_PEARL_TICKET_TIMEOUT - 1;
143    }
144
145    /// Vanilla `ThrownEnderpearl.tick` owner-death short-circuit: a pearl whose
146    /// owner is a dead player vanishes when the gamerule is set.
147    fn should_vanish_on_owner_death(&self, world: &Arc<World>) -> bool {
148        let Some(owner) = self.owner_player() else {
149            return false;
150        };
151        let Some(player) = owner.as_player() else {
152            return false;
153        };
154        // Vanilla checks `!owner.isAlive()` for a dead (but still connected) player.
155        // `LivingEntity::is_alive` is the health-based override vanilla dispatches to.
156        Self::should_vanish_for_owner_state(
157            LivingEntity::is_alive(player),
158            player.has_won_game(),
159            world.get_game_rule(&ENDER_PEARLS_VANISH_ON_DEATH),
160        )
161    }
162
163    const fn should_vanish_for_owner_state(
164        owner_alive: bool,
165        owner_won_game: bool,
166        vanish_on_death_rule: bool,
167    ) -> bool {
168        !owner_alive && !owner_won_game && vanish_on_death_rule
169    }
170
171    /// Vanilla `ThrownEnderpearl.isAllowedToTeleportOwner`.
172    fn is_allowed_to_teleport_owner(world: &Arc<World>, player: &Player) -> bool {
173        let player_world = player.get_world();
174        if Arc::ptr_eq(&player_world, world) {
175            return LivingEntity::is_alive(player) && !player.is_sleeping();
176        }
177
178        player.can_use_portal(true)
179    }
180
181    /// Teleports the owning player and applies the pearl's effects.
182    ///
183    /// Mirrors the `ServerPlayer` branch of vanilla `ThrownEnderpearl.onHit`.
184    fn teleport_owner(
185        &self,
186        world: &Arc<World>,
187        owner: &SharedEntity,
188        player: &Player,
189        teleport_pos: DVec3,
190    ) {
191        if world.get_game_rule(&SPAWN_MOBS) && rand::random::<f32>() < ENDERMITE_SPAWN_CHANCE {
192            let endermite = ENTITIES.create(
193                &vanilla_entities::ENDERMITE,
194                next_entity_id(),
195                teleport_pos,
196                Arc::downgrade(world),
197            );
198            if let Some(endermite) = endermite {
199                if let Some(endermite_entity) = endermite.as_ref().downcast_ref::<EndermiteEntity>()
200                {
201                    endermite_entity.set_player_spawned(true);
202                }
203                let _ = world.try_add_entity(endermite);
204            }
205        }
206        if self.is_on_portal_cooldown() {
207            player.reset_portal_cooldown();
208        }
209
210        let transition = TeleportTransition {
211            target_world: Arc::clone(world),
212            position: teleport_pos,
213            rotation: (0.0, 0.0),
214            velocity: DVec3::ZERO,
215            relatives: RelativeMovement::ROTATION.union(RelativeMovement::DELTA),
216            portal_cooldown: player.portal_cooldown(),
217            as_passenger: false,
218            post_transition: TeleportPostTransition::do_nothing(),
219        };
220        let Some(new_owner) = change_entity_world(Arc::clone(owner), &transition) else {
221            log::debug!("failed to teleport ender pearl owner {}", self.id());
222            return;
223        };
224        let Some(new_player) = new_owner.as_player() else {
225            return;
226        };
227
228        new_player.reset_fall_distance();
229        new_player.reset_current_impulse_context();
230
231        let damage = DamageSource::environment(&vanilla_damage_types::ENDER_PEARL);
232        new_player.hurt(world, &damage, TELEPORT_DAMAGE);
233
234        world.play_sound_at(
235            &sound_events::ENTITY_PLAYER_TELEPORT,
236            SoundSource::Players,
237            teleport_pos,
238            1.0,
239            1.0,
240            None,
241        );
242    }
243}
244
245impl Entity for EnderPearlEntity {
246    fn base(&self) -> &EntityBase {
247        &self.base
248    }
249
250    fn entity_type(&self) -> EntityTypeRef {
251        self.entity_type
252    }
253
254    fn tick(&self) {
255        // Vanilla `ThrownEnderpearl.tick`: vanish if the owner died (gamerule),
256        // otherwise run the throwable projectile movement/collision loop and keep
257        // the pearl's chunk loaded via the ENDER_PEARL ticket.
258        let Some(world) = self.level() else {
259            self.throwable_projectile_tick();
260            return;
261        };
262
263        if self.should_vanish_on_owner_death(&world) {
264            self.deregister_from_owner();
265            self.set_removed(RemovalReason::Discarded);
266            return;
267        }
268
269        self.throwable_projectile_tick();
270
271        if self.is_alive() {
272            self.update_ender_pearl_ticket(&world);
273        }
274    }
275
276    fn get_default_gravity(&self) -> f64 {
277        self.throwable_default_gravity()
278    }
279
280    fn on_above_bubble_column(&self, drag_down: bool, pos: BlockPos) {
281        self.default_on_above_bubble_column(drag_down, pos);
282    }
283
284    fn on_inside_bubble_column(&self, drag_down: bool) {
285        self.default_on_inside_bubble_column(drag_down);
286    }
287
288    fn sound_source(&self) -> SoundSource {
289        SoundSource::Neutral
290    }
291
292    fn spawn_data(&self) -> i32 {
293        self.get_owner().map_or(0, |owner| owner.id())
294    }
295
296    fn restore_owner_reference(&self, owner: &SharedEntity) {
297        self.cache_owner_entity(owner);
298    }
299
300    fn projectile_owner_uuid(&self) -> Option<uuid::Uuid> {
301        self.owner_uuid()
302    }
303
304    fn projectile_owner(&self) -> Option<SharedEntity> {
305        self.get_owner()
306    }
307
308    fn attackable(&self) -> bool {
309        false
310    }
311
312    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
313        Some(&self.entity_data)
314    }
315
316    fn save_additional(&self, nbt: &mut NbtCompound) {
317        self.save_projectile(nbt);
318        self.save_throwable_item(nbt);
319    }
320
321    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
322        self.load_projectile(nbt);
323        self.load_throwable_item(nbt);
324    }
325}
326
327impl Projectile for EnderPearlEntity {
328    fn projectile_base(&self) -> &ProjectileBase {
329        &self.projectile_base
330    }
331
332    fn on_hit_entity(&self, entity: &SharedEntity, _location: DVec3) {
333        // Vanilla `ThrownEnderpearl.onHitEntity`: deal 0 damage with a `thrown`
334        // source so the hit entity registers the impact without being hurt.
335        let mut damage =
336            DamageSource::environment(&vanilla_damage_types::THROWN).with_direct_entity(self.id());
337        if let Some(owner) = self.get_owner() {
338            damage = damage.with_causing_entity(owner.id());
339        }
340        if let Some(world) = entity.level() {
341            entity.hurt(&world, &damage, 0.0);
342        }
343    }
344
345    fn on_hit(&self, hit: &ProjectileHit) {
346        // Vanilla `ThrownEnderpearl.onHit`: super.onHit() then teleport the owner.
347        self.projectile_on_hit(hit);
348
349        // VANILLA CLIENT-LOCAL: `ThrownEnderpearl.onHit` creates the 32 portal particles.
350        let Some(world) = self.level() else {
351            return;
352        };
353        if self.is_removed() {
354            return;
355        }
356
357        let teleport_pos = self.old_position();
358        if let Some(owner) = self.owner_player()
359            && let Some(player) = owner.as_player()
360            && Self::is_allowed_to_teleport_owner(&world, player)
361        {
362            self.teleport_owner(&world, &owner, player, teleport_pos);
363        }
364        self.deregister_from_owner();
365        self.set_removed(RemovalReason::Discarded);
366    }
367}
368
369impl ThrowableProjectile for EnderPearlEntity {}
370
371impl ThrowableItemProjectile for EnderPearlEntity {
372    fn get_default_item(&self) -> ItemRef {
373        &vanilla_items::ENDER_PEARL
374    }
375
376    fn set_item(&self, item: ItemStack) {
377        self.entity_data
378            .lock()
379            .throwable_item_projectile
380            .item_stack
381            .set(item);
382    }
383
384    fn get_item(&self) -> ItemStack {
385        self.entity_data
386            .lock()
387            .throwable_item_projectile
388            .item_stack
389            .get()
390            .clone()
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use std::sync::{Arc, Weak};
397
398    use glam::DVec3;
399    use steel_registry::{init_vanilla_registry, vanilla_entities, vanilla_items};
400    use steel_utils::{BlockPos, ChunkPos};
401
402    use crate::entity::{Entity, Projectile, SharedEntity, ThrowableItemProjectile};
403    use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
404    use crate::world::World;
405
406    use super::EnderPearlEntity;
407
408    #[test]
409    fn shoot_aligns_velocity_with_direction() {
410        init_vanilla_registry();
411
412        let pearl = EnderPearlEntity::new(
413            &vanilla_entities::ENDER_PEARL,
414            1,
415            DVec3::ZERO,
416            Weak::<World>::new(),
417        );
418        pearl.shoot(DVec3::new(0.0, 0.0, 1.0), 1.5, 1.0);
419
420        let velocity = pearl.velocity();
421        assert!(velocity.z > 0.0);
422        assert!((velocity.length() - 1.5).abs() < 0.1);
423        assert!(velocity.x.abs() < 0.1 && velocity.y.abs() < 0.1);
424    }
425
426    #[test]
427    fn owner_round_trips() {
428        init_vanilla_registry();
429
430        let pearl = EnderPearlEntity::new(
431            &vanilla_entities::ENDER_PEARL,
432            1,
433            DVec3::ZERO,
434            Weak::<World>::new(),
435        );
436        assert!(pearl.owner_uuid().is_none());
437
438        let uuid = uuid::Uuid::from_u128(0x1234_5678_9abc_def0);
439        pearl.set_owner_uuid(Some(uuid));
440        assert_eq!(pearl.owner_uuid(), Some(uuid));
441        assert_eq!(pearl.projectile_owner_uuid(), Some(uuid));
442    }
443
444    #[test]
445    fn default_item_is_ender_pearl() {
446        init_vanilla_registry();
447
448        let pearl = EnderPearlEntity::new(
449            &vanilla_entities::ENDER_PEARL,
450            1,
451            DVec3::ZERO,
452            Weak::<World>::new(),
453        );
454        assert_eq!(pearl.get_default_item().key, vanilla_items::ENDER_PEARL.key);
455    }
456
457    #[test]
458    fn owner_death_vanish_predicate_respects_won_game() {
459        assert!(EnderPearlEntity::should_vanish_for_owner_state(
460            false, false, true
461        ));
462        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
463            false, true, true
464        ));
465        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
466            true, false, true
467        ));
468        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
469            false, false, false
470        ));
471    }
472
473    #[test]
474    fn ticket_renewal_registers_with_the_resolved_owner() {
475        init_vanilla_registry();
476
477        let world = fresh_test_world("ender_pearl_ticket_owner");
478        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
479        let player = TestPlayerBuilder::new(Arc::clone(&world), "Owner", 1).build();
480        let owner: SharedEntity = player.clone();
481        let pearl = Arc::new(EnderPearlEntity::new(
482            &vanilla_entities::ENDER_PEARL,
483            2,
484            DVec3::new(0.5, 64.0, 0.5),
485            Arc::downgrade(&world),
486        ));
487        pearl.set_owner_entity(Some(&owner));
488        let shared_pearl: SharedEntity = pearl.clone();
489        if let Err(error) = world.try_add_entity(shared_pearl) {
490            panic!("test pearl should be added: {error}");
491        }
492
493        assert!(player.ender_pearls().is_empty());
494        pearl.update_ender_pearl_ticket(&world);
495
496        let registered = player.ender_pearls();
497        assert_eq!(registered.len(), 1);
498        assert_eq!(registered[0].generation(), pearl.generation());
499    }
500
501    #[test]
502    fn bubble_column_uses_clamped_entity_behavior() {
503        init_vanilla_registry();
504
505        let pearl = EnderPearlEntity::new(
506            &vanilla_entities::ENDER_PEARL,
507            1,
508            DVec3::ZERO,
509            Weak::<World>::new(),
510        );
511        let entity: &dyn Entity = &pearl;
512
513        for (above_column, drag_down, initial_y, expected_y) in [
514            (true, false, 2.0, 1.8),
515            (true, true, -2.0, -0.9),
516            (false, false, 2.0, 0.7),
517            (false, true, -2.0, -0.3),
518        ] {
519            entity.set_velocity(DVec3::new(0.25, initial_y, -0.25));
520            entity.set_fall_distance(5.0);
521
522            if above_column {
523                entity.on_above_bubble_column(drag_down, BlockPos::new(0, 0, 0));
524            } else {
525                entity.on_inside_bubble_column(drag_down);
526            }
527
528            let expected = DVec3::new(0.25, expected_y, -0.25);
529            let actual = entity.velocity();
530
531            assert!(
532                (actual - expected).abs().max_element() < 1.0e-12,
533                "above_column={above_column}, drag_down={drag_down}: \
534                 expected {expected:?}, got {actual:?}"
535            );
536
537            let expected_fall_distance = if above_column { 5.0 } else { 0.0 };
538            assert!(
539                (entity.fall_distance() - expected_fall_distance).abs() < 1.0e-12,
540                "above_column={above_column}, drag_down={drag_down}: \
541                 unexpected fall distance"
542            );
543        }
544    }
545}