Fix falling damage helmet durability order

This commit is contained in:
voidsip committed 2026-11-06 16:14:24 -06:00
1 parent 41f822b659
commit 3cdf31772f
15 files changed
+1007 -101

No files matched your search

+111 -44
View File
@@ -13359,6 +13359,56 @@ impl Game<'_> {
let _ = player.clamp_health_to_max();
self.apply_enchantment_attributes();
}
/// `LivingEntity.hurtServer` with the two equipment services performed
/// synchronously. Keeping the acting player installed lets durability
/// hooks observe and modify its real inventory before this hit continues.
fn hurt_player_equipment(
&mut self,
source: horizon_world::damage_source::DamageTypeId,
amount: f32,
blocking: Option<horizon_client::health::Blocking>,
living_cause: bool,
causing: i32,
) -> horizon_client::Result<(horizon_client::health::BlockOutcome, f32)> {
let mut hit = self
.player
.as_mut()
.ok_or("missing damage target")?
.prepare_equipment_hurt(
&self.health_resources,
self.world.health_rules,
source,
amount,
blocking,
living_cause,
causing,
)?;
if let Some(damage) = hit.helmet_damage()
&& self.armor[0]
.as_ref()
.is_some_and(|s| s.count > 0 && &*s.item != "minecraft:air")
{
self.hurt_equipment(source, damage, &[0]);
hit.helmet_worn();
self.refresh_armor_protection();
}
let actual = hit.damage().unwrap_or(amount);
let armor_damage = self
.player
.as_mut()
.ok_or("missing damage target")?
.select_equipment_hurt(&self.health_resources, &mut hit)?;
if let Some(damage) = armor_damage {
self.hurt_armor(source, damage);
self.refresh_armor_protection();
}
let outcome = self
.player
.as_mut()
.ok_or("missing damage target")?
.finish_equipment_hurt(&self.health_resources, hit)?;
Ok((outcome, actual))
}
/// The armor hits among a tick's motion events (`Player.hurtArmor` from
/// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion,
/// a cactus, lava, a campfire.
@@ -13377,10 +13427,29 @@ impl Game<'_> {
/// wears when its wearer is hurt loses max(1, damage / 4) durability, except
/// to a fire source when the piece resists fire (`ItemStack.canBeHurtBy`).
fn hurt_armor(&mut self, source: horizon_world::damage_source::DamageTypeId, damage: f32) {
self.hurt_equipment(source, damage, &[3, 2, 1, 0]);
}
/// Vanilla visits FEET, LEGS, CHEST, HEAD, applying each break before the
/// next slot's durability hook and enchantment random draws.
fn hurt_equipment(
&mut self,
source: horizon_world::damage_source::DamageTypeId,
damage: f32,
slots: &[usize],
) {
if damage <= 0.0 {
return;
}
let amount = ((damage / 4.0) as u32).max(1);
// The maximum of 1.0F and damage / 4.0F, then Java's saturating float-to-int.
let amount = if damage.is_nan() {
0
} else {
(damage / 4.0).max(1.0) as i32
};
if amount <= 0 {
return;
}
let amount = amount as u32;
let name = self
.health_resources
.damage
@@ -13391,51 +13460,49 @@ impl Game<'_> {
.health_resources
.damage
.in_tag(source, "minecraft:is_fire");
let mut broke = false;
let mut broken_items = Vec::new();
let losses: Vec<Option<(u32, u32)>> = (0..4)
.map(|i| {
let stack = self.armor[i].clone()?;
let info = world_items!(self).get(&stack.item)?;
let component = |id| stack.data().and_then(|data| data.other(id));
// ItemStack.isDamageableItem: component removal and
// unbreakable are checked before enchantment RNG or hooks.
if stack.count == 0
|| !info.equip_damage_on_hurt
|| component("minecraft:unbreakable").is_some()
|| component("!minecraft:max_damage").is_some()
|| component("!minecraft:damage").is_some()
|| (fire && world_items!(self).resists_fire(&stack, &name))
{
return None;
}
let max = match component("minecraft:max_damage") {
Some(horizon_world::nbt::Tag::Int(value)) => u32::try_from(*value).ok()?,
_ => info.max_damage,
};
if max == 0 {
return None;
}
let loss = self.wear(&stack, amount);
(loss > 0).then_some((loss, max))
})
.collect();
for (index, (slot, loss)) in self.armor.iter_mut().zip(losses).enumerate() {
if let (Some(stack), Some((amount, max))) = (slot.as_mut(), loss) {
stack.damage = stack.damage.saturating_add(amount).min(max);
if stack.damage >= max {
broken_items.push((stack.clone(), index));
*slot = None;
broke = true;
}
for &i in slots {
let Some(stack) = self.armor[i].clone() else {
continue;
};
let Some(info) = world_items!(self).get(&stack.item) else {
continue;
};
let component = |id| stack.data().and_then(|data| data.other(id));
// ItemStack.isDamageableItem: component removal and
// unbreakable are checked before enchantment RNG or hooks.
if stack.count == 0
|| !info.equip_damage_on_hurt
|| component("minecraft:unbreakable").is_some()
|| component("!minecraft:max_damage").is_some()
|| component("!minecraft:damage").is_some()
|| (fire && world_items!(self).resists_fire(&stack, &name))
{
continue;
}
}
if broke {
// LivingEntity.onEquippedItemBroken for each armour piece that broke.
for (stack, index) in &broken_items {
self.player_equipment_broke(armor_slot(*index), stack);
let max = match component("minecraft:max_damage") {
Some(horizon_world::nbt::Tag::Int(value)) => match u32::try_from(*value) {
Ok(value) => value,
Err(_) => continue,
},
_ => info.max_damage,
};
if max == 0 {
continue;
}
let loss = self.wear(&stack, amount);
if loss == 0 {
continue;
}
let Some(worn) = self.armor[i].as_mut() else {
continue;
};
worn.damage = worn.damage.saturating_add(loss).min(max);
if worn.damage >= max {
let broken = worn.clone();
self.armor[i] = None;
self.player_equipment_broke(armor_slot(i), &broken);
self.apply_equipment();
}
self.apply_equipment();
}
}
/// Voxel traversal that also stops at fluid source blocks (bucket pick).
+31 -12
View File
@@ -3784,17 +3784,36 @@ impl Game<'_> {
// The hit's own push, held back until after the shield's (`hurtServer`
// runs `applyItemBlocking` before `dealDefaultKnockback`).
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.health_resources.damage.id(damage_type)
&& let Ok(o) = player.hurt_caused_by(
&self.health_resources,
self.world.health_rules,
id,
amount,
blocking,
living_cause,
causing,
)
let mut resolved_damage = None;
let damage_outcome = if let Some(id) = self.health_resources.damage.id(damage_type) {
if self
.health_resources
.damage
.in_tag(id, "minecraft:damages_helmet")
{
self.hurt_player_equipment(id, amount, blocking, living_cause, causing)
.map(|(outcome, actual)| {
resolved_damage = Some(actual);
outcome
})
} else if let Some(player) = self.player.as_mut() {
player.hurt_caused_by(
&self.health_resources,
self.world.health_rules,
id,
amount,
blocking,
living_cause,
causing,
)
} else {
Ok(Default::default())
}
} else {
Ok(Default::default())
};
if let Ok(o) = damage_outcome
&& let Some(player) = self.player.as_mut()
&& {
outcome = o;
o.hurt
@@ -3853,7 +3872,7 @@ impl Game<'_> {
is_direct: !projectile,
source,
original: amount,
actual: amount - outcome.blocked,
actual: resolved_damage.unwrap_or(amount - outcome.blocked),
blocked: outcome.blocked > 0.0,
});
}
@@ -192,16 +192,15 @@ impl Game<'_> {
return;
}
let original = amount;
let amount = if self.armor[0].is_some() {
amount * 0.75
} else {
amount
let Some(id) = self.health_resources.damage.id(kind) else {
return;
};
let hurt = match (self.player.as_mut(), self.health_resources.damage.id(kind)) {
(Some(p), Some(id)) => p
.hurt(&self.health_resources, self.world.health_rules, id, amount)
.unwrap_or(false),
_ => false,
let (hurt, amount) = match self.hurt_player_equipment(id, amount, None, false, 0) {
Ok((outcome, actual)) => (outcome.hurt, actual),
Err(error) => {
log::warn!("falling block damage failed: {error}");
(false, amount)
}
};
if hurt {
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, the falling block
@@ -224,3 +223,6 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod helmet_tests;
@@ -0,0 +1,170 @@
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{items::ItemStack, motion::PlayerMotion};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn falling_impacts_wear_the_helmet_before_normal_armor_and_reduction() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!("horizon-helmet-{}", std::process::id()));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.creative = false;
// Equipment breaks are server notifications, with no local visual RNG.
game.lan_acting = Some(1);
let position = Vec3::new(4.5, 100.0, 4.5);
let cases: serde_json::Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/helmet_damage.json"
)))
.unwrap();
for (case_index, case) in cases.as_array().unwrap().iter().enumerate() {
for path in 0..2 {
let index = case_index * 2 + path;
game.player = Some(PlayerMotion::new(game.motion, position).unwrap());
game.armor = [None, None, None, None];
if let Some(item) = case["head"].as_str() {
let mut stack = ItemStack::new(&format!("minecraft:{item}"), 1);
stack.damage = case["head_damage"].as_u64().unwrap() as u32;
if case["unbreaking"].as_u64().unwrap() > 0 {
stack.enchant(
"minecraft:unbreaking",
case["unbreaking"].as_u64().unwrap() as u32,
);
}
if case["unbreakable"].as_bool().unwrap_or(false) {
stack.data_mut().set_other(
"minecraft:unbreakable",
Some(horizon_world::nbt::Tag::Compound(Default::default())),
);
}
game.armor[0] = Some(stack);
}
if let Some(damage) = case["boots_damage"].as_u64() {
let mut stack = ItemStack::new("minecraft:iron_boots", 1);
stack.damage = damage as u32;
if case["unbreaking"].as_u64().unwrap() > 0 {
stack.enchant(
"minecraft:unbreaking",
case["unbreaking"].as_u64().unwrap() as u32,
);
}
game.armor[3] = Some(stack);
}
game.apply_equipment();
game.apply_effect_status();
game.world.random = LegacyRandom::new(case["seed"].as_i64().unwrap());
let source = format!("minecraft:{}", case["source"].as_str().unwrap());
if path == 0 {
game.falling_hurt_player(position, 8.0, &source);
} else {
// This is the authoritative player path used by /damage. A
// position avoids unrelated random-direction knockback draws.
game.hurt_player_from(8.0, Some([5.5, 100.0, 4.5]), &source, Some(None));
}
// Flush the same equipment events as the server-player tick. This
// also makes the baseline regression observe completed legacy wear.
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
game.wear_armor(&events);
let head = game.armor[0].as_ref().map_or(-1, |s| i64::from(s.damage));
let boots = game.armor[3].as_ref().map_or(-1, |s| i64::from(s.damage));
let health = game.player.as_ref().unwrap().health.health();
let next = game.world.random.clone().next_long();
println!(
"HELMET {index} {head} {boots} {:08x} {next}",
health.to_bits()
);
if let Some(expected) = case["expected_head"].as_i64() {
assert_eq!(head, expected, "case={index}");
}
if let Some(expected) = case["expected_health"].as_f64() {
assert_eq!(health, expected as f32, "case={index}");
}
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
game.wear_armor(&events);
assert_eq!(
game.armor[0].as_ref().map_or(-1, |s| i64::from(s.damage)),
head,
"equipment must not wear twice when events drain"
);
assert_eq!(game.world.random.clone().next_long(), next);
}
}
// LivingEntity.hurtServer wears the helmet even when the cooldown then
// rejects an equal hit: the second hit has no ordinary armor wear.
for (initial, expected) in [(0, Some(5)), (160, None)] {
game.player = Some(PlayerMotion::new(game.motion, position).unwrap());
let mut helmet = ItemStack::new("minecraft:iron_helmet", 1);
helmet.damage = initial;
game.armor = [Some(helmet), None, None, None];
game.apply_equipment();
game.apply_effect_status();
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
assert_eq!(game.armor[0].as_ref().map(|s| s.damage), expected);
assert_eq!(
game.player.as_ref().unwrap().health.health(),
20.0_f32 - 5.9040003_f32
);
if expected.is_none() {
// The hit that breaks the helmet still gets its 0.75 reduction;
// only the next hit compares an unprotected 8 against lastHurt=6.
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
assert_eq!(game.player.as_ref().unwrap().health.health(), 12.096_f32);
}
}
// Refreshing equipment protection during a hit must preserve absorption
// that the damage pipeline has not consumed yet.
game.player = Some(PlayerMotion::new(game.motion, position).unwrap());
game.armor = [
Some(ItemStack::new("minecraft:iron_helmet", 1)),
None,
None,
None,
];
game.apply_equipment();
let player = game.player.as_mut().unwrap();
player
.set_modifier_unclamped(
"minecraft:max_absorption",
"horizon:helmet_test",
4.0,
"add_value",
true,
)
.unwrap();
player.set_health(4.0, true).unwrap();
game.falling_hurt_player(position, 8.0, "minecraft:falling_anvil");
assert_eq!(game.player.as_ref().unwrap().health.health(), 18.096_f32);
assert_eq!(game.player.as_ref().unwrap().health.absorption(), 0.0);
std::fs::remove_dir_all(scratch).unwrap();
}
+10 -5
View File
@@ -1131,6 +1131,16 @@ impl Game<'_> {
{
let _ = p.set_health(0.0, true);
}
self.refresh_armor_protection();
let mut status = self.potions.effects.status();
if let Some(p) = self.player.as_mut() {
status.protection = std::mem::take(&mut p.status.protection);
p.status = status;
}
}
/// `EnchantmentHelper.getDamageProtection` after equipment changes. This
/// does not tick effects or clear absorption while a hit is in progress.
pub(super) fn refresh_armor_protection(&mut self) {
// EnchantmentHelper.getDamageProtection per damage type, recomputed on armor change.
let armor: Vec<Option<ItemStack>> = self.armor.to_vec();
if !self
@@ -1173,11 +1183,6 @@ impl Game<'_> {
p.status.protection = table;
}
}
let mut status = self.potions.effects.status();
if let Some(p) = self.player.as_mut() {
status.protection = std::mem::take(&mut p.status.protection);
p.status = status;
}
}
/// The players a thrown potion can hit (`Projectile.canHitEntity`): the
/// host and the guests, but not a spectator or a dead one.
+4
View File
@@ -22,6 +22,10 @@ use horizon_world::{
synced_floats::{FloatValue, Observer, SyncedFloats},
};
use std::{cell::Cell, fs, io, path::Path, sync::OnceLock};
mod equipment;
#[doc(hidden)]
pub use equipment::EquipmentHurt;
pub(crate) type Attributes = LiveAttributes<Box<dyn horizon_world::attributes::live::Identities>>;
pub(crate) type State = HurtState<DamageTypeId>;
@@ -0,0 +1,228 @@
//! Damage continuations at the two equipment boundaries. The gameplay owner
//! keeps the complete acting player installed while running durability hooks.
use super::*;
use horizon_world::living_hurt::{self, PendingHurt, PreparedHurt};
#[doc(hidden)]
pub struct EquipmentHurt {
source: DamageTypeId,
rules: HealthRules,
amount: f32,
blocking: Option<Blocking>,
living_cause: bool,
causing: i32,
before: f32,
fire_ticks: i32,
prepared: Option<PreparedHurt<()>>,
pending: Option<PendingHurt<()>>,
inner: Option<bool>,
blocked: f32,
fully_blocked: bool,
block_sound: bool,
knocked: bool,
totem_fired: bool,
canceled: bool,
damages_helmet: bool,
}
impl EquipmentHurt {
pub fn damage(&self) -> Option<f32> {
self.prepared.as_ref().map(|p| {
if p.damage.is_finite() {
p.damage
} else {
f32::MAX
}
})
}
pub fn helmet_damage(&self) -> Option<f32> {
self.prepared
.as_ref()
.filter(|_| self.damages_helmet)
.map(|p| p.damage)
}
pub fn helmet_worn(&mut self) {
if let Some(prepared) = &mut self.prepared {
prepared.damage *= 0.75;
}
}
}
impl PlayerMotion {
fn equipment_step<T>(
&mut self,
resources: &HealthResources,
hit: &mut EquipmentHurt,
step: impl FnOnce(
&mut State,
&mut ServerPlayerCombat<'_, Environment<'_>, Attributes>,
) -> Result<T>,
) -> Result<T> {
let source = resources
.damage
.source::<()>(hit.source, None, None, None)
.ok_or("unknown resident damage source")?;
let h = &mut self.health;
let mut environment = Environment {
entity: self.entity_id,
causing: hit.causing,
invulnerable: self.invulnerable,
client_loaded: self.client_loaded,
rules: hit.rules,
source,
living_cause: hit.living_cause,
events: &mut self.events,
dead: &mut h.dead,
sync_velocity: &mut h.sync_velocity,
game_time: h.game_time,
status: self.status.clone(),
blocking: hit.blocking.clone(),
blocked: hit.blocked,
fully_blocked: hit.fully_blocked,
block_sound: hit.block_sound,
knocked: hit.knocked,
totem: h.totem_ready,
totem_fired: hit.totem_fired,
canceled: hit.canceled,
};
let mut combat = ServerPlayerCombat::new(
&mut self.attributes,
&mut h.data,
&mut h.food,
h.ids,
&mut environment,
)?;
let result = step(&mut h.hurt, &mut combat);
hit.blocked = environment.blocked;
hit.fully_blocked = environment.fully_blocked;
hit.block_sound = environment.block_sound;
hit.knocked = environment.knocked;
hit.totem_fired = environment.totem_fired;
hit.canceled = environment.canceled;
result
}
#[doc(hidden)]
pub fn prepare_equipment_hurt(
&mut self,
resources: &HealthResources,
rules: HealthRules,
source: DamageTypeId,
amount: f32,
blocking: Option<Blocking>,
living_cause: bool,
causing: i32,
) -> Result<EquipmentHurt> {
let mut hit = EquipmentHurt {
source,
rules,
amount,
blocking,
living_cause,
causing,
before: self.health.health() + self.health.absorption(),
fire_ticks: self.fire_ticks,
prepared: None,
pending: None,
inner: None,
blocked: 0.0,
fully_blocked: false,
block_sound: false,
knocked: false,
totem_fired: false,
canceled: false,
damages_helmet: resources.damage.in_tag(source, "minecraft:damages_helmet"),
};
if !self.client_side {
hit.prepared = self.equipment_step(resources, &mut hit, |state, combat| {
Ok(combat.prepare_hurt(state, amount)?)
})?;
}
Ok(hit)
}
#[doc(hidden)]
pub fn select_equipment_hurt(
&mut self,
resources: &HealthResources,
hit: &mut EquipmentHurt,
) -> Result<Option<f32>> {
let Some(prepared) = hit.prepared.take() else {
return Ok(None);
};
hit.pending = self.equipment_step(resources, hit, |state, combat| {
Ok(living_hurt::select_hurt_server(state, combat, prepared)?)
})?;
if let Some(pending) = &hit.pending {
let damage = pending.damage;
hit.inner = self.equipment_step(resources, hit, |_, combat| {
Ok(combat.prepare_equipment_inner()?)
})?;
if hit.inner == Some(false) {
return Ok(Some(damage));
}
}
Ok(None)
}
#[doc(hidden)]
pub fn finish_equipment_hurt(
&mut self,
resources: &HealthResources,
mut hit: EquipmentHurt,
) -> Result<BlockOutcome> {
let hurt = if let Some(pending) = hit.pending.take() {
let inner = hit.inner;
self.equipment_step(resources, &mut hit, |state, combat| {
if let Some(bypass) = inner {
combat.apply_equipment_inner(pending.damage, bypass)?;
}
Ok(living_hurt::finish_hurt_server(state, combat, pending)?)
})?
} else {
false
};
let h = &mut self.health;
if hit.totem_fired {
h.totem_used = true;
h.totem_ready = false;
}
if hurt {
h.hurt_animation_time = h.hurt.hurt_time;
}
if hit.knocked && hit.blocked <= 0.0 {
h.indicate_damage(0.0, 0.0, self.yaw);
}
let after = h.health() + h.absorption();
if self.hurt_log.len() >= HURT_LOG {
self.hurt_log.remove(0);
}
self.hurt_log.push(HurtRecord {
game_time: h.game_time,
source: resources
.damage
.definition(hit.source)
.ok_or("unknown resident damage source")?
.name
.clone(),
amount: hit.amount,
blocked: hit.blocked,
taken: hit.before - after,
hurt,
armor: self
.attributes
.cached_value_of("minecraft:armor")
.unwrap_or(0.0) as f32,
toughness: self
.attributes
.cached_value_of("minecraft:armor_toughness")
.unwrap_or(0.0) as f32,
health: after,
fire_ticks: hit.fire_ticks,
knockback: None,
});
Ok(BlockOutcome {
hurt,
blocked: hit.blocked,
fully_blocked: hit.fully_blocked,
sound: hit.block_sound,
knockback: hit.knocked,
canceled: hit.canceled,
})
}
}
@@ -81,6 +81,14 @@ pub(crate) fn actually_hurt_after_immunity_check<W: DamageWorld>(
player: bool,
) -> Result<(), W::Error> {
let damage = world.after_armor(damage)?;
actually_hurt_after_armor(world, damage, player)
}
/// Resume an inner hit after synchronous equipment wear and armor reduction.
pub(crate) fn actually_hurt_after_armor<W: DamageWorld>(
world: &mut W,
damage: f32,
player: bool,
) -> Result<(), W::Error> {
let damage = world.after_magic(damage)?;
let mut remaining = positive(damage - world.absorption()?);
let absorption = world.absorption()? - (damage - remaining);
+94 -22
View File
@@ -115,19 +115,33 @@ pub trait HurtWorld {
blocked: bool,
) -> Result<(), Self::Error>;
}
pub fn hurt_server<W: HurtWorld>(
/// Outer-hit values retained while equipment services run with their owner.
pub struct PreparedHurt<I> {
pub damage: f32,
original: f32,
item: I,
blocked_amount: f32,
}
/// The cooldown decision is made before inner equipment wear and retained even
/// if a durability callback changes the player's cooldown.
pub struct PendingHurt<I> {
pub damage: f32,
prepared: PreparedHurt<I>,
fresh: bool,
}
pub fn prepare_hurt_server<W: HurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
mut damage: f32,
) -> Result<bool, W::Error> {
) -> Result<Option<PreparedHurt<W::UseItem>>, W::Error> {
if w.invulnerable(s)? || w.dead_or_dying(s)? {
return Ok(false);
return Ok(None);
}
if w.tag(s, HurtTag::Fire)? && w.fire_resistance(s)? {
return Ok(false);
return Ok(None);
}
if w.hurt_start(s, &mut damage)? {
return Ok(false);
return Ok(None);
}
if w.sleeping(s)? {
w.stop_sleeping(s)?;
@@ -140,32 +154,74 @@ pub fn hurt_server<W: HurtWorld>(
let item = w.use_item(s)?;
let blocked_amount = w.apply_blocking(s, damage)?;
damage -= blocked_amount;
let blocked = blocked_amount > 0.0;
if w.tag(s, HurtTag::Freezing)? && w.freeze_hurts_extra(s)? {
damage *= 5.0;
}
Ok(Some(PreparedHurt {
damage,
original,
item,
blocked_amount,
}))
}
pub fn hurt_helmet<W: HurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
prepared: &mut PreparedHurt<W::UseItem>,
) -> Result<(), W::Error> {
if w.tag(s, HurtTag::DamagesHelmet)? && !w.helmet_empty(s)? {
w.hurt_helmet(s, damage)?;
damage *= 0.75;
w.hurt_helmet(s, prepared.damage)?;
prepared.damage *= 0.75;
}
if !damage.is_finite() {
damage = f32::MAX;
Ok(())
}
pub fn select_hurt_server<W: HurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
mut prepared: PreparedHurt<W::UseItem>,
) -> Result<Option<PendingHurt<W::UseItem>>, W::Error> {
if !prepared.damage.is_finite() {
prepared.damage = f32::MAX;
}
let fresh = if s.cooldown as f32 > 10.0 && !w.tag(s, HurtTag::BypassesCooldown)? {
if damage <= s.last_hurt {
return Ok(false);
}
w.actually_hurt(s, damage - s.last_hurt)?;
s.last_hurt = damage;
false
} else {
s.last_hurt = damage;
let fresh = !(s.cooldown as f32 > 10.0 && !w.tag(s, HurtTag::BypassesCooldown)?);
let damage = if fresh {
s.last_hurt = prepared.damage;
s.cooldown = 20;
w.actually_hurt(s, damage)?;
prepared.damage
} else {
if prepared.damage <= s.last_hurt {
return Ok(None);
}
prepared.damage - s.last_hurt
};
Ok(Some(PendingHurt {
damage,
prepared,
fresh,
}))
}
/// Complete an outer hit after its selected inner hit has run.
pub fn finish_hurt_server<W: HurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
pending: PendingHurt<W::UseItem>,
) -> Result<bool, W::Error> {
let PendingHurt {
prepared, fresh, ..
} = pending;
let PreparedHurt {
damage,
original,
item,
blocked_amount,
} = prepared;
let blocked = blocked_amount > 0.0;
if fresh {
s.hurt_duration = 10;
s.hurt_time = s.hurt_duration;
true
};
} else {
s.last_hurt = damage;
}
w.resolve_mob(s)?;
w.resolve_player(s)?;
if fresh {
@@ -211,3 +267,19 @@ pub fn hurt_server<W: HurtWorld>(
w.attacker_criterion(s, original, damage, blocked)?;
Ok(accepted)
}
pub fn hurt_server<W: HurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
damage: f32,
) -> Result<bool, W::Error> {
let Some(mut prepared) = prepare_hurt_server(s, w, damage)? else {
return Ok(false);
};
hurt_helmet(s, w, &mut prepared)?;
let Some(pending) = select_hurt_server(s, w, prepared)? else {
return Ok(false);
};
w.actually_hurt(s, pending.damage)?;
finish_hurt_server(s, w, pending)
}
+50 -9
View File
@@ -25,20 +25,20 @@ fn min(a: f32, b: f32) -> f32 {
a.min(b)
}
}
pub fn player_hurt_server<W: PlayerHurtWorld>(
fn player_hurt_start<W: PlayerHurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
mut damage: f32,
) -> Result<bool, W::Error> {
) -> Result<Option<f32>, W::Error> {
if w.invulnerable(s)? {
return Ok(false);
return Ok(None);
}
if w.ability_invulnerable() && !w.bypasses_invulnerability(s)? {
return Ok(false);
return Ok(None);
}
s.no_action_time = 0;
if w.dead_or_dying(s)? {
return Ok(false);
return Ok(None);
}
w.remove_shoulders(s)?;
if w.scales_with_difficulty(s)? {
@@ -53,9 +53,29 @@ pub fn player_hurt_server<W: PlayerHurtWorld>(
}
}
if damage == 0.0 {
return Ok(false);
return Ok(None);
}
Ok(Some(damage))
}
pub fn player_hurt_server<W: PlayerHurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
damage: f32,
) -> Result<bool, W::Error> {
match player_hurt_start(s, w, damage)? {
Some(damage) => living_hurt::hurt_server(s, w, damage),
None => Ok(false),
}
}
pub fn prepare_player_hurt_server<W: PlayerHurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
damage: f32,
) -> Result<Option<living_hurt::PreparedHurt<W::UseItem>>, W::Error> {
match player_hurt_start(s, w, damage)? {
Some(damage) => living_hurt::prepare_hurt_server(s, w, damage),
None => Ok(None),
}
living_hurt::hurt_server(s, w, damage)
}
#[derive(Clone, Copy, Debug)]
pub enum CausingEntity<P, A> {
@@ -81,10 +101,10 @@ pub trait ServerPlayerHurtWorld: PlayerHurtWorld {
player: Self::Player,
) -> Result<bool, Self::Error>;
}
pub fn server_player_hurt_server<W: ServerPlayerHurtWorld>(
fn server_player_hurt_start<W: ServerPlayerHurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
damage: f32,
_damage: f32,
) -> Result<bool, W::Error> {
if w.invulnerable(s)? {
return Ok(false);
@@ -104,5 +124,26 @@ pub fn server_player_hurt_server<W: ServerPlayerHurtWorld>(
}
CausingEntity::Other => {}
}
Ok(true)
}
pub fn server_player_hurt_server<W: ServerPlayerHurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
damage: f32,
) -> Result<bool, W::Error> {
if !server_player_hurt_start(s, w, damage)? {
return Ok(false);
}
player_hurt_server(s, w, damage)
}
pub fn prepare_server_player_hurt_server<W: ServerPlayerHurtWorld>(
s: &mut HurtState<W::Source>,
w: &mut W,
damage: f32,
) -> Result<Option<living_hurt::PreparedHurt<W::UseItem>>, W::Error> {
if !server_player_hurt_start(s, w, damage)? {
return Ok(None);
}
prepare_player_hurt_server(s, w, damage)
}
@@ -54,6 +54,46 @@ impl<'a, A: HealthAttributes, E: CombatEnvironment<A>> ServerPlayerCombat<'a, E,
) -> Result<bool, ServerDamageError<Error<E>, A::Error>> {
server_player_hurt_server(state, self, damage)
}
/// Prepare the guards, blocking and freezing before an external equipment service.
pub fn prepare_hurt(
&mut self,
state: &mut HurtState<E::Source>,
damage: f32,
) -> Result<
Option<crate::living_hurt::PreparedHurt<E::UseItem>>,
ServerDamageError<Error<E>, A::Error>,
> {
crate::player_hurt::prepare_server_player_hurt_server(state, self, damage)
}
/// The inner immunity and armor-bypass gates precede ordinary equipment wear.
pub fn prepare_equipment_inner(
&mut self,
) -> Result<Option<bool>, ServerDamageError<Error<E>, A::Error>> {
if damage_immunity::server_player_immunity(self.environment)
.map_err(ServerDamageError::Environment)?
{
return Ok(None);
}
self.environment
.bypasses(crate::damage_reduction::Bypass::Armor)
.map(Some)
.map_err(ServerDamageError::Environment)
}
pub fn apply_equipment_inner(
&mut self,
damage: f32,
bypass_armor: bool,
) -> Result<(), ServerDamageError<Error<E>, A::Error>> {
ServerPlayerDamage::new(
self.attributes,
self.data,
self.food,
self.ids,
self.environment,
)
.expect("validated immutable player schema")
.apply_after_equipment(damage, bypass_armor)
}
}
impl<A: HealthAttributes, E: CombatEnvironment<A>> HurtWorld for ServerPlayerCombat<'_, E, A> {
type Error = ServerDamageError<Error<E>, A::Error>;
@@ -125,6 +125,21 @@ impl<'a, A: HealthAttributes, E: ServerDamageEnvironment<A>> ServerPlayerDamage<
) -> Result<(), ServerDamageError<EnvError<E>, A::Error>> {
living_damage::actually_hurt_after_immunity_check(self, damage, true)
}
pub(crate) fn apply_after_equipment(
&mut self,
damage: f32,
bypass_armor: bool,
) -> Result<(), ServerDamageError<EnvError<E>, A::Error>> {
use crate::damage_reduction::DamageReductionWorld;
let damage = if bypass_armor {
damage
} else {
let armor = self.armor_value()? as f32;
let toughness = self.toughness_attribute()? as f32;
crate::combat_rules::after_armor(self, damage, armor, toughness)?
};
living_damage::actually_hurt_after_armor(self, damage, true)
}
}
impl<A: HealthAttributes, E: ServerDamageEnvironment<A>> ArmorWorld
for ServerPlayerDamage<'_, E, A>
@@ -0,0 +1,129 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.lang.reflect.*;
import java.nio.file.*;
import java.util.*;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.*;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.*;
import net.minecraft.tags.DamageTypeTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.damagesource.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.*;
import net.minecraft.world.item.enchantment.*;
/** Actual head/ordinary equipment durability, modifier removal and armor reduction.
* Outer guards, cooldown, effects, health storage and player notifications are
* covered separately; this oracle isolates the two vanilla equipment boundaries.
*/
public final class VanillaHelmetDamageOracle {
static Object unsafe;
static Method allocate;
static <T> T make(Class<T> type) throws Exception {
return type.cast(allocate.invoke(unsafe, type));
}
static final class Level extends ServerLevel {
RandomSource random;
VanillaBlockUpdateOracle.Server server;
Level() { super(null, null, null, null, null, null, false, 0L, List.of(), false); }
@Override public void broadcastEntityEvent(Entity entity, byte event) {}
@Override public RandomSource getRandom() { return random; }
@Override public VanillaBlockUpdateOracle.Server getServer() { return server; }
@Override public RegistryAccess registryAccess() { return ParityData.access; }
}
static final class Actor extends Player {
Level level;
EnumMap<EquipmentSlot, ItemStack> gear;
AttributeMap attributes;
Actor() { super(null, null); }
@Override public net.minecraft.world.level.GameType gameMode() {
return net.minecraft.world.level.GameType.SURVIVAL;
}
@Override public Level level() { return level; }
@Override public ItemStack getItemBySlot(EquipmentSlot slot) {
return gear.getOrDefault(slot, ItemStack.EMPTY);
}
@Override public AttributeMap getAttributes() { return attributes; }
@Override public double getAttributeValue(Holder<Attribute> attribute) {
return attributes.getValue(attribute);
}
float hit(DamageSource source, float damage) {
// LivingEntity.hurtServer's equipment boundary, followed by the
// real Player.getDamageAfterArmorAbsorb and doHurtEquipment.
if (source.is(DamageTypeTags.DAMAGES_HELMET) && !getItemBySlot(EquipmentSlot.HEAD).isEmpty()) {
super.hurtHelmet(source, damage);
damage *= 0.75F;
}
return super.getDamageAfterArmorAbsorb(source, damage);
}
}
static ItemStack stack(String item, int damage, int unbreaking) {
ItemStack stack = new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.parse("minecraft:" + item)));
stack.setDamageValue(damage);
if (unbreaking > 0) {
var levels = new ItemEnchantments.Mutable(ItemEnchantments.EMPTY);
levels.set(ParityData.lookup.lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.UNBREAKING), unbreaking);
stack.set(DataComponents.ENCHANTMENTS, levels.toImmutable());
}
return stack;
}
static int damage(Actor actor, EquipmentSlot slot) {
ItemStack stack = actor.getItemBySlot(slot);
return stack.isEmpty() ? -1 : stack.getDamageValue();
}
public static void main(String[] args) throws Exception {
ParityData.init();
var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
unsafe = unsafeField.get(null);
allocate = unsafe.getClass().getMethod("allocateInstance", Class.class);
var attributesField = LivingEntity.class.getDeclaredField("attributes");
attributesField.setAccessible(true);
var locationEffects = LivingEntity.class.getDeclaredField("activeLocationDependentEnchantments");
locationEffects.setAccessible(true);
var cases = JsonParser.parseString(Files.readString(Path.of(args[0]))).getAsJsonArray();
try (var out = Files.newBufferedWriter(Path.of(args[1]))) {
for (int index = 0; index < cases.size(); index++) {
var input = cases.get(index).getAsJsonObject();
Actor actor = make(Actor.class);
actor.level = make(Level.class);
actor.level.random = RandomSource.create(input.get("seed").getAsLong());
actor.level.server = make(VanillaBlockUpdateOracle.Server.class);
actor.level.server.loot = new VanillaBlockUpdateOracle.Loot();
actor.level.server.loot.delegate = ParityData.loot;
actor.gear = new EnumMap<>(EquipmentSlot.class);
actor.attributes = new AttributeMap(Player.createAttributes().build());
attributesField.set(actor, actor.attributes);
locationEffects.set(actor, new EnumMap<>(EquipmentSlot.class));
int unbreaking = input.get("unbreaking").getAsInt();
if (!input.get("head").isJsonNull()) {
actor.gear.put(EquipmentSlot.HEAD, stack(input.get("head").getAsString(), input.get("head_damage").getAsInt(), unbreaking));
}
if (!input.get("boots_damage").isJsonNull()) {
actor.gear.put(EquipmentSlot.FEET, stack("iron_boots", input.get("boots_damage").getAsInt(), unbreaking));
}
if (input.has("unbreakable") && input.get("unbreakable").getAsBoolean()) {
actor.gear.get(EquipmentSlot.HEAD).set(DataComponents.UNBREAKABLE, net.minecraft.util.Unit.INSTANCE);
}
for (var entry : actor.gear.entrySet()) {
entry.getValue().forEachModifier(entry.getKey(), (attribute, modifier) -> actor.attributes.getInstance(attribute).addTransientModifier(modifier));
}
var source = new DamageSource(ParityData.lookup.lookupOrThrow(Registries.DAMAGE_TYPE)
.getOrThrow(net.minecraft.resources.ResourceKey.create(Registries.DAMAGE_TYPE, Identifier.parse("minecraft:" + input.get("source").getAsString()))));
float health = 20.0F - actor.hit(source, 8.0F);
long next = actor.level.random.nextLong();
for (int path = 0; path < 2; path++) {
out.write("HELMET " + (index * 2 + path) + " " + damage(actor, EquipmentSlot.HEAD) + " " + damage(actor, EquipmentSlot.FEET)
+ " " + String.format("%08x", Float.floatToIntBits(health)) + " " + next);
out.newLine();
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
[
{"head":null,"head_damage":0,"boots_damage":null,"unbreaking":0,"seed":42,"source":"falling_anvil","expected_head":-1,"expected_health":12.0},
{"head":"iron_helmet","head_damage":0,"boots_damage":null,"unbreaking":0,"seed":42,"source":"falling_anvil","expected_head":3,"expected_health":14.096},
{"head":"iron_helmet","head_damage":162,"boots_damage":null,"unbreaking":0,"seed":42,"source":"falling_anvil","expected_head":-1,"expected_health":14.0},
{"head":"iron_helmet","head_damage":163,"boots_damage":null,"unbreaking":0,"seed":42,"source":"falling_anvil","expected_head":-1,"expected_health":14.0},
{"head":"iron_helmet","head_damage":164,"boots_damage":194,"unbreaking":0,"seed":42,"source":"falling_anvil","expected_head":-1,"expected_health":14.0},
{"head":"iron_helmet","head_damage":0,"boots_damage":0,"unbreaking":3,"seed":42,"source":"falling_anvil"},
{"head":"iron_helmet","head_damage":0,"boots_damage":0,"unbreaking":3,"seed":17,"source":"falling_block"},
{"head":"iron_helmet","head_damage":0,"boots_damage":0,"unbreaking":3,"seed":99,"source":"falling_stalactite"},
{"head":"iron_helmet","head_damage":0,"boots_damage":0,"unbreaking":3,"seed":42,"source":"generic"},
{"head":"stone","head_damage":0,"boots_damage":null,"unbreaking":0,"seed":42,"source":"falling_anvil","expected_head":0,"expected_health":14.0},
{"head":null,"head_damage":0,"boots_damage":194,"unbreaking":0,"seed":42,"source":"falling_anvil","expected_head":-1,"expected_health":12.0},
{"head":"iron_helmet","head_damage":164,"boots_damage":null,"unbreaking":0,"unbreakable":true,"seed":42,"source":"falling_anvil","expected_head":164,"expected_health":14.096},
{"head":"iron_helmet","head_damage":164,"boots_damage":194,"unbreaking":3,"seed":42,"source":"falling_anvil"}
]
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Compare falling-impact equipment wear and armor reduction without starting a server."""
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
try:
from . import lab, test_rust_parity as base, integration_env
except ImportError:
import lab, test_rust_parity as base, integration_env
PARITY_RUST_SCOPE = [
'crates/horizon-world/src/living_hurt.rs',
'crates/horizon-world/src/player_hurt.rs',
'crates/horizon-world/src/living_damage.rs',
'crates/horizon-world/src/server_player_combat.rs',
'crates/horizon-world/src/server_player_damage.rs',
'crates/horizon-client/src/health',
'crates/horizon-client/src/health.rs',
'crates/horizon-client/src/game.rs',
'crates/horizon-client/src/game/creatures.rs',
'crates/horizon-client/src/game/potions.rs',
'crates/horizon-client/src/game/falling_damage',
'crates/horizon-client/src/game/falling_damage.rs',
]
TEST = 'game::falling_damage::helmet_tests::falling_impacts_wear_the_helmet_before_normal_armor_and_reduction'
def main():
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / 'helmet-damage-parity'
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
oracle_root = lab.ROOT / 'mods/horizon/src/test/java'
oracle = oracle_root / 'dev/horizonlab/parity/VanillaHelmetDamageOracle.java'
fixtures = lab.ROOT / 'tests/fixtures/helmet_damage.json'
sources = [Path(__file__), lab.ROOT / 'tools/lab.py', lab.ROOT / 'tools/test_rust_parity.py',
lab.ROOT / 'tools/integration_env.py', oracle, fixtures,
oracle_root / 'dev/horizonlab/parity/ParityData.java',
oracle_root / 'dev/horizonlab/parity/VanillaBlockUpdateOracle.java',
lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock']
for entry in PARITY_RUST_SCOPE:
path = lab.ROOT / entry
sources.extend(sorted(path.rglob('*.rs')) if path.is_dir() else [path])
tested = base.source_hashes(sources)
classes = build / 'classes'
classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
def run(command, name, cwd=lab.ROOT, env=None):
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
subprocess.run(list(map(str, command)), cwd=cwd, env=env, check=True,
stdout=log, stderr=subprocess.STDOUT, timeout=900)
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath',
oracle_root, '-cp', dependencies, '-d', classes, oracle], 'compile-java')
with tempfile.TemporaryDirectory(prefix='horizon-helmet-parity-') as scratch:
env = integration_env.game_environment(os.environ, scratch)
env['HORIZON_TEST_RESOURCES'] = str(Path(os.environ.get('HORIZON_TEST_RESOURCES', lab.RESOURCES)).resolve())
run(['java', '-Xmx768m', '-Djava.awt.headless=true', '-cp',
str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaHelmetDamageOracle',
fixtures, build / 'server.txt'], 'server', cwd=scratch, env=env)
run(['cargo', 'test', '--locked', '-p', 'horizon-client', '--lib',
TEST, '--', '--exact', '--ignored', '--nocapture'], 'rust', env=env)
records = [line for line in (build / 'rust.log').read_text(encoding='utf-8').splitlines()
if re.fullmatch(r'HELMET \d+ -?\d+ -?\d+ [0-9a-f]{8} -?\d+', line)]
(build / 'rust.txt').write_text('\n'.join(records) + '\n', encoding='utf-8')
matched = base.compare(build / 'server.txt', build / 'rust.txt')
count = 2 * len(json.loads(fixtures.read_text(encoding='utf-8')))
if matched != count:
raise AssertionError(f'Incomplete output: {matched} of {count} cases')
base.ensure_sources_unchanged(sources, tested)
lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'],
sources_sha256=tested, server_jar_sha256=lab.digest(jars['server'], 'sha256'),
matched_output_lines=matched, output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
scope='Real Player.hurtHelmet, Player.hurtArmor, LivingEntity.doHurtEquipment, ItemStack durability, '
'onEquippedItemBroken modifier removal and armor reduction versus the production Game falling-hit path. '
'Includes bare heads, nondamageable head items, breaks in both equipment phases, ordinary armor '
'breaks, all three falling sources, a generic-source control and seeded Unbreaking with next RNG state. '
'Vanilla outer hurt guards/cooldown, effects, health storage, transport and player notifications '
'are not compared by this oracle; the Game test also checks both falling impacts and the /damage player path, '
'cooldown-sensitive repeat hits and that draining events cannot wear equipment twice.'))
print(f'{matched} helmet damage cases match pinned vanilla: {report}', flush=True)
if __name__ == '__main__':
main()