Skip to main content

steel_registry/data_components/components/
combat.rs

1//! Combat-related item components.
2
3use std::io::{Cursor, Error, Result, Write};
4use std::str::FromStr;
5
6use simdnbt::owned::{NbtCompound, NbtTag};
7use simdnbt::{FromNbtTag, ToNbtTag};
8use steel_utils::Identifier;
9use steel_utils::codec::VarInt;
10use steel_utils::hash::{ComponentHasher, HashComponent, HashEntry, sort_map_entries};
11use steel_utils::nbt::NbtNumeric as _;
12use steel_utils::serial::{ReadFrom, WriteTo};
13
14use crate::damage_type::DamageTypeRef;
15use crate::sound_event::SoundEventHolder;
16use crate::{REGISTRY, RegistryEntry, RegistryExt};
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct DamageTypeComponent {
20    pub damage_type: DamageTypeRef,
21}
22
23impl DamageTypeComponent {
24    #[must_use]
25    pub const fn new(damage_type: DamageTypeRef) -> Self {
26        Self { damage_type }
27    }
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct Weapon {
32    pub item_damage_per_attack: i32,
33    pub disable_blocking_for_seconds: f32,
34}
35
36impl Default for Weapon {
37    fn default() -> Self {
38        Self {
39            item_damage_per_attack: 1,
40            disable_blocking_for_seconds: 0.0,
41        }
42    }
43}
44
45impl WriteTo for DamageTypeComponent {
46    fn write(&self, writer: &mut impl Write) -> Result<()> {
47        let id = self.damage_type.try_id().ok_or_else(|| {
48            Error::other(format!("Unknown damage type: {}", self.damage_type.key))
49        })?;
50        let id = i32::try_from(id)
51            .map_err(|_| Error::other(format!("Damage type id out of protocol range: {id}")))?;
52        VarInt(id).write(writer)
53    }
54}
55
56impl ReadFrom for DamageTypeComponent {
57    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
58        let id = VarInt::read(data)?.0;
59        let id = usize::try_from(id)
60            .map_err(|_| Error::other(format!("Negative damage type id: {id}")))?;
61        let damage_type = REGISTRY
62            .damage_types
63            .by_id(id)
64            .ok_or_else(|| Error::other(format!("Unknown damage type id: {id}")))?;
65        Ok(Self { damage_type })
66    }
67}
68
69impl ToNbtTag for DamageTypeComponent {
70    fn to_nbt_tag(self) -> NbtTag {
71        self.damage_type.key.to_string().to_nbt_tag()
72    }
73}
74
75impl FromNbtTag for DamageTypeComponent {
76    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
77        let id = Identifier::from_str(&tag.string()?.to_str()).ok()?;
78        REGISTRY
79            .damage_types
80            .by_key(&id)
81            .map(|damage_type| Self { damage_type })
82    }
83}
84
85#[derive(Debug, Clone, PartialEq)]
86pub struct AttackRange {
87    pub min_reach: f32,
88    pub max_reach: f32,
89    pub min_creative_reach: f32,
90    pub max_creative_reach: f32,
91    pub hitbox_margin: f32,
92    pub mob_factor: f32,
93}
94
95impl Default for AttackRange {
96    fn default() -> Self {
97        Self {
98            min_reach: 0.0,
99            max_reach: 3.0,
100            min_creative_reach: 0.0,
101            max_creative_reach: 5.0,
102            hitbox_margin: 0.3,
103            mob_factor: 1.0,
104        }
105    }
106}
107
108#[derive(Debug, Clone, PartialEq)]
109pub struct PiercingWeapon {
110    pub deals_knockback: bool,
111    pub dismounts: bool,
112    pub sound: Option<SoundEventHolder>,
113    pub hit_sound: Option<SoundEventHolder>,
114}
115
116impl Default for PiercingWeapon {
117    fn default() -> Self {
118        Self {
119            deals_knockback: true,
120            dismounts: false,
121            sound: None,
122            hit_sound: None,
123        }
124    }
125}
126
127impl WriteTo for Weapon {
128    fn write(&self, writer: &mut impl Write) -> Result<()> {
129        VarInt(self.item_damage_per_attack).write(writer)?;
130        self.disable_blocking_for_seconds.write(writer)
131    }
132}
133
134impl ReadFrom for Weapon {
135    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
136        Ok(Self {
137            item_damage_per_attack: VarInt::read(data)?.0,
138            disable_blocking_for_seconds: f32::read(data)?,
139        })
140    }
141}
142
143impl WriteTo for AttackRange {
144    fn write(&self, writer: &mut impl Write) -> Result<()> {
145        self.min_reach.write(writer)?;
146        self.max_reach.write(writer)?;
147        self.min_creative_reach.write(writer)?;
148        self.max_creative_reach.write(writer)?;
149        self.hitbox_margin.write(writer)?;
150        self.mob_factor.write(writer)
151    }
152}
153
154impl ReadFrom for AttackRange {
155    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
156        Ok(Self {
157            min_reach: f32::read(data)?,
158            max_reach: f32::read(data)?,
159            min_creative_reach: f32::read(data)?,
160            max_creative_reach: f32::read(data)?,
161            hitbox_margin: f32::read(data)?,
162            mob_factor: f32::read(data)?,
163        })
164    }
165}
166
167impl WriteTo for PiercingWeapon {
168    fn write(&self, writer: &mut impl Write) -> Result<()> {
169        self.deals_knockback.write(writer)?;
170        self.dismounts.write(writer)?;
171        self.sound.write(writer)?;
172        self.hit_sound.write(writer)
173    }
174}
175
176impl ReadFrom for PiercingWeapon {
177    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
178        Ok(Self {
179            deals_knockback: bool::read(data)?,
180            dismounts: bool::read(data)?,
181            sound: Option::<SoundEventHolder>::read(data)?,
182            hit_sound: Option::<SoundEventHolder>::read(data)?,
183        })
184    }
185}
186
187impl ToNbtTag for Weapon {
188    fn to_nbt_tag(self) -> NbtTag {
189        let mut compound = NbtCompound::new();
190        if self.item_damage_per_attack != 1 {
191            compound.insert("item_damage_per_attack", self.item_damage_per_attack);
192        }
193        if self.disable_blocking_for_seconds.to_bits() != 0.0_f32.to_bits() {
194            compound.insert(
195                "disable_blocking_for_seconds",
196                self.disable_blocking_for_seconds,
197            );
198        }
199        NbtTag::Compound(compound)
200    }
201}
202
203impl FromNbtTag for Weapon {
204    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
205        let compound = tag.compound()?;
206        let item_damage_per_attack = match compound.get("item_damage_per_attack") {
207            Some(tag) => tag.codec_i32()?,
208            None => 1,
209        };
210        if item_damage_per_attack < 0 {
211            return None;
212        }
213        let disable_blocking_for_seconds = optional_ranged_f32(
214            compound.get("disable_blocking_for_seconds"),
215            0.0,
216            0.0,
217            f32::MAX,
218        )?;
219        Some(Self {
220            item_damage_per_attack,
221            disable_blocking_for_seconds,
222        })
223    }
224}
225
226impl ToNbtTag for AttackRange {
227    fn to_nbt_tag(self) -> NbtTag {
228        let default = Self::default();
229        let mut compound = NbtCompound::new();
230        if self.min_reach.to_bits() != default.min_reach.to_bits() {
231            compound.insert("min_reach", self.min_reach);
232        }
233        if self.max_reach.to_bits() != default.max_reach.to_bits() {
234            compound.insert("max_reach", self.max_reach);
235        }
236        if self.min_creative_reach.to_bits() != default.min_creative_reach.to_bits() {
237            compound.insert("min_creative_reach", self.min_creative_reach);
238        }
239        if self.max_creative_reach.to_bits() != default.max_creative_reach.to_bits() {
240            compound.insert("max_creative_reach", self.max_creative_reach);
241        }
242        if self.hitbox_margin.to_bits() != default.hitbox_margin.to_bits() {
243            compound.insert("hitbox_margin", self.hitbox_margin);
244        }
245        if self.mob_factor.to_bits() != default.mob_factor.to_bits() {
246            compound.insert("mob_factor", self.mob_factor);
247        }
248        NbtTag::Compound(compound)
249    }
250}
251
252impl FromNbtTag for AttackRange {
253    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
254        let compound = tag.compound()?;
255        let default = Self::default();
256        Some(Self {
257            min_reach: optional_ranged_f32(
258                compound.get("min_reach"),
259                default.min_reach,
260                0.0,
261                64.0,
262            )?,
263            max_reach: optional_ranged_f32(
264                compound.get("max_reach"),
265                default.max_reach,
266                0.0,
267                64.0,
268            )?,
269            min_creative_reach: optional_ranged_f32(
270                compound.get("min_creative_reach"),
271                default.min_creative_reach,
272                0.0,
273                64.0,
274            )?,
275            max_creative_reach: optional_ranged_f32(
276                compound.get("max_creative_reach"),
277                default.max_creative_reach,
278                0.0,
279                64.0,
280            )?,
281            hitbox_margin: optional_ranged_f32(
282                compound.get("hitbox_margin"),
283                default.hitbox_margin,
284                0.0,
285                1.0,
286            )?,
287            mob_factor: optional_ranged_f32(
288                compound.get("mob_factor"),
289                default.mob_factor,
290                0.0,
291                2.0,
292            )?,
293        })
294    }
295}
296
297impl ToNbtTag for PiercingWeapon {
298    fn to_nbt_tag(self) -> NbtTag {
299        let mut compound = NbtCompound::new();
300        if !self.deals_knockback {
301            compound.insert("deals_knockback", self.deals_knockback);
302        }
303        if self.dismounts {
304            compound.insert("dismounts", self.dismounts);
305        }
306        if let Some(sound) = self.sound {
307            compound.insert("sound", sound.to_nbt_tag());
308        }
309        if let Some(sound) = self.hit_sound {
310            compound.insert("hit_sound", sound.to_nbt_tag());
311        }
312        NbtTag::Compound(compound)
313    }
314}
315
316impl FromNbtTag for PiercingWeapon {
317    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
318        let compound = tag.compound()?;
319        let deals_knockback = match compound.get("deals_knockback") {
320            Some(tag) => tag.codec_bool()?,
321            None => true,
322        };
323        let dismounts = match compound.get("dismounts") {
324            Some(tag) => tag.codec_bool()?,
325            None => false,
326        };
327        let sound = match compound.get("sound") {
328            Some(tag) => Some(SoundEventHolder::from_nbt_tag(tag)?),
329            None => None,
330        };
331        let hit_sound = match compound.get("hit_sound") {
332            Some(tag) => Some(SoundEventHolder::from_nbt_tag(tag)?),
333            None => None,
334        };
335        Some(Self {
336            deals_knockback,
337            dismounts,
338            sound,
339            hit_sound,
340        })
341    }
342}
343
344fn optional_ranged_f32(
345    tag: Option<simdnbt::borrow::NbtTag<'_, '_>>,
346    default: f32,
347    min: f32,
348    max: f32,
349) -> Option<f32> {
350    let value = match tag {
351        Some(tag) => tag.codec_f32()?,
352        None => default,
353    };
354    (value.is_finite() && !value.is_sign_negative() && value >= min && value <= max)
355        .then_some(value)
356}
357
358impl HashComponent for Weapon {
359    fn hash_component(&self, hasher: &mut ComponentHasher) {
360        let mut entries = Vec::new();
361        if self.item_damage_per_attack != 1 {
362            push_hash_entry(
363                &mut entries,
364                "item_damage_per_attack",
365                &self.item_damage_per_attack,
366            );
367        }
368        if self.disable_blocking_for_seconds.to_bits() != 0.0_f32.to_bits() {
369            push_hash_entry(
370                &mut entries,
371                "disable_blocking_for_seconds",
372                &self.disable_blocking_for_seconds,
373            );
374        }
375        hash_entries(hasher, &mut entries);
376    }
377}
378
379impl HashComponent for DamageTypeComponent {
380    fn hash_component(&self, hasher: &mut ComponentHasher) {
381        hasher.put_string(&self.damage_type.key.to_string());
382    }
383}
384
385impl HashComponent for AttackRange {
386    fn hash_component(&self, hasher: &mut ComponentHasher) {
387        let default = Self::default();
388        let mut entries = Vec::new();
389        if self.min_reach.to_bits() != default.min_reach.to_bits() {
390            push_hash_entry(&mut entries, "min_reach", &self.min_reach);
391        }
392        if self.max_reach.to_bits() != default.max_reach.to_bits() {
393            push_hash_entry(&mut entries, "max_reach", &self.max_reach);
394        }
395        if self.min_creative_reach.to_bits() != default.min_creative_reach.to_bits() {
396            push_hash_entry(&mut entries, "min_creative_reach", &self.min_creative_reach);
397        }
398        if self.max_creative_reach.to_bits() != default.max_creative_reach.to_bits() {
399            push_hash_entry(&mut entries, "max_creative_reach", &self.max_creative_reach);
400        }
401        if self.hitbox_margin.to_bits() != default.hitbox_margin.to_bits() {
402            push_hash_entry(&mut entries, "hitbox_margin", &self.hitbox_margin);
403        }
404        if self.mob_factor.to_bits() != default.mob_factor.to_bits() {
405            push_hash_entry(&mut entries, "mob_factor", &self.mob_factor);
406        }
407        hash_entries(hasher, &mut entries);
408    }
409}
410
411impl HashComponent for PiercingWeapon {
412    fn hash_component(&self, hasher: &mut ComponentHasher) {
413        let mut entries = Vec::new();
414        if !self.deals_knockback {
415            push_hash_entry(&mut entries, "deals_knockback", &self.deals_knockback);
416        }
417        if self.dismounts {
418            push_hash_entry(&mut entries, "dismounts", &self.dismounts);
419        }
420        if let Some(sound) = &self.sound {
421            push_hash_entry(&mut entries, "sound", sound);
422        }
423        if let Some(sound) = &self.hit_sound {
424            push_hash_entry(&mut entries, "hit_sound", sound);
425        }
426        hash_entries(hasher, &mut entries);
427    }
428}
429
430fn hash_entries(hasher: &mut ComponentHasher, entries: &mut [HashEntry]) {
431    sort_map_entries(entries);
432    hasher.start_map();
433    for entry in entries {
434        hasher.put_raw_bytes(&entry.key_bytes);
435        hasher.put_raw_bytes(&entry.value_bytes);
436    }
437    hasher.end_map();
438}
439
440fn push_hash_entry<T: HashComponent + ?Sized>(entries: &mut Vec<HashEntry>, key: &str, value: &T) {
441    let mut key_hasher = ComponentHasher::new();
442    key_hasher.put_string(key);
443    let mut value_hasher = ComponentHasher::new();
444    value.hash_component(&mut value_hasher);
445    entries.push(HashEntry::new(key_hasher, value_hasher));
446}
447
448#[cfg(test)]
449mod tests {
450    use std::io::Cursor;
451
452    use simdnbt::FromNbtTag;
453    use simdnbt::borrow::{NbtTag as BorrowedNbtTag, read_tag};
454    use simdnbt::owned::{NbtCompound, NbtTag};
455
456    use super::{AttackRange, Weapon};
457
458    fn with_borrowed_tag<R>(tag: NbtTag, visitor: impl FnOnce(BorrowedNbtTag<'_, '_>) -> R) -> R {
459        let mut bytes = Vec::new();
460        tag.write(&mut bytes);
461        let borrowed =
462            read_tag(&mut Cursor::new(bytes.as_slice())).expect("owned test tag should parse");
463        visitor(borrowed.as_tag())
464    }
465
466    #[test]
467    fn combat_components_coerce_numbers_but_reject_malformed_present_fields() {
468        let mut weapon = NbtCompound::new();
469        weapon.insert("item_damage_per_attack", 2_i8);
470        weapon.insert("disable_blocking_for_seconds", 5.5_f64);
471        let weapon = with_borrowed_tag(NbtTag::Compound(weapon), Weapon::from_nbt_tag)
472            .expect("valid weapon should parse");
473        assert_eq!(weapon.item_damage_per_attack, 2);
474        assert_eq!(weapon.disable_blocking_for_seconds, 5.5);
475
476        let mut malformed = NbtCompound::new();
477        malformed.insert("item_damage_per_attack", "two");
478        assert!(with_borrowed_tag(NbtTag::Compound(malformed), Weapon::from_nbt_tag).is_none());
479
480        let mut out_of_range = NbtCompound::new();
481        out_of_range.insert("hitbox_margin", 1.5_f64);
482        assert!(
483            with_borrowed_tag(NbtTag::Compound(out_of_range), AttackRange::from_nbt_tag).is_none()
484        );
485    }
486}