1use 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
24struct PaymentContainer {
26 item: ItemStack,
27}
28
29unsafe 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#[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
106fn encode_effect(effect: Option<MobEffectRef>) -> i16 {
109 effect.map_or(0, |effect| effect.id() as i16 + 1)
110}
111
112pub struct BeaconKind {
114 payment_ref: ContainerRef,
115 payment_container: Shared<PaymentContainer>,
116 state: Arc<SyncMutex<BeaconState>>,
117 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
129unsafe 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 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 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 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 #[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 #[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}