Merge pull request #10075 from Ragbie/fix/nautilus-sight-armadillo-panic

A nautilus only goes for a pufferfish it sees, and frost panics armadillos
This commit is contained in:
Kacper authored and GitHub committed 2026-11-06 09:15:15 +01:00
commit 46e88c47b1
5 files changed
+210 -33

No files matched your search

+3 -1
View File
@@ -2030,7 +2030,9 @@ fn species_ai_step(
super::cube::ai_step(m, w, events);
super::sulfur_cube::ai_step(m, w, events);
}
Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events),
Species::Nautilus | Species::ZombieNautilus => {
super::nautilus::ai_step(m, w, level_random, events)
}
Species::Chicken => farm::chicken_ai_step(m, w, events),
Species::Parrot => super::parrot::calculate_flapping(m),
Species::Armadillo => super::armadillo::tick(m),
@@ -272,6 +272,8 @@ pub fn environmental(kind: DamageKind) -> bool {
| DamageKind::Lava
| DamageKind::LightningBolt
| DamageKind::HotFloor
| DamageKind::Freeze
| DamageKind::SulfurCubeHot
)
}
/// The hurt sound: a ball's is muffled, and it has no ambient sound
@@ -806,3 +808,44 @@ mod tests {
assert!(!environmental(DamageKind::Campfire));
}
}
#[cfg(test)]
mod panic_environmental_tests {
use super::*;
/// `#panic_environmental_causes` from the vanilla data: every type in it
/// panics the armadillo (`Armadillo.actuallyHurt` rolls out).
#[test]
fn the_environmental_causes_are_vanillas_tag() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
.join("data/minecraft/tags/damage_type/panic_environmental_causes.json");
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let json: serde_json::Value = serde_json::from_str(&text).unwrap();
let mut ids: Vec<&str> = json["values"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
ids.sort_unstable();
let kinds = [
("minecraft:cactus", DamageKind::Cactus),
("minecraft:freeze", DamageKind::Freeze),
("minecraft:hot_floor", DamageKind::HotFloor),
("minecraft:in_fire", DamageKind::InFire),
("minecraft:lava", DamageKind::Lava),
("minecraft:lightning_bolt", DamageKind::LightningBolt),
("minecraft:on_fire", DamageKind::OnFire),
("minecraft:sulfur_cube_hot", DamageKind::SulfurCubeHot),
];
assert_eq!(ids, kinds.map(|(id, _)| id).to_vec());
for (id, kind) in kinds {
assert!(environmental(kind), "{id}");
}
assert!(!environmental(DamageKind::Drown));
}
}
+110 -10
View File
@@ -276,7 +276,12 @@ fn check_restriction(m: &mut Mob) {
/// `AbstractNautilus.tick` and the brains' memories: the rider's breath, the
/// dash clock, bubbles, and choosing whom to charge at
/// (`NautilusAi.findNearestValidAttackTarget`).
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
pub fn ai_step(
m: &mut Mob,
w: &MobWorld,
level_random: &mut horizon_core::random::LegacyRandom,
events: &mut Vec<MobEvent>,
) {
// customServerAiStep: the home is kept before the rest of the step.
check_restriction(m);
if m.extra.scratch[1] != 0 {
@@ -321,7 +326,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.anger -= 1;
}
if m.ai.target.is_none() && m.alive() && !m.no_ai {
m.ai.target = find_target(m, w);
m.ai.target = find_target(m, w, level_random);
}
}
/// `spawnBubbles`: more as it swims faster, from behind its mouth.
@@ -375,7 +380,11 @@ pub fn aggravated(m: &Mob) -> bool {
}
/// `NautilusAi.findNearestValidAttackTarget`: whoever it is angry at, else
/// now and then (a coin flip every 2400 to 3600 ticks) the nearest pufferfish.
fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
fn find_target(
m: &mut Mob,
w: &MobWorld,
level_random: &mut horizon_core::random::LegacyRandom,
) -> Option<Who> {
if m.extra.in_love > 0 || !m.fluids.in_water || m.is_baby() || m.extra.tame {
return None;
}
@@ -391,15 +400,26 @@ fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
if m.extra.counter > 0 {
return None;
}
m.extra.counter = 2400 + m.random.next_int_bound(1201).unwrap_or(0);
if m.random.next_float() < 0.5 {
// The cooldown and the coin come from the level's random (`level.getRandom()`).
m.extra.counter = 2400 + level_random.next_int_bound(1201).unwrap_or(0);
if level_random.next_float() < 0.5 {
return None;
}
w.nearby(m.position, 16.0, |s| {
s.species == Species::Pufferfish && in_water_at(w, s.position)
})
.next()
.map(|s| Who::Mob(s.id))
// NEAREST_VISIBLE_LIVING_ENTITIES.findClosest(isHostileTarget): the
// nearest #nautilus_hostiles in water that the nautilus can see
// (`Sensor.isEntityTargetable`: line of sight).
let mut near: Vec<(f64, u32)> = w
.nearby(m.position, 16.0, |s| {
s.species == Species::Pufferfish && in_water_at(w, s.position)
})
.map(|s| (s.position.distance_squared(m.position), s.id))
// TargetingConditions.range(16): feet to feet.
.filter(|&(d, _)| d <= 16.0 * 16.0)
.collect();
near.sort_by(|a, b| a.0.total_cmp(&b.0));
near.into_iter()
.map(|(_, id)| Who::Mob(id))
.find(|&who| m.sees(w, who))
}
/// `hurtServer`: `NautilusAi.setAngerTarget` for 400 ticks.
pub fn after_hurt(m: &mut Mob, source: &super::Damage) {
@@ -790,3 +810,83 @@ mod tests {
assert_eq!(m.rider.expect("the rider stays on").jump, 0.0);
}
}
#[cfg(test)]
mod hostile_target_tests {
use super::*;
use std::sync::Arc;
/// A pool from x -8 to 8, with a stone wall at x = 3 when `wall`.
fn pool(
wall: bool,
) -> impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry) {
move |world, registry| {
let water = registry.default_state("minecraft:water").unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
for cx in -1..2 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -8..=18 {
for y in 60..=66 {
for z in -4..=4 {
let block = if wall && x == 3 { stone } else { water };
world.set(x, y, z, block).unwrap();
}
}
}
}
}
/// `NautilusAi.findNearestValidAttackTarget`: past the cooldown and the
/// level random's 1 in 2, the target is the closest #nautilus_hostiles in
/// water among NEAREST_VISIBLE_LIVING_ENTITIES, so not a pufferfish behind
/// stone. Each seed is a fresh level random.
fn targets(wall: bool, fish_at: Vec3) -> Option<Vec<Option<Who>>> {
let mut out = None;
let mut fish = None;
crate::mobs::world::with_built_test_world(
&[],
|_, _| {},
|_, types| {
let kind = Arc::new(types.types["minecraft:pufferfish"].clone());
fish = Some(Mob::new(2, kind, fish_at, 0.0).seen(None));
},
);
let fish = fish?;
crate::mobs::world::with_built_test_world(&[fish], pool(wall), |w, types| {
let kind = Arc::new(types.types["minecraft:nautilus"].clone());
let picks = (0..16)
.map(|seed| {
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 62.0, 0.5), 270.0);
m.fluids.in_water = true;
let mut level = horizon_core::random::LegacyRandom::new(seed);
find_target(&mut m, w, &mut level)
})
.collect();
out = Some(picks);
});
out
}
#[test]
fn a_nautilus_only_targets_a_pufferfish_it_sees() {
let near = Vec3::new(6.5, 62.0, 0.5);
// 16 out on x and 3 on z: inside the sensor's box, past range(16).
let far = Vec3::new(16.5, 62.0, 3.5);
let (Some(open), Some(walled), Some(beyond)) = (
targets(false, near),
targets(true, near),
targets(false, far),
) else {
return;
};
assert!(open.contains(&Some(Who::Mob(2))), "{open:?}");
assert!(open.iter().all(|t| t.is_none() || *t == Some(Who::Mob(2))));
assert!(walled.iter().all(Option::is_none), "{walled:?}");
assert!(beyond.iter().all(Option::is_none), "{beyond:?}");
}
}
+27 -11
View File
@@ -6,7 +6,7 @@
//! (`MAIN_GENE_ID`, `HIDDEN_GENE_ID`); the rest is [`State`], whose `flags`,
//! `unhappy`, `sneeze` and `eat` are the synched data a server sends.
use super::{
DamageKind, Mob, MobEvent, MobWorld, Species, Who,
Mob, MobEvent, MobWorld, Species, Who,
body_anim::Panda as Pose,
finalize::{self, Attr},
goals::{
@@ -708,19 +708,11 @@ impl Goal for PandaPanic {
self.0.stop(m, cx)
}
}
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES`, or burning.
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (`armadillo::environmental`), or burning.
fn environmental(m: &Mob) -> bool {
m.fire_ticks > 0 && !m.kind.flags.fire_immune
|| m.last_damage.is_some_and(|(d, t)| {
matches!(
d.kind,
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Cactus
| DamageKind::LightningBolt
| DamageKind::HotFloor
) && i64::from(m.age) - t <= 40
super::armadillo::environmental(d.kind) && i64::from(m.age) - t <= 40
})
}
@@ -1095,3 +1087,27 @@ pub fn register(m: &mut Mob) {
),
);
}
#[cfg(test)]
mod freeze_panic_tests {
use super::*;
/// `#panic_environmental_causes` holds freeze and sulfur_cube_hot too.
#[test]
fn freezing_and_a_hot_sulfur_cube_are_environmental() {
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
for cause in [
super::super::DamageKind::Freeze,
super::super::DamageKind::SulfurCubeHot,
] {
let mut m = Mob::new(
1,
std::sync::Arc::new(kind.clone()),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
m.last_damage = Some((super::super::Damage::of(cause), i64::from(m.age)));
assert!(environmental(&m), "{cause:?}");
}
}
}
+27 -11
View File
@@ -9,7 +9,7 @@
use horizon_core::{geometry::Vec3, random::RandomSource};
use super::{
DamageKind, Mob, MobWorld, Species, Who,
Mob, MobWorld, Species, Who,
goals::{
Cx, Float, FollowParent, Goal, HurtByTarget, LookAtPlayer, MOVE, MeleeAttack,
NearestTarget, Panic, RandomLookAround, Stroll, goal_clone,
@@ -88,20 +88,12 @@ impl Goal for PolarBearPanic {
self.0.stop(m, cx)
}
}
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (fire, lava, cactus), or
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (`armadillo::environmental`), or
/// burning.
fn environmental_panic(m: &Mob) -> bool {
m.fire_ticks > 0 && !m.kind.flags.fire_immune
|| m.last_damage.is_some_and(|(d, t)| {
matches!(
d.kind,
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Cactus
| DamageKind::LightningBolt
| DamageKind::HotFloor
) && i64::from(m.age) - t <= 40
super::armadillo::environmental(d.kind) && i64::from(m.age) - t <= 40
})
}
@@ -276,3 +268,27 @@ mod tests {
assert_eq!(m.dims().1, f64::from(base_h * (1.0 + 1.0 / STAND_TICKS)));
}
}
#[cfg(test)]
mod freeze_panic_tests {
use super::*;
/// `#panic_environmental_causes` holds freeze and sulfur_cube_hot too.
#[test]
fn freezing_and_a_hot_sulfur_cube_are_environmental() {
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
for cause in [
super::super::DamageKind::Freeze,
super::super::DamageKind::SulfurCubeHot,
] {
let mut m = Mob::new(
1,
std::sync::Arc::new(kind.clone()),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
m.last_damage = Some((super::super::Damage::of(cause), i64::from(m.age)));
assert!(environmental_panic(&m), "{cause:?}");
}
}
}