Skip to main content

steel_core/inventory/menu/
mod.rs

1//! Contains the Menu API
2
3mod behavior;
4mod builder;
5mod grid;
6mod kind;
7pub mod kinds;
8mod layout;
9
10use crate::player::player_inventory::Prediction;
11use std::sync::Arc;
12
13use crate::inventory::container::Container as _;
14pub use behavior::MenuBehavior;
15pub use builder::{
16    ContainerSlots, DataSlot, FakeResultRemainderPolicy, FillDirection, IntoSections, MenuBuilder,
17    PlayerInventorySections, Section, SectionKind, SectionSource, SlotFactory,
18};
19pub use grid::{ColSpan, GridPlacer, PlacementBuilder, Rect, Region, RowSpan, SpanBounds};
20pub use kind::{MenuKind, still_valid_block};
21pub(crate) use layout::MenuLayout;
22#[cfg(test)]
23use steel_utils::locks::Shared;
24
25use std::fmt;
26use std::mem;
27
28use steel_registry::{
29    equipment::EquipmentSlot, item_stack::ItemStack, menu_type::MenuTypeRef,
30    mob_effect::MobEffectRef,
31};
32use steel_utils::{Downcast as _, types::GameType};
33
34use crate::inventory::container::CraftingContainer;
35use crate::inventory::menu::kinds::InventoryKind;
36use crate::{
37    entity::LivingEntity,
38    inventory::lock::{ContainerId, ContainerLockGuard, ContainerRef},
39    inventory::slots::{ArmorSlot, Slot},
40    player::{Player, PlayerConnection},
41};
42
43use crate::inventory::click::{Click, ClickOutcome, SwapTarget, can_item_quick_replace};
44
45/// A menu opened by a player: the shared click machinery plus one
46/// [`MenuKind`].
47///
48/// The single concrete menu type. It owns the [`MenuBehavior`], the
49/// `MenuLayout`, and a boxed [`MenuKind`]. Click handlers are inherent methods.
50pub struct Menu {
51    behavior: MenuBehavior,
52    layout: MenuLayout,
53    kind: Box<dyn MenuKind>,
54    overrides_player_slots: bool,
55}
56
57impl fmt::Debug for Menu {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.debug_struct("Menu")
60            .field("behavior", &self.behavior)
61            .field("kind", &self.kind.downcast_type_key())
62            .finish_non_exhaustive()
63    }
64}
65
66impl Menu {
67    /// Assembles a menu from its parts.
68    pub(super) fn from_parts(
69        behavior: MenuBehavior,
70        layout: MenuLayout,
71        kind: Box<dyn MenuKind>,
72        overrides_player_slots: bool,
73    ) -> Self {
74        Self {
75            behavior,
76            layout,
77            kind,
78            overrides_player_slots,
79        }
80    }
81
82    /// Returns a reference to the shared menu behavior.
83    #[must_use]
84    pub const fn behavior(&self) -> &MenuBehavior {
85        &self.behavior
86    }
87
88    /// Returns a mutable reference to the shared menu behavior.
89    pub const fn behavior_mut(&mut self) -> &mut MenuBehavior {
90        &mut self.behavior
91    }
92
93    /// Returns a reference to this menu's kind.
94    #[must_use]
95    pub fn kind(&self) -> &dyn MenuKind {
96        self.kind.as_ref()
97    }
98
99    /// Returns a mutable reference to this menu's kind.
100    pub fn kind_mut(&mut self) -> &mut dyn MenuKind {
101        self.kind.as_mut()
102    }
103
104    /// The container ID for this menu (0 for the player inventory).
105    #[must_use]
106    pub const fn container_id(&self) -> u8 {
107        self.behavior.container_id()
108    }
109
110    /// The menu type for the open-screen packet, or `None` for the player's own
111    /// inventory.
112    #[must_use]
113    pub const fn menu_type(&self) -> Option<MenuTypeRef> {
114        self.behavior.menu_type()
115    }
116
117    /// Returns whether this menu paints over the client's standard player slots.
118    #[must_use]
119    pub const fn overrides_player_slots(&self) -> bool {
120        self.overrides_player_slots
121    }
122
123    /// Returns true if this menu is still valid for the player.
124    #[must_use]
125    pub fn still_valid(&self, player: &Player) -> bool {
126        self.kind.still_valid(&self.behavior, player)
127    }
128
129    /// Returns true if the item can be taken from the slot during pickup all.
130    #[must_use]
131    pub fn can_take_item_for_pick_all(&self, carried: &ItemStack, slot_index: usize) -> bool {
132        self.kind.can_take_item_for_pick_all(carried, slot_index)
133    }
134
135    /// Forwards a beacon effect selection from the set-beacon packet.
136    pub fn update_effects(
137        &mut self,
138        primary: Option<MobEffectRef>,
139        secondary: Option<MobEffectRef>,
140        connection: &Arc<PlayerConnection>,
141    ) -> bool {
142        let accepted = {
143            let mut guard = self.behavior.lock_all_containers();
144            self.kind
145                .on_update_effects(&mut self.behavior, &mut guard, primary, secondary)
146        };
147        self.behavior.broadcast_changes(connection);
148        accepted
149    }
150
151    /// Called when the menu is closed. Hands the carried item and input sections
152    /// back to the player, then runs the kind's cleanup. Items drop into the
153    /// world if the player can't take them (see
154    /// [`Player::returns_menu_items_to_inventory`]).
155    pub fn removed(&mut self, player: &Player) {
156        let return_to_inventory = player.returns_menu_items_to_inventory();
157
158        let carried = mem::take(self.behavior.carried_mut());
159        if !carried.is_empty() {
160            if return_to_inventory {
161                player.add_item_or_drop(carried, Prediction::ServerOnly);
162            } else {
163                let _ = player.drop_item(carried, false, Prediction::ServerOnly);
164            }
165        }
166        self.layout
167            .return_drained_items(&self.behavior, player, return_to_inventory);
168
169        let Self { behavior, kind, .. } = self;
170        kind.removed(behavior, player);
171    }
172
173    /// Applies a client rename to the menu's kind. A no-op for kinds without a
174    /// rename input.
175    pub fn set_item_name(&mut self, name: impl Into<String>, player: &Player) {
176        let Self { behavior, kind, .. } = self;
177        kind.on_rename(behavior, name.into(), player);
178    }
179
180    /// Forwards a client button press to the menu's kind and reports whether
181    /// the kind handled it. Always false for kinds without buttons.
182    pub fn click_menu_button(&mut self, button_id: i32, player: &Player) -> bool {
183        let Self { behavior, kind, .. } = self;
184        kind.click_menu_button(behavior, button_id, player)
185    }
186
187    /// Clears or counts crafting-grid items in the base inventory menu,
188    /// returning the number cleared or counted. Returns 0 for any other menu.
189    pub(crate) fn clear_or_count_crafting_items(
190        &mut self,
191        predicate: &dyn Fn(&ItemStack) -> bool,
192        amount_to_remove: i32,
193        counting_only: bool,
194    ) -> i32 {
195        let Some(kind) = self.kind.downcast_ref::<InventoryKind>() else {
196            return 0;
197        };
198        let crafting_id = kind.crafting_id();
199        let mut guard = self.behavior.lock_all_containers();
200        let Some(crafting) = guard.get_typed_mut::<CraftingContainer>(crafting_id) else {
201            return 0;
202        };
203
204        crafting.clear_or_count_matching_items(predicate, amount_to_remove, counting_only)
205    }
206
207    /// A shared handle to the base inventory menu's 2x2 crafting grid, or `None`
208    /// for any other menu.
209    #[cfg(test)]
210    pub(crate) fn crafting_container(&self) -> Option<Shared<CraftingContainer>> {
211        let kind = self.kind.downcast_ref::<InventoryKind>()?;
212        Some(kind.crafting_container())
213    }
214
215    /// Recomputes the base inventory menu's crafting result. A no-op for any
216    /// other menu.
217    pub(crate) fn update_crafting_result(&mut self) {
218        let Some(kind) = self.kind.downcast_mut::<InventoryKind>() else {
219            return;
220        };
221        let mut guard = self.behavior.lock_all_containers();
222        kind.update_result(&mut guard);
223    }
224
225    /// Recomputes recipe-driven slots after a change (delegates to the kind).
226    fn slots_changed(&mut self, guard: &mut ContainerLockGuard, player: &Player) {
227        let Self { behavior, kind, .. } = self;
228        kind.slots_changed(behavior, guard, player);
229    }
230
231    /// Runs the kind's `on_open` hook, after contents are built but before they
232    /// are sent to the client.
233    pub fn on_open(&mut self, player: &Player) {
234        let mut guard = self.behavior().lock_all_containers();
235        let Self { behavior, kind, .. } = self;
236        kind.on_open(behavior, &mut guard, player);
237    }
238
239    /// Runs the kind's `on_tick` hook. Called once per server tick while open.
240    pub fn on_tick(&mut self, player: &Player) {
241        let mut guard = self.behavior().lock_all_containers();
242        let Self { behavior, kind, .. } = self;
243        kind.on_tick(behavior, &mut guard, player);
244    }
245
246    /// Shift-click (quick move) for a slot: the kind's override if any, else the
247    /// declarative route table. Returns the item originally in the slot, or
248    /// empty if nothing moved.
249    fn quick_move_stack(
250        &mut self,
251        guard: &mut ContainerLockGuard,
252        slot_index: usize,
253        player: &Player,
254    ) -> ItemStack {
255        let Self {
256            behavior,
257            layout,
258            kind,
259            ..
260        } = self;
261        if let Some(result) = kind.quick_move(behavior, guard, slot_index, player) {
262            result
263        } else {
264            layout.quick_move(behavior, guard, slot_index, player)
265        }
266    }
267
268    /// Handles a click action in this menu. Packet clicks are validated via
269    /// [`Click::parse`]; invalid programmatically constructed clicks are ignored.
270    ///
271    /// TODO: Add `tryItemClickBehaviorOverride` for bundle item support.
272    pub fn clicked(&mut self, click: Click, player: &Player) {
273        if !click.is_valid_for(self.behavior().slot_count()) {
274            log::debug!(
275                "Ignoring programmatic container click that violates parsed-click invariants: \
276                 {click:?}"
277            );
278            return;
279        }
280
281        let armor_before = self.armor_items();
282        let has_infinite_materials = player.game_mode() == GameType::Creative;
283        if let Click::QuickCraft(action) = click {
284            let outcome = {
285                let mut guard = self.behavior().lock_all_containers();
286                let Self { behavior, kind, .. } = self;
287                kind.on_drag(behavior, &mut guard, action, player)
288            };
289            if outcome == ClickOutcome::Consume {
290                self.behavior_mut().reset_quick_craft();
291            } else {
292                let Self { behavior, kind, .. } = self;
293                behavior.do_quick_craft(action, has_infinite_materials, player, &|slot| {
294                    kind.can_drag_to(slot)
295                });
296            }
297        } else {
298            // Any non-quickcraft click resets an in-progress quickcraft.
299            if self.behavior().quickcraft().is_some() {
300                self.behavior_mut().reset_quick_craft();
301            }
302
303            // Menu-defined click hook. A consumed click skips default handling.
304            // The guard is dropped before the default arms re-lock the same containers.
305            let outcome = {
306                let mut guard = self.behavior().lock_all_containers();
307                let Self { behavior, kind, .. } = self;
308                kind.on_slot_clicked(behavior, &mut guard, click, player)
309            };
310
311            if outcome == ClickOutcome::Fallthrough {
312                match click {
313                    Click::Pickup { slot, button } => {
314                        self.behavior_mut().do_pickup(slot, button, player);
315                    }
316                    Click::DropCarried { button } => {
317                        self.behavior_mut().drop_carried(button, player);
318                    }
319                    Click::QuickMove { slot } => {
320                        self.do_quick_move(slot, player);
321                    }
322                    Click::Swap { slot, with } => {
323                        self.do_swap(slot, with, player);
324                    }
325                    Click::Clone { slot } => {
326                        self.behavior_mut().do_clone(slot, has_infinite_materials);
327                    }
328                    Click::Throw { slot, whole_stack } => {
329                        self.behavior_mut().do_throw(slot, whole_stack, player);
330                    }
331                    Click::PickupAll { slot, direction } => {
332                        self.do_pickup_all(slot, direction, player);
333                    }
334                    Click::QuickCraft(_) => unreachable!(),
335                }
336            }
337        }
338        // Recompute recipe-driven slots after the click. A QuickCraft drag has
339        // no slot on its end phase, so recompute on any non-empty menu.
340        let should_recompute = match click {
341            Click::DropCarried { .. } => false,
342            Click::QuickCraft(_) => !self.behavior().slots().is_empty(),
343            _ => true,
344        };
345        if should_recompute {
346            let mut guard = self.behavior().lock_all_containers();
347            self.slots_changed(&mut guard, player);
348        }
349        self.notify_armor_equipment_changes(player, armor_before);
350    }
351
352    /// Sets a slot on behalf of a direct player action, such as a creative mode
353    /// slot packet. Reports the resulting equipment changes like [`Self::clicked`].
354    pub(crate) fn set_slot_by_player(
355        &mut self,
356        slot_index: usize,
357        stack: ItemStack,
358        player: &Player,
359    ) {
360        let armor_before = self.armor_items();
361        {
362            let mut guard = self.behavior().lock_all_containers();
363            let Some(slot) = self.behavior().slots().get(slot_index) else {
364                return;
365            };
366            let previous = slot.get_item(&guard).clone();
367            slot.set_by_player(&mut guard, stack, &previous);
368        }
369        self.notify_armor_equipment_changes(player, armor_before);
370    }
371
372    fn armor_items(&self) -> Vec<(EquipmentSlot, ItemStack)> {
373        let guard = self.behavior().lock_all_containers();
374        self.behavior()
375            .slots()
376            .iter()
377            .filter_map(|slot| {
378                let armor_slot = slot.downcast_ref::<ArmorSlot>()?;
379                Some((
380                    armor_slot.equipment_slot(),
381                    armor_slot.get_item(&guard).clone(),
382                ))
383            })
384            .collect()
385    }
386
387    fn notify_armor_equipment_changes(
388        &self,
389        player: &Player,
390        armor_before: Vec<(EquipmentSlot, ItemStack)>,
391    ) {
392        for ((slot, previous), (_, current)) in armor_before.into_iter().zip(self.armor_items()) {
393            player.on_equip_item(slot, &previous, &current);
394        }
395    }
396
397    /// Handles quick move (shift-click).
398    fn do_quick_move(&mut self, slot_index: usize, player: &Player) {
399        let mut guard = self.behavior().lock_all_containers();
400
401        if !self.behavior().slots()[slot_index].may_pickup(&guard, player) {
402            return;
403        }
404
405        let initial_item = self.behavior().slots()[slot_index].get_item(&guard).clone();
406        if initial_item.is_empty() {
407            return;
408        }
409
410        // Loop while the slot still holds the same item type.
411        let mut result = self.quick_move_stack(&mut guard, slot_index, player);
412
413        while !result.is_empty() {
414            let current_item = self.behavior().slots()[slot_index].get_item(&guard).clone();
415            if !ItemStack::is_same_item(&current_item, &result) {
416                break;
417            }
418            result = self.quick_move_stack(&mut guard, slot_index, player);
419        }
420    }
421
422    /// Handles swap (number keys for a hotbar slot, or swap-hands for the
423    /// offhand).
424    fn do_swap(&mut self, slot_index: usize, with: SwapTarget, player: &Player) {
425        let player_inventory = ContainerRef::from(Arc::clone(&player.inventory));
426        let player_inv_id = ContainerId::from_arc(&player.inventory);
427        let mut guard = self.behavior().lock_all_containers_with(player_inventory);
428
429        let behavior = self.behavior();
430        let target_slot = &behavior.slots()[slot_index];
431        let inventory_slot = with.inventory_slot();
432
433        let target_item = target_slot.get_item(&guard).clone();
434        let Some(inventory) = guard.get(player_inv_id) else {
435            unreachable!("the explicitly locked player inventory must be present");
436        };
437        let source_item = inventory.get_item(inventory_slot).clone();
438
439        if source_item.is_empty() && target_item.is_empty() {
440            return;
441        }
442
443        if source_item.is_empty() {
444            // Move target -> inventory.
445            if target_slot.may_pickup(&guard, player) {
446                let Some(inventory) = guard.get_mut(player_inv_id) else {
447                    unreachable!("the explicitly locked player inventory must be present");
448                };
449                inventory.set_item(inventory_slot, target_item.clone());
450                target_slot.on_swap_craft(target_item.count);
451                target_slot.set_by_player(&mut guard, ItemStack::empty(), &target_item);
452                if let Some(remainder) = target_slot.on_take(&mut guard, &target_item, player) {
453                    player.add_item_or_drop_with_guard(
454                        &mut guard,
455                        remainder,
456                        Prediction::Predicted,
457                    );
458                }
459            }
460        } else if target_item.is_empty() {
461            // Move inventory -> target.
462            if target_slot.may_place(&source_item) {
463                let max_size = target_slot.get_max_stack_size_for_item(&guard, &source_item);
464                if source_item.count > max_size {
465                    let Some(inv) = guard.get_mut(player_inv_id) else {
466                        unreachable!("the explicitly locked player inventory must be present");
467                    };
468                    let to_place = inv.get_item_mut(inventory_slot).split(max_size);
469                    target_slot.set_by_player(&mut guard, to_place, &ItemStack::empty());
470                } else {
471                    let Some(inventory) = guard.get_mut(player_inv_id) else {
472                        unreachable!("the explicitly locked player inventory must be present");
473                    };
474                    inventory.set_item(inventory_slot, ItemStack::empty());
475                    target_slot.set_by_player(&mut guard, source_item, &ItemStack::empty());
476                }
477            }
478        } else {
479            // Swap target <-> inventory.
480            if target_slot.may_pickup(&guard, player) && target_slot.may_place(&source_item) {
481                let max_size = target_slot.get_max_stack_size_for_item(&guard, &source_item);
482                if source_item.count > max_size {
483                    // Source too big: place a partial stack, return target to inventory.
484                    let Some(inv) = guard.get_mut(player_inv_id) else {
485                        unreachable!("the explicitly locked player inventory must be present");
486                    };
487                    let to_place = inv.get_item_mut(inventory_slot).split(max_size);
488                    target_slot.set_by_player(&mut guard, to_place, &target_item);
489                    if let Some(remainder) = target_slot.on_take(&mut guard, &target_item, player) {
490                        player.add_item_or_drop_with_guard(
491                            &mut guard,
492                            remainder,
493                            Prediction::Predicted,
494                        );
495                    }
496                    let mut displaced = target_item;
497                    let Some(inventory) = guard.get_mut(player_inv_id) else {
498                        unreachable!("the explicitly locked player inventory must be present");
499                    };
500                    let added = inventory.add(&mut displaced);
501                    // Vanilla's Inventory::add consumes uninserted stacks in creative mode.
502                    if !added && !player.has_infinite_materials() {
503                        let _ = guard.run_unlocked(|| {
504                            player.drop_item(displaced, true, Prediction::Predicted)
505                        });
506                    }
507                } else {
508                    let Some(inventory) = guard.get_mut(player_inv_id) else {
509                        unreachable!("the explicitly locked player inventory must be present");
510                    };
511                    inventory.set_item(inventory_slot, target_item.clone());
512                    target_slot.set_by_player(&mut guard, source_item, &target_item);
513                    if let Some(remainder) = target_slot.on_take(&mut guard, &target_item, player) {
514                        player.add_item_or_drop_with_guard(
515                            &mut guard,
516                            remainder,
517                            Prediction::Predicted,
518                        );
519                    }
520                }
521            }
522        }
523    }
524
525    /// Handles pickup all (double-click): collects matching items from all slots
526    /// into the carried stack.
527    fn do_pickup_all(&mut self, slot_index: usize, direction: FillDirection, player: &Player) {
528        let mut guard = self.behavior().lock_all_containers();
529
530        let behavior = self.behavior();
531        let slot = &behavior.slots()[slot_index];
532        let slot_has_item = !slot.get_item(&guard).is_empty();
533        let slot_may_pickup = slot.may_pickup(&guard, player);
534
535        if behavior.carried().is_empty() || (slot_has_item && slot_may_pickup) {
536            return;
537        }
538
539        let max_stack = behavior.carried().max_stack_size();
540        let carried_item = behavior.carried().clone();
541        let slot_count = behavior.slots().len();
542
543        let (start, step): (i32, i32) = match direction {
544            FillDirection::Forward => (0, 1),
545            FillDirection::Backward => (slot_count as i32 - 1, -1),
546        };
547
548        // First pass collects non-full stacks, second pass the full ones.
549        for pass in 0..2 {
550            let mut i = start;
551            while i >= 0 && i < slot_count as i32 && self.behavior().carried().count < max_stack {
552                let target_slot = &self.behavior().slots()[i as usize];
553                let target_item = target_slot.get_item(&guard).clone();
554
555                if !target_item.is_empty()
556                    && can_item_quick_replace(&target_item, &carried_item, true)
557                    && target_slot.may_pickup(&guard, player)
558                    && self.can_take_item_for_pick_all(&carried_item, i as usize)
559                {
560                    // First pass skips full stacks, second pass includes them.
561                    if pass != 0 || target_item.count != target_item.max_stack_size() {
562                        let can_take = max_stack - self.behavior().carried().count;
563                        let to_take = target_item.count.min(can_take);
564                        let removed = target_slot.safe_take(&mut guard, to_take, can_take, player);
565                        self.behavior_mut()
566                            .carried_mut()
567                            .grow(removed.count.min(can_take));
568                    }
569                }
570
571                i += step;
572            }
573        }
574    }
575}
576
577#[cfg(test)]
578mod tests;