Skip to main content

steel_core/inventory/menu/
builder.rs

1//! A declarative builder for assembling [`MenuBehavior`]s.
2//!
3//! ```rust
4//! use steel_registry::{vanilla_items, vanilla_menu_types};
5//! use steel_core::{inventory::menu::kinds::BasicKind, player::player_inventory::PlayerInventory};
6//!
7//! use steel_core::inventory::prelude::*;
8//!
9//! fn example(container_id: u8, inventory: Shared<PlayerInventory>) -> Menu {
10//!     let mut builder = MenuBuilder::new(&vanilla_menu_types::GENERIC_9X1, container_id);
11//!
12//!     let items = vec![ItemStack::new(&vanilla_items::FLINT_AND_STEEL); 9];
13//!     let container = SimpleContainer::from_items(items).into_shared();
14//!
15//!     let section = builder.section_all(container);
16//!
17//!     let player = builder.player_inventory(&inventory);
18//!     let level_cost = builder.data_slot(0);
19//!
20//!     builder.route(section, player.all(), FillDirection::Backward);
21//!     builder.route(player.all(), section, FillDirection::Forward);
22//!
23//!     builder.build(BasicKind)
24//! }
25//! ```
26
27use std::array::IntoIter;
28use std::fmt;
29use std::iter;
30use std::range::Range;
31use std::slice;
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::{Arc, OnceLock};
34use std::vec;
35
36use steel_registry::{item_stack::ItemStack, menu_type::MenuTypeRef};
37use steel_utils::locks::Shared;
38
39use crate::inventory::menu::Menu;
40use crate::inventory::menu::behavior::MenuBehavior;
41use crate::inventory::menu::kind::MenuKind;
42use crate::inventory::menu::layout::MenuLayout;
43use crate::inventory::{
44    lock::{ContainerId, ContainerLockGuard, ContainerRef},
45    slots::{NormalSlot, RestrictedRules, RestrictedSlot, ResultHandler, ResultSlot, Slot},
46};
47use crate::player::Player;
48use crate::player::player_inventory::PlayerInventory;
49
50/// Identity of one built menu.
51///
52/// Given to every [`Section`] and [`DataSlot`] a [`MenuBuilder`] creates, so
53/// a handle can never act on a [`Menu`] it wasn't made for.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub(crate) struct MenuInstanceId(u64);
56
57impl MenuInstanceId {
58    /// Creates a new unique `MenuInstanceId`
59    fn next() -> Self {
60        static NEXT: AtomicU64 = AtomicU64::new(0);
61        Self(NEXT.fetch_add(1, Ordering::Relaxed))
62    }
63}
64
65/// A handle to a contiguous range of slots added to a [`MenuBuilder`].
66///
67/// Sections contain the id of the [`Menu`] they were made for and can only be
68/// created by a builder. Two Sections cannot cover the same range for the same
69/// [`Menu`].
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub struct Section {
72    menu: MenuInstanceId,
73    range: Range<usize>,
74}
75
76impl Section {
77    pub(crate) fn new(menu: MenuInstanceId, range: impl Into<Range<usize>>) -> Self {
78        Self {
79            menu,
80            range: range.into(),
81        }
82    }
83
84    /// The start of the section.
85    #[must_use]
86    pub const fn start(self) -> usize {
87        self.range.start
88    }
89
90    /// The end of the section.
91    #[must_use]
92    pub const fn end(self) -> usize {
93        self.range.end
94    }
95
96    /// The length of the section.
97    #[must_use]
98    pub const fn len(self) -> usize {
99        self.range.end - self.range.start
100    }
101
102    /// Whether the section is empty (start == end).
103    #[must_use]
104    pub const fn is_empty(self) -> bool {
105        self.range.start == self.range.end
106    }
107
108    /// Whether the section contains an index.
109    #[must_use]
110    pub const fn contains(self, slot_index: usize) -> bool {
111        slot_index >= self.range.start && slot_index < self.range.end
112    }
113
114    /// A copy of the internal range.
115    #[must_use]
116    pub const fn range(self) -> Range<usize> {
117        self.range
118    }
119}
120
121/// Converts different types into an Iterator of sections so they can be passed into `MenuBuilder::route`
122pub trait IntoSections {
123    /// The Iterator over the Section(s).
124    type Iter: Iterator<Item = Section>;
125
126    /// Converts self into the Iterator.
127    fn into_sections(self) -> Self::Iter;
128}
129
130impl IntoSections for Section {
131    type Iter = iter::Once<Section>;
132
133    fn into_sections(self) -> Self::Iter {
134        iter::once(self)
135    }
136}
137
138impl<const N: usize> IntoSections for [Section; N] {
139    type Iter = IntoIter<Section, N>;
140
141    fn into_sections(self) -> Self::Iter {
142        self.into_iter()
143    }
144}
145
146impl<'a> IntoSections for &'a [Section] {
147    type Iter = iter::Copied<slice::Iter<'a, Section>>;
148
149    fn into_sections(self) -> Self::Iter {
150        self.iter().copied()
151    }
152}
153
154impl IntoSections for Vec<Section> {
155    type Iter = vec::IntoIter<Section>;
156
157    fn into_sections(self) -> Self::Iter {
158        self.into_iter()
159    }
160}
161
162/// The sections that cover the player's inventory.
163///
164/// Exclusively produced by [`MenuBuilder::player_inventory`].
165#[derive(Clone, Copy, Debug)]
166pub struct PlayerInventorySections {
167    /// All 36 player slots (main and hotbar).
168    all: Section,
169    /// The 27 main inventory slots.
170    main: Section,
171    /// The 9 hotbar slots.
172    hotbar: Section,
173}
174
175impl PlayerInventorySections {
176    /// All 36 player slots (main and hotbar).
177    #[must_use]
178    pub const fn all(&self) -> Section {
179        self.all
180    }
181
182    /// The 27 main inventory slots.
183    #[must_use]
184    pub const fn main(&self) -> Section {
185        self.main
186    }
187
188    /// The 9 hotbar slots.
189    #[must_use]
190    pub const fn hotbar(&self) -> Section {
191        self.hotbar
192    }
193}
194
195/// A data slot handle created by the [`MenuBuilder::data_slot`], to use for easy access
196/// instead of a bare index.
197#[derive(Clone, Copy, Debug, PartialEq, Eq)]
198pub struct DataSlot {
199    menu: MenuInstanceId,
200    index: usize,
201}
202
203impl DataSlot {
204    /// Reads the current value of this data slot.
205    ///
206    /// # Panics
207    /// Panics if `behavior` belongs to a different menu than the
208    /// [`MenuBuilder`] that minted this handle.
209    #[must_use]
210    pub fn get(self, behavior: &MenuBehavior) -> i16 {
211        assert_eq!(
212            self.menu,
213            behavior.instance(),
214            "DataSlot used with a MenuBehavior it does not belong to"
215        );
216        behavior
217            .get_data(self.index)
218            .expect("DataSlot index is always valid for its own menu")
219    }
220
221    /// Writes a new value to this data slot.
222    ///
223    /// # Panics
224    /// Panics if `behavior` belongs to a different menu than the
225    /// [`MenuBuilder`] that minted this handle.
226    pub fn set(self, behavior: &mut MenuBehavior, value: i16) {
227        assert_eq!(
228            self.menu,
229            behavior.instance(),
230            "DataSlot used with a MenuBehavior it does not belong to"
231        );
232        behavior.set_data(self.index, value);
233    }
234
235    /// The raw data slot index.
236    #[must_use]
237    pub const fn index(self) -> usize {
238        self.index
239    }
240}
241
242/// The not-yet-carved slots of a container being split across multiple
243/// sections.
244///
245/// Created only by [`MenuBuilder::split`]. Every section created from this handle
246/// consumes the next `count` container slots.
247pub struct ContainerSlots {
248    /// The container being split.
249    container: ContainerRef,
250    /// The next container slot not yet covered by a section.
251    next: usize,
252    /// The container's size when [`MenuBuilder::split`] was called, used to
253    /// catch sections that take more slots than the container has.
254    size: usize,
255}
256
257impl fmt::Debug for ContainerSlots {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.debug_struct("ContainerSlots")
260            .field("next", &self.next)
261            .field("size", &self.size)
262            .finish_non_exhaustive()
263    }
264}
265
266/// A supplier for ranges of slots in a [`ContainerRef`]. Allowing either making
267/// the whole Container a [`Section`] or splitting one Container into multiple Sections.
268pub trait SectionSource {
269    /// Consumes the next `count` slots of the slot range.
270    fn take(self, count: usize) -> (ContainerRef, Range<usize>);
271}
272
273impl<T: Into<ContainerRef>> SectionSource for T {
274    fn take(self, count: usize) -> (ContainerRef, Range<usize>) {
275        (self.into(), (0..count).into())
276    }
277}
278
279impl SectionSource for &mut ContainerSlots {
280    /// # Panics
281    /// Panics if taking `count` slots overflows the actual size of the container.
282    fn take(self, count: usize) -> (ContainerRef, Range<usize>) {
283        let start = self.next;
284        assert!(
285            start + count <= self.size,
286            "section takes container slots {}..{}, but the container only has {} slots",
287            start,
288            start + count,
289            self.size
290        );
291        self.next = start + count;
292        (self.container.clone(), (start..start + count).into())
293    }
294}
295
296/// Produces the slot for one container index of a section.
297pub type SlotFactory = Arc<dyn Fn(&ContainerRef, usize) -> Box<dyn Slot> + Send + Sync>;
298
299/// How a section lowers container indices into menu slots.
300///
301/// The section methods pick the indices; the kind decides what each index
302/// becomes. Accepted by [`MenuBuilder::section_with`], [`MenuBuilder::section_at`],
303/// [`MenuBuilder::player_inventory_with`] and grid placements via
304/// [`PlacementBuilder::kind`](super::grid::PlacementBuilder::kind).
305#[derive(Clone)]
306#[non_exhaustive]
307pub enum SectionKind {
308    /// Plain storage slots.
309    Normal,
310    /// Placement gated by the rules; pickup gated too when they carry a pickup
311    /// predicate. Built by [`restricted`](Self::restricted),
312    /// [`guarded`](Self::guarded) and [`take_only`](Self::take_only).
313    Restricted(Arc<RestrictedRules>),
314    /// No placement, no pickup; clicks are rejected and surface in
315    /// `MenuKind::on_slot_clicked`.
316    Display,
317    /// Slots produced by a caller-supplied factory.
318    Custom(SlotFactory),
319}
320
321impl SectionKind {
322    /// Placement gated by `may_place`, which receives the container-local slot
323    /// index; pickup stays allowed.
324    pub fn restricted(
325        may_place: impl Fn(usize, &ItemStack) -> bool + Send + Sync + 'static,
326    ) -> Self {
327        Self::Restricted(RestrictedRules::place_only(may_place))
328    }
329
330    /// Like [`restricted`](Self::restricted), but pickup is also gated: items
331    /// only come out while `may_pickup` returns true.
332    pub fn guarded(
333        may_place: impl Fn(usize, &ItemStack) -> bool + Send + Sync + 'static,
334        may_pickup: impl Fn(usize, &ItemStack, &ContainerLockGuard, &Player) -> bool
335        + Send
336        + Sync
337        + 'static,
338    ) -> Self {
339        Self::Restricted(RestrictedRules::guarded(may_place, may_pickup))
340    }
341
342    /// Slots produced by `factory` from the section's container and each
343    /// covered container index.
344    pub fn custom(
345        factory: impl Fn(&ContainerRef, usize) -> Box<dyn Slot> + Send + Sync + 'static,
346    ) -> Self {
347        Self::Custom(Arc::new(factory))
348    }
349
350    /// Pickup allowed, placement always rejected — take-only output-style
351    /// slots.
352    #[must_use]
353    pub fn take_only() -> Self {
354        Self::Restricted(deny_place_rules())
355    }
356
357    pub(crate) fn make(&self, container: &ContainerRef, index: usize) -> Box<dyn Slot> {
358        match self {
359            Self::Normal => Box::new(NormalSlot::new(container.clone(), index)),
360            Self::Restricted(rules) => Box::new(RestrictedSlot::with_rules(
361                container.clone(),
362                index,
363                Arc::clone(rules),
364            )),
365            Self::Display => Box::new(RestrictedSlot::with_rules(
366                container.clone(),
367                index,
368                deny_all_rules(),
369            )),
370            Self::Custom(factory) => factory(container, index),
371        }
372    }
373}
374
375impl fmt::Debug for SectionKind {
376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377        f.write_str(match self {
378            Self::Normal => "SectionKind::Normal",
379            Self::Restricted(_) => "SectionKind::Restricted(..)",
380            Self::Display => "SectionKind::Display",
381            Self::Custom(_) => "SectionKind::Custom(..)",
382        })
383    }
384}
385
386impl From<&Self> for SectionKind {
387    fn from(kind: &Self) -> Self {
388        kind.clone()
389    }
390}
391
392/// Rules that reject placement and allow pickup, shared process-wide.
393fn deny_place_rules() -> Arc<RestrictedRules> {
394    static DENY: OnceLock<Arc<RestrictedRules>> = OnceLock::new();
395    DENY.get_or_init(|| RestrictedRules::place_only(|_, _| false))
396        .clone()
397}
398
399/// Rules that reject both placement and pickup, shared process-wide.
400fn deny_all_rules() -> Arc<RestrictedRules> {
401    static DENY: OnceLock<Arc<RestrictedRules>> = OnceLock::new();
402    DENY.get_or_init(|| RestrictedRules::guarded(|_, _| false, |_, _, _, _| false))
403        .clone()
404}
405
406/// The direction in which a slot range is walked when distributing items.
407///
408/// Vanilla fills backwards when moving into the player inventory so existing
409/// hotbar stacks top up first.
410#[derive(Clone, Copy, Debug, PartialEq, Eq)]
411pub enum FillDirection {
412    /// Walk from the first slot of the range to the last.
413    Forward,
414    /// Walk from the last slot of the range to the first.
415    Backward,
416}
417
418/// What to do with fake result output that cannot fit during a shift-click.
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
420pub enum FakeResultRemainderPolicy {
421    /// Drop the unresolved output into the world, as crafting menus do.
422    Drop,
423    /// Discard the unresolved output after the result handler runs, as anvils do.
424    Discard,
425}
426
427/// A shift clicking Route that goes from a single Range to a Vec of Ranges.
428pub(crate) struct Route {
429    pub(crate) from: Range<usize>,
430    pub(crate) targets: Vec<Range<usize>>,
431    pub(crate) direction: FillDirection,
432    pub(crate) fake_result_remainder: FakeResultRemainderPolicy,
433}
434
435/// Builds a Menu.
436///
437/// See the [module documentation](self) for an overview.
438pub struct MenuBuilder {
439    instance: MenuInstanceId,
440    menu_type: Option<MenuTypeRef>,
441    container_id: u8,
442    overrides_player_slots: bool,
443    slots: Vec<Box<dyn Slot>>,
444    container_refs: Vec<ContainerRef>,
445    data_slots: Vec<i16>,
446    routes: Vec<Route>,
447    drain_sections: Vec<Range<usize>>,
448    /// Container-local slot ranges already covered by a section, used to catch
449    /// two sections mapping onto the same container slots.
450    claimed: Vec<(ContainerId, Range<usize>)>,
451}
452
453impl fmt::Debug for MenuBuilder {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        f.debug_struct("MenuBuilder")
456            .field("instance", &self.instance)
457            .field("container_id", &self.container_id)
458            .field("slots", &self.slots.len())
459            .field("routes", &self.routes.len())
460            .finish_non_exhaustive()
461    }
462}
463
464impl MenuBuilder {
465    /// Creates a new builder for a menu of the given type and container id.
466    ///
467    /// Pass `None` for the player's own inventory menu, or a menu type
468    /// (`&vanilla_menu_types::ANVIL`, ...).
469    #[must_use]
470    pub fn new(menu_type: impl Into<Option<MenuTypeRef>>, container_id: u8) -> Self {
471        Self {
472            instance: MenuInstanceId::next(),
473            menu_type: menu_type.into(),
474            container_id,
475            overrides_player_slots: false,
476            slots: Vec::new(),
477            container_refs: Vec::new(),
478            data_slots: Vec::new(),
479            routes: Vec::new(),
480            drain_sections: Vec::new(),
481            claimed: Vec::new(),
482        }
483    }
484
485    /// Starts splitting a `Container` into multiple sections.
486    ///
487    /// Use this when you are locked to storing items in one `Container`
488    /// and need to split them into different [Section]s.
489    ///
490    /// # Example
491    /// ```rust
492    /// use steel_core::inventory::prelude::*;
493    /// use steel_core::inventory::menu::kinds::BasicKind;
494    ///
495    /// let mut b = MenuBuilder::new(None, 0);
496    ///
497    /// let mut stand = b.split(SimpleContainer::new(5).into_shared());
498    /// let bottles = b.section(&mut stand, 3); // slots 0..3
499    /// let ingredient = b.section(&mut stand, 1); // slot 3
500    /// let fuel = b.section(&mut stand, 1); // slot 4
501    ///
502    /// b.build(BasicKind);
503    /// ```
504    ///
505    /// # Panics
506    /// Panics if the sections carved from the returned handle take more slots
507    /// than the container has.
508    #[must_use]
509    #[expect(
510        clippy::unused_self,
511        reason = "split is intentionally builder-scoped as part of the menu DSL"
512    )]
513    pub fn split(&mut self, container: impl Into<ContainerRef>) -> ContainerSlots {
514        let container = container.into();
515        let size = Self::container_size(&container);
516        ContainerSlots {
517            container,
518            next: 0,
519            size,
520        }
521    }
522
523    /// The container's size, read under a short lock.
524    fn container_size(container: &ContainerRef) -> usize {
525        ContainerLockGuard::lock_all(slice::from_ref(container))
526            .get(container.container_id())
527            .expect("container was just locked")
528            .get_container_size()
529    }
530
531    /// Adds `count` plain slots backed by `source`.
532    ///
533    /// Pass a container directly to cover its slots `0..count`, or a
534    /// [`ContainerSlots`] handle from [`MenuBuilder::split`] to cover the next
535    /// `count` slots of a container shared between several sections.
536    ///
537    /// Returns a [`Section`] handle over the slots that were added.
538    ///
539    /// # Panics
540    /// Panics if the covered container slots overlap another section of this
541    /// menu.
542    pub fn section(&mut self, source: impl SectionSource, count: usize) -> Section {
543        self.section_with(source, count, SectionKind::Normal)
544    }
545
546    /// Adds `count` slots backed by `source`, lowered through `kind`.
547    ///
548    /// # Example
549    /// ```rust
550    /// use steel_registry::vanilla_items;
551    /// use steel_core::inventory::prelude::*;
552    /// use steel_core::inventory::menu::kinds::BasicKind;
553    ///
554    /// let mut b = MenuBuilder::new(None, 0);
555    ///
556    /// let container = SimpleContainer::new(9).into_shared();
557    /// let fuel = b.section_with(container, 9, SectionKind::restricted(|_slot, stack| {
558    ///     stack.is(&vanilla_items::COAL)
559    /// }));
560    ///
561    /// b.build(BasicKind);
562    /// ```
563    ///
564    /// # Panics
565    /// Panics if the covered container slots overlap another section of this
566    /// menu.
567    pub fn section_with(
568        &mut self,
569        source: impl SectionSource,
570        count: usize,
571        kind: impl Into<SectionKind>,
572    ) -> Section {
573        let kind = kind.into();
574        let (container, range) = source.take(count);
575        self.claim(&container, range);
576        let start = self.slots.len();
577        for index in range {
578            let slot = kind.make(&container, index);
579            self.push_section_slot(slot, &container, index);
580        }
581        self.section_from(start)
582    }
583
584    /// Adds a section covering every slot of `container`.
585    ///
586    /// Like [`section`](Self::section) with the container's full size as the
587    /// count, so the section can never drift from the container when it is
588    /// resized.
589    ///
590    /// # Panics
591    /// Panics if the covered container slots overlap another section of this
592    /// menu.
593    pub fn section_all(&mut self, container: impl Into<ContainerRef>) -> Section {
594        self.section_all_with(container, SectionKind::Normal)
595    }
596
597    /// Like [`section_all`](Self::section_all), but lowered through `kind`.
598    ///
599    /// # Panics
600    /// Panics if the covered container slots overlap another section of this
601    /// menu.
602    pub fn section_all_with(
603        &mut self,
604        container: impl Into<ContainerRef>,
605        kind: impl Into<SectionKind>,
606    ) -> Section {
607        let container = container.into();
608        let size = Self::container_size(&container);
609        self.section_with(container, size, kind)
610    }
611
612    /// Adds slots over explicit container indices, in the given order.
613    ///
614    /// The indices may be non-contiguous and in any order; each menu slot maps
615    /// to the next index of the iterator. Like [`section_with`](Self::section_with),
616    /// the covered indices are claimed against overlapping sections.
617    ///
618    /// # Panics
619    /// Panics if an index repeats or the covered container slots overlap
620    /// another section of this menu.
621    pub fn section_at(
622        &mut self,
623        container: impl Into<ContainerRef>,
624        indices: impl IntoIterator<Item = usize>,
625        kind: impl Into<SectionKind>,
626    ) -> Section {
627        let kind = kind.into();
628        let container = container.into();
629        let start = self.slots.len();
630        let mut run: Option<Range<usize>> = None;
631        for index in indices {
632            match &mut run {
633                Some(r) if index == r.end => r.end += 1,
634                _ => {
635                    if let Some(r) = run.take() {
636                        self.claim(&container, r);
637                    }
638                    run = Some((index..index + 1).into());
639                }
640            }
641            let slot = kind.make(&container, index);
642            self.push_section_slot(slot, &container, index);
643        }
644        if let Some(r) = run {
645            self.claim(&container, r);
646        }
647        self.section_from(start)
648    }
649
650    /// Adds the player's 36 inventory slots (main inventory then hotbar).
651    pub fn player_inventory(
652        &mut self,
653        inventory: &Shared<PlayerInventory>,
654    ) -> PlayerInventorySections {
655        self.player_inventory_with(inventory, SectionKind::Normal)
656    }
657
658    /// Like [`player_inventory`](Self::player_inventory), but lowers the slots
659    /// through `kind`, e.g. [`SectionKind::Display`] for a read-only view of
660    /// another player's inventory.
661    ///
662    /// Player inventory sections never claim their container slots: menus like
663    /// invsee legitimately map the same inventory into two sections, and
664    /// quick-move skips aliased slots at runtime.
665    pub fn player_inventory_with(
666        &mut self,
667        inventory: &Shared<PlayerInventory>,
668        kind: impl Into<SectionKind>,
669    ) -> PlayerInventorySections {
670        let kind = kind.into();
671        let container = ContainerRef::from(inventory.clone());
672        let start = self.slots.len();
673        for index in PlayerInventory::MAIN.chain(PlayerInventory::HOTBAR) {
674            let slot = kind.make(&container, index);
675            self.push_section_slot(slot, &container, index);
676        }
677
678        let main = Section::new(self.instance, start..start + PlayerInventory::MAIN.len());
679        let hotbar = Section::new(
680            self.instance,
681            start + PlayerInventory::MAIN.len()..self.slots.len(),
682        );
683        let all = Section::new(self.instance, start..self.slots.len());
684        PlayerInventorySections { all, main, hotbar }
685    }
686
687    /// Adds a single fake result slot driven by `handler`, backed by the
688    /// handler's [`result_container`](ResultHandler::result_container).
689    ///
690    /// See [`crate::inventory::container::ResultContainer`] and [`crate::inventory::slots::ResultHandler`].
691    ///
692    /// # Panics
693    /// Panics if the result container has no slot `0`, or that slot is already
694    /// covered by another section of this menu.
695    pub fn result_slot(&mut self, handler: impl ResultHandler + 'static) -> Section {
696        let slot = ResultSlot::new(handler);
697        let container = slot.result_container().clone();
698        self.claim(&container, (0..1).into());
699        let start = self.slots.len();
700        self.push_section_slot(Box::new(slot), &container, 0);
701        self.section_from(start)
702    }
703
704    /// Adds raw slots without claiming their container coverage, so tests can
705    /// model aliased slots (two menu slots over one container index) the way
706    /// [`player_inventory_with`](Self::player_inventory_with) can produce them.
707    ///
708    /// All slots produced by the iterator have the same concrete type. Use
709    /// [`custom_boxed_section`](Self::custom_boxed_section) for a heterogeneous
710    /// or already-erased collection.
711    ///
712    /// Production menus go through [`section_at`](Self::section_at) or a
713    /// [`SectionKind::custom`] factory instead, which keep overlap validation.
714    #[cfg(test)]
715    pub(crate) fn custom_section<S>(&mut self, slots: impl IntoIterator<Item = S>) -> Section
716    where
717        S: Slot + 'static,
718    {
719        self.custom_boxed_section(
720            slots
721                .into_iter()
722                .map(|slot| Box::new(slot) as Box<dyn Slot>),
723        )
724    }
725
726    /// Adds heterogeneous or already-erased slots.
727    #[cfg(test)]
728    pub(crate) fn custom_boxed_section(
729        &mut self,
730        slots: impl IntoIterator<Item = Box<dyn Slot>>,
731    ) -> Section {
732        let start = self.slots.len();
733        for slot in slots {
734            self.push_boxed_slot(slot);
735        }
736        self.section_from(start)
737    }
738
739    /// Adds a data slot with an initial value and returns a typed handle to it.
740    pub fn data_slot(&mut self, initial: i16) -> DataSlot {
741        let index = self.data_slots.len();
742        self.data_slots.push(initial);
743        DataSlot {
744            menu: self.instance,
745            index,
746        }
747    }
748
749    /// Declares a shift-click route from each section of `from` into
750    /// `targets`.
751    ///
752    /// Both arguments accept anything [`IntoSections`]: pass a single
753    /// [`Section`] directly and use an array/slice/Vec only when there is
754    /// genuinely more than one, so brackets signal arity. A multi-section
755    /// `from` declares one route per source section.
756    ///
757    /// Most commonly:
758    /// `player_inventory` -> `container` is [`FillDirection::Forward`]
759    /// `container` -> `player_inventory` is [`FillDirection::Backward`]
760    ///
761    /// # Panics
762    /// Panics if a section belongs to another builder, a source overlaps an
763    /// existing route, or a target overlaps its source.
764    pub fn route(
765        &mut self,
766        from: impl IntoSections,
767        targets: impl IntoSections,
768        direction: FillDirection,
769    ) -> &mut Self {
770        self.route_with_remainder_policy(from, targets, direction, FakeResultRemainderPolicy::Drop)
771    }
772
773    /// Declares a shift-click route with an explicit fake-result remainder
774    /// policy. The policy has no effect on ordinary source slots.
775    ///
776    /// # Panics
777    /// Panics if a section belongs to another builder, a source overlaps an
778    /// existing route, or a target overlaps its source.
779    pub fn route_with_remainder_policy(
780        &mut self,
781        from: impl IntoSections,
782        targets: impl IntoSections,
783        direction: FillDirection,
784        fake_result_remainder: FakeResultRemainderPolicy,
785    ) -> &mut Self {
786        let targets: Vec<Range<usize>> = targets.into_sections().map(|s| self.owned(s)).collect();
787        for from in from.into_sections() {
788            let from = self.owned(from);
789            assert!(
790                !self
791                    .routes
792                    .iter()
793                    .any(|route| route.from.start < from.end && from.start < route.from.end),
794                "shift-click route source {from:?} overlaps an existing route source",
795            );
796            assert!(
797                !targets
798                    .iter()
799                    .any(|t| t.start < from.end && from.start < t.end),
800                "shift-click route target {targets:?} overlaps its own source {from:?}",
801            );
802            self.routes.push(Route {
803                from,
804                targets: targets.clone(),
805                direction,
806                fake_result_remainder,
807            });
808        }
809        self
810    }
811
812    /// Marks `sections` to be emptied back into the player or dropped on the floor on close.
813    ///
814    /// Accepts anything [`IntoSections`]: pass a single [`Section`] directly
815    /// and use an array only for genuinely multiple sections.
816    ///
817    /// # Panics
818    /// Panics if any section was created by a different [`MenuBuilder`].
819    ///
820    /// # Example
821    /// ```rust
822    /// use std::sync::Arc;
823    ///
824    /// use steel_registry::{item_stack::ItemStack, vanilla_items};
825    /// use steel_utils::locks::SyncMutex;
826    ///
827    /// use steel_core::inventory::prelude::*;
828    /// use steel_core::inventory::menu::kinds::BasicKind;
829    /// use steel_core::inventory::container::SimpleContainer;
830    ///
831    /// let container_id = 0;
832    ///
833    /// let mut b = MenuBuilder::new(None, container_id);
834    ///
835    /// let items = vec![ItemStack::empty(); 9];
836    /// let upper_container = SimpleContainer::from_items(items).into_shared();
837    ///
838    /// let items = vec![ItemStack::new(&vanilla_items::BARRIER); 9];
839    /// let lower_container = SimpleContainer::from_items(items).into_shared();
840    ///
841    /// let display = b.section_with(lower_container, 9, SectionKind::Display);
842    ///
843    /// let section = b.section(upper_container, 9);
844    /// b.drain(section); // only 'section' gets drained when the menu is closed
845    /// b.build(BasicKind);
846    /// ```
847    pub fn drain(&mut self, sections: impl IntoSections) -> &mut Self {
848        let ranges: Vec<_> = sections.into_sections().map(|s| self.owned(s)).collect();
849        assert!(
850            ranges
851                .iter()
852                .flat_map(|range| *range)
853                .all(|slot| !self.slots[slot].is_fake()),
854            "drain sections cannot contain fake or result slots"
855        );
856        self.drain_sections.extend(ranges);
857        self
858    }
859
860    /// Declares that this menu paints over the client's standard 36 player slots.
861    ///
862    /// Pending logical inventory updates are deferred while the
863    /// menu is open and the slots are restored when it closes.
864    pub const fn override_player_slots(&mut self) -> &mut Self {
865        self.overrides_player_slots = true;
866        self
867    }
868
869    /// Consumes the builder, creating the finished [`Menu`].
870    ///
871    /// # Panics
872    /// Panics if the number of slots does not match the client layout declared
873    /// by the menu type, or if a fake slot aliases another physical slot.
874    #[must_use]
875    pub fn build(self, kind: impl MenuKind + 'static) -> Menu {
876        self.build_boxed(Box::new(kind))
877    }
878
879    /// Consumes the builder using menu behavior selected at runtime.
880    ///
881    /// This is the erased counterpart to [`Self::build`] for plugin factories
882    /// and other callers that already own a boxed menu kind.
883    ///
884    /// # Panics
885    /// Panics if the number of slots does not match the client layout declared
886    /// by the menu type, or if a fake slot aliases another physical slot.
887    #[must_use]
888    pub fn build_boxed(self, kind: Box<dyn MenuKind>) -> Menu {
889        if let Some(menu_type) = self.menu_type {
890            assert_eq!(
891                self.slots.len(),
892                menu_type.slot_count,
893                "menu type {} expects {} slots, but the builder has {}",
894                menu_type.key,
895                menu_type.slot_count,
896                self.slots.len(),
897            );
898        }
899        Self::assert_no_fake_slot_aliases(&self.slots);
900
901        let mut behavior = MenuBehavior::new(
902            self.instance,
903            self.slots,
904            self.container_id,
905            self.menu_type,
906            self.container_refs,
907        );
908        for initial in self.data_slots {
909            behavior.add_data_slot(initial);
910        }
911
912        let layout = MenuLayout {
913            routes: self.routes,
914            drain_sections: self.drain_sections,
915        };
916        Menu::from_parts(behavior, layout, kind, self.overrides_player_slots)
917    }
918
919    /// Fake slots have special removal and persistence semantics, so no other
920    /// menu slot may expose their physical backing storage.
921    fn assert_no_fake_slot_aliases(slots: &[Box<dyn Slot>]) {
922        use rustc_hash::FxHashMap;
923
924        let mut physical_slots: FxHashMap<(ContainerId, usize), (usize, bool)> =
925            FxHashMap::default();
926        for (slot_index, slot) in slots.iter().enumerate() {
927            let Some(key) = slot.storage().physical_key() else {
928                continue;
929            };
930            let is_fake = slot.is_fake();
931            if let Some(&(other_index, other_is_fake)) = physical_slots.get(&key) {
932                assert!(
933                    !is_fake && !other_is_fake,
934                    "menu slots {other_index} and {slot_index} alias physical container slot \
935                     {key:?}, but fake slots require exclusive backing storage"
936                );
937            } else {
938                physical_slots.insert(key, (slot_index, is_fake));
939            }
940        }
941    }
942
943    /// The identity of the menu being built.
944    pub(crate) const fn instance(&self) -> MenuInstanceId {
945        self.instance
946    }
947
948    /// The number of menu slots added so far.
949    #[must_use]
950    pub const fn slot_count(&self) -> usize {
951        self.slots.len()
952    }
953
954    /// Appends a single already-erased slot without creating a section.
955    pub(crate) fn push_boxed_slot(&mut self, slot: Box<dyn Slot>) {
956        for container in slot.storage().container_refs() {
957            self.register_container(container.clone());
958        }
959        self.slots.push(slot);
960    }
961
962    /// Appends a slot whose physical backing must match its declarative source.
963    pub(crate) fn push_section_slot(
964        &mut self,
965        slot: Box<dyn Slot>,
966        source: &ContainerRef,
967        source_index: usize,
968    ) {
969        assert_eq!(
970            slot.storage().physical_key(),
971            Some((source.container_id(), source_index)),
972            "section slot backing must match its declared source container and index"
973        );
974        self.push_boxed_slot(slot);
975    }
976
977    /// Records that a section covers the container-local `range` of `container`.
978    ///
979    /// # Panics
980    /// Panics if the range exceeds the container or was already covered by another range.
981    pub(crate) fn claim(&mut self, container: &ContainerRef, range: Range<usize>) {
982        let id = container.container_id();
983        let size = {
984            let guard = ContainerLockGuard::lock_all(slice::from_ref(container));
985            let Some(container) = guard.get(id) else {
986                panic!("container was not locked while validating a menu section");
987            };
988            container.get_container_size()
989        };
990        assert!(
991            range.end <= size,
992            "section takes container slots {}..{}, but the container only has {size} slots",
993            range.start,
994            range.end,
995        );
996        for (other_id, other) in &self.claimed {
997            assert!(
998                *other_id != id || range.start >= other.end || other.start >= range.end,
999                "two sections cover overlapping slots ({other:?} and {range:?}) of the same \
1000                 container; carve shared containers with MenuBuilder::split"
1001            );
1002        }
1003        self.claimed.push((id, range));
1004    }
1005
1006    /// Records a container to lock.
1007    pub(crate) fn register_container(&mut self, container: impl Into<ContainerRef>) {
1008        let container_ref = container.into();
1009        let id = container_ref.container_id();
1010        if !self.container_refs.iter().any(|c| c.container_id() == id) {
1011            self.container_refs.push(container_ref);
1012        }
1013    }
1014
1015    /// Verifies that `section` was created by this builder.
1016    fn owned(&self, section: Section) -> Range<usize> {
1017        assert_eq!(
1018            section.menu, self.instance,
1019            "Section was minted by a different MenuBuilder"
1020        );
1021        section.range()
1022    }
1023
1024    /// Returns a section spanning `start..self.slots.len()`.
1025    fn section_from(&self, start: usize) -> Section {
1026        Section::new(self.instance, start..self.slots.len())
1027    }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use steel_registry::{init_vanilla_registry, vanilla_items, vanilla_menu_types};
1033    use steel_utils::{Downcast as _, locks::IntoShared};
1034
1035    use super::*;
1036    use crate::inventory::container::SimpleContainer;
1037    use crate::inventory::menu::kinds::BasicKind;
1038
1039    struct NoopResultHandler(ContainerRef);
1040
1041    impl ResultHandler for NoopResultHandler {
1042        fn result_container(&self) -> ContainerRef {
1043            self.0.clone()
1044        }
1045
1046        fn dependencies(&self) -> Vec<ContainerRef> {
1047            Vec::new()
1048        }
1049
1050        fn update_result(&self, _guard: &mut ContainerLockGuard) {}
1051
1052        fn on_result_taken(
1053            &self,
1054            _guard: &mut ContainerLockGuard,
1055            _player: &Player,
1056        ) -> Option<ItemStack> {
1057            None
1058        }
1059
1060        fn is_result_valid(&self, _guard: &ContainerLockGuard, _player: &Player) -> bool {
1061            true
1062        }
1063    }
1064
1065    struct DependencyResultHandler {
1066        result: ContainerRef,
1067        dependency: ContainerRef,
1068    }
1069
1070    impl ResultHandler for DependencyResultHandler {
1071        fn result_container(&self) -> ContainerRef {
1072            self.result.clone()
1073        }
1074
1075        fn dependencies(&self) -> Vec<ContainerRef> {
1076            vec![self.dependency.clone()]
1077        }
1078
1079        fn update_result(&self, _guard: &mut ContainerLockGuard) {}
1080
1081        fn on_result_taken(
1082            &self,
1083            _guard: &mut ContainerLockGuard,
1084            _player: &Player,
1085        ) -> Option<ItemStack> {
1086            None
1087        }
1088
1089        fn is_result_valid(&self, _guard: &ContainerLockGuard, _player: &Player) -> bool {
1090            true
1091        }
1092    }
1093
1094    #[test]
1095    #[should_panic(
1096        expected = "menu type minecraft:generic_9x6 expects 90 slots, but the builder has 0"
1097    )]
1098    fn build_rejects_a_slot_count_that_disagrees_with_the_menu_type() {
1099        let _ = MenuBuilder::new(&vanilla_menu_types::GENERIC_9X6, 1).build(BasicKind);
1100    }
1101
1102    #[test]
1103    fn builds_with_an_erased_menu_kind() {
1104        let kind: Box<dyn MenuKind> = Box::new(BasicKind);
1105        let menu = MenuBuilder::new(None, 0).build_boxed(kind);
1106
1107        assert!(menu.kind().downcast_ref::<BasicKind>().is_some());
1108    }
1109
1110    #[test]
1111    #[should_panic(
1112        expected = "section takes container slots 0..2, but the container only has 1 slots"
1113    )]
1114    fn direct_section_rejects_a_range_past_container_capacity() {
1115        let mut builder = MenuBuilder::new(None, 0);
1116        builder.section(SimpleContainer::new(1).into_shared(), 2);
1117    }
1118
1119    #[test]
1120    #[should_panic(
1121        expected = "section takes container slots 0..1, but the container only has 0 slots"
1122    )]
1123    fn result_slot_rejects_a_container_without_slot_zero() {
1124        let container = ContainerRef::from(SimpleContainer::new(0).into_shared());
1125        let mut builder = MenuBuilder::new(None, 0);
1126
1127        let _ = builder.result_slot(NoopResultHandler(container));
1128    }
1129
1130    #[test]
1131    #[should_panic(expected = "two sections cover overlapping slots")]
1132    fn result_slot_claims_slot_zero_against_normal_sections() {
1133        let container = ContainerRef::from(SimpleContainer::new(1).into_shared());
1134        let mut builder = MenuBuilder::new(None, 0);
1135        let _ = builder.result_slot(NoopResultHandler(container.clone()));
1136
1137        let _ = builder.section_all(container);
1138    }
1139
1140    #[test]
1141    fn result_slot_registers_handler_dependencies() {
1142        let result = ContainerRef::from(SimpleContainer::new(1).into_shared());
1143        let dependency = ContainerRef::from(SimpleContainer::new(1).into_shared());
1144        let dependency_id = dependency.container_id();
1145        let mut builder = MenuBuilder::new(None, 0);
1146        let _ = builder.result_slot(DependencyResultHandler { result, dependency });
1147
1148        let menu = builder.build(BasicKind);
1149        let guard = menu.behavior().lock_all_containers();
1150
1151        assert!(guard.contains(dependency_id));
1152    }
1153
1154    #[test]
1155    #[should_panic(expected = "drain sections cannot contain fake or result slots")]
1156    fn drain_rejects_a_result_slot() {
1157        let result = ContainerRef::from(SimpleContainer::new(1).into_shared());
1158        let mut builder = MenuBuilder::new(None, 0);
1159        let section = builder.result_slot(NoopResultHandler(result));
1160
1161        builder.drain(section);
1162    }
1163
1164    #[test]
1165    #[should_panic(expected = "fake slots require exclusive backing storage")]
1166    fn build_rejects_a_result_alias_through_player_inventory() {
1167        let inventory = PlayerInventory::new().into_shared();
1168        let mut builder = MenuBuilder::new(None, 0);
1169        let _ = builder.result_slot(NoopResultHandler(ContainerRef::from(inventory.clone())));
1170        let _ = builder.player_inventory_with(&inventory, SectionKind::Normal);
1171
1172        let _ = builder.build(BasicKind);
1173    }
1174
1175    #[test]
1176    fn build_allows_non_fake_player_inventory_aliases() {
1177        let inventory = PlayerInventory::new().into_shared();
1178        let mut builder = MenuBuilder::new(None, 0);
1179        let _ = builder.player_inventory(&inventory);
1180        let _ = builder.player_inventory_with(&inventory, SectionKind::Display);
1181
1182        let _ = builder.build(BasicKind);
1183    }
1184
1185    #[test]
1186    #[should_panic(expected = "shift-click route source 0..27 overlaps an existing route source")]
1187    fn route_rejects_overlapping_source_sections() {
1188        let inventory = PlayerInventory::new().into_shared();
1189        let mut builder = MenuBuilder::new(None, 0);
1190        let player = builder.player_inventory(&inventory);
1191        let target = builder.section(SimpleContainer::new(1).into_shared(), 1);
1192
1193        builder.route(player.all(), [target], FillDirection::Forward);
1194        builder.route(player.main(), [target], FillDirection::Forward);
1195    }
1196
1197    #[test]
1198    fn section_at_preserves_the_given_index_order() {
1199        let container = ContainerRef::from(SimpleContainer::new(5).into_shared());
1200        let mut b = MenuBuilder::new(None, 0);
1201        let section = b.section_at(container, [4, 3, 0, 1], SectionKind::Normal);
1202        let menu = b.build(BasicKind);
1203
1204        assert_eq!((section.start(), section.end()), (0, 4));
1205        let container_slots: Vec<usize> = menu
1206            .behavior()
1207            .slots()
1208            .iter()
1209            .map(|slot| slot.get_container_slot())
1210            .collect();
1211        assert_eq!(container_slots, vec![4, 3, 0, 1]);
1212    }
1213
1214    #[test]
1215    #[should_panic(expected = "two sections cover overlapping slots")]
1216    fn section_at_rejects_indices_claimed_by_another_section() {
1217        let container = SimpleContainer::new(4).into_shared();
1218        let mut b = MenuBuilder::new(None, 0);
1219        let _ = b.section(container.clone(), 2);
1220        let _ = b.section_at(container, [1], SectionKind::Normal);
1221    }
1222
1223    #[test]
1224    #[should_panic(expected = "two sections cover overlapping slots")]
1225    fn section_at_rejects_a_repeated_index() {
1226        let container = ContainerRef::from(SimpleContainer::new(4).into_shared());
1227        let mut b = MenuBuilder::new(None, 0);
1228        let _ = b.section_at(container, [0, 2, 0], SectionKind::Normal);
1229    }
1230
1231    #[test]
1232    fn display_kind_rejects_placement() {
1233        init_vanilla_registry();
1234        let container = ContainerRef::from(SimpleContainer::new(1).into_shared());
1235        let mut b = MenuBuilder::new(None, 0);
1236        let _ = b.section_at(container, [0], SectionKind::Display);
1237        let menu = b.build(BasicKind);
1238
1239        let stack = ItemStack::new(&vanilla_items::STONE);
1240        assert!(!menu.behavior().slots()[0].may_place(&stack));
1241    }
1242
1243    #[test]
1244    fn custom_kind_lowers_through_the_factory() {
1245        let container = ContainerRef::from(SimpleContainer::new(3).into_shared());
1246        let factory = SectionKind::custom(|container, index| {
1247            Box::new(NormalSlot::new(container.clone(), index))
1248        });
1249        let mut b = MenuBuilder::new(None, 0);
1250        let _ = b.section_at(container, [2, 0], factory);
1251        let menu = b.build(BasicKind);
1252
1253        let container_slots: Vec<usize> = menu
1254            .behavior()
1255            .slots()
1256            .iter()
1257            .map(|slot| slot.get_container_slot())
1258            .collect();
1259        assert_eq!(container_slots, vec![2, 0]);
1260    }
1261
1262    #[test]
1263    #[should_panic(expected = "section slot backing must match its declared source")]
1264    fn custom_kind_rejects_a_mismatched_physical_backing() {
1265        let source = ContainerRef::from(SimpleContainer::new(1).into_shared());
1266        let other = ContainerRef::from(SimpleContainer::new(1).into_shared());
1267        let kind =
1268            SectionKind::custom(move |_container, _index| Box::new(NormalSlot::new(&other, 0)));
1269        let mut builder = MenuBuilder::new(None, 0);
1270
1271        let _ = builder.section_with(source, 1, kind);
1272    }
1273}