Skip to main content

steel_core/inventory/menu/kinds/
beacon_menu.rs

1//! Beacon menu.
2//!
3//! A single payment slot plus the player inventory. Three data slots mirror the
4//! beacon's pyramid level and configured effects, matching vanilla's
5//! `DATA_LEVELS`, `DATA_PRIMARY`, and `DATA_SECONDARY`.
6
7use std::slice;
8use std::sync::Arc;
9
10use steel_registry::blocks::block_state_ext::BlockStateExt as _;
11use steel_registry::{
12    REGISTRY, RegistryEntry, TaggedRegistryExt, item_stack::ItemStack, mob_effect::MobEffectRef,
13    sound_events, vanilla_blocks, vanilla_item_tags, vanilla_menu_types,
14};
15use steel_utils::{BlockPos, locks::SyncMutex};
16
17use crate::block_entity::BlockEntityBase;
18use crate::block_entity::entities::{BeaconBlockEntity, BeaconState};
19use crate::inventory::container::Container;
20use crate::inventory::prelude::*;
21use crate::player::player_inventory::PlayerInventory;
22use crate::world::World;
23
24/// Payment slot container; capped at 1 item like vanilla's `PaymentSlot`.
25struct PaymentContainer {
26    item: ItemStack,
27}
28
29// SAFETY: This Steel-owned key uniquely identifies this concrete container type.
30unsafe impl steel_utils::DowncastType for PaymentContainer {
31    const TYPE_KEY: steel_utils::DowncastTypeKey =
32        steel_utils::DowncastTypeKey::new("steel:container/beacon_payment");
33}
34
35impl PaymentContainer {
36    fn new() -> Self {
37        Self {
38            item: ItemStack::empty(),
39        }
40    }
41}
42
43impl Container for PaymentContainer {
44    fn items(&self) -> &[ItemStack] {
45        slice::from_ref(&self.item)
46    }
47
48    fn items_mut(&mut self) -> &mut [ItemStack] {
49        slice::from_mut(&mut self.item)
50    }
51
52    fn get_max_stack_size(&self) -> i32 {
53        1
54    }
55
56    fn set_changed(&mut self) {}
57}
58
59/// Builds the beacon menu.
60#[must_use]
61pub fn beacon(
62    inventory: Shared<PlayerInventory>,
63    container_id: u8,
64    pos: BlockPos,
65    world: &Arc<World>,
66    state: Arc<SyncMutex<BeaconState>>,
67    block_entity: Arc<BlockEntityBase>,
68) -> Menu {
69    let payment_container = PaymentContainer::new().into_shared();
70    let payment_ref = ContainerRef::from(payment_container.clone());
71
72    let mut builder = MenuBuilder::new(&vanilla_menu_types::BEACON, container_id);
73    let payment = builder.section_with(
74        &payment_ref,
75        1,
76        SectionKind::restricted(|_, stack| {
77            REGISTRY.items.is_in_tag(
78                stack.item(),
79                &vanilla_item_tags::ItemTag::BEACON_PAYMENT_ITEMS,
80            )
81        }),
82    );
83    let player = builder.player_inventory(&inventory);
84    let levels = builder.data_slot(0);
85    let primary = builder.data_slot(0);
86    let secondary = builder.data_slot(0);
87
88    builder.route(payment, player.all(), FillDirection::Backward);
89
90    builder.build(BeaconKind {
91        payment_ref,
92        payment_container,
93        state,
94        block_entity,
95        levels,
96        primary,
97        secondary,
98        payment,
99        player_main: player.main(),
100        player_hotbar: player.hotbar(),
101        world: Arc::clone(world),
102        pos,
103    })
104}
105
106/// Encodes an effect as vanilla's `BeaconMenu.encodeEffect`: `0` for none, or
107/// the effect's registry id plus one.
108fn encode_effect(effect: Option<MobEffectRef>) -> i16 {
109    effect.map_or(0, |effect| effect.id() as i16 + 1)
110}
111
112/// Per-menu beacon state.
113pub struct BeaconKind {
114    payment_ref: ContainerRef,
115    payment_container: Shared<PaymentContainer>,
116    state: Arc<SyncMutex<BeaconState>>,
117    /// Handle used to mark the beacon changed after a selection.
118    block_entity: Arc<BlockEntityBase>,
119    levels: DataSlot,
120    primary: DataSlot,
121    secondary: DataSlot,
122    payment: Section,
123    player_main: Section,
124    player_hotbar: Section,
125    world: Arc<World>,
126    pos: BlockPos,
127}
128
129// SAFETY: This Steel-owned key uniquely identifies the concrete menu kind
130// within the process.
131unsafe impl steel_utils::DowncastType for BeaconKind {
132    const TYPE_KEY: steel_utils::DowncastTypeKey =
133        steel_utils::DowncastTypeKey::new("steel:menu/beacon");
134}
135
136impl BeaconKind {
137    fn sync_data_slots(&self, behavior: &mut MenuBehavior) {
138        let state = self.state.lock();
139        self.levels.set(behavior, state.levels as i16);
140        self.primary
141            .set(behavior, encode_effect(state.primary_power));
142        self.secondary
143            .set(behavior, encode_effect(state.secondary_power));
144    }
145}
146
147impl MenuKind for BeaconKind {
148    fn removed(&mut self, _behavior: &mut MenuBehavior, player: &Player) {
149        let payment = self.payment_container.lock().remove_item_no_update(0);
150        if payment.is_empty() {
151            return;
152        }
153        let _ = player.drop_item(payment, false, false);
154    }
155
156    fn still_valid(&self, _behavior: &MenuBehavior, player: &Player) -> bool {
157        self.world.get_block_state(self.pos).get_block() == &vanilla_blocks::BEACON
158            && player.is_within_block_interaction_range_with_buffer(self.pos, 4.0)
159    }
160
161    fn on_open(
162        &mut self,
163        behavior: &mut MenuBehavior,
164        _guard: &mut ContainerLockGuard,
165        _player: &Player,
166    ) {
167        self.sync_data_slots(behavior);
168    }
169
170    fn on_tick(
171        &mut self,
172        behavior: &mut MenuBehavior,
173        _guard: &mut ContainerLockGuard,
174        _player: &Player,
175    ) {
176        self.sync_data_slots(behavior);
177    }
178
179    fn on_update_effects(
180        &mut self,
181        behavior: &mut MenuBehavior,
182        guard: &mut ContainerLockGuard,
183        primary: Option<MobEffectRef>,
184        secondary: Option<MobEffectRef>,
185    ) -> bool {
186        let Some(payment) = guard.get(self.payment_ref.container_id()) else {
187            return false;
188        };
189        if payment.get_item(0).is_empty() {
190            return false;
191        }
192
193        let has_beam = {
194            let mut state = self.state.lock();
195            let levels = state.levels;
196            if !BeaconState::validate_effects(primary, secondary, levels) {
197                return false;
198            }
199            state.primary_power = primary;
200            state.secondary_power = secondary;
201            !state.beam_sections.is_empty()
202        };
203
204        guard.set_item(self.payment_ref.container_id(), 0, ItemStack::empty());
205        self.sync_data_slots(behavior);
206
207        self.block_entity.set_changed();
208
209        if has_beam {
210            BeaconBlockEntity::play_sound(
211                &self.world,
212                self.pos,
213                &sound_events::BLOCK_BEACON_POWER_SELECT,
214            );
215        }
216        true
217    }
218
219    fn quick_move(
220        &mut self,
221        behavior: &mut MenuBehavior,
222        guard: &mut ContainerLockGuard,
223        slot_index: usize,
224        player: &Player,
225    ) -> Option<ItemStack> {
226        if self.payment.contains(slot_index) {
227            return None;
228        }
229
230        let clicked = behavior.slots()[slot_index].get_item(guard).clone();
231        if clicked.is_empty() {
232            return Some(ItemStack::empty());
233        }
234        if !behavior.slots()[slot_index].may_pickup(guard, player) {
235            return Some(ItemStack::empty());
236        }
237
238        let mut remaining = clicked.clone();
239        let pay_start = self.payment.start();
240        let payment_slot = &behavior.slots()[pay_start];
241        let is_single_payment = !payment_slot.has_item(guard)
242            && payment_slot.may_place(&clicked)
243            && clicked.count() == 1;
244
245        let moved = if is_single_payment {
246            behavior.move_item_stack_to(
247                guard,
248                slot_index,
249                &mut remaining,
250                pay_start,
251                pay_start + 1,
252                FillDirection::Forward,
253            )
254        } else if self.player_main.contains(slot_index) {
255            behavior.move_item_stack_to(
256                guard,
257                slot_index,
258                &mut remaining,
259                self.player_hotbar.start(),
260                self.player_hotbar.end(),
261                FillDirection::Forward,
262            )
263        } else if self.player_hotbar.contains(slot_index) {
264            behavior.move_item_stack_to(
265                guard,
266                slot_index,
267                &mut remaining,
268                self.player_main.start(),
269                self.player_main.end(),
270                FillDirection::Forward,
271            )
272        } else {
273            behavior.move_item_stack_to(
274                guard,
275                slot_index,
276                &mut remaining,
277                self.player_main.start(),
278                self.player_hotbar.end(),
279                FillDirection::Forward,
280            )
281        };
282
283        if !moved {
284            return Some(ItemStack::empty());
285        }
286
287        behavior.update_quick_move_source(guard, slot_index, &remaining, &clicked);
288        if remaining.count == clicked.count {
289            return Some(ItemStack::empty());
290        }
291        let slot = &behavior.slots()[slot_index];
292        if let Some(leftover) = slot.on_take(guard, &remaining, player) {
293            player.add_item_or_drop_with_guard(guard, leftover);
294        }
295        Some(clicked)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use std::sync::Arc;
302
303    use steel_registry::{
304        init_vanilla_registry, item_stack::ItemStack, vanilla_blocks, vanilla_items,
305        vanilla_mob_effects,
306    };
307    use steel_utils::locks::SyncMutex;
308    use steel_utils::types::UpdateFlags;
309    use steel_utils::{BlockPos, ChunkPos, Downcast as _};
310
311    use crate::behavior::init_behaviors;
312    use crate::block_entity::entities::{BeaconBlockEntity, BeaconState};
313    use crate::block_entity::init_block_entities;
314    use crate::inventory::click::{Click, MouseButton};
315    use crate::inventory::container::Container as _;
316    use crate::inventory::lock::ContainerId;
317    use crate::inventory::prelude::Menu;
318    use crate::player::Player;
319    use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
320    use crate::world::World;
321
322    use super::{BeaconKind, beacon};
323
324    /// Places a beacon and opens its menu, returning the menu and the entity's shared state.
325    fn open_test_beacon(
326        world: &Arc<World>,
327        player: &Player,
328        pos: BlockPos,
329    ) -> (Menu, Arc<SyncMutex<BeaconState>>) {
330        assert!(world.set_block(
331            pos,
332            vanilla_blocks::BEACON.default_state(),
333            UpdateFlags::UPDATE_NONE,
334        ));
335        let block_entity = world.get_block_entity(pos).expect("beacon block entity");
336        let beacon_entity = block_entity
337            .downcast_ref::<BeaconBlockEntity>()
338            .expect("beacon block entity type");
339
340        let menu = beacon(
341            player.inventory.clone(),
342            1,
343            pos,
344            world,
345            beacon_entity.state(),
346            beacon_entity.base_handle(),
347        );
348        (menu, beacon_entity.state())
349    }
350
351    /// Returns the payment container id of a beacon menu.
352    fn payment_id_of(menu: &Menu) -> ContainerId {
353        menu.kind()
354            .downcast_ref::<BeaconKind>()
355            .expect("beacon kind")
356            .payment_ref
357            .container_id()
358    }
359
360    /// Puts `stack` into the payment slot through a normal click.
361    fn click_into_payment(menu: &mut Menu, player: &Player, stack: ItemStack) {
362        *menu.behavior_mut().carried_mut() = stack;
363        menu.clicked(
364            Click::Pickup {
365                slot: 0,
366                button: MouseButton::Left,
367            },
368            player,
369        );
370    }
371
372    #[test]
373    fn beacon_menu_rejects_other_items_and_consumes_iron_on_effect_selection() {
374        init_vanilla_registry();
375        init_behaviors();
376        init_block_entities();
377        let world = fresh_test_world("beacon_menu_iron_payment");
378        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
379        let player = TestPlayerBuilder::new(Arc::clone(&world), "BeaconTester", 1).build();
380
381        let (mut menu, state) = open_test_beacon(&world, &player, BlockPos::new(8, 64, 8));
382        let payment_id = payment_id_of(&menu);
383
384        click_into_payment(&mut menu, &player, ItemStack::new(&vanilla_items::DIRT));
385        assert!(menu.behavior().carried().is(&vanilla_items::DIRT));
386        assert!({
387            let guard = menu.behavior().lock_all_containers();
388            guard
389                .get(payment_id)
390                .expect("payment container locked")
391                .get_item(0)
392                .is_empty()
393        });
394
395        click_into_payment(
396            &mut menu,
397            &player,
398            ItemStack::with_count(&vanilla_items::IRON_INGOT, 2),
399        );
400        assert_eq!(menu.behavior().carried().count(), 1);
401        assert!({
402            let guard = menu.behavior().lock_all_containers();
403            guard
404                .get(payment_id)
405                .expect("payment container locked")
406                .get_item(0)
407                .is(&vanilla_items::IRON_INGOT)
408        });
409
410        state.lock().levels = 1;
411
412        menu.update_effects(Some(vanilla_mob_effects::SPEED), None, &player.connection);
413        assert!({
414            let guard = menu.behavior().lock_all_containers();
415            guard
416                .get(payment_id)
417                .expect("payment container locked")
418                .get_item(0)
419                .is_empty()
420        });
421        let state = state.lock();
422        assert_eq!(
423            state.primary_power.map(|effect| effect.key.clone()),
424            Some(vanilla_mob_effects::SPEED.key.clone())
425        );
426        assert!(state.secondary_power.is_none());
427    }
428
429    /// Vanilla's `quickMoveStack` only routes an item into the payment slot when that slot is
430    /// empty. Without the `!hasItem()` guard the move into the full 1-capacity slot fails and the
431    /// item is stranded instead of falling through to the inventory/hotbar branch.
432    #[test]
433    fn shift_click_moves_payment_item_when_payment_slot_is_occupied() {
434        init_vanilla_registry();
435        init_behaviors();
436        init_block_entities();
437        let world = fresh_test_world("beacon_menu_occupied_payment");
438        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
439        let player = TestPlayerBuilder::new(Arc::clone(&world), "BeaconShift", 1).build();
440
441        let (mut menu, _) = open_test_beacon(&world, &player, BlockPos::new(8, 64, 8));
442        let payment_id = payment_id_of(&menu);
443
444        click_into_payment(
445            &mut menu,
446            &player,
447            ItemStack::new(&vanilla_items::IRON_INGOT),
448        );
449
450        let main_slot = menu
451            .kind()
452            .downcast_ref::<BeaconKind>()
453            .expect("beacon kind")
454            .player_main
455            .start();
456        {
457            let mut guard = menu.behavior().lock_all_containers();
458            menu.behavior().slots()[main_slot]
459                .set_item(&mut guard, ItemStack::new(&vanilla_items::IRON_INGOT));
460        }
461
462        menu.clicked(Click::QuickMove { slot: main_slot }, &player);
463
464        assert!(
465            {
466                let guard = menu.behavior().lock_all_containers();
467                menu.behavior().slots()[main_slot]
468                    .get_item(&guard)
469                    .is_empty()
470            },
471            "shift-clicked ingot should have left the main inventory slot"
472        );
473        assert!({
474            let guard = menu.behavior().lock_all_containers();
475            guard
476                .get(payment_id)
477                .expect("payment container locked")
478                .get_item(0)
479                .is(&vanilla_items::IRON_INGOT)
480        });
481    }
482
483    /// Vanilla `BeaconMenu.removed` drops the unspent payment on the floor instead of returning
484    /// it to the inventory, even for a connected, living player.
485    #[test]
486    fn closing_the_menu_drops_the_unspent_payment() {
487        init_vanilla_registry();
488        init_behaviors();
489        init_block_entities();
490        let world = fresh_test_world("beacon_menu_drops_payment");
491        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
492        let player = TestPlayerBuilder::new(Arc::clone(&world), "BeaconClose", 1).build();
493
494        let (mut menu, _) = open_test_beacon(&world, &player, BlockPos::new(8, 64, 8));
495        click_into_payment(
496            &mut menu,
497            &player,
498            ItemStack::new(&vanilla_items::IRON_INGOT),
499        );
500
501        menu.removed(&player);
502
503        assert!(
504            !player
505                .inventory
506                .lock()
507                .contains_stack(&ItemStack::new(&vanilla_items::IRON_INGOT)),
508            "vanilla drops the payment rather than returning it to the inventory"
509        );
510    }
511}