Skip to main content

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