Skip to main content

steel_core/entity/entities/
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;
25use steel_registry::{sound_events, vanilla_damage_types, vanilla_items};
26use steel_utils::ChunkPos;
27use steel_utils::locks::SyncMutex;
28use steel_utils::{DowncastType, DowncastTypeKey};
29
30use crate::chunk::chunk_map::ENDER_PEARL_TICKET_TIMEOUT;
31use crate::entity::damage::DamageSource;
32use crate::entity::{
33    Entity, EntityBase, EntityBaseLoad, EntitySyncedData, LivingEntity, Projectile, ProjectileBase,
34    ProjectileHit, RemovalReason, SharedEntity, ThrowableItemProjectile, ThrowableProjectile,
35    change_entity_world,
36};
37use crate::player::Player;
38use crate::portal::{TeleportPostTransition, TeleportTransition};
39use crate::world::World;
40
41/// Fall-style damage dealt to the teleporting owner (vanilla `enderPearl()`, 5.0).
42const TELEPORT_DAMAGE: f32 = 5.0;
43
44/// A thrown ender pearl.
45#[entity_behavior(class = "ThrownEnderpearl")]
46pub struct EnderPearlEntity {
47    /// Common entity fields (id, uuid, position, etc.).
48    base: EntityBase,
49    /// Vanilla entity type registered for this implementation.
50    entity_type: EntityTypeRef,
51    /// Synced data carrying the rendered item stack.
52    entity_data: SyncMutex<EnderPearlEntityData>,
53    /// Shared `Projectile` state (owner / left-owner / has-been-shot).
54    projectile_base: ProjectileBase,
55    /// Countdown until the chunk-loading ticket is refreshed (vanilla `ticketTimer`).
56    ticket_timer: SyncMutex<i32>,
57}
58
59// SAFETY: This key is owned by Steel and uniquely identifies `EnderPearlEntity`.
60unsafe impl DowncastType for EnderPearlEntity {
61    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/ender_pearl");
62}
63
64impl EnderPearlEntity {
65    /// Creates a new ender pearl with no owner and the default rendered item.
66    #[must_use]
67    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
68        Self {
69            base: EntityBase::new(id, position, entity_type.dimensions, world),
70            entity_type,
71            entity_data: SyncMutex::new(EnderPearlEntityData::new()),
72            projectile_base: ProjectileBase::new(),
73            ticket_timer: SyncMutex::new(0),
74        }
75    }
76
77    /// Creates an ender pearl from saved base data.
78    #[must_use]
79    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
80        Self {
81            base: EntityBase::from_load(load, entity_type.dimensions),
82            entity_type,
83            entity_data: SyncMutex::new(EnderPearlEntityData::new()),
84            projectile_base: ProjectileBase::new(),
85            ticket_timer: SyncMutex::new(0),
86        }
87    }
88
89    /// Resolves the owner as an online player, including cached cross-world refs.
90    fn owner_player(&self) -> Option<SharedEntity> {
91        let owner = self.get_owner()?;
92        owner.as_player()?;
93        Some(owner)
94    }
95
96    /// Removes this pearl from its owner's persistence set when it hits or is
97    /// discarded (vanilla `ThrownEnderpearl.onRemoval` deregistration).
98    fn deregister_from_owner(&self) {
99        if let Some(owner) = self.owner_player()
100            && let Some(player) = owner.as_player()
101        {
102            player.deregister_ender_pearl(self.uuid());
103        }
104    }
105
106    /// Refreshes the chunk-loading ticket so the pearl keeps flying across the
107    /// simulation border (vanilla `ThrownEnderpearl.tick` →
108    /// `registerAndUpdateEnderPearlTicket`).
109    ///
110    /// Re-places the ticket when the countdown lapses or the pearl crosses a chunk
111    /// border, but only while owned by an online player.
112    fn update_ender_pearl_ticket(&self, world: &Arc<World>) {
113        let current_chunk = ChunkPos::from_entity_pos(self.position());
114        let crossed_border = ChunkPos::from_entity_pos(self.old_position()) != current_chunk;
115
116        let mut timer = self.ticket_timer.lock();
117        *timer -= 1;
118        if (*timer > 0 && !crossed_border) || self.owner_player().is_none() {
119            return;
120        }
121
122        world.chunk_map.place_ender_pearl_ticket(current_chunk);
123        // Vanilla `registerAndUpdateEnderPearlTicket` returns `timeout - 1`.
124        *timer = ENDER_PEARL_TICKET_TIMEOUT as i32 - 1;
125    }
126
127    /// Vanilla `ThrownEnderpearl.tick` owner-death short-circuit: a pearl whose
128    /// owner is a dead player vanishes when the gamerule is set.
129    fn should_vanish_on_owner_death(&self, world: &Arc<World>) -> bool {
130        let Some(owner) = self.owner_player() else {
131            return false;
132        };
133        let Some(player) = owner.as_player() else {
134            return false;
135        };
136        // Vanilla checks `!owner.isAlive()` for a dead (but still connected) player.
137        // `LivingEntity::is_alive` is the health-based override vanilla dispatches to.
138        Self::should_vanish_for_owner_state(
139            LivingEntity::is_alive(player),
140            player.has_won_game(),
141            world.get_game_rule(&ENDER_PEARLS_VANISH_ON_DEATH),
142        )
143    }
144
145    const fn should_vanish_for_owner_state(
146        owner_alive: bool,
147        owner_won_game: bool,
148        vanish_on_death_rule: bool,
149    ) -> bool {
150        !owner_alive && !owner_won_game && vanish_on_death_rule
151    }
152
153    /// Vanilla `ThrownEnderpearl.isAllowedToTeleportOwner`.
154    fn is_allowed_to_teleport_owner(world: &Arc<World>, player: &Player) -> bool {
155        let player_world = player.get_world();
156        if Arc::ptr_eq(&player_world, world) {
157            return LivingEntity::is_alive(player) && !player.is_sleeping();
158        }
159
160        player.can_use_portal(true)
161    }
162
163    /// Teleports the owning player and applies the pearl's effects.
164    ///
165    /// Mirrors the `ServerPlayer` branch of vanilla `ThrownEnderpearl.onHit`.
166    fn teleport_owner(
167        &self,
168        world: &Arc<World>,
169        owner: &SharedEntity,
170        player: &Player,
171        teleport_pos: DVec3,
172    ) {
173        // TODO: 5% endermite spawn (Endermite entity not implemented).
174        if self.is_on_portal_cooldown() {
175            player.reset_portal_cooldown();
176        }
177
178        let transition = TeleportTransition {
179            target_world: Arc::clone(world),
180            position: teleport_pos,
181            rotation: (0.0, 0.0),
182            velocity: DVec3::ZERO,
183            relatives: RelativeMovement::ROTATION.union(RelativeMovement::DELTA),
184            portal_cooldown: player.portal_cooldown(),
185            as_passenger: false,
186            post_transition: TeleportPostTransition::do_nothing(),
187        };
188        let Some(new_owner) = change_entity_world(Arc::clone(owner), &transition) else {
189            log::debug!("failed to teleport ender pearl owner {}", self.id());
190            return;
191        };
192        let Some(new_player) = new_owner.as_player() else {
193            return;
194        };
195
196        new_player.reset_fall_distance();
197        new_player.reset_current_impulse_context();
198
199        let damage = DamageSource::environment(&vanilla_damage_types::ENDER_PEARL);
200        new_player.hurt(world, &damage, TELEPORT_DAMAGE);
201
202        world.play_sound_at(
203            &sound_events::ENTITY_PLAYER_TELEPORT,
204            SoundSource::Players,
205            teleport_pos,
206            1.0,
207            1.0,
208            None,
209        );
210    }
211}
212
213impl Entity for EnderPearlEntity {
214    fn base(&self) -> &EntityBase {
215        &self.base
216    }
217
218    fn entity_type(&self) -> EntityTypeRef {
219        self.entity_type
220    }
221
222    fn tick(&self) {
223        // Vanilla `ThrownEnderpearl.tick`: vanish if the owner died (gamerule),
224        // otherwise run the throwable projectile movement/collision loop and keep
225        // the pearl's chunk loaded via the ENDER_PEARL ticket.
226        let Some(world) = self.level() else {
227            self.throwable_projectile_tick();
228            return;
229        };
230
231        if self.should_vanish_on_owner_death(&world) {
232            self.deregister_from_owner();
233            self.set_removed(RemovalReason::Discarded);
234            return;
235        }
236
237        self.throwable_projectile_tick();
238
239        if self.is_alive() {
240            self.update_ender_pearl_ticket(&world);
241        }
242    }
243
244    fn get_default_gravity(&self) -> f64 {
245        self.throwable_default_gravity()
246    }
247
248    fn sound_source(&self) -> SoundSource {
249        SoundSource::Neutral
250    }
251
252    fn spawn_data(&self) -> i32 {
253        self.get_owner().map_or(0, |owner| owner.id())
254    }
255
256    fn restore_owner_reference(&self, owner: &SharedEntity) {
257        self.cache_owner_entity(owner);
258    }
259
260    fn projectile_owner_uuid(&self) -> Option<uuid::Uuid> {
261        self.owner_uuid()
262    }
263
264    fn projectile_owner(&self) -> Option<SharedEntity> {
265        self.get_owner()
266    }
267
268    fn attackable(&self) -> bool {
269        false
270    }
271
272    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
273        Some(&self.entity_data)
274    }
275
276    fn hurt(&self, _world: &World, _source: &DamageSource, _amount: f32) -> bool {
277        // Vanilla `Projectile.hurtServer` marks hurt but never takes damage.
278        false
279    }
280
281    fn save_additional(&self, nbt: &mut NbtCompound) {
282        self.save_projectile(nbt);
283        self.save_throwable_item(nbt);
284    }
285
286    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
287        self.load_projectile(nbt);
288        self.load_throwable_item(nbt);
289    }
290}
291
292impl Projectile for EnderPearlEntity {
293    fn projectile_base(&self) -> &ProjectileBase {
294        &self.projectile_base
295    }
296
297    fn on_hit_entity(&self, entity: &SharedEntity, _location: DVec3) {
298        // Vanilla `ThrownEnderpearl.onHitEntity`: deal 0 damage with a `thrown`
299        // source so the hit entity registers the impact without being hurt.
300        let mut damage =
301            DamageSource::environment(&vanilla_damage_types::THROWN).with_direct_entity(self.id());
302        if let Some(owner) = self.get_owner() {
303            damage = damage.with_causing_entity(owner.id());
304        }
305        if let Some(world) = entity.level() {
306            entity.hurt(&world, &damage, 0.0);
307        }
308    }
309
310    fn on_hit(&self, hit: &ProjectileHit) {
311        // Vanilla `ThrownEnderpearl.onHit`: super.onHit() then teleport the owner.
312        self.projectile_on_hit(hit);
313
314        // VANILLA CLIENT-LOCAL: `ThrownEnderpearl.onHit` creates the 32 portal particles.
315        let Some(world) = self.level() else {
316            return;
317        };
318        if self.is_removed() {
319            return;
320        }
321
322        let teleport_pos = self.old_position();
323        if let Some(owner) = self.owner_player()
324            && let Some(player) = owner.as_player()
325            && Self::is_allowed_to_teleport_owner(&world, player)
326        {
327            self.teleport_owner(&world, &owner, player, teleport_pos);
328        }
329        self.deregister_from_owner();
330        self.set_removed(RemovalReason::Discarded);
331    }
332}
333
334impl ThrowableProjectile for EnderPearlEntity {}
335
336impl ThrowableItemProjectile for EnderPearlEntity {
337    fn get_default_item(&self) -> ItemRef {
338        &vanilla_items::ENDER_PEARL
339    }
340
341    fn set_item(&self, item: ItemStack) {
342        self.entity_data
343            .lock()
344            .throwable_item_projectile
345            .item_stack
346            .set(item);
347    }
348
349    fn get_item(&self) -> ItemStack {
350        self.entity_data
351            .lock()
352            .throwable_item_projectile
353            .item_stack
354            .get()
355            .clone()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use std::sync::Weak;
362
363    use glam::DVec3;
364    use steel_registry::{init_vanilla_registry, vanilla_entities, vanilla_items};
365
366    use crate::entity::{Entity, Projectile, ThrowableItemProjectile};
367    use crate::world::World;
368
369    use super::EnderPearlEntity;
370
371    #[test]
372    fn shoot_aligns_velocity_with_direction() {
373        init_vanilla_registry();
374
375        let pearl = EnderPearlEntity::new(
376            &vanilla_entities::ENDER_PEARL,
377            1,
378            DVec3::ZERO,
379            Weak::<World>::new(),
380        );
381        pearl.shoot(DVec3::new(0.0, 0.0, 1.0), 1.5, 1.0);
382
383        let velocity = pearl.velocity();
384        assert!(velocity.z > 0.0);
385        assert!((velocity.length() - 1.5).abs() < 0.1);
386        assert!(velocity.x.abs() < 0.1 && velocity.y.abs() < 0.1);
387    }
388
389    #[test]
390    fn owner_round_trips() {
391        init_vanilla_registry();
392
393        let pearl = EnderPearlEntity::new(
394            &vanilla_entities::ENDER_PEARL,
395            1,
396            DVec3::ZERO,
397            Weak::<World>::new(),
398        );
399        assert!(pearl.owner_uuid().is_none());
400
401        let uuid = uuid::Uuid::from_u128(0x1234_5678_9abc_def0);
402        pearl.set_owner_uuid(Some(uuid));
403        assert_eq!(pearl.owner_uuid(), Some(uuid));
404        assert_eq!(pearl.projectile_owner_uuid(), Some(uuid));
405    }
406
407    #[test]
408    fn default_item_is_ender_pearl() {
409        init_vanilla_registry();
410
411        let pearl = EnderPearlEntity::new(
412            &vanilla_entities::ENDER_PEARL,
413            1,
414            DVec3::ZERO,
415            Weak::<World>::new(),
416        );
417        assert_eq!(pearl.get_default_item().key, vanilla_items::ENDER_PEARL.key);
418    }
419
420    #[test]
421    fn owner_death_vanish_predicate_respects_won_game() {
422        assert!(EnderPearlEntity::should_vanish_for_owner_state(
423            false, false, true
424        ));
425        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
426            false, true, true
427        ));
428        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
429            true, false, true
430        ));
431        assert!(!EnderPearlEntity::should_vanish_for_owner_state(
432            false, false, false
433        ));
434    }
435}