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 crate::player::player_inventory::Prediction;
8use std::slice;
9use std::sync::Arc;
10
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(Arc::clone(&payment_container));
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, Prediction::ServerOnly);
154    }
155
156    fn still_valid(&self, _behavior: &MenuBehavior, player: &Player) -> bool {
157        still_valid_block(&self.world, self.pos, &vanilla_blocks::BEACON, player)
158    }
159
160    fn on_open(
161        &mut self,
162        behavior: &mut MenuBehavior,
163        _guard: &mut ContainerLockGuard,
164        _player: &Player,
165    ) {
166        self.sync_data_slots(behavior);
167    }
168
169    fn on_tick(
170        &mut self,
171        behavior: &mut MenuBehavior,
172        _guard: &mut ContainerLockGuard,
173        _player: &Player,
174    ) {
175        self.sync_data_slots(behavior);
176    }
177
178    fn on_update_effects(
179        &mut self,
180        behavior: &mut MenuBehavior,
181        guard: &mut ContainerLockGuard,
182        primary: Option<MobEffectRef>,
183        secondary: Option<MobEffectRef>,
184    ) -> bool {
185        let Some(payment) = guard.get(self.payment_ref.container_id()) else {
186            return false;
187        };
188        if payment.get_item(0).is_empty() {
189            return false;
190        }
191
192        let has_beam = {
193            let mut state = self.state.lock();
194            let levels = state.levels;
195            if !BeaconState::validate_effects(primary, secondary, levels) {
196                return false;
197            }
198            state.primary_power = primary;
199            state.secondary_power = secondary;
200            !state.beam_sections.is_empty()
201        };
202
203        guard.set_item(self.payment_ref.container_id(), 0, ItemStack::empty());
204        self.sync_data_slots(behavior);
205
206        self.block_entity.set_changed();
207
208        if has_beam {
209            BeaconBlockEntity::play_sound(
210                &self.world,
211                self.pos,
212                &sound_events::BLOCK_BEACON_POWER_SELECT,
213            );
214        }
215        true
216    }
217
218    fn quick_move(
219        &mut self,
220        behavior: &mut MenuBehavior,
221        guard: &mut ContainerLockGuard,
222        slot_index: usize,
223        player: &Player,
224    ) -> Option<ItemStack> {
225        if self.payment.contains(slot_index) {
226            return None;
227        }
228
229        let clicked = behavior.slots()[slot_index].get_item(guard).clone();
230        if clicked.is_empty() {
231            return Some(ItemStack::empty());
232        }
233        if !behavior.slots()[slot_index].may_pickup(guard, player) {
234            return Some(ItemStack::empty());
235        }
236
237        let mut remaining = clicked.clone();
238        let pay_start = self.payment.start();
239        let payment_slot = &behavior.slots()[pay_start];
240        let is_single_payment = !payment_slot.has_item(guard)
241            && payment_slot.may_place(&clicked)
242            && clicked.count() == 1;
243
244        let moved = if is_single_payment {
245            behavior.move_item_stack_to(
246                guard,
247                slot_index,
248                &mut remaining,
249                pay_start,
250                pay_start + 1,
251                FillDirection::Forward,
252            )
253        } else if self.player_main.contains(slot_index) {
254            behavior.move_item_stack_to(
255                guard,
256                slot_index,
257                &mut remaining,
258                self.player_hotbar.start(),
259                self.player_hotbar.end(),
260                FillDirection::Forward,
261            )
262        } else if self.player_hotbar.contains(slot_index) {
263            behavior.move_item_stack_to(
264                guard,
265                slot_index,
266                &mut remaining,
267                self.player_main.start(),
268                self.player_main.end(),
269                FillDirection::Forward,
270            )
271        } else {
272            behavior.move_item_stack_to(
273                guard,
274                slot_index,
275                &mut remaining,
276                self.player_main.start(),
277                self.player_hotbar.end(),
278                FillDirection::Forward,
279            )
280        };
281
282        if !moved {
283            return Some(ItemStack::empty());
284        }
285
286        behavior.update_quick_move_source(guard, slot_index, &remaining, &clicked);
287        if remaining.count == clicked.count {
288            return Some(ItemStack::empty());
289        }
290        let slot = &behavior.slots()[slot_index];
291        if let Some(leftover) = slot.on_take(guard, &remaining, player) {
292            player.add_item_or_drop_with_guard(guard, leftover, Prediction::Predicted);
293        }
294        Some(clicked)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use std::sync::Arc;
301
302    use steel_registry::{
303        init_vanilla_registry, item_stack::ItemStack, vanilla_blocks, vanilla_items,
304        vanilla_mob_effects,
305    };
306    use steel_utils::locks::SyncMutex;
307    use steel_utils::types::UpdateFlags;
308    use steel_utils::{BlockPos, ChunkPos, Downcast as _};
309
310    use crate::behavior::init_behaviors;
311    use crate::block_entity::entities::{BeaconBlockEntity, BeaconState};
312    use crate::block_entity::init_block_entities;
313    use crate::inventory::click::{Click, MouseButton};
314    use crate::inventory::container::Container as _;
315    use crate::inventory::lock::ContainerId;
316    use crate::inventory::prelude::Menu;
317    use crate::player::Player;
318    use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
319    use crate::world::World;
320
321    use super::{BeaconKind, beacon};
322
323    /// Places a beacon and opens its menu, returning the menu and the entity's shared state.
324    fn open_test_beacon(
325        world: &Arc<World>,
326        player: &Player,
327        pos: BlockPos,
328    ) -> (Menu, Arc<SyncMutex<BeaconState>>) {
329        assert!(world.set_block(
330            pos,
331            vanilla_blocks::BEACON.default_state(),
332            UpdateFlags::UPDATE_NONE,
333        ));
334        let block_entity = world.get_block_entity(pos).expect("beacon block entity");
335        let beacon_entity = block_entity
336            .downcast_ref::<BeaconBlockEntity>()
337            .expect("beacon block entity type");
338
339        let menu = beacon(
340            Arc::clone(&player.inventory),
341            1,
342            pos,
343            world,
344            beacon_entity.state(),
345            beacon_entity.base_handle(),
346        );
347        (menu, beacon_entity.state())
348    }
349
350    /// Returns the payment container id of a beacon menu.
351    fn payment_id_of(menu: &Menu) -> ContainerId {
352        menu.kind()
353            .downcast_ref::<BeaconKind>()
354            .expect("beacon kind")
355            .payment_ref
356            .container_id()
357    }
358
359    /// Puts `stack` into the payment slot through a normal click.
360    fn click_into_payment(menu: &mut Menu, player: &Player, stack: ItemStack) {
361        *menu.behavior_mut().carried_mut() = stack;
362        menu.clicked(
363            Click::Pickup {
364                slot: 0,
365                button: MouseButton::Left,
366            },
367            player,
368        );
369    }
370
371    #[test]
372    fn beacon_menu_rejects_other_items_and_consumes_iron_on_effect_selection() {
373        init_vanilla_registry();
374        init_behaviors();
375        init_block_entities();
376        let world_fixture = fresh_test_world("beacon_menu_iron_payment");
377        let world = &world_fixture.world;
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_fixture = fresh_test_world("beacon_menu_occupied_payment");
438        let world = &world_fixture.world;
439        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
440        let player = TestPlayerBuilder::new(Arc::clone(world), "BeaconShift", 1).build();
441
442        let (mut menu, _) = open_test_beacon(world, &player, BlockPos::new(8, 64, 8));
443        let payment_id = payment_id_of(&menu);
444
445        click_into_payment(
446            &mut menu,
447            &player,
448            ItemStack::new(&vanilla_items::IRON_INGOT),
449        );
450
451        let main_slot = menu
452            .kind()
453            .downcast_ref::<BeaconKind>()
454            .expect("beacon kind")
455            .player_main
456            .start();
457        {
458            let mut guard = menu.behavior().lock_all_containers();
459            menu.behavior().slots()[main_slot]
460                .set_item(&mut guard, ItemStack::new(&vanilla_items::IRON_INGOT));
461        }
462
463        menu.clicked(Click::QuickMove { slot: main_slot }, &player);
464
465        assert!(
466            {
467                let guard = menu.behavior().lock_all_containers();
468                menu.behavior().slots()[main_slot]
469                    .get_item(&guard)
470                    .is_empty()
471            },
472            "shift-clicked ingot should have left the main inventory slot"
473        );
474        assert!({
475            let guard = menu.behavior().lock_all_containers();
476            guard
477                .get(payment_id)
478                .expect("payment container locked")
479                .get_item(0)
480                .is(&vanilla_items::IRON_INGOT)
481        });
482    }
483
484    /// Vanilla `BeaconMenu.removed` drops the unspent payment on the floor instead of returning
485    /// it to the inventory, even for a connected, living player.
486    #[test]
487    fn closing_the_menu_drops_the_unspent_payment() {
488        init_vanilla_registry();
489        init_behaviors();
490        init_block_entities();
491        let world_fixture = fresh_test_world("beacon_menu_drops_payment");
492        let world = &world_fixture.world;
493        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
494        let player = TestPlayerBuilder::new(Arc::clone(world), "BeaconClose", 1).build();
495
496        let (mut menu, _) = open_test_beacon(world, &player, BlockPos::new(8, 64, 8));
497        click_into_payment(
498            &mut menu,
499            &player,
500            ItemStack::new(&vanilla_items::IRON_INGOT),
501        );
502
503        menu.removed(&player);
504
505        assert!(
506            !player
507                .inventory
508                .lock()
509                .contains_stack(&ItemStack::new(&vanilla_items::IRON_INGOT)),
510            "vanilla drops the payment rather than returning it to the inventory"
511        );
512    }
513}