diff --git a/steel-core/src/behavior/block/mod.rs b/steel-core/src/behavior/block/mod.rs index 6f76efc6c31a..a1c5845d3a33 100644 --- a/steel-core/src/behavior/block/mod.rs +++ b/steel-core/src/behavior/block/mod.rs @@ -47,6 +47,11 @@ use crate::world::{ }; use steel_registry::vanilla_fluids; +/// Maximum light emission a block may emit while still allowing mobs to spawn +/// on it, mirroring vanilla `BlockBehaviour`'s default `isValidSpawn` predicate +/// (`state.getLightEmission() < 14`). +pub(crate) const MAX_MOB_SPAWN_LIGHT_EMISSION: u8 = 14; + /// Vanilla `BlockBehaviour.canBeReplaced(BlockState, BlockPlaceContext)`. pub(crate) fn default_can_be_replaced( state: BlockStateId, @@ -221,6 +226,25 @@ pub trait BlockBehavior: Send + Sync { true } + /// Returns whether this block accepts a mob spawning on or inside it. + /// + /// Vanilla parity: `BlockState.isValidSpawn(BlockGetter, BlockPos, EntityType)`, whose + /// default requires a full UP support face and light emission below + /// [`MAX_MOB_SPAWN_LIGHT_EMISSION`]. Blocks override it for special spawn surfaces + /// (soul sand accepts everything, magma only fire-immune mobs, ...). Consulted by + /// `Mob.checkMobSpawnRules`. + fn is_valid_spawn( + &self, + state: BlockStateId, + world: &dyn LevelReader, + pos: BlockPos, + _entity_type: EntityTypeRef, + ) -> bool { + // Vanilla evaluation order: full UP support first, then the light-emission gate. + world.is_face_sturdy_for(state, pos, Direction::Up, SupportType::Full) + && state.get_light_emission() < MAX_MOB_SPAWN_LIGHT_EMISSION + } + /// Returns whether this block can be occupied by a forced respawn position fn is_possible_to_respawn_in_this(&self, state: BlockStateId) -> bool { !state.is_solid() && !state.get_block().config.liquid diff --git a/steel-core/src/behavior/block/tests.rs b/steel-core/src/behavior/block/tests.rs index dc10cdb5cc4d..1e2070edbf4a 100644 --- a/steel-core/src/behavior/block/tests.rs +++ b/steel-core/src/behavior/block/tests.rs @@ -7,10 +7,47 @@ use steel_registry::blocks::properties::{BlockStateProperties, SlabType}; use steel_registry::init_vanilla_registry; use steel_registry::sound_events; use steel_registry::vanilla_blocks; +use steel_registry::vanilla_entities; use steel_registry::vanilla_items; use crate::behavior::init_behaviors; +#[test] +fn default_spawn_rule_requires_full_up_support_and_low_light_emission() { + init_vanilla_registry(); + init_behaviors(); + let level = TestLevel::default(); + let pos = BlockPos::new(3, 64, 5); + let entity_type = &vanilla_entities::ZOMBIE; + + // Stone is a full support surface with no light emission: mobs may spawn. + let stone = BLOCK_BEHAVIORS.get_behavior(&vanilla_blocks::STONE); + assert!(stone.is_valid_spawn( + vanilla_blocks::STONE.default_state(), + &level, + pos, + entity_type + )); + + // Glowstone is full-support but emits light at or above the gate: blocked. + let glowstone = BLOCK_BEHAVIORS.get_behavior(&vanilla_blocks::GLOWSTONE); + assert!(!glowstone.is_valid_spawn( + vanilla_blocks::GLOWSTONE.default_state(), + &level, + pos, + entity_type + )); + + // A torch has no full UP support face: blocked even though it is dark. + let torch = BLOCK_BEHAVIORS.get_behavior(&vanilla_blocks::TORCH); + assert!(!torch.is_valid_spawn( + vanilla_blocks::TORCH.default_state(), + &level, + pos, + entity_type + )); +} + #[test] fn clone_item_stack_uses_registered_block_item_association() { init_vanilla_registry(); diff --git a/steel-core/src/behavior/blocks/building/magma_block.rs b/steel-core/src/behavior/blocks/building/magma_block.rs index 84ffbd46fa11..e016368caa9b 100644 --- a/steel-core/src/behavior/blocks/building/magma_block.rs +++ b/steel-core/src/behavior/blocks/building/magma_block.rs @@ -2,13 +2,14 @@ use std::sync::Arc; use steel_macros::block_behavior; use steel_registry::blocks::BlockRef; +use steel_registry::entity_type::EntityTypeRef; use steel_registry::vanilla_damage_types; use steel_utils::{BlockPos, BlockStateId}; use crate::{ behavior::{BlockBehavior, BlockPlaceContext}, entity::{Entity, damage::DamageSource}, - world::World, + world::{LevelReader, World}, }; /// Behavior for magma blocks. @@ -42,6 +43,18 @@ impl BlockBehavior for MagmaBlock { Some(self.block.default_state()) } + /// Vanilla magma registers `isValidSpawn(entityType -> entityType.fireImmune())`: + /// only fire-immune mobs (magma cubes, blazes) spawn on it. + fn is_valid_spawn( + &self, + _state: BlockStateId, + _world: &dyn LevelReader, + _pos: BlockPos, + entity_type: EntityTypeRef, + ) -> bool { + entity_type.fire_immune + } + fn step_on(&self, state: BlockStateId, world: &Arc, pos: BlockPos, entity: &dyn Entity) { if let Some(damage) = Self::step_damage_amount(entity.is_stepping_carefully(), entity.is_living_entity()) diff --git a/steel-core/src/behavior/blocks/building/soul_sand_block.rs b/steel-core/src/behavior/blocks/building/soul_sand_block.rs index 11517063a457..e5125807dc1f 100644 --- a/steel-core/src/behavior/blocks/building/soul_sand_block.rs +++ b/steel-core/src/behavior/blocks/building/soul_sand_block.rs @@ -1,10 +1,12 @@ use steel_macros::block_behavior; use steel_registry::blocks::BlockRef; -use steel_utils::BlockStateId; +use steel_registry::entity_type::EntityTypeRef; +use steel_utils::{BlockPos, BlockStateId}; use crate::{ behavior::{BlockBehavior, BlockPlaceContext}, entity::ai::path::PathComputationType, + world::LevelReader, }; /// Soul sand. Mobs will not pathfind through this block. @@ -26,6 +28,18 @@ impl BlockBehavior for SoulSandBlock { Some(self.block.default_state()) } + /// Vanilla soul sand uses `Blocks::always` for `isValidSpawn`: any mob may + /// spawn on it (wither skeletons, nether mobs). + fn is_valid_spawn( + &self, + _state: BlockStateId, + _world: &dyn LevelReader, + _pos: BlockPos, + _entity_type: EntityTypeRef, + ) -> bool { + true + } + fn is_pathfindable( &self, _state: BlockStateId, diff --git a/steel-core/src/entity/ai/goal/mod.rs b/steel-core/src/entity/ai/goal/mod.rs index 966c1def78ed..3370ea7809e8 100644 --- a/steel-core/src/entity/ai/goal/mod.rs +++ b/steel-core/src/entity/ai/goal/mod.rs @@ -43,7 +43,9 @@ pub(crate) use melee_attack::MeleeAttackGoal; pub(crate) use nearest_attackable_target::NearestAttackableTargetGoal; pub(crate) use panic_goal::PanicGoal; pub(crate) use random_look_around::RandomLookAroundGoal; -pub(crate) use selector::{GoalControl, GoalSelector}; +#[cfg(test)] +pub(crate) use selector::GoalControls; +pub(crate) use selector::{Goal, GoalControl, GoalSelector}; pub(crate) use tempt_goal::TemptGoal; pub(crate) use water_avoiding_random_stroll::WaterAvoidingRandomStrollGoal; diff --git a/steel-core/src/entity/ai/goal/selector.rs b/steel-core/src/entity/ai/goal/selector.rs index aafb97a3a2c8..e56f8dd0fce9 100644 --- a/steel-core/src/entity/ai/goal/selector.rs +++ b/steel-core/src/entity/ai/goal/selector.rs @@ -121,6 +121,10 @@ impl WrappedGoal { self.running } + fn goal(&self) -> &dyn Goal { + self.goal.as_ref() + } + fn controls(&self) -> GoalControls { self.goal.controls() } @@ -188,6 +192,24 @@ impl GoalSelector { .push(WrappedGoal::new(priority, Box::new(goal))); } + /// Removes every goal matching `predicate`, stopping running matches first. + /// + /// Mirrors vanilla `GoalSelector.removeAllGoals`. The mob is required only + /// because Steel's `Goal.stop` receives the mob for its stop hook. + pub fn remove_all_goals( + &mut self, + mob: &dyn PathfinderMob, + mut predicate: impl FnMut(&dyn Goal) -> bool, + ) { + for wrapped in &mut self.available_goals { + if predicate(wrapped.goal()) && wrapped.is_running() { + wrapped.stop(mob); + } + } + self.available_goals + .retain(|wrapped| !predicate(wrapped.goal())); + } + pub fn tick(&mut self, mob: &dyn PathfinderMob) { for index in 0..self.available_goals.len() { let should_stop = { @@ -612,4 +634,48 @@ mod tests { ); assert!(mob.is_panicking()); } + + #[test] + fn remove_all_goals_stops_matching_running_goals_and_keeps_others() { + let mob = TestPathfinderMob::new(); + let mut selector = GoalSelector::new(); + selector.add_goal(3, StaticGoal::new(GoalControls::MOVE)); + selector.add_goal(7, StaticGoal::new(GoalControls::LOOK)); + selector.tick(&mob); + assert_eq!(selector.running_goal_count(), 2); + + selector.remove_all_goals(&mob, |goal| goal.controls() == GoalControls::MOVE); + + assert_eq!(selector.available_goal_count(), 1); + assert!( + selector.is_priority_running(7), + "the non-matching goal must keep running through removal" + ); + assert!( + !selector.is_priority_running(3), + "the removed goal must be gone entirely" + ); + + selector.tick(&mob); + assert_eq!(selector.running_goal_count(), 1); + assert!(selector.is_priority_running(7)); + } + + #[test] + fn pathfinder_mob_remove_free_will_clears_all_goals() { + let mob = TestPathfinderMob::new(); + { + let mut selector = mob.mob_base().goal_selector().lock(); + selector.add_goal(1, StaticGoal::new(GoalControls::MOVE)); + selector.add_goal(2, StaticGoal::new(GoalControls::LOOK)); + selector.tick(&mob); + assert_eq!(selector.running_goal_count(), 2); + } + + mob.remove_free_will(); + + let selector = mob.mob_base().goal_selector().lock(); + assert_eq!(selector.available_goal_count(), 0); + assert_eq!(selector.running_goal_count(), 0); + } } diff --git a/steel-core/src/entity/conversion.rs b/steel-core/src/entity/conversion.rs new file mode 100644 index 000000000000..16697d75033b --- /dev/null +++ b/steel-core/src/entity/conversion.rs @@ -0,0 +1,756 @@ +//! Vanilla-shaped mob conversion foundations (`ConversionParams`, `ConversionType`). +//! +//! Mirrors the `world/entity/ConversionParams.java` and `ConversionType.java` +//! records that back `Mob.convertTo`. A conversion replaces (or splits) one mob +//! with a fresh instance of another `EntityType`, transferring the shared state +//! every converted mob inherits. + +use std::sync::Arc; + +use steel_registry::entity_type::EntityTypeRef; +use steel_utils::types::Difficulty; + +use crate::entity::registry::ENTITIES; +use crate::entity::{ + Entity, Mob, RemovalReason, SharedEntity, next_entity_id, start_riding_entities, +}; +use crate::inventory::equipment::EquipmentSlot; + +/// Vanilla `ConversionParams.AfterConversion`: finalizes a freshly converted +/// mob, mirroring the vanilla functional interface. +pub type AfterConversion<'a> = dyn Fn(&dyn Mob) + 'a; + +/// Vanilla `ConversionParams`: how a converted mob should inherit the original. +/// +/// Vanilla's record also carries the original mob's `PlayerTeam`; Steel has no +/// scoreboard-team foundation yet, so team transfer is not part of this struct +/// (documented gap). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConversionParams { + /// Vanilla `ConversionParams.type`. + pub conversion_type: ConversionType, + /// Vanilla `ConversionParams.keepEquipment`. + pub keep_equipment: bool, + /// Vanilla `ConversionParams.preserveCanPickUpLoot`. + pub preserve_can_pick_up_loot: bool, +} + +impl ConversionParams { + /// Vanilla `ConversionParams.single`: a single-mob conversion replacing the + /// original in place. Vanilla also captures the source mob's team here; + /// Steel has no scoreboard-team foundation yet (documented gap). + #[must_use] + pub const fn single(keep_equipment: bool, preserve_can_pick_up_loot: bool) -> Self { + Self { + conversion_type: ConversionType::Single, + keep_equipment, + preserve_can_pick_up_loot, + } + } +} + +/// Vanilla `ConversionType`: how `Mob.convertTo` replaces the source mob. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversionType { + /// The converted mob replaces the original in place (`SINGLE`). + Single, + /// The original keeps living and the converted mob spawns separately + /// (`SPLIT_ON_DEATH`; used by slime splits). + SplitOnDeath, +} + +impl ConversionType { + /// Returns vanilla `ConversionType.shouldDiscardAfterConversion`. + #[must_use] + pub const fn should_discard_after_conversion(self) -> bool { + matches!(self, Self::Single) + } + + /// Vanilla `ConversionType.convert`: transfers the conversion state from + /// `from` onto the freshly created `to`. + fn convert(self, from: &dyn Mob, to: &SharedEntity, params: ConversionParams) { + let Some(to_mob) = to.as_mob() else { + log::error!( + "cannot convert {} to {}: created entity is not a mob", + from.entity_type().key, + to.entity_type().key + ); + return; + }; + match self { + Self::Single => convert_single(from, to_mob, to, params), + Self::SplitOnDeath => convert_split_on_death(from, to_mob, params), + } + } +} + +/// Vanilla `Mob.convertTo`: creates a fresh `entity_type` mob, transfers the +/// shared conversion state, runs `after_conversion`, adds the new mob to the +/// world, and discards `from` for single-mob conversions. +/// +/// Steel registers the new entity *before* transferring state. Vanilla runs +/// `ConversionType.convert` and `AfterConversion` first and then discards the +/// source unconditionally after `addFreshEntity`, with no failure path; Steel +/// instead keeps `from` alive when the world rejects the new entity, which only +/// holds if nothing on `from` (its passengers, vehicle, and leash) has been +/// moved onto `to` yet (documented divergence). +/// +/// Returns `None` when `from` is removed, the world is gone, `entity_type` is +/// not allowed on the current difficulty (vanilla `EntityType.canSpawn`), has no +/// registered factory, the created entity is not a mob, or the world rejects the +/// new entity; `from` is left untouched in all of those cases. +pub(crate) fn convert_to( + from: &dyn Mob, + entity_type: EntityTypeRef, + params: ConversionParams, + after_conversion: Option<&AfterConversion<'_>>, +) -> Option { + // Vanilla `Mob.convertTo`: a removed mob cannot convert. + if from.is_removed() { + return None; + } + let world = from.level()?; + // Vanilla `EntityType.canSpawn` gates `convertTo`'s `entityType.create`: + // types that are not allowed on Peaceful difficulty must not be created. + // Steel has no feature-flag foundation yet, so only the peaceful clause of + // `canSpawn` is checked here. + if !entity_type.allowed_in_peaceful && world.difficulty() == Difficulty::Peaceful { + return None; + } + let Some(to) = ENTITIES.create( + entity_type, + next_entity_id(), + from.position(), + Arc::downgrade(&world), + ) else { + log::warn!( + "cannot convert {} to {}: no entity factory is registered", + from.entity_type().key, + entity_type.key + ); + return None; + }; + let Some(to_mob) = to.as_mob() else { + log::error!( + "cannot convert {} to {}: created entity is not a mob", + from.entity_type().key, + entity_type.key + ); + return None; + }; + + // Register `to` before moving any of `from`'s state onto it, so a world + // that rejects the new entity leaves the source (passengers, vehicle, and + // leash included) fully untouched. + if let Err(error) = world.try_add_entity(to.clone()) { + log::error!( + "failed to add converted {} for {}: {error}", + to_mob.entity_type().key, + from.entity_type().key + ); + return None; + } + + params.conversion_type.convert(from, &to, params); + if let Some(after_conversion) = after_conversion { + after_conversion(to_mob); + } + + if params.conversion_type.should_discard_after_conversion() { + from.set_removed(RemovalReason::Discarded); + } + Some(to) +} + +/// Vanilla `ConversionType.SINGLE.convert`, in statement order. +fn convert_single(from: &dyn Mob, to: &dyn Mob, to_arc: &SharedEntity, params: ConversionParams) { + let root_passenger = from.first_passenger(); + + copy_position(from, to); + to.set_velocity(from.velocity()); + + if let Some(root_passenger) = root_passenger { + root_passenger.stop_riding(); + root_passenger.base().set_boarding_cooldown(0); + + for passenger in to_arc.passengers() { + passenger.stop_riding(); + passenger.set_removed(RemovalReason::Discarded); + } + + start_riding_entities(&root_passenger, to_arc); + } + + if let Some(vehicle) = from.vehicle() { + from.stop_riding(); + start_riding_entities(to_arc, &vehicle); + } + + if params.keep_equipment { + for slot in EquipmentSlot::ALL { + let stack = from.equipment_in_slot(slot); + if !stack.is_empty() { + to.living_base().equipment().lock().set(slot, stack); + to.set_equipment_drop_chance(slot, from.equipment_drop_chance(slot)); + } + } + } + + to.set_fall_distance(from.fall_distance()); + to.set_shared_fall_flying(from.is_fall_flying()); + + // Vanilla copies the raw `lastHurtByPlayerMemoryTime` field; Steel stores it + // together with the attacking player's UUID, so it is copied as a pair. + if let Some(uuid) = from.last_hurt_by_player_uuid() { + to.set_last_hurt_by_player(uuid, from.last_hurt_by_player_memory_time()); + } + // Vanilla also copies `hurtTime` (the visual hurt-flash counter); Steel has + // no equivalent field yet, so it is left out (documented gap). + + to.set_y_body_rot(from.y_body_rot()); + to.set_on_ground(from.on_ground()); + if let Some(bed_position) = from.sleeping_pos() { + to.set_sleeping_pos(bed_position); + } + if let Some(leash_holder) = from.leash_holder() { + to.set_leashed_to(&leash_holder); + } + + convert_common(from, to, params); +} + +/// Vanilla `ConversionType.SPLIT_ON_DEATH.convert`, in statement order. +fn convert_split_on_death(from: &dyn Mob, to: &dyn Mob, params: ConversionParams) { + if let Some(root_passenger) = from.first_passenger() { + root_passenger.stop_riding(); + } + if from.leash_holder().is_some() { + from.drop_leash(); + } + + convert_common(from, to, params); +} + +/// Vanilla `ConversionType.convertCommon`, in statement order. +fn convert_common(from: &dyn Mob, to: &dyn Mob, params: ConversionParams) { + to.set_absorption_amount(from.living_base().absorption_amount()); + + for effect in from.active_mob_effects() { + // Vanilla `ConversionType.convertCommon` re-adds each effect with + // `Mob.addEffect`, so the converted mob re-runs `onEffectStarted` (the + // ABSORPTION effect, for example, grants fresh hearts again after the + // absorption amount copy above). + to.add_mob_effect(effect); + } + + // Vanilla `Mob.setBaby` is a no-op for non-ageable mobs; Steel mirrors that + // by only forwarding to ageable targets. + if from.is_baby() + && let Some(to_ageable) = to.as_ageable_mob() + { + to_ageable.set_baby(true); + } + + if let (Some(old_ageable), Some(converted_ageable)) = + (from.as_ageable_mob(), to.as_ageable_mob()) + { + converted_ageable.set_age(old_ageable.get_age()); + converted_ageable.set_forced_age(old_ageable.forced_age()); + converted_ageable.set_forced_age_timer(old_ageable.forced_age_timer()); + } + + // Vanilla copies the `ANGRY_AT` brain memory here; Steel's Brain foundation + // is still in flight, so the memory transfer is a documented gap. + + if params.preserve_can_pick_up_loot { + to.set_can_pick_up_loot(from.can_pick_up_loot()); + } + + to.set_left_handed(from.is_left_handed()); + to.set_no_ai(from.is_no_ai()); + if from.is_persistence_required() { + to.set_persistence_required(); + } + + to.set_custom_name_visible(from.is_custom_name_visible()); + // Vanilla `setSharedFlagOnFire(from.isOnFire())`: keep the fire visual + // state rather than restarting an ignition timer. + to.base().set_visual_fire(from.is_on_fire()); + to.set_invulnerable(from.is_invulnerable()); + to.set_no_gravity(from.is_no_gravity()); + to.set_portal_cooldown(from.portal_cooldown()); + to.set_silent(from.is_silent()); + for tag in from.tags() { + to.add_tag(tag); + } + + // Vanilla copies the `CUSTOM_NAME` and `CUSTOM_DATA` data components. + to.set_custom_name(from.custom_name()); + to.set_custom_data(from.custom_data()); + + // Vanilla moves `to` onto `from`'s scoreboard team; Steel has no + // scoreboard-team foundation yet (documented gap). Vanilla's zombie + // door-breaking copy is Zombie-specific and stays out of this foundation. +} + +/// Vanilla `Entity.copyPosition`: copies position and rotation. +fn copy_position(from: &dyn Entity, to: &dyn Entity) { + if let Err(error) = to.try_set_position(from.position()) { + log::warn!( + "failed to copy conversion position {} to {}: {error}", + from.entity_type().key, + to.entity_type().key + ); + } + to.set_rotation(from.rotation()); +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::sync::Arc; + + use glam::DVec3; + use steel_registry::item_stack::ItemStack; + use steel_registry::{ + init_vanilla_registry, vanilla_attributes, vanilla_entities, vanilla_items, + vanilla_mob_effects, + }; + use steel_utils::Downcast as _; + use steel_utils::types::Difficulty; + use steel_utils::{ChunkPos, Identifier}; + use text_components::TextComponent; + + use super::{ConversionParams, ConversionType}; + use crate::behavior::init_behaviors; + use crate::entity::attribute::{AttributeModifier, AttributeModifierOperation}; + use crate::entity::entities::PigEntity; + use crate::entity::mob_effect::ABSORPTION_PER_LEVEL; + use crate::entity::{ + AgeableMob, Mob, MobEffectInstance, SharedEntity, init_entities, next_entity_id, + }; + use crate::inventory::equipment::EquipmentSlot; + use crate::test_support::{fresh_test_world, insert_ready_full_chunk}; + use crate::world::World; + + fn conversion_world() -> Arc { + let world = fresh_test_world("conversion"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + world + } + + fn add_pig(world: &Arc) -> SharedEntity { + let pig: SharedEntity = Arc::new(PigEntity::new( + &vanilla_entities::PIG, + next_entity_id(), + DVec3::new(1.0, 64.0, 1.0), + Arc::downgrade(world), + )); + world + .try_add_entity(Arc::clone(&pig)) + .expect("test pig should attach to the loaded chunk"); + pig + } + + #[test] + fn single_conversion_transfers_shared_state_and_discards_source() { + init_vanilla_registry(); + init_entities(); + init_behaviors(); + let world = conversion_world(); + let pig = add_pig(&world); + let pig_mob = pig.as_mob().expect("pig is a mob"); + + pig_mob.set_rotation((45.0, -10.0)); + pig_mob.set_velocity(DVec3::new(0.5, 0.0, -0.25)); + pig_mob.set_fall_distance(2.25); + pig_mob.set_on_ground(true); + pig_mob.set_left_handed(true); + pig_mob.set_no_ai(true); + pig_mob.set_persistence_required(); + pig_mob.set_custom_name(Some(TextComponent::plain("converted"))); + pig_mob.set_custom_name_visible(true); + pig_mob.set_invulnerable(true); + pig_mob.set_no_gravity(true); + pig_mob.set_portal_cooldown(7); + pig_mob.set_silent(true); + pig_mob.add_tag("audit_tag".to_owned()); + pig_mob.set_can_pick_up_loot(true); + // The modifier lets the source pig hold absorption despite its zero + // vanilla max; the ABSORPTION effect gives the converted cow headroom + // after the effects are copied. + pig_mob.attributes().lock().add_modifier( + vanilla_attributes::MAX_ABSORPTION, + AttributeModifier { + id: Identifier::vanilla_static("conversion_test_absorption"), + amount: 10.0, + operation: AttributeModifierOperation::AddValue, + }, + true, + ); + pig_mob.add_mob_effect(MobEffectInstance::new(vanilla_mob_effects::SPEED, 1)); + pig_mob.add_mob_effect(MobEffectInstance::new(vanilla_mob_effects::ABSORPTION, 1)); + pig_mob.set_absorption_amount(3.5); + assert_eq!( + pig_mob.living_base().absorption_amount(), + 3.5, + "the source pig should hold its absorption before conversion" + ); + pig_mob.set_equipment_drop_chance(EquipmentSlot::Head, 0.5); + pig_mob.living_base().equipment().lock().set( + EquipmentSlot::Head, + ItemStack::new(&vanilla_items::IRON_HELMET), + ); + let pig_ageable = pig.as_ageable_mob().expect("pig is ageable"); + pig_ageable.set_age(-24_000); + pig_ageable.set_forced_age(12); + pig_ageable.set_forced_age_timer(3); + + let cow = pig + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::COW, + ConversionParams::single(true, true), + None, + ) + .expect("pig should convert to cow"); + + assert_eq!(cow.entity_type(), &vanilla_entities::COW); + assert!(pig.is_removed(), "single conversion discards the source"); + assert!( + world.get_entity_by_id(cow.id()).is_some(), + "the converted mob joins the world" + ); + assert_eq!(cow.position(), DVec3::new(1.0, 64.0, 1.0)); + assert_eq!(cow.rotation(), (45.0, -10.0)); + assert_eq!(cow.velocity(), DVec3::new(0.5, 0.0, -0.25)); + assert_eq!(cow.fall_distance(), 2.25); + assert!(cow.on_ground()); + + let cow_mob = cow.as_mob().expect("cow is a mob"); + assert!(cow_mob.is_left_handed()); + assert!(cow_mob.is_no_ai()); + assert!(cow_mob.is_persistence_required()); + assert_eq!( + cow_mob.custom_name(), + Some(TextComponent::plain("converted")) + ); + assert!(cow_mob.is_custom_name_visible()); + assert!(cow_mob.is_invulnerable()); + assert!(cow_mob.is_no_gravity()); + assert_eq!(cow_mob.portal_cooldown(), 7); + assert!(cow_mob.is_silent()); + assert!(cow_mob.tags().contains(&"audit_tag".to_owned())); + assert!(cow_mob.can_pick_up_loot()); + // Vanilla `convertCommon` copies absorption before re-adding the + // effects, so the copied amount is clamped away by the cow's pre-effect + // max absorption (0); the re-added ABSORPTION effect then applies its + // MAX_ABSORPTION modifier and `onEffectStarted` grants + // `4 * (1 + amplifier)` fresh hearts, exactly as in vanilla. + assert_eq!( + cow_mob.living_base().absorption_amount(), + ABSORPTION_PER_LEVEL * 2.0, + "the re-added absorption effect must grant its onEffectStarted hearts" + ); + let effects = cow_mob.active_mob_effects(); + assert!(effects.contains(&MobEffectInstance::new(vanilla_mob_effects::SPEED, 1))); + assert!(effects.contains(&MobEffectInstance::new(vanilla_mob_effects::ABSORPTION, 1))); + assert_eq!(cow_mob.equipment_drop_chance(EquipmentSlot::Head), 0.5); + assert_eq!( + cow_mob.equipment_in_slot(EquipmentSlot::Head).item().key, + vanilla_items::IRON_HELMET.key + ); + + let cow_ageable = cow.as_ageable_mob().expect("cow is ageable"); + assert!(AgeableMob::is_baby(cow_ageable)); + assert_eq!(cow_ageable.get_age(), -24_000); + assert_eq!(cow_ageable.forced_age(), 12); + assert_eq!(cow_ageable.forced_age_timer(), 3); + } + + #[test] + fn single_conversion_reboards_the_root_passenger() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let pig = add_pig(&world); + let passenger = add_pig(&world); + assert!( + passenger.start_riding(&pig), + "passenger should ride the pig" + ); + assert_eq!(pig.first_passenger().map(|p| p.id()), Some(passenger.id())); + + let cow = pig + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::COW, + ConversionParams::single(false, false), + None, + ) + .expect("pig should convert to cow"); + + assert_eq!( + cow.first_passenger().map(|p| p.id()), + Some(passenger.id()), + "the root passenger reboards the converted mob" + ); + assert!(!passenger.is_removed(), "the passenger survives conversion"); + assert!(pig.is_removed()); + } + + #[test] + fn split_on_death_conversion_keeps_the_source_living() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let pig = add_pig(&world); + + let cow = pig + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::COW, + ConversionParams { + conversion_type: ConversionType::SplitOnDeath, + keep_equipment: false, + preserve_can_pick_up_loot: false, + }, + None, + ) + .expect("split conversion should create the cow"); + + assert_eq!(cow.entity_type(), &vanilla_entities::COW); + assert!(world.get_entity_by_id(cow.id()).is_some()); + assert!(!pig.is_removed(), "split-on-death keeps the source living"); + } + + #[test] + fn convert_to_fails_without_a_registered_factory() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let pig = add_pig(&world); + + let converted = pig + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::ZOMBIE, + ConversionParams::single(true, true), + None, + ); + + assert!( + converted.is_none(), + "no zombie factory is registered, so conversion cannot happen" + ); + assert!( + !pig.is_removed(), + "failed conversions leave the source untouched" + ); + } + + #[test] + fn refused_conversion_keeps_the_source_passenger_mounted() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let source = add_pig(&world); + let passenger = add_pig(&world); + assert!( + passenger.start_riding(&source), + "the passenger should ride the source" + ); + + let converted = source + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::ZOMBIE, + ConversionParams::single(true, true), + None, + ); + + assert!(converted.is_none()); + assert!( + !source.is_removed(), + "refused conversions leave the source alive" + ); + assert_eq!( + source.first_passenger().map(|entity| entity.id()), + Some(passenger.id()), + "a refused conversion must not dismount the source's passenger" + ); + } + + #[test] + fn refused_conversion_keeps_the_source_on_its_vehicle() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let source = add_pig(&world); + let vehicle = add_pig(&world); + assert!( + source.start_riding(&vehicle), + "the source should ride its vehicle" + ); + + let converted = source + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::ZOMBIE, + ConversionParams::single(true, true), + None, + ); + + assert!(converted.is_none()); + assert!( + !source.is_removed(), + "refused conversions leave the source alive" + ); + assert_eq!( + source.vehicle().map(|entity| entity.id()), + Some(vehicle.id()), + "a refused conversion must not dismount the source from its vehicle" + ); + } + + #[test] + fn refused_conversion_keeps_the_source_leash() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let source = add_pig(&world); + let holder = add_pig(&world); + let source_mob = source.as_mob().expect("pig is a mob"); + assert!( + source_mob.set_leashed_to(&holder), + "the test pig should leash to the holder" + ); + + let converted = source + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::ZOMBIE, + ConversionParams::single(true, true), + None, + ); + + assert!(converted.is_none()); + assert!( + !source.is_removed(), + "refused conversions leave the source alive" + ); + assert_eq!( + source_mob.leash_holder().map(|entity| entity.id()), + Some(holder.id()), + "a refused conversion must not steal the source's leash" + ); + } + + #[test] + fn convert_to_refuses_hostile_types_on_peaceful_difficulty() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + + // Peaceful: the registered hostile endermite factory must be refused. + world.set_difficulty(Difficulty::Peaceful); + let pig = add_pig(&world); + let pig_mob = pig.as_mob().expect("pig is a mob"); + assert!( + pig.downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::ENDERMITE, + ConversionParams::single(true, true), + None, + ) + .is_none(), + "hostile conversions are refused on peaceful" + ); + assert!( + !pig_mob.is_removed(), + "refused conversions leave the source untouched" + ); + + // Control: the same conversion is permitted off peaceful. + world.set_difficulty(Difficulty::Normal); + let other_pig = add_pig(&world); + assert!( + other_pig + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::ENDERMITE, + ConversionParams::single(true, true), + None, + ) + .is_some(), + "hostile conversions are allowed on non-peaceful difficulties" + ); + } + + #[test] + fn conversion_skips_equipment_when_not_requested() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let pig = add_pig(&world); + let pig_mob = pig.as_mob().expect("pig is a mob"); + pig_mob.living_base().equipment().lock().set( + EquipmentSlot::Head, + ItemStack::new(&vanilla_items::IRON_HELMET), + ); + + let cow = pig + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::COW, + ConversionParams::single(false, true), + None, + ) + .expect("pig should convert to cow"); + let cow_mob = cow.as_mob().expect("cow is a mob"); + + assert!( + cow_mob.equipment_in_slot(EquipmentSlot::Head).is_empty(), + "equipment is only copied when keep_equipment is set" + ); + } + + #[test] + fn after_conversion_callback_receives_the_new_mob() { + init_vanilla_registry(); + init_entities(); + let world = conversion_world(); + let pig = add_pig(&world); + let finalized = Cell::new(false); + let after_conversion = |mob: &dyn Mob| { + assert_eq!(mob.entity_type(), &vanilla_entities::COW); + finalized.set(true); + }; + + let cow = pig + .downcast_ref::() + .expect("pig is concrete") + .convert_to( + &vanilla_entities::COW, + ConversionParams::single(false, false), + Some(&after_conversion), + ) + .expect("pig should convert to cow"); + + assert!( + finalized.get(), + "the after-conversion callback runs on the new mob" + ); + assert_eq!(cow.entity_type(), &vanilla_entities::COW); + } +} diff --git a/steel-core/src/entity/entities/mobs/hostile/endermite.rs b/steel-core/src/entity/entities/mobs/hostile/endermite.rs index 2a4d0ae4c620..fa1a28b52331 100644 --- a/steel-core/src/entity/entities/mobs/hostile/endermite.rs +++ b/steel-core/src/entity/entities/mobs/hostile/endermite.rs @@ -20,13 +20,18 @@ use crate::entity::ai::goal::{ use crate::entity::damage::DamageSource; use crate::entity::{ Entity, EntityBase, EntityBaseLoad, EntityPose, EntitySyncedData, LivingEntity, - LivingEntityBase, Mob, MobBase, PathfinderMob, RemovalReason, + LivingEntityBase, Mob, MobBase, Monster, PathfinderMob, RemovalReason, }; use crate::physics::MoveResult; use crate::world::World; +/// Vanilla `Attributes.STEP_HEIGHT` default, used when the attribute is absent. const DEFAULT_STEP_HEIGHT: f32 = 0.6; +/// Vanilla `Endermite.MAX_LIFE`: ticks until the mob discards itself when not +/// persistence-required. const MAX_LIFETIME: i32 = 2400; +/// Vanilla `Endermite` constructor: `this.xpReward = 3`. +const XP_REWARD: i32 = 3; /// A hostile endermite entity. #[entity_behavior(class = "Endermite")] @@ -67,6 +72,7 @@ impl EndermiteEntity { fn new_with_base(base: EntityBase, entity_type: EntityTypeRef) -> Self { let living_base = LivingEntityBase::new(entity_type); let mob_base = MobBase::new(); + mob_base.set_xp_reward(XP_REWARD); let mut entity_data = EndermiteEntityData::new(); living_base.initialize_synced_data(&mut entity_data); @@ -174,7 +180,15 @@ impl Entity for EndermiteEntity { } fn sound_source(&self) -> SoundSource { - SoundSource::Hostile + Monster::sound_source_monster(self) + } + + fn fall_sounds(&self) -> (SoundEventRef, SoundEventRef) { + Monster::fall_sounds_monster(self) + } + + fn swim_sound(&self) -> SoundEventRef { + Monster::swim_sound_monster(self) } fn play_step_sound(&self, _pos: BlockPos, _block_state: BlockStateId) { @@ -225,16 +239,31 @@ impl LivingEntity for EndermiteEntity { Some(&sound_events::ENTITY_ENDERMITE_DEATH) } + fn should_drop_experience(&self) -> bool { + Monster::should_drop_experience_monster(self) + } + + fn should_drop_loot(&self, world: &World) -> bool { + Monster::should_drop_loot_monster(self, world) + } + fn server_ai_step(&self) { Mob::mob_server_ai_step(self); } fn ai_step(&self) -> Option { - let result = self.default_ai_step(); - if self.level().is_some() && !self.is_persistence_required() { - let mut lifetime = self.lifetime.lock(); - *lifetime += 1; - if *lifetime >= MAX_LIFETIME { + let result = Monster::monster_ai_step(self); + // Vanilla `Endermite.aiStep`: the lifetime grows only while the mob is + // not persistence-required, and the discard check runs unconditionally. + if self.level().is_some() { + let discard = { + let mut lifetime = self.lifetime.lock(); + if !self.is_persistence_required() { + *lifetime += 1; + } + *lifetime >= MAX_LIFETIME + }; + if discard { self.set_removed(RemovalReason::Discarded); } } @@ -268,18 +297,77 @@ impl Mob for EndermiteEntity { } } +impl Monster for EndermiteEntity {} + impl PathfinderMob for EndermiteEntity {} #[cfg(test)] mod tests { - use super::EndermiteEntity; - use crate::entity::Entity; + use std::io::Cursor; + use std::sync::{Arc, Weak}; + use glam::DVec3; use simdnbt::borrow::read_compound; use simdnbt::owned::NbtCompound; - use std::io::Cursor; - use std::sync::Weak; use steel_registry::{init_vanilla_registry, vanilla_entities}; + use steel_utils::{ChunkPos, Downcast as _}; + + use super::{EndermiteEntity, MAX_LIFETIME}; + use crate::entity::{Entity, LivingEntity, Mob, SharedEntity, next_entity_id}; + use crate::test_support::{fresh_test_world, insert_ready_full_chunk}; + + #[test] + fn endermite_discards_after_max_lifetime_unless_persistence_required() { + init_vanilla_registry(); + let world = fresh_test_world("endermite_lifetime"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + + // Vanilla `Endermite.aiStep` grows the lifetime each tick and discards + // the mob once it reaches 2400. + let endermite: SharedEntity = Arc::new(EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::new(8.0, 65.0, 8.0), + Arc::downgrade(&world), + )); + world + .try_add_entity(Arc::clone(&endermite)) + .expect("test endermite should attach to the loaded chunk"); + let endermite = endermite + .downcast_ref::() + .expect("endermite is concrete"); + // One tick below the cap stays alive; the next tick crosses it. + endermite.set_lifetime(MAX_LIFETIME - 2); + endermite.ai_step(); + assert!( + !endermite.is_removed(), + "lifetime below the cap keeps the mob alive" + ); + endermite.ai_step(); + assert!(endermite.is_removed(), "reaching the cap discards the mob"); + + // A persistence-required mob never grows its lifetime, mirroring the + // vanilla guard around the increment. + let persistent: SharedEntity = Arc::new(EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::new(9.0, 65.0, 8.0), + Arc::downgrade(&world), + )); + world + .try_add_entity(Arc::clone(&persistent)) + .expect("test endermite should attach to the loaded chunk"); + let persistent = persistent + .downcast_ref::() + .expect("endermite is concrete"); + persistent.set_persistence_required(); + persistent.set_lifetime(MAX_LIFETIME - 1); + persistent.ai_step(); + assert!( + !persistent.is_removed(), + "persistence-required endermites must not expire" + ); + } #[test] fn endermite_nbt_round_trip() { diff --git a/steel-core/src/entity/entity/mod.rs b/steel-core/src/entity/entity/mod.rs index 3aee00314c5f..ee324c64baaa 100644 --- a/steel-core/src/entity/entity/mod.rs +++ b/steel-core/src/entity/entity/mod.rs @@ -1512,6 +1512,32 @@ pub trait Entity: EntityEventSource + ErasedType + Send + Sync + 'static { try_as_dyn::(self) } + /// Returns true for entities that implement vanilla hostile-mob behavior. + fn is_monster(&self) -> bool { + self.as_monster().is_some() + } + + /// Returns this entity as a hostile mob when it has hostile behavior. + /// + /// Mirrors vanilla's frequent `instanceof Monster` branches. + fn as_monster(&self) -> Option<&dyn Monster> { + try_as_dyn::(self) + } + + /// Returns true for entities that implement vanilla `Enemy`. + /// + /// Mirrors vanilla's frequent `instanceof Enemy` branches (hostile mobs). + fn is_enemy(&self) -> bool { + self.as_enemy().is_some() + } + + /// Returns this entity as an enemy when it is hostile. + /// + /// Mirrors vanilla's frequent `instanceof Enemy` branches. + fn as_enemy(&self) -> Option<&dyn Enemy> { + try_as_dyn::(self) + } + /// Returns true for entities that implement vanilla ageable-mob behavior. fn is_ageable_mob(&self) -> bool { self.as_ageable_mob().is_some() diff --git a/steel-core/src/entity/leash.rs b/steel-core/src/entity/leash.rs index 201b404d5647..7a7dfd253914 100644 --- a/steel-core/src/entity/leash.rs +++ b/steel-core/src/entity/leash.rs @@ -25,6 +25,9 @@ pub const ENTITY_LEASH_ATTACHMENT_POINT: DVec3 = DVec3::new(0.0, 0.5, 0.5); pub const LEASHER_ATTACHMENT_POINT: DVec3 = DVec3::new(0.0, 0.5, 0.0); pub const DELAYED_LEASH_DROP_TICKS: i32 = 100; pub const BASE_HORIZONTAL_FRICTION: f64 = 0.91; +/// Vanilla `Leashable.angularFriction`: friction applied to the leash's angular +/// momentum while the entity is in a liquid (water or lava). +pub const LEASH_LIQUID_ANGULAR_FRICTION: f64 = 0.8; /// Vanilla behavior shared by entities that extend `Leashable`. /// @@ -38,11 +41,12 @@ pub trait Leashable: Entity { self.leash_holder().is_some() } - /// Returns whether this entity can be leashed with respect to its leash state. + /// Returns whether this entity currently has leash data. /// - /// In other words, this returns `false` if the entity is already leashed and `true` if not. - /// - /// See also: [`Leashable::can_be_leashed`] + /// Mirrors vanilla `Leashable.mayBeLeashed`, which checks `getLeashData() + /// != null` — true while leashed (or awaiting save-restore), not a + /// permission check. See [`Leashable::can_be_leashed`] for the type-level + /// gate. fn may_be_leashed(&self) -> bool { self.leash_data().lock().is_some() } @@ -71,9 +75,12 @@ pub trait Leashable: Entity { /// /// For example, mobs like dolphins and hoglins return `true` for this, while `villagers` return `false`. /// + /// Mirrors the vanilla `Leashable` default of `true`; the `Mob` blanket + /// impl overrides this with vanilla `Mob.canBeLeashed`, which returns + /// `false` for `Enemy` mobs. + /// /// See also: [`Leashable::may_be_leashed`] fn can_be_leashed(&self) -> bool { - // TODO: Return false for enemy mobs once hostile mob foundations exist. true } @@ -101,6 +108,13 @@ pub trait Leashable: Entity { holder.notify_leash_holder(self.as_entity_event_source()); } + /// Called when this entity's leash data is removed. + /// + /// Mirrors vanilla `Leashable.onLeashRemoved`, invoked by `dropLeash` + /// (both the drop-lead and plain-removal variants) right after the leash + /// data is cleared. + fn on_leash_removed(&self) {} + /// Called every tick this entity's leash is stretched too far (this entity is too far from its holder). fn leash_too_far_behaviour(&self) { self.drop_leash(); @@ -190,7 +204,7 @@ pub trait Leashable: Entity { } if self.is_in_water() || self.is_in_lava() { - return 0.8; + return LEASH_LIQUID_ANGULAR_FRICTION; } BASE_HORIZONTAL_FRICTION @@ -318,6 +332,9 @@ pub trait Leashable: Entity { } let holder = self.remove_leash_state(); + // Vanilla `Leashable.dropLeash` invokes `onLeashRemoved` right after + // clearing the leash data, before dropping the lead item. + self.on_leash_removed(); let _ = self.spawn_at_location(ItemStack::new(&vanilla_items::LEAD), 0.0); if let Some(holder) = holder { holder.notify_leashee_removed(self.as_entity_event_source()); @@ -329,11 +346,18 @@ pub trait Leashable: Entity { if self.leash_holder().is_some() && let Some(holder) = self.remove_leash_state() { + // Vanilla's static `dropLeash` serves both variants and invokes + // `onLeashRemoved` after clearing the leash data. + self.on_leash_removed(); holder.notify_leashee_removed(self.as_entity_event_source()); } } /// Removes the leash state of this entity, returning its holder before the leash's removal, if any. + /// + /// Does not invoke [`Leashable::on_leash_removed`]; vanilla only calls the + /// hook from its static `dropLeash`, so callers that bypass it (such as the + /// failed save-restore drop) keep the vanilla behavior. fn remove_leash_state(&self) -> Option { self.leash_data() .lock() diff --git a/steel-core/src/entity/mob/mod.rs b/steel-core/src/entity/mob/mod.rs index 02809e98245e..1e6ee1339f4f 100644 --- a/steel-core/src/entity/mob/mod.rs +++ b/steel-core/src/entity/mob/mod.rs @@ -21,13 +21,16 @@ use steel_registry::blocks::block_state_ext::BlockStateExt as _; use steel_registry::data_components::components::ItemEnchantments; use steel_registry::data_components::vanilla_components::CUSTOM_NAME; use steel_registry::enchantment_effect::EnchantmentEffectComponent; +use steel_registry::entity_type::EntityTypeRef; use steel_registry::item_stack::ItemStack; +use steel_registry::items::ItemRef; use steel_registry::loot_table::LootTableRef; use steel_registry::sound_event::SoundEventRef; use steel_registry::vanilla_block_tags::BlockTag; use steel_registry::{ REGISTRY, RegistryExt, TaggedRegistryExt, vanilla_attributes, vanilla_damage_types, - vanilla_entities, vanilla_game_events, vanilla_game_rules, + vanilla_entities, vanilla_entity_type_tags::EntityTypeTag, vanilla_game_events, + vanilla_game_rules, }; use steel_utils::locks::SyncMutex; use steel_utils::types::{Difficulty, InteractionHand}; @@ -44,14 +47,16 @@ use crate::entity::ai::path::{PathType, PathfindingContext, PathfindingMalus}; use crate::entity::ai::sensing::Sensing; use crate::entity::ai::walk::WalkPathEvaluator; use crate::entity::attribute::{AttributeModifier, AttributeModifierOperation}; +use crate::entity::conversion::{AfterConversion, convert_to as convert_mob}; use crate::entity::damage::DamageSource; use crate::entity::entities::objects::items::ItemEntity; use crate::entity::{ - Entity, EntitySpawnReason, LivingEntity, LivingTravelInput, RemovalReason, SharedEntity, - SpawnGroupData, WeakEntity, + ConversionParams, Entity, EntitySpawnReason, LivingEntity, LivingTravelInput, RemovalReason, + SharedEntity, SpawnGroupData, WeakEntity, aabb_contains_any_liquid, }; use crate::inventory::equipment::EquipmentSlot; -use crate::physics::MoveResult; +use crate::physics::collision::CollisionWorld; +use crate::physics::{COLLISION_EPSILON, MoveResult, WorldCollisionProvider}; use crate::player::Player; use crate::world::{LevelReader, World}; @@ -76,6 +81,20 @@ const LEFT_HANDED_SPAWN_CHANCE: f32 = 0.05; /// Vanilla `Mob.ITEM_PICKUP_REACH`: the per-axis distance the item-pickup search /// box is inflated by when a mob looks for nearby dropped items to collect. const ITEM_PICKUP_REACH: DVec3 = DVec3::new(1.0, 0.0, 1.0); +/// Vanilla `Mob.burnUndead`: the seconds of fire an unprotected daylight-burning +/// mob is set alight for (`igniteForSeconds(8.0F)`). +const SUN_BURN_IGNITE_SECONDS: f32 = 8.0; +/// Vanilla `Entity.igniteForSeconds`: the ignite tick count for the burn tick. +const SUN_BURN_IGNITE_TICKS: i32 = (SUN_BURN_IGNITE_SECONDS * 20.0) as i32; +/// Vanilla's 0.5F `getLightLevelDependentMagicValue` brightness gate, shared +/// by `Mob.isSunBurnTick` and `Monster.updateNoActionTime`. +pub(crate) const LIGHT_MAGIC_VALUE_BRIGHTNESS_GATE: f32 = 0.5; +/// Vanilla `Mob.isSunBurnTick`: the random roll in the per-tick burn chance. +const SUN_BURN_RANDOM_SCALE: f32 = 30.0; +/// Vanilla `Mob.isSunBurnTick`: subtracted from brightness in the burn chance. +const SUN_BURN_LIGHT_OFFSET: f32 = 0.4; +/// Vanilla `Mob.isSunBurnTick`: the brightness multiplier in the burn chance. +const SUN_BURN_LIGHT_GAIN: f32 = 2.0; #[derive(Debug, Clone, Copy, PartialEq)] struct DropChances { @@ -493,6 +512,66 @@ pub trait Mob: LivingEntity + Leashable { group_data } + /// Vanilla `Mob.convertTo`: replaces this mob with a fresh `entity_type` + /// instance that inherits the shared conversion state described by `params`. + /// + /// Returns `None` when the mob is removed, the world is gone, `entity_type` + /// has no registered factory, the created entity is not a mob, or the world + /// rejects the new entity; the source mob is left untouched in those cases. + /// The after-conversion callback downcasts to the concrete mob type when it + /// needs to finalize type-specific state (vanilla `AfterConversion`). + fn convert_to( + &self, + entity_type: EntityTypeRef, + params: ConversionParams, + after_conversion: Option<&AfterConversion<'_>>, + ) -> Option + where + Self: Sized, + { + convert_mob(self, entity_type, params, after_conversion) + } + + /// Vanilla `Mob.checkMobSpawnRules`: the block below `pos` must accept this + /// spawn unless a spawner is involved. The block's + /// [`BlockBehavior::is_valid_spawn`](crate::behavior::BlockBehavior::is_valid_spawn) + /// decides per-block acceptance (soul sand accepts everything, magma only + /// fire-immune mobs, ...). + fn check_mob_spawn_rules( + entity_type: EntityTypeRef, + level: &dyn LevelReader, + spawn_reason: EntitySpawnReason, + pos: BlockPos, + ) -> bool + where + Self: Sized, + { + if spawn_reason.is_spawner() { + return true; + } + + let below = pos.below(); + let state = level.get_block_state(below); + BLOCK_BEHAVIORS + .get_behavior(state.get_block()) + .is_valid_spawn(state, level, below, entity_type) + } + + /// Vanilla `Mob.checkSpawnObstruction`: the mob's bounding box must contain + /// no liquid and no block collision. + fn check_spawn_obstruction(&self, world: &Arc) -> bool { + let aabb = self.bounding_box(); + if aabb_contains_any_liquid(world, aabb) { + return false; + } + + let provider = WorldCollisionProvider::for_entity(world, self.as_entity_event_source()); + !provider.has_block_collision_with_context( + &aabb.deflate(COLLISION_EPSILON), + BlockCollisionContext::entity(aabb.min_y(), self.is_descending()), + ) + } + /// Handles vanilla `Mob.interact`. fn interact_mob( &self, @@ -564,6 +643,14 @@ pub trait Mob: LivingEntity + Leashable { self.mob_base().drop_chances().lock().by_equipment(slot) } + /// Sets vanilla `Mob.setDropChance`: the per-slot equipment drop chance. + fn set_equipment_drop_chance(&self, slot: EquipmentSlot, chance: f32) { + self.mob_base() + .drop_chances() + .lock() + .set_equipment_chance(slot, chance); + } + fn is_equipment_drop_preserved(&self, slot: EquipmentSlot) -> bool { self.mob_base().drop_chances().lock().is_preserved(slot) } @@ -1407,15 +1494,105 @@ pub trait Mob: LivingEntity + Leashable { self.tick_jump_control(); } + /// Returns vanilla `Mob.isSunBurnTick`. + /// + /// While the `MONSTERS_BURN` environment attribute is on, a mob standing in + /// the open rolls a per-tick chance to be set alight that scales with the + /// light-dependent magic value at its eyes. Water, rain, and powder snow + /// shelter the mob from burning. + fn is_sun_burn_tick(&self) -> bool { + let Some(world) = self.level() else { + return false; + }; + if !world.monsters_burn() { + return false; + } + + let eye_pos = BlockPos::new( + fast_floor(self.position().x), + fast_floor(self.get_eye_y()), + fast_floor(self.position().z), + ); + let brightness = world.light_level_dependent_magic_value(eye_pos); + // Vanilla `Entity.isInRain`: rain reaching either the feet or the top of + // the bounding box shelters the mob from burning. + let feet_pos = self.block_position(); + let in_rain = world.is_raining_at(feet_pos) + || world.is_raining_at(BlockPos::new( + feet_pos.x(), + fast_floor(self.bounding_box().max_y()), + feet_pos.z(), + )); + let in_non_burnable_block = + self.is_in_water() || in_rain || self.is_in_powder_snow() || self.was_in_powder_snow(); + brightness > LIGHT_MAGIC_VALUE_BRIGHTNESS_GATE + && rand::random::() * SUN_BURN_RANDOM_SCALE + < (brightness - SUN_BURN_LIGHT_OFFSET) * SUN_BURN_LIGHT_GAIN + && !in_non_burnable_block + && world.can_see_sky(eye_pos) + } + + /// Returns vanilla `Mob.sunProtectionSlot`: the equipment slot whose item + /// shields a daylight-burning mob from the sun. + fn sun_protection_slot(&self) -> EquipmentSlot { + EquipmentSlot::Head + } + + /// Vanilla `Mob.burnUndead`: while the daylight-burning roll succeeds, the + /// item in the sun-protection slot absorbs the tick with one random + /// durability point and breaks once depleted; an unprotected mob is set + /// alight instead. + fn tick_daylight_burning(&self) { + if !LivingEntity::is_alive(self) || !self.is_sun_burn_tick() { + return; + } + + let slot = self.sun_protection_slot(); + let mut broke_sun_blocker: Option = None; + { + let mut equipment = self.living_base().equipment().lock(); + if equipment.get_ref(slot).is_empty() { + drop(equipment); + self.ignite_for_ticks(SUN_BURN_IGNITE_TICKS); + return; + } + if equipment.get_ref(slot).is_damageable_item() { + let item = equipment.get_ref(slot).item(); + let sun_blocker = equipment.get_mut(slot); + sun_blocker + .set_damage_value(sun_blocker.get_damage_value() + rand::random_range(0..2)); + if sun_blocker.get_damage_value() >= sun_blocker.get_max_damage() { + broke_sun_blocker = Some(item); + } + } + } + + let Some(item) = broke_sun_blocker else { + return; + }; + self.on_equipped_item_broken(item, slot); + self.living_base() + .equipment() + .lock() + .set(slot, ItemStack::empty()); + } + /// Vanilla `Mob.aiStep`: the `LivingEntity.aiStep` movement foundation followed - /// by the item-pickup looting loop. + /// by daylight burning for undead mobs and the item-pickup looting loop. /// - /// Looting lives here rather than in [`mob_server_ai_step`](Self::mob_server_ai_step) - /// because vanilla runs it from `aiStep`, not `serverAiStep`: a mob with - /// `NoAI` set still collects loot, so the looting must not sit behind the - /// `isEffectiveAi` gate that guards the goal/navigation ticks. + /// Burning and looting live here rather than in + /// [`mob_server_ai_step`](Self::mob_server_ai_step) because vanilla runs + /// them from `aiStep`, not `serverAiStep`: a mob with `NoAI` set still burns + /// and collects loot, so they must not sit behind the `isEffectiveAi` gate + /// that guards the goal/navigation ticks. fn mob_ai_step(&self) -> Option { let result = self.default_ai_step(); + if REGISTRY + .entity_types + .is_in_tag(self.entity_type(), &EntityTypeTag::BURN_IN_DAYLIGHT) + { + self.tick_daylight_burning(); + } self.tick_looting(); result } @@ -1659,6 +1836,52 @@ impl Leashable for T { fn leash_data(&self) -> &SyncMutex> { self.mob_base().leash_data() } + + /// Vanilla `Mob.canBeLeashed`: hostile (`Enemy`) mobs can never be leashed. + /// + /// Non-enemy mobs keep the `Leashable` default unless a future concrete mob + /// needs its own rule (villagers, for example, override `canBeLeashed` to + /// always return `false` in vanilla). + fn can_be_leashed(&self) -> bool { + !self.is_enemy() + } + + /// Vanilla `Mob.onLeashRemoved`: clears the home restriction once the + /// leash data is gone. + fn on_leash_removed(&self) { + if self.leash_holder().is_none() { + self.clear_home(); + } + } + + /// Vanilla `Mob.leashTooFarBehaviour`: drops the lead and disables the + /// MOVE goal control. Like vanilla, the flag stays disabled until the mob + /// is re-leashed and reaches close range (which re-enables it). + fn leash_too_far_behaviour(&self) { + self.drop_leash(); + self.mob_base() + .goal_selector() + .lock() + .disable_control(GoalControl::Move); + } + + /// Vanilla `Leashable.whenLeashedTo`; `PathfinderMob` adds the home + /// restriction pin and forwards the same attach notification. + fn when_leashed_to(&self, holder: &dyn Entity) { + if let Some(pathfinder) = self.as_pathfinder_mob() { + pathfinder.when_leashed_to_pathfinder(holder); + } else { + holder.notify_leash_holder(self.as_entity_event_source()); + } + } + + /// Vanilla `Leashable.closeRangeLeashBehaviour`; only pathfinder mobs walk + /// back to a close-range leash holder. + fn close_range_leash_behaviour(&self, holder: &dyn Entity) { + if let Some(pathfinder) = self.as_pathfinder_mob() { + pathfinder.close_range_leash_behaviour_pathfinder(holder); + } + } } fn can_attempt_equipment_drop(drop_chance: f32, preserve: bool, killed_by_player: bool) -> bool { diff --git a/steel-core/src/entity/mob/pathfinder.rs b/steel-core/src/entity/mob/pathfinder.rs index b68b97953782..a1179c078113 100644 --- a/steel-core/src/entity/mob/pathfinder.rs +++ b/steel-core/src/entity/mob/pathfinder.rs @@ -7,6 +7,7 @@ use steel_registry::{vanilla_attributes, vanilla_blocks}; use steel_utils::{BlockPos, ChunkPos}; use super::{Mob, TARGET_REACH_DISTANCE_SQR}; +use crate::entity::ai::goal::{Goal, GoalControl}; use crate::entity::ai::navigation::{ NavigationPathRequest, NavigationRecomputeRequest, NavigationTickContext, }; @@ -16,6 +17,10 @@ use crate::entity::{Entity, LivingEntity, SharedEntity}; use crate::physics::WorldCollisionProvider; use crate::world::{LevelReader, World}; +/// Vanilla `PathfinderMob.closeRangeLeashBehaviour`'s wanted distance between +/// the mob and its holder. +pub(super) const LEASH_CLOSE_RANGE_WANTED_DISTANCE: f64 = 2.0; + pub(super) fn tick_path_navigation_target( mob: &M, world: &Arc, @@ -102,9 +107,16 @@ pub trait PathfinderMob: Mob { Some(vehicle) } + /// Returns vanilla `Mob.getWalkTargetValue`; `Animal` and `Monster` refine + /// it with their light preferences (animals favor light, monsters darkness). fn get_walk_target_value(&self, pos: BlockPos) -> f32 { - self.as_animal() - .map_or(0.0, |animal| animal.animal_walk_target_value(pos)) + if let Some(monster) = self.as_monster() { + monster.get_walk_target_value_monster(pos) + } else if let Some(animal) = self.as_animal() { + animal.animal_walk_target_value(pos) + } else { + 0.0 + } } fn has_line_of_sight_cached(&self, target: &dyn Entity) -> bool { @@ -285,6 +297,77 @@ pub trait PathfinderMob: Mob { .has_running_panic_goal() } + /// Returns whether this mob keeps close to its leash holder when leashed. + /// + /// Vanilla `PathfinderMob.shouldStayCloseToLeashHolder`, default true. + fn should_stay_close_to_leash_holder(&self) -> bool { + true + } + + /// Returns the speed at which this mob follows its leash holder. + /// + /// Vanilla `PathfinderMob.followLeashSpeed`, default 1.0. + fn follow_leash_speed(&self) -> f64 { + 1.0 + } + + /// Vanilla `PathfinderMob.whenLeashedTo`: pins the home restriction to the + /// holder and forwards the attach notification to the holder. + fn when_leashed_to_pathfinder(&self, holder: &dyn Entity) { + let radius = self.leash_elastic_distance() as i32 - 1; + self.set_home_to(holder.block_position(), radius); + holder.notify_leash_holder(self.as_entity_event_source()); + } + + /// Vanilla `PathfinderMob.closeRangeLeashBehaviour`: once the leash is at + /// close range, re-enable the MOVE goal control and walk back toward the + /// holder until the wanted distance is reached. + /// + /// Vanilla guards this with `shouldStayCloseToLeashHolder` and skips it + /// while panicking; its brain-based panic branch is blocked on the Brain + /// foundation, so Steel uses the running-`PanicGoal` scan (the vanilla + /// fallback that also runs for every pathfinder mob). + fn close_range_leash_behaviour_pathfinder(&self, holder: &dyn Entity) { + if !self.should_stay_close_to_leash_holder() || self.is_panicking() { + return; + } + + self.mob_base() + .goal_selector() + .lock() + .enable_control(GoalControl::Move); + // Vanilla `Entity.distanceTo`, center-to-center distance. + let distance_to = (holder.position() - self.position()).length(); + let delta = (holder.position() - self.position()).normalize_or_zero() + * (distance_to - LEASH_CLOSE_RANGE_WANTED_DISTANCE).max(0.0); + self.move_to_pos(self.position() + delta, self.follow_leash_speed()); + } + + /// Vanilla `Mob.removeAllGoals`: removes every goal matching `predicate`, + /// stopping running matches first. + /// + /// Vanilla declares this on `Mob`; Steel places it on `PathfinderMob` + /// because Steel's goal lifecycle (`Goal::stop`) only operates on + /// pathfinding mobs. + fn remove_all_goals(&self, predicate: impl FnMut(&dyn Goal) -> bool) + where + Self: Sized, + { + self.mob_base() + .goal_selector() + .lock() + .remove_all_goals(self, predicate); + } + + /// Vanilla `Mob.removeFreeWill`: clears every goal. + fn remove_free_will(&self) + where + Self: Sized, + { + self.remove_all_goals(|_| true); + // Vanilla also removes all brain behaviors; Steel has no brain system. + } + fn create_path_to_targets( &self, world: &Arc, diff --git a/steel-core/src/entity/mob/tests.rs b/steel-core/src/entity/mob/tests.rs index 80682bdf49af..6e7fbd851ce2 100644 --- a/steel-core/src/entity/mob/tests.rs +++ b/steel-core/src/entity/mob/tests.rs @@ -8,6 +8,7 @@ use steel_registry::vanilla_item_tags::ItemTag; use steel_registry::vanilla_items; use steel_registry::{ REGISTRY, init_vanilla_registry, vanilla_attributes, vanilla_blocks, vanilla_damage_types, + vanilla_world_clocks, }; use steel_utils::locks::SyncMutex; use steel_utils::{BlockPos, BlockStateId, ChunkPos, Downcast as _, Identifier}; @@ -17,20 +18,25 @@ use super::{ }; use crate::behavior::init_behaviors; use crate::entity::ai::control::{DEFAULT_LOOK_X_MAX_ROT_ANGLE, DEFAULT_LOOK_Y_MAX_ROT_SPEED}; -use crate::entity::ai::goal::GoalControl; +use crate::entity::ai::goal::{Goal, GoalControl, GoalControls}; use crate::entity::ai::node::Node; use crate::entity::ai::path::{Path, PathType}; use crate::entity::damage::DamageSource; use crate::entity::entities::PigEntity; use crate::entity::entities::objects::items::ItemEntity; -use crate::entity::leash::Leashable; +use crate::entity::leash::{LEASH_ELASTIC_DISTANCE, LEASH_SNAP_DISTANCE, Leashable}; +use crate::entity::mob::pathfinder::LEASH_CLOSE_RANGE_WANTED_DISTANCE; use crate::entity::mob::{Mob, MobBase}; use crate::entity::{ Entity, EntityBase, LivingEntity, LivingEntityBase, PathfinderMob, SharedEntity, next_entity_id, }; use crate::inventory::equipment::EquipmentSlot; -use crate::test_support::{fresh_test_world, insert_ready_full_chunk, test_world}; +use crate::test_support::{ + fresh_test_world, fresh_test_world_with_dimension_type, insert_ready_full_chunk, test_world, +}; use crate::world::{LevelReader, World}; +use steel_registry::vanilla_dimension_types; +use steel_utils::types::UpdateFlags; #[test] fn equipment_drop_attempt_gate_matches_vanilla_conditions() { @@ -141,6 +147,28 @@ impl DespawnTestMob { } } + fn with_world_and_entity_type( + id: i32, + position: DVec3, + entity_type: EntityTypeRef, + world: &Arc, + ) -> Self { + init_vanilla_registry(); + + Self { + base: EntityBase::new(id, position, entity_type.dimensions, Arc::downgrade(world)), + entity_type, + living_base: LivingEntityBase::new(entity_type), + mob_base: MobBase::new(), + flags: SyncMutex::new(0), + health: SyncMutex::new(10.0), + nearest_player_distance_sqr: None, + remove_when_far_away: false, + controlling_passenger: SyncMutex::new(None), + preferred_weapon_type: SyncMutex::new(None), + } + } + fn set_preferred_weapon_type(&self, tag: Identifier) { *self.preferred_weapon_type.lock() = Some(tag); } @@ -686,6 +714,219 @@ fn mob_tick_leash_applies_default_elastic_pull() { assert!(mob.is_leashed()); } +#[test] +fn mob_leash_snap_drops_leash_and_disables_move_control() { + // Vanilla `Mob.leashTooFarBehaviour`: past the snap distance the lead is + // dropped and the MOVE goal control is disabled (staying disabled until + // re-leash at close range, like vanilla). + let mob = Arc::new(DespawnTestMob::with_position(1, DVec3::ZERO, None, false)); + let holder = Arc::new(DespawnTestMob::with_position( + 2, + DVec3::new(LEASH_SNAP_DISTANCE + 8.0, 0.0, 0.0), + None, + false, + )); + let holder_entity: SharedEntity = holder.clone(); + assert!(mob.set_leashed_to(&holder_entity)); + + mob.tick_leash(); + + assert!( + !mob.is_leashed(), + "a snapped leash is dropped (and the lead item spawns)" + ); + assert!( + mob.mob_base() + .goal_selector() + .lock() + .is_control_disabled(GoalControl::Move), + "vanilla `Mob.leashTooFarBehaviour` disables the MOVE goal control" + ); +} + +#[test] +fn pathfinder_leash_attach_pins_home_restriction_to_holder() { + // Vanilla `PathfinderMob.whenLeashedTo`: the home restriction snaps to the + // holder's position with radius `leashElasticDistance - 1`. + let mob = DespawnTestMob::with_position(1, DVec3::new(0.0, 64.0, 0.0), None, false); + let holder = Arc::new(DespawnTestMob::with_position( + 2, + DVec3::new(5.0, 64.0, 0.0), + None, + false, + )); + let holder_entity: SharedEntity = holder.clone(); + assert!(mob.set_leashed_to(&holder_entity)); + + mob.tick_leash(); + + assert!(mob.has_home()); + assert_eq!(mob.home_radius(), LEASH_ELASTIC_DISTANCE as i32 - 1); + let home = *mob.mob_base().home_restriction().lock(); + assert_eq!(home.position, BlockPos::new(5, 64, 0)); +} + +#[test] +fn mob_on_leash_removed_clears_home_restriction() { + // Vanilla `Mob.onLeashRemoved`: once the leash data is gone the home + // restriction is cleared. + let mob = Arc::new(DespawnTestMob::with_position( + 1, + DVec3::new(0.0, 64.0, 0.0), + None, + false, + )); + let holder = Arc::new(DespawnTestMob::with_position( + 2, + DVec3::new(5.0, 64.0, 0.0), + None, + false, + )); + let holder_entity: SharedEntity = holder.clone(); + assert!(mob.set_leashed_to(&holder_entity)); + mob.tick_leash(); + assert!(mob.has_home()); + + mob.drop_leash(); + + assert!(!mob.is_leashed()); + assert!( + !mob.has_home(), + "dropping the leash must clear the pinned home restriction" + ); +} + +/// Builds a world with a ready chunk and a stone floor strip so pathfinding +/// can plan walks between the mob and holder positions. +fn leash_test_world(name: &'static str) -> Arc { + init_vanilla_registry(); + init_behaviors(); + let world = fresh_test_world(name); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + for x in 7..=12 { + assert!(world.set_block( + BlockPos::new(x, 64, 8), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + } + world +} + +fn leashed_pair( + world: &Arc, + mob_id: i32, + holder_id: i32, +) -> (Arc, SharedEntity) { + let mob = Arc::new(DespawnTestMob::with_world_and_entity_type( + mob_id, + DVec3::new(8.0, 65.0, 8.0), + &vanilla_entities::PIG, + world, + )); + // The holder sits one block past the wanted distance, which keeps the + // mob within close range (below the elastic boundary) so the walk-back + // path actually starts. + let holder_x = 8.0 + LEASH_CLOSE_RANGE_WANTED_DISTANCE + 1.0; + let holder: SharedEntity = Arc::new(DespawnTestMob::with_world_and_entity_type( + holder_id, + DVec3::new(holder_x, 65.0, 8.0), + &vanilla_entities::PIG, + world, + )); + for entity in [Arc::clone(&mob) as SharedEntity, Arc::clone(&holder)] { + world + .try_add_entity(entity) + .expect("test entity should attach to the loaded chunk"); + } + (mob, holder) +} + +#[test] +fn pathfinder_close_range_leash_navigates_toward_holder() { + // Vanilla `PathfinderMob.closeRangeLeashBehaviour`: at close range the mob + // re-enables MOVE and walks back toward the holder. + let world = leash_test_world("mob_leash_close_range"); + let (mob, holder) = leashed_pair(&world, 1, 2); + + mob.set_on_ground(true); + assert!(mob.set_leashed_to(&holder)); + + mob.tick_leash(); + + assert!(mob.is_leashed(), "a close-range leash must not snap"); + assert!( + mob.is_path_finding(), + "the mob should path back toward its holder at close range" + ); + assert!( + !mob.mob_base() + .goal_selector() + .lock() + .is_control_disabled(GoalControl::Move), + "close-range leash behaviour re-enables the MOVE goal control" + ); +} +/// A deterministic stand-in for a running `PanicGoal`, so the shared +/// close-range leash panic gate can be tested without the vanilla random-pos +/// search (which only accepts ~1/9 of directions and is inherently flaky). +struct TestPanicGoal; + +impl Goal for TestPanicGoal { + fn controls(&self) -> GoalControls { + GoalControls::MOVE + } + + fn is_panic_goal(&self) -> bool { + true + } + + fn can_use(&mut self, _mob: &dyn PathfinderMob) -> bool { + true + } +} + +#[test] +fn pathfinder_close_range_leash_skips_following_while_panicking() { + // Vanilla `PathfinderMob.closeRangeLeashBehaviour` skips the walk-back + // while the mob is panicking (a running panic goal). + let mob = Arc::new(DespawnTestMob::with_position( + 1, + DVec3::new(0.0, 64.0, 0.0), + None, + false, + )); + let holder: SharedEntity = Arc::new(DespawnTestMob::with_position( + 2, + DVec3::new(3.0, 64.0, 0.0), + None, + false, + )); + + mob.mob_base() + .goal_selector() + .lock() + .add_goal(1, TestPanicGoal); + mob.mob_base().goal_selector().lock().tick(mob.as_ref()); + assert!(mob.is_panicking()); + + // Leave MOVE disabled as a snapped leash would, then confirm the panic + // gate neither re-enables it nor starts navigating. + mob.mob_base() + .goal_selector() + .lock() + .disable_control(GoalControl::Move); + mob.close_range_leash_behaviour(holder.as_ref()); + + assert!(mob.mob_base().navigation().lock().is_done()); + assert!( + mob.mob_base() + .goal_selector() + .lock() + .is_control_disabled(GoalControl::Move) + ); +} + #[test] fn mob_despawn_resets_no_action_time_near_player() { let mob = DespawnTestMob::new(Some(31.0 * 31.0), false); @@ -1235,3 +1476,153 @@ fn ground_path_target_solid_rewrites_to_first_open_block_above() { BlockPos::new(4, 66, 4) ); } + +const OVERWORLD_DAY_TICKS: i64 = 6_000; +const OVERWORLD_NIGHT_TICKS: i64 = 18_000; +/// Bounded re-roll cap: at full daylight the per-tick ignition chance is 4%, so +/// this cap leaves the "never ignites" failure odds below one in a quadrillion. +const BURN_ROLL_ATTEMPTS: i32 = 1_000; + +/// Builds an overworld test world with a ready chunk, the clock pinned to a +/// fixed time of day, and a stone floor under the mob position. +fn daylight_test_world(name: &'static str, total_ticks: i64) -> Arc { + init_vanilla_registry(); + init_behaviors(); + let world = fresh_test_world(name); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + world.set_clock_total_ticks(&vanilla_world_clocks::OVERWORLD, total_ticks); + assert!(world.set_block( + BlockPos::new(8, 64, 8), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + Arc::clone(&world) +} + +fn daylit_mob(world: &Arc, entity_type: EntityTypeRef) -> Arc { + Arc::new(DespawnTestMob::with_world_and_entity_type( + next_entity_id(), + DVec3::new(8.0, 65.0, 8.0), + entity_type, + world, + )) +} + +#[test] +fn daylight_burning_ignites_unprotected_mob_in_burn_in_daylight_tag() { + let world = daylight_test_world("daylight_burn_ignite", OVERWORLD_DAY_TICKS); + assert!( + world.monsters_burn(), + "the pinned noon clock must enable MONSTERS_BURN" + ); + let mob = daylit_mob(&world, &vanilla_entities::ZOMBIE); + + for _ in 0..BURN_ROLL_ATTEMPTS { + Mob::tick_daylight_burning(mob.as_ref()); + if mob.is_on_fire() { + break; + } + } + + assert!( + mob.is_on_fire(), + "an unprotected daylight-burning mob should eventually be set alight" + ); +} + +#[test] +fn daylight_burning_damages_a_protecting_helmet_without_igniting() { + let world = daylight_test_world("daylight_burn_helmet", OVERWORLD_DAY_TICKS); + let mob = daylit_mob(&world, &vanilla_entities::ZOMBIE); + let mut helmet = ItemStack::new(&vanilla_items::IRON_HELMET); + assert!( + helmet.is_damageable_item(), + "an iron helmet must be damageable for the sun-protection test" + ); + helmet.set_damage_value(helmet.get_max_damage() - 1); + mob.living_base() + .equipment() + .lock() + .set(EquipmentSlot::Head, helmet); + + for _ in 0..BURN_ROLL_ATTEMPTS { + Mob::tick_daylight_burning(mob.as_ref()); + assert!( + !mob.is_on_fire(), + "a mob wearing sun protection must never be ignited by daylight" + ); + let mut head_is_empty = false; + mob.with_equipment_slot(EquipmentSlot::Head, &mut |item_stack| { + head_is_empty = item_stack.is_empty(); + }); + if head_is_empty { + break; + } + } + + let mut head_is_empty = false; + mob.with_equipment_slot(EquipmentSlot::Head, &mut |item_stack| { + head_is_empty = item_stack.is_empty(); + }); + assert!( + head_is_empty, + "the burnt-out helmet should break and be unequipped" + ); +} + +#[test] +fn daylight_burning_is_gated_off_at_night_and_in_the_nether() { + let night_world = daylight_test_world("daylight_burn_night", OVERWORLD_NIGHT_TICKS); + let night_mob = daylit_mob(&night_world, &vanilla_entities::ZOMBIE); + assert!( + !night_world.monsters_burn(), + "the pinned midnight clock must disable MONSTERS_BURN" + ); + for _ in 0..BURN_ROLL_ATTEMPTS { + Mob::tick_daylight_burning(night_mob.as_ref()); + assert!( + !night_mob.is_on_fire(), + "a mob must not burn in daylight at night" + ); + } + + init_vanilla_registry(); + let nether = fresh_test_world_with_dimension_type( + "mob", + "daylight_burn_nether", + &vanilla_dimension_types::THE_NETHER, + ); + insert_ready_full_chunk(&nether, ChunkPos::new(0, 0)); + let nether_mob = daylit_mob(&nether, &vanilla_entities::ZOMBIE); + for _ in 0..BURN_ROLL_ATTEMPTS { + Mob::tick_daylight_burning(nether_mob.as_ref()); + assert!( + !nether_mob.is_on_fire(), + "a mob must not burn in a dimension without the day timeline" + ); + } +} + +#[test] +fn mob_ai_step_gates_daylight_burning_on_burn_in_daylight_tag() { + let world = daylight_test_world("daylight_burn_gate", OVERWORLD_DAY_TICKS); + + let zombie = daylit_mob(&world, &vanilla_entities::ZOMBIE); + for _ in 0..BURN_ROLL_ATTEMPTS { + Mob::mob_ai_step(zombie.as_ref()); + if zombie.is_on_fire() { + break; + } + } + assert!( + zombie.is_on_fire(), + "aiStep should burn a mob tagged burn_in_daylight" + ); + + let pig = daylit_mob(&world, &vanilla_entities::PIG); + Mob::mob_ai_step(pig.as_ref()); + assert!( + !pig.is_on_fire(), + "aiStep must not burn a mob outside the burn_in_daylight tag" + ); +} diff --git a/steel-core/src/entity/mob_effect/absorption.rs b/steel-core/src/entity/mob_effect/absorption.rs index 6fbd68bc9dd7..137d9eb17773 100644 --- a/steel-core/src/entity/mob_effect/absorption.rs +++ b/steel-core/src/entity/mob_effect/absorption.rs @@ -9,7 +9,7 @@ use crate::world::World; pub struct AbsorptionBehavior; /// Absorption HP granted per amplifier level (vanilla `4.0F`). -const ABSORPTION_PER_LEVEL: f32 = 4.0; +pub(crate) const ABSORPTION_PER_LEVEL: f32 = 4.0; impl MobEffectBehavior for AbsorptionBehavior { fn should_apply_effect_tick_this_tick(&self, _tick_count: i32, _amplifier: i32) -> bool { diff --git a/steel-core/src/entity/mob_effect/mod.rs b/steel-core/src/entity/mob_effect/mod.rs index 07a2e2921450..3dc1abdea45e 100644 --- a/steel-core/src/entity/mob_effect/mod.rs +++ b/steel-core/src/entity/mob_effect/mod.rs @@ -15,6 +15,8 @@ mod weaving; mod wind_charged; mod wither; +#[cfg(test)] +pub(crate) use absorption::ABSORPTION_PER_LEVEL; pub use absorption::AbsorptionBehavior; pub use bad_omen::BadOmenBehavior; pub use heal_or_harm::HealOrHarmBehavior; diff --git a/steel-core/src/entity/mod.rs b/steel-core/src/entity/mod.rs index 8ad81e79f3a5..67956a8cfe83 100644 --- a/steel-core/src/entity/mod.rs +++ b/steel-core/src/entity/mod.rs @@ -263,7 +263,7 @@ pub(crate) fn equipment_items_to_packet_items( .collect() } -fn aabb_contains_any_liquid(world: &Arc, aabb: WorldAabb) -> bool { +pub(crate) fn aabb_contains_any_liquid(world: &Arc, aabb: WorldAabb) -> bool { (aabb.min_x().floor() as i32..aabb.max_x().ceil() as i32).any(|x| { (aabb.min_y().floor() as i32..aabb.max_y().ceil() as i32).any(|y| { (aabb.min_z().floor() as i32..aabb.max_z().ceil() as i32) @@ -735,6 +735,7 @@ mod block_effects; mod callback; mod combat_rules; pub mod consume_effect; +mod conversion; pub mod damage; pub(crate) mod dismount_helper; pub mod entities; @@ -757,6 +758,7 @@ mod living_entity; mod manager; mod mob; pub mod mob_effect; +mod monster; mod movement_sync; mod potion_contents; pub mod projectile; @@ -784,6 +786,7 @@ pub use callback::{ EntityChunkCallback, EntityLevelCallback, InactiveEntityCallback, NullEntityCallback, PlayerEntityCallback, RemovalReason, }; +pub use conversion::{AfterConversion, ConversionParams, ConversionType}; pub(crate) use entity::apply_entity_look_at; pub(crate) use entity::position_rider_default; pub use entity::{ @@ -806,6 +809,7 @@ pub use manager::{ EntityMoveUpdate, EntityOwnership, EntityVisibility, WorldEntityManager, }; pub(crate) use mob::{Mob, MobBase, PathfinderMob}; +pub(crate) use monster::{Enemy, Monster}; pub use movement_sync::{ EntityMovementSyncPacket, EntityMovementSyncPackets, EntityMovementSyncState, EntityMovementSyncUpdate, EntityPositionRotSyncPacket, EntityPositionSyncDecision, @@ -908,6 +912,12 @@ pub(crate) fn start_riding_entities( passenger.set_pose(EntityPose::Standing); EntityBase::start_riding_relationship(entity_to_ride, passenger); + // Vanilla `Mob.startRiding` drops the leash when a leashed mob starts riding. + if let Some(mob) = passenger.as_mob() + && mob.is_leashed() + { + mob.drop_leash(); + } // TODO: Emit ENTITY_MOUNT game event and riding advancement trigger once those foundations exist. true } diff --git a/steel-core/src/entity/monster.rs b/steel-core/src/entity/monster.rs new file mode 100644 index 000000000000..1144d0d6e030 --- /dev/null +++ b/steel-core/src/entity/monster.rs @@ -0,0 +1,274 @@ +//! Vanilla-shaped hostile mob foundations (`Enemy`, `Monster`). + +use steel_math::fast_floor; +use steel_protocol::packets::game::SoundSource; +use steel_registry::dimension_type::MonsterSpawnLightLevel; +use steel_registry::entity_type::EntityTypeRef; +use steel_registry::sound_event::SoundEventRef; +use steel_registry::sound_events; +use steel_registry::vanilla_game_rules::MOB_DROPS; +use steel_utils::BlockPos; +use steel_utils::random::Random; +use steel_utils::types::Difficulty; + +use crate::chunk::light::LightLayer; +use crate::entity::damage::DamageSource; +use crate::entity::mob::LIGHT_MAGIC_VALUE_BRIGHTNESS_GATE; +use crate::entity::{EntitySpawnReason, Mob, PathfinderMob}; +use crate::physics::MoveResult; +use crate::player::Player; +use crate::world::{LevelReader, World}; + +/// Vanilla `Monster` constructor: `this.xpReward = 5`. +/// +/// Hostile mob constructors assign the shared reward through +/// `MobBase::set_xp_reward` and only override it when their mob type differs +/// (an endermite uses 3). +pub const DEFAULT_XP_REWARD: i32 = 5; + +/// Vanilla `Monster.isDarkEnoughToSpawn` passes this fixed sky darkening while +/// thundering instead of the dimension's current `getSkyDarken`. +const THUNDERING_SKY_DARKENING: u8 = 10; + +/// Vanilla `Enemy` marker: mobs that count as hostile toward the player. +/// +/// Vanilla classes that are hostile without necessarily extending `Monster` +/// (ghasts, phantoms, slimes, hoglins, the ender dragon, ...) implement this +/// marker directly. Steel mirrors that by making `Monster` extend it; non-`Monster` +/// enemies implement it on their own future traits. +/// +/// The marker carries no behavior of its own; shared hostile-mob rules consult +/// it, e.g. vanilla `Mob.canBeLeashed` returns `false` for every `Enemy`. +pub trait Enemy {} + +/// Shared hostile-mob base, mirroring vanilla `Monster extends PathfinderMob +/// implements Enemy`. +/// +/// Vanilla `Monster` carries the constructor-level defaults every hostile mob +/// shares (`xpReward = 5`, hostile sound source and sound variants, light-based +/// no-action-time growth in `aiStep`, the dark-spawn rules, dropping experience, +/// and preventing players from resting). Concrete hostile mobs implement this +/// trait and only override what their mob type changes. +/// +/// Hooks whose names collide with the base entity traits (`sound_source`, +/// `hurt_sound`, `get_walk_target_value`, ...) follow Steel's `Animal` +/// convention: the shared default lives here under a `_monster` suffix, and the +/// concrete mob's base-trait override delegates to it. +/// Every `Monster` is an `Enemy`, mirroring vanilla's `Monster implements Enemy`. +impl Enemy for T {} + +pub trait Monster: PathfinderMob + Enemy { + /// Returns the vanilla `Monster` constructor experience reward. + /// + /// Hostile mob constructors assign it through + /// [`Mob::set_xp_reward`](Mob::set_xp_reward) and only override it when + /// their mob type differs (an endermite uses 3). + fn default_xp_reward_monster(&self) -> i32 { + DEFAULT_XP_REWARD + } + + /// Vanilla `Monster.getSoundSource`. + fn sound_source_monster(&self) -> SoundSource { + SoundSource::Hostile + } + + /// Vanilla `Monster.getHurtSound`. + fn hurt_sound_monster(&self, _source: &DamageSource) -> Option { + Some(&sound_events::ENTITY_HOSTILE_HURT) + } + + /// Vanilla `Monster.getDeathSound`. + fn death_sound_monster(&self) -> Option { + Some(&sound_events::ENTITY_HOSTILE_DEATH) + } + + /// Vanilla `Monster.getFallSounds`. + fn fall_sounds_monster(&self) -> (SoundEventRef, SoundEventRef) { + ( + &sound_events::ENTITY_HOSTILE_SMALL_FALL, + &sound_events::ENTITY_HOSTILE_BIG_FALL, + ) + } + + /// Vanilla `Monster.getSwimSound`. + /// + /// Vanilla also overrides `getSwimSplashSound` to `entity.hostile.splash`; + /// Steel plays one swim sound per entity, so the splash variant has no hook + /// to override yet. + fn swim_sound_monster(&self) -> SoundEventRef { + &sound_events::ENTITY_HOSTILE_SWIM + } + + /// Vanilla `Monster.updateNoActionTime`: while standing in bright light, the + /// monster's no-action timer grows twice as fast so it despawns sooner. + fn update_no_action_time(&self) { + let Some(world) = self.level() else { + return; + }; + // Vanilla `Monster.updateNoActionTime` calls the eye-position + // `LivingEntity.getLightLevelDependentMagicValue()` overload. + let eye_pos = BlockPos::new( + fast_floor(self.position().x), + fast_floor(self.get_eye_y()), + fast_floor(self.position().z), + ); + if world.light_level_dependent_magic_value(eye_pos) > LIGHT_MAGIC_VALUE_BRIGHTNESS_GATE { + self.set_no_action_time(self.no_action_time() + 2); + } + } + + /// Vanilla `Monster.aiStep`: advance the swing timer, apply light-based + /// no-action-time growth, then the standard mob `aiStep`. + /// + /// Vanilla advances `LivingEntity.updateSwingTime` from `Monster.aiStep` + /// (and from `Player` and `Mannequin`); passive mobs never swing their + /// animation, so hostile mobs are the only mobs that do. + fn monster_ai_step(&self) -> Option { + self.update_swing_time(); + self.update_no_action_time(); + self.mob_ai_step() + } + + /// Vanilla `Monster.getWalkTargetValue`: monsters prefer darker positions. + fn get_walk_target_value_monster(&self, pos: BlockPos) -> f32 { + let Some(world) = self.level() else { + return 0.0; + }; + -world.pathfinding_cost_from_light_levels(pos) + } + + /// Vanilla `Monster.isPreventingPlayerRest`: monsters keep nearby players + /// from sleeping in a bed. + fn is_preventing_player_rest(&self, _level: &World, _player: &Player) -> bool { + true + } + + /// Vanilla `Monster.shouldDropExperience`. + fn should_drop_experience_monster(&self) -> bool { + true + } + + /// Vanilla `Monster.shouldDropLoot`: monsters drop loot whenever the + /// `mobDrops` rule allows it, even as babies. + fn should_drop_loot_monster(&self, world: &World) -> bool { + world.get_game_rule(&MOB_DROPS) + } + + /// Vanilla `Monster.isDarkEnoughToSpawn`. + /// + /// `random` matches vanilla's `RandomSource` parameter so the check stays + /// deterministic for callers that seed their randomness. + fn is_dark_enough_to_spawn(level: &World, pos: BlockPos, random: &mut impl Random) -> bool + where + Self: Sized, + { + if i32::from(level.light_value_at(LightLayer::Sky, pos)) > random.next_i32_bounded(32) { + return false; + } + + let dimension_type = &level.dimension_type; + let block_light_limit = dimension_type.monster_spawn_block_light_limit; + if block_light_limit < 15 + && i32::from(level.light_value_at(LightLayer::Block, pos)) > block_light_limit + { + return false; + } + + // Vanilla 26.2 `Monster.isDarkEnoughToSpawn`: the thundering branch + // passes a fixed darkening of 10, the clear branch the dimension's + // current `getSkyDarken` (via the no-arg `getMaxLocalRawBrightness`). + let sky_darkening = if level.is_thundering() { + THUNDERING_SKY_DARKENING + } else { + level.sky_darkening() + }; + let brightness = level.max_local_raw_brightness(pos, sky_darkening); + i32::from(brightness) + <= sample_monster_spawn_light_test(&dimension_type.monster_spawn_light_level, random) + } + + /// Vanilla `Monster.checkMonsterSpawnRules`. + fn check_monster_spawn_rules( + entity_type: EntityTypeRef, + level: &World, + spawn_reason: EntitySpawnReason, + pos: BlockPos, + random: &mut impl Random, + ) -> bool + where + Self: Sized, + { + is_spawn_allowed_on_difficulty(entity_type, level) + && (spawn_reason.ignores_light_requirements() + || Self::is_dark_enough_to_spawn(level, pos, random)) + && ::check_mob_spawn_rules(entity_type, level, spawn_reason, pos) + } + + /// Vanilla `Monster.checkAnyLightMonsterSpawnRules`. + fn check_any_light_monster_spawn_rules( + entity_type: EntityTypeRef, + level: &World, + spawn_reason: EntitySpawnReason, + pos: BlockPos, + ) -> bool + where + Self: Sized, + { + is_spawn_allowed_on_difficulty(entity_type, level) + && ::check_mob_spawn_rules(entity_type, level, spawn_reason, pos) + } + + /// Vanilla `Monster.checkSurfaceMonstersSpawnRules`. + fn check_surface_monsters_spawn_rules( + entity_type: EntityTypeRef, + level: &World, + spawn_reason: EntitySpawnReason, + pos: BlockPos, + random: &mut impl Random, + ) -> bool + where + Self: Sized, + { + Self::check_monster_spawn_rules(entity_type, level, spawn_reason, pos, random) + && (spawn_reason.is_spawner() || level.can_see_sky(pos)) + } +} + +/// Steel-level peaceful clause mirroring vanilla `EntityType.canSpawn`. +/// +/// Vanilla never asks `Monster.checkMonsterSpawnRules` for hostile types on +/// Peaceful: `EntityType.canSpawn`/`create` refuses those spawns and the +/// natural spawner skips hostile categories. Steel has no `canSpawn` gate or +/// natural spawner yet, so the spawn-rule helpers enforce the clause +/// themselves; drop it once a spawn-rule invocation mirrors vanilla's +/// `EntityType.create` (documented divergence). +fn is_spawn_allowed_on_difficulty(entity_type: EntityTypeRef, level: &World) -> bool { + entity_type.allowed_in_peaceful || level.difficulty() != Difficulty::Peaceful +} + +/// Samples the dimension's vanilla `monster_spawn_light_test` int provider. +/// +/// Vanilla only ships `uniform` providers in dimension type data; unknown +/// distribution types fall back to the minimum for robustness. +fn sample_monster_spawn_light_test( + level: &MonsterSpawnLightLevel, + random: &mut impl Random, +) -> i32 { + match level { + MonsterSpawnLightLevel::Simple(level) => *level, + MonsterSpawnLightLevel::Complex { + distribution_type, + min_inclusive, + max_inclusive, + } => { + if *distribution_type == "minecraft:uniform" { + random.next_i32_between(*min_inclusive, *max_inclusive) + } else { + *min_inclusive + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/steel-core/src/entity/monster/tests.rs b/steel-core/src/entity/monster/tests.rs new file mode 100644 index 000000000000..c85a2245f282 --- /dev/null +++ b/steel-core/src/entity/monster/tests.rs @@ -0,0 +1,727 @@ +//! Tests for the shared hostile-mob (`Monster`) foundation. + +use std::sync::{Arc, Weak}; + +use glam::DVec3; +use steel_protocol::packets::game::SoundSource; +use steel_registry::entity_type::EntityTypeRef; +use steel_registry::sound_events; +use steel_registry::{ + init_vanilla_registry, vanilla_blocks, vanilla_damage_types, vanilla_dimension_types, + vanilla_entities, vanilla_game_rules, +}; +use steel_utils::locks::SyncMutex; +use steel_utils::random::legacy_random::LegacyRandom; +use steel_utils::random::{Random, RandomSource}; +use steel_utils::types::{Difficulty, UpdateFlags}; +use steel_utils::{BlockPos, BlockStateId, ChunkPos}; + +use super::DEFAULT_XP_REWARD; +use crate::behavior::init_behaviors; +use crate::entity::damage::DamageSource; +use crate::entity::entities::PigEntity; +use crate::entity::mob::{Mob, MobBase}; +use crate::entity::{ + Entity, EntityBase, EntitySpawnReason, LivingEntity, LivingEntityBase, Monster, PathfinderMob, + SharedEntity, entities::mobs::hostile::EndermiteEntity, next_entity_id, +}; +use crate::test_support::{ + TestPlayerBuilder, fresh_test_world, fresh_test_world_with_dimension_type, + insert_ready_full_chunk, test_world, +}; +use crate::world::{LevelReader, World}; + +struct TestMonster { + base: EntityBase, + entity_type: EntityTypeRef, + living_base: LivingEntityBase, + mob_base: MobBase, + mob_flags: SyncMutex, + health: SyncMutex, +} + +impl TestMonster { + fn new() -> Self { + Self::with_world(None) + } + + fn with_world(world: Option<&Arc>) -> Self { + init_vanilla_registry(); + Self { + base: EntityBase::new( + 1, + DVec3::new(8.0, 65.0, 8.0), + vanilla_entities::PIG.dimensions, + world.map_or_else(Weak::new, Arc::downgrade), + ), + entity_type: &vanilla_entities::PIG, + living_base: LivingEntityBase::new(&vanilla_entities::PIG), + mob_base: MobBase::new(), + mob_flags: SyncMutex::new(0), + health: SyncMutex::new(20.0), + } + } +} + +crate::entity::impl_test_downcast_type!(TestMonster); + +impl Entity for TestMonster { + fn base(&self) -> &EntityBase { + &self.base + } + + fn entity_type(&self) -> EntityTypeRef { + self.entity_type + } +} + +impl LivingEntity for TestMonster { + fn living_base(&self) -> &LivingEntityBase { + &self.living_base + } + + fn get_health(&self) -> f32 { + *self.health.lock() + } + + fn set_health(&self, health: f32) { + *self.health.lock() = health; + } +} + +impl Mob for TestMonster { + fn mob_base(&self) -> &MobBase { + &self.mob_base + } + + fn mob_flags(&self) -> i8 { + *self.mob_flags.lock() + } + + fn set_mob_flags(&self, flags: i8) { + *self.mob_flags.lock() = flags; + } +} + +impl PathfinderMob for TestMonster {} + +impl Monster for TestMonster {} + +/// A fake level whose block surface can be scripted for spawn-surface checks. +struct SpawnSurfaceLevel { + default_state: BlockStateId, + states: Vec<(BlockPos, BlockStateId)>, +} + +impl SpawnSurfaceLevel { + fn new(default_state: BlockStateId) -> Self { + Self { + default_state, + states: Vec::new(), + } + } + + fn with(mut self, pos: BlockPos, state: BlockStateId) -> Self { + self.states.push((pos, state)); + self + } +} + +impl LevelReader for SpawnSurfaceLevel { + fn get_block_state(&self, pos: BlockPos) -> BlockStateId { + self.states + .iter() + .find_map(|(state_pos, state)| (*state_pos == pos).then_some(*state)) + .unwrap_or(self.default_state) + } + + fn raw_brightness(&self, _pos: BlockPos, _sky_darkening: u8) -> u8 { + 0 + } + + fn min_y(&self) -> i32 { + -64 + } + + fn height(&self) -> i32 { + 384 + } +} + +#[test] +fn monster_constructor_defaults_match_vanilla() { + init_vanilla_registry(); + let world = test_world(); + let player = TestPlayerBuilder::new(Arc::clone(world), "RestTest", 1).build(); + let monster = TestMonster::new(); + let source = DamageSource::environment(&vanilla_damage_types::GENERIC); + + assert_eq!(DEFAULT_XP_REWARD, 5); + assert_eq!(monster.default_xp_reward_monster(), 5); + assert_eq!(monster.sound_source_monster(), SoundSource::Hostile); + assert_eq!( + monster.hurt_sound_monster(&source), + Some(&sound_events::ENTITY_HOSTILE_HURT) + ); + assert_eq!( + monster.death_sound_monster(), + Some(&sound_events::ENTITY_HOSTILE_DEATH) + ); + assert_eq!( + monster.fall_sounds_monster(), + ( + &sound_events::ENTITY_HOSTILE_SMALL_FALL, + &sound_events::ENTITY_HOSTILE_BIG_FALL, + ) + ); + assert_eq!( + monster.swim_sound_monster(), + &sound_events::ENTITY_HOSTILE_SWIM + ); + assert!( + monster.is_preventing_player_rest(world, &player), + "a plain monster should keep players from resting" + ); + assert!(monster.should_drop_experience_monster()); + assert_eq!( + monster.should_drop_loot_monster(world), + world.get_game_rule(&vanilla_game_rules::MOB_DROPS) + ); +} + +#[test] +fn monster_is_dark_enough_to_spawn_rejects_bright_overworld() { + let world = fresh_test_world("monster_spawn_bright_overworld"); + let pos = BlockPos::new(8, 65, 8); + + // Fresh chunks read full sky light, so the sky-light roll must beat 15 to + // even reach the brightness test, which the overworld's uniform 0..7 light + // test can never pass. + let mut low_roll = RandomSource::Legacy(LegacyRandom::from_seed(5120)); + assert!( + low_roll.next_i32_bounded(32) < 15, + "test seed must roll under the sky-light value" + ); + assert!(!TestMonster::is_dark_enough_to_spawn( + &world, + pos, + &mut low_roll + )); + + let mut high_roll = RandomSource::Legacy(LegacyRandom::from_seed(14880)); + assert!( + high_roll.next_i32_bounded(32) >= 15, + "test seed must roll at or above the sky-light value" + ); + assert!(!TestMonster::is_dark_enough_to_spawn( + &world, + pos, + &mut high_roll + )); +} + +#[test] +fn monster_is_dark_enough_to_spawn_accepts_dark_nether() { + let world = fresh_test_world_with_dimension_type( + "monster", + "nether_spawn_dark", + &vanilla_dimension_types::THE_NETHER, + ); + let pos = BlockPos::new(8, 65, 8); + + // The nether has no sky light and its light test accepts 7, so a fresh + // chunk is always dark enough regardless of the roll. + let mut random = RandomSource::Legacy(LegacyRandom::from_seed(5120)); + assert!(TestMonster::is_dark_enough_to_spawn( + &world, + pos, + &mut random + )); +} + +#[test] +fn monster_spawn_rules_accept_only_sturdy_non_glowing_surfaces() { + init_vanilla_registry(); + init_behaviors(); + let air = vanilla_blocks::AIR.default_state(); + let stone = vanilla_blocks::STONE.default_state(); + let glowstone = vanilla_blocks::GLOWSTONE.default_state(); + let pos = BlockPos::new(8, 65, 8); + let surface_pos = pos.below(); + + let surface = SpawnSurfaceLevel::new(air); + assert!(!TestMonster::check_mob_spawn_rules( + &vanilla_entities::ZOMBIE, + &surface, + EntitySpawnReason::Natural, + pos + )); + + let surface = SpawnSurfaceLevel::new(air).with(surface_pos, stone); + assert!(TestMonster::check_mob_spawn_rules( + &vanilla_entities::ZOMBIE, + &surface, + EntitySpawnReason::Natural, + pos + )); + + // Glowstone emits 15 light, so it never counts as a spawn surface. + let surface = SpawnSurfaceLevel::new(air).with(surface_pos, glowstone); + assert!(!TestMonster::check_mob_spawn_rules( + &vanilla_entities::ZOMBIE, + &surface, + EntitySpawnReason::Natural, + pos + )); + + // A spawner skips the surface requirement entirely. + let surface = SpawnSurfaceLevel::new(air); + assert!(TestMonster::check_mob_spawn_rules( + &vanilla_entities::ZOMBIE, + &surface, + EntitySpawnReason::TrialSpawner, + pos, + )); +} + +#[test] +fn monster_spawn_rules_consult_block_specific_spawn_surfaces() { + init_vanilla_registry(); + init_behaviors(); + let air = vanilla_blocks::AIR.default_state(); + let soul_sand = vanilla_blocks::SOUL_SAND.default_state(); + let magma = vanilla_blocks::MAGMA_BLOCK.default_state(); + let pos = BlockPos::new(8, 65, 8); + let surface_pos = pos.below(); + + // Soul sand accepts any mob (vanilla `Blocks::always`). + let surface = SpawnSurfaceLevel::new(air).with(surface_pos, soul_sand); + assert!(TestMonster::check_mob_spawn_rules( + &vanilla_entities::ZOMBIE, + &surface, + EntitySpawnReason::Natural, + pos + )); + + // Magma accepts only fire-immune mobs (vanilla `entityType.fireImmune()`). + let surface = SpawnSurfaceLevel::new(air).with(surface_pos, magma); + assert!(!TestMonster::check_mob_spawn_rules( + &vanilla_entities::ZOMBIE, + &surface, + EntitySpawnReason::Natural, + pos + )); + assert!(TestMonster::check_mob_spawn_rules( + &vanilla_entities::MAGMA_CUBE, + &surface, + EntitySpawnReason::Natural, + pos + )); +} + +#[test] +fn monster_spawn_rules_combine_light_and_surface_checks() { + init_vanilla_registry(); + init_behaviors(); + let overworld = fresh_test_world("monster_spawn_rules_overworld"); + let nether = fresh_test_world_with_dimension_type( + "monster", + "monster_spawn_rules_nether", + &vanilla_dimension_types::THE_NETHER, + ); + for (name, world) in [("overworld", &overworld), ("nether", &nether)] { + insert_ready_full_chunk(world, ChunkPos::new(0, 0)); + let pos = BlockPos::new(8, 65, 8); + assert!( + world.set_block( + pos.below(), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + ), + "{name}: stone spawn surface should place" + ); + let mut random = RandomSource::Legacy(LegacyRandom::from_seed(0)); + + assert_eq!( + TestMonster::check_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + world, + EntitySpawnReason::Natural, + pos, + &mut random, + ), + name == "nether", + "{name}: natural spawns must also pass the light check" + ); + + // Trial spawners ignore the light requirement and bypass the surface + // check entirely (vanilla `checkMobSpawnRules` short-circuits). + assert!(TestMonster::check_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + world, + EntitySpawnReason::TrialSpawner, + BlockPos::new(12, 65, 8), + &mut random, + )); + assert!(TestMonster::check_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + world, + EntitySpawnReason::TrialSpawner, + pos, + &mut random, + )); + } +} + +#[test] +fn monster_any_light_spawn_rules_skip_light_but_keep_surface_checks() { + init_vanilla_registry(); + init_behaviors(); + let world = fresh_test_world_with_dimension_type( + "monster", + "any_light_spawn_rules", + &vanilla_dimension_types::THE_NETHER, + ); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + let pos = BlockPos::new(8, 65, 8); + assert!(world.set_block( + pos.below(), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + + assert!(!TestMonster::check_any_light_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::Natural, + BlockPos::new(12, 65, 8), + )); + assert!(TestMonster::check_any_light_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::Natural, + pos, + )); + assert!(TestMonster::check_any_light_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::Spawner, + BlockPos::new(12, 65, 8), + )); +} + +#[test] +fn monster_surface_spawn_rules_require_sky_visibility_or_spawner() { + init_vanilla_registry(); + init_behaviors(); + let overworld = fresh_test_world("monster_surface_spawn_rules"); + let nether = fresh_test_world_with_dimension_type( + "monster", + "monster_surface_spawn_rules_nether", + &vanilla_dimension_types::THE_NETHER, + ); + let mut random = RandomSource::Legacy(LegacyRandom::from_seed(0)); + + // The nether never sees the sky, so even a valid dark surface fails. + insert_ready_full_chunk(&nether, ChunkPos::new(0, 0)); + let nether_pos = BlockPos::new(8, 65, 8); + assert!(nether.set_block( + nether_pos.below(), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + assert!(!TestMonster::check_surface_monsters_spawn_rules( + &vanilla_entities::ZOMBIE, + &nether, + EntitySpawnReason::Natural, + nether_pos, + &mut random, + )); + assert!(TestMonster::check_surface_monsters_spawn_rules( + &vanilla_entities::ZOMBIE, + &nether, + EntitySpawnReason::Spawner, + nether_pos, + &mut random, + )); + + // The bright overworld fails on the light requirement. + insert_ready_full_chunk(&overworld, ChunkPos::new(0, 0)); + let overworld_pos = BlockPos::new(8, 65, 8); + assert!(overworld.set_block( + overworld_pos.below(), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + assert!(!TestMonster::check_surface_monsters_spawn_rules( + &vanilla_entities::ZOMBIE, + &overworld, + EntitySpawnReason::Natural, + overworld_pos, + &mut random, + )); +} + +#[test] +fn monster_spawn_rules_refuse_hostile_types_on_peaceful_difficulty() { + init_vanilla_registry(); + init_behaviors(); + let world = fresh_test_world("monster_peaceful_spawn_rules"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + let pos = BlockPos::new(8, 65, 8); + assert!(world.set_block( + pos.below(), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + let mut random = RandomSource::Legacy(LegacyRandom::from_seed(0)); + + // Spawner reasons skip the light and surface checks, so only the + // difficulty clause can refuse the spawn on Peaceful. + assert!(TestMonster::check_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::TrialSpawner, + pos, + &mut random, + )); + + world.set_difficulty(Difficulty::Peaceful); + assert!( + !TestMonster::check_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::TrialSpawner, + pos, + &mut random, + ), + "spawn rules must refuse types that cannot exist on Peaceful" + ); + assert!( + !TestMonster::check_any_light_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::TrialSpawner, + pos, + ), + "any-light spawn rules must also refuse hostile types on Peaceful" + ); + assert!( + !TestMonster::check_surface_monsters_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::Spawner, + pos, + &mut random, + ), + "surface spawn rules delegate to the same difficulty clause" + ); + assert!( + TestMonster::check_any_light_monster_spawn_rules( + &vanilla_entities::COW, + &world, + EntitySpawnReason::TrialSpawner, + pos, + ), + "the clause mirrors vanilla `allowed_in_peaceful`, not a blanket refusal" + ); + + world.set_difficulty(Difficulty::Normal); + assert!( + TestMonster::check_monster_spawn_rules( + &vanilla_entities::ZOMBIE, + &world, + EntitySpawnReason::TrialSpawner, + pos, + &mut random, + ), + "the same spawn passes once difficulty leaves Peaceful" + ); +} + +#[test] +fn monster_walk_target_value_prefers_darkness() { + let overworld = fresh_test_world("monster_walk_target_overworld"); + let nether = fresh_test_world_with_dimension_type( + "monster", + "monster_walk_target_nether", + &vanilla_dimension_types::THE_NETHER, + ); + let pos = BlockPos::new(8, 65, 8); + + let bright_monster = TestMonster::with_world(Some(&overworld)); + assert!( + bright_monster.get_walk_target_value_monster(pos) < 0.0, + "bright positions should be penalized" + ); + // The `PathfinderMob` default must dispatch hostile mobs to the monster + // walk-target value automatically (vanilla `Monster.getWalkTargetValue` + // overrides the base), without per-mob boilerplate. + assert!( + bright_monster.get_walk_target_value(pos) < 0.0, + "the PathfinderMob default must dispatch monsters to the dark preference" + ); + let dark_monster = TestMonster::with_world(Some(&nether)); + assert!( + dark_monster.get_walk_target_value_monster(pos) > 0.0, + "dark positions should be preferred" + ); +} + +#[test] +fn monster_ai_step_grows_no_action_time_by_vanilla_light_rate() { + init_vanilla_registry(); + init_behaviors(); + let bright_world = fresh_test_world("monster_ai_step_bright"); + insert_ready_full_chunk(&bright_world, ChunkPos::new(0, 0)); + let bright_mob = Arc::new(EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::new(8.0, 65.0, 8.0), + Arc::downgrade(&bright_world), + )); + bright_world + .try_add_entity(Arc::clone(&bright_mob) as SharedEntity) + .expect("test endermite should attach to the loaded chunk"); + + bright_mob.ai_step(); + + // Bright light adds 2 via `Monster.updateNoActionTime` plus the standard + // per-tick increment from `Mob.serverAiStep`. + assert_eq!(bright_mob.no_action_time(), 3); + + let dark_world = fresh_test_world_with_dimension_type( + "monster", + "monster_ai_step_dark", + &vanilla_dimension_types::THE_NETHER, + ); + insert_ready_full_chunk(&dark_world, ChunkPos::new(0, 0)); + let dark_mob = Arc::new(EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::new(8.0, 65.0, 8.0), + Arc::downgrade(&dark_world), + )); + dark_world + .try_add_entity(Arc::clone(&dark_mob) as SharedEntity) + .expect("test endermite should attach to the loaded chunk"); + + dark_mob.ai_step(); + + assert_eq!(dark_mob.no_action_time(), 1); +} + +#[test] +fn monster_check_spawn_obstruction_rejects_blocks_and_liquids() { + init_vanilla_registry(); + let world = fresh_test_world("monster_spawn_obstruction"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + let mob = EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::new(8.0, 65.0, 8.0), + Arc::downgrade(&world), + ); + let mob_pos = mob.block_position(); + + assert!( + mob.check_spawn_obstruction(&world), + "an empty spawn box should be unobstructed" + ); + + assert!(world.set_block( + mob_pos, + vanilla_blocks::WATER.default_state(), + UpdateFlags::UPDATE_ALL, + )); + assert!( + !mob.check_spawn_obstruction(&world), + "liquid inside the spawn box must reject the spawn" + ); + + assert!(world.set_block( + mob_pos, + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + assert!( + !mob.check_spawn_obstruction(&world), + "solid blocks inside the spawn box must reject the spawn" + ); + + assert!(world.set_block( + mob_pos, + vanilla_blocks::AIR.default_state(), + UpdateFlags::UPDATE_ALL, + )); + assert!(mob.check_spawn_obstruction(&world)); +} + +#[test] +fn enemy_monsters_cannot_be_leashed() { + init_vanilla_registry(); + let world = fresh_test_world("enemy_leash"); + let hostile: SharedEntity = Arc::new(EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::ZERO, + Arc::downgrade(&world), + )); + let pig: SharedEntity = Arc::new(PigEntity::new( + &vanilla_entities::PIG, + next_entity_id(), + DVec3::new(2.0, 0.0, 0.0), + Arc::downgrade(&world), + )); + + assert!(hostile.is_enemy(), "monsters are vanilla `Enemy`s"); + assert!(!pig.is_enemy(), "a passive animal is not a vanilla `Enemy`"); + let hostile_leashable = hostile + .as_leashable() + .expect("a hostile mob implements Leashable"); + assert!( + !hostile_leashable.can_be_leashed(), + "enemy mobs must not be leashable" + ); + let pig_leashable = pig.as_leashable().expect("a pig implements Leashable"); + assert!( + pig_leashable.can_be_leashed(), + "non-enemy mobs keep their existing leashability" + ); + assert!( + !hostile_leashable.can_have_a_leash_attached_to(pig.as_ref()), + "a lead must not attach to a hostile mob" + ); + assert!( + pig_leashable.can_have_a_leash_attached_to(hostile.as_ref()), + "a lead should still attach to a non-enemy mob in reach" + ); + assert!( + !pig_leashable.can_have_a_leash_attached_to(pig.as_ref()), + "an entity can never leash to itself" + ); +} + +#[test] +fn monster_downcasts_from_shared_entity() { + init_vanilla_registry(); + let world = fresh_test_world("monster_downcast"); + let endermite: SharedEntity = Arc::new(EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::ZERO, + Arc::downgrade(&world), + )); + + assert!(endermite.is_monster()); + let monster = endermite + .as_monster() + .expect("endermite should be a monster"); + assert_eq!(monster.sound_source_monster(), SoundSource::Hostile); + let mob = endermite.as_mob().expect("endermite should be a mob"); + assert_eq!( + mob.xp_reward(), + 3, + "the endermite constructor assigns its own reward" + ); +} diff --git a/steel-core/src/entity/tests/riding_and_leashes.rs b/steel-core/src/entity/tests/riding_and_leashes.rs index 18f80729638c..bcff8281d923 100644 --- a/steel-core/src/entity/tests/riding_and_leashes.rs +++ b/steel-core/src/entity/tests/riding_and_leashes.rs @@ -1,4 +1,7 @@ use super::*; +use crate::entity::leash::{DELAYED_LEASH_DROP_TICKS, LeashAttachment}; +use crate::entity::mob::Mob; +use crate::entity::next_entity_id; #[test] fn living_ride_tick_resets_fall_distance() { @@ -46,6 +49,79 @@ fn start_riding_entities_links_passenger_and_vehicle() { assert_eq!(passenger.pose(), EntityPose::Standing); } +#[test] +fn failed_save_restore_drops_leash_but_keeps_home_restriction() { + // Vanilla `restoreLeashFromSave` clears the leash data directly when the + // delayed holder cannot be resolved, without invoking `onLeashRemoved` — so + // the home restriction pinned by save data must survive the failed restore. + init_vanilla_registry(); + let world = fresh_test_world("leash_save_restore_failure"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + + let mob = Arc::new(PigEntity::new( + &vanilla_entities::PIG, + next_entity_id(), + DVec3::new(8.0, 65.0, 8.0), + Arc::downgrade(&world), + )); + world + .try_add_entity(Arc::clone(&mob) as SharedEntity) + .expect("test entity should attach to the loaded chunk"); + let leashable = mob + .as_leashable() + .expect("pig should expose leashable behavior"); + + mob.set_home_to(BlockPos::new(8, 65, 8), 5); + leashable.set_delayed_leash_attachment(LeashAttachment::Entity(Uuid::nil())); + for _ in 0..=DELAYED_LEASH_DROP_TICKS { + mob.advance_tick_count(); + } + + leashable.tick_leash(); + + assert!( + !leashable.is_leashed(), + "an unresolvable save-leash is dropped after 100 ticks" + ); + assert!( + mob.has_home(), + "the failed restore must keep the saved home restriction" + ); +} + +#[test] +fn mob_start_riding_drops_attached_leash() { + // Vanilla `Mob.startRiding`: starting to ride drops the leash. + init_vanilla_registry(); + + let passenger: SharedEntity = Arc::new(PigEntity::new( + &vanilla_entities::PIG, + 1, + DVec3::ZERO, + Weak::new(), + )); + let vehicle = PushableTestEntity::shared(2, DVec3::ZERO); + let leash_holder: SharedEntity = Arc::new(PigEntity::new( + &vanilla_entities::PIG, + 3, + DVec3::new(5.0, 0.0, 0.0), + Weak::new(), + )); + let mob = passenger + .as_leashable() + .expect("pig should expose leashable behavior"); + assert!(mob.set_leashed_to(&leash_holder)); + assert!(mob.is_leashed()); + + assert!(start_riding_entities(&passenger, &vehicle)); + + assert!(passenger.is_passenger()); + assert!( + !mob.is_leashed(), + "a leashed mob that starts riding must drop its leash" + ); +} + #[test] fn transfer_leashables_to_holder_moves_valid_mobs() { init_vanilla_registry(); diff --git a/steel-core/src/player/sleep.rs b/steel-core/src/player/sleep.rs index ce9e74b6357f..cb000f5ff53b 100644 --- a/steel-core/src/player/sleep.rs +++ b/steel-core/src/player/sleep.rs @@ -5,7 +5,7 @@ use steel_registry::{ dimension_type::BedRuleValue, vanilla_custom_stats, }; -use steel_utils::{BlockPos, Direction}; +use steel_utils::{BlockPos, Direction, WorldAabb, types::GameType}; use text_components::{TextComponent, translation::TranslatedMessage}; use super::sleep_state::SLEEP_DURATION; @@ -18,10 +18,16 @@ use crate::{ const BED_INTERACTION_XZ_RANGE: f64 = 3.0; const BED_INTERACTION_Y_RANGE: f64 = 2.0; +/// Horizontal half-extent of the vanilla monster-rest search box. +const MONSTER_SEARCH_XZ_RANGE: f64 = 8.0; +/// Vertical half-extent of the vanilla monster-rest search box. +const MONSTER_SEARCH_Y_RANGE: f64 = 5.0; #[derive(Debug)] pub(crate) enum BedSleepingProblem { OtherProblem, + /// Vanilla `Player.BedSleepingProblem.NOT_SAFE`: a monster prevents resting. + NotSafe(Box), Message(Box), } @@ -29,12 +35,39 @@ impl BedSleepingProblem { #[must_use] pub(crate) fn message(&self) -> Option<&TextComponent> { match self { - Self::Message(message) => Some(message.as_ref()), + Self::Message(message) | Self::NotSafe(message) => Some(message.as_ref()), Self::OtherProblem => None, } } } +impl Player { + /// Vanilla `ServerPlayer.startSleepInBed` monster-rest check: any hostile + /// mob within the 8x5x8 box around the bed center keeps this player from + /// sleeping. + fn monster_prevents_rest(&self, pos: BlockPos) -> bool { + let world = self.get_world(); + let center = DVec3::new( + f64::from(pos.x()) + 0.5, + f64::from(pos.y()), + f64::from(pos.z()) + 0.5, + ); + let search = WorldAabb::new( + center.x - MONSTER_SEARCH_XZ_RANGE, + center.y - MONSTER_SEARCH_Y_RANGE, + center.z - MONSTER_SEARCH_XZ_RANGE, + center.x + MONSTER_SEARCH_XZ_RANGE, + center.y + MONSTER_SEARCH_Y_RANGE, + center.z + MONSTER_SEARCH_XZ_RANGE, + ); + world.has_entity_in_aabb_matching(&search, |entity| { + entity + .as_monster() + .is_some_and(|monster| monster.is_preventing_player_rest(&world, self)) + }) + } +} + impl Player { pub(super) fn bed_rule_value_allows_in_world(world: &World, value: BedRuleValue) -> bool { match value { @@ -183,8 +216,17 @@ impl Player { return Err(self.bed_sleep_problem()); } - // TODO: Mirror vanilla Monster::isPreventingPlayerRest once Steel has - // the required Monster capability/class foundation. + if self.game_mode() != GameType::Creative && self.monster_prevents_rest(pos) { + return Err(BedSleepingProblem::NotSafe(Box::new( + TranslatedMessage { + key: "block.minecraft.bed.not_safe".into(), + fallback: None, + args: None, + } + .component(), + ))); + } + self.set_sleep_counter(0); if self.start_sleeping(pos).is_err() { return Err(BedSleepingProblem::OtherProblem); diff --git a/steel-core/src/player/tests.rs b/steel-core/src/player/tests.rs index f79e925a3f23..08e70d297974 100644 --- a/steel-core/src/player/tests.rs +++ b/steel-core/src/player/tests.rs @@ -6,7 +6,9 @@ use crate::behavior::{InteractionResult, init_behaviors}; use crate::chunk_saver::PersistentEntity; use crate::entity::{ DEFAULT_MAX_AIR_SUPPLY, Entity, EntitySyncedData, LivingEntity, SharedEntity, - damage::DamageSource, entities::ItemEntity, next_entity_id, + damage::DamageSource, + entities::{EndermiteEntity, ItemEntity}, + next_entity_id, }; use crate::inventory::{ click::{Click, DragKind, QuickCraft}, @@ -37,7 +39,7 @@ use steel_registry::stat::vanilla_stat_types; use steel_registry::{ RegistryHolderSet, entity_data::EntityData, init_vanilla_registry, item_stack::ItemStack, vanilla_attributes, vanilla_blocks, vanilla_custom_stats, vanilla_damage_types, - vanilla_entities, vanilla_game_rules, vanilla_items, vanilla_menu_types, + vanilla_entities, vanilla_game_rules, vanilla_items, vanilla_menu_types, vanilla_world_clocks, }; use steel_utils::codec::VarInt; use steel_utils::locks::{IntoShared as _, SyncMutex}; @@ -56,6 +58,7 @@ use super::{ game_mode::block_breaking::BlockBreakAction, lifecycle::nullable_game_mode_id, player_data::{PersistentEnderPearl, PersistentPlayerData, PersistentRootVehicle}, + sleep::BedSleepingProblem, }; const PLAYER_MAIN_HAND_METADATA_INDEX: u8 = 15; @@ -65,6 +68,8 @@ const HUMANOID_ARM_ENTITY_DATA_SERIALIZER_ID: i32 = 42; const MODEL_CUSTOMIZATION_WITH_HIGH_BIT_SET: u8 = 0xff; const CAPE_LEFT_SLEEVE_LEFT_PANTS_MASK: u8 = 0b0001_0101; +/// Midnight day time (`18_000`), deep enough that `skyDarkening` keeps monsters awake. +const NIGHT_TIME: i64 = 18_000; #[test] fn client_information_initializes_player_cosmetic_metadata() { @@ -1541,3 +1546,43 @@ fn throttle_player_dropping_items_from_creative_menu() { player.handle_set_creative_mode_slot(packet); check_drop_count(DROPS_ALLOWED_BEFORE_THROTTLE + 2); } + +#[test] +fn monster_prevents_rest_blocks_sleeping_near_a_bed() { + init_vanilla_registry(); + init_behaviors(); + let world = fresh_test_world("monster_prevents_rest"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + world.set_clock_total_ticks(&vanilla_world_clocks::OVERWORLD, NIGHT_TIME); + let bed_pos = BlockPos::new(8, 65, 8); + assert!(world.set_block( + bed_pos, + vanilla_blocks::WHITE_BED.default_state(), + UpdateFlags::UPDATE_ALL, + )); + let player = test_player(Arc::clone(&world)); + player.base.set_position_local(DVec3::new(8.5, 65.0, 8.5)); + + // Without monsters the night-time bed rule admits the player. + assert!(player.start_sleep_in_bed(bed_pos).is_ok()); + assert!(player.is_sleeping()); + player.stop_sleep_in_bed(false, false); + + // A hostile mob within the vanilla 8x5x8 rest box keeps the player up. + let endermite: SharedEntity = Arc::new(EndermiteEntity::new( + &vanilla_entities::ENDERMITE, + next_entity_id(), + DVec3::new(11.0, 65.0, 8.0), + Arc::downgrade(&world), + )); + world + .try_add_entity(Arc::clone(&endermite)) + .expect("test endermite should attach to the loaded chunk"); + + let result = player.start_sleep_in_bed(bed_pos); + assert!( + matches!(result, Err(BedSleepingProblem::NotSafe(_))), + "a monster nearby must yield the not-safe problem, got {result:?}" + ); + assert!(!player.is_sleeping()); +} diff --git a/steel-core/src/world/environment.rs b/steel-core/src/world/environment.rs index a78b93f2306a..4d68e52cb9d8 100644 --- a/steel-core/src/world/environment.rs +++ b/steel-core/src/world/environment.rs @@ -9,6 +9,9 @@ use super::clock::WorldClockManager; const SKY_LIGHT_LEVEL_ATTRIBUTE: &str = "minecraft:gameplay/sky_light_level"; const SUN_ANGLE_ATTRIBUTE: &str = "minecraft:visual/sun_angle"; +/// Vanilla `EnvironmentAttributes.MONSTERS_BURN`, the boolean environment +/// attribute the overworld's `day` timeline keeps true during daylight. +const MONSTERS_BURN_ATTRIBUTE: &str = "minecraft:gameplay/monsters_burn"; const DEFAULT_SKY_LIGHT_LEVEL: f32 = 15.0; const DEFAULT_SUN_ANGLE: f32 = 0.0; const MIN_SKY_LIGHT_LEVEL: f32 = 0.0; @@ -65,25 +68,144 @@ fn apply_timeline_float_attribute( clock_manager: &WorldClockManager, attribute: &str, ) -> f32 { + for_each_timeline(dimension_type, |timeline| { + value = apply_timeline_float_track(value, timeline, clock_manager, attribute); + }); + value +} + +/// Returns vanilla `EnvironmentAttributes.MONSTERS_BURN` for a dimension. +/// +/// The environment attribute defaults to `false`; the overworld's `day` +/// timeline turns it on during daylight through its boolean `or`-modified +/// track, so dimensions outside that timeline never burn undead mobs. +#[must_use] +pub(super) fn monsters_burn( + dimension_type: DimensionTypeRef, + clock_manager: &WorldClockManager, +) -> bool { + let mut value = false; + for_each_timeline(dimension_type, |timeline| { + value = apply_timeline_bool_track(value, timeline, clock_manager, MONSTERS_BURN_ATTRIBUTE); + }); + value +} + +/// Runs `visit` over every timeline the dimension references, whether a single +/// timeline key or a `#`-prefixed timeline tag. +fn for_each_timeline(dimension_type: DimensionTypeRef, mut visit: impl FnMut(TimelineRef)) { let Some(timelines) = dimension_type.timelines else { - return value; + return; }; if let Some(tag) = timelines.strip_prefix('#') { let Ok(tag) = Identifier::from_str(tag) else { - return value; + return; }; for timeline in REGISTRY.timelines.iter_tag(&tag) { - value = apply_timeline_float_track(value, timeline, clock_manager, attribute); + visit(timeline); } - return value; + return; } let Ok(key) = Identifier::from_str(timelines) else { + return; + }; + if let Some(timeline) = REGISTRY.timelines.by_key(&key) { + visit(timeline); + } +} + +fn apply_timeline_bool_track( + value: bool, + timeline: TimelineRef, + clock_manager: &WorldClockManager, + attribute: &str, +) -> bool { + let Some(track) = timeline.tracks.iter().find(|track| track.name == attribute) else { return value; }; - REGISTRY.timelines.by_key(&key).map_or(value, |timeline| { - apply_timeline_float_track(value, timeline, clock_manager, attribute) - }) + let Some(total_ticks) = clock_manager.total_ticks(timeline.clock) else { + return value; + }; + let Some(sample) = sample_bool_track(track, timeline.period_ticks.map(i64::from), total_ticks) + else { + return value; + }; + match track.modifier { + Some("or") => value || sample, + Some("and") => value && sample, + None => sample, + _ => value, + } +} + +/// Samples a boolean timeline track. +/// +/// Vanilla registers boolean environment attributes as non-interpolated +/// (`AttributeType.ofNotInterpolated`), whose constant lerp returns the segment +/// start until the destination keyframe tick is reached exactly. The easing is +/// therefore irrelevant: the value holds and then snaps at each keyframe. +fn sample_bool_track(track: &Track, period_ticks: Option, ticks: i64) -> Option { + let keyframes = track.keyframes; + match keyframes.len() { + 0 => return None, + 1 => return keyframe_bool_value(&keyframes[0].value), + _ => {} + } + + let sample_ticks = period_ticks.map_or(ticks, |period| ticks.rem_euclid(period)); + let first = &keyframes[0]; + let last = &keyframes[keyframes.len() - 1]; + + if let Some(period) = period_ticks + && sample_ticks < first.ticks + { + return step_bool_segment( + last.ticks - period, + &last.value, + first.ticks, + &first.value, + sample_ticks, + ); + } + + for segment in keyframes.windows(2) { + let from = &segment[0]; + let to = &segment[1]; + if sample_ticks < to.ticks { + return step_bool_segment(from.ticks, &from.value, to.ticks, &to.value, sample_ticks); + } + } + + if let Some(period) = period_ticks { + return step_bool_segment( + last.ticks, + &last.value, + first.ticks + period, + &first.value, + sample_ticks, + ); + } + + keyframe_bool_value(&last.value) +} + +fn step_bool_segment( + from_ticks: i64, + from_value: &KeyframeValue, + to_ticks: i64, + to_value: &KeyframeValue, + sample_ticks: i64, +) -> Option { + let from = keyframe_bool_value(from_value)?; + let to = keyframe_bool_value(to_value)?; + if sample_ticks <= from_ticks { + return Some(from); + } + if sample_ticks >= to_ticks { + return Some(to); + } + Some(from) } fn apply_timeline_float_track( @@ -273,6 +395,13 @@ const fn keyframe_float_value(value: &KeyframeValue) -> Option { } } +const fn keyframe_bool_value(value: &KeyframeValue) -> Option { + match value { + KeyframeValue::Bool(value) => Some(*value), + _ => None, + } +} + fn apply_weather_sky_light_level(mut value: f32, rain_level: f32, thunder_level: f32) -> f32 { let thunder_level = thunder_level.clamp(0.0, 1.0); let rain_level = (rain_level - thunder_level).clamp(0.0, 1.0); @@ -441,4 +570,42 @@ mod tests { assert_eq!(sky_darkening(11.5625), 3); assert_eq!(sky_darkening(4.0), 11); } + + #[test] + fn monsters_burn_follows_the_generated_daylight_window() { + init_vanilla_registry(); + + // The overworld `day` timeline's `monsters_burn` track snaps true at + // sunrise (23460) and false at sunset (12542), so it holds true across + // the whole daylight stretch including the period wrap at midnight. + for ticks in [0, 6_000, 12_000, 12_541, 23_460, 23_999] { + assert!( + monsters_burn(&OVERWORLD, &clock_manager_at(ticks)), + "monsters_burn should hold at tick {ticks}" + ); + } + for ticks in [12_542, 18_000, 23_000, 23_459] { + assert!( + !monsters_burn(&OVERWORLD, &clock_manager_at(ticks)), + "monsters_burn should be off at tick {ticks}" + ); + } + } + + #[test] + fn monsters_burn_snaps_at_keyframes_instead_of_interpolating() { + init_vanilla_registry(); + + // Boolean environment attributes are non-interpolated: the value holds + // until the destination keyframe tick is reached exactly, then snaps. + assert!(!monsters_burn(&OVERWORLD, &clock_manager_at(23_459))); + assert!(monsters_burn(&OVERWORLD, &clock_manager_at(23_460))); + } + + #[test] + fn monsters_burn_is_off_in_dimensions_without_the_day_timeline() { + init_vanilla_registry(); + + assert!(!monsters_burn(&THE_NETHER, &clock_manager_at(6_000))); + } } diff --git a/steel-core/src/world/level_reader.rs b/steel-core/src/world/level_reader.rs index 98727ef410b3..b956f12b6d31 100644 --- a/steel-core/src/world/level_reader.rs +++ b/steel-core/src/world/level_reader.rs @@ -56,7 +56,20 @@ pub trait LevelReader { /// Returns vanilla raw brightness at a position after sky darkening. fn raw_brightness(&self, pos: BlockPos, sky_darkening: u8) -> u8; - /// Returns vanilla `BlockAndLightGetter.canSeeSky`. + /// Returns vanilla `LevelReader.getSkyDarken` (`15 - SKY_LIGHT_LEVEL`). + /// + /// Lightweight and worldgen views default to `0`, mirroring vanilla + /// `WorldGenRegion.getSkyDarken`; the live `World` overrides this with its + /// environment-attribute value. + fn sky_darkening(&self) -> u8 { + 0 + } + + /// Returns whether the sky layer at `pos` holds full light. + /// + /// Mirrors vanilla `BlockAndLightGetter.canSeeSky`; lightweight and worldgen + /// views approximate it from raw brightness, while the live `World` + /// overrides this with the sky-layer light value. fn can_see_sky(&self, pos: BlockPos) -> bool { self.raw_brightness(pos, 0) >= MAX_LIGHT_LEVEL } @@ -91,9 +104,12 @@ pub trait LevelReader { self.raw_brightness(pos, sky_darkening) } - /// Returns vanilla `LevelReader.getLightLevelDependentMagicValue`. + /// Returns vanilla `LevelReader.getLightLevelDependentMagicValue`, whose + /// brightness comes from the no-arg `getMaxLocalRawBrightness`, i.e. the + /// current `getSkyDarken`. fn light_level_dependent_magic_value(&self, pos: BlockPos) -> f32 { - let value = f32::from(self.max_local_raw_brightness(pos, 0)) / f32::from(MAX_LIGHT_LEVEL); + let value = f32::from(self.max_local_raw_brightness(pos, self.sky_darkening())) + / f32::from(MAX_LIGHT_LEVEL); let curved_value = value / value.mul_add(-3.0, 4.0); curved_value + self.ambient_light() * (1.0 - curved_value) } @@ -185,6 +201,7 @@ mod tests { struct TestLevel { raw_brightness: u8, ambient_light: f32, + sky_darkening: u8, } impl LevelReader for TestLevel { @@ -192,8 +209,12 @@ mod tests { BlockStateId(0) } - fn raw_brightness(&self, _pos: BlockPos, _sky_darkening: u8) -> u8 { - self.raw_brightness + fn raw_brightness(&self, _pos: BlockPos, sky_darkening: u8) -> u8 { + self.raw_brightness.saturating_sub(sky_darkening) + } + + fn sky_darkening(&self) -> u8 { + self.sky_darkening } fn ambient_light(&self) -> f32 { @@ -221,6 +242,7 @@ mod tests { let level = TestLevel { raw_brightness: 6, ambient_light: 0.0, + sky_darkening: 0, }; assert_f32_close( @@ -234,6 +256,7 @@ mod tests { let level = TestLevel { raw_brightness: 6, ambient_light: 0.2, + sky_darkening: 0, }; assert_f32_close( @@ -247,6 +270,7 @@ mod tests { let level = TestLevel { raw_brightness: 0, ambient_light: 0.0, + sky_darkening: 0, }; assert_eq!( @@ -259,12 +283,29 @@ mod tests { ); } + #[test] + fn light_level_dependent_magic_value_applies_current_sky_darkening() { + let level = TestLevel { + raw_brightness: 15, + ambient_light: 0.0, + sky_darkening: 11, + }; + + // Brightness is max(block, sky - darkening); raw 15 with darkening 11 + // leaves 4, curving to 4/15 -> ~0.083 instead of the full 1.0. + assert_f32_close( + level.light_level_dependent_magic_value(BlockPos::ZERO), + 0.083_333_33, + ); + } + #[test] fn can_see_sky_uses_vanilla_sky_light_threshold() { assert!( TestLevel { raw_brightness: 15, ambient_light: 0.0, + sky_darkening: 0, } .can_see_sky(BlockPos::ZERO) ); @@ -272,6 +313,7 @@ mod tests { !TestLevel { raw_brightness: 14, ambient_light: 0.0, + sky_darkening: 0, } .can_see_sky(BlockPos::ZERO) ); diff --git a/steel-core/src/world/mod.rs b/steel-core/src/world/mod.rs index 3c56eba74f82..b4d40297eec1 100644 --- a/steel-core/src/world/mod.rs +++ b/steel-core/src/world/mod.rs @@ -664,6 +664,10 @@ impl LevelReader for World { .is_face_sturdy(state, self, pos, direction, support_type) } + fn sky_darkening(&self) -> u8 { + World::sky_darkening(self) + } + fn raw_brightness(&self, pos: BlockPos, sky_darkening: u8) -> u8 { let sky_light = if self.dimension_type.has_skylight { self.light_value_at(LightLayer::Sky, pos) @@ -716,6 +720,10 @@ impl LevelReader for Arc { .is_face_sturdy_for(state, pos, direction, support_type) } + fn sky_darkening(&self) -> u8 { + self.as_ref().sky_darkening() + } + fn raw_brightness(&self, pos: BlockPos, sky_darkening: u8) -> u8 { self.as_ref().raw_brightness(pos, sky_darkening) } diff --git a/steel-core/src/world/tests.rs b/steel-core/src/world/tests.rs index e5b08c4893fa..592a2e933f37 100644 --- a/steel-core/src/world/tests.rs +++ b/steel-core/src/world/tests.rs @@ -569,6 +569,77 @@ fn clip_local_aabb_supports_runtime_fluid_heights() { assert_vec3_close(hit.location, DVec3::new(0.5, 0.5, 0.5)); } +#[test] +fn can_see_sky_requires_full_sky_light_like_vanilla_26_2() { + init_vanilla_registry(); + init_behaviors(); + let world = fresh_test_world("can_see_sky_light"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + let pos = BlockPos::new(8, 65, 8); + + // 26.2 stores sky light un-dimmed: open air holds full sky light, so the + // position sees the sky regardless of the time of day. + assert!(world.can_see_sky(pos)); + + // A solid roof attenuates the sky layer below full: no longer sky-visible. + assert!(world.set_block( + pos.above(), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + // The world tick loop drains queued light updates; tests must do so manually. + world.chunk_map.broadcast_changed_chunks(); + assert!(!world.can_see_sky(pos)); +} + +#[test] +fn precipitation_sky_exposure_requires_light_and_heightmap_gates() { + init_vanilla_registry(); + init_behaviors(); + let world = fresh_test_world("precipitation_sky_exposure"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + let pos = BlockPos::new(8, 65, 8); + + // Open sky: vanilla `precipitationAt` gates (light-based canSeeSky plus + // the motion-blocking heightmap) both pass. + assert!(world.can_see_sky_for_precipitation(pos)); + + // A solid roof is rejected by the light gate. + assert!(world.set_block( + pos.above(), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_ALL, + )); + world.chunk_map.broadcast_changed_chunks(); + assert!(!world.can_see_sky_for_precipitation(pos)); + + // A light-transmitting glass roof keeps full sky light but still fails the + // motion-blocking heightmap gate. + assert!(world.set_block( + pos.above(), + vanilla_blocks::GLASS.default_state(), + UpdateFlags::UPDATE_ALL, + )); + world.chunk_map.broadcast_changed_chunks(); + assert!(world.can_see_sky(pos)); + assert!(!world.can_see_sky_for_precipitation(pos)); + + // Water attenuates sky light below full: the light gate rejects the + // position even though the heightmap column stays unobstructed. + assert!(world.set_block( + pos.above(), + vanilla_blocks::AIR.default_state(), + UpdateFlags::UPDATE_ALL, + )); + assert!(world.set_block( + pos, + vanilla_blocks::WATER.default_state(), + UpdateFlags::UPDATE_ALL, + )); + world.chunk_map.broadcast_changed_chunks(); + assert!(!world.can_see_sky_for_precipitation(pos)); +} + #[test] fn fluid_clip_height_treats_source_and_flowing_variants_as_same_fluid_above() { init_vanilla_registry(); diff --git a/steel-core/src/world/weather.rs b/steel-core/src/world/weather.rs index 96f0f7d9c16f..fdf9266419c9 100644 --- a/steel-core/src/world/weather.rs +++ b/steel-core/src/world/weather.rs @@ -17,6 +17,8 @@ use super::{ obfuscate_biome_seed, vanilla_dimension_types, }; +use crate::chunk::light::MAX_LIGHT_LEVEL; + static BIOME_TEMPERATURE_NOISE: LazyLock = LazyLock::new(|| { let mut random = RandomSource::Legacy(LegacyRandom::from_seed(1234)); PerlinSimplexNoise::new(&mut random, &[0]) @@ -257,6 +259,12 @@ impl World { environment::sun_angle_degrees(self.dimension_type, level_data.world_clocks()) } + /// Returns the current vanilla `MONSTERS_BURN` environment attribute. + pub fn monsters_burn(&self) -> bool { + let level_data = self.level_data.read(); + environment::monsters_burn(self.dimension_type, level_data.world_clocks()) + } + /// Returns sky-layer light after the current sky darkening is subtracted. /// /// Mirrors vanilla `LevelReader.getEffectiveSkyBrightness` without allowing @@ -286,22 +294,29 @@ impl World { && self.dimension_type.key != vanilla_dimension_types::THE_END.key } - /// Returns whether the position has unobstructed sky exposure. - /// - /// Live worlds use the motion-blocking heightmap until Steel has a full - /// live sky-light engine. + /// Returns vanilla `BlockAndLightGetter.canSeeSky`: the sky layer at `pos` + /// holds full light. 26.2 stores sky light un-dimmed (time-of-day darkening + /// applies at read time), so this is true in the open at any time of day + /// and false under roofs that attenuate sky light. pub fn can_see_sky(&self, pos: BlockPos) -> bool { - if !self.dimension_type.has_skylight { + self.light_value_at(LightLayer::Sky, pos) >= MAX_LIGHT_LEVEL + } + + /// Returns whether the position is exposed to the sky for precipitation. + /// + /// Mirrors vanilla `Level.precipitationAt`: light-based `canSeeSky` and the + /// motion-blocking heightmap gate must both pass. The light gate rejects + /// positions whose sky light is attenuated by non-motion-blocking blocks + /// (e.g. underwater); the heightmap gate rejects positions under roofs that + /// transmit light, such as glass. + pub(super) fn can_see_sky_for_precipitation(&self, pos: BlockPos) -> bool { + if !self.can_see_sky(pos) { return false; } self.height_at(HeightmapType::MotionBlocking, pos.x(), pos.z()) .is_some_and(|height| height <= pos.y()) } - pub(super) fn can_see_sky_for_precipitation(&self, pos: BlockPos) -> bool { - self.can_see_sky(pos) - } - pub(crate) fn biome_at(&self, pos: BlockPos) -> Option { let biome_zoom_seed = obfuscate_biome_seed(self.seed()); let mut missing_chunk = false;