Skip to main content

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

1//! Firework rocket projectile entity (`FireworkRocketEntity`).
2//!
3//! The server owns rocket movement, collision, Elytra boosting, lifetime,
4//! explosion damage, and entity-event dispatch. Firework trail and explosion
5//! particles are created by the client from synced rocket data and event 17.
6
7use std::sync::{Arc, Weak};
8
9use glam::DVec3;
10use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
11use simdnbt::owned::NbtCompound;
12use steel_macros::entity_behavior;
13use steel_protocol::packets::game::SoundSource;
14use steel_registry::blocks::block_state_ext::BlockStateExt as _;
15use steel_registry::data_components::vanilla_components::FIREWORKS;
16use steel_registry::entity_type::EntityTypeRef;
17use steel_registry::item_stack::ItemStack;
18use steel_registry::vanilla_entity_data::FireworkRocketEntityData;
19use steel_registry::{sound_events, vanilla_damage_types, vanilla_game_events, vanilla_items};
20use steel_utils::entity_events::EntityStatus;
21use steel_utils::locks::SyncMutex;
22use steel_utils::nbt::NbtValueInput as _;
23use steel_utils::{DowncastType, DowncastTypeKey};
24
25use crate::behavior::BLOCK_BEHAVIORS;
26use crate::entity::damage::DamageSource;
27use crate::entity::{
28    Entity, EntityBase, EntityBaseLoad, EntityEventSource, EntitySyncedData,
29    InsideBlockEffectCollector, LivingEntity, LivingEntityRef, Projectile, ProjectileBase,
30    ProjectileHit, RemovalReason, SharedEntity,
31};
32
33use crate::physics::MoverType;
34use crate::world::{ClipBlockShape, ClipFluid, ClipHitResult, World};
35
36const INITIAL_VERTICAL_VELOCITY: f64 = 0.05;
37const INITIAL_HORIZONTAL_DEVIATION: f64 = 0.002_297;
38const HORIZONTAL_ACCELERATION: f64 = 1.15;
39const VERTICAL_ACCELERATION: f64 = 0.04;
40const ELYTRA_TARGET_SPEED: f64 = 1.5;
41const ELYTRA_POWER_ADD: f64 = 0.1;
42const ELYTRA_VELOCITY_BLEND: f64 = 0.5;
43const EXPLOSION_RADIUS: f64 = 5.0;
44const EXPLOSION_RADIUS_SQUARED: f64 = EXPLOSION_RADIUS * EXPLOSION_RADIUS;
45
46struct FireworkRocketState {
47    life: i32,
48    lifetime: i32,
49    attached_to_entity: Option<Weak<dyn Entity>>,
50}
51
52/// A launched firework rocket.
53#[entity_behavior(class = "FireworkRocketEntity")]
54pub struct FireworkRocketEntity {
55    base: EntityBase,
56    entity_type: EntityTypeRef,
57    entity_data: SyncMutex<FireworkRocketEntityData>,
58    projectile_base: ProjectileBase,
59    state: SyncMutex<FireworkRocketState>,
60}
61
62// SAFETY: This key is owned by Steel and uniquely identifies `FireworkRocketEntity`.
63unsafe impl DowncastType for FireworkRocketEntity {
64    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/firework_rocket");
65}
66
67impl FireworkRocketEntity {
68    /// Creates an uninitialized rocket for the entity factory.
69    #[must_use]
70    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
71        Self {
72            base: EntityBase::new(id, position, entity_type.dimensions, world),
73            entity_type,
74            entity_data: SyncMutex::new(FireworkRocketEntityData::new()),
75            projectile_base: ProjectileBase::new(),
76            state: SyncMutex::new(FireworkRocketState {
77                life: 0,
78                lifetime: 0,
79                attached_to_entity: None,
80            }),
81        }
82    }
83
84    /// Creates a rocket from saved base data.
85    #[must_use]
86    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
87        Self {
88            base: EntityBase::from_load(load, entity_type.dimensions),
89            entity_type,
90            entity_data: SyncMutex::new(FireworkRocketEntityData::new()),
91            projectile_base: ProjectileBase::new(),
92            state: SyncMutex::new(FireworkRocketState {
93                life: 0,
94                lifetime: 0,
95                attached_to_entity: None,
96            }),
97        }
98    }
99
100    /// Creates a normally launched rocket at an exact position.
101    #[must_use]
102    pub fn launched(
103        entity_type: EntityTypeRef,
104        id: i32,
105        position: DVec3,
106        world: Weak<World>,
107        source_item: ItemStack,
108    ) -> Self {
109        let rocket = Self::new(entity_type, id, position, world);
110        rocket.initialize_launch(source_item);
111        rocket
112    }
113
114    /// Creates a rocket attached to the supplied living entity.
115    #[must_use]
116    pub fn attached_to_living(
117        entity_type: EntityTypeRef,
118        id: i32,
119        world: Weak<World>,
120        source_item: ItemStack,
121        attached_to: LivingEntityRef<'_>,
122    ) -> Self {
123        let attached_to = attached_to.entity();
124        let rocket = Self::launched(entity_type, id, attached_to.position(), world, source_item);
125        rocket.set_owner_entity(Some(attached_to));
126        rocket.state.lock().attached_to_entity = Some(Arc::downgrade(attached_to));
127        if let Ok(attached_id) = u32::try_from(attached_to.id()) {
128            rocket
129                .entity_data
130                .lock()
131                .attached_to_target
132                .set(Some(attached_id));
133        }
134        rocket
135    }
136
137    fn initialize_launch(&self, source_item: ItemStack) {
138        let flight_count = source_item
139            .get(FIREWORKS)
140            .map_or(1, |fireworks| 1 + fireworks.flight_duration());
141        self.entity_data.lock().id_fireworks_item.set(source_item);
142        self.set_velocity(DVec3::new(
143            triangle_random(0.0, INITIAL_HORIZONTAL_DEVIATION),
144            INITIAL_VERTICAL_VELOCITY,
145            triangle_random(0.0, INITIAL_HORIZONTAL_DEVIATION),
146        ));
147        self.state.lock().lifetime =
148            10 * flight_count + rand::random_range(0..6) + rand::random_range(0..7);
149    }
150
151    /// Sets whether this rocket was fired at an angle.
152    pub fn set_shot_at_angle(&self, shot_at_angle: bool) {
153        self.entity_data.lock().shot_at_angle.set(shot_at_angle);
154    }
155
156    /// Returns whether this rocket was fired at an angle.
157    #[must_use]
158    pub fn is_shot_at_angle(&self) -> bool {
159        *self.entity_data.lock().shot_at_angle.get()
160    }
161
162    fn is_attached_to_entity(&self) -> bool {
163        self.entity_data.lock().attached_to_target.get().is_some()
164    }
165
166    fn attached_entity(&self, world: &Arc<World>) -> Option<SharedEntity> {
167        let attached = self.state.lock().attached_to_entity.clone();
168        if let Some(attached) = attached {
169            // A resolved attachment must never rebind through a reused runtime ID.
170            return attached.upgrade();
171        }
172
173        let attached_id = *self.entity_data.lock().attached_to_target.get();
174        let attached_id = i32::try_from(attached_id?).ok()?;
175        let attached = world.get_entity_by_id(attached_id)?;
176        attached.as_living_entity()?;
177        self.state.lock().attached_to_entity = Some(Arc::downgrade(&attached));
178        Some(attached)
179    }
180
181    fn tick_attached(&self, world: &Arc<World>) -> Option<ProjectileHit> {
182        if let Some(attached) = self.attached_entity(world)
183            && let Some(living) = attached.as_living_entity()
184        {
185            let hand_angle = if living.is_fall_flying() {
186                let look_angle = living.look_angle();
187                let movement = living.velocity();
188                living.set_velocity(elytra_boosted_velocity(movement, look_angle));
189                living.hand_holding_item_angle(&vanilla_items::FIREWORK_ROCKET)
190            } else {
191                DVec3::ZERO
192            };
193
194            if let Err(error) = self.try_set_position(living.position() + hand_angle) {
195                log::debug!("failed to move attached firework rocket: {error}");
196            }
197            self.set_velocity(living.velocity());
198        }
199
200        self.get_hit_result_on_move_vector()
201    }
202
203    fn tick_free_flying(self: &Arc<Self>) -> Option<ProjectileHit> {
204        if !self.is_shot_at_angle() {
205            let horizontal_acceleration = if self.horizontal_collision() {
206                1.0
207            } else {
208                HORIZONTAL_ACCELERATION
209            };
210            let movement = self.velocity();
211            self.set_velocity(DVec3::new(
212                movement.x * horizontal_acceleration,
213                movement.y + VERTICAL_ACCELERATION,
214                movement.z * horizontal_acceleration,
215            ));
216        }
217
218        let movement = self.velocity();
219        let hit = self.get_hit_result_on_move_vector();
220        Arc::clone(self).move_entity(MoverType::SelfMovement, movement);
221        self.apply_effects_from_blocks();
222        self.set_velocity(movement);
223        hit
224    }
225
226    fn explosion_count(&self) -> usize {
227        self.entity_data
228            .lock()
229            .id_fireworks_item
230            .get()
231            .get(FIREWORKS)
232            .map_or(0, |fireworks| fireworks.explosions().len())
233    }
234
235    fn has_explosion(&self) -> bool {
236        self.explosion_count() != 0
237    }
238
239    fn fireworks_damage_source(self: &Arc<Self>) -> DamageSource {
240        let mut source = DamageSource::environment(&vanilla_damage_types::FIREWORKS)
241            .with_direct_entity(Arc::<Self>::clone(self));
242        if let Some(owner) = self.get_owner() {
243            source = source.with_causing_entity(owner);
244        }
245        source
246    }
247
248    fn deal_explosion_damage(self: &Arc<Self>, world: &Arc<World>) {
249        let explosion_count = self.explosion_count();
250        if explosion_count == 0 {
251            return;
252        }
253        let damage_amount = 5.0 + explosion_count as f32 * 2.0;
254        let attached = self.attached_entity(world);
255
256        if let Some(attached) = &attached {
257            attached.hurt(world, &self.fireworks_damage_source(), damage_amount);
258        }
259
260        let rocket_position = self.position();
261        let search_box = self.bounding_box().inflate(EXPLOSION_RADIUS);
262        for target in world.get_entities_in_aabb_matching(&search_box, Entity::is_living_entity) {
263            if attached
264                .as_ref()
265                .is_some_and(|attached| Arc::ptr_eq(attached, &target))
266            {
267                continue;
268            }
269            let distance_squared = rocket_position.distance_squared(target.position());
270            if distance_squared > EXPLOSION_RADIUS_SQUARED {
271                continue;
272            }
273
274            let target_height = f64::from(target.base().dimensions().height);
275            let can_see = [0.0, 0.5].into_iter().any(|height_scale| {
276                let target_position = target.position();
277                let to = DVec3::new(
278                    target_position.x,
279                    target_position.y + target_height * height_scale,
280                    target_position.z,
281                );
282                world
283                    .clip(
284                        rocket_position,
285                        to,
286                        ClipBlockShape::Collider,
287                        ClipFluid::None,
288                    )
289                    .is_miss()
290            });
291            if !can_see {
292                continue;
293            }
294
295            let distance = distance_squared.sqrt();
296            let distance_scale = ((EXPLOSION_RADIUS - distance) / EXPLOSION_RADIUS).sqrt();
297            target.hurt(
298                world,
299                &self.fireworks_damage_source(),
300                damage_amount * distance_scale as f32,
301            );
302        }
303    }
304
305    fn explode(self: &Arc<Self>, world: &Arc<World>) {
306        self.broadcast_entity_event(EntityStatus::FireworksExplode);
307        let owner = self.get_owner();
308        self.game_event_with_source_entity(&vanilla_game_events::EXPLODE, owner.as_deref());
309        self.deal_explosion_damage(world);
310        self.set_removed(RemovalReason::Discarded);
311    }
312
313    fn run_hit_block_entity_inside(&self, world: &Arc<World>, hit: &ClipHitResult) {
314        let state = world.get_block_state(hit.block_pos);
315        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
316        let mut ignored_effects = InsideBlockEffectCollector::new();
317        behavior.entity_inside(
318            state,
319            world,
320            hit.block_pos,
321            self.as_entity_event_source(),
322            &mut ignored_effects,
323            true,
324        );
325    }
326
327    #[cfg(test)]
328    fn life_and_lifetime(&self) -> (i32, i32) {
329        let state = self.state.lock();
330        (state.life, state.lifetime)
331    }
332}
333
334impl Entity for FireworkRocketEntity {
335    fn base(&self) -> &EntityBase {
336        &self.base
337    }
338
339    fn entity_type(&self) -> EntityTypeRef {
340        self.entity_type
341    }
342
343    fn tick(self: Arc<Self>) {
344        self.projectile_base_tick();
345        let Some(world) = self.level() else {
346            return;
347        };
348
349        let hit = if self.is_attached_to_entity() {
350            self.tick_attached(&world)
351        } else {
352            self.tick_free_flying()
353        };
354        if !self.no_physics()
355            && self.is_alive()
356            && let Some(hit) = &hit
357        {
358            Arc::clone(&self).hit_target_or_deflect_self(hit);
359            self.mark_velocity_sync();
360        }
361
362        self.update_rotation();
363        let (play_launch_sound, expired) = {
364            let mut state = self.state.lock();
365            let play_launch_sound = state.life == 0;
366            state.life = state.life.wrapping_add(1);
367            (play_launch_sound, state.life > state.lifetime)
368        };
369        if play_launch_sound && !self.is_silent() {
370            world.play_sound_at(
371                &sound_events::ENTITY_FIREWORK_ROCKET_LAUNCH,
372                SoundSource::Ambient,
373                self.position(),
374                3.0,
375                1.0,
376                None,
377            );
378        }
379        if expired {
380            self.explode(&world);
381        }
382    }
383
384    fn spawn_data(&self) -> i32 {
385        self.get_owner().map_or(0, |owner| owner.id())
386    }
387
388    fn restore_owner_reference(&self, owner: &SharedEntity) {
389        self.cache_owner_entity(owner);
390    }
391
392    fn projectile_owner_uuid(&self) -> Option<uuid::Uuid> {
393        self.owner_uuid()
394    }
395
396    fn projectile_owner(&self) -> Option<SharedEntity> {
397        self.get_owner()
398    }
399
400    fn attackable(&self) -> bool {
401        false
402    }
403
404    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
405        Some(&self.entity_data)
406    }
407
408    fn save_additional(&self, nbt: &mut NbtCompound) {
409        self.save_projectile(nbt);
410        let state = self.state.lock();
411        nbt.insert("Life", state.life);
412        nbt.insert("LifeTime", state.lifetime);
413        drop(state);
414        nbt.insert("FireworksItem", self.get_item().to_nbt_tag_ref());
415        nbt.insert("ShotAtAngle", i8::from(self.is_shot_at_angle()));
416    }
417
418    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
419        self.load_projectile(nbt);
420        {
421            let mut state = self.state.lock();
422            state.life = nbt.int_value("Life").unwrap_or(0);
423            state.lifetime = nbt.int_value("LifeTime").unwrap_or(0);
424        }
425        let item = nbt
426            .compound("FireworksItem")
427            .and_then(|item| ItemStack::from_borrowed_compound(&item))
428            .unwrap_or_else(|| ItemStack::new(&vanilla_items::FIREWORK_ROCKET));
429        self.set_item(item);
430        self.set_shot_at_angle(
431            nbt.byte_value("ShotAtAngle")
432                .is_some_and(|value| value != 0),
433        );
434    }
435}
436
437impl Projectile for FireworkRocketEntity {
438    fn projectile_base(&self) -> &ProjectileBase {
439        &self.projectile_base
440    }
441
442    fn calculate_horizontal_hurt_knockback_direction(
443        &self,
444        hurt_entity: &dyn LivingEntity,
445        _damage_source: &DamageSource,
446    ) -> (f64, f64) {
447        let delta = hurt_entity.position() - self.position();
448        (delta.x, delta.z)
449    }
450
451    fn on_hit_entity(self: Arc<Self>, _entity: &SharedEntity, _location: DVec3) {
452        if let Some(world) = self.level() {
453            self.explode(&world);
454        }
455    }
456
457    fn on_hit_block(self: Arc<Self>, hit: &ClipHitResult) {
458        if let Some(world) = self.level() {
459            self.run_hit_block_entity_inside(&world, hit);
460            if self.has_explosion() {
461                self.explode(&world);
462            }
463        }
464        self.projectile_on_hit_block(hit);
465    }
466}
467
468impl FireworkRocketEntity {
469    /// Returns the synced source stack rendered by the client.
470    #[must_use]
471    pub fn get_item(&self) -> ItemStack {
472        self.entity_data.lock().id_fireworks_item.get().clone()
473    }
474
475    /// Replaces the synced source stack.
476    pub fn set_item(&self, item: ItemStack) {
477        self.entity_data.lock().id_fireworks_item.set(item);
478    }
479}
480
481fn triangle_random(mode: f64, deviation: f64) -> f64 {
482    mode + deviation * (rand::random::<f64>() - rand::random::<f64>())
483}
484
485fn elytra_boosted_velocity(movement: DVec3, look_angle: DVec3) -> DVec3 {
486    movement
487        + look_angle * ELYTRA_POWER_ADD
488        + (look_angle * ELYTRA_TARGET_SPEED - movement) * ELYTRA_VELOCITY_BLEND
489}
490
491#[cfg(test)]
492mod tests {
493    use std::io::Cursor;
494
495    use simdnbt::borrow::read_compound as read_borrowed_compound;
496    use simdnbt::owned::NbtCompound;
497    use steel_registry::data_components::components::{FireworkExplosion, Fireworks};
498    use steel_registry::data_components::vanilla_components::FIREWORKS;
499    use steel_registry::item_stack::ItemStack;
500    use steel_registry::{init_vanilla_registry, vanilla_entities, vanilla_items};
501    use steel_utils::ChunkPos;
502
503    use crate::{
504        entity::{Entity, Projectile, entities::PigEntity},
505        test_support::{fresh_test_world, insert_ready_full_chunk, test_world},
506    };
507
508    use super::*;
509
510    #[test]
511    fn launched_rocket_uses_fireworks_flight_duration_for_lifetime() {
512        init_vanilla_registry();
513        let mut item = ItemStack::new(&vanilla_items::FIREWORK_ROCKET);
514        item.set(
515            FIREWORKS,
516            Fireworks::new(3, Vec::new()).unwrap_or_else(|error| {
517                panic!("valid firework component should construct: {error}")
518            }),
519        );
520        let rocket = FireworkRocketEntity::launched(
521            &vanilla_entities::FIREWORK_ROCKET,
522            1,
523            DVec3::ZERO,
524            Weak::new(),
525            item,
526        );
527
528        let (_, lifetime) = rocket.life_and_lifetime();
529        assert!((40..=51).contains(&lifetime));
530        assert_eq!(
531            rocket.velocity().y.to_bits(),
532            INITIAL_VERTICAL_VELOCITY.to_bits()
533        );
534    }
535
536    #[test]
537    fn firework_uses_vanilla_neutral_sound_source() {
538        init_vanilla_registry();
539        let rocket = FireworkRocketEntity::new(
540            &vanilla_entities::FIREWORK_ROCKET,
541            1,
542            DVec3::ZERO,
543            Weak::new(),
544        );
545
546        assert_eq!(rocket.sound_source(), SoundSource::Neutral);
547    }
548
549    #[test]
550    fn hurt_marks_rocket_unless_base_invulnerable_and_always_returns_false() {
551        init_vanilla_registry();
552        let rocket = FireworkRocketEntity::new(
553            &vanilla_entities::FIREWORK_ROCKET,
554            1,
555            DVec3::ZERO,
556            Weak::new(),
557        );
558        let source = DamageSource::environment(&vanilla_damage_types::GENERIC);
559
560        assert!(!Entity::hurt(&rocket, test_world(), &source, 1.0));
561        assert!(rocket.hurt_marked());
562
563        rocket.clear_hurt_mark();
564        rocket.set_invulnerable(true);
565        assert!(!Entity::hurt(&rocket, test_world(), &source, 1.0));
566        assert!(!rocket.hurt_marked());
567    }
568
569    #[test]
570    fn firework_metadata_carries_item_attachment_and_angle() {
571        init_vanilla_registry();
572        let target: SharedEntity = Arc::new(PigEntity::new(
573            &vanilla_entities::PIG,
574            19,
575            DVec3::new(1.0, 2.0, 3.0),
576            Weak::new(),
577        ));
578        let Some(living_target) = LivingEntityRef::new(&target) else {
579            panic!("pig test entity should be living");
580        };
581        let rocket = FireworkRocketEntity::attached_to_living(
582            &vanilla_entities::FIREWORK_ROCKET,
583            2,
584            Weak::new(),
585            ItemStack::new(&vanilla_items::FIREWORK_ROCKET),
586            living_target,
587        );
588        rocket.set_shot_at_angle(true);
589
590        let data = rocket.entity_data.lock();
591        assert_eq!(*data.attached_to_target.get(), Some(19));
592        assert!(*data.shot_at_angle.get());
593        assert!(
594            data.id_fireworks_item
595                .get()
596                .is(&vanilla_items::FIREWORK_ROCKET)
597        );
598        assert_eq!(rocket.owner_uuid(), Some(target.uuid()));
599    }
600
601    #[test]
602    fn attached_rocket_keeps_its_target_when_world_ids_are_reused() {
603        let world_fixture = fresh_test_world("rocket_attachment_identity");
604        let world = &world_fixture.world;
605        let owner_world_fixture = fresh_test_world("rocket_owner_world");
606        let owner_world = &owner_world_fixture.world;
607        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
608        let target: SharedEntity = Arc::new(PigEntity::new(
609            &vanilla_entities::PIG,
610            19,
611            DVec3::ZERO,
612            Arc::downgrade(owner_world),
613        ));
614        let rocket = FireworkRocketEntity::attached_to_living(
615            &vanilla_entities::FIREWORK_ROCKET,
616            20,
617            Arc::downgrade(world),
618            ItemStack::new(&vanilla_items::FIREWORK_ROCKET),
619            LivingEntityRef::new(&target).expect("living target"),
620        );
621        assert!(Arc::ptr_eq(
622            &rocket.get_owner().expect("owner in other world"),
623            &target
624        ));
625
626        target.set_removed(RemovalReason::Discarded);
627        let original_generation = target.generation();
628        let replacement: SharedEntity = Arc::new(PigEntity::new(
629            &vanilla_entities::PIG,
630            target.id(),
631            DVec3::ZERO,
632            Arc::downgrade(world),
633        ));
634        world
635            .try_add_entity(replacement)
636            .expect("replacement registration");
637        assert!(Arc::ptr_eq(
638            &rocket.attached_entity(world).expect("original attachment"),
639            &target
640        ));
641        drop(target);
642        assert!(
643            rocket
644                .attached_entity(world)
645                .is_none_or(|entity| entity.generation() == original_generation)
646        );
647    }
648
649    #[test]
650    fn explosion_damages_replacement_with_attached_entity_id() {
651        let world_fixture = fresh_test_world("firework_explosion_identity");
652        let world = &world_fixture.world;
653        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
654        let position = DVec3::new(8.0, 64.0, 8.0);
655        let original: SharedEntity = Arc::new(PigEntity::new(
656            &vanilla_entities::PIG,
657            1,
658            position,
659            Arc::downgrade(world),
660        ));
661        world
662            .try_add_entity(Arc::clone(&original))
663            .expect("register original attachment");
664        let mut item = ItemStack::new(&vanilla_items::FIREWORK_ROCKET);
665        item.set(
666            FIREWORKS,
667            Fireworks::new(1, vec![FireworkExplosion::default()]).expect("fireworks"),
668        );
669        let rocket = Arc::new(FireworkRocketEntity::attached_to_living(
670            &vanilla_entities::FIREWORK_ROCKET,
671            2,
672            Arc::downgrade(world),
673            item,
674            LivingEntityRef::new(&original).expect("living attachment"),
675        ));
676
677        original.set_removed(RemovalReason::Discarded);
678        let replacement = Arc::new(PigEntity::new(
679            &vanilla_entities::PIG,
680            original.id(),
681            position + DVec3::X,
682            Arc::downgrade(world),
683        ));
684        world
685            .try_add_entity(Arc::<PigEntity>::clone(&replacement))
686            .expect("register replacement");
687
688        let replacement_health = replacement.get_health();
689        rocket.deal_explosion_damage(world);
690
691        assert!(
692            replacement.get_health() < replacement_health,
693            "a different allocation with the attachment ID must receive explosion damage"
694        );
695    }
696
697    #[test]
698    fn firework_state_persists_with_vanilla_keys() {
699        init_vanilla_registry();
700        let rocket = FireworkRocketEntity::launched(
701            &vanilla_entities::FIREWORK_ROCKET,
702            1,
703            DVec3::ZERO,
704            Weak::new(),
705            ItemStack::new(&vanilla_items::FIREWORK_ROCKET),
706        );
707        {
708            let mut state = rocket.state.lock();
709            state.life = 7;
710            state.lifetime = 29;
711        }
712        rocket.set_shot_at_angle(true);
713        rocket.set_owner_uuid(Some(uuid::Uuid::from_u128(42)));
714
715        let mut nbt = NbtCompound::new();
716        rocket.save_additional(&mut nbt);
717        assert_eq!(nbt.int("Life"), Some(7));
718        assert_eq!(nbt.int("LifeTime"), Some(29));
719        assert_eq!(nbt.byte("ShotAtAngle"), Some(1));
720
721        let mut bytes = Vec::new();
722        nbt.write(&mut bytes);
723        let borrowed = read_borrowed_compound(&mut Cursor::new(&bytes))
724            .unwrap_or_else(|error| panic!("test NBT should reborrow: {error}"));
725        let loaded = FireworkRocketEntity::new(
726            &vanilla_entities::FIREWORK_ROCKET,
727            2,
728            DVec3::ZERO,
729            Weak::new(),
730        );
731        loaded.load_additional((&borrowed).into());
732
733        assert_eq!(loaded.life_and_lifetime(), (7, 29));
734        assert!(loaded.is_shot_at_angle());
735        assert_eq!(loaded.owner_uuid(), Some(uuid::Uuid::from_u128(42)));
736        assert!(loaded.get_item().is(&vanilla_items::FIREWORK_ROCKET));
737    }
738
739    #[test]
740    fn firework_knockback_direction_points_from_rocket_to_target() {
741        init_vanilla_registry();
742        let rocket = FireworkRocketEntity::new(
743            &vanilla_entities::FIREWORK_ROCKET,
744            1,
745            DVec3::new(2.0, 0.0, 3.0),
746            Weak::new(),
747        );
748        let target = PigEntity::new(
749            &vanilla_entities::PIG,
750            2,
751            DVec3::new(5.0, 0.0, 1.0),
752            Weak::new(),
753        );
754        let source = DamageSource::environment(&vanilla_damage_types::FIREWORKS);
755
756        assert_eq!(
757            rocket.calculate_horizontal_hurt_knockback_direction(&target, &source),
758            (3.0, -2.0)
759        );
760        assert!(rocket.as_projectile().is_some());
761    }
762
763    #[test]
764    fn firework_damage_source_has_no_raw_position() {
765        init_vanilla_registry();
766        let rocket = Arc::new(FireworkRocketEntity::new(
767            &vanilla_entities::FIREWORK_ROCKET,
768            23,
769            DVec3::new(1.0, 2.0, 3.0),
770            Weak::new(),
771        ));
772
773        let source = rocket.fireworks_damage_source();
774
775        assert_eq!(source.direct_entity().map(|entity| entity.id()), Some(23));
776        assert!(source.source_position_raw().is_none());
777    }
778
779    #[test]
780    fn elytra_boost_matches_vanilla_vector_formula() {
781        let movement = DVec3::new(0.2, -0.1, 0.4);
782        let look_angle = DVec3::new(0.0, 0.0, 1.0);
783
784        assert_eq!(
785            elytra_boosted_velocity(movement, look_angle),
786            DVec3::new(0.1, -0.05, 1.05)
787        );
788    }
789}