Merge pull request #11515 from horizon-rust/fix/mob-entity-collisions-10445

🤖 Agent: mobs path-stop at shulkers and a held-still happy ghast; earlier parity failures were env + main's /say regression (#11687 fixed it); CI parity reruns with run-parity before landing.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 10:50:43 +01:00
commit 411d1ca42c
6 files changed
+205 -2

No files matched your search

@@ -1200,6 +1200,7 @@ pub fn main() -> horizon_client::Result<()> {
others_grid: $grid,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
game_time: world.game_time,
@@ -927,6 +927,7 @@ impl Game<'_> {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
// day.json: false from tick 12542, true from 23460 (a step track).
monsters_burn: self.dim == Dim::Overworld
@@ -1148,11 +1149,15 @@ impl Game<'_> {
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
let mut villager_slots = Vec::new();
let mut solid_slots = Vec::new();
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
if s.species == horizon_client::mobs::Species::Villager {
villager_slots.push(i as u32);
}
if s.may_block_mobs() {
solid_slots.push(i as u32);
}
}
laps.lap("creature_prepare");
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
@@ -1191,6 +1196,7 @@ impl Game<'_> {
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
others_solid: Some(&solid_slots),
nearest_player,
player: None,
teams: std::sync::Arc::clone(&base.teams),
@@ -1254,6 +1260,7 @@ impl Game<'_> {
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
others_solid: Some(&solid_slots),
difficulty,
nearest_player,
player: active_player,
@@ -8685,6 +8692,7 @@ mod pearl_tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
+30
View File
@@ -2487,8 +2487,33 @@ pub struct Seen {
pub hurt_recently: bool,
/// `Hoglin.canBeHunted`: an adult hoglin without `CannotBeHunted`.
pub huntable: bool,
/// A happy ghast's `isOnStillTimeout` (a player on its back holds it).
pub still: bool,
/// `Entity.isVehicle`: something rides it.
pub carrying: bool,
}
impl Seen {
/// Whether `canBeCollidedWith` can ever hold for this kind: the slots
/// [`MobWorld::others_solid`] lists.
pub fn may_block_mobs(&self) -> bool {
matches!(self.species, Species::Shulker | Species::HappyGhast)
}
/// `canBeCollidedWith(mover)` for a mob of species `mover` moving into
/// it (`EntityGetter.getEntityCollisions`): a living shulker
/// (`Shulker`), or an adult happy ghast held still, or carrying someone
/// when the mover is a happy ghast too (`HappyGhast`). Every other mob
/// keeps `Entity`'s false.
pub fn blocks_mob(&self, mover: Species) -> bool {
match self.species {
Species::Shulker => self.alive,
Species::HappyGhast => {
self.alive
&& !self.baby
&& (self.still || (self.carrying && mover == Species::HappyGhast))
}
_ => false,
}
}
/// `LivingEntity.hasEffect`, by id without namespace.
pub fn has_effect(&self, effect: &str) -> bool {
self.effects.contains(&effect)
@@ -2678,6 +2703,8 @@ impl Mob {
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
is_vehicle: equine::is_vehicle(m),
still: m.species() == Species::HappyGhast && happy_ghast::on_still_timeout(m),
carrying: m.rider.is_some() || m.extra.rider.is_some(),
knockback_resistance: m.attrs.knockback_resistance,
effects: m
.extra
@@ -3055,6 +3082,9 @@ pub struct MobWorld<'a> {
/// keeps them (the mob ticks: a slot keeps its mob, and its species, all
/// tick). Each zombie looking for a village read every slot.
pub others_villagers: Option<&'a [u32]>,
/// The slots of `others` that may stop a moving mob
/// ([`Seen::may_block_mobs`]), when the caller keeps them; None: any.
pub others_solid: Option<&'a [u32]>,
pub daylight: bool,
/// The `gameplay/monsters_burn` attribute: the overworld day timeline's
/// step track, false in every other dimension.
+164 -2
View File
@@ -653,6 +653,40 @@ impl Mob {
}
/// Entity.move with block collisions and step-up; updates collision flags
/// and fall distance (Entity.checkFallDamage).
/// `EntityGetter.getEntityCollisions(this, box.expandTowards(movement))`:
/// the boxes of the mobs whose `canBeCollidedWith(this)` holds (a living
/// shulker, a happy ghast held still), but not this mob's vehicle or a
/// fellow passenger (`Entity.canCollideWith`).
fn entity_collisions(&self, w: &MobWorld, movement: Vec3) -> Vec<Aabb> {
let area = self.bounds().expand_towards(movement);
if area.average_size() < 1.0e-7 {
return Vec::new();
}
let area = area.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
let mover = self.species();
let blocks = |s: &&super::Seen| {
s.id != self.id
&& s.blocks_mob(mover)
&& s.bounds.intersects(area)
&& !self.passenger_of_same_vehicle(s.id, s.vehicle)
};
match w.others_solid {
Some(slots) => slots
.iter()
.map(|&i| &w.others[i as usize])
.filter(blocks)
.map(|s| s.bounds)
.collect(),
None => w.others.iter().filter(blocks).map(|s| s.bounds).collect(),
}
}
/// `Entity.isPassengerOfSameVehicle` toward the entity `id` riding
/// `vehicle`: one rides the other, or both ride the same one.
fn passenger_of_same_vehicle(&self, id: u32, vehicle: Option<u32>) -> bool {
self.extra.mount == Some(id)
|| vehicle == Some(self.id)
|| (self.extra.mount.is_some() && vehicle == self.extra.mount)
}
pub fn do_move(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
let mut wanted = self.velocity;
// Entity.move: a block the mob is stuck in (sweet berry bush) scales
@@ -672,10 +706,19 @@ impl Mob {
..Default::default()
}),
};
// Entity.collide: the world border's walls stop the mob too.
// Entity.collide: the world border's walls stop the mob too, and so
// do the entities it may not pass through.
let border = w.border.map(|b| (b, [self.position.x, self.position.z]));
let entities = self.entity_collisions(w, wanted);
let moved = scene
.resolve_in_border(self.bounds(), wanted, step, self.on_ground, border)
.resolve_in_border_with(
self.bounds(),
wanted,
step,
self.on_ground,
border,
&entities,
)
.unwrap_or(Vec3::ZERO);
let length = moved.x * moved.x + moved.y * moved.y + moved.z * moved.z;
let wanted_length = wanted.x * wanted.x + wanted.y * wanted.y + wanted.z * wanted.z;
@@ -2132,6 +2175,7 @@ mod tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
@@ -2249,6 +2293,7 @@ mod tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
@@ -2307,6 +2352,123 @@ mod tests {
);
}
/// `Entity.collide` takes `getEntityCollisions` too: a zombie dropping
/// onto a living shulker or a happy ghast held still (a player on its
/// back) lands on it; a dead shulker, a happy ghast that is not held, or
/// a ghastling lets it through (`canBeCollidedWith`).
#[test]
fn a_falling_mob_lands_on_a_shulker_or_a_held_happy_ghast() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists()
|| !root.join("movement-properties.nbt").exists()
{
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let data = super::super::MobData::load(&root, &facts);
let types = super::super::MobTypes::load(&root).unwrap();
let biomes = crate::biome::Biomes::load(&root).unwrap();
let items = crate::items::Items::load(
&root,
registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let collision = std::sync::Arc::new(crate::collisions::load(&root, registry).unwrap());
let suffocation =
crate::mobs::suffocation::bind(collision.clone(), &motion.causes_suffocation).unwrap();
let lid_openers = std::collections::HashMap::new();
let air = registry.default_state("minecraft:air").unwrap();
let mut world = crate::world::World::new(air, 0);
world.chunks.insert(
(0, 0),
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
);
let mob = |id: u32, kind: &str, at: Vec3| {
Mob::new(id, std::sync::Arc::new(types.types[kind].clone()), at, 0.0)
};
// Where a zombie starting 3 blocks above `below`'s top ends after 20
// falling moves, with `below` in the mobs it sees.
let landing = |below: &Mob| -> f64 {
let others = [below.seen(None)];
let w = MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
suffocation: &suffocation,
data: &data,
player: None,
controlling_player_movement: None,
nearest_player: None,
others: &others,
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
day_time: 6000,
raining: false,
thundering: false,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
universal_anger: false,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: super::super::spawning::LocalDifficulty::new(2, 6000, 0, 0.0),
end: false,
world_seed: 0,
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
border: None,
};
let top = below.bounds().max_position().y;
let mut zombie = mob(1, "minecraft:zombie", Vec3::new(8.5, top + 3.0, 8.5));
for _ in 0..20 {
zombie.velocity = Vec3::new(0.0, -0.5, 0.0);
zombie.do_move(&w, &mut Vec::new());
}
zombie.position.y - top
};
let mut shulker = mob(2, "minecraft:shulker", Vec3::new(8.5, 64.0, 8.5));
assert_eq!(landing(&shulker), 0.0, "a living shulker holds it up");
shulker.health = 0.0;
assert!(landing(&shulker) < -5.0, "a dead shulker does not");
let mut ghast = mob(3, "minecraft:happy_ghast", Vec3::new(8.5, 64.0, 8.5));
assert!(
landing(&ghast) < -5.0,
"a happy ghast left alone is not solid"
);
ghast.extra.counter = 10;
assert_eq!(
landing(&ghast),
0.0,
"held still, its back holds the zombie"
);
let mut ghastling = mob(4, "minecraft:happy_ghast", Vec3::new(8.5, 64.0, 8.5));
super::super::finalize::set_baby(&mut ghastling, true);
ghastling.extra.counter = 10;
assert!(landing(&ghastling) < -5.0, "a ghastling never is");
}
/// `LivingEntity.aiStep` calls `travel` and only then
/// `applyEffectsFromBlocks`, so the lift of a bubble column is added after
/// the water's drag and is not scaled by it. A pig 1.0 high in a column of
+1
View File
@@ -512,6 +512,7 @@ pub(crate) fn with_resource_test_world(
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
@@ -236,6 +236,7 @@ mod tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,