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    /// Returns whether the owner may be teleported to: if still in the
172    /// pearl's world they must be alive and not sleeping, otherwise a
173    /// cross-dimension teleport requires portal permission.
174    fn is_allowed_to_teleport_owner(world: &Arc<World>, player: &Player) -> bool {
175        let player_world = player.get_world();
176        if Arc::ptr_eq(&player_world, world) {
177            return LivingEntity::is_alive(player) && !player.is_sleeping();
178        }
179
180        player.can_use_portal(true)
181    }
182
183    /// Teleports the owning player and applies the pearl's effects.
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: Arc<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        Arc::clone(&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 synced_data(&self) -> Option<&dyn EntitySyncedData> {
309        Some(&self.entity_data)
310    }
311
312    fn save_additional(&self, nbt: &mut NbtCompound) {
313        self.save_projectile(nbt);
314        self.save_throwable_item(nbt);
315    }
316
317    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
318        self.load_projectile(nbt);
319        self.load_throwable_item(nbt);
320    }
321}
322
323impl Projectile for EnderPearlEntity {
324    fn projectile_base(&self) -> &ProjectileBase {
325        &self.projectile_base
326    }
327
328    fn on_hit_entity(self: Arc<Self>, entity: &SharedEntity, _location: DVec3) {
329        // Vanilla `ThrownEnderpearl.onHitEntity`: deal 0 damage with a `thrown`
330        // source so the hit entity registers the impact without being hurt.
331        let mut damage = DamageSource::environment(&vanilla_damage_types::THROWN)
332            .with_direct_entity(Arc::<Self>::clone(&self));
333        if let Some(owner) = self.get_owner() {
334            damage = damage.with_causing_entity(owner);
335        }
336        if let Some(world) = entity.level() {
337            entity.hurt(&world, &damage, 0.0);
338        }
339    }
340
341    fn on_hit(self: Arc<Self>, hit: &ProjectileHit) {
342        // Vanilla `ThrownEnderpearl.onHit`: super.onHit() then teleport the owner.
343        Arc::clone(&self).projectile_on_hit(hit);
344
345        // VANILLA CLIENT-LOCAL: `ThrownEnderpearl.onHit` creates the 32 portal particles.
346        let Some(world) = self.level() else {
347            return;
348        };
349        if self.is_removed() {
350            return;
351        }
352
353        let teleport_pos = self.old_position();
354        if let Some(owner) = self.owner_player()
355            && let Some(player) = owner.as_player()
356            && Self::is_allowed_to_teleport_owner(&world, player)
357        {
358            self.teleport_owner(&world, &owner, player, teleport_pos);
359        }
360        self.deregister_from_owner();
361        self.set_removed(RemovalReason::Discarded);
362    }
363}
364
365impl ThrowableProjectile for EnderPearlEntity {}
366
367impl ThrowableItemProjectile for EnderPearlEntity {
368    fn get_default_item(&self) -> ItemRef {
369        &vanilla_items::ENDER_PEARL
370    }
371
372    fn set_item(&self, item: ItemStack) {
373        self.entity_data
374            .lock()
375            .throwable_item_projectile
376            .item_stack
377            .set(item);
378    }
379
380    fn get_item(&self) -> ItemStack {
381        self.entity_data
382            .lock()
383            .throwable_item_projectile
384            .item_stack
385            .get()
386            .clone()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use std::sync::{Arc, Weak};
393
394    use glam::DVec3;
395    use steel_registry::{init_vanilla_registry, vanilla_entities, vanilla_items};
396    use steel_utils::{BlockPos, ChunkPos};
397
398    use crate::entity::{Entity, Projectile, SharedEntity, ThrowableItemProjectile};
399    use crate::player::Player;
400    use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
401    use crate::world::World;
402
403    use super::EnderPearlEntity;
404
405    #[test]
406    fn shoot_aligns_velocity_with_direction() {
407        init_vanilla_registry();
408
409        let pearl = EnderPearlEntity::new(
410            &vanilla_entities::ENDER_PEARL,
411            1,
412            DVec3::ZERO,
413            Weak::<World>::new(),
414        );
415        pearl.shoot(DVec3::new(0.0, 0.0, 1.0), 1.5, 1.0);
416
417        let velocity = pearl.velocity();
418        assert!(velocity.z > 0.0);
419        assert!((velocity.length() - 1.5).abs() < 0.1);
420        assert!(velocity.x.abs() < 0.1 && velocity.y.abs() < 0.1);
421    }
422
423    #[test]
424    fn owner_round_trips() {
425        init_vanilla_registry();
426
427        let pearl = EnderPearlEntity::new(
428            &vanilla_entities::ENDER_PEARL,
429            1,
430            DVec3::ZERO,
431            Weak::<World>::new(),
432        );
433        assert!(pearl.owner_uuid().is_none());
434
435        let uuid = uuid::Uuid::from_u128(0x1234_5678_9abc_def0);
436        pearl.set_owner_uuid(Some(uuid));
437        assert_eq!(pearl.owner_uuid(), Some(uuid));
438        assert_eq!(pearl.projectile_owner_uuid(), Some(uuid));
439    }
440
441    #[test]
442    fn default_item_is_ender_pearl() {
443        init_vanilla_registry();
444
445        let pearl = EnderPearlEntity::new(
446            &vanilla_entities::ENDER_PEARL,
447            1,
448            DVec3::ZERO,
449            Weak::<World>::new(),
450        );
451        assert_eq!(pearl.get_default_item().key, vanilla_items::ENDER_PEARL.key);
452    }
453
454    #[test]
455    fn owner_death_vanish_predicate_respects_won_game() {
456        assert!(EnderPearlEntity::should_vanish_for_owner_state(
457            false, false, true
458        ));
459        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
460            false, true, true
461        ));
462        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
463            true, false, true
464        ));
465        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
466            false, false, false
467        ));
468    }
469
470    #[test]
471    fn ticket_renewal_registers_with_the_resolved_owner() {
472        init_vanilla_registry();
473
474        let world_fixture = fresh_test_world("ender_pearl_ticket_owner");
475        let world = &world_fixture.world;
476        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
477        let player = TestPlayerBuilder::new(Arc::clone(world), "Owner", 1).build();
478        let owner: SharedEntity = Arc::<Player>::clone(&player);
479        let pearl = Arc::new(EnderPearlEntity::new(
480            &vanilla_entities::ENDER_PEARL,
481            2,
482            DVec3::new(0.5, 64.0, 0.5),
483            Arc::downgrade(world),
484        ));
485        pearl.set_owner_entity(Some(&owner));
486        let shared_pearl: SharedEntity = Arc::<EnderPearlEntity>::clone(&pearl);
487        if let Err(error) = world.try_add_entity(shared_pearl) {
488            panic!("test pearl should be added: {error}");
489        }
490
491        assert!(player.ender_pearls().is_empty());
492        pearl.update_ender_pearl_ticket(world);
493
494        let registered = player.ender_pearls();
495        assert_eq!(registered.len(), 1);
496        assert_eq!(registered[0].generation(), pearl.generation());
497    }
498
499    #[test]
500    fn bubble_column_uses_clamped_entity_behavior() {
501        init_vanilla_registry();
502
503        let pearl = EnderPearlEntity::new(
504            &vanilla_entities::ENDER_PEARL,
505            1,
506            DVec3::ZERO,
507            Weak::<World>::new(),
508        );
509        let entity: &dyn Entity = &pearl;
510
511        for (above_column, drag_down, initial_y, expected_y) in [
512            (true, false, 2.0, 1.8),
513            (true, true, -2.0, -0.9),
514            (false, false, 2.0, 0.7),
515            (false, true, -2.0, -0.3),
516        ] {
517            entity.set_velocity(DVec3::new(0.25, initial_y, -0.25));
518            entity.set_fall_distance(5.0);
519
520            if above_column {
521                entity.on_above_bubble_column(drag_down, BlockPos::new(0, 0, 0));
522            } else {
523                entity.on_inside_bubble_column(drag_down);
524            }
525
526            let expected = DVec3::new(0.25, expected_y, -0.25);
527            let actual = entity.velocity();
528
529            assert!(
530                (actual - expected).abs().max_element() < 1.0e-12,
531                "above_column={above_column}, drag_down={drag_down}: \
532                 expected {expected:?}, got {actual:?}"
533            );
534
535            let expected_fall_distance = if above_column { 5.0 } else { 0.0 };
536            assert!(
537                (entity.fall_distance() - expected_fall_distance).abs() < 1.0e-12,
538                "above_column={above_column}, drag_down={drag_down}: \
539                 unexpected fall distance"
540            );
541        }
542    }
543}