mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
db34ce484d
commit
779ab7dd4c
2 files changed
+128
-72
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user