azalea_entity/
inventory.rs

1use std::{cmp, collections::HashSet};
2
3use azalea_chat::FormattedText;
4use azalea_inventory::{
5    ItemStack, ItemStackData, Menu,
6    components::EquipmentSlot,
7    item::MaxStackSizeExt,
8    operations::{
9        ClickOperation, CloneClick, PickupAllClick, PickupClick, QuickCraftKind, QuickCraftStatus,
10        QuickCraftStatusKind, QuickMoveClick, ThrowClick,
11    },
12};
13use bevy_ecs::prelude::*;
14
15use crate::PlayerAbilities;
16
17/// A component present on all local players that have an inventory.
18#[derive(Component, Debug, Clone)]
19pub struct Inventory {
20    /// The player's inventory menu. This is guaranteed to be a `Menu::Player`.
21    ///
22    /// We keep it as a [`Menu`] since `Menu` has some useful functions that
23    /// bare [`azalea_inventory::Player`] doesn't have.
24    pub inventory_menu: azalea_inventory::Menu,
25
26    /// The ID of the container that's currently open.
27    ///
28    /// Its value is not guaranteed to be anything specific, and it may change
29    /// every time you open a container (unless it's 0, in which case it
30    /// means that no container is open).
31    pub id: i32,
32    /// The current container menu that the player has open, or `None` if no
33    /// container is open.
34    pub container_menu: Option<azalea_inventory::Menu>,
35    /// The custom name of the menu that's currently open.
36    ///
37    /// This can only be `Some` when `container_menu` is `Some`.
38    pub container_menu_title: Option<FormattedText>,
39    /// The item that is currently held by the cursor, or `Slot::Empty` if
40    /// nothing is currently being held.
41    ///
42    /// This is different from [`Self::selected_hotbar_slot`], which is the
43    /// item that's selected in the hotbar.
44    pub carried: ItemStack,
45    /// An identifier used by the server to track client inventory desyncs.
46    ///
47    /// This is sent on every container click, and it's only ever updated when
48    /// the server sends a new container update.
49    pub state_id: u32,
50
51    pub quick_craft_status: QuickCraftStatusKind,
52    pub quick_craft_kind: QuickCraftKind,
53    /// A set of the indexes of the slots that have been right clicked in
54    /// this "quick craft".
55    pub quick_craft_slots: HashSet<u16>,
56
57    /// The index of the item in the hotbar that's currently being held by the
58    /// player. This must be in the range 0..=8.
59    ///
60    /// In a vanilla client this is changed by pressing the number keys or using
61    /// the scroll wheel.
62    pub selected_hotbar_slot: u8,
63}
64
65impl Inventory {
66    /// Returns a reference to the currently active menu.
67    ///
68    /// If a container is open then it'll return [`Self::container_menu`],
69    /// otherwise [`Self::inventory_menu`].
70    ///
71    /// Use [`Self::menu_mut`] if you need a mutable reference.
72    pub fn menu(&self) -> &azalea_inventory::Menu {
73        match &self.container_menu {
74            Some(menu) => menu,
75            _ => &self.inventory_menu,
76        }
77    }
78
79    /// Returns a mutable reference to the currently active menu.
80    ///
81    /// If a container is open then it'll return [`Self::container_menu`],
82    /// otherwise [`Self::inventory_menu`].
83    ///
84    /// Use [`Self::menu`] if you don't need a mutable reference.
85    pub fn menu_mut(&mut self) -> &mut azalea_inventory::Menu {
86        match &mut self.container_menu {
87            Some(menu) => menu,
88            _ => &mut self.inventory_menu,
89        }
90    }
91
92    /// Modify the inventory as if the given operation was performed on it.
93    pub fn simulate_click(
94        &mut self,
95        operation: &ClickOperation,
96        player_abilities: &PlayerAbilities,
97    ) {
98        if let ClickOperation::QuickCraft(quick_craft) = operation {
99            let last_quick_craft_status_tmp = self.quick_craft_status.clone();
100            self.quick_craft_status = last_quick_craft_status_tmp.clone();
101            let last_quick_craft_status = last_quick_craft_status_tmp;
102
103            // no carried item, reset
104            if self.carried.is_empty() {
105                return self.reset_quick_craft();
106            }
107            // if we were starting or ending, or now we aren't ending and the status
108            // changed, reset
109            if (last_quick_craft_status == QuickCraftStatusKind::Start
110                || last_quick_craft_status == QuickCraftStatusKind::End
111                || self.quick_craft_status != QuickCraftStatusKind::End)
112                && (self.quick_craft_status != last_quick_craft_status)
113            {
114                return self.reset_quick_craft();
115            }
116            if self.quick_craft_status == QuickCraftStatusKind::Start {
117                self.quick_craft_kind = quick_craft.kind.clone();
118                if self.quick_craft_kind == QuickCraftKind::Middle && player_abilities.instant_break
119                {
120                    self.quick_craft_status = QuickCraftStatusKind::Add;
121                    self.quick_craft_slots.clear();
122                } else {
123                    self.reset_quick_craft();
124                }
125                return;
126            }
127            if let QuickCraftStatus::Add { slot } = quick_craft.status {
128                let slot_item = self.menu().slot(slot as usize);
129                if let Some(slot_item) = slot_item
130                    && let ItemStack::Present(carried) = &self.carried
131                {
132                    // minecraft also checks slot.may_place(carried) and
133                    // menu.can_drag_to(slot)
134                    // but they always return true so they're not relevant for us
135                    if can_item_quick_replace(slot_item, &self.carried, true)
136                        && (self.quick_craft_kind == QuickCraftKind::Right
137                            || carried.count as usize > self.quick_craft_slots.len())
138                    {
139                        self.quick_craft_slots.insert(slot);
140                    }
141                }
142                return;
143            }
144            if self.quick_craft_status == QuickCraftStatusKind::End {
145                if !self.quick_craft_slots.is_empty() {
146                    if self.quick_craft_slots.len() == 1 {
147                        // if we only clicked one slot, then turn this
148                        // QuickCraftClick into a PickupClick
149                        let slot = *self.quick_craft_slots.iter().next().unwrap();
150                        self.reset_quick_craft();
151                        self.simulate_click(
152                            &match self.quick_craft_kind {
153                                QuickCraftKind::Left => {
154                                    PickupClick::Left { slot: Some(slot) }.into()
155                                }
156                                QuickCraftKind::Right => {
157                                    PickupClick::Left { slot: Some(slot) }.into()
158                                }
159                                QuickCraftKind::Middle => {
160                                    // idk just do nothing i guess
161                                    return;
162                                }
163                            },
164                            player_abilities,
165                        );
166                        return;
167                    }
168
169                    let ItemStack::Present(mut carried) = self.carried.clone() else {
170                        // this should never happen
171                        return self.reset_quick_craft();
172                    };
173
174                    let mut carried_count = carried.count;
175                    let mut quick_craft_slots_iter = self.quick_craft_slots.iter();
176
177                    loop {
178                        let mut slot: &ItemStack;
179                        let mut slot_index: u16;
180                        let mut item_stack: &ItemStack;
181
182                        loop {
183                            let Some(&next_slot) = quick_craft_slots_iter.next() else {
184                                carried.count = carried_count;
185                                self.carried = ItemStack::Present(carried);
186                                return self.reset_quick_craft();
187                            };
188
189                            slot = self.menu().slot(next_slot as usize).unwrap();
190                            slot_index = next_slot;
191                            item_stack = &self.carried;
192
193                            if slot.is_present()
194                                    && can_item_quick_replace(slot, item_stack, true)
195                                    // this always returns true in most cases
196                                    // && slot.may_place(item_stack)
197                                    && (
198                                        self.quick_craft_kind == QuickCraftKind::Middle
199                                        || item_stack.count()  >= self.quick_craft_slots.len() as i32
200                                    )
201                            {
202                                break;
203                            }
204                        }
205
206                        // get the ItemStackData for the slot
207                        let ItemStack::Present(slot) = slot else {
208                            unreachable!("the loop above requires the slot to be present to break")
209                        };
210
211                        // if self.can_drag_to(slot) {
212                        let mut new_carried = carried.clone();
213                        let slot_item_count = slot.count;
214                        get_quick_craft_slot_count(
215                            &self.quick_craft_slots,
216                            &self.quick_craft_kind,
217                            &mut new_carried,
218                            slot_item_count,
219                        );
220                        let max_stack_size = i32::min(
221                            new_carried.kind.max_stack_size(),
222                            i32::min(
223                                new_carried.kind.max_stack_size(),
224                                slot.kind.max_stack_size(),
225                            ),
226                        );
227                        if new_carried.count > max_stack_size {
228                            new_carried.count = max_stack_size;
229                        }
230
231                        carried_count -= new_carried.count - slot_item_count;
232                        // we have to inline self.menu_mut() here to avoid the borrow checker
233                        // complaining
234                        let menu = match &mut self.container_menu {
235                            Some(menu) => menu,
236                            _ => &mut self.inventory_menu,
237                        };
238                        *menu.slot_mut(slot_index as usize).unwrap() =
239                            ItemStack::Present(new_carried);
240                    }
241                }
242            } else {
243                return self.reset_quick_craft();
244            }
245        }
246        // the quick craft status should always be in start if we're not in quick craft
247        // mode
248        if self.quick_craft_status != QuickCraftStatusKind::Start {
249            return self.reset_quick_craft();
250        }
251
252        match operation {
253            // left clicking outside inventory
254            ClickOperation::Pickup(PickupClick::Left { slot: None }) => {
255                if self.carried.is_present() {
256                    // vanilla has `player.drop`s but they're only used
257                    // server-side
258                    // they're included as comments here in case you want to adapt this for a server
259                    // implementation
260
261                    // player.drop(self.carried, true);
262                    self.carried = ItemStack::Empty;
263                }
264            }
265            ClickOperation::Pickup(PickupClick::Right { slot: None }) => {
266                if self.carried.is_present() {
267                    let _item = self.carried.split(1);
268                    // player.drop(item, true);
269                }
270            }
271            &ClickOperation::Pickup(
272                // lol
273                ref pickup @ (PickupClick::Left { slot: Some(slot) }
274                | PickupClick::Right { slot: Some(slot) }),
275            ) => {
276                let slot = slot as usize;
277                let Some(slot_item) = self.menu().slot(slot) else {
278                    return;
279                };
280
281                if self.try_item_click_behavior_override(operation, slot) {
282                    return;
283                }
284
285                let is_left_click = matches!(pickup, PickupClick::Left { .. });
286
287                match slot_item {
288                    ItemStack::Empty => {
289                        if self.carried.is_present() {
290                            let place_count = if is_left_click {
291                                self.carried.count()
292                            } else {
293                                1
294                            };
295                            self.carried =
296                                self.safe_insert(slot, self.carried.clone(), place_count);
297                        }
298                    }
299                    ItemStack::Present(_) => {
300                        if !self.menu().may_pickup(slot) {
301                            return;
302                        }
303                        if let ItemStack::Present(carried) = self.carried.clone() {
304                            let slot_is_same_item_as_carried = slot_item
305                                .as_present()
306                                .is_some_and(|s| carried.is_same_item_and_components(s));
307
308                            if self.menu().may_place(slot, &carried) {
309                                if slot_is_same_item_as_carried {
310                                    let place_count = if is_left_click { carried.count } else { 1 };
311                                    self.carried =
312                                        self.safe_insert(slot, self.carried.clone(), place_count);
313                                } else if carried.count
314                                    <= self
315                                        .menu()
316                                        .max_stack_size(slot)
317                                        .min(carried.kind.max_stack_size())
318                                {
319                                    // swap slot_item and carried
320                                    self.carried = slot_item.clone();
321                                    let slot_item = self.menu_mut().slot_mut(slot).unwrap();
322                                    *slot_item = carried.into();
323                                }
324                            } else if slot_is_same_item_as_carried
325                                && let Some(removed) = self.try_remove(
326                                    slot,
327                                    slot_item.count(),
328                                    carried.kind.max_stack_size() - carried.count,
329                                )
330                            {
331                                self.carried.as_present_mut().unwrap().count += removed.count();
332                                // slot.onTake(player, removed);
333                            }
334                        } else {
335                            let pickup_count = if is_left_click {
336                                slot_item.count()
337                            } else {
338                                (slot_item.count() + 1) / 2
339                            };
340                            if let Some(new_slot_item) =
341                                self.try_remove(slot, pickup_count, i32::MAX)
342                            {
343                                self.carried = new_slot_item;
344                                // slot.onTake(player, newSlot);
345                            }
346                        }
347                    }
348                }
349            }
350            &ClickOperation::QuickMove(
351                QuickMoveClick::Left { slot } | QuickMoveClick::Right { slot },
352            ) => {
353                // in vanilla it also tests if QuickMove has a slot index of -999
354                // but i don't think that's ever possible so it's not covered here
355                let slot = slot as usize;
356                loop {
357                    let new_slot_item = self.menu_mut().quick_move_stack(slot);
358                    let slot_item = self.menu().slot(slot).unwrap();
359                    if new_slot_item.is_empty() || slot_item.kind() != new_slot_item.kind() {
360                        break;
361                    }
362                }
363            }
364            ClickOperation::Swap(s) => {
365                let source_slot_index = s.source_slot as usize;
366                let target_slot_index = s.target_slot as usize;
367
368                let Some(source_slot) = self.menu().slot(source_slot_index) else {
369                    return;
370                };
371                let Some(target_slot) = self.menu().slot(target_slot_index) else {
372                    return;
373                };
374                if source_slot.is_empty() && target_slot.is_empty() {
375                    return;
376                }
377
378                if target_slot.is_empty() {
379                    if self.menu().may_pickup(source_slot_index) {
380                        let source_slot = source_slot.clone();
381                        let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
382                        *target_slot = source_slot;
383                    }
384                } else if source_slot.is_empty() {
385                    let target_item = target_slot
386                        .as_present()
387                        .expect("target slot was already checked to not be empty");
388                    if self.menu().may_place(source_slot_index, target_item) {
389                        // get the target_item but mutable
390                        let source_max_stack_size = self.menu().max_stack_size(source_slot_index);
391
392                        let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
393                        let new_source_slot =
394                            target_slot.split(source_max_stack_size.try_into().unwrap());
395                        *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
396                    }
397                } else if self.menu().may_pickup(source_slot_index) {
398                    let ItemStack::Present(target_item) = target_slot else {
399                        unreachable!("target slot is not empty but is not present");
400                    };
401                    if self.menu().may_place(source_slot_index, target_item) {
402                        let source_max_stack = self.menu().max_stack_size(source_slot_index);
403                        if target_slot.count() > source_max_stack {
404                            // if there's more than the max stack size in the target slot
405
406                            let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
407                            let new_source_slot =
408                                target_slot.split(source_max_stack.try_into().unwrap());
409                            *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
410                            // if !self.inventory_menu.add(new_source_slot) {
411                            //     player.drop(new_source_slot, true);
412                            // }
413                        } else {
414                            // normal swap
415                            let new_target_slot = source_slot.clone();
416                            let new_source_slot = target_slot.clone();
417
418                            let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
419                            *target_slot = new_target_slot;
420
421                            let source_slot = self.menu_mut().slot_mut(source_slot_index).unwrap();
422                            *source_slot = new_source_slot;
423                        }
424                    }
425                }
426            }
427            ClickOperation::Clone(CloneClick { slot }) => {
428                if !player_abilities.instant_break || self.carried.is_present() {
429                    return;
430                }
431                let Some(source_slot) = self.menu().slot(*slot as usize) else {
432                    return;
433                };
434                let ItemStack::Present(source_item) = source_slot else {
435                    return;
436                };
437                let mut new_carried = source_item.clone();
438                new_carried.count = new_carried.kind.max_stack_size();
439                self.carried = ItemStack::Present(new_carried);
440            }
441            ClickOperation::Throw(c) => {
442                if self.carried.is_present() {
443                    return;
444                }
445
446                let (ThrowClick::Single { slot: slot_index }
447                | ThrowClick::All { slot: slot_index }) = c;
448                let slot_index = *slot_index as usize;
449
450                let Some(slot) = self.menu_mut().slot_mut(slot_index) else {
451                    return;
452                };
453                let ItemStack::Present(slot_item) = slot else {
454                    return;
455                };
456
457                let dropping_count = match c {
458                    ThrowClick::Single { .. } => 1,
459                    ThrowClick::All { .. } => slot_item.count,
460                };
461
462                let _dropping = slot_item.split(dropping_count as u32);
463                // player.drop(dropping, true);
464            }
465            ClickOperation::PickupAll(PickupAllClick {
466                slot: source_slot_index,
467                reversed,
468            }) => {
469                let source_slot_index = *source_slot_index as usize;
470
471                let source_slot = self.menu().slot(source_slot_index).unwrap();
472                let target_slot = self.carried.clone();
473
474                if target_slot.is_empty()
475                    || (source_slot.is_present() && self.menu().may_pickup(source_slot_index))
476                {
477                    return;
478                }
479
480                let ItemStack::Present(target_slot_item) = &target_slot else {
481                    unreachable!("target slot is not empty but is not present");
482                };
483
484                for round in 0..2 {
485                    let iterator: Box<dyn Iterator<Item = usize>> = if *reversed {
486                        Box::new((0..self.menu().len()).rev())
487                    } else {
488                        Box::new(0..self.menu().len())
489                    };
490
491                    for i in iterator {
492                        if target_slot_item.count < target_slot_item.kind.max_stack_size() {
493                            let checking_slot = self.menu().slot(i).unwrap();
494                            if let ItemStack::Present(checking_item) = checking_slot
495                                && can_item_quick_replace(checking_slot, &target_slot, true)
496                                && self.menu().may_pickup(i)
497                                && (round != 0
498                                    || checking_item.count != checking_item.kind.max_stack_size())
499                            {
500                                // get the checking_slot and checking_item again but mutable
501                                let checking_slot = self.menu_mut().slot_mut(i).unwrap();
502
503                                let taken_item = checking_slot.split(checking_slot.count() as u32);
504
505                                // now extend the carried item
506                                let target_slot = &mut self.carried;
507                                let ItemStack::Present(target_slot_item) = target_slot else {
508                                    unreachable!("target slot is not empty but is not present");
509                                };
510                                target_slot_item.count += taken_item.count();
511                            }
512                        }
513                    }
514                }
515            }
516            _ => {}
517        }
518    }
519
520    fn reset_quick_craft(&mut self) {
521        self.quick_craft_status = QuickCraftStatusKind::Start;
522        self.quick_craft_slots.clear();
523    }
524
525    /// Get the item in the player's hotbar that is currently being held in
526    /// their main hand.
527    pub fn held_item(&self) -> &ItemStack {
528        self.get_equipment(EquipmentSlot::Mainhand)
529            .expect("The main hand item should always be present")
530    }
531
532    /// TODO: implement bundles
533    fn try_item_click_behavior_override(
534        &self,
535        _operation: &ClickOperation,
536        _slot_item_index: usize,
537    ) -> bool {
538        false
539    }
540
541    fn safe_insert(&mut self, slot: usize, src_item: ItemStack, take_count: i32) -> ItemStack {
542        let Some(slot_item) = self.menu_mut().slot_mut(slot) else {
543            return src_item;
544        };
545        let ItemStack::Present(mut src_item) = src_item else {
546            return src_item;
547        };
548
549        let take_count = cmp::min(
550            cmp::min(take_count, src_item.count),
551            src_item.kind.max_stack_size() - slot_item.count(),
552        );
553        if take_count <= 0 {
554            return src_item.into();
555        }
556        let take_count = take_count as u32;
557
558        if slot_item.is_empty() {
559            *slot_item = src_item.split(take_count).into();
560        } else if let ItemStack::Present(slot_item) = slot_item
561            && slot_item.is_same_item_and_components(&src_item)
562        {
563            src_item.count -= take_count as i32;
564            slot_item.count += take_count as i32;
565        }
566
567        src_item.into()
568    }
569
570    fn try_remove(&mut self, slot: usize, count: i32, limit: i32) -> Option<ItemStack> {
571        if !self.menu().may_pickup(slot) {
572            return None;
573        }
574        let mut slot_item = self.menu().slot(slot)?.clone();
575        if !self.menu().allow_modification(slot) && limit < slot_item.count() {
576            return None;
577        }
578
579        let count = count.min(limit);
580        if count <= 0 {
581            return None;
582        }
583        // vanilla calls .remove here but i think it has the same behavior as split?
584        let removed = slot_item.split(count as u32);
585
586        if removed.is_present() && slot_item.is_empty() {
587            *self.menu_mut().slot_mut(slot).unwrap() = ItemStack::Empty;
588        }
589
590        Some(removed)
591    }
592
593    /// Get the item at the given equipment slot, or `None` if the inventory
594    /// can't contain that slot.
595    pub fn get_equipment(&self, equipment_slot: EquipmentSlot) -> Option<&ItemStack> {
596        let player = self.inventory_menu.as_player();
597        let item = match equipment_slot {
598            EquipmentSlot::Mainhand => {
599                let menu = self.menu();
600                let main_hand_slot_idx =
601                    *menu.hotbar_slots_range().start() + self.selected_hotbar_slot as usize;
602                menu.slot(main_hand_slot_idx)?
603            }
604            EquipmentSlot::Offhand => &player.offhand,
605            EquipmentSlot::Feet => &player.armor[3],
606            EquipmentSlot::Legs => &player.armor[2],
607            EquipmentSlot::Chest => &player.armor[1],
608            EquipmentSlot::Head => &player.armor[0],
609            EquipmentSlot::Body => {
610                // TODO: when riding entities is implemented, mount/horse inventories should be
611                // implemented too. note that horse inventories aren't a normal menu (they're
612                // not in MenuKind), maybe they should be a separate field in `Inventory`?
613                return None;
614            }
615            EquipmentSlot::Saddle => {
616                // TODO: implement riding entities, see above
617                return None;
618            }
619        };
620        Some(item)
621    }
622}
623
624fn can_item_quick_replace(
625    target_slot: &ItemStack,
626    item: &ItemStack,
627    ignore_item_count: bool,
628) -> bool {
629    let ItemStack::Present(target_slot) = target_slot else {
630        return false;
631    };
632    let ItemStack::Present(item) = item else {
633        // i *think* this is what vanilla does
634        // not 100% sure lol probably doesn't matter though
635        return false;
636    };
637
638    if !item.is_same_item_and_components(target_slot) {
639        return false;
640    }
641    let count = target_slot.count as u16
642        + if ignore_item_count {
643            0
644        } else {
645            item.count as u16
646        };
647    count <= item.kind.max_stack_size() as u16
648}
649
650fn get_quick_craft_slot_count(
651    quick_craft_slots: &HashSet<u16>,
652    quick_craft_kind: &QuickCraftKind,
653    item: &mut ItemStackData,
654    slot_item_count: i32,
655) {
656    item.count = match quick_craft_kind {
657        QuickCraftKind::Left => item.count / quick_craft_slots.len() as i32,
658        QuickCraftKind::Right => 1,
659        QuickCraftKind::Middle => item.kind.max_stack_size(),
660    };
661    item.count += slot_item_count;
662}
663
664impl Default for Inventory {
665    fn default() -> Self {
666        Inventory {
667            inventory_menu: Menu::Player(azalea_inventory::Player::default()),
668            id: 0,
669            container_menu: None,
670            container_menu_title: None,
671            carried: ItemStack::Empty,
672            state_id: 0,
673            quick_craft_status: QuickCraftStatusKind::Start,
674            quick_craft_kind: QuickCraftKind::Middle,
675            quick_craft_slots: HashSet::new(),
676            selected_hotbar_slot: 0,
677        }
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use azalea_inventory::SlotList;
684    use azalea_registry::builtin::ItemKind;
685
686    use super::*;
687
688    #[test]
689    fn test_simulate_shift_click_in_crafting_table() {
690        let spruce_planks = ItemStack::new(ItemKind::SprucePlanks, 4);
691
692        let mut inventory = Inventory {
693            inventory_menu: Menu::Player(azalea_inventory::Player::default()),
694            id: 1,
695            container_menu: Some(Menu::Crafting {
696                result: spruce_planks.clone(),
697                // simulate_click won't delete the items from here
698                grid: SlotList::default(),
699                player: SlotList::default(),
700            }),
701            container_menu_title: None,
702            carried: ItemStack::Empty,
703            state_id: 0,
704            quick_craft_status: QuickCraftStatusKind::Start,
705            quick_craft_kind: QuickCraftKind::Middle,
706            quick_craft_slots: HashSet::new(),
707            selected_hotbar_slot: 0,
708        };
709
710        inventory.simulate_click(
711            &ClickOperation::QuickMove(QuickMoveClick::Left { slot: 0 }),
712            &PlayerAbilities::default(),
713        );
714
715        let new_slots = inventory.menu().slots();
716        assert_eq!(&new_slots[0], &ItemStack::Empty);
717        assert_eq!(
718            &new_slots[*Menu::CRAFTING_PLAYER_SLOTS.start()],
719            &spruce_planks
720        );
721    }
722}