Skip to main content

steel_core/block_entity/entities/
chiseled_bookshelf.rs

1//! Chiseled bookshelf block entity implementation.
2
3use std::io;
4use std::mem;
5use std::sync::{Arc, Weak};
6
7use simdnbt::ToNbtTag;
8use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
9use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
10use steel_registry::ItemStackTemplate;
11use steel_registry::blocks::block_state_ext::BlockStateExt as _;
12use steel_registry::blocks::properties::{BlockStateProperties, BoolProperty};
13use steel_registry::data_components::components::ItemContainerContents;
14use steel_registry::item_stack::ItemStack;
15use steel_registry::vanilla_block_entity_types;
16use steel_registry::vanilla_item_tags::ItemTag;
17use steel_registry::{vanilla_blocks, vanilla_game_events};
18use steel_utils::types::UpdateFlags;
19use steel_utils::{BlockPos, BlockStateId, DowncastType, DowncastTypeKey, locks::SyncMutex};
20
21use crate::block_entity::{BlockEntity, BlockEntityBase};
22use crate::inventory::container::Container;
23use crate::inventory::lock::{ContainerLockGuard, ContainerRef, SharedContainer};
24use crate::world::{LevelReader as _, World, game_event::GameEventContext};
25
26/// Number of slots in a chiseled bookshelf.
27pub const CHISELED_BOOKSHELF_SLOTS: usize = 6;
28
29const DEFAULT_LAST_INTERACTED_SLOT: i32 = -1;
30const ITEMS_NBT_KEY: &str = "Items";
31const ITEM_SLOT_NBT_KEY: &str = "Slot";
32const LAST_INTERACTED_SLOT_NBT_KEY: &str = "last_interacted_slot";
33const MAX_BOOKS_PER_SLOT: i32 = 1;
34
35const OCCUPIED_PROPERTIES: [&BoolProperty; CHISELED_BOOKSHELF_SLOTS] = [
36    &BlockStateProperties::SLOT_0_OCCUPIED,
37    &BlockStateProperties::SLOT_1_OCCUPIED,
38    &BlockStateProperties::SLOT_2_OCCUPIED,
39    &BlockStateProperties::SLOT_3_OCCUPIED,
40    &BlockStateProperties::SLOT_4_OCCUPIED,
41    &BlockStateProperties::SLOT_5_OCCUPIED,
42];
43
44struct ChiseledBookShelfContainer {
45    items: Vec<ItemStack>,
46    last_interacted_slot: i32,
47    state_update_pending: bool,
48}
49
50/// Six-slot storage for a chiseled bookshelf.
51pub struct ChiseledBookShelfBlockEntity {
52    base: Arc<BlockEntityBase>,
53    container: Arc<SyncMutex<ChiseledBookShelfContainer>>,
54    container_ref: ContainerRef,
55}
56
57// SAFETY: This key is owned by Steel and uniquely identifies
58// `ChiseledBookShelfBlockEntity`.
59unsafe impl DowncastType for ChiseledBookShelfBlockEntity {
60    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/chiseled_bookshelf");
61}
62
63// SAFETY: This key is owned by Steel and uniquely identifies the independently
64// lockable inventory data used by a chiseled bookshelf block entity.
65unsafe impl DowncastType for ChiseledBookShelfContainer {
66    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:container/chiseled_bookshelf");
67}
68
69impl ChiseledBookShelfBlockEntity {
70    /// Creates a chiseled bookshelf block entity.
71    #[must_use]
72    pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
73        let callback_level = Weak::clone(&level);
74        let base = Arc::new(BlockEntityBase::new(
75            &vanilla_block_entity_types::CHISELED_BOOKSHELF,
76            level,
77            pos,
78            state,
79        ));
80        let container = Arc::new(SyncMutex::new(ChiseledBookShelfContainer {
81            items: (0..CHISELED_BOOKSHELF_SLOTS)
82                .map(|_| ItemStack::empty())
83                .collect(),
84            last_interacted_slot: DEFAULT_LAST_INTERACTED_SLOT,
85            state_update_pending: false,
86        }));
87        let callback_container = Arc::clone(&container);
88        let after_changed = Arc::new(move || {
89            Self::publish_pending_state(&callback_level, pos, &callback_container);
90        });
91        let shared_container: SharedContainer = container.clone();
92
93        Self {
94            container_ref: ContainerRef::owned_by_block_entity_with_callback(
95                shared_container,
96                Arc::clone(&base),
97                after_changed,
98            ),
99            base,
100            container,
101        }
102    }
103
104    fn publish_pending_state(
105        level: &Weak<World>,
106        pos: BlockPos,
107        container: &SyncMutex<ChiseledBookShelfContainer>,
108    ) {
109        let occupied = {
110            let mut container = container.lock();
111            if !container.state_update_pending {
112                return;
113            }
114            container.state_update_pending = false;
115            container
116                .items
117                .iter()
118                .map(|item| !item.is_empty())
119                .collect::<Vec<_>>()
120        };
121
122        let Some(world) = level.upgrade() else {
123            return;
124        };
125        let state = world.get_block_state(pos);
126        if state.get_block() != &vanilla_blocks::CHISELED_BOOKSHELF {
127            return;
128        }
129
130        let mut updated_state = state;
131        for (property, occupied) in OCCUPIED_PROPERTIES.iter().zip(occupied) {
132            updated_state = updated_state.set_value(*property, occupied);
133        }
134
135        world.set_block(pos, updated_state, UpdateFlags::UPDATE_ALL);
136        world.game_event(
137            &vanilla_game_events::BLOCK_CHANGE,
138            pos,
139            &GameEventContext::new(None, Some(updated_state)),
140        );
141    }
142
143    /// Inserts a valid bookshelf book into an empty slot.
144    #[must_use]
145    pub fn insert_book(&self, slot: usize, item: ItemStack) -> bool {
146        if slot >= CHISELED_BOOKSHELF_SLOTS
147            || item.is_empty()
148            || !item.item().has_tag(&ItemTag::BOOKSHELF_BOOKS)
149        {
150            return false;
151        }
152
153        let mut guard = ContainerLockGuard::lock_all(&[&self.container_ref]);
154        let Some(container) = guard.get(self.container_ref.container_id()) else {
155            return false;
156        };
157        if !container.get_item(slot).is_empty() {
158            return false;
159        }
160        guard.set_item(self.container_ref.container_id(), slot, item)
161    }
162
163    /// Removes the entire stack from a slot, matching Vanilla's count-ignoring override.
164    #[must_use]
165    pub fn remove_book(&self, slot: usize) -> ItemStack {
166        if slot >= CHISELED_BOOKSHELF_SLOTS {
167            return ItemStack::empty();
168        }
169        let mut guard = ContainerLockGuard::lock_all(&[&self.container_ref]);
170        guard
171            .remove_item(self.container_ref.container_id(), slot, MAX_BOOKS_PER_SLOT)
172            .unwrap_or_else(ItemStack::empty)
173    }
174
175    /// Returns a copy of the item stored in `slot`.
176    #[must_use]
177    pub fn item(&self, slot: usize) -> Option<ItemStack> {
178        let container = self.container.lock();
179        let item = container.items.get(slot)?;
180        Some(item.copy_with_count(item.count()))
181    }
182
183    /// Returns the last slot changed by insertion, removal, or automation.
184    #[must_use]
185    pub fn last_interacted_slot(&self) -> i32 {
186        self.container.lock().last_interacted_slot
187    }
188
189    /// Applies the `minecraft:container` component from a placed block item.
190    pub fn apply_container_items(&self, items: Vec<ItemStack>) {
191        let mut container = self.container.lock();
192        container.items.fill_with(ItemStack::empty);
193        for (destination, item) in container.items.iter_mut().zip(items) {
194            *destination = item;
195        }
196        container.state_update_pending = false;
197        drop(container);
198        BlockEntity::set_changed(self);
199    }
200
201    /// Collects the inventory as Vanilla's implicit `minecraft:container` component.
202    pub fn collect_container_component(&self) -> io::Result<ItemContainerContents> {
203        let container = self.container.lock();
204        let Some(last_non_empty) = container.items.iter().rposition(|item| !item.is_empty()) else {
205            return Ok(ItemContainerContents::empty());
206        };
207
208        let mut items = Vec::with_capacity(last_non_empty + 1);
209        for item in &container.items[..=last_non_empty] {
210            if item.is_empty() {
211                items.push(None);
212            } else {
213                items.push(Some(ItemStackTemplate::from_stack(item)?));
214            }
215        }
216        ItemContainerContents::new(items)
217    }
218}
219
220impl BlockEntity for ChiseledBookShelfBlockEntity {
221    fn base(&self) -> &BlockEntityBase {
222        &self.base
223    }
224
225    fn pre_remove_side_effects(&self, pos: BlockPos, _state: BlockStateId) {
226        let items = {
227            let mut container = self.container.lock();
228            container.state_update_pending = false;
229            mem::replace(
230                &mut container.items,
231                (0..CHISELED_BOOKSHELF_SLOTS)
232                    .map(|_| ItemStack::empty())
233                    .collect(),
234            )
235        };
236        let Some(world) = self.get_level() else {
237            return;
238        };
239        for item in items {
240            world.drop_item_stack(pos, item);
241        }
242    }
243
244    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
245        let nbt: NbtCompoundView<'_, '_> = nbt.into();
246        let mut container = self.container.lock();
247        container.items.fill_with(ItemStack::empty);
248        container.state_update_pending = false;
249
250        if let Some(items) = nbt.list(ITEMS_NBT_KEY)
251            && let Some(compounds) = items.compounds()
252        {
253            for compound in compounds {
254                let Some(slot) = compound.byte(ITEM_SLOT_NBT_KEY) else {
255                    continue;
256                };
257                let Ok(slot) = usize::try_from(slot) else {
258                    continue;
259                };
260                if slot < CHISELED_BOOKSHELF_SLOTS
261                    && let Some(item) = ItemStack::from_borrowed_compound(&compound)
262                {
263                    container.items[slot] = item;
264                }
265            }
266        }
267        container.last_interacted_slot = nbt
268            .int(LAST_INTERACTED_SLOT_NBT_KEY)
269            .unwrap_or(DEFAULT_LAST_INTERACTED_SLOT);
270    }
271
272    fn save_additional(&self, nbt: &mut NbtCompound) {
273        let container = self.container.lock();
274        let mut items = Vec::new();
275        for (slot, item) in container.items.iter().enumerate() {
276            if item.is_empty() {
277                continue;
278            }
279            if let NbtTag::Compound(mut item_nbt) = item.copy_with_count(item.count()).to_nbt_tag()
280            {
281                item_nbt.insert(ITEM_SLOT_NBT_KEY, slot as i8);
282                items.push(item_nbt);
283            }
284        }
285        nbt.insert(ITEMS_NBT_KEY, NbtList::Compound(items));
286        nbt.insert(LAST_INTERACTED_SLOT_NBT_KEY, container.last_interacted_slot);
287    }
288
289    fn container_ref(&self) -> Option<ContainerRef> {
290        Some(self.container_ref.clone())
291    }
292}
293
294impl Container for ChiseledBookShelfContainer {
295    fn items(&self) -> &[ItemStack] {
296        &self.items
297    }
298
299    fn items_mut(&mut self) -> &mut [ItemStack] {
300        &mut self.items
301    }
302
303    fn set_item(&mut self, slot: usize, stack: ItemStack) {
304        if slot >= CHISELED_BOOKSHELF_SLOTS {
305            return;
306        }
307        if stack.is_empty() {
308            let _ = self.remove_item(slot, MAX_BOOKS_PER_SLOT);
309            return;
310        }
311        if !stack.item().has_tag(&ItemTag::BOOKSHELF_BOOKS) {
312            return;
313        }
314
315        self.items[slot] = stack;
316        self.last_interacted_slot = slot as i32;
317        self.state_update_pending = true;
318    }
319
320    fn remove_item(&mut self, slot: usize, _count: i32) -> ItemStack {
321        let Some(item) = self.items.get_mut(slot) else {
322            return ItemStack::empty();
323        };
324        let removed = mem::take(item);
325        if !removed.is_empty() {
326            self.last_interacted_slot = slot as i32;
327            self.state_update_pending = true;
328        }
329        removed
330    }
331
332    fn get_max_stack_size(&self) -> i32 {
333        MAX_BOOKS_PER_SLOT
334    }
335
336    fn set_changed(&mut self) {}
337
338    fn can_place_item(&self, slot: usize, stack: &ItemStack) -> bool {
339        let Some(stored_item) = self.items.get(slot) else {
340            return false;
341        };
342        stack.item().has_tag(&ItemTag::BOOKSHELF_BOOKS)
343            && (stored_item.is_empty()
344                || stored_item.count() < self.get_max_stack_size_for_item(stack))
345    }
346
347    fn can_take_item(&self, destination: &dyn Container, _slot: usize, stack: &ItemStack) -> bool {
348        destination.items().iter().any(|destination_item| {
349            destination_item.is_empty()
350                || (ItemStack::is_same_item_same_components(stack, destination_item)
351                    && destination_item.count() + stack.count()
352                        <= destination.get_max_stack_size_for_item(destination_item))
353        })
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use std::io::Cursor;
360
361    use simdnbt::borrow::read_compound as read_borrowed_compound;
362    use steel_registry::{init_vanilla_registry, vanilla_items};
363
364    use super::*;
365    use crate::inventory::container::SimpleContainer;
366
367    const TEST_POS: BlockPos = BlockPos::new(1, 2, 3);
368    const FIRST_SLOT_INDEX: usize = 0;
369    const LAST_SLOT_INDEX: usize = CHISELED_BOOKSHELF_SLOTS - 1;
370    const INTERACTION_TEST_SLOT: usize = 4;
371    const OVERSIZED_TEST_STACK_COUNT: i32 = MAX_BOOKS_PER_SLOT + 1;
372    const DESTINATION_SLOT_COUNT: usize = 1;
373    const DESTINATION_SLOT_INDEX: usize = 0;
374    const WRITABLE_BOOK_COMPONENT_SLOT: usize = 0;
375    const EMPTY_COMPONENT_SLOT: usize = 1;
376    const ENCHANTED_BOOK_COMPONENT_SLOT: usize = 2;
377    const FIRST_UNSPECIFIED_COMPONENT_SLOT: usize = 3;
378
379    fn test_bookshelf() -> ChiseledBookShelfBlockEntity {
380        init_vanilla_registry();
381        ChiseledBookShelfBlockEntity::new(
382            Weak::new(),
383            TEST_POS,
384            vanilla_blocks::CHISELED_BOOKSHELF.default_state(),
385        )
386    }
387
388    #[test]
389    fn bookshelf_book_tag_and_slot_capacity_control_container_capability() {
390        let bookshelf = test_bookshelf();
391        let valid_items = [
392            &vanilla_items::BOOK,
393            &vanilla_items::WRITABLE_BOOK,
394            &vanilla_items::WRITTEN_BOOK,
395            &vanilla_items::KNOWLEDGE_BOOK,
396            &vanilla_items::ENCHANTED_BOOK,
397        ];
398
399        for (slot, item) in valid_items.into_iter().enumerate() {
400            assert!(bookshelf.insert_book(slot, ItemStack::new(item)));
401        }
402        let invalid_item = ItemStack::new(&vanilla_items::STONE);
403        assert!(!bookshelf.insert_book(LAST_SLOT_INDEX, invalid_item));
404
405        let container = bookshelf.container.lock();
406        assert_eq!(container.get_max_stack_size(), MAX_BOOKS_PER_SLOT);
407        let valid_item = ItemStack::new(&vanilla_items::BOOK);
408        let invalid_item = ItemStack::new(&vanilla_items::STONE);
409        assert!(!container.can_place_item(FIRST_SLOT_INDEX, &valid_item));
410        assert!(!container.can_place_item(FIRST_SLOT_INDEX, &invalid_item));
411        assert!(container.can_place_item(LAST_SLOT_INDEX, &valid_item));
412        assert!(!container.can_place_item(LAST_SLOT_INDEX, &invalid_item));
413        assert!(!container.can_place_item(CHISELED_BOOKSHELF_SLOTS, &valid_item));
414    }
415
416    #[test]
417    fn insertion_and_removal_update_occupancy_and_last_slot_storage() {
418        let bookshelf = test_bookshelf();
419        assert_eq!(
420            bookshelf.last_interacted_slot(),
421            DEFAULT_LAST_INTERACTED_SLOT
422        );
423
424        assert!(bookshelf.insert_book(
425            INTERACTION_TEST_SLOT,
426            ItemStack::with_count(&vanilla_items::WRITTEN_BOOK, OVERSIZED_TEST_STACK_COUNT),
427        ));
428        assert_eq!(
429            bookshelf.last_interacted_slot(),
430            INTERACTION_TEST_SLOT as i32,
431        );
432        assert_eq!(
433            bookshelf
434                .item(INTERACTION_TEST_SLOT)
435                .map(|item| item.count()),
436            Some(OVERSIZED_TEST_STACK_COUNT),
437        );
438        let replacement = ItemStack::new(&vanilla_items::BOOK);
439        assert!(!bookshelf.insert_book(INTERACTION_TEST_SLOT, replacement));
440
441        let removed = bookshelf.remove_book(INTERACTION_TEST_SLOT);
442        assert!(removed.is(&vanilla_items::WRITTEN_BOOK));
443        assert_eq!(removed.count(), OVERSIZED_TEST_STACK_COUNT);
444        assert_eq!(
445            bookshelf.last_interacted_slot(),
446            INTERACTION_TEST_SLOT as i32,
447        );
448        assert!(
449            bookshelf
450                .item(INTERACTION_TEST_SLOT)
451                .is_some_and(|item| item.is_empty())
452        );
453        assert!(bookshelf.remove_book(INTERACTION_TEST_SLOT).is_empty());
454    }
455
456    #[test]
457    fn extraction_requires_space_somewhere_in_destination() {
458        let bookshelf = test_bookshelf();
459        let source = ItemStack::new(&vanilla_items::BOOK);
460        let container = bookshelf.container.lock();
461        let mut destination = SimpleContainer::new(DESTINATION_SLOT_COUNT);
462        let full_stack_size = source.max_stack_size();
463        let one_item_short_of_full_stack = full_stack_size - 1;
464
465        assert!(container.can_take_item(&destination, DESTINATION_SLOT_INDEX, &source));
466        destination.set_item(
467            DESTINATION_SLOT_INDEX,
468            ItemStack::new(&vanilla_items::STONE),
469        );
470        assert!(!container.can_take_item(&destination, DESTINATION_SLOT_INDEX, &source));
471        destination.set_item(
472            DESTINATION_SLOT_INDEX,
473            ItemStack::with_count(&vanilla_items::BOOK, one_item_short_of_full_stack),
474        );
475        assert!(container.can_take_item(&destination, DESTINATION_SLOT_INDEX, &source));
476        destination.set_item(
477            DESTINATION_SLOT_INDEX,
478            ItemStack::with_count(&vanilla_items::BOOK, full_stack_size),
479        );
480        assert!(!container.can_take_item(&destination, DESTINATION_SLOT_INDEX, &source));
481    }
482
483    #[test]
484    fn persistence_and_container_component_preserve_all_slots_and_last_slot() {
485        let bookshelf = test_bookshelf();
486        for slot in 0..LAST_SLOT_INDEX {
487            assert!(bookshelf.insert_book(slot, ItemStack::new(&vanilla_items::BOOK)));
488        }
489        assert!(bookshelf.insert_book(
490            LAST_SLOT_INDEX,
491            ItemStack::new(&vanilla_items::ENCHANTED_BOOK),
492        ));
493
494        let component = bookshelf
495            .collect_container_component()
496            .expect("valid bookshelf items should form a container component");
497        assert_eq!(component.items().len(), CHISELED_BOOKSHELF_SLOTS);
498        for item in &component.items()[..LAST_SLOT_INDEX] {
499            assert!(
500                item.as_ref()
501                    .is_some_and(|item| item.item().key == vanilla_items::BOOK.key)
502            );
503        }
504        assert!(
505            component.items()[LAST_SLOT_INDEX]
506                .as_ref()
507                .is_some_and(|item| item.item().key == vanilla_items::ENCHANTED_BOOK.key)
508        );
509
510        let mut nbt = NbtCompound::new();
511        bookshelf.save_additional(&mut nbt);
512        let mut bytes = Vec::new();
513        nbt.write(&mut bytes);
514        let borrowed = read_borrowed_compound(&mut Cursor::new(bytes.as_slice()))
515            .expect("saved bookshelf NBT should decode");
516
517        let loaded = test_bookshelf();
518        loaded.load_additional(&borrowed);
519        assert_eq!(loaded.last_interacted_slot(), LAST_SLOT_INDEX as i32);
520        for slot in 0..LAST_SLOT_INDEX {
521            assert!(
522                loaded
523                    .item(slot)
524                    .is_some_and(|item| item.is(&vanilla_items::BOOK))
525            );
526        }
527        assert!(
528            loaded
529                .item(LAST_SLOT_INDEX)
530                .is_some_and(|item| item.is(&vanilla_items::ENCHANTED_BOOK))
531        );
532    }
533
534    #[test]
535    fn component_application_replaces_all_six_slots_without_changing_last_slot() {
536        let bookshelf = test_bookshelf();
537        let previously_inserted_book = ItemStack::new(&vanilla_items::BOOK);
538        assert!(bookshelf.insert_book(INTERACTION_TEST_SLOT, previously_inserted_book));
539
540        bookshelf.apply_container_items(vec![
541            ItemStack::new(&vanilla_items::WRITABLE_BOOK),
542            ItemStack::empty(),
543            ItemStack::new(&vanilla_items::ENCHANTED_BOOK),
544        ]);
545
546        assert_eq!(
547            bookshelf.last_interacted_slot(),
548            INTERACTION_TEST_SLOT as i32,
549        );
550        assert!(
551            bookshelf
552                .item(WRITABLE_BOOK_COMPONENT_SLOT)
553                .is_some_and(|item| item.is(&vanilla_items::WRITABLE_BOOK))
554        );
555        assert!(
556            bookshelf
557                .item(EMPTY_COMPONENT_SLOT)
558                .is_some_and(|item| item.is_empty())
559        );
560        assert!(
561            bookshelf
562                .item(ENCHANTED_BOOK_COMPONENT_SLOT)
563                .is_some_and(|item| item.is(&vanilla_items::ENCHANTED_BOOK))
564        );
565        for slot in FIRST_UNSPECIFIED_COMPONENT_SLOT..CHISELED_BOOKSHELF_SLOTS {
566            assert!(bookshelf.item(slot).is_some_and(|item| item.is_empty()));
567        }
568    }
569}