Horses rear when hurt

AbstractHorse.hurtServer had no port: after a hurt that went through,
vanilla draws random.nextInt(3) and on 0 calls standIfPossible, which
rears the horse for 20 ticks unless canPerformRearing is false (llamas,
camels). Mob::damage_with now ends with that roll for every equine, and
make_mad shares the new stand_if_possible.

The stand counter runs down in AbstractHorse.tick, with or without AI,
but Horizon ran it down only in the AI step, so a NoAI horse that reared
from a hit stayed reared for good. A mob with no AI now runs it down too.

The mob_ai harness gets a llama_hurt scenario (the draw without the
rear), which matched for 10 ticks before and all 201 after; the probe
names MountPanic and LlamaHurtBy as vanilla does.

Fixes #3432

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-29 15:45:42 +07:00
1 parent 11494e17f9
commit a30b9525d1
5 files changed
+95 -5

No files matched your search

@@ -226,6 +226,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
"LookForPlayer" => "EndermanLookForPlayerGoal",
"HealRaiders" => "NearestHealableRaiderTargetGoal",
"MountPanic" => "MountPanicGoal",
"LlamaHurtBy" => "LlamaHurtByTargetGoal",
other => return other.to_owned(),
};
vanilla.to_owned()
+4
View File
@@ -2234,6 +2234,10 @@ impl Mob {
{
aquatic::spawn_ink(self);
}
// AbstractHorse.hurtServer: the rear roll after the hurt.
if equine::is_equine(self.species()) {
equine::after_hurt_server(self);
}
true
}
/// A splash or cloud's harming effect on this mob: the potion's
+7
View File
@@ -493,6 +493,13 @@ pub fn tick(
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
// AbstractHorse.tick runs the stand counter down with or without AI.
if super::equine::is_equine(m.species())
&& !super::camel::is_camel(m.species())
&& !super::llama::is_llama(m.species())
{
super::equine::stand_tick(m);
}
} else {
ai_step(m, w, level_random, events);
}
+77 -5
View File
@@ -283,15 +283,27 @@ impl Goal for RunAroundLikeCrazy {
make_mad(m, cx.events);
}
}
/// `AbstractHorse.standIfPossible`: `setStanding(20)` (which also stops the
/// eating) when `canPerformRearing`.
fn stand_if_possible(m: &mut Mob) {
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
}
/// `AbstractHorse.hurtServer`, after the hurt went through: one hit in three
/// rears the horse. Llamas and camels make the draw too, but never rear.
pub fn after_hurt_server(m: &mut Mob) {
if m.random.next_int_bound(3).unwrap_or(1) == 0 {
stand_if_possible(m);
}
}
/// `AbstractHorse.makeMad`: rear with the angry sound.
pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.extra.counter > 0 {
return;
}
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
stand_if_possible(m);
// getAngrySound: null (silent) for the skeleton horse.
let sound = match m.species() {
Species::SkeletonHorse => return,
@@ -591,10 +603,15 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.flag = false;
}
}
stand_tick(m);
animate(m);
}
/// `AbstractHorse.tick`: the stand counter runs down to `clearStanding`. It is
/// in `tick`, not `aiStep`, so a horse with no AI stops rearing too.
pub fn stand_tick(m: &mut Mob) {
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
animate(m);
}
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
/// eating and standing flags (`flag` and `counter > 0`).
@@ -784,3 +801,58 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::Damage;
use crate::mobs::sensor_scan_tests::kind;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn equine(id: u32, species: Species, name: &str) -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = name.into();
k.species = species;
k.max_health = 30.0;
Mob::new(id, kind, Vec3::new(f64::from(id), 64.0, 0.0), 0.0)
}
/// Hits `n` fresh mobs of a species once each; how many reared.
fn rears(species: Species, name: &str, n: u32) -> u32 {
(1..=n)
.filter(|&id| {
let mut m = equine(id, species, name);
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 0.0])));
m.extra.counter == 20
})
.count() as u32
}
#[test]
fn a_hurt_horse_rears_one_hit_in_three() {
// AbstractHorse.hurtServer: nextInt(3) == 0 -> standIfPossible (#3432).
let n = rears(Species::Horse, "minecraft:horse", 300);
assert!((60..=140).contains(&n), "{n} of 300 hits reared");
}
#[test]
fn the_rear_ends_after_twenty_ticks() {
// AbstractHorse.tick: standCounter runs down, then clearStanding.
let mut m = equine(1, Species::Horse, "minecraft:horse");
stand_if_possible(&mut m);
for _ in 0..19 {
stand_tick(&mut m);
}
assert!(m.extra.counter > 0);
stand_tick(&mut m);
assert_eq!(m.extra.counter, 0);
}
#[test]
fn llamas_and_camels_never_rear_when_hurt() {
// Llama and Camel: canPerformRearing is false.
assert_eq!(rears(Species::Llama, "minecraft:llama", 60), 0);
assert_eq!(rears(Species::Camel, "minecraft:camel", 60), 0);
}
}
+5
View File
@@ -278,6 +278,11 @@ def scenarios():
('witch', 176), ('vindicator', 177), ('zombie', 178)]:
add(f'{species}_deep_water', 160, ['fill -4 -64 -4 4 -61 4 minecraft:water',
f'mob minecraft:{species} 0.5 -62 0.5 0 {seed} persistent'])
# AbstractHorse.hurtServer (#3432): every hurt draws nextInt(3), and a 0 rears the horse
# (standIfPossible). A llama makes the draw too but never rears (canPerformRearing). A horse
# scenario waits for #2720: a rearing horse is immobile (AbstractHorse.isImmobile).
add('llama_hurt', 200, ['player 4.5 -60 0.5', 'mob minecraft:llama 0.5 -60 0.5 0 182 persistent']
+ [f'hurt {t} 0' for t in range(10, 190, 25)])
# Axolotls (#64): idle swimming, hunting a cod, and playing dead after hits, in a pool sunk into
# the ground so no water flows out of it (the probe doesn't tick fluids).
POOL = 'fill -5 -63 -5 5 -61 5 minecraft:water'