Skip to main content

steel_registry/data_components/components/
consumable.rs

1//! Vanilla consumable and death-protection item components.
2
3use std::io::{Cursor, Error, Result, Write};
4
5use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
6use simdnbt::{FromNbtTag, ToNbtTag};
7use steel_utils::codec::VarInt;
8use steel_utils::hash::{ComponentHasher, HashComponent, HashEntry, sort_map_entries};
9use steel_utils::nbt::NbtNumeric as _;
10use steel_utils::serial::{ReadFrom, WriteTo};
11
12use crate::consume_effect::ConsumeEffectData;
13use crate::sound_event::SoundEventHolder;
14
15/// Arm animation displayed while using an item.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
17pub enum ItemUseAnimation {
18    None,
19    #[default]
20    Eat,
21    Drink,
22    Block,
23    Bow,
24    Trident,
25    Crossbow,
26    Spyglass,
27    TootHorn,
28    Brush,
29    Bundle,
30    Spear,
31}
32
33impl ItemUseAnimation {
34    #[must_use]
35    pub const fn id(self) -> i32 {
36        match self {
37            Self::None => 0,
38            Self::Eat => 1,
39            Self::Drink => 2,
40            Self::Block => 3,
41            Self::Bow => 4,
42            Self::Trident => 5,
43            Self::Crossbow => 6,
44            Self::Spyglass => 7,
45            Self::TootHorn => 8,
46            Self::Brush => 9,
47            Self::Bundle => 10,
48            Self::Spear => 11,
49        }
50    }
51
52    #[must_use]
53    pub const fn serialized_name(self) -> &'static str {
54        match self {
55            Self::None => "none",
56            Self::Eat => "eat",
57            Self::Drink => "drink",
58            Self::Block => "block",
59            Self::Bow => "bow",
60            Self::Trident => "trident",
61            Self::Crossbow => "crossbow",
62            Self::Spyglass => "spyglass",
63            Self::TootHorn => "toot_horn",
64            Self::Brush => "brush",
65            Self::Bundle => "bundle",
66            Self::Spear => "spear",
67        }
68    }
69
70    #[must_use]
71    pub const fn by_id(id: i32) -> Self {
72        match id {
73            1 => Self::Eat,
74            2 => Self::Drink,
75            3 => Self::Block,
76            4 => Self::Bow,
77            5 => Self::Trident,
78            6 => Self::Crossbow,
79            7 => Self::Spyglass,
80            8 => Self::TootHorn,
81            9 => Self::Brush,
82            10 => Self::Bundle,
83            11 => Self::Spear,
84            _ => Self::None,
85        }
86    }
87
88    const fn from_serialized_name(name: &str) -> Option<Self> {
89        match name {
90            "none" => Some(Self::None),
91            "eat" => Some(Self::Eat),
92            "drink" => Some(Self::Drink),
93            "block" => Some(Self::Block),
94            "bow" => Some(Self::Bow),
95            "trident" => Some(Self::Trident),
96            "crossbow" => Some(Self::Crossbow),
97            "spyglass" => Some(Self::Spyglass),
98            "toot_horn" => Some(Self::TootHorn),
99            "brush" => Some(Self::Brush),
100            "bundle" => Some(Self::Bundle),
101            "spear" => Some(Self::Spear),
102            _ => None,
103        }
104    }
105}
106
107impl WriteTo for ItemUseAnimation {
108    fn write(&self, writer: &mut impl Write) -> Result<()> {
109        VarInt(self.id()).write(writer)
110    }
111}
112
113impl ReadFrom for ItemUseAnimation {
114    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
115        Ok(Self::by_id(VarInt::read(data)?.0))
116    }
117}
118
119/// Use timing, presentation, and post-consumption effects.
120#[derive(Debug, Clone)]
121pub struct Consumable {
122    consume_seconds: f32,
123    animation: ItemUseAnimation,
124    sound: SoundEventHolder,
125    has_consume_particles: bool,
126    on_consume_effects: Vec<ConsumeEffectData>,
127}
128
129impl Consumable {
130    pub const DEFAULT_CONSUME_SECONDS: f32 = 1.6;
131
132    pub fn new(
133        consume_seconds: f32,
134        animation: ItemUseAnimation,
135        sound: SoundEventHolder,
136        has_consume_particles: bool,
137        on_consume_effects: Vec<ConsumeEffectData>,
138    ) -> Result<Self> {
139        if !is_non_negative_float(consume_seconds) {
140            return Err(Error::other("Consume duration must be non-negative"));
141        }
142        Ok(Self {
143            consume_seconds,
144            animation,
145            sound,
146            has_consume_particles,
147            on_consume_effects,
148        })
149    }
150
151    pub(crate) fn from_extracted(
152        consume_seconds: f32,
153        animation: ItemUseAnimation,
154        sound: SoundEventHolder,
155        has_consume_particles: bool,
156        on_consume_effects: Vec<ConsumeEffectData>,
157    ) -> Self {
158        assert!(
159            is_non_negative_float(consume_seconds),
160            "extracted consume duration must be non-negative"
161        );
162        Self {
163            consume_seconds,
164            animation,
165            sound,
166            has_consume_particles,
167            on_consume_effects,
168        }
169    }
170
171    #[must_use]
172    pub const fn consume_seconds(&self) -> f32 {
173        self.consume_seconds
174    }
175
176    #[must_use]
177    pub fn consume_ticks(&self) -> i32 {
178        (self.consume_seconds * 20.0) as i32
179    }
180
181    #[must_use]
182    pub const fn animation(&self) -> ItemUseAnimation {
183        self.animation
184    }
185
186    #[must_use]
187    pub const fn sound(&self) -> &SoundEventHolder {
188        &self.sound
189    }
190
191    #[must_use]
192    pub const fn has_consume_particles(&self) -> bool {
193        self.has_consume_particles
194    }
195
196    #[must_use]
197    pub fn on_consume_effects(&self) -> &[ConsumeEffectData] {
198        &self.on_consume_effects
199    }
200
201    fn to_nbt_tag_ref(&self) -> NbtTag {
202        let mut compound = NbtCompound::new();
203        if !float_equals(self.consume_seconds, Self::DEFAULT_CONSUME_SECONDS) {
204            compound.insert("consume_seconds", self.consume_seconds);
205        }
206        if self.animation != ItemUseAnimation::Eat {
207            compound.insert("animation", self.animation.serialized_name());
208        }
209        if !is_default_eat_sound(&self.sound) {
210            compound.insert("sound", self.sound.clone().to_nbt_tag());
211        }
212        if !self.has_consume_particles {
213            compound.insert("has_consume_particles", false);
214        }
215        if !self.on_consume_effects.is_empty() {
216            compound.insert(
217                "on_consume_effects",
218                NbtList::Compound(
219                    self.on_consume_effects
220                        .iter()
221                        .map(|effect| match effect.to_nbt_tag_ref() {
222                            NbtTag::Compound(compound) => compound,
223                            _ => unreachable!("consume-effect codec always produces a compound"),
224                        })
225                        .collect(),
226                ),
227            );
228        }
229        NbtTag::Compound(compound)
230    }
231
232    fn from_owned_nbt(tag: &NbtTag) -> Option<Self> {
233        let compound = tag.compound()?;
234        let animation = match compound.get("animation") {
235            Some(tag) => ItemUseAnimation::from_serialized_name(&tag.string()?.to_string())?,
236            None => ItemUseAnimation::Eat,
237        };
238        let sound = match compound.get("sound") {
239            Some(tag) => SoundEventHolder::from_owned_nbt(tag)?,
240            None => default_eat_sound(),
241        };
242        let has_consume_particles = match compound.get("has_consume_particles") {
243            Some(tag) => tag.codec_bool()?,
244            None => true,
245        };
246        let on_consume_effects = match compound.get("on_consume_effects") {
247            Some(tag) => tag
248                .list()?
249                .as_nbt_tags()
250                .iter()
251                .map(ConsumeEffectData::from_owned_nbt)
252                .collect::<Option<Vec<_>>>()?,
253            None => Vec::new(),
254        };
255        Self::new(
256            optional_f32(
257                compound.get("consume_seconds"),
258                Self::DEFAULT_CONSUME_SECONDS,
259            )?,
260            animation,
261            sound,
262            has_consume_particles,
263            on_consume_effects,
264        )
265        .ok()
266    }
267}
268
269impl PartialEq for Consumable {
270    fn eq(&self, other: &Self) -> bool {
271        float_equals(self.consume_seconds, other.consume_seconds)
272            && self.animation == other.animation
273            && self.sound == other.sound
274            && self.has_consume_particles == other.has_consume_particles
275            && self.on_consume_effects == other.on_consume_effects
276    }
277}
278
279impl WriteTo for Consumable {
280    fn write(&self, writer: &mut impl Write) -> Result<()> {
281        self.consume_seconds.write(writer)?;
282        self.animation.write(writer)?;
283        self.sound.write(writer)?;
284        self.has_consume_particles.write(writer)?;
285        write_effect_list(&self.on_consume_effects, writer)
286    }
287}
288
289impl ReadFrom for Consumable {
290    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
291        Self::new(
292            f32::read(data)?,
293            ItemUseAnimation::read(data)?,
294            SoundEventHolder::read(data)?,
295            bool::read(data)?,
296            read_effect_list(data)?,
297        )
298    }
299}
300
301impl ToNbtTag for Consumable {
302    fn to_nbt_tag(self) -> NbtTag {
303        self.to_nbt_tag_ref()
304    }
305}
306
307impl FromNbtTag for Consumable {
308    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
309        Self::from_owned_nbt(&tag.to_owned())
310    }
311}
312
313impl HashComponent for Consumable {
314    fn hash_component(&self, hasher: &mut ComponentHasher) {
315        let mut entries = Vec::with_capacity(5);
316        if !float_equals(self.consume_seconds, Self::DEFAULT_CONSUME_SECONDS) {
317            push_hash_entry(&mut entries, "consume_seconds", &self.consume_seconds);
318        }
319        if self.animation != ItemUseAnimation::Eat {
320            push_hash_entry(&mut entries, "animation", self.animation.serialized_name());
321        }
322        if !is_default_eat_sound(&self.sound) {
323            push_hash_entry(&mut entries, "sound", &self.sound);
324        }
325        if !self.has_consume_particles {
326            push_hash_entry(&mut entries, "has_consume_particles", &false);
327        }
328        if !self.on_consume_effects.is_empty() {
329            push_hash_entry(
330                &mut entries,
331                "on_consume_effects",
332                &ConsumeEffectList(&self.on_consume_effects),
333            );
334        }
335        hash_entries(hasher, &mut entries);
336    }
337}
338
339/// Effects applied when an item prevents death.
340#[derive(Debug, Default, Clone, PartialEq)]
341pub struct DeathProtection {
342    death_effects: Vec<ConsumeEffectData>,
343}
344
345impl DeathProtection {
346    #[must_use]
347    pub const fn new(death_effects: Vec<ConsumeEffectData>) -> Self {
348        Self { death_effects }
349    }
350
351    #[must_use]
352    pub fn death_effects(&self) -> &[ConsumeEffectData] {
353        &self.death_effects
354    }
355}
356
357impl WriteTo for DeathProtection {
358    fn write(&self, writer: &mut impl Write) -> Result<()> {
359        write_effect_list(&self.death_effects, writer)
360    }
361}
362
363impl ReadFrom for DeathProtection {
364    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
365        Ok(Self::new(read_effect_list(data)?))
366    }
367}
368
369impl ToNbtTag for DeathProtection {
370    fn to_nbt_tag(self) -> NbtTag {
371        let mut compound = NbtCompound::new();
372        if !self.death_effects.is_empty() {
373            compound.insert(
374                "death_effects",
375                NbtList::Compound(
376                    self.death_effects
377                        .iter()
378                        .map(|effect| match effect.to_nbt_tag_ref() {
379                            NbtTag::Compound(compound) => compound,
380                            _ => unreachable!("consume-effect codec always produces a compound"),
381                        })
382                        .collect(),
383                ),
384            );
385        }
386        NbtTag::Compound(compound)
387    }
388}
389
390impl FromNbtTag for DeathProtection {
391    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
392        let tag = tag.to_owned();
393        let compound = tag.compound()?;
394        let death_effects = match compound.get("death_effects") {
395            Some(tag) => tag
396                .list()?
397                .as_nbt_tags()
398                .iter()
399                .map(ConsumeEffectData::from_owned_nbt)
400                .collect::<Option<Vec<_>>>()?,
401            None => Vec::new(),
402        };
403        Some(Self::new(death_effects))
404    }
405}
406
407impl HashComponent for DeathProtection {
408    fn hash_component(&self, hasher: &mut ComponentHasher) {
409        let mut entries = Vec::with_capacity(1);
410        if !self.death_effects.is_empty() {
411            push_hash_entry(
412                &mut entries,
413                "death_effects",
414                &ConsumeEffectList(&self.death_effects),
415            );
416        }
417        hash_entries(hasher, &mut entries);
418    }
419}
420
421struct ConsumeEffectList<'a>(&'a [ConsumeEffectData]);
422
423impl HashComponent for ConsumeEffectList<'_> {
424    fn hash_component(&self, hasher: &mut ComponentHasher) {
425        hasher.start_list();
426        for effect in self.0 {
427            hasher.put_component_hash(effect);
428        }
429        hasher.end_list();
430    }
431}
432
433fn default_eat_sound() -> SoundEventHolder {
434    SoundEventHolder::registry(&crate::sound_events::ENTITY_GENERIC_EAT)
435}
436
437fn is_default_eat_sound(sound: &SoundEventHolder) -> bool {
438    sound == &default_eat_sound()
439}
440
441fn write_effect_list(effects: &[ConsumeEffectData], writer: &mut impl Write) -> Result<()> {
442    let count = i32::try_from(effects.len())
443        .map_err(|_| Error::other("Consume effect list is too large"))?;
444    VarInt(count).write(writer)?;
445    for effect in effects {
446        effect.write(writer)?;
447    }
448    Ok(())
449}
450
451fn read_effect_list(data: &mut Cursor<&[u8]>) -> Result<Vec<ConsumeEffectData>> {
452    let count = VarInt::read(data)?.0;
453    let count = usize::try_from(count)
454        .map_err(|_| Error::other(format!("Negative consume effect count: {count}")))?;
455    let mut effects = Vec::with_capacity(count.min(65_536));
456    for _ in 0..count {
457        effects.push(ConsumeEffectData::read(data)?);
458    }
459    Ok(effects)
460}
461
462const fn float_equals(left: f32, right: f32) -> bool {
463    (left.is_nan() && right.is_nan()) || left.to_bits() == right.to_bits()
464}
465
466const fn is_non_negative_float(value: f32) -> bool {
467    value.is_finite() && !value.is_sign_negative()
468}
469
470fn optional_f32(tag: Option<&NbtTag>, default: f32) -> Option<f32> {
471    match tag {
472        Some(tag) => tag.codec_f32(),
473        None => Some(default),
474    }
475}
476
477fn push_hash_entry<T: HashComponent + ?Sized>(entries: &mut Vec<HashEntry>, key: &str, value: &T) {
478    let mut key_hasher = ComponentHasher::new();
479    key_hasher.put_string(key);
480    let mut value_hasher = ComponentHasher::new();
481    value.hash_component(&mut value_hasher);
482    entries.push(HashEntry::new(key_hasher, value_hasher));
483}
484
485fn hash_entries(hasher: &mut ComponentHasher, entries: &mut [HashEntry]) {
486    sort_map_entries(entries);
487    hasher.start_map();
488    for entry in entries {
489        hasher.put_raw_bytes(&entry.key_bytes);
490        hasher.put_raw_bytes(&entry.value_bytes);
491    }
492    hasher.end_map();
493}
494
495#[cfg(test)]
496mod tests {
497    use std::io::Cursor;
498
499    use steel_utils::codec::VarInt;
500    use steel_utils::serial::{ReadFrom as _, WriteTo as _};
501
502    use super::{Consumable, DeathProtection, ItemUseAnimation};
503    use crate::consume_effect::{
504        ApplyStatusEffectsConsumeEffect, ClearAllStatusEffectsConsumeEffect,
505        RemoveStatusEffectsConsumeEffect, TeleportRandomlyConsumeEffect,
506    };
507    use crate::data_components::vanilla_components::{CONSUMABLE, DEATH_PROTECTION};
508    use crate::init_vanilla_registry;
509    use crate::{REGISTRY, RegistryExt};
510
511    fn parse<T: simdnbt::FromNbtTag>(tag: simdnbt::owned::NbtTag) -> Option<T> {
512        let mut bytes = Vec::new();
513        tag.write(&mut bytes);
514        let borrowed = simdnbt::borrow::read_tag(&mut Cursor::new(bytes.as_slice())).ok()?;
515        T::from_nbt_tag(borrowed.as_tag())
516    }
517
518    fn assert_round_trip<T>(value: &T)
519    where
520        T: Clone
521            + PartialEq
522            + std::fmt::Debug
523            + simdnbt::ToNbtTag
524            + simdnbt::FromNbtTag
525            + steel_utils::serial::WriteTo
526            + steel_utils::serial::ReadFrom,
527    {
528        assert_eq!(parse(value.clone().to_nbt_tag()), Some(value.clone()));
529        let mut network = Vec::new();
530        value.write(&mut network).expect("component should encode");
531        assert_eq!(
532            T::read(&mut Cursor::new(network.as_slice())).expect("component should decode"),
533            value.clone()
534        );
535    }
536
537    #[test]
538    fn extracted_consumables_keep_typed_effects_and_round_trip() {
539        init_vanilla_registry();
540        let golden_apple = REGISTRY
541            .items
542            .by_key(&steel_utils::Identifier::vanilla_static("golden_apple"))
543            .expect("golden apple should be registered")
544            .components
545            .get(CONSUMABLE)
546            .expect("golden apple should be consumable");
547        let apply = golden_apple.on_consume_effects()[0]
548            .downcast_ref::<ApplyStatusEffectsConsumeEffect>()
549            .expect("golden apple should apply effects");
550        assert_eq!(apply.effects().len(), 2);
551        assert_round_trip(&golden_apple);
552
553        let milk = REGISTRY
554            .items
555            .by_key(&steel_utils::Identifier::vanilla_static("milk_bucket"))
556            .expect("milk bucket should be registered")
557            .components
558            .get(CONSUMABLE)
559            .expect("milk should be consumable");
560        assert_eq!(milk.animation(), ItemUseAnimation::Drink);
561        assert_eq!(
562            milk.consume_ticks(),
563            (milk.consume_seconds() * 20.0) as i32,
564            "consume_ticks must match vanilla (int)(seconds * 20)"
565        );
566        assert!(!milk.has_consume_particles());
567        assert!(
568            milk.on_consume_effects()[0]
569                .downcast_ref::<ClearAllStatusEffectsConsumeEffect>()
570                .is_some()
571        );
572        assert_round_trip(&milk);
573
574        let honey = REGISTRY
575            .items
576            .by_key(&steel_utils::Identifier::vanilla_static("honey_bottle"))
577            .expect("honey bottle should be registered")
578            .components
579            .get(CONSUMABLE)
580            .expect("honey should be consumable");
581        assert!(
582            honey.on_consume_effects()[0]
583                .downcast_ref::<RemoveStatusEffectsConsumeEffect>()
584                .is_some()
585        );
586        assert_round_trip(&honey);
587    }
588
589    #[test]
590    fn extracted_totem_death_protection_round_trips() {
591        init_vanilla_registry();
592        let totem = REGISTRY
593            .items
594            .by_key(&steel_utils::Identifier::vanilla_static("totem_of_undying"))
595            .expect("totem should be registered")
596            .components
597            .get(DEATH_PROTECTION)
598            .expect("totem should provide death protection");
599        assert_eq!(totem.death_effects().len(), 2);
600        assert!(
601            totem.death_effects()[0]
602                .downcast_ref::<ClearAllStatusEffectsConsumeEffect>()
603                .is_some()
604        );
605        assert_round_trip::<DeathProtection>(&totem);
606    }
607
608    #[test]
609    fn network_animation_ids_use_vanilla_zero_fallback() {
610        let mut bytes = Vec::new();
611        VarInt(i32::MAX)
612            .write(&mut bytes)
613            .expect("test id should encode");
614        assert_eq!(
615            ItemUseAnimation::read(&mut Cursor::new(bytes.as_slice()))
616                .expect("unknown id should decode"),
617            ItemUseAnimation::None
618        );
619        assert!(
620            Consumable::new(
621                -1.0,
622                ItemUseAnimation::Eat,
623                crate::sound_event::SoundEventHolder::Direct {
624                    sound_id: steel_utils::Identifier::vanilla_static("test"),
625                    fixed_range: None,
626                },
627                true,
628                Vec::new(),
629            )
630            .is_err()
631        );
632    }
633
634    #[test]
635    fn persistent_float_ranges_match_vanilla_float_compare_bounds() {
636        let sound = || crate::sound_event::SoundEventHolder::Direct {
637            sound_id: steel_utils::Identifier::vanilla_static("test"),
638            fixed_range: None,
639        };
640        for invalid in [-0.0, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
641            assert!(
642                Consumable::new(invalid, ItemUseAnimation::Eat, sound(), true, Vec::new()).is_err()
643            );
644        }
645        assert!(Consumable::new(0.0, ItemUseAnimation::Eat, sound(), true, Vec::new()).is_ok());
646
647        for invalid in [0.0, -0.0, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
648            assert!(TeleportRandomlyConsumeEffect::new(invalid).is_err());
649        }
650        assert!(TeleportRandomlyConsumeEffect::new(f32::MAX).is_ok());
651
652        for invalid in [-0.0, 1.1, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
653            assert!(ApplyStatusEffectsConsumeEffect::new(Vec::new(), invalid).is_err());
654        }
655        assert!(ApplyStatusEffectsConsumeEffect::new(Vec::new(), 0.0).is_ok());
656        assert!(ApplyStatusEffectsConsumeEffect::new(Vec::new(), 1.0).is_ok());
657    }
658}