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    Arc::clone(DENY.get_or_init(|| RestrictedRules::place_only(|_, _| false)))
396}
397
398/// Rules that reject both placement and pickup, shared process-wide.
399fn deny_all_rules() -> Arc<RestrictedRules> {
400    static DENY: OnceLock<Arc<RestrictedRules>> = OnceLock::new();
401    Arc::clone(DENY.get_or_init(|| RestrictedRules::guarded(|_, _| false, |_, _, _, _| false)))
402}
403
404/// The direction in which a slot range is walked when distributing items.
405///
406/// Vanilla fills backwards when moving into the player inventory so existing
407/// hotbar stacks top up first.
408#[derive(Clone, Copy, Debug, PartialEq, Eq)]
409pub enum FillDirection {
410    /// Walk from the first slot of the range to the last.
411    Forward,
412    /// Walk from the last slot of the range to the first.
413    Backward,
414}
415
416/// What to do with fake result output that cannot fit during a shift-click.
417#[derive(Clone, Copy, Debug, PartialEq, Eq)]
418pub enum FakeResultRemainderPolicy {
419    /// Drop the unresolved output into the world, as crafting menus do.
420    Drop,
421    /// Discard the unresolved output after the result handler runs, as anvils do.
422    Discard,
423}
424
425/// A shift clicking Route that goes from a single Range to a Vec of Ranges.
426pub(crate) struct Route {
427    pub(crate) from: Range<usize>,
428    pub(crate) targets: Vec<Range<usize>>,
429    pub(crate) direction: FillDirection,
430    pub(crate) fake_result_remainder: FakeResultRemainderPolicy,
431}
432
433/// Builds a Menu.
434///
435/// See the [module documentation](self) for an overview.
436pub struct MenuBuilder {
437    instance: MenuInstanceId,
438    menu_type: Option<MenuTypeRef>,
439    container_id: u8,
440    overrides_player_slots: bool,
441    slots: Vec<Box<dyn Slot>>,
442    container_refs: Vec<ContainerRef>,
443    data_slots: Vec<i16>,
444    routes: Vec<Route>,
445    drain_sections: Vec<Range<usize>>,
446    /// Container-local slot ranges already covered by a section, used to catch
447    /// two sections mapping onto the same container slots.
448    claimed: Vec<(ContainerId, Range<usize>)>,
449}
450
451impl fmt::Debug for MenuBuilder {
452    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
453        f.debug_struct("MenuBuilder")
454            .field("instance", &self.instance)
455            .field("container_id", &self.container_id)
456            .field("slots", &self.slots.len())
457            .field("routes", &self.routes.len())
458            .finish_non_exhaustive()
459    }
460}
461
462impl MenuBuilder {
463    /// Creates a new builder for a menu of the given type and container id.
464    ///
465    /// Pass `None` for the player's own inventory menu, or a menu type
466    /// (`&vanilla_menu_types::ANVIL`, ...).
467    #[must_use]
468    pub fn new(menu_type: impl Into<Option<MenuTypeRef>>, container_id: u8) -> Self {
469        Self {
470            instance: MenuInstanceId::next(),
471            menu_type: menu_type.into(),
472            container_id,
473            overrides_player_slots: false,
474            slots: Vec::new(),
475            container_refs: Vec::new(),
476            data_slots: Vec::new(),
477            routes: Vec::new(),
478            drain_sections: Vec::new(),
479            claimed: Vec::new(),
480        }
481    }
482
483    /// Starts splitting a `Container` into multiple sections.
484    ///
485    /// Use this when you are locked to storing items in one `Container`
486    /// and need to split them into different [Section]s.
487    ///
488    /// # Example
489    /// ```rust
490    /// use steel_core::inventory::prelude::*;
491    /// use steel_core::inventory::menu::kinds::BasicKind;
492    ///
493    /// let mut b = MenuBuilder::new(None, 0);
494    ///
495    /// let mut stand = b.split(SimpleContainer::new(5).into_shared());
496    /// let bottles = b.section(&mut stand, 3); // slots 0..3
497    /// let ingredient = b.section(&mut stand, 1); // slot 3
498    /// let fuel = b.section(&mut stand, 1); // slot 4
499    ///
500    /// b.build(BasicKind);
501    /// ```
502    ///
503    /// # Panics
504    /// Panics if the sections carved from the returned handle take more slots
505    /// than the container has.
506    #[must_use]
507    #[expect(
508        clippy::unused_self,
509        reason = "split is intentionally builder-scoped as part of the menu DSL"
510    )]
511    pub fn split(&mut self, container: impl Into<ContainerRef>) -> ContainerSlots {
512        let container = container.into();
513        let size = Self::container_size(&container);
514        ContainerSlots {
515            container,
516            next: 0,
517            size,
518        }
519    }
520
521    /// The container's size, read under a short lock.
522    fn container_size(container: &ContainerRef) -> usize {
523        ContainerLockGuard::lock_all(slice::from_ref(container))
524            .get(container.container_id())
525            .expect("container was just locked")
526            .get_container_size()
527    }
528
529    /// Adds `count` plain slots backed by `source`.
530    ///
531    /// Pass a container directly to cover its slots `0..count`, or a
532    /// [`ContainerSlots`] handle from [`MenuBuilder::split`] to cover the next
533    /// `count` slots of a container shared between several sections.
534    ///
535    /// Returns a [`Section`] handle over the slots that were added.
536    ///
537    /// # Panics
538    /// Panics if the covered container slots overlap another section of this
539    /// menu.
540    pub fn section(&mut self, source: impl SectionSource, count: usize) -> Section {
541        self.section_with(source, count, SectionKind::Normal)
542    }
543
544    /// Adds `count` slots backed by `source`, lowered through `kind`.
545    ///
546    /// # Example
547    /// ```rust
548    /// use steel_registry::vanilla_items;
549    /// use steel_core::inventory::prelude::*;
550    /// use steel_core::inventory::menu::kinds::BasicKind;
551    ///
552    /// let mut b = MenuBuilder::new(None, 0);
553    ///
554    /// let container = SimpleContainer::new(9).into_shared();
555    /// let fuel = b.section_with(container, 9, SectionKind::restricted(|_slot, stack| {
556    ///     stack.is(&vanilla_items::COAL)
557    /// }));
558    ///
559    /// b.build(BasicKind);
560    /// ```
561    ///
562    /// # Panics
563    /// Panics if the covered container slots overlap another section of this
564    /// menu.
565    pub fn section_with(
566        &mut self,
567        source: impl SectionSource,
568        count: usize,
569        kind: impl Into<SectionKind>,
570    ) -> Section {
571        let kind = kind.into();
572        let (container, range) = source.take(count);
573        self.claim(&container, range);
574        let start = self.slots.len();
575        for index in range {
576            let slot = kind.make(&container, index);
577            self.push_section_slot(slot, &container, index);
578        }
579        self.section_from(start)
580    }
581
582    /// Adds a section covering every slot of `container`.
583    ///
584    /// Like [`section`](Self::section) with the container's full size as the
585    /// count, so the section can never drift from the container when it is
586    /// resized.
587    ///
588    /// # Panics
589    /// Panics if the covered container slots overlap another section of this
590    /// menu.
591    pub fn section_all(&mut self, container: impl Into<ContainerRef>) -> Section {
592        self.section_all_with(container, SectionKind::Normal)
593    }
594
595    /// Like [`section_all`](Self::section_all), but lowered through `kind`.
596    ///
597    /// # Panics
598    /// Panics if the covered container slots overlap another section of this
599    /// menu.
600    pub fn section_all_with(
601        &mut self,
602        container: impl Into<ContainerRef>,
603        kind: impl Into<SectionKind>,
604    ) -> Section {
605        let container = container.into();
606        let size = Self::container_size(&container);
607        self.section_with(container, size, kind)
608    }
609
610    /// Adds slots over explicit container indices, in the given order.
611    ///
612    /// The indices may be non-contiguous and in any order; each menu slot maps
613    /// to the next index of the iterator. Like [`section_with`](Self::section_with),
614    /// the covered indices are claimed against overlapping sections.
615    ///
616    /// # Panics
617    /// Panics if an index repeats or the covered container slots overlap
618    /// another section of this menu.
619    pub fn section_at(
620        &mut self,
621        container: impl Into<ContainerRef>,
622        indices: impl IntoIterator<Item = usize>,
623        kind: impl Into<SectionKind>,
624    ) -> Section {
625        let kind = kind.into();
626        let container = container.into();
627        let start = self.slots.len();
628        let mut run: Option<Range<usize>> = None;
629        for index in indices {
630            match &mut run {
631                Some(r) if index == r.end => r.end += 1,
632                _ => {
633                    if let Some(r) = run.take() {
634                        self.claim(&container, r);
635                    }
636                    run = Some((index..index + 1).into());
637                }
638            }
639            let slot = kind.make(&container, index);
640            self.push_section_slot(slot, &container, index);
641        }
642        if let Some(r) = run {
643            self.claim(&container, r);
644        }
645        self.section_from(start)
646    }
647
648    /// Adds the player's 36 inventory slots (main inventory then hotbar).
649    pub fn player_inventory(
650        &mut self,
651        inventory: &Shared<PlayerInventory>,
652    ) -> PlayerInventorySections {
653        self.player_inventory_with(inventory, SectionKind::Normal)
654    }
655
656    /// Like [`player_inventory`](Self::player_inventory), but lowers the slots
657    /// through `kind`, e.g. [`SectionKind::Display`] for a read-only view of
658    /// another player's inventory.
659    ///
660    /// Player inventory sections never claim their container slots: menus like
661    /// invsee legitimately map the same inventory into two sections, and
662    /// quick-move skips aliased slots at runtime.
663    pub fn player_inventory_with(
664        &mut self,
665        inventory: &Shared<PlayerInventory>,
666        kind: impl Into<SectionKind>,
667    ) -> PlayerInventorySections {
668        let kind = kind.into();
669        let container = ContainerRef::from(Arc::clone(inventory));
670        let start = self.slots.len();
671        for index in PlayerInventory::MAIN.chain(PlayerInventory::HOTBAR) {
672            let slot = kind.make(&container, index);
673            self.push_section_slot(slot, &container, index);
674        }
675
676        let main = Section::new(self.instance, start..start + PlayerInventory::MAIN.len());
677        let hotbar = Section::new(
678            self.instance,
679            start + PlayerInventory::MAIN.len()..self.slots.len(),
680        );
681        let all = Section::new(self.instance, start..self.slots.len());
682        PlayerInventorySections { all, main, hotbar }
683    }
684
685    /// Adds a single fake result slot driven by `handler`, backed by the
686    /// handler's [`result_container`](ResultHandler::result_container).
687    ///
688    /// Taken items do not award `ITEM_CRAFTED`; use
689    /// [`crafting_result_slot`](Self::crafting_result_slot) for crafting grids.
690    ///
691    /// See [`crate::inventory::container::ResultContainer`] and [`crate::inventory::slots::ResultHandler`].
692    ///
693    /// # Panics
694    /// Panics if the result container has no slot `0`, or that slot is already
695    /// covered by another section of this menu.
696    pub fn result_slot(&mut self, handler: impl ResultHandler + 'static) -> Section {
697        self.add_result_slot(ResultSlot::new(handler))
698    }
699
700    /// Adds a crafting result slot whose taken items award `ITEM_CRAFTED`.
701    ///
702    /// # Panics
703    /// Panics if the result container has no slot `0`, or that slot is already
704    /// covered by another section of this menu.
705    pub fn crafting_result_slot(&mut self, handler: impl ResultHandler + 'static) -> Section {
706        self.add_result_slot(ResultSlot::crafting(handler))
707    }
708
709    fn add_result_slot(&mut self, slot: ResultSlot) -> Section {
710        let container = slot.result_container().clone();
711        self.claim(&container, (0..1).into());
712        let start = self.slots.len();
713        self.push_section_slot(Box::new(slot), &container, 0);
714        self.section_from(start)
715    }
716
717    /// Adds raw slots without claiming their container coverage, so tests can
718    /// model aliased slots (two menu slots over one container index) the way
719    /// [`player_inventory_with`](Self::player_inventory_with) can produce them.
720    ///
721    /// All slots produced by the iterator have the same concrete type. Use
722    /// [`custom_boxed_section`](Self::custom_boxed_section) for a heterogeneous
723    /// or already-erased collection.
724    ///
725    /// Production menus go through [`section_at`](Self::section_at) or a
726    /// [`SectionKind::custom`] factory instead, which keep overlap validation.
727    #[cfg(test)]
728    pub(crate) fn custom_section<S>(&mut self, slots: impl IntoIterator<Item = S>) -> Section
729    where
730        S: Slot + 'static,
731    {
732        self.custom_boxed_section(
733            slots
734                .into_iter()
735                .map(|slot| Box::new(slot) as Box<dyn Slot>),
736        )
737    }
738
739    /// Adds heterogeneous or already-erased slots.
740    #[cfg(test)]
741    pub(crate) fn custom_boxed_section(
742        &mut self,
743        slots: impl IntoIterator<Item = Box<dyn Slot>>,
744    ) -> Section {
745        let start = self.slots.len();
746        for slot in slots {
747            self.push_boxed_slot(slot);
748        }
749        self.section_from(start)
750    }
751
752    /// Adds a data slot with an initial value and returns a typed handle to it.
753    pub fn data_slot(&mut self, initial: i16) -> DataSlot {
754        let index = self.data_slots.len();
755        self.data_slots.push(initial);
756        DataSlot {
757            menu: self.instance,
758            index,
759        }
760    }
761
762    /// Declares a shift-click route from each section of `from` into
763    /// `targets`.
764    ///
765    /// Both arguments accept anything [`IntoSections`]: pass a single
766    /// [`Section`] directly and use an array/slice/Vec only when there is
767    /// genuinely more than one, so brackets signal arity. A multi-section
768    /// `from` declares one route per source section.
769    ///
770    /// Most commonly:
771    /// `player_inventory` -> `container` is [`FillDirection::Forward`]
772    /// `container` -> `player_inventory` is [`FillDirection::Backward`]
773    ///
774    /// # Panics
775    /// Panics if a section belongs to another builder, a source overlaps an
776    /// existing route, or a target overlaps its source.
777    pub fn route(
778        &mut self,
779        from: impl IntoSections,
780        targets: impl IntoSections,
781        direction: FillDirection,
782    ) -> &mut Self {
783        self.route_with_remainder_policy(from, targets, direction, FakeResultRemainderPolicy::Drop)
784    }
785
786    /// Declares a shift-click route with an explicit fake-result remainder
787    /// policy. The policy has no effect on ordinary source slots.
788    ///
789    /// # Panics
790    /// Panics if a section belongs to another builder, a source overlaps an
791    /// existing route, or a target overlaps its source.
792    pub fn route_with_remainder_policy(
793        &mut self,
794        from: impl IntoSections,
795        targets: impl IntoSections,
796        direction: FillDirection,
797        fake_result_remainder: FakeResultRemainderPolicy,
798    ) -> &mut Self {
799        let targets: Vec<Range<usize>> = targets.into_sections().map(|s| self.owned(s)).collect();
800        for from in from.into_sections() {
801            let from = self.owned(from);
802            assert!(
803                !self
804                    .routes
805                    .iter()
806                    .any(|route| route.from.start < from.end && from.start < route.from.end),
807                "shift-click route source {from:?} overlaps an existing route source",
808            );
809            assert!(
810                !targets
811                    .iter()
812                    .any(|t| t.start < from.end && from.start < t.end),
813                "shift-click route target {targets:?} overlaps its own source {from:?}",
814            );
815            self.routes.push(Route {
816                from,
817                targets: targets.clone(),
818                direction,
819                fake_result_remainder,
820            });
821        }
822        self
823    }
824
825    /// Marks `sections` to be emptied back into the player or dropped on the floor on close.
826    ///
827    /// Accepts anything [`IntoSections`]: pass a single [`Section`] directly
828    /// and use an array only for genuinely multiple sections.
829    ///
830    /// # Panics
831    /// Panics if any section was created by a different [`MenuBuilder`].
832    ///
833    /// # Example
834    /// ```rust
835    /// use std::sync::Arc;
836    ///
837    /// use steel_registry::{item_stack::ItemStack, vanilla_items};
838    /// use steel_utils::locks::SyncMutex;
839    ///
840    /// use steel_core::inventory::prelude::*;
841    /// use steel_core::inventory::menu::kinds::BasicKind;
842    /// use steel_core::inventory::container::SimpleContainer;
843    ///
844    /// let container_id = 0;
845    ///
846    /// let mut b = MenuBuilder::new(None, container_id);
847    ///
848    /// let items = vec![ItemStack::empty(); 9];
849    /// let upper_container = SimpleContainer::from_items(items).into_shared();
850    ///
851    /// let items = vec![ItemStack::new(&vanilla_items::BARRIER); 9];
852    /// let lower_container = SimpleContainer::from_items(items).into_shared();
853    ///
854    /// let display = b.section_with(lower_container, 9, SectionKind::Display);
855    ///
856    /// let section = b.section(upper_container, 9);
857    /// b.drain(section); // only 'section' gets drained when the menu is closed
858    /// b.build(BasicKind);
859    /// ```
860    pub fn drain(&mut self, sections: impl IntoSections) -> &mut Self {
861        let ranges: Vec<_> = sections.into_sections().map(|s| self.owned(s)).collect();
862        assert!(
863            ranges
864                .iter()
865                .flat_map(|range| *range)
866                .all(|slot| !self.slots[slot].is_fake()),
867            "drain sections cannot contain fake or result slots"
868        );
869        self.drain_sections.extend(ranges);
870        self
871    }
872
873    /// Declares that this menu paints over the client's standard 36 player slots.
874    ///
875    /// Pending logical inventory updates are deferred while the
876    /// menu is open and the slots are restored when it closes.
877    pub const fn override_player_slots(&mut self) -> &mut Self {
878        self.overrides_player_slots = true;
879        self
880    }
881
882    /// Consumes the builder, creating the finished [`Menu`].
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(self, kind: impl MenuKind + 'static) -> Menu {
889        self.build_boxed(Box::new(kind))
890    }
891
892    /// Consumes the builder using menu behavior selected at runtime.
893    ///
894    /// This is the erased counterpart to [`Self::build`] for plugin factories
895    /// and other callers that already own a boxed menu kind.
896    ///
897    /// # Panics
898    /// Panics if the number of slots does not match the client layout declared
899    /// by the menu type, or if a fake slot aliases another physical slot.
900    #[must_use]
901    pub fn build_boxed(self, kind: Box<dyn MenuKind>) -> Menu {
902        if let Some(menu_type) = self.menu_type {
903            assert_eq!(
904                self.slots.len(),
905                menu_type.slot_count,
906                "menu type {} expects {} slots, but the builder has {}",
907                menu_type.key,
908                menu_type.slot_count,
909                self.slots.len(),
910            );
911        }
912        Self::assert_no_fake_slot_aliases(&self.slots);
913
914        let mut behavior = MenuBehavior::new(
915            self.instance,
916            self.slots,
917            self.container_id,
918            self.menu_type,
919            self.container_refs,
920        );
921        for initial in self.data_slots {
922            behavior.add_data_slot(initial);
923        }
924
925        let layout = MenuLayout {
926            routes: self.routes,
927            drain_sections: self.drain_sections,
928        };
929        Menu::from_parts(behavior, layout, kind, self.overrides_player_slots)
930    }
931
932    /// Fake slots have special removal and persistence semantics, so no other
933    /// menu slot may expose their physical backing storage.
934    fn assert_no_fake_slot_aliases(slots: &[Box<dyn Slot>]) {
935        use rustc_hash::FxHashMap;
936
937        let mut physical_slots: FxHashMap<(ContainerId, usize), (usize, bool)> =
938            FxHashMap::default();
939        for (slot_index, slot) in slots.iter().enumerate() {
940            let Some(key) = slot.storage().physical_key() else {
941                continue;
942            };
943            let is_fake = slot.is_fake();
944            if let Some(&(other_index, other_is_fake)) = physical_slots.get(&key) {
945                assert!(
946                    !is_fake && !other_is_fake,
947                    "menu slots {other_index} and {slot_index} alias physical container slot \
948                     {key:?}, but fake slots require exclusive backing storage"
949                );
950            } else {
951                physical_slots.insert(key, (slot_index, is_fake));
952            }
953        }
954    }
955
956    /// The identity of the menu being built.
957    pub(crate) const fn instance(&self) -> MenuInstanceId {
958        self.instance
959    }
960
961    /// The number of menu slots added so far.
962    #[must_use]
963    pub const fn slot_count(&self) -> usize {
964        self.slots.len()
965    }
966
967    /// Appends a single already-erased slot without creating a section.
968    pub(crate) fn push_boxed_slot(&mut self, slot: Box<dyn Slot>) {
969        for container in slot.storage().container_refs() {
970            self.register_container(container.clone());
971        }
972        self.slots.push(slot);
973    }
974
975    /// Appends a slot whose physical backing must match its declarative source.
976    pub(crate) fn push_section_slot(
977        &mut self,
978        slot: Box<dyn Slot>,
979        source: &ContainerRef,
980        source_index: usize,
981    ) {
982        assert_eq!(
983            slot.storage().physical_key(),
984            Some((source.container_id(), source_index)),
985            "section slot backing must match its declared source container and index"
986        );
987        self.push_boxed_slot(slot);
988    }
989
990    /// Records that a section covers the container-local `range` of `container`.
991    ///
992    /// # Panics
993    /// Panics if the range exceeds the container or was already covered by another range.
994    pub(crate) fn claim(&mut self, container: &ContainerRef, range: Range<usize>) {
995        let id = container.container_id();
996        let size = {
997            let guard = ContainerLockGuard::lock_all(slice::from_ref(container));
998            let Some(container) = guard.get(id) else {
999                panic!("container was not locked while validating a menu section");
1000            };
1001            container.get_container_size()
1002        };
1003        assert!(
1004            range.end <= size,
1005            "section takes container slots {}..{}, but the container only has {size} slots",
1006            range.start,
1007            range.end,
1008        );
1009        for (other_id, other) in &self.claimed {
1010            assert!(
1011                *other_id != id || range.start >= other.end || other.start >= range.end,
1012                "two sections cover overlapping slots ({other:?} and {range:?}) of the same \
1013                 container; carve shared containers with MenuBuilder::split"
1014            );
1015        }
1016        self.claimed.push((id, range));
1017    }
1018
1019    /// Records a container to lock.
1020    pub(crate) fn register_container(&mut self, container: impl Into<ContainerRef>) {
1021        let container_ref = container.into();
1022        let id = container_ref.container_id();
1023        if !self.container_refs.iter().any(|c| c.container_id() == id) {
1024            self.container_refs.push(container_ref);
1025        }
1026    }
1027
1028    /// Verifies that `section` was created by this builder.
1029    fn owned(&self, section: Section) -> Range<usize> {
1030        assert_eq!(
1031            section.menu, self.instance,
1032            "Section was minted by a different MenuBuilder"
1033        );
1034        section.range()
1035    }
1036
1037    /// Returns a section spanning `start..self.slots.len()`.
1038    fn section_from(&self, start: usize) -> Section {
1039        Section::new(self.instance, start..self.slots.len())
1040    }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use steel_registry::{init_vanilla_registry, vanilla_items, vanilla_menu_types};
1046    use steel_utils::{Downcast as _, locks::IntoShared};
1047
1048    use super::*;
1049    use crate::inventory::container::SimpleContainer;
1050    use crate::inventory::menu::kinds::BasicKind;
1051
1052    struct NoopResultHandler(ContainerRef);
1053
1054    impl ResultHandler for NoopResultHandler {
1055        fn result_container(&self) -> ContainerRef {
1056            self.0.clone()
1057        }
1058
1059        fn dependencies(&self) -> Vec<ContainerRef> {
1060            Vec::new()
1061        }
1062
1063        fn update_result(&self, _guard: &mut ContainerLockGuard) {}
1064
1065        fn on_result_taken(
1066            &self,
1067            _guard: &mut ContainerLockGuard,
1068            _player: &Player,
1069        ) -> Option<ItemStack> {
1070            None
1071        }
1072
1073        fn is_result_valid(&self, _guard: &ContainerLockGuard, _player: &Player) -> bool {
1074            true
1075        }
1076    }
1077
1078    struct DependencyResultHandler {
1079        result: ContainerRef,
1080        dependency: ContainerRef,
1081    }
1082
1083    impl ResultHandler for DependencyResultHandler {
1084        fn result_container(&self) -> ContainerRef {
1085            self.result.clone()
1086        }
1087
1088        fn dependencies(&self) -> Vec<ContainerRef> {
1089            vec![self.dependency.clone()]
1090        }
1091
1092        fn update_result(&self, _guard: &mut ContainerLockGuard) {}
1093
1094        fn on_result_taken(
1095            &self,
1096            _guard: &mut ContainerLockGuard,
1097            _player: &Player,
1098        ) -> Option<ItemStack> {
1099            None
1100        }
1101
1102        fn is_result_valid(&self, _guard: &ContainerLockGuard, _player: &Player) -> bool {
1103            true
1104        }
1105    }
1106
1107    #[test]
1108    #[should_panic(
1109        expected = "menu type minecraft:generic_9x6 expects 90 slots, but the builder has 0"
1110    )]
1111    fn build_rejects_a_slot_count_that_disagrees_with_the_menu_type() {
1112        let _ = MenuBuilder::new(&vanilla_menu_types::GENERIC_9X6, 1).build(BasicKind);
1113    }
1114
1115    #[test]
1116    fn builds_with_an_erased_menu_kind() {
1117        let kind: Box<dyn MenuKind> = Box::new(BasicKind);
1118        let menu = MenuBuilder::new(None, 0).build_boxed(kind);
1119
1120        assert!(menu.kind().downcast_ref::<BasicKind>().is_some());
1121    }
1122
1123    #[test]
1124    #[should_panic(
1125        expected = "section takes container slots 0..2, but the container only has 1 slots"
1126    )]
1127    fn direct_section_rejects_a_range_past_container_capacity() {
1128        let mut builder = MenuBuilder::new(None, 0);
1129        builder.section(SimpleContainer::new(1).into_shared(), 2);
1130    }
1131
1132    #[test]
1133    #[should_panic(
1134        expected = "section takes container slots 0..1, but the container only has 0 slots"
1135    )]
1136    fn result_slot_rejects_a_container_without_slot_zero() {
1137        let container = ContainerRef::from(SimpleContainer::new(0).into_shared());
1138        let mut builder = MenuBuilder::new(None, 0);
1139
1140        let _ = builder.result_slot(NoopResultHandler(container));
1141    }
1142
1143    #[test]
1144    #[should_panic(expected = "two sections cover overlapping slots")]
1145    fn result_slot_claims_slot_zero_against_normal_sections() {
1146        let container = ContainerRef::from(SimpleContainer::new(1).into_shared());
1147        let mut builder = MenuBuilder::new(None, 0);
1148        let _ = builder.result_slot(NoopResultHandler(container.clone()));
1149
1150        let _ = builder.section_all(container);
1151    }
1152
1153    #[test]
1154    fn result_slot_registers_handler_dependencies() {
1155        let result = ContainerRef::from(SimpleContainer::new(1).into_shared());
1156        let dependency = ContainerRef::from(SimpleContainer::new(1).into_shared());
1157        let dependency_id = dependency.container_id();
1158        let mut builder = MenuBuilder::new(None, 0);
1159        let _ = builder.result_slot(DependencyResultHandler { result, dependency });
1160
1161        let menu = builder.build(BasicKind);
1162        let guard = menu.behavior().lock_all_containers();
1163
1164        assert!(guard.contains(dependency_id));
1165    }
1166
1167    #[test]
1168    #[should_panic(expected = "drain sections cannot contain fake or result slots")]
1169    fn drain_rejects_a_result_slot() {
1170        let result = ContainerRef::from(SimpleContainer::new(1).into_shared());
1171        let mut builder = MenuBuilder::new(None, 0);
1172        let section = builder.result_slot(NoopResultHandler(result));
1173
1174        builder.drain(section);
1175    }
1176
1177    #[test]
1178    #[should_panic(expected = "fake slots require exclusive backing storage")]
1179    fn build_rejects_a_result_alias_through_player_inventory() {
1180        let inventory = PlayerInventory::new().into_shared();
1181        let mut builder = MenuBuilder::new(None, 0);
1182        let _ = builder.result_slot(NoopResultHandler(ContainerRef::from(Arc::clone(
1183            &inventory,
1184        ))));
1185        let _ = builder.player_inventory_with(&inventory, SectionKind::Normal);
1186
1187        let _ = builder.build(BasicKind);
1188    }
1189
1190    #[test]
1191    fn build_allows_non_fake_player_inventory_aliases() {
1192        let inventory = PlayerInventory::new().into_shared();
1193        let mut builder = MenuBuilder::new(None, 0);
1194        let _ = builder.player_inventory(&inventory);
1195        let _ = builder.player_inventory_with(&inventory, SectionKind::Display);
1196
1197        let _ = builder.build(BasicKind);
1198    }
1199
1200    #[test]
1201    #[should_panic(expected = "shift-click route source 0..27 overlaps an existing route source")]
1202    fn route_rejects_overlapping_source_sections() {
1203        let inventory = PlayerInventory::new().into_shared();
1204        let mut builder = MenuBuilder::new(None, 0);
1205        let player = builder.player_inventory(&inventory);
1206        let target = builder.section(SimpleContainer::new(1).into_shared(), 1);
1207
1208        builder.route(player.all(), [target], FillDirection::Forward);
1209        builder.route(player.main(), [target], FillDirection::Forward);
1210    }
1211
1212    #[test]
1213    fn section_at_preserves_the_given_index_order() {
1214        let container = ContainerRef::from(SimpleContainer::new(5).into_shared());
1215        let mut b = MenuBuilder::new(None, 0);
1216        let section = b.section_at(container, [4, 3, 0, 1], SectionKind::Normal);
1217        let menu = b.build(BasicKind);
1218
1219        assert_eq!((section.start(), section.end()), (0, 4));
1220        let container_slots: Vec<usize> = menu
1221            .behavior()
1222            .slots()
1223            .iter()
1224            .map(|slot| slot.get_container_slot())
1225            .collect();
1226        assert_eq!(container_slots, vec![4, 3, 0, 1]);
1227    }
1228
1229    #[test]
1230    #[should_panic(expected = "two sections cover overlapping slots")]
1231    fn section_at_rejects_indices_claimed_by_another_section() {
1232        let container = SimpleContainer::new(4).into_shared();
1233        let mut b = MenuBuilder::new(None, 0);
1234        let _ = b.section(Arc::clone(&container), 2);
1235        let _ = b.section_at(container, [1], SectionKind::Normal);
1236    }
1237
1238    #[test]
1239    #[should_panic(expected = "two sections cover overlapping slots")]
1240    fn section_at_rejects_a_repeated_index() {
1241        let container = ContainerRef::from(SimpleContainer::new(4).into_shared());
1242        let mut b = MenuBuilder::new(None, 0);
1243        let _ = b.section_at(container, [0, 2, 0], SectionKind::Normal);
1244    }
1245
1246    #[test]
1247    fn display_kind_rejects_placement() {
1248        init_vanilla_registry();
1249        let container = ContainerRef::from(SimpleContainer::new(1).into_shared());
1250        let mut b = MenuBuilder::new(None, 0);
1251        let _ = b.section_at(container, [0], SectionKind::Display);
1252        let menu = b.build(BasicKind);
1253
1254        let stack = ItemStack::new(&vanilla_items::STONE);
1255        assert!(!menu.behavior().slots()[0].may_place(&stack));
1256    }
1257
1258    #[test]
1259    fn custom_kind_lowers_through_the_factory() {
1260        let container = ContainerRef::from(SimpleContainer::new(3).into_shared());
1261        let factory = SectionKind::custom(|container, index| {
1262            Box::new(NormalSlot::new(container.clone(), index))
1263        });
1264        let mut b = MenuBuilder::new(None, 0);
1265        let _ = b.section_at(container, [2, 0], factory);
1266        let menu = b.build(BasicKind);
1267
1268        let container_slots: Vec<usize> = menu
1269            .behavior()
1270            .slots()
1271            .iter()
1272            .map(|slot| slot.get_container_slot())
1273            .collect();
1274        assert_eq!(container_slots, vec![2, 0]);
1275    }
1276
1277    #[test]
1278    #[should_panic(expected = "section slot backing must match its declared source")]
1279    fn custom_kind_rejects_a_mismatched_physical_backing() {
1280        let source = ContainerRef::from(SimpleContainer::new(1).into_shared());
1281        let other = ContainerRef::from(SimpleContainer::new(1).into_shared());
1282        let kind =
1283            SectionKind::custom(move |_container, _index| Box::new(NormalSlot::new(&other, 0)));
1284        let mut builder = MenuBuilder::new(None, 0);
1285
1286        let _ = builder.section_with(source, 1, kind);
1287    }
1288}