Skip to main content

basalt_api/container/
mod.rs

1//! Container types shared across the Basalt crate ecosystem.
2//!
3//! The main type is [`Container`], a reusable template value describing
4//! how to open a container window.  Use [`ContainerBuilder`] (or
5//! [`Container::builder()`]) for fluent construction.
6
7use crate::components::BlockPosition;
8use basalt_types::Slot;
9
10/// Type of inventory window in Minecraft 1.21.4.
11///
12/// Each variant maps to a specific Minecraft protocol ID and has a
13/// known slot count. Used when opening custom containers for players.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum InventoryType {
16    /// 9x1 chest-like inventory (9 slots).
17    Generic9x1,
18    /// 9x2 chest-like inventory (18 slots).
19    Generic9x2,
20    /// 9x3 chest-like inventory (27 slots) -- single chest.
21    Generic9x3,
22    /// 9x4 chest-like inventory (36 slots).
23    Generic9x4,
24    /// 9x5 chest-like inventory (45 slots).
25    Generic9x5,
26    /// 9x6 chest-like inventory (54 slots) -- double chest.
27    Generic9x6,
28    /// 3x3 dispenser/dropper (9 slots).
29    Generic3x3,
30    /// 3x3 crafter (9 slots).
31    Crafter3x3,
32    /// Anvil (3 slots).
33    Anvil,
34    /// Beacon (1 slot).
35    Beacon,
36    /// Blast furnace (3 slots).
37    BlastFurnace,
38    /// Brewing stand (5 slots).
39    BrewingStand,
40    /// Crafting table (10 slots: 1 output + 3x3 grid).
41    Crafting,
42    /// Enchantment table (2 slots).
43    Enchantment,
44    /// Furnace (3 slots).
45    Furnace,
46    /// Grindstone (3 slots).
47    Grindstone,
48    /// Hopper (5 slots).
49    Hopper,
50    /// Lectern (1 slot).
51    Lectern,
52    /// Loom (4 slots).
53    Loom,
54    /// Merchant/villager trade (3 slots).
55    Merchant,
56    /// Shulker box (27 slots).
57    ShulkerBox,
58    /// Smithing table (4 slots).
59    Smithing,
60    /// Smoker (3 slots).
61    Smoker,
62    /// Cartography table (3 slots).
63    Cartography,
64    /// Stonecutter (2 slots).
65    Stonecutter,
66}
67
68impl InventoryType {
69    /// Returns the Minecraft protocol VarInt ID for this inventory type.
70    ///
71    /// Used when encoding the `OpenWindow` packet. IDs correspond to the
72    /// 1.21.4 protocol (0 = generic\_9x1 through 24 = stonecutter).
73    pub fn protocol_id(&self) -> i32 {
74        match self {
75            Self::Generic9x1 => 0,
76            Self::Generic9x2 => 1,
77            Self::Generic9x3 => 2,
78            Self::Generic9x4 => 3,
79            Self::Generic9x5 => 4,
80            Self::Generic9x6 => 5,
81            Self::Generic3x3 => 6,
82            Self::Crafter3x3 => 7,
83            Self::Anvil => 8,
84            Self::Beacon => 9,
85            Self::BlastFurnace => 10,
86            Self::BrewingStand => 11,
87            Self::Crafting => 12,
88            Self::Enchantment => 13,
89            Self::Furnace => 14,
90            Self::Grindstone => 15,
91            Self::Hopper => 16,
92            Self::Lectern => 17,
93            Self::Loom => 18,
94            Self::Merchant => 19,
95            Self::ShulkerBox => 20,
96            Self::Smithing => 21,
97            Self::Smoker => 22,
98            Self::Cartography => 23,
99            Self::Stonecutter => 24,
100        }
101    }
102
103    /// Returns the number of container slots this type has.
104    ///
105    /// Does not include the player inventory slots that are appended
106    /// when the window is opened.
107    pub fn slot_count(&self) -> usize {
108        match self {
109            Self::Generic9x1 => 9,
110            Self::Generic9x2 => 18,
111            Self::Generic9x3 => 27,
112            Self::Generic9x4 => 36,
113            Self::Generic9x5 => 45,
114            Self::Generic9x6 => 54,
115            Self::Generic3x3 | Self::Crafter3x3 => 9,
116            Self::Anvil => 3,
117            Self::Beacon => 1,
118            Self::BlastFurnace => 3,
119            Self::BrewingStand => 5,
120            Self::Crafting => 10,
121            Self::Enchantment => 2,
122            Self::Furnace => 3,
123            Self::Grindstone => 3,
124            Self::Hopper => 5,
125            Self::Lectern => 1,
126            Self::Loom => 4,
127            Self::Merchant => 3,
128            Self::ShulkerBox => 27,
129            Self::Smithing => 4,
130            Self::Smoker => 3,
131            Self::Cartography => 3,
132            Self::Stonecutter => 2,
133        }
134    }
135
136    /// Returns true if this is a generic chest-like inventory (9xN) or a shulker box.
137    ///
138    /// Chest-like inventories have simple slot layouts where every slot
139    /// behaves the same (no special output slot, no fuel/product slots).
140    pub fn is_chest_like(&self) -> bool {
141        matches!(
142            self,
143            Self::Generic9x1
144                | Self::Generic9x2
145                | Self::Generic9x3
146                | Self::Generic9x4
147                | Self::Generic9x5
148                | Self::Generic9x6
149                | Self::ShulkerBox
150        )
151    }
152
153    /// Returns true if this inventory has a special crafting output slot at index 0.
154    ///
155    /// Currently only `Crafting` returns true. The output slot is
156    /// server-computed from the crafting grid contents.
157    pub fn has_craft_output(&self) -> bool {
158        matches!(self, Self::Crafting)
159    }
160}
161
162/// How a container window is backed in the world.
163///
164/// Virtual containers exist only on the server as transient GUI state;
165/// their slots live on the player entity. Block-backed containers
166/// correspond to a real block entity in the world.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ContainerBacking {
169    /// No backing block -- a pure GUI menu.
170    ///
171    /// Slots live on the player entity via `VirtualContainerSlots`
172    /// component and are cleaned up when the window closes.
173    Virtual,
174    /// Backed by a block entity at the given position.
175    ///
176    /// Reads and writes go through the block entity stored in the world.
177    /// Other players viewing the same block see updates via viewer sync.
178    Block {
179        /// World position of the backing block.
180        position: BlockPosition,
181    },
182}
183
184/// Reusable template value describing how to open a container window.
185///
186/// A `Container` is a plain data value with no side effects.  It can
187/// be stored, cloned, and passed to `ctx.containers().open(&container)`
188/// to show the window to any player.  Build one via
189/// [`Container::builder()`] or [`ContainerBuilder::new()`].
190#[derive(Debug, Clone)]
191pub struct Container {
192    /// The Minecraft inventory type to open.
193    pub inventory_type: InventoryType,
194    /// Window title shown to the player.
195    pub title: String,
196    /// Whether the container is virtual (GUI) or backed by a block.
197    pub backing: ContainerBacking,
198    /// Optional initial slot contents.
199    ///
200    /// If `None` and `backing` is `Block`, the server reads slots from
201    /// the block entity at the position. If `None` and `backing` is
202    /// `Virtual`, slots start empty.
203    ///
204    /// If `Some`, these slots are used as-is (padded/truncated to match
205    /// `inventory_type.slot_count()`).
206    pub initial_slots: Option<Vec<Slot>>,
207}
208
209impl Container {
210    /// Returns a fluent builder for configuring a container.
211    pub fn builder() -> ContainerBuilder {
212        ContainerBuilder::new()
213    }
214}
215
216/// Fluent builder for [`Container`] configurations.
217///
218/// The builder has no side effects; call `.build()` to produce a
219/// `Container` value that can be stored, cloned, and opened for any
220/// player via `ctx.containers().open(&container)`.
221pub struct ContainerBuilder {
222    /// The inventory type to open.
223    inventory_type: InventoryType,
224    /// Window title displayed to the player.
225    title: String,
226    /// Whether the container is virtual or block-backed.
227    backing: ContainerBacking,
228    /// Optional pre-filled slot contents.
229    initial_slots: Option<Vec<Slot>>,
230}
231
232impl ContainerBuilder {
233    /// Creates a builder with sensible defaults (Generic9x3, empty title, Virtual, no slots).
234    pub fn new() -> Self {
235        Self {
236            inventory_type: InventoryType::Generic9x3,
237            title: String::new(),
238            backing: ContainerBacking::Virtual,
239            initial_slots: None,
240        }
241    }
242
243    /// Sets the inventory type for the container window.
244    pub fn inventory_type(mut self, t: InventoryType) -> Self {
245        self.inventory_type = t;
246        self
247    }
248
249    /// Sets the window title shown to the player.
250    pub fn title(mut self, title: impl Into<String>) -> Self {
251        self.title = title.into();
252        self
253    }
254
255    /// Backs the container with a block entity at the given position.
256    ///
257    /// If no initial slots are provided via [`slots()`](Self::slots),
258    /// the server reads the slots from the block entity at this position.
259    pub fn backed_by(mut self, x: i32, y: i32, z: i32) -> Self {
260        self.backing = ContainerBacking::Block {
261            position: BlockPosition { x, y, z },
262        };
263        self
264    }
265
266    /// Pre-fills the container with the given slot contents.
267    ///
268    /// If the vector length does not match
269    /// [`InventoryType::slot_count()`], it will be truncated or
270    /// padded with empty slots on [`build()`](Self::build).
271    pub fn slots(mut self, slots: Vec<Slot>) -> Self {
272        self.initial_slots = Some(slots);
273        self
274    }
275
276    /// Finalizes the builder into a [`Container`] value.
277    ///
278    /// Pads or truncates `initial_slots` to match `inventory_type.slot_count()`.
279    pub fn build(self) -> Container {
280        let expected = self.inventory_type.slot_count();
281        let initial_slots = self.initial_slots.map(|mut s| {
282            s.resize(expected, Slot::empty());
283            s
284        });
285
286        Container {
287            inventory_type: self.inventory_type,
288            title: self.title,
289            backing: self.backing,
290            initial_slots,
291        }
292    }
293}
294
295impl Default for ContainerBuilder {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// All 25 inventory types with their expected protocol ID and slot count.
306    const VARIANTS: [(InventoryType, i32, usize); 25] = [
307        (InventoryType::Generic9x1, 0, 9),
308        (InventoryType::Generic9x2, 1, 18),
309        (InventoryType::Generic9x3, 2, 27),
310        (InventoryType::Generic9x4, 3, 36),
311        (InventoryType::Generic9x5, 4, 45),
312        (InventoryType::Generic9x6, 5, 54),
313        (InventoryType::Generic3x3, 6, 9),
314        (InventoryType::Crafter3x3, 7, 9),
315        (InventoryType::Anvil, 8, 3),
316        (InventoryType::Beacon, 9, 1),
317        (InventoryType::BlastFurnace, 10, 3),
318        (InventoryType::BrewingStand, 11, 5),
319        (InventoryType::Crafting, 12, 10),
320        (InventoryType::Enchantment, 13, 2),
321        (InventoryType::Furnace, 14, 3),
322        (InventoryType::Grindstone, 15, 3),
323        (InventoryType::Hopper, 16, 5),
324        (InventoryType::Lectern, 17, 1),
325        (InventoryType::Loom, 18, 4),
326        (InventoryType::Merchant, 19, 3),
327        (InventoryType::ShulkerBox, 20, 27),
328        (InventoryType::Smithing, 21, 4),
329        (InventoryType::Smoker, 22, 3),
330        (InventoryType::Cartography, 23, 3),
331        (InventoryType::Stonecutter, 24, 2),
332    ];
333
334    #[test]
335    fn protocol_id_matches_all_variants() {
336        for (variant, expected_id, _) in &VARIANTS {
337            assert_eq!(
338                variant.protocol_id(),
339                *expected_id,
340                "wrong protocol_id for {:?}",
341                variant
342            );
343        }
344    }
345
346    #[test]
347    fn slot_count_matches_all_variants() {
348        for (variant, _, expected_count) in &VARIANTS {
349            assert_eq!(
350                variant.slot_count(),
351                *expected_count,
352                "wrong slot_count for {:?}",
353                variant
354            );
355        }
356    }
357
358    #[test]
359    fn is_chest_like_generic_and_shulker() {
360        let chest_like = [
361            InventoryType::Generic9x1,
362            InventoryType::Generic9x2,
363            InventoryType::Generic9x3,
364            InventoryType::Generic9x4,
365            InventoryType::Generic9x5,
366            InventoryType::Generic9x6,
367            InventoryType::ShulkerBox,
368        ];
369        for variant in &chest_like {
370            assert!(
371                variant.is_chest_like(),
372                "{:?} should be chest-like",
373                variant
374            );
375        }
376    }
377
378    #[test]
379    fn is_chest_like_false_for_non_chest() {
380        let non_chest = [
381            InventoryType::Generic3x3,
382            InventoryType::Crafter3x3,
383            InventoryType::Anvil,
384            InventoryType::Beacon,
385            InventoryType::BlastFurnace,
386            InventoryType::BrewingStand,
387            InventoryType::Crafting,
388            InventoryType::Enchantment,
389            InventoryType::Furnace,
390            InventoryType::Grindstone,
391            InventoryType::Hopper,
392            InventoryType::Lectern,
393            InventoryType::Loom,
394            InventoryType::Merchant,
395            InventoryType::Smithing,
396            InventoryType::Smoker,
397            InventoryType::Cartography,
398            InventoryType::Stonecutter,
399        ];
400        for variant in &non_chest {
401            assert!(
402                !variant.is_chest_like(),
403                "{:?} should NOT be chest-like",
404                variant
405            );
406        }
407    }
408
409    #[test]
410    fn has_craft_output_only_crafting() {
411        assert!(InventoryType::Crafting.has_craft_output());
412        for (variant, _, _) in &VARIANTS {
413            if *variant != InventoryType::Crafting {
414                assert!(
415                    !variant.has_craft_output(),
416                    "{:?} should NOT have craft output",
417                    variant
418                );
419            }
420        }
421    }
422
423    #[test]
424    fn container_backing_virtual() {
425        let backing = ContainerBacking::Virtual;
426        assert_eq!(backing, ContainerBacking::Virtual);
427    }
428
429    #[test]
430    fn container_backing_block() {
431        let pos = BlockPosition { x: 1, y: 2, z: 3 };
432        let backing = ContainerBacking::Block { position: pos };
433        assert_eq!(
434            backing,
435            ContainerBacking::Block {
436                position: BlockPosition { x: 1, y: 2, z: 3 }
437            }
438        );
439    }
440
441    #[test]
442    fn container_with_initial_slots() {
443        let c = Container {
444            inventory_type: InventoryType::Generic9x3,
445            title: "My Chest".to_string(),
446            backing: ContainerBacking::Virtual,
447            initial_slots: Some(vec![Slot::default(); 27]),
448        };
449        assert_eq!(c.inventory_type.slot_count(), 27);
450        assert_eq!(c.initial_slots.as_ref().unwrap().len(), 27);
451    }
452
453    #[test]
454    fn container_no_initial_slots() {
455        let c = Container {
456            inventory_type: InventoryType::Crafting,
457            title: "Crafting".to_string(),
458            backing: ContainerBacking::Block {
459                position: BlockPosition {
460                    x: 10,
461                    y: 64,
462                    z: -5,
463                },
464            },
465            initial_slots: None,
466        };
467        assert!(c.initial_slots.is_none());
468        assert!(c.inventory_type.has_craft_output());
469        assert_eq!(c.inventory_type.protocol_id(), 12);
470    }
471
472    #[test]
473    fn builder_defaults() {
474        let c = Container::builder().build();
475        assert_eq!(c.inventory_type, InventoryType::Generic9x3);
476        assert!(c.title.is_empty());
477        assert!(matches!(c.backing, ContainerBacking::Virtual));
478        assert!(c.initial_slots.is_none());
479    }
480
481    #[test]
482    fn builder_full_chain() {
483        let c = Container::builder()
484            .inventory_type(InventoryType::Generic9x6)
485            .title("Shop")
486            .backed_by(5, 64, 3)
487            .slots(vec![Slot::empty(); 54])
488            .build();
489        assert_eq!(c.inventory_type, InventoryType::Generic9x6);
490        assert_eq!(c.title, "Shop");
491        assert_eq!(
492            c.backing,
493            ContainerBacking::Block {
494                position: BlockPosition { x: 5, y: 64, z: 3 }
495            }
496        );
497        assert_eq!(c.initial_slots.unwrap().len(), 54);
498    }
499
500    #[test]
501    fn builder_pads_slots() {
502        let c = Container::builder()
503            .inventory_type(InventoryType::Generic9x3)
504            .slots(vec![Slot::new(1, 5)])
505            .build();
506        assert_eq!(c.initial_slots.as_ref().unwrap().len(), 27);
507    }
508
509    #[test]
510    fn builder_truncates_slots() {
511        let c = Container::builder()
512            .inventory_type(InventoryType::Hopper)
513            .slots(vec![Slot::new(1, 1); 10])
514            .build();
515        assert_eq!(c.initial_slots.as_ref().unwrap().len(), 5);
516    }
517
518    #[test]
519    fn container_is_reusable() {
520        let c = Container::builder()
521            .inventory_type(InventoryType::Generic9x6)
522            .build();
523        let c2 = c.clone();
524        assert_eq!(c.inventory_type, c2.inventory_type);
525        assert_eq!(c.title, c2.title);
526    }
527
528    #[test]
529    fn builder_default_trait() {
530        let b = ContainerBuilder::default();
531        let c = b.build();
532        assert_eq!(c.inventory_type, InventoryType::Generic9x3);
533    }
534
535    #[test]
536    fn builder_title_into_string() {
537        let c = Container::builder().title(String::from("Dynamic")).build();
538        assert_eq!(c.title, "Dynamic");
539    }
540}