Merge pull request #11033 from DUzzL/fix/10587-death-protection-item

Death protection: use the death_protection item and its effects, for players and mobs
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:26:31 +00:00
commit a8055893a2
4 files changed
+284 -62

No files matched your search

+1
View File
@@ -12759,6 +12759,7 @@ impl Game<'_> {
.collect()
}
fn tick_mobs(&mut self) -> Result<()> {
self.sync_mob_death_protection();
self.tick_creatures()?;
if !self.entity_attachments.is_empty() {
self.prune_entity_attachments();
+103 -42
View File
@@ -1,10 +1,10 @@
//! The totem of undying (`LivingEntity.checkTotemDeathProtection` with the
//! totem's `death_protection` component, entity event 35 on the client).
//! The totem of undying (`LivingEntity.checkTotemDeathProtection`: a hand's
//! item carrying the `death_protection` component, whatever item it is, and
//! entity event 35 on the client).
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
effects::EffectInstance,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
@@ -16,18 +16,64 @@ const TOTEM: &str = "minecraft:totem_of_undying";
pub(super) const TOTEM_EVENT: i8 = 35;
impl Game<'_> {
/// Whether either hand holds a totem, for the next hurt: the window's
/// player once a tick, a server's player for each of its turns.
/// Whether the stack has `DataComponents.DEATH_PROTECTION`, whatever
/// item it is: the totem of undying is only one item that carries it.
fn has_death_protection(&self, s: &ItemStack) -> bool {
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(s))
.get("minecraft:death_protection")
.is_some()
}
/// The `death_effects` of the stack's `minecraft:death_protection`.
fn death_effects(&self, s: &ItemStack) -> Vec<horizon_client::items::ConsumeEffect> {
death_effects_of(&self.pack.item_renderer.models, s)
}
/// What each armed mob's hands carry as `minecraft:death_protection`, for
/// `Mob::use_death_protection` (the mob has no item data of its own): any
/// item with the component counts, not only the totem.
pub(super) fn sync_mob_death_protection(&mut self) {
let models = &self.pack.item_renderer.models;
let of = |id: Option<&std::sync::Arc<str>>, stack: Option<&ItemStack>| {
let stack = match (stack, id) {
(Some(s), _) => s.clone(),
(None, Some(id)) => ItemStack::new(id, 1),
(None, None) => return None,
};
models
.components(&horizon_client::item_render::stack_view(&stack))
.get("minecraft:death_protection")
.map(horizon_client::items::death_effects)
};
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
if m.extra.main_hand.is_none() && m.extra.off_hand.is_none() {
m.extra.hand_protection = None;
continue;
}
m.extra.hand_protection = Some(horizon_client::mobs::HandProtection {
main: of(m.extra.main_hand.as_ref(), m.extra.main_hand_stack()),
off: of(m.extra.off_hand.as_ref(), None),
});
}
}
/// Whether either hand holds a death-protection item, for the next hurt:
/// the window's player once a tick, a server's player for each of its
/// turns.
pub(super) fn update_totem_ready(&mut self) {
let ready = self.held().is_some_and(|s| &*s.item == TOTEM)
|| self.offhand.as_ref().is_some_and(|s| &*s.item == TOTEM);
let ready = self.held().is_some_and(|s| self.has_death_protection(s))
|| self
.offhand
.as_ref()
.is_some_and(|s| self.has_death_protection(s));
if let Some(p) = self.player.as_mut() {
p.health.totem_ready = ready;
}
}
/// The server's half once a totem fired: use it up (main hand first),
/// count it, set health to 1, then clear and apply its effects. Returns
/// the player's body, for entity event 35, which the caller broadcasts.
/// the player's body, for entity event 35, which the caller broadcasts,
/// with the protector copy the activation animation shows.
pub(super) fn apply_totem(&mut self) -> Option<Body> {
if !self.player.as_ref()?.health.totem_used {
return None;
@@ -43,42 +89,43 @@ impl Game<'_> {
at: [p.position.x, p.position.y, p.position.z],
width: f64::from(p.dimensions.width),
height: f64::from(p.dimensions.height),
protector: None,
};
let slot = if self.held().is_some_and(|s| &*s.item == TOTEM) {
Some(self.selected)
// `checkTotemDeathProtection`: the selected slot first, then the
// offhand. The copy of that stack is the protector (`protectionItem`)
// the use is counted and the activation animation shown with, taken
// before the use takes one off the stack.
let main = self.held().is_some_and(|s| self.has_death_protection(s));
let cell = if main {
&mut self.inventory[self.selected]
} else {
None
};
let cell = match slot {
Some(i) => &mut self.inventory[i],
None => &mut self.offhand,
&mut self.offhand
};
let protector = cell
.as_ref()
.cloned()
.unwrap_or_else(|| ItemStack::new(TOTEM, 1));
if let Some(s) = cell.as_mut() {
s.count -= 1;
if s.count == 0 {
*cell = None;
}
}
self.award_stat("used", TOTEM, 1);
self.trigger_item(
"minecraft:used_totem",
&horizon_client::items::ItemStack::new(TOTEM, 1),
);
self.award_stat("used", &protector.item, 1);
self.trigger_item("minecraft:used_totem", &protector);
// ItemStack.causeUseVibration: the totem's use effects make one.
let ctx = self.player_context(None);
self.post_game_event("minecraft:item_interact_finish", body.at, ctx);
// DeathProtection.TOTEM_EFFECTS: clear, then regeneration II, absorption II, fire resistance.
self.potions.effects.clear();
for (id, ticks, amp) in [
("regeneration", 900, 1),
("absorption", 100, 1),
("fire_resistance", 800, 0),
] {
if let Some(e) = horizon_client::brewing::effect(id) {
self.add_player_effect(EffectInstance::new(e.id, ticks, amp));
}
// `DeathProtection.applyEffects`: the component's own `death_effects`,
// read from the protector copy (the totem's clear-then-effects, or a
// custom list; `death_protection={}` has none).
for effect in self.death_effects(&protector) {
self.apply_consume_effect(&effect);
}
Some(body)
Some(Body {
protector: Some(protector),
..body
})
}
/// The window's player's totem: the server's half, then the event to
/// the guests and to this window's own client.
@@ -97,12 +144,14 @@ impl Game<'_> {
at: [p.position.x, p.position.y, p.position.z],
width: f64::from(p.dimensions.width),
height: f64::from(p.dimensions.height),
protector: None,
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| Body {
at: [m.position.x, m.position.y, m.position.z],
width: f64::from(m.width()),
height: f64::from(m.height()),
protector: None,
})
};
if let Some(body) = body {
@@ -126,8 +175,10 @@ impl Game<'_> {
self.pad_rumble(horizon_client::gamepad::Rumble::Totem);
// findTotem, then LocalPlayer.displayItemActivation. The offsets
// come from the player's own random in vanilla, not the level's:
// the client-only particle random stands in for it.
let totem = self.find_totem();
// the client-only particle random stands in for it. The server
// half hands over the stack it used up; only an event from a
// remote server, whose item is still in the hand, looks one up.
let totem = body.protector.unwrap_or_else(|| self.find_totem());
self.item_activation
.activate(totem, self.particles.random());
}
@@ -136,16 +187,10 @@ impl Game<'_> {
/// `death_protection`, else a new totem (a used single totem is gone by
/// the time the event arrives).
fn find_totem(&self) -> ItemStack {
let models = &self.pack.item_renderer.models;
[self.held(), self.offhand.as_ref()]
.into_iter()
.flatten()
.find(|s| {
models
.components(&horizon_client::item_render::stack_view(s))
.get("minecraft:death_protection")
.is_some()
})
.find(|s| self.has_death_protection(s))
.cloned()
.unwrap_or_else(|| ItemStack::new(TOTEM, 1))
}
@@ -190,10 +235,26 @@ impl Game<'_> {
}
}
/// Where an entity event's entity stands and its size.
#[derive(Clone, Copy)]
/// The `death_effects` of the stack's `minecraft:death_protection`, if it has
/// the component.
fn death_effects_of(
models: &horizon_assets::item_model::ItemModels,
s: &ItemStack,
) -> Vec<horizon_client::items::ConsumeEffect> {
models
.components(&horizon_client::item_render::stack_view(s))
.get("minecraft:death_protection")
.map(horizon_client::items::death_effects)
.unwrap_or_default()
}
/// Where an entity event's entity stands and its size, and, for the event
/// this window's own player fires after the server half used a stack up, the
/// protector copy the activation animation shows.
#[derive(Clone)]
pub(super) struct Body {
pub at: [f64; 3],
width: f64,
height: f64,
protector: Option<ItemStack>,
}
+124 -20
View File
@@ -1328,6 +1328,17 @@ pub struct Extra {
/// (`LivingEntity.lastEquipmentItems`, empty at first), by
/// `EquipmentSlot` ordinal: see `Mob::equipment_stacks`.
pub equipment_heard: Option<Box<[Option<crate::items::ItemStack>; 8]>>,
/// What the game last read from the hands' `minecraft:death_protection`
/// components (`game/totem.rs`); `None` until it has, when the totem's id
/// stands in for the component.
pub hand_protection: Option<HandProtection>,
}
/// The `death_effects` of the `death_protection` component each hand's stack
/// carries; `None` for a hand without the component.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HandProtection {
pub main: Option<Vec<crate::items::ConsumeEffect>>,
pub off: Option<Vec<crate::items::ConsumeEffect>>,
}
impl Extra {
pub fn set_main_hand(&mut self, item: Option<Arc<str>>) {
@@ -1349,6 +1360,30 @@ impl Extra {
}
}
/// `DeathProtection.TOTEM_EFFECTS`: clear, then regeneration II, absorption II
/// and fire resistance.
fn totem_death_effects() -> Vec<crate::items::ConsumeEffect> {
use crate::items::{ConsumeEffect, ConsumeEffectSpec};
let spec = |id: &str, duration, amplifier| ConsumeEffectSpec {
id: id.to_owned(),
duration,
amplifier,
ambient: false,
show_particles: true,
};
vec![
ConsumeEffect::ClearAllEffects,
ConsumeEffect::ApplyEffects {
effects: vec![
spec("minecraft:regeneration", 900, 1),
spec("minecraft:absorption", 100, 1),
spec("minecraft:fire_resistance", 800, 0),
],
probability: 1.0,
},
]
}
/// How far the boxes of a set of mobs reach from their positions: the widest half width and the
/// tallest height among them. A box query over many mobs (a vehicle's, against every mob of the
/// level) tests each mob's position against this first, and works out the box ([`Mob::bounds`],
@@ -4003,34 +4038,52 @@ impl Mob {
through
}
/// `LivingEntity.checkTotemDeathProtection`: the first hand with a
/// `death_protection` item uses it up, the mob survives at 1 health with
/// the totem's effects, and entity event 35 tells the clients.
/// `death_protection` stack uses one item up, the mob survives at 1 health
/// with that component's `death_effects`, and entity event 35 tells the
/// clients.
fn use_death_protection(&mut self, source: &Damage) -> bool {
// `DamageTypeTags.BYPASSES_INVULNERABILITY`: the void and /kill take it.
if source.kind.bypasses_invulnerability() {
return false;
}
// `getItemInHand`: the main hand first. The totem is the only item in
// the shipped data with `minecraft:death_protection`.
// `getItemInHand`: the main hand first. The game reads the components
// (`hand_protection`); before it has, the totem is the only item in the
// shipped data with `minecraft:death_protection`.
const TOTEM: &str = "minecraft:totem_of_undying";
let main = self.extra.main_hand.as_deref() == Some(TOTEM);
let off = !main && self.extra.off_hand.as_deref() == Some(TOTEM);
if !main && !off {
return false;
}
if main {
self.extra.set_main_hand(None);
} else {
let (main, off) = match self.extra.hand_protection.clone() {
Some(h) => (h.main, h.off),
None => {
let totem = || Some(totem_death_effects());
(
(self.extra.main_hand.as_deref() == Some(TOTEM))
.then(totem)
.flatten(),
(self.extra.off_hand.as_deref() == Some(TOTEM))
.then(totem)
.flatten(),
)
}
};
// `itemStack.shrink(1)`: a held stack of several keeps the rest.
let effects = if let Some(effects) = main {
match self.extra.main_hand_stack.as_mut() {
Some(s) if s.count > 1 && self.extra.main_hand.as_ref() == Some(&s.item) => {
s.count -= 1;
}
_ => self.extra.set_main_hand(None),
}
effects
} else if let Some(effects) = off {
self.extra.off_hand = None;
}
// `setHealth(1.0F)`, then `DeathProtection.TOTEM_EFFECTS`.
effects
} else {
return false;
};
self.extra.hand_protection = None;
// `setHealth(1.0F)`, then `DeathProtection.applyEffects`.
self.health = 1.0;
for (effect, ticks, amplifier) in [
("regeneration", 900, 1),
("absorption", 100, 1),
("fire_resistance", 800, 0),
] {
self.add_effect(effect, ticks, amplifier);
for effect in &effects {
self.apply_death_effect(effect);
}
let at = [self.position.x, self.position.y, self.position.z];
self.outbox.push(MobEvent::EntityEvent {
@@ -4040,6 +4093,57 @@ impl Mob {
});
true
}
/// One `ConsumeEffect` of a `death_protection` component on the mob.
/// Teleporting, sounds and a mod's own kinds are not applied to mobs.
fn apply_death_effect(&mut self, effect: &crate::items::ConsumeEffect) {
use crate::items::ConsumeEffect;
match effect {
// `LivingEntity.removeAllEffects`: the absorption hearts go with it.
ConsumeEffect::ClearAllEffects => {
for e in std::mem::take(&mut self.extra.effects) {
if e.0 == "minecraft:absorption" {
self.absorption = (self.absorption - 4.0 * (e.2 + 1) as f32).max(0.0);
}
}
}
ConsumeEffect::RemoveEffects(ids) => {
self.extra.effects.retain(|e| {
let held = e.0.trim_start_matches("minecraft:");
!ids.iter()
.any(|id| id.trim_start_matches("minecraft:") == held)
});
}
// `ApplyStatusEffectsConsumeEffect.apply`: one random draw, even at
// probability 1.
ConsumeEffect::ApplyEffects {
effects,
probability,
} => {
if self.random.next_float() >= *probability {
return;
}
for spec in effects {
let Some(def) = horizon_client_core::potions::effect(&spec.id) else {
continue;
};
if def.instant {
match def.id {
"instant_damage" | "instant_health" => self.apply_heal_or_harm(
def.id == "instant_damage",
self.kind.flags.undead,
spec.amplifier,
1.0,
),
_ => {}
}
} else {
self.add_effect(def.id, spec.duration, spec.amplifier);
}
}
}
_ => {}
}
}
/// `LivingEntity.heal`, with the native heal hook.
pub fn heal(&mut self, mut amount: f32) {
let mut cancel = false;
+56
View File
@@ -2306,6 +2306,62 @@ pub fn bucket_contents(
}
/// One `MobEffectInstance` of an `apply_effects` consume effect (its codec's defaults).
/// `DeathProtection.CODEC`: the `death_effects` of a `minecraft:death_protection`
/// component, in order (`applyEffects` runs each one). A missing or empty
/// list, as in `death_protection={}`, has none.
pub fn death_effects(component: &Value) -> Vec<ConsumeEffect> {
component
.get("death_effects")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(parse_consume_effect)
.collect()
}
#[cfg(test)]
mod death_effects_tests {
use super::*;
#[test]
fn the_totems_component_reads_clear_then_three_effects() {
let totem = serde_json::json!({"death_effects": [
{"type": "minecraft:clear_all_effects"},
{"type": "minecraft:apply_effects", "effects": [
{"amplifier": 1, "duration": 900, "id": "minecraft:regeneration", "show_icon": true},
{"amplifier": 1, "duration": 100, "id": "minecraft:absorption", "show_icon": true},
{"duration": 800, "id": "minecraft:fire_resistance", "show_icon": true}
]}
]});
let effects = death_effects(&totem);
assert_eq!(effects.len(), 2);
assert_eq!(effects[0], ConsumeEffect::ClearAllEffects);
let ConsumeEffect::ApplyEffects {
effects,
probability,
} = &effects[1]
else {
panic!("second effect is apply_effects");
};
assert_eq!(*probability, 1.0);
let ids: Vec<_> = effects
.iter()
.map(|e| (e.id.as_str(), e.duration, e.amplifier))
.collect();
assert_eq!(
ids,
[
("minecraft:regeneration", 900, 1),
("minecraft:absorption", 100, 1),
("minecraft:fire_resistance", 800, 0)
]
);
}
#[test]
fn an_empty_component_has_no_effects() {
assert!(death_effects(&serde_json::json!({})).is_empty());
}
}
fn consume_effect_spec(e: &Value) -> Option<ConsumeEffectSpec> {
let flag = |key: &str, default: bool| e.get(key).and_then(|v| v.as_bool()).unwrap_or(default);
Some(ConsumeEffectSpec {