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