Skip to main content

basalt_api/components/
inventory.rs

1//! Player inventory component with protocol slot remapping.
2
3use crate::components::Component;
4
5/// Player inventory — 36 slots (27 main + 9 hotbar).
6///
7/// Slot layout matches Minecraft's raw player inventory:
8/// - Slots 0–8: hotbar
9/// - Slots 9–35: main inventory (3 rows of 9)
10///
11/// This matches the `SetPlayerInventory` packet (1.21.4) directly —
12/// no slot conversion needed when syncing individual slots.
13///
14/// For the player inventory *window* (SetContainerContent), slots remap:
15/// window 9-35 = main (our 9-35, same), window 36-44 = hotbar (our 0-8).
16#[derive(Debug, Clone)]
17pub struct Inventory {
18    /// Currently selected hotbar index (0-8).
19    pub held_slot: u8,
20    /// All 36 inventory slots (0-8 hotbar, 9-35 main).
21    pub slots: [basalt_types::Slot; 36],
22    /// Item currently held on the mouse cursor (in an open window).
23    pub cursor: basalt_types::Slot,
24}
25
26impl Inventory {
27    /// First hotbar slot index within `slots`.
28    pub const HOTBAR_START: usize = 0;
29    /// First main inventory slot index within `slots`.
30    pub const MAIN_START: usize = 9;
31
32    /// Creates an empty inventory with slot 0 selected.
33    pub fn empty() -> Self {
34        Self {
35            held_slot: 0,
36            slots: std::array::from_fn(|_| basalt_types::Slot::empty()),
37            cursor: basalt_types::Slot::empty(),
38        }
39    }
40
41    /// Returns the currently held item (from the hotbar).
42    pub fn held_item(&self) -> &basalt_types::Slot {
43        &self.slots[self.held_slot as usize]
44    }
45
46    /// Returns a reference to the hotbar (9 slots).
47    pub fn hotbar(&self) -> &[basalt_types::Slot] {
48        &self.slots[..9]
49    }
50
51    /// Returns a mutable reference to the hotbar (9 slots).
52    pub fn hotbar_mut(&mut self) -> &mut [basalt_types::Slot] {
53        &mut self.slots[..9]
54    }
55
56    /// Converts a protocol window slot to an internal slot index.
57    ///
58    /// Window layout: 9-35 = main, 36-44 = hotbar.
59    /// Internal layout: 0-8 = hotbar, 9-35 = main.
60    pub fn window_to_index(window_slot: i16) -> Option<usize> {
61        match window_slot {
62            9..=35 => Some(window_slot as usize), // main: same numbering
63            36..=44 => Some((window_slot - 36) as usize), // hotbar: window 36-44 → 0-8
64            _ => None,
65        }
66    }
67
68    /// Converts an internal slot index to a protocol window slot.
69    pub fn index_to_window(index: usize) -> Option<i16> {
70        match index {
71            0..=8 => Some(index as i16 + 36), // hotbar → window 36-44
72            9..=35 => Some(index as i16),     // main: same numbering
73            _ => None,
74        }
75    }
76
77    /// Tries to insert an item into the inventory.
78    ///
79    /// Searches hotbar first (for convenience), then main inventory.
80    /// Tries matching stacks (count < 64) first, then empty slots.
81    /// Returns `Some(internal_index)` if inserted, `None` if full.
82    pub fn try_insert(&mut self, item_id: i32, count: i32) -> Option<usize> {
83        // Hotbar first, then main — matching stacks
84        let search_order = (0..9).chain(Self::MAIN_START..36);
85        for i in search_order {
86            let slot = &mut self.slots[i];
87            if slot.item_id == Some(item_id) && slot.item_count < 64 {
88                let space = 64 - slot.item_count;
89                let to_add = count.min(space);
90                slot.item_count += to_add;
91                if to_add == count {
92                    return Some(i);
93                }
94            }
95        }
96        // Hotbar first, then main — empty slots
97        let search_order = (0..9).chain(Self::MAIN_START..36);
98        for i in search_order {
99            if self.slots[i].is_empty() {
100                self.slots[i] = basalt_types::Slot::new(item_id, count);
101                return Some(i);
102            }
103        }
104        None
105    }
106
107    /// Builds the 46-slot protocol representation for SetContainerContent.
108    ///
109    /// Window slot layout for player inventory (id=0):
110    /// 0 = crafting output, 1-4 = crafting grid, 5-8 = armor,
111    /// 9-35 = main inventory, 36-44 = hotbar, 45 = offhand.
112    pub fn to_protocol_slots(&self) -> Vec<basalt_types::Slot> {
113        let mut protocol = vec![basalt_types::Slot::empty(); 46];
114        // Main: internal 9-35 → window 9-35 (same)
115        protocol[9..36].clone_from_slice(&self.slots[9..]);
116        // Hotbar: internal 0-8 → window 36-44
117        protocol[36..45].clone_from_slice(&self.slots[..9]);
118        protocol
119    }
120}
121impl Component for Inventory {}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn try_insert_empty_hotbar() {
129        let mut inv = Inventory::empty();
130        let idx = inv.try_insert(1, 1);
131        assert_eq!(idx, Some(Inventory::HOTBAR_START));
132        assert_eq!(inv.slots[Inventory::HOTBAR_START].item_id, Some(1));
133    }
134
135    #[test]
136    fn try_insert_stacks() {
137        let mut inv = Inventory::empty();
138        inv.try_insert(1, 32);
139        let idx = inv.try_insert(1, 16);
140        assert_eq!(idx, Some(Inventory::HOTBAR_START));
141        assert_eq!(inv.slots[Inventory::HOTBAR_START].item_count, 48);
142    }
143
144    #[test]
145    fn try_insert_full_returns_none() {
146        let mut inv = Inventory::empty();
147        for i in 0..36 {
148            inv.slots[i] = basalt_types::Slot::new(i as i32 + 100, 64);
149        }
150        assert_eq!(inv.try_insert(999, 1), None);
151    }
152
153    #[test]
154    fn slot_conversion() {
155        assert_eq!(Inventory::window_to_index(9), Some(9));
156        assert_eq!(Inventory::window_to_index(35), Some(35));
157        assert_eq!(Inventory::window_to_index(36), Some(0));
158        assert_eq!(Inventory::window_to_index(44), Some(8));
159        assert_eq!(Inventory::window_to_index(0), None);
160        assert_eq!(Inventory::window_to_index(45), None);
161        assert_eq!(Inventory::index_to_window(0), Some(36));
162        assert_eq!(Inventory::index_to_window(9), Some(9));
163    }
164
165    #[test]
166    fn to_protocol_slots_length() {
167        let inv = Inventory::empty();
168        assert_eq!(inv.to_protocol_slots().len(), 46);
169    }
170
171    #[test]
172    fn to_protocol_slots_maps_correctly() {
173        let mut inv = Inventory::empty();
174        inv.slots[0] = basalt_types::Slot::new(1, 1);
175        inv.slots[9] = basalt_types::Slot::new(2, 2);
176        let proto = inv.to_protocol_slots();
177        assert_eq!(proto[36].item_id, Some(1));
178        assert_eq!(proto[9].item_id, Some(2));
179    }
180
181    #[test]
182    fn held_item_and_hotbar() {
183        let mut inv = Inventory::empty();
184        inv.slots[3] = basalt_types::Slot::new(5, 10);
185        inv.held_slot = 3;
186        assert_eq!(inv.held_item().item_id, Some(5));
187        assert_eq!(inv.hotbar().len(), 9);
188        assert_eq!(inv.hotbar()[3].item_count, 10);
189    }
190
191    #[test]
192    fn try_insert_main_when_hotbar_full() {
193        let mut inv = Inventory::empty();
194        for i in 0..9 {
195            inv.slots[i] = basalt_types::Slot::new(i as i32, 64);
196        }
197        let idx = inv.try_insert(999, 1);
198        assert_eq!(idx, Some(Inventory::MAIN_START));
199        assert_eq!(inv.slots[Inventory::MAIN_START].item_id, Some(999));
200    }
201
202    #[test]
203    fn window_roundtrip() {
204        for i in 0..36 {
205            let window = Inventory::index_to_window(i).unwrap();
206            let back = Inventory::window_to_index(window).unwrap();
207            assert_eq!(back, i);
208        }
209    }
210}