mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Fix falling damage helmet durability order
This commit is contained in:
1 parent
41f822b659
commit
3cdf31772f
15 files changed
+1007
-101
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -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"}
|
||||
]
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user