1use std::sync::{Arc, Weak};
4
5use glam::DVec3;
6use steel_macros::block_behavior;
7use steel_registry::blocks::BlockRef;
8use steel_registry::blocks::block_state_ext::BlockStateExt as _;
9use steel_registry::blocks::properties::{BlockStateProperties, Direction, EnumProperty};
10use steel_registry::data_components::vanilla_components::CONTAINER;
11use steel_registry::item_stack::ItemStack;
12use steel_registry::sound_event::SoundEventRef;
13use steel_registry::stat::vanilla_stat_types;
14use steel_registry::vanilla_item_tags::ItemTag;
15use steel_registry::{
16 sound_events, vanilla_block_entity_types, vanilla_game_events, vanilla_items,
17};
18use steel_utils::types::InteractionHand;
19use steel_utils::{BlockPos, BlockStateId, Downcast as _};
20
21use crate::behavior::{
22 BlockBehavior, BlockEntityCreation, BlockHitResult, BlockPlaceContext, InteractionResult,
23 InventoryAccess, PlacementSource,
24};
25use crate::block_entity::BLOCK_ENTITIES;
26use crate::block_entity::entities::{CHISELED_BOOKSHELF_SLOTS, ChiseledBookShelfBlockEntity};
27use crate::player::Player;
28use crate::world::game_event::GameEventContext;
29use crate::world::{LevelReader, World};
30
31#[block_behavior]
37pub struct ChiseledBookShelfBlock {
38 block: BlockRef,
39}
40
41const HORIZONTAL_FACING: &EnumProperty<Direction> = &BlockStateProperties::HORIZONTAL_FACING;
42
43impl ChiseledBookShelfBlock {
44 const PIXELS_PER_BLOCK_EDGE: f32 = 16.0;
45 const BOOKS_PER_INTERACTION: i32 = 1;
46 const COLUMN_COUNT: i32 = 3;
47 const ROW_COUNT: i32 = 2;
48 const SOUND_VOLUME: f32 = 1.0;
49 const SOUND_PITCH: f32 = 1.0;
50 const NO_COMPARATOR_OUTPUT: i32 = 0;
51 const COMPARATOR_SLOT_INDEX_OFFSET: i32 = 1;
52
53 #[must_use]
55 pub const fn new(block: BlockRef) -> Self {
56 Self { block }
57 }
58
59 fn section_index(normalized_coordinate: f64, section_count: i32) -> usize {
60 let targeted_pixel = normalized_coordinate as f32 * Self::PIXELS_PER_BLOCK_EDGE;
61 let section_size = Self::PIXELS_PER_BLOCK_EDGE / section_count as f32;
62 ((targeted_pixel / section_size).floor() as i32).clamp(0, section_count - 1) as usize
63 }
64
65 fn hit_slot(state: BlockStateId, hit_result: &BlockHitResult) -> Option<usize> {
66 let facing = state.get_value(HORIZONTAL_FACING);
67 if hit_result.direction != facing {
68 return None;
69 }
70
71 let hit_face_origin = hit_result.direction.relative(hit_result.block_pos);
72 let relative_hit = hit_result.location
73 - DVec3::new(
74 f64::from(hit_face_origin.x()),
75 f64::from(hit_face_origin.y()),
76 f64::from(hit_face_origin.z()),
77 );
78 let horizontal_fraction = match facing {
79 Direction::North => 1.0 - relative_hit.x,
80 Direction::South => relative_hit.x,
81 Direction::West => relative_hit.z,
82 Direction::East => 1.0 - relative_hit.z,
83 Direction::Down | Direction::Up => return None,
84 };
85 let row = Self::section_index(1.0 - relative_hit.y, Self::ROW_COUNT);
86 let column = Self::section_index(horizontal_fraction, Self::COLUMN_COUNT);
87 Some(column + row * Self::COLUMN_COUNT as usize)
88 }
89
90 fn insert_sound(item: &ItemStack) -> SoundEventRef {
91 if item.is(&vanilla_items::ENCHANTED_BOOK) {
92 return &sound_events::BLOCK_CHISELED_BOOKSHELF_INSERT_ENCHANTED;
93 }
94 &sound_events::BLOCK_CHISELED_BOOKSHELF_INSERT
95 }
96
97 fn pickup_sound(item: &ItemStack) -> SoundEventRef {
98 if item.is(&vanilla_items::ENCHANTED_BOOK) {
99 return &sound_events::BLOCK_CHISELED_BOOKSHELF_PICKUP_ENCHANTED;
100 }
101 &sound_events::BLOCK_CHISELED_BOOKSHELF_PICKUP
102 }
103}
104
105impl BlockBehavior for ChiseledBookShelfBlock {
106 fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
107 Some(
108 self.block
109 .default_state()
110 .set_value(HORIZONTAL_FACING, context.horizontal_direction().opposite()),
111 )
112 }
113
114 fn set_placed_by(
115 &self,
116 _state: BlockStateId,
117 world: &Arc<World>,
118 pos: BlockPos,
119 source: &PlacementSource<'_>,
120 ) {
121 let component_items = source.with_item(|item| {
122 item.get(CONTAINER).map(|contents| {
123 contents
124 .items()
125 .iter()
126 .take(CHISELED_BOOKSHELF_SLOTS)
127 .map(|item| {
128 item.as_ref().map_or_else(
129 ItemStack::empty,
130 steel_registry::ItemStackTemplate::create,
131 )
132 })
133 .collect::<Vec<_>>()
134 })
135 });
136 let Some(component_items) = component_items else {
137 return;
138 };
139 let Some(block_entity) = world.get_block_entity(pos) else {
140 return;
141 };
142 let Some(bookshelf) = block_entity.downcast_ref::<ChiseledBookShelfBlockEntity>() else {
143 return;
144 };
145 bookshelf.apply_container_items(component_items);
146 }
147
148 fn use_item_on(
149 &self,
150 state: BlockStateId,
151 world: &Arc<World>,
152 pos: BlockPos,
153 player: &Player,
154 _hand: InteractionHand,
155 hit_result: &BlockHitResult,
156 inv: &mut InventoryAccess,
157 ) -> InteractionResult {
158 let is_bookshelf_book = inv
159 .with_item(|item| !item.is_empty() && item.item().has_tag(&ItemTag::BOOKSHELF_BOOKS));
160 if !is_bookshelf_book {
161 return InteractionResult::TryEmptyHandInteraction;
162 }
163
164 let Some(slot) = Self::hit_slot(state, hit_result) else {
165 return InteractionResult::Pass;
166 };
167 let Some(block_entity) = world.get_block_entity(pos) else {
168 return InteractionResult::Pass;
169 };
170 let Some(bookshelf) = block_entity.downcast_ref::<ChiseledBookShelfBlockEntity>() else {
171 return InteractionResult::Pass;
172 };
173 if bookshelf.item(slot).is_some_and(|item| !item.is_empty()) {
174 return InteractionResult::TryEmptyHandInteraction;
175 }
176
177 let inserted = inv.with_item(|item| item.copy_with_count(Self::BOOKS_PER_INTERACTION));
178 let insert_sound = Self::insert_sound(&inserted);
179 let item = inserted.item;
180 if !bookshelf.insert_book(slot, inserted) {
181 return InteractionResult::Pass;
182 }
183 let has_infinite_materials = player.has_infinite_materials();
184 inv.with_item(|item| item.consume(Self::BOOKS_PER_INTERACTION, has_infinite_materials));
185 world.play_block_sound(
186 insert_sound,
187 pos,
188 Self::SOUND_VOLUME,
189 Self::SOUND_PITCH,
190 None,
191 );
192 player.award_stat(&vanilla_stat_types::ITEM_USED, item);
193 InteractionResult::Success
194 }
195
196 fn use_without_item(
197 &self,
198 state: BlockStateId,
199 world: &Arc<World>,
200 pos: BlockPos,
201 player: &Player,
202 hit_result: &BlockHitResult,
203 _inv: &mut InventoryAccess,
204 ) -> InteractionResult {
205 let Some(slot) = Self::hit_slot(state, hit_result) else {
206 return InteractionResult::Pass;
207 };
208 let Some(block_entity) = world.get_block_entity(pos) else {
209 return InteractionResult::Pass;
210 };
211 let Some(bookshelf) = block_entity.downcast_ref::<ChiseledBookShelfBlockEntity>() else {
212 return InteractionResult::Pass;
213 };
214 if bookshelf.item(slot).is_none_or(|item| item.is_empty()) {
215 return InteractionResult::Consume;
216 }
217
218 let removed = bookshelf.remove_book(slot);
219 if removed.is_empty() {
220 return InteractionResult::Consume;
221 }
222 world.play_block_sound(
223 Self::pickup_sound(&removed),
224 pos,
225 Self::SOUND_VOLUME,
226 Self::SOUND_PITCH,
227 None,
228 );
229 player.add_item_or_drop(removed);
230 world.game_event(
231 &vanilla_game_events::BLOCK_CHANGE,
232 pos,
233 &GameEventContext::new(Some(player), None),
234 );
235 InteractionResult::Success
236 }
237
238 fn affect_neighbors_after_removal(
239 &self,
240 state: BlockStateId,
241 world: &Arc<World>,
242 pos: BlockPos,
243 _moved_by_piston: bool,
244 ) {
245 world.update_neighbor_for_output_signal(pos, state.get_block());
246 }
247
248 fn new_block_entity(
249 &self,
250 level: Weak<World>,
251 pos: BlockPos,
252 state: BlockStateId,
253 ) -> BlockEntityCreation {
254 BlockEntityCreation::from_registered_factory(BLOCK_ENTITIES.create(
255 &vanilla_block_entity_types::CHISELED_BOOKSHELF,
256 level,
257 pos,
258 state,
259 ))
260 }
261
262 fn has_analog_output_signal(&self, _state: BlockStateId) -> bool {
263 true
264 }
265
266 fn get_analog_output_signal(
267 &self,
268 _state: BlockStateId,
269 world: &dyn LevelReader,
270 pos: BlockPos,
271 _direction: Direction,
272 ) -> i32 {
273 world
274 .get_block_entity(pos)
275 .and_then(|block_entity| {
276 block_entity
277 .downcast_ref::<ChiseledBookShelfBlockEntity>()
278 .map(ChiseledBookShelfBlockEntity::last_interacted_slot)
279 })
280 .map_or(Self::NO_COMPARATOR_OUTPUT, |slot| {
281 slot + Self::COMPARATOR_SLOT_INDEX_OFFSET
282 })
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use std::sync::Arc;
289
290 use steel_registry::ItemStackTemplate;
291 use steel_registry::blocks::shapes::is_shape_full_block;
292 use steel_registry::data_components::components::ItemContainerContents;
293 use steel_registry::{vanilla_blocks, vanilla_entities};
294 use steel_utils::types::{GameType, UpdateFlags};
295 use steel_utils::{ChunkPos, WorldAabb};
296 use uuid::Uuid;
297
298 use super::*;
299 use crate::behavior::PlacementOrientation;
300 use crate::bootstrap::init_globals;
301 use crate::entity::entities::ItemEntity;
302 use crate::inventory::container::Container as _;
303 use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
304
305 const TEST_POS: BlockPos = BlockPos::new(8, 64, 8);
306 const TEST_PLAYER_UUID: Uuid = Uuid::from_u128(1);
307 const TEST_PLAYER_ENTITY_ID: i32 = 1;
308 const INTERACTION_FACING: Direction = Direction::South;
309 const COMPONENT_PLACER_FACING: Direction = Direction::South;
310 const ARBITRARY_COMPARATOR_QUERY_DIRECTION: Direction = Direction::North;
311 const BLOCK_EDGE_IN_PIXELS: f64 = ChiseledBookShelfBlock::PIXELS_PER_BLOCK_EDGE as f64;
312 const LEFT_COLUMN_HIT: f64 = 2.5 / BLOCK_EDGE_IN_PIXELS;
313 const MIDDLE_COLUMN_HIT: f64 = 7.5 / BLOCK_EDGE_IN_PIXELS;
314 const RIGHT_COLUMN_HIT: f64 = 13.0 / BLOCK_EDGE_IN_PIXELS;
315 const TOP_ROW_HIT: f64 = 12.0 / BLOCK_EDGE_IN_PIXELS;
316 const BOTTOM_ROW_HIT: f64 = 4.0 / BLOCK_EDGE_IN_PIXELS;
317 const FACE_CENTER_HIT: f64 = 8.0 / BLOCK_EDGE_IN_PIXELS;
318 const FIRST_COLUMN_BOUNDARY: f64 = 1.0 / 3.0;
319 const SECOND_COLUMN_BOUNDARY: f64 = 2.0 / 3.0;
320 const ROW_BOUNDARY: f64 = FACE_CENTER_HIT;
321 const BOUNDARY_EPSILON: f64 = 1.0e-6;
322 const JUST_BEFORE_FIRST_COLUMN_BOUNDARY: f64 = FIRST_COLUMN_BOUNDARY - BOUNDARY_EPSILON;
323 const JUST_BEFORE_SECOND_COLUMN_BOUNDARY: f64 = SECOND_COLUMN_BOUNDARY - BOUNDARY_EPSILON;
324 const JUST_ABOVE_ROW_BOUNDARY: f64 = ROW_BOUNDARY + BOUNDARY_EPSILON;
325 const TOP_LEFT_SLOT: usize = 0;
326 const TOP_MIDDLE_SLOT: usize = 1;
327 const TOP_RIGHT_SLOT: usize = 2;
328 const BOTTOM_LEFT_SLOT: usize = 3;
329 const BOTTOM_RIGHT_SLOT: usize = CHISELED_BOOKSHELF_SLOTS - 1;
330 const DROP_SEARCH_MARGIN: f64 = 1.0;
331 const SURVIVAL_STARTING_BOOK_COUNT: i32 = ChiseledBookShelfBlock::BOOKS_PER_INTERACTION + 1;
332 const EXPECTED_ENCHANTED_BOOK_DROP_COUNT: i32 = ChiseledBookShelfBlock::BOOKS_PER_INTERACTION;
333
334 fn state_facing(facing: Direction) -> BlockStateId {
335 vanilla_blocks::CHISELED_BOOKSHELF
336 .default_state()
337 .set_value(HORIZONTAL_FACING, facing)
338 }
339
340 fn bookshelf_hit(
341 facing: Direction,
342 horizontal_fraction: f64,
343 height_fraction: f64,
344 ) -> BlockHitResult {
345 let x = f64::from(TEST_POS.x());
346 let block_y = f64::from(TEST_POS.y());
347 let z = f64::from(TEST_POS.z());
348 let location = match facing {
349 Direction::North => {
350 DVec3::new(x + 1.0 - horizontal_fraction, block_y + height_fraction, z)
351 }
352 Direction::South => {
353 DVec3::new(x + horizontal_fraction, block_y + height_fraction, z + 1.0)
354 }
355 Direction::West => DVec3::new(x, block_y + height_fraction, z + horizontal_fraction),
356 Direction::East => DVec3::new(
357 x + 1.0,
358 block_y + height_fraction,
359 z + 1.0 - horizontal_fraction,
360 ),
361 Direction::Down | Direction::Up => unreachable!("test uses horizontal facings"),
362 };
363 BlockHitResult {
364 location,
365 direction: facing,
366 block_pos: TEST_POS,
367 miss: false,
368 inside: false,
369 world_border_hit: false,
370 }
371 }
372
373 fn test_player(world: &Arc<World>) -> Arc<Player> {
374 TestPlayerBuilder::new(Arc::clone(world), "BookshelfTester", TEST_PLAYER_ENTITY_ID)
375 .uuid(TEST_PLAYER_UUID)
376 .build()
377 }
378
379 fn assert_hit_targets_slot(
380 state: BlockStateId,
381 hit_result: &BlockHitResult,
382 expected_slot: usize,
383 ) {
384 assert_eq!(
385 ChiseledBookShelfBlock::hit_slot(state, hit_result),
386 Some(expected_slot),
387 );
388 }
389
390 fn expected_comparator_output(slot: usize) -> i32 {
391 slot as i32 + ChiseledBookShelfBlock::COMPARATOR_SLOT_INDEX_OFFSET
392 }
393
394 #[test]
395 fn all_six_hit_regions_map_identically_for_every_horizontal_facing() {
396 init_globals();
397 let column_hits = [LEFT_COLUMN_HIT, MIDDLE_COLUMN_HIT, RIGHT_COLUMN_HIT];
398 for facing in Direction::HORIZONTAL {
399 let state = state_facing(facing);
400 for (column, horizontal_fraction) in column_hits.into_iter().enumerate() {
401 let top_slot = column;
402 let bottom_slot = column + ChiseledBookShelfBlock::COLUMN_COUNT as usize;
403 assert_hit_targets_slot(
404 state,
405 &bookshelf_hit(facing, horizontal_fraction, TOP_ROW_HIT),
406 top_slot,
407 );
408 assert_hit_targets_slot(
409 state,
410 &bookshelf_hit(facing, horizontal_fraction, BOTTOM_ROW_HIT),
411 bottom_slot,
412 );
413 }
414
415 let mut wrong_face = bookshelf_hit(facing, FACE_CENTER_HIT, FACE_CENTER_HIT);
416 wrong_face.direction = facing.opposite();
417 assert_eq!(ChiseledBookShelfBlock::hit_slot(state, &wrong_face), None);
418 }
419 }
420
421 #[test]
422 fn hit_boundaries_use_vanilla_pixel_sections() {
423 init_globals();
424 let facing = INTERACTION_FACING;
425 let state = state_facing(facing);
426
427 assert_hit_targets_slot(
428 state,
429 &bookshelf_hit(facing, JUST_BEFORE_FIRST_COLUMN_BOUNDARY, TOP_ROW_HIT),
430 TOP_LEFT_SLOT,
431 );
432 assert_hit_targets_slot(
433 state,
434 &bookshelf_hit(facing, FIRST_COLUMN_BOUNDARY, TOP_ROW_HIT),
435 TOP_MIDDLE_SLOT,
436 );
437 assert_hit_targets_slot(
438 state,
439 &bookshelf_hit(facing, JUST_BEFORE_SECOND_COLUMN_BOUNDARY, TOP_ROW_HIT),
440 TOP_MIDDLE_SLOT,
441 );
442 assert_hit_targets_slot(
443 state,
444 &bookshelf_hit(facing, SECOND_COLUMN_BOUNDARY, TOP_ROW_HIT),
445 TOP_RIGHT_SLOT,
446 );
447 assert_hit_targets_slot(
448 state,
449 &bookshelf_hit(facing, LEFT_COLUMN_HIT, JUST_ABOVE_ROW_BOUNDARY),
450 TOP_LEFT_SLOT,
451 );
452 assert_hit_targets_slot(
453 state,
454 &bookshelf_hit(facing, LEFT_COLUMN_HIT, ROW_BOUNDARY),
455 BOTTOM_LEFT_SLOT,
456 );
457 }
458
459 #[test]
460 fn placement_faces_the_player_and_uses_the_extracted_full_block_shape() {
461 init_globals();
462 let world = fresh_test_world("chiseled_bookshelf_placement");
463 let behavior = ChiseledBookShelfBlock::new(&vanilla_blocks::CHISELED_BOOKSHELF);
464
465 for facing in Direction::HORIZONTAL {
466 let mut stack = ItemStack::new(&vanilla_items::CHISELED_BOOKSHELF);
467 let is_secondary_use_active = false;
468 let source = PlacementSource::direct(
469 None,
470 InteractionHand::MainHand,
471 &mut stack,
472 PlacementOrientation::Directional { direction: facing },
473 is_secondary_use_active,
474 );
475 let context = BlockPlaceContext::new(
476 &world,
477 source,
478 &bookshelf_hit(facing, FACE_CENTER_HIT, FACE_CENTER_HIT),
479 );
480 let placed = behavior
481 .get_state_for_placement(&context)
482 .expect("chiseled bookshelf always has a placement state");
483 assert_eq!(placed.get_value(HORIZONTAL_FACING), facing.opposite());
484 assert!(is_shape_full_block(placed.get_static_collision_shape()));
485 }
486 }
487
488 fn assert_invalid_item_is_rejected(
489 behavior: &ChiseledBookShelfBlock,
490 world: &Arc<World>,
491 state: BlockStateId,
492 player: &Player,
493 inventory: &mut InventoryAccess,
494 ) {
495 let facing = state.get_value(HORIZONTAL_FACING);
496 player
497 .inventory
498 .lock()
499 .set_selected_item(ItemStack::new(&vanilla_items::STONE));
500 assert_eq!(
501 behavior.use_item_on(
502 state,
503 world,
504 TEST_POS,
505 player,
506 InteractionHand::MainHand,
507 &bookshelf_hit(facing, LEFT_COLUMN_HIT, TOP_ROW_HIT),
508 inventory,
509 ),
510 InteractionResult::TryEmptyHandInteraction,
511 );
512 assert!(
513 player
514 .inventory
515 .lock()
516 .get_selected_item()
517 .is(&vanilla_items::STONE)
518 );
519 }
520
521 fn assert_top_left_slot_removal_returns_book(
522 behavior: &ChiseledBookShelfBlock,
523 world: &Arc<World>,
524 player: &Player,
525 inventory: &mut InventoryAccess,
526 ) {
527 let state = world.get_block_state(TEST_POS);
528 let facing = state.get_value(HORIZONTAL_FACING);
529 player
530 .inventory
531 .lock()
532 .set_selected_item(ItemStack::empty());
533 assert_eq!(
534 behavior.use_without_item(
535 state,
536 world,
537 TEST_POS,
538 player,
539 &bookshelf_hit(facing, LEFT_COLUMN_HIT, TOP_ROW_HIT),
540 inventory,
541 ),
542 InteractionResult::Success,
543 );
544 assert!(
545 !world
546 .get_block_state(TEST_POS)
547 .get_value(&BlockStateProperties::SLOT_0_OCCUPIED)
548 );
549 assert_eq!(
550 behavior.get_analog_output_signal(
551 world.get_block_state(TEST_POS),
552 world,
553 TEST_POS,
554 ARBITRARY_COMPARATOR_QUERY_DIRECTION,
555 ),
556 expected_comparator_output(TOP_LEFT_SLOT),
557 );
558 assert!(
559 player
560 .inventory
561 .lock()
562 .items()
563 .iter()
564 .any(|item| item.is(&vanilla_items::BOOK))
565 );
566 }
567
568 #[test]
569 fn interactions_update_inventory_occupied_state_and_comparator() {
570 init_globals();
571 let world = fresh_test_world("chiseled_bookshelf_interactions");
572 let holder = insert_ready_full_chunk(&world, ChunkPos::from_block_pos(TEST_POS));
573 let state = state_facing(INTERACTION_FACING);
574 assert!(world.set_block(TEST_POS, state, UpdateFlags::UPDATE_ALL));
575 let behavior = ChiseledBookShelfBlock::new(&vanilla_blocks::CHISELED_BOOKSHELF);
576 let player = test_player(&world);
577 let mut inventory =
578 InventoryAccess::new(Arc::clone(&player.inventory), InteractionHand::MainHand);
579
580 assert_invalid_item_is_rejected(&behavior, &world, state, &player, &mut inventory);
581
582 let revision = holder.packet_content_revision();
583 let facing = state.get_value(HORIZONTAL_FACING);
584 player
585 .inventory
586 .lock()
587 .set_selected_item(ItemStack::with_count(
588 &vanilla_items::BOOK,
589 SURVIVAL_STARTING_BOOK_COUNT,
590 ));
591 assert_eq!(
592 behavior.use_item_on(
593 state,
594 &world,
595 TEST_POS,
596 &player,
597 InteractionHand::MainHand,
598 &bookshelf_hit(facing, LEFT_COLUMN_HIT, TOP_ROW_HIT),
599 &mut inventory,
600 ),
601 InteractionResult::Success,
602 );
603 assert_eq!(
604 player.inventory.lock().get_selected_item().count(),
605 SURVIVAL_STARTING_BOOK_COUNT - ChiseledBookShelfBlock::BOOKS_PER_INTERACTION,
606 );
607 assert!(
608 world
609 .get_block_state(TEST_POS)
610 .get_value(&BlockStateProperties::SLOT_0_OCCUPIED)
611 );
612 assert!(holder.packet_content_revision() > revision);
613 assert_eq!(
614 behavior.get_analog_output_signal(
615 world.get_block_state(TEST_POS),
616 &world,
617 TEST_POS,
618 ARBITRARY_COMPARATOR_QUERY_DIRECTION,
619 ),
620 expected_comparator_output(TOP_LEFT_SLOT),
621 );
622
623 player.restore_game_modes(GameType::Creative, None);
624 player
625 .inventory
626 .lock()
627 .set_selected_item(ItemStack::new(&vanilla_items::ENCHANTED_BOOK));
628 let creative_book_count = player.inventory.lock().get_selected_item().count();
629 assert_eq!(
630 behavior.use_item_on(
631 world.get_block_state(TEST_POS),
632 &world,
633 TEST_POS,
634 &player,
635 InteractionHand::MainHand,
636 &bookshelf_hit(INTERACTION_FACING, MIDDLE_COLUMN_HIT, TOP_ROW_HIT),
637 &mut inventory,
638 ),
639 InteractionResult::Success,
640 );
641 assert_eq!(
642 player.inventory.lock().get_selected_item().count(),
643 creative_book_count,
644 );
645
646 let block_entity = world
647 .get_block_entity(TEST_POS)
648 .expect("placed chiseled bookshelf should have a block entity");
649 let bookshelf = block_entity
650 .downcast_ref::<ChiseledBookShelfBlockEntity>()
651 .expect("registered chiseled bookshelf should use its concrete block entity");
652 let occupied_properties = [
653 &BlockStateProperties::SLOT_0_OCCUPIED,
654 &BlockStateProperties::SLOT_1_OCCUPIED,
655 &BlockStateProperties::SLOT_2_OCCUPIED,
656 &BlockStateProperties::SLOT_3_OCCUPIED,
657 &BlockStateProperties::SLOT_4_OCCUPIED,
658 &BlockStateProperties::SLOT_5_OCCUPIED,
659 ];
660 for (slot, property) in occupied_properties.iter().enumerate().skip(TOP_RIGHT_SLOT) {
661 assert!(bookshelf.insert_book(slot, ItemStack::new(&vanilla_items::BOOK)));
662 assert!(world.get_block_state(TEST_POS).get_value(*property));
663 assert_eq!(
664 behavior.get_analog_output_signal(
665 world.get_block_state(TEST_POS),
666 &world,
667 TEST_POS,
668 ARBITRARY_COMPARATOR_QUERY_DIRECTION,
669 ),
670 expected_comparator_output(slot),
671 );
672 }
673
674 assert_top_left_slot_removal_returns_book(&behavior, &world, &player, &mut inventory);
675 }
676
677 #[test]
678 fn enchanted_books_select_the_enchanted_insert_and_pickup_sounds() {
679 init_globals();
680 assert_eq!(
681 ChiseledBookShelfBlock::insert_sound(&ItemStack::new(&vanilla_items::BOOK)).key,
682 sound_events::BLOCK_CHISELED_BOOKSHELF_INSERT.key,
683 );
684 assert_eq!(
685 ChiseledBookShelfBlock::insert_sound(&ItemStack::new(&vanilla_items::ENCHANTED_BOOK))
686 .key,
687 sound_events::BLOCK_CHISELED_BOOKSHELF_INSERT_ENCHANTED.key,
688 );
689 assert_eq!(
690 ChiseledBookShelfBlock::pickup_sound(&ItemStack::new(&vanilla_items::BOOK)).key,
691 sound_events::BLOCK_CHISELED_BOOKSHELF_PICKUP.key,
692 );
693 assert_eq!(
694 ChiseledBookShelfBlock::pickup_sound(&ItemStack::new(&vanilla_items::ENCHANTED_BOOK))
695 .key,
696 sound_events::BLOCK_CHISELED_BOOKSHELF_PICKUP_ENCHANTED.key,
697 );
698 }
699
700 #[test]
701 fn placement_applies_the_container_component_to_the_block_entity() {
702 init_globals();
703 let world = fresh_test_world("chiseled_bookshelf_component_placement");
704 insert_ready_full_chunk(&world, ChunkPos::from_block_pos(TEST_POS));
705 let state = state_facing(COMPONENT_PLACER_FACING.opposite());
706 assert!(world.set_block(TEST_POS, state, UpdateFlags::UPDATE_ALL));
707 let behavior = ChiseledBookShelfBlock::new(&vanilla_blocks::CHISELED_BOOKSHELF);
708
709 let contents = ItemContainerContents::new(vec![
710 Some(ItemStackTemplate::new(&vanilla_items::BOOK)),
711 None,
712 Some(ItemStackTemplate::new(&vanilla_items::ENCHANTED_BOOK)),
713 ])
714 .expect("three component slots are valid");
715 let mut placed_item = ItemStack::new(&vanilla_items::CHISELED_BOOKSHELF);
716 placed_item.set(CONTAINER, contents);
717 let initial_last_interacted_slot = world
718 .get_block_entity(TEST_POS)
719 .and_then(|block_entity| {
720 block_entity
721 .downcast_ref::<ChiseledBookShelfBlockEntity>()
722 .map(ChiseledBookShelfBlockEntity::last_interacted_slot)
723 })
724 .expect("placed chiseled bookshelf should have its concrete block entity");
725 let is_secondary_use_active = false;
726 let source = PlacementSource::direct(
727 None,
728 InteractionHand::MainHand,
729 &mut placed_item,
730 PlacementOrientation::Directional {
731 direction: COMPONENT_PLACER_FACING,
732 },
733 is_secondary_use_active,
734 );
735 behavior.set_placed_by(state, &world, TEST_POS, &source);
736
737 let block_entity = world
738 .get_block_entity(TEST_POS)
739 .expect("placed chiseled bookshelf should have a block entity");
740 let bookshelf = block_entity
741 .downcast_ref::<ChiseledBookShelfBlockEntity>()
742 .expect("registered chiseled bookshelf should use its concrete block entity");
743 assert!(
744 bookshelf
745 .item(TOP_LEFT_SLOT)
746 .is_some_and(|item| item.is(&vanilla_items::BOOK))
747 );
748 assert!(
749 bookshelf
750 .item(TOP_MIDDLE_SLOT)
751 .is_some_and(|item| item.is_empty())
752 );
753 assert!(
754 bookshelf
755 .item(TOP_RIGHT_SLOT)
756 .is_some_and(|item| item.is(&vanilla_items::ENCHANTED_BOOK))
757 );
758 assert_eq!(
759 bookshelf.last_interacted_slot(),
760 initial_last_interacted_slot,
761 );
762 }
763
764 #[test]
765 fn destruction_drains_and_drops_every_stored_book() {
766 init_globals();
767 let world = fresh_test_world("chiseled_bookshelf_drops");
768 insert_ready_full_chunk(&world, ChunkPos::from_block_pos(TEST_POS));
769 let state = state_facing(INTERACTION_FACING);
770 assert!(world.set_block(TEST_POS, state, UpdateFlags::UPDATE_ALL));
771 let block_entity = world
772 .get_block_entity(TEST_POS)
773 .expect("placed chiseled bookshelf should have a block entity");
774 let bookshelf = block_entity
775 .downcast_ref::<ChiseledBookShelfBlockEntity>()
776 .expect("registered chiseled bookshelf should use its concrete block entity");
777 for slot in 0..BOTTOM_RIGHT_SLOT {
778 assert!(bookshelf.insert_book(slot, ItemStack::new(&vanilla_items::BOOK)));
779 }
780 assert!(bookshelf.insert_book(
781 BOTTOM_RIGHT_SLOT,
782 ItemStack::new(&vanilla_items::ENCHANTED_BOOK),
783 ));
784
785 assert!(world.set_block(
786 TEST_POS,
787 vanilla_blocks::AIR.default_state(),
788 UpdateFlags::UPDATE_ALL,
789 ));
790 assert!(world.get_block_entity(TEST_POS).is_none());
791 for slot in 0..CHISELED_BOOKSHELF_SLOTS {
792 assert!(bookshelf.item(slot).is_some_and(|item| item.is_empty()));
793 }
794
795 let block_min = DVec3::new(
796 f64::from(TEST_POS.x()),
797 f64::from(TEST_POS.y()),
798 f64::from(TEST_POS.z()),
799 );
800 let drop_search_area =
801 WorldAabb::from_min_max(block_min, block_min + DVec3::ONE).inflate(DROP_SEARCH_MARGIN);
802 let drops = world.get_entities_in_aabb_matching(&drop_search_area, |entity| {
803 entity.entity_type() == &vanilla_entities::ITEM
804 });
805 assert_eq!(drops.len(), CHISELED_BOOKSHELF_SLOTS);
806 let dropped_items = drops
807 .iter()
808 .filter_map(|entity| {
809 entity
810 .downcast_ref::<ItemEntity>()
811 .map(ItemEntity::get_item)
812 })
813 .collect::<Vec<_>>();
814 assert_eq!(dropped_items.len(), CHISELED_BOOKSHELF_SLOTS);
815 assert_eq!(
816 dropped_items
817 .iter()
818 .filter(|item| item.is(&vanilla_items::BOOK))
819 .map(ItemStack::count)
820 .sum::<i32>(),
821 BOTTOM_RIGHT_SLOT as i32,
822 );
823 assert_eq!(
824 dropped_items
825 .iter()
826 .filter(|item| item.is(&vanilla_items::ENCHANTED_BOOK))
827 .map(ItemStack::count)
828 .sum::<i32>(),
829 EXPECTED_ENCHANTED_BOOK_DROP_COUNT,
830 );
831 }
832}