Skip to main content

steel_core/entity/
conversion.rs

1use std::sync::Arc;
2
3use steel_registry::entity_type::EntityTypeRef;
4use steel_utils::types::Difficulty;
5
6use crate::entity::registry::ENTITIES;
7use crate::entity::{
8    Entity, Mob, RemovalReason, SharedEntity, next_entity_id, start_riding_entities,
9};
10use crate::inventory::equipment::EquipmentSlot;
11
12/// Finalizes a freshly converted mob.
13pub type AfterConversion<'a> = dyn Fn(&dyn Mob) + 'a;
14
15/// Controls which state a converted mob inherits.
16///
17/// Team membership is not transferred yet.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct ConversionParams {
20    /// Whether to replace or split the source mob.
21    pub conversion_type: ConversionType,
22    /// Whether to copy equipped items and their drop chances.
23    pub keep_equipment: bool,
24    /// Whether to copy the source mob's loot-pickup setting.
25    pub preserve_can_pick_up_loot: bool,
26}
27
28impl ConversionParams {
29    /// Creates parameters for replacing the source mob.
30    #[must_use]
31    pub const fn single(keep_equipment: bool, preserve_can_pick_up_loot: bool) -> Self {
32        Self {
33            conversion_type: ConversionType::Single,
34            keep_equipment,
35            preserve_can_pick_up_loot,
36        }
37    }
38}
39
40/// How conversion handles the source mob.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ConversionType {
43    /// The converted mob replaces the original in place (`SINGLE`).
44    Single,
45    /// Spawns a separate mob and keeps the source, as with slime splits.
46    SplitOnDeath,
47}
48
49impl ConversionType {
50    /// Whether conversion discards the source mob.
51    #[must_use]
52    pub const fn should_discard_after_conversion(self) -> bool {
53        matches!(self, Self::Single)
54    }
55
56    fn convert(self, from: &dyn Mob, to: &SharedEntity, params: ConversionParams) {
57        let Some(to_mob) = to.as_mob() else {
58            log::error!(
59                "cannot convert {} to {}: created entity is not a mob",
60                from.entity_type().key,
61                to.entity_type().key
62            );
63            return;
64        };
65        match self {
66            Self::Single => convert_single(from, to_mob, to, params),
67            Self::SplitOnDeath => convert_split_on_death(from, to_mob, params),
68        }
69    }
70}
71
72/// Unlike vanilla, inserts the new mob before transferring state so
73/// rejected insertions leave the source, passengers, and leash untouched.
74pub(crate) fn convert_to(
75    from: &dyn Mob,
76    entity_type: EntityTypeRef,
77    params: ConversionParams,
78    after_conversion: Option<&AfterConversion<'_>>,
79) -> Option<SharedEntity> {
80    if from.is_removed() {
81        return None;
82    }
83    let world = from.level()?;
84    // TODO: Check feature flags once supported cause currently only checks difficulty
85    if !entity_type.allowed_in_peaceful && world.difficulty() == Difficulty::Peaceful {
86        return None;
87    }
88    let Some(to) = ENTITIES.create(
89        entity_type,
90        next_entity_id(),
91        from.position(),
92        Arc::downgrade(&world),
93    ) else {
94        log::warn!(
95            "cannot convert {} to {}: no entity factory is registered",
96            from.entity_type().key,
97            entity_type.key
98        );
99        return None;
100    };
101    let Some(to_mob) = to.as_mob() else {
102        log::error!(
103            "cannot convert {} to {}: created entity is not a mob",
104            from.entity_type().key,
105            entity_type.key
106        );
107        return None;
108    };
109
110    if let Err(error) = world.try_add_entity(Arc::clone(&to)) {
111        log::error!(
112            "failed to add converted {} for {}: {error}",
113            to_mob.entity_type().key,
114            from.entity_type().key
115        );
116        return None;
117    }
118
119    params.conversion_type.convert(from, &to, params);
120    if let Some(after_conversion) = after_conversion {
121        after_conversion(to_mob);
122    }
123
124    if params.conversion_type.should_discard_after_conversion() {
125        from.set_removed(RemovalReason::Discarded);
126    }
127    Some(to)
128}
129
130fn convert_single(from: &dyn Mob, to: &dyn Mob, to_arc: &SharedEntity, params: ConversionParams) {
131    let root_passenger = from.first_passenger();
132
133    copy_position(from, to);
134    to.set_velocity(from.velocity());
135
136    if let Some(root_passenger) = root_passenger {
137        root_passenger.stop_riding();
138        root_passenger.base().set_boarding_cooldown(0);
139
140        for passenger in to_arc.passengers() {
141            passenger.stop_riding();
142            passenger.set_removed(RemovalReason::Discarded);
143        }
144
145        start_riding_entities(&root_passenger, to_arc);
146    }
147
148    if let Some(vehicle) = from.vehicle() {
149        from.stop_riding();
150        start_riding_entities(to_arc, &vehicle);
151    }
152
153    if params.keep_equipment {
154        for slot in EquipmentSlot::ALL {
155            let stack = from.living_base().equipment().lock().take(slot);
156            if !stack.is_empty() {
157                to.living_base().equipment().lock().set(slot, stack);
158                to.set_equipment_drop_chance(slot, from.equipment_drop_chance(slot));
159            }
160        }
161    }
162
163    to.set_fall_distance(from.fall_distance());
164    to.set_fall_flying(from.is_fall_flying());
165
166    // Vanilla copies only the timer, not the remembered player.
167    to.set_last_hurt_by_player_memory_time(from.last_hurt_by_player_memory_time());
168    // TODO: Copy vanilla `hurtTime` once Steel supports the hurt-flash counter.
169
170    to.set_y_body_rot(from.y_body_rot());
171    to.set_on_ground(from.on_ground());
172    if let Some(bed_position) = from.sleeping_pos() {
173        to.set_sleeping_pos(bed_position);
174    }
175    if let Some(leash_holder) = from.leash_holder() {
176        to.set_leashed_to(&leash_holder);
177    }
178
179    convert_common(from, to, params);
180}
181
182fn convert_split_on_death(from: &dyn Mob, to: &dyn Mob, params: ConversionParams) {
183    if let Some(root_passenger) = from.first_passenger() {
184        root_passenger.stop_riding();
185    }
186    if from.leash_holder().is_some() {
187        from.drop_leash();
188    }
189
190    convert_common(from, to, params);
191}
192
193fn convert_common(from: &dyn Mob, to: &dyn Mob, params: ConversionParams) {
194    to.set_absorption_amount(from.living_base().absorption_amount());
195
196    for effect in from.active_mob_effects() {
197        // Re-adding effects reruns `onEffectStarted`, granting fresh absorption
198        // hearts after the amount copied above, as in vanilla.
199        to.add_mob_effect(effect);
200    }
201
202    if from.is_baby()
203        && let Some(to_ageable) = to.as_ageable_mob()
204    {
205        to_ageable.set_baby(true);
206    }
207
208    if let (Some(old_ageable), Some(converted_ageable)) =
209        (from.as_ageable_mob(), to.as_ageable_mob())
210    {
211        converted_ageable.set_age(old_ageable.get_age());
212        converted_ageable.set_forced_age(old_ageable.forced_age());
213        converted_ageable.set_forced_age_timer(old_ageable.forced_age_timer());
214    }
215
216    // The vanilla `ANGRY_AT` memory copy awaits the Brain foundation.
217
218    if params.preserve_can_pick_up_loot {
219        to.set_can_pick_up_loot(from.can_pick_up_loot());
220    }
221
222    to.set_left_handed(from.is_left_handed());
223    to.set_no_ai(from.is_no_ai());
224    if from.is_persistence_required() {
225        to.set_persistence_required();
226    }
227
228    to.set_custom_name_visible(from.is_custom_name_visible());
229    if let Some(data) = to.synced_data() {
230        data.set_base_on_fire_flag(from.is_on_fire() || to.has_visual_fire());
231    }
232    to.set_invulnerable(from.is_invulnerable());
233    to.set_invulnerable_time(from.invulnerable_time());
234    to.set_no_gravity(from.is_no_gravity());
235    to.set_portal_cooldown(from.portal_cooldown());
236    to.set_silent(from.is_silent());
237    for tag in from.tags() {
238        to.add_tag(tag);
239    }
240
241    to.set_custom_name(from.custom_name());
242    to.set_custom_data(from.custom_data());
243
244    // Zombie door-breaking state belongs in the concrete conversion callback.
245}
246
247fn copy_position(from: &dyn Entity, to: &dyn Entity) {
248    if let Err(error) = to.try_set_position(from.position()) {
249        log::warn!(
250            "failed to copy conversion position {} to {}: {error}",
251            from.entity_type().key,
252            to.entity_type().key
253        );
254    }
255    to.set_rotation(from.rotation());
256}
257
258#[cfg(test)]
259mod tests {
260    use std::cell::Cell;
261    use std::sync::Arc;
262
263    use glam::DVec3;
264    use steel_registry::item_stack::ItemStack;
265    use steel_registry::{
266        init_vanilla_registry, vanilla_attributes, vanilla_entities, vanilla_items,
267        vanilla_mob_effects,
268    };
269    use steel_utils::Downcast as _;
270    use steel_utils::types::Difficulty;
271    use steel_utils::{ChunkPos, Identifier};
272    use text_components::TextComponent;
273
274    use super::{ConversionParams, ConversionType};
275    use crate::behavior::init_behaviors;
276    use crate::entity::attribute::{AttributeModifier, AttributeModifierOperation};
277    use crate::entity::entities::PigEntity;
278    use crate::entity::mob_effect::ABSORPTION_PER_LEVEL;
279    use crate::entity::{
280        AgeableMob, Mob, MobEffectInstance, SharedEntity, init_entities, next_entity_id,
281    };
282    use crate::inventory::equipment::EquipmentSlot;
283    use crate::test_support::{TestWorld, fresh_test_world, insert_ready_full_chunk};
284    use crate::world::World;
285
286    fn conversion_world() -> TestWorld {
287        let world_fixture = fresh_test_world("conversion");
288        let world = &world_fixture.world;
289        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
290        world_fixture
291    }
292
293    fn add_pig(world: &Arc<World>) -> SharedEntity {
294        let pig: SharedEntity = Arc::new(PigEntity::new(
295            &vanilla_entities::PIG,
296            next_entity_id(),
297            DVec3::new(1.0, 64.0, 1.0),
298            Arc::downgrade(world),
299        ));
300        world
301            .try_add_entity(Arc::clone(&pig))
302            .expect("test pig should attach to the loaded chunk");
303        pig
304    }
305
306    #[test]
307    #[expect(
308        clippy::too_many_lines,
309        reason = "one fixture asserts every field SINGLE conversion transfers"
310    )]
311    fn single_conversion_transfers_shared_state_and_discards_source() {
312        init_vanilla_registry();
313        init_entities();
314        init_behaviors();
315        let world_fixture = conversion_world();
316        let pig = add_pig(&world_fixture.world);
317        let pig_mob = pig.as_mob().expect("pig is a mob");
318
319        pig_mob.set_rotation((45.0, -10.0));
320        pig_mob.set_velocity(DVec3::new(0.5, 0.0, -0.25));
321        pig_mob.set_fall_distance(2.25);
322        pig_mob.set_on_ground(true);
323        pig_mob.set_left_handed(true);
324        pig_mob.set_no_ai(true);
325        pig_mob.set_persistence_required();
326        pig_mob.set_custom_name(Some(TextComponent::plain("converted")));
327        pig_mob.set_custom_name_visible(true);
328        pig_mob.set_invulnerable(true);
329        pig_mob.set_invulnerable_time(13);
330        pig_mob.set_no_gravity(true);
331        pig_mob.set_portal_cooldown(7);
332        pig_mob.set_silent(true);
333        pig_mob.add_tag("audit_tag".to_owned());
334        pig_mob.set_can_pick_up_loot(true);
335        pig_mob.set_last_hurt_by_player(uuid::Uuid::from_u128(1), 60);
336        pig_mob.attributes().lock().add_modifier(
337            vanilla_attributes::MAX_ABSORPTION,
338            AttributeModifier {
339                id: Identifier::vanilla_static("conversion_test_absorption"),
340                amount: 10.0,
341                operation: AttributeModifierOperation::AddValue,
342            },
343            true,
344        );
345        pig_mob.add_mob_effect(MobEffectInstance::new(vanilla_mob_effects::SPEED, 1));
346        pig_mob.add_mob_effect(MobEffectInstance::new(vanilla_mob_effects::ABSORPTION, 1));
347        pig_mob.set_absorption_amount(3.5);
348        assert_eq!(
349            pig_mob.living_base().absorption_amount(),
350            3.5,
351            "the source pig should hold its absorption before conversion"
352        );
353        pig_mob.set_equipment_drop_chance(EquipmentSlot::Head, 0.5);
354        pig_mob.living_base().equipment().lock().set(
355            EquipmentSlot::Head,
356            ItemStack::new(&vanilla_items::IRON_HELMET),
357        );
358        let pig_ageable = pig.as_ageable_mob().expect("pig is ageable");
359        pig_ageable.set_age(-24_000);
360        pig_ageable.set_forced_age(12);
361        pig_ageable.set_forced_age_timer(3);
362
363        let cow = pig
364            .downcast_ref::<PigEntity>()
365            .expect("pig is concrete")
366            .convert_to(
367                &vanilla_entities::COW,
368                ConversionParams::single(true, true),
369                None,
370            )
371            .expect("pig should convert to cow");
372
373        assert_eq!(cow.entity_type(), &vanilla_entities::COW);
374        assert!(pig.is_removed(), "single conversion discards the source");
375        assert!(
376            world_fixture.world.get_entity_by_id(cow.id()).is_some(),
377            "the converted mob joins the world"
378        );
379        assert_eq!(cow.position(), DVec3::new(1.0, 64.0, 1.0));
380        assert_eq!(cow.rotation(), (45.0, -10.0));
381        assert_eq!(cow.velocity(), DVec3::new(0.5, 0.0, -0.25));
382        assert_eq!(cow.fall_distance(), 2.25);
383        assert!(cow.on_ground());
384
385        let cow_mob = cow.as_mob().expect("cow is a mob");
386        assert!(cow_mob.is_left_handed());
387        assert!(cow_mob.is_no_ai());
388        assert!(cow_mob.is_persistence_required());
389        assert_eq!(
390            cow_mob.custom_name(),
391            Some(TextComponent::plain("converted"))
392        );
393        assert!(cow_mob.is_custom_name_visible());
394        assert!(cow_mob.is_invulnerable());
395        assert_eq!(cow_mob.invulnerable_time(), 13);
396        assert!(cow_mob.is_no_gravity());
397        assert_eq!(cow_mob.portal_cooldown(), 7);
398        assert!(cow_mob.is_silent());
399        assert!(cow_mob.tags().contains(&"audit_tag".to_owned()));
400        assert!(cow_mob.can_pick_up_loot());
401        assert_eq!(cow_mob.last_hurt_by_player_memory_time(), 60);
402        assert_eq!(
403            cow_mob.last_hurt_by_player_uuid(),
404            None,
405            "vanilla copies the memory timer but not the player"
406        );
407        assert_eq!(
408            cow_mob.living_base().absorption_amount(),
409            ABSORPTION_PER_LEVEL * 2.0,
410            "the re-added absorption effect must grant its onEffectStarted hearts"
411        );
412        let effects = cow_mob.active_mob_effects();
413        assert!(effects.contains(&MobEffectInstance::new(vanilla_mob_effects::SPEED, 1)));
414        assert!(effects.contains(&MobEffectInstance::new(vanilla_mob_effects::ABSORPTION, 1)));
415        assert_eq!(cow_mob.equipment_drop_chance(EquipmentSlot::Head), 0.5);
416        assert_eq!(
417            cow_mob.equipment_in_slot(EquipmentSlot::Head).item().key,
418            vanilla_items::IRON_HELMET.key
419        );
420
421        let cow_ageable = cow.as_ageable_mob().expect("cow is ageable");
422        assert!(AgeableMob::is_baby(cow_ageable));
423        assert_eq!(cow_ageable.get_age(), -24_000);
424        assert_eq!(cow_ageable.forced_age(), 12);
425        assert_eq!(cow_ageable.forced_age_timer(), 3);
426    }
427
428    #[test]
429    fn single_conversion_reboards_the_root_passenger() {
430        init_vanilla_registry();
431        init_entities();
432        let world_fixture = conversion_world();
433        let world = &world_fixture.world;
434        let pig = add_pig(world);
435        let passenger = add_pig(world);
436        assert!(
437            passenger.start_riding(&pig),
438            "passenger should ride the pig"
439        );
440        assert_eq!(pig.first_passenger().map(|p| p.id()), Some(passenger.id()));
441
442        let cow = pig
443            .downcast_ref::<PigEntity>()
444            .expect("pig is concrete")
445            .convert_to(
446                &vanilla_entities::COW,
447                ConversionParams::single(false, false),
448                None,
449            )
450            .expect("pig should convert to cow");
451
452        assert_eq!(
453            cow.first_passenger().map(|p| p.id()),
454            Some(passenger.id()),
455            "the root passenger reboards the converted mob"
456        );
457        assert!(!passenger.is_removed(), "the passenger survives conversion");
458        assert!(pig.is_removed());
459    }
460
461    #[test]
462    fn single_conversion_moves_equipment_before_the_callback() {
463        init_vanilla_registry();
464        init_entities();
465        let world_fixture = conversion_world();
466        let pig = add_pig(&world_fixture.world);
467        let pig_mob = pig.as_mob().expect("pig is a mob");
468        pig_mob.living_base().equipment().lock().set(
469            EquipmentSlot::Head,
470            ItemStack::new(&vanilla_items::IRON_HELMET),
471        );
472        let after_conversion = |converted: &dyn Mob| {
473            assert!(pig_mob.equipment_in_slot(EquipmentSlot::Head).is_empty());
474            assert_eq!(
475                converted.equipment_in_slot(EquipmentSlot::Head).item().key,
476                vanilla_items::IRON_HELMET.key,
477            );
478        };
479
480        let converted = pig
481            .downcast_ref::<PigEntity>()
482            .expect("pig is concrete")
483            .convert_to(
484                &vanilla_entities::COW,
485                ConversionParams::single(true, false),
486                Some(&after_conversion),
487            );
488        assert!(converted.is_some());
489    }
490
491    #[test]
492    fn split_on_death_conversion_keeps_the_source_living() {
493        init_vanilla_registry();
494        init_entities();
495        let world_fixture = conversion_world();
496        let world = &world_fixture.world;
497        let pig = add_pig(world);
498
499        let cow = pig
500            .downcast_ref::<PigEntity>()
501            .expect("pig is concrete")
502            .convert_to(
503                &vanilla_entities::COW,
504                ConversionParams {
505                    conversion_type: ConversionType::SplitOnDeath,
506                    keep_equipment: false,
507                    preserve_can_pick_up_loot: false,
508                },
509                None,
510            )
511            .expect("split conversion should create the cow");
512
513        assert_eq!(cow.entity_type(), &vanilla_entities::COW);
514        assert!(world.get_entity_by_id(cow.id()).is_some());
515        assert!(!pig.is_removed(), "split-on-death keeps the source living");
516    }
517
518    #[test]
519    fn convert_to_fails_without_a_registered_factory() {
520        init_vanilla_registry();
521        init_entities();
522        let world_fixture = conversion_world();
523        let world = &world_fixture.world;
524        let pig = add_pig(world);
525
526        let converted = pig
527            .downcast_ref::<PigEntity>()
528            .expect("pig is concrete")
529            .convert_to(
530                &vanilla_entities::ZOMBIE,
531                ConversionParams::single(true, true),
532                None,
533            );
534
535        assert!(
536            converted.is_none(),
537            "no zombie factory is registered, so conversion cannot happen"
538        );
539        assert!(
540            !pig.is_removed(),
541            "failed conversions leave the source untouched"
542        );
543    }
544
545    #[test]
546    fn refused_conversion_keeps_the_source_passenger_mounted() {
547        init_vanilla_registry();
548        init_entities();
549        let world_fixture = conversion_world();
550        let world = &world_fixture.world;
551        let source = add_pig(world);
552        let passenger = add_pig(world);
553        assert!(
554            passenger.start_riding(&source),
555            "the passenger should ride the source"
556        );
557
558        let converted = source
559            .downcast_ref::<PigEntity>()
560            .expect("pig is concrete")
561            .convert_to(
562                &vanilla_entities::ZOMBIE,
563                ConversionParams::single(true, true),
564                None,
565            );
566
567        assert!(converted.is_none());
568        assert!(
569            !source.is_removed(),
570            "refused conversions leave the source alive"
571        );
572        assert_eq!(
573            source.first_passenger().map(|entity| entity.id()),
574            Some(passenger.id()),
575            "a refused conversion must not dismount the source's passenger"
576        );
577    }
578
579    #[test]
580    fn refused_conversion_keeps_the_source_on_its_vehicle() {
581        init_vanilla_registry();
582        init_entities();
583        let world_fixture = conversion_world();
584        let world = &world_fixture.world;
585        let source = add_pig(world);
586        let vehicle = add_pig(world);
587        assert!(
588            source.start_riding(&vehicle),
589            "the source should ride its vehicle"
590        );
591
592        let converted = source
593            .downcast_ref::<PigEntity>()
594            .expect("pig is concrete")
595            .convert_to(
596                &vanilla_entities::ZOMBIE,
597                ConversionParams::single(true, true),
598                None,
599            );
600
601        assert!(converted.is_none());
602        assert!(
603            !source.is_removed(),
604            "refused conversions leave the source alive"
605        );
606        assert_eq!(
607            source.vehicle().map(|entity| entity.id()),
608            Some(vehicle.id()),
609            "a refused conversion must not dismount the source from its vehicle"
610        );
611    }
612
613    #[test]
614    fn refused_conversion_keeps_the_source_leash() {
615        init_vanilla_registry();
616        init_entities();
617        let world_fixture = conversion_world();
618        let world = &world_fixture.world;
619        let source = add_pig(world);
620        let holder = add_pig(world);
621        let source_mob = source.as_mob().expect("pig is a mob");
622        assert!(
623            source_mob.set_leashed_to(&holder),
624            "the test pig should leash to the holder"
625        );
626
627        let converted = source
628            .downcast_ref::<PigEntity>()
629            .expect("pig is concrete")
630            .convert_to(
631                &vanilla_entities::ZOMBIE,
632                ConversionParams::single(true, true),
633                None,
634            );
635
636        assert!(converted.is_none());
637        assert!(
638            !source.is_removed(),
639            "refused conversions leave the source alive"
640        );
641        assert_eq!(
642            source_mob.leash_holder().map(|entity| entity.id()),
643            Some(holder.id()),
644            "a refused conversion must not steal the source's leash"
645        );
646    }
647
648    #[test]
649    fn convert_to_refuses_hostile_types_on_peaceful_difficulty() {
650        init_vanilla_registry();
651        init_entities();
652        let world_fixture = conversion_world();
653        let world = &world_fixture.world;
654
655        world.set_difficulty(Difficulty::Peaceful);
656        let pig = add_pig(world);
657        let pig_mob = pig.as_mob().expect("pig is a mob");
658        assert!(
659            pig.downcast_ref::<PigEntity>()
660                .expect("pig is concrete")
661                .convert_to(
662                    &vanilla_entities::ENDERMITE,
663                    ConversionParams::single(true, true),
664                    None,
665                )
666                .is_none(),
667            "hostile conversions are refused on peaceful"
668        );
669        assert!(
670            !pig_mob.is_removed(),
671            "refused conversions leave the source untouched"
672        );
673
674        world.set_difficulty(Difficulty::Normal);
675        let other_pig = add_pig(world);
676        assert!(
677            other_pig
678                .downcast_ref::<PigEntity>()
679                .expect("pig is concrete")
680                .convert_to(
681                    &vanilla_entities::ENDERMITE,
682                    ConversionParams::single(true, true),
683                    None,
684                )
685                .is_some(),
686            "hostile conversions are allowed on non-peaceful difficulties"
687        );
688    }
689
690    #[test]
691    fn conversion_skips_equipment_when_not_requested() {
692        init_vanilla_registry();
693        init_entities();
694        let world_fixture = conversion_world();
695        let world = &world_fixture.world;
696        let pig = add_pig(world);
697        let pig_mob = pig.as_mob().expect("pig is a mob");
698        pig_mob.living_base().equipment().lock().set(
699            EquipmentSlot::Head,
700            ItemStack::new(&vanilla_items::IRON_HELMET),
701        );
702
703        let cow = pig
704            .downcast_ref::<PigEntity>()
705            .expect("pig is concrete")
706            .convert_to(
707                &vanilla_entities::COW,
708                ConversionParams::single(false, true),
709                None,
710            )
711            .expect("pig should convert to cow");
712        let cow_mob = cow.as_mob().expect("cow is a mob");
713
714        assert!(
715            cow_mob.equipment_in_slot(EquipmentSlot::Head).is_empty(),
716            "equipment is only copied when keep_equipment is set"
717        );
718    }
719
720    #[test]
721    fn after_conversion_callback_receives_the_new_mob() {
722        init_vanilla_registry();
723        init_entities();
724        let world_fixture = conversion_world();
725        let world = &world_fixture.world;
726        let pig = add_pig(world);
727        let finalized = Cell::new(false);
728        let after_conversion = |mob: &dyn Mob| {
729            assert_eq!(mob.entity_type(), &vanilla_entities::COW);
730            finalized.set(true);
731        };
732
733        let cow = pig
734            .downcast_ref::<PigEntity>()
735            .expect("pig is concrete")
736            .convert_to(
737                &vanilla_entities::COW,
738                ConversionParams::single(false, false),
739                Some(&after_conversion),
740            )
741            .expect("pig should convert to cow");
742
743        assert!(
744            finalized.get(),
745            "the after-conversion callback runs on the new mob"
746        );
747        assert_eq!(cow.entity_type(), &vanilla_entities::COW);
748    }
749}