fix(mobs): a hit that bypasses armour, or isn't a horse's, wears no body piece

#5317 was merged with three deviations from vanilla 26.3 in the `ArmorHurt`
path that its review had already named (Joansitoh's agent:
`LivingEntity.java:1887`, `:1907-1909`, and `doHurtEquipment`'s amount):

- the gate was `drops::head_is_body` minus Wolf, which also matches donkey,
  mule, skeleton and zombie horse, camel, llama, nautilus, happy ghast and
  sulfur cube. `hurtArmor` is overridden by `Horse` alone — the base one is
  empty (`LivingEntity.java:1887`), `Wolf` and `Player` have their own rules —
  so those species wore a body item vanilla never touches;
- nothing tested `#bypasses_armor`, so a fall, drowning, starvation, the void
  or a magic hit wore the piece too (`hurtArmor` is called inside that gate,
  `LivingEntity.java:1907-1909`);
- the amount was `dealt`, computed after the armour, toughness and
  enchantment reductions and after the invulnerability window's subtraction,
  where vanilla hands `hurtArmor` the damage `getDamageAfterArmorAbsorb`
  received: 8 raw damage is 8/4 = 2 durability, not 1.

`Horse.hurtArmor` also asks `doHurtEquipment` for `EquipmentSlot.BODY` alone,
so the four armour slots are not this path's to wear; `hurt_mob_armor` now
wears the body piece only, which drops the slot loop with it.

`doHurtEquipment` also starts from the item's `equippable` component
(`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), which
`wearable` did not check: `equip_damage_on_hurt` defaults to true when the
component is absent (`horizon-items/src/items.rs`), so a damageable item
carrying no `equippable` — a sword put in a horse's body slot by
`/item replace` — wore out.

Tests: three cases in `mobs::armor_hurt_tests` (a donkey with a body item, a
fall on a horse, the amount before the reduction); each one fails on the
merged code and passes here. The `equippable` gate needs a live `Game`, so it
is the one part without a unit test.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
This commit is contained in:
sirrayiandDeepSeek Harness committed 2026-11-02 00:20:51 +00:00
1 parent db34ce484d
commit 779ab7dd4c
2 files changed
+128 -72

No files matched your search

+42 -63
View File
@@ -487,14 +487,15 @@ impl Game<'_> {
}
}
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
/// applied, and at its maximum the slot empties and the equipment-break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
/// piece alone (`ItemStack.canBeHurtBy`).
/// `MobEvent::ArmorHurt` reported. Only `Horse` overrides `hurtArmor` for a
/// mob, and its override asks for the BODY slot alone, so the body piece is
/// the only one this wears: it loses `max(1, damage / 4)` durability,
/// Unbreaking applied, and at its maximum the slot empties and the break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 65 for the
/// body — 49 to 52 are the four armour slots, which only a player's own
/// pieces reach). A fire hit leaves a fire-resistant piece alone
/// (`ItemStack.canBeHurtBy`).
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
use horizon_client::mobs::drops;
use horizon_world::player_inventory::EquipmentSlot;
if damage <= 0.0 {
return;
@@ -503,7 +504,6 @@ impl Game<'_> {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let body_armour = drops::head_is_body(m.species());
let amount = ((damage / 4.0) as u32).max(1);
let name = damage_type(kind, false);
let fire = self
@@ -513,9 +513,15 @@ impl Game<'_> {
.id(name)
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
// A piece's maximum, or none when it may not lose durability at all.
// `doHurtEquipment` starts from the item's `equippable` component
// (`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), so a
// damageable item with no such component — a sword put in a horse's body
// slot by `/item replace` — never wears; `equip_damage_on_hurt` alone
// cannot say that, since it defaults to true when the component is absent.
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
let info = me.res.items().get(&stack.item)?;
if !info.equip_damage_on_hurt
if info.equip_slot.is_none()
|| !info.equip_damage_on_hurt
|| info.max_damage == 0
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|| (fire && me.res.items().resists_fire(stack, name))
@@ -524,66 +530,39 @@ impl Game<'_> {
}
Some(info.max_damage)
}
let mut broke: Vec<i8> = Vec::new();
// The four armour slots, then the body slot (`extra.head`).
let event_of = horizon_client::mobs::item_break::equipment_break_event;
for (index, slot) in [
(0usize, EquipmentSlot::Head),
(1, EquipmentSlot::Chest),
(2, EquipmentSlot::Legs),
(3, EquipmentSlot::Feet),
] {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let Some(stack) = m.extra.armor[index].clone() else {
continue;
};
let Some(max) = wearable(self, &stack, fire, name) else {
continue;
};
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
continue;
}
let lost = stack.damage + loss;
let Some(body) = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body))
else {
return;
};
let Some(max) = wearable(self, &body, fire, name) else {
return;
};
let loss = self.durability_loss(&body, amount);
if loss == 0 {
return;
}
let lost = body.damage + loss;
let broke = {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.armor[index] = None;
broke.push(event_of(slot));
} else if let Some(worn) = m.extra.armor[index].as_mut() {
worn.damage = lost;
m.extra.head = None;
m.extra.body_damage = 0;
true
} else {
m.extra.body_damage = lost as i32;
false
}
}
if body_armour {
let body = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
if let Some(body) = body
&& let Some(max) = wearable(self, &body, fire, name)
{
let loss = self.durability_loss(&body, amount);
if loss > 0 {
let lost = body.damage + loss;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.head = None;
m.extra.body_damage = 0;
broke.push(event_of(EquipmentSlot::Body));
} else {
m.extra.body_damage = lost as i32;
}
}
}
}
for event in broke {
};
if broke {
// LivingEntity.onEquippedItemBroken through the level's broadcast.
let event =
horizon_client::mobs::item_break::equipment_break_event(EquipmentSlot::Body);
self.lan_entity_event(id as i32, event, at);
}
}
+86 -9
View File
@@ -2799,6 +2799,11 @@ impl Mob {
} else {
0.0
};
// LivingEntity.getDamageAfterArmorAbsorb asks `hurtArmor` *before*
// `CombatRules.getDamageAfterAbsorb`, so a worn piece wears by the
// damage the armour, toughness and enchantment reductions have not
// touched yet, and only a hit outside `#bypasses_armor` asks at all.
let armor_hit = (!source.bypasses_armor() && amount > 0.0).then_some(amount);
if !source.bypasses_armor() && self.kind.armor + worn + covered > 0.0 {
// CombatRules.getDamageAfterAbsorb
let armor = self.kind.armor + worn + covered;
@@ -2881,17 +2886,19 @@ impl Mob {
if !absorbed && dealt != 0.0 {
self.record_damage(source, dealt);
}
// LivingEntity.hurtArmor: the body armour a mob wears (a horse's) wears
// on a hit that reached it. A wolf's armour has its own rule
// (`Wolf.actuallyHurt`, `hurt_wolf_armor`).
if dealt > 0.0
// LivingEntity.hurtArmor: `Horse` is the only mob that overrides it, and
// it hands `doHurtEquipment` the body slot alone. A wolf's armour has
// its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`) and a player's
// four pieces theirs, so the other "body slot" species — donkey, mule,
// llama, camel, the skeleton and zombie horses — wear nothing, whatever
// `drops::head_is_body` says about where the item lives.
if let Some(damage) = armor_hit
&& self.species() == Species::Horse
&& self.extra.head.is_some()
&& self.species() != Species::Wolf
&& drops::head_is_body(self.species())
{
self.outbox.push(MobEvent::ArmorHurt {
mob: self.id,
amount: dealt,
amount: damage,
kind: source.kind,
});
}
@@ -4325,10 +4332,14 @@ mod armor_hurt_tests {
use horizon_core::geometry::Vec3;
fn horse() -> Mob {
mob("minecraft:horse", Species::Horse)
}
fn mob(id: &str, species: Species) -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:horse".into();
k.species = Species::Horse;
k.id = id.into();
k.species = species;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
@@ -4363,6 +4374,72 @@ mod armor_hurt_tests {
.any(|e| matches!(e, MobEvent::ArmorHurt { .. }))
);
}
/// `Horse` is the only mob that overrides `LivingEntity.hurtArmor` (the
/// base one is empty, `LivingEntity.java:1887`), so a donkey wearing a body
/// item — `/item replace entity @e[type=minecraft:donkey,limit=1]
/// armor.body with minecraft:wolf_armor` — wears nothing, even though
/// `drops::head_is_body` stores a donkey's body item in the slot a horse's
/// goes in.
#[test]
fn a_donkey_wearing_a_body_item_wears_nothing() {
let mut donkey = mob("minecraft:donkey", Species::Donkey);
donkey.extra.head = Some("minecraft:wolf_armor".into());
assert!(
donkey.damage(6.0, Damage::of(DamageKind::MobAttack)),
"the hit landed"
);
assert!(
!donkey
.outbox
.iter()
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
"only a horse's own override wears its body piece"
);
}
/// `getDamageAfterArmorAbsorb` asks `hurtArmor` only when the damage type
/// is not in `#bypasses_armor` (`LivingEntity.java:1907-1909`), so a horse
/// that falls keeps its body piece whole.
#[test]
fn a_fall_does_not_wear_the_body_piece() {
let mut m = horse();
m.extra.head = Some("minecraft:diamond_horse_armor".into());
assert!(
m.damage(6.0, Damage::of(DamageKind::Fall)),
"the fall landed"
);
assert!(
!m.outbox
.iter()
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
"a #bypasses_armor hit asks for no wear"
);
}
/// The wear is measured by the damage `hurtArmor` is handed, which is the
/// one the armour, toughness and enchantment reductions have not touched
/// yet: 8 raw damage is `8 / 4 = 2` durability even though plate leaves far
/// less than 8 through to the health.
#[test]
fn the_wear_is_the_damage_before_the_armour_reduction() {
let mut m = horse();
let k = Arc::make_mut(&mut m.kind);
k.armor = 20.0;
m.extra.head = Some("minecraft:diamond_horse_armor".into());
let before = m.health;
assert!(m.damage(8.0, Damage::of(DamageKind::MobAttack)));
assert!(m.health < before, "the hit reached the health");
let owed = m.outbox.iter().find_map(|e| match e {
MobEvent::ArmorHurt { amount, .. } => Some(*amount),
_ => None,
});
assert_eq!(
owed,
Some(8.0),
"the pre-reduction damage, not the damage the health took"
);
}
}
#[cfg(test)]