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