Fix animal age clocks with NoAI

Run ageable updates outside server AI while preserving growth hooks and age locking. Cover age signs, AI modes, and locks in the mob regression and parity harness.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-02 14:51:30 +01:00
1 parent 0f0fa62dc2
commit 72b58bd112
4 files changed
+147 -53

No files matched your search

@@ -753,6 +753,9 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
if let Some((acts, mem)) = brain {
let _ = write!(out, "\tact {acts}\tmem {mem}");
}
if horizon_client::mobs::finalize::ageable(m.species()) {
let _ = write!(out, "\tage {}", m.extra.age);
}
if m.species() == Species::Villager {
let _ = write!(out, "\tvx {}", villager(m));
}
@@ -1026,6 +1029,7 @@ pub fn main() -> horizon_client::Result<()> {
m.rider = Some(horizon_client::mobs::equine::Rider::default());
}
"noai" => m.no_ai = true,
"agelocked" => m.extra.age_locked = true,
// Warden.finalizeSpawn's own part.
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
&mut m,
+133 -53
View File
@@ -636,6 +636,7 @@ pub fn tick(
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
ageable_step(m, w, level_random, events);
// AbstractHorse.tick runs the stand counter down with or without AI.
if super::equine::is_equine(m.species())
&& !super::camel::is_camel(m.species())
@@ -1263,6 +1264,68 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
fn turtle_ambient_silent(m: &Mob) -> bool {
m.fluids.in_water || !m.on_ground || m.is_baby()
}
/// `AgeableMob.aiStep` runs even when `Mob.serverAiStep` is disabled.
fn ageable_step(
m: &mut Mob,
w: &MobWorld,
level_random: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
// AgeableMob.aiStep: a forced age-up's particles (`sendParticles`, so the
// players are sent them), then the age.
if m.extra.forced_age_timer > 0 {
if m.extra.forced_age_timer % 4 == 0 {
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
let r = &mut m.random;
let x = m.position.x + w * (2.0 * r.next_double() - 1.0);
let y = m.position.y + h * r.next_double() + 0.5;
let z = m.position.z + w * (2.0 * r.next_double() - 1.0);
// sendParticles(HAPPY_VILLAGER, x, y, z, 1, 0, 0, 0, 0): exactly there, at rest.
events.push(MobEvent::LevelParticles {
kind: "happy_villager",
at: [x, y, z],
spread: [0.0; 3],
speed: 0.0,
count: 1,
});
}
m.extra.forced_age_timer -= 1;
}
// AgeableMob.aiStep: an age counts only for its subclasses (a tadpole
// counts its own age up, a zombie's baby age never moves). `canAgeUp`:
// a baby grows unless an age lock holds it; a breeding pause counts down.
if m.alive() && super::finalize::ageable(m.species()) {
if can_age_up(m) {
let (bw, bh, _) = m.dims();
// AgeableMob.setAge(getAge() + 1): the crossing is where a species'
// ageBoundaryReached runs (a goat's and a hoglin's attack damage, a
// sulfur cube's size), which the age write used to skip.
let next = m.extra.age + 1;
super::finalize::set_age(m, next);
// setAge at the age boundary: refreshDimensions.
if m.extra.age >= 0 {
m.fudge_position_after_size_change(w, bw, bh);
if m.species() == Species::Villager {
super::villager_brain::age_boundary_reached(m, w, level_random, events);
}
// Turtle.ageBoundaryReached: a grown turtle drops a scute.
if m.species() == Species::Turtle {
events.push(MobEvent::GiftLoot {
mob: m.id,
table: "minecraft:gameplay/turtle_grow",
at: [m.position.x, m.position.y, m.position.z],
});
}
}
} else if m.extra.age > 0 {
m.extra.age -= 1;
}
}
if super::finalize::ageable(m.species()) {
make_age_locked_particle(m, events);
}
}
/// LivingEntity.aiStep (and Mob/AgeableMob/Animal additions).
fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &mut Vec<MobEvent>) {
if m.species() == Species::Parrot {
@@ -1337,59 +1400,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
if m.kind.flags.climber {
m.climbing = m.horizontal_collision;
}
// AgeableMob.aiStep: a forced age-up's particles (`sendParticles`, so the
// players are sent them), then the age.
if m.extra.forced_age_timer > 0 {
if m.extra.forced_age_timer % 4 == 0 {
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
let r = &mut m.random;
let x = m.position.x + w * (2.0 * r.next_double() - 1.0);
let y = m.position.y + h * r.next_double() + 0.5;
let z = m.position.z + w * (2.0 * r.next_double() - 1.0);
// sendParticles(HAPPY_VILLAGER, x, y, z, 1, 0, 0, 0, 0): exactly there, at rest.
events.push(MobEvent::LevelParticles {
kind: "happy_villager",
at: [x, y, z],
spread: [0.0; 3],
speed: 0.0,
count: 1,
});
}
m.extra.forced_age_timer -= 1;
}
// AgeableMob.aiStep: an age counts only for its subclasses (a tadpole
// counts its own age up, a zombie's baby age never moves). `canAgeUp`:
// a baby grows unless an age lock holds it; a breeding pause counts down.
if m.alive() && super::finalize::ageable(m.species()) {
if can_age_up(m) {
let (bw, bh, _) = m.dims();
// AgeableMob.setAge(getAge() + 1): the crossing is where a species'
// ageBoundaryReached runs (a goat's and a hoglin's attack damage, a
// sulfur cube's size), which the age write used to skip.
let next = m.extra.age + 1;
super::finalize::set_age(m, next);
// setAge at the age boundary: refreshDimensions.
if m.extra.age >= 0 {
m.fudge_position_after_size_change(w, bw, bh);
if m.species() == Species::Villager {
super::villager_brain::age_boundary_reached(m, w, level_random, events);
}
// Turtle.ageBoundaryReached: a grown turtle drops a scute.
if m.species() == Species::Turtle {
events.push(MobEvent::GiftLoot {
mob: m.id,
table: "minecraft:gameplay/turtle_grow",
at: [m.position.x, m.position.y, m.position.z],
});
}
}
} else if m.extra.age > 0 {
m.extra.age -= 1;
}
}
if super::finalize::ageable(m.species()) {
make_age_locked_particle(m, events);
}
ageable_step(m, w, level_random, events);
if m.alive() {
// Animal.aiStep
if m.extra.age != 0 {
@@ -3431,6 +3442,75 @@ mod age_up_tests {
use super::*;
use std::sync::Arc;
/// Official Java 26.3: NoAI leaves both age clocks running. AgeLocked
/// holds a baby's age, but a positive breeding cooldown still expires.
#[test]
fn age_clocks_tick_with_no_ai_and_respect_baby_age_lock() {
let ran = crate::mobs::world::with_test_world(&[], |w, types| {
for species in ["pig", "cow", "horse", "turtle"] {
for no_ai in [true, false] {
for locked in [false, true] {
for age in [-2, 2] {
let kind =
Arc::new(types.types[&format!("minecraft:{species}")].clone());
let mut m = Mob::new(1, kind, Vec3::new(4.0, 220.0, 4.0), 0.0);
m.no_ai = no_ai;
m.no_gravity = true;
m.persistent = true;
m.extra.age_locked = locked;
super::super::finalize::set_age(&mut m, age);
let (mut random, mut events) = (LegacyRandom::new(0), Vec::new());
let expected = if age > 0 {
[1, 0, 0, 0]
} else if locked {
[-2, -2, -2, -2]
} else {
[-1, 0, 0, 0]
};
for (elapsed, expected_age) in expected.into_iter().enumerate() {
let previous_age = m.extra.age;
let previous_position = m.position;
tick(&mut m, w, &mut random, &mut events);
assert_eq!(
m.extra.age,
expected_age,
"{species}: NoAI={no_ai}, AgeLocked={locked}, initial Age={age}, tick={}",
elapsed + 1
);
if no_ai {
assert_eq!(m.velocity, Vec3::ZERO);
// Growing dimensions can reposition the mob at adulthood.
if previous_age != -1 || expected_age != 0 {
assert_eq!(m.position, previous_position);
}
}
}
if species == "turtle" && age < 0 && !locked {
assert_eq!(
events
.iter()
.filter(|event| matches!(
event,
MobEvent::GiftLoot {
table: "minecraft:gameplay/turtle_grow",
..
}
))
.count(),
1,
"growing up drops one scute"
);
}
}
}
}
}
});
if !ran {
eprintln!("age clock regression needs prepared vanilla resources");
}
}
/// A baby goat `age` ticks from adulthood, from no vanilla data.
fn baby_goat(age: i32) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
@@ -585,6 +585,7 @@ public final class VanillaMobAiOracle {
new net.minecraft.world.item.ItemStack(BuiltInRegistries.ITEM.getValue(
net.minecraft.resources.Identifier.parse("minecraft:red_harness"))));
case "noai" -> mob.setNoAi(true);
case "agelocked" -> method(AgeableMob.class, "setAgeLocked", boolean.class).invoke(mob, true);
case "profession" -> {
Villager villager = (Villager) mob;
villager.setVillagerData(villager.getVillagerData().withProfession(level.registryAccess(),
@@ -1109,6 +1110,7 @@ public final class VanillaMobAiOracle {
mem.sort(null);
sb.append("\tmem ").append(String.join(" ", mem));
}
if (m instanceof AgeableMob ageable) sb.append("\tage ").append(ageable.getAge());
if (m instanceof Villager villager) sb.append("\tvx ").append(villager(villager));
if (m instanceof net.minecraft.world.entity.animal.golem.CopperGolem golem) {
Object opened = get(golem, "openedChestPos");
+8
View File
@@ -68,6 +68,14 @@ def scenarios():
add('skeleton_day_burn', 120, ['mob minecraft:skeleton 0.5 -60 0.5 0 32 persistent'])
# NoAI only skips serverAiStep: Mob.aiStep's burnUndead still sets a NoAI zombie alight (#3430).
add('zombie_noai_day_burn', 120, ['mob minecraft:zombie 0.5 -60 0.5 0 33 persistent noai'])
# AgeableMob.aiStep runs with NoAI, including both signs and age-lock controls.
for species in ('pig', 'cow', 'horse', 'turtle'):
for age in (-2, 2):
for noai in (False, True):
for locked in (False, True):
flags = (' noai' if noai else '') + (' agelocked' if locked else '')
add(f'{species}_age_{age}_noai_{noai}_locked_{locked}', 4,
[f'mob minecraft:{species} 0.5 -60 0.5 0 34 persistent age={age}{flags}'])
# The wither (summoned, not built: no invulnerable spawn phase).
add('wither_player', 200, ['player 10.5 -60 0.5', 'mob minecraft:wither 0.5 -58 0.5 0 41 persistent'])
# A villager with a job site, a bed and a bell.