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
12pub type AfterConversion<'a> = dyn Fn(&dyn Mob) + 'a;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct ConversionParams {
20 pub conversion_type: ConversionType,
22 pub keep_equipment: bool,
24 pub preserve_can_pick_up_loot: bool,
26}
27
28impl ConversionParams {
29 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ConversionType {
43 Single,
45 SplitOnDeath,
47}
48
49impl ConversionType {
50 #[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
72pub(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 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 to.set_last_hurt_by_player_memory_time(from.last_hurt_by_player_memory_time());
168 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 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 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 }
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}