azalea_client/plugins/
inventory.rs

1use std::{cmp, collections::HashSet};
2
3use azalea_chat::FormattedText;
4use azalea_core::tick::GameTick;
5use azalea_entity::PlayerAbilities;
6pub use azalea_inventory::*;
7use azalea_inventory::{
8    item::MaxStackSizeExt,
9    operations::{
10        ClickOperation, CloneClick, PickupAllClick, PickupClick, QuickCraftKind, QuickCraftStatus,
11        QuickCraftStatusKind, QuickMoveClick, ThrowClick,
12    },
13};
14use azalea_protocol::packets::game::{
15    s_container_click::{HashedStack, ServerboundContainerClick},
16    s_container_close::ServerboundContainerClose,
17    s_set_carried_item::ServerboundSetCarriedItem,
18};
19use azalea_registry::MenuKind;
20use azalea_world::{InstanceContainer, InstanceName};
21use bevy_app::{App, Plugin, Update};
22use bevy_ecs::prelude::*;
23use indexmap::IndexMap;
24use tracing::{error, warn};
25
26use crate::{Client, packet::game::SendPacketEvent, respawn::perform_respawn};
27
28pub struct InventoryPlugin;
29impl Plugin for InventoryPlugin {
30    fn build(&self, app: &mut App) {
31        app.add_event::<ClientsideCloseContainerEvent>()
32            .add_event::<MenuOpenedEvent>()
33            .add_event::<CloseContainerEvent>()
34            .add_event::<ContainerClickEvent>()
35            .add_event::<SetContainerContentEvent>()
36            .add_event::<SetSelectedHotbarSlotEvent>()
37            .add_systems(
38                Update,
39                (
40                    handle_set_selected_hotbar_slot_event,
41                    handle_menu_opened_event,
42                    handle_container_click_event,
43                    handle_container_close_event,
44                    handle_client_side_close_container_event,
45                )
46                    .chain()
47                    .in_set(InventorySet)
48                    .before(perform_respawn),
49            )
50            .add_systems(
51                GameTick,
52                ensure_has_sent_carried_item.after(super::mining::handle_mining_queued),
53            )
54            .add_observer(handle_client_side_close_container_trigger)
55            .add_observer(handle_menu_opened_trigger)
56            .add_observer(handle_set_container_content_trigger);
57    }
58}
59
60#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
61pub struct InventorySet;
62
63impl Client {
64    /// Return the menu that is currently open. If no menu is open, this will
65    /// have the player's inventory.
66    pub fn menu(&self) -> Menu {
67        let mut ecs = self.ecs.lock();
68        let inventory = self.query::<&Inventory>(&mut ecs);
69        inventory.menu().clone()
70    }
71
72    /// Returns the index of the hotbar slot that's currently selected.
73    ///
74    /// If you want to access the actual held item, you can get the current menu
75    /// with [`Client::menu`] and then get the slot index by offsetting from
76    /// the start of [`azalea_inventory::Menu::hotbar_slots_range`].
77    ///
78    /// You can use [`Self::set_selected_hotbar_slot`] to change it.
79    pub fn selected_hotbar_slot(&self) -> u8 {
80        let mut ecs = self.ecs.lock();
81        let inventory = self.query::<&Inventory>(&mut ecs);
82        inventory.selected_hotbar_slot
83    }
84
85    /// Update the selected hotbar slot index.
86    ///
87    /// This will run next `Update`, so you might want to call
88    /// `bot.wait_updates(1)` after calling this if you're using `azalea`.
89    pub fn set_selected_hotbar_slot(&self, new_hotbar_slot_index: u8) {
90        assert!(
91            new_hotbar_slot_index < 9,
92            "Hotbar slot index must be in the range 0..=8"
93        );
94
95        let mut ecs = self.ecs.lock();
96        ecs.send_event(SetSelectedHotbarSlotEvent {
97            entity: self.entity,
98            slot: new_hotbar_slot_index,
99        });
100    }
101}
102
103/// A component present on all local players that have an inventory.
104#[derive(Component, Debug, Clone)]
105pub struct Inventory {
106    /// The player's inventory menu. This is guaranteed to be a `Menu::Player`.
107    ///
108    /// We keep it as a [`Menu`] since `Menu` has some useful functions that
109    /// bare [`azalea_inventory::Player`] doesn't have.
110    pub inventory_menu: azalea_inventory::Menu,
111
112    /// The ID of the container that's currently open. Its value is not
113    /// guaranteed to be anything specific, and may change every time you open a
114    /// container (unless it's 0, in which case it means that no container is
115    /// open).
116    pub id: i32,
117    /// The current container menu that the player has open. If no container is
118    /// open, this will be `None`.
119    pub container_menu: Option<azalea_inventory::Menu>,
120    /// The custom name of the menu that's currently open. This is Some when
121    /// `container_menu` is Some.
122    pub container_menu_title: Option<FormattedText>,
123    /// The item that is currently held by the cursor. `Slot::Empty` if nothing
124    /// is currently being held.
125    ///
126    /// This is different from [`Self::selected_hotbar_slot`], which is the
127    /// item that's selected in the hotbar.
128    pub carried: ItemStack,
129    /// An identifier used by the server to track client inventory desyncs. This
130    /// is sent on every container click, and it's only ever updated when the
131    /// server sends a new container update.
132    pub state_id: u32,
133
134    pub quick_craft_status: QuickCraftStatusKind,
135    pub quick_craft_kind: QuickCraftKind,
136    /// A set of the indexes of the slots that have been right clicked in
137    /// this "quick craft".
138    pub quick_craft_slots: HashSet<u16>,
139
140    /// The index of the item in the hotbar that's currently being held by the
141    /// player. This MUST be in the range 0..9 (not including 9).
142    ///
143    /// In a vanilla client this is changed by pressing the number keys or using
144    /// the scroll wheel.
145    pub selected_hotbar_slot: u8,
146}
147
148impl Inventory {
149    /// Returns a reference to the currently active menu. If a container is open
150    /// it'll return [`Self::container_menu`], otherwise
151    /// [`Self::inventory_menu`].
152    ///
153    /// Use [`Self::menu_mut`] if you need a mutable reference.
154    pub fn menu(&self) -> &azalea_inventory::Menu {
155        match &self.container_menu {
156            Some(menu) => menu,
157            _ => &self.inventory_menu,
158        }
159    }
160
161    /// Returns a mutable reference to the currently active menu. If a container
162    /// is open it'll return [`Self::container_menu`], otherwise
163    /// [`Self::inventory_menu`].
164    ///
165    /// Use [`Self::menu`] if you don't need a mutable reference.
166    pub fn menu_mut(&mut self) -> &mut azalea_inventory::Menu {
167        match &mut self.container_menu {
168            Some(menu) => menu,
169            _ => &mut self.inventory_menu,
170        }
171    }
172
173    /// Modify the inventory as if the given operation was performed on it.
174    pub fn simulate_click(
175        &mut self,
176        operation: &ClickOperation,
177        player_abilities: &PlayerAbilities,
178    ) {
179        if let ClickOperation::QuickCraft(quick_craft) = operation {
180            let last_quick_craft_status_tmp = self.quick_craft_status.clone();
181            self.quick_craft_status = last_quick_craft_status_tmp.clone();
182            let last_quick_craft_status = last_quick_craft_status_tmp;
183
184            // no carried item, reset
185            if self.carried.is_empty() {
186                return self.reset_quick_craft();
187            }
188            // if we were starting or ending, or now we aren't ending and the status
189            // changed, reset
190            if (last_quick_craft_status == QuickCraftStatusKind::Start
191                || last_quick_craft_status == QuickCraftStatusKind::End
192                || self.quick_craft_status != QuickCraftStatusKind::End)
193                && (self.quick_craft_status != last_quick_craft_status)
194            {
195                return self.reset_quick_craft();
196            }
197            if self.quick_craft_status == QuickCraftStatusKind::Start {
198                self.quick_craft_kind = quick_craft.kind.clone();
199                if self.quick_craft_kind == QuickCraftKind::Middle && player_abilities.instant_break
200                {
201                    self.quick_craft_status = QuickCraftStatusKind::Add;
202                    self.quick_craft_slots.clear();
203                } else {
204                    self.reset_quick_craft();
205                }
206                return;
207            }
208            if let QuickCraftStatus::Add { slot } = quick_craft.status {
209                let slot_item = self.menu().slot(slot as usize);
210                if let Some(slot_item) = slot_item
211                    && let ItemStack::Present(carried) = &self.carried
212                {
213                    // minecraft also checks slot.may_place(carried) and
214                    // menu.can_drag_to(slot)
215                    // but they always return true so they're not relevant for us
216                    if can_item_quick_replace(slot_item, &self.carried, true)
217                        && (self.quick_craft_kind == QuickCraftKind::Right
218                            || carried.count as usize > self.quick_craft_slots.len())
219                    {
220                        self.quick_craft_slots.insert(slot);
221                    }
222                }
223                return;
224            }
225            if self.quick_craft_status == QuickCraftStatusKind::End {
226                if !self.quick_craft_slots.is_empty() {
227                    if self.quick_craft_slots.len() == 1 {
228                        // if we only clicked one slot, then turn this
229                        // QuickCraftClick into a PickupClick
230                        let slot = *self.quick_craft_slots.iter().next().unwrap();
231                        self.reset_quick_craft();
232                        self.simulate_click(
233                            &match self.quick_craft_kind {
234                                QuickCraftKind::Left => {
235                                    PickupClick::Left { slot: Some(slot) }.into()
236                                }
237                                QuickCraftKind::Right => {
238                                    PickupClick::Left { slot: Some(slot) }.into()
239                                }
240                                QuickCraftKind::Middle => {
241                                    // idk just do nothing i guess
242                                    return;
243                                }
244                            },
245                            player_abilities,
246                        );
247                        return;
248                    }
249
250                    let ItemStack::Present(mut carried) = self.carried.clone() else {
251                        // this should never happen
252                        return self.reset_quick_craft();
253                    };
254
255                    let mut carried_count = carried.count;
256                    let mut quick_craft_slots_iter = self.quick_craft_slots.iter();
257
258                    loop {
259                        let mut slot: &ItemStack;
260                        let mut slot_index: u16;
261                        let mut item_stack: &ItemStack;
262
263                        loop {
264                            let Some(&next_slot) = quick_craft_slots_iter.next() else {
265                                carried.count = carried_count;
266                                self.carried = ItemStack::Present(carried);
267                                return self.reset_quick_craft();
268                            };
269
270                            slot = self.menu().slot(next_slot as usize).unwrap();
271                            slot_index = next_slot;
272                            item_stack = &self.carried;
273
274                            if slot.is_present()
275                                    && can_item_quick_replace(slot, item_stack, true)
276                                    // this always returns true in most cases
277                                    // && slot.may_place(item_stack)
278                                    && (
279                                        self.quick_craft_kind == QuickCraftKind::Middle
280                                        || item_stack.count()  >= self.quick_craft_slots.len() as i32
281                                    )
282                            {
283                                break;
284                            }
285                        }
286
287                        // get the ItemStackData for the slot
288                        let ItemStack::Present(slot) = slot else {
289                            unreachable!("the loop above requires the slot to be present to break")
290                        };
291
292                        // if self.can_drag_to(slot) {
293                        let mut new_carried = carried.clone();
294                        let slot_item_count = slot.count;
295                        get_quick_craft_slot_count(
296                            &self.quick_craft_slots,
297                            &self.quick_craft_kind,
298                            &mut new_carried,
299                            slot_item_count,
300                        );
301                        let max_stack_size = i32::min(
302                            new_carried.kind.max_stack_size(),
303                            i32::min(
304                                new_carried.kind.max_stack_size(),
305                                slot.kind.max_stack_size(),
306                            ),
307                        );
308                        if new_carried.count > max_stack_size {
309                            new_carried.count = max_stack_size;
310                        }
311
312                        carried_count -= new_carried.count - slot_item_count;
313                        // we have to inline self.menu_mut() here to avoid the borrow checker
314                        // complaining
315                        let menu = match &mut self.container_menu {
316                            Some(menu) => menu,
317                            _ => &mut self.inventory_menu,
318                        };
319                        *menu.slot_mut(slot_index as usize).unwrap() =
320                            ItemStack::Present(new_carried);
321                    }
322                }
323            } else {
324                return self.reset_quick_craft();
325            }
326        }
327        // the quick craft status should always be in start if we're not in quick craft
328        // mode
329        if self.quick_craft_status != QuickCraftStatusKind::Start {
330            return self.reset_quick_craft();
331        }
332
333        match operation {
334            // left clicking outside inventory
335            ClickOperation::Pickup(PickupClick::Left { slot: None }) => {
336                if self.carried.is_present() {
337                    // vanilla has `player.drop`s but they're only used
338                    // server-side
339                    // they're included as comments here in case you want to adapt this for a server
340                    // implementation
341
342                    // player.drop(self.carried, true);
343                    self.carried = ItemStack::Empty;
344                }
345            }
346            ClickOperation::Pickup(PickupClick::Right { slot: None }) => {
347                if self.carried.is_present() {
348                    let _item = self.carried.split(1);
349                    // player.drop(item, true);
350                }
351            }
352            &ClickOperation::Pickup(
353                // lol
354                ref pickup @ (PickupClick::Left { slot: Some(slot) }
355                | PickupClick::Right { slot: Some(slot) }),
356            ) => {
357                let slot = slot as usize;
358                let Some(slot_item) = self.menu().slot(slot) else {
359                    return;
360                };
361
362                if self.try_item_click_behavior_override(operation, slot) {
363                    return;
364                }
365
366                let is_left_click = matches!(pickup, PickupClick::Left { .. });
367
368                match slot_item {
369                    ItemStack::Empty => {
370                        if self.carried.is_present() {
371                            let place_count = if is_left_click {
372                                self.carried.count()
373                            } else {
374                                1
375                            };
376                            self.carried =
377                                self.safe_insert(slot, self.carried.clone(), place_count);
378                        }
379                    }
380                    ItemStack::Present(_) => {
381                        if !self.menu().may_pickup(slot) {
382                            return;
383                        }
384                        if let ItemStack::Present(carried) = self.carried.clone() {
385                            let slot_is_same_item_as_carried = slot_item
386                                .as_present()
387                                .is_some_and(|s| carried.is_same_item_and_components(s));
388
389                            if self.menu().may_place(slot, &carried) {
390                                if slot_is_same_item_as_carried {
391                                    let place_count = if is_left_click { carried.count } else { 1 };
392                                    self.carried =
393                                        self.safe_insert(slot, self.carried.clone(), place_count);
394                                } else if carried.count
395                                    <= self
396                                        .menu()
397                                        .max_stack_size(slot)
398                                        .min(carried.kind.max_stack_size())
399                                {
400                                    // swap slot_item and carried
401                                    self.carried = slot_item.clone();
402                                    let slot_item = self.menu_mut().slot_mut(slot).unwrap();
403                                    *slot_item = carried.into();
404                                }
405                            } else if slot_is_same_item_as_carried
406                                && let Some(removed) = self.try_remove(
407                                    slot,
408                                    slot_item.count(),
409                                    carried.kind.max_stack_size() - carried.count,
410                                )
411                            {
412                                self.carried.as_present_mut().unwrap().count += removed.count();
413                                // slot.onTake(player, removed);
414                            }
415                        } else {
416                            let pickup_count = if is_left_click {
417                                slot_item.count()
418                            } else {
419                                (slot_item.count() + 1) / 2
420                            };
421                            if let Some(new_slot_item) =
422                                self.try_remove(slot, pickup_count, i32::MAX)
423                            {
424                                self.carried = new_slot_item;
425                                // slot.onTake(player, newSlot);
426                            }
427                        }
428                    }
429                }
430            }
431            &ClickOperation::QuickMove(
432                QuickMoveClick::Left { slot } | QuickMoveClick::Right { slot },
433            ) => {
434                // in vanilla it also tests if QuickMove has a slot index of -999
435                // but i don't think that's ever possible so it's not covered here
436                let slot = slot as usize;
437                loop {
438                    let new_slot_item = self.menu_mut().quick_move_stack(slot);
439                    let slot_item = self.menu().slot(slot).unwrap();
440                    if new_slot_item.is_empty() || slot_item.kind() != new_slot_item.kind() {
441                        break;
442                    }
443                }
444            }
445            ClickOperation::Swap(s) => {
446                let source_slot_index = s.source_slot as usize;
447                let target_slot_index = s.target_slot as usize;
448
449                let Some(source_slot) = self.menu().slot(source_slot_index) else {
450                    return;
451                };
452                let Some(target_slot) = self.menu().slot(target_slot_index) else {
453                    return;
454                };
455                if source_slot.is_empty() && target_slot.is_empty() {
456                    return;
457                }
458
459                if target_slot.is_empty() {
460                    if self.menu().may_pickup(source_slot_index) {
461                        let source_slot = source_slot.clone();
462                        let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
463                        *target_slot = source_slot;
464                    }
465                } else if source_slot.is_empty() {
466                    let target_item = target_slot
467                        .as_present()
468                        .expect("target slot was already checked to not be empty");
469                    if self.menu().may_place(source_slot_index, target_item) {
470                        // get the target_item but mutable
471                        let source_max_stack_size = self.menu().max_stack_size(source_slot_index);
472
473                        let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
474                        let new_source_slot =
475                            target_slot.split(source_max_stack_size.try_into().unwrap());
476                        *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
477                    }
478                } else if self.menu().may_pickup(source_slot_index) {
479                    let ItemStack::Present(target_item) = target_slot else {
480                        unreachable!("target slot is not empty but is not present");
481                    };
482                    if self.menu().may_place(source_slot_index, target_item) {
483                        let source_max_stack = self.menu().max_stack_size(source_slot_index);
484                        if target_slot.count() > source_max_stack {
485                            // if there's more than the max stack size in the target slot
486
487                            let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
488                            let new_source_slot =
489                                target_slot.split(source_max_stack.try_into().unwrap());
490                            *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
491                            // if !self.inventory_menu.add(new_source_slot) {
492                            //     player.drop(new_source_slot, true);
493                            // }
494                        } else {
495                            // normal swap
496                            let new_target_slot = source_slot.clone();
497                            let new_source_slot = target_slot.clone();
498
499                            let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
500                            *target_slot = new_target_slot;
501
502                            let source_slot = self.menu_mut().slot_mut(source_slot_index).unwrap();
503                            *source_slot = new_source_slot;
504                        }
505                    }
506                }
507            }
508            ClickOperation::Clone(CloneClick { slot }) => {
509                if !player_abilities.instant_break || self.carried.is_present() {
510                    return;
511                }
512                let Some(source_slot) = self.menu().slot(*slot as usize) else {
513                    return;
514                };
515                let ItemStack::Present(source_item) = source_slot else {
516                    return;
517                };
518                let mut new_carried = source_item.clone();
519                new_carried.count = new_carried.kind.max_stack_size();
520                self.carried = ItemStack::Present(new_carried);
521            }
522            ClickOperation::Throw(c) => {
523                if self.carried.is_present() {
524                    return;
525                }
526
527                let (ThrowClick::Single { slot: slot_index }
528                | ThrowClick::All { slot: slot_index }) = c;
529                let slot_index = *slot_index as usize;
530
531                let Some(slot) = self.menu_mut().slot_mut(slot_index) else {
532                    return;
533                };
534                let ItemStack::Present(slot_item) = slot else {
535                    return;
536                };
537
538                let dropping_count = match c {
539                    ThrowClick::Single { .. } => 1,
540                    ThrowClick::All { .. } => slot_item.count,
541                };
542
543                let _dropping = slot_item.split(dropping_count as u32);
544                // player.drop(dropping, true);
545            }
546            ClickOperation::PickupAll(PickupAllClick {
547                slot: source_slot_index,
548                reversed,
549            }) => {
550                let source_slot_index = *source_slot_index as usize;
551
552                let source_slot = self.menu().slot(source_slot_index).unwrap();
553                let target_slot = self.carried.clone();
554
555                if target_slot.is_empty()
556                    || (source_slot.is_present() && self.menu().may_pickup(source_slot_index))
557                {
558                    return;
559                }
560
561                let ItemStack::Present(target_slot_item) = &target_slot else {
562                    unreachable!("target slot is not empty but is not present");
563                };
564
565                for round in 0..2 {
566                    let iterator: Box<dyn Iterator<Item = usize>> = if *reversed {
567                        Box::new((0..self.menu().len()).rev())
568                    } else {
569                        Box::new(0..self.menu().len())
570                    };
571
572                    for i in iterator {
573                        if target_slot_item.count < target_slot_item.kind.max_stack_size() {
574                            let checking_slot = self.menu().slot(i).unwrap();
575                            if let ItemStack::Present(checking_item) = checking_slot
576                                && can_item_quick_replace(checking_slot, &target_slot, true)
577                                && self.menu().may_pickup(i)
578                                && (round != 0
579                                    || checking_item.count != checking_item.kind.max_stack_size())
580                            {
581                                // get the checking_slot and checking_item again but mutable
582                                let checking_slot = self.menu_mut().slot_mut(i).unwrap();
583
584                                let taken_item = checking_slot.split(checking_slot.count() as u32);
585
586                                // now extend the carried item
587                                let target_slot = &mut self.carried;
588                                let ItemStack::Present(target_slot_item) = target_slot else {
589                                    unreachable!("target slot is not empty but is not present");
590                                };
591                                target_slot_item.count += taken_item.count();
592                            }
593                        }
594                    }
595                }
596            }
597            _ => {}
598        }
599    }
600
601    fn reset_quick_craft(&mut self) {
602        self.quick_craft_status = QuickCraftStatusKind::Start;
603        self.quick_craft_slots.clear();
604    }
605
606    /// Get the item in the player's hotbar that is currently being held in its
607    /// main hand.
608    pub fn held_item(&self) -> ItemStack {
609        let inventory = &self.inventory_menu;
610        let hotbar_items = &inventory.slots()[inventory.hotbar_slots_range()];
611        hotbar_items[self.selected_hotbar_slot as usize].clone()
612    }
613
614    /// TODO: implement bundles
615    fn try_item_click_behavior_override(
616        &self,
617        _operation: &ClickOperation,
618        _slot_item_index: usize,
619    ) -> bool {
620        false
621    }
622
623    fn safe_insert(&mut self, slot: usize, src_item: ItemStack, take_count: i32) -> ItemStack {
624        let Some(slot_item) = self.menu_mut().slot_mut(slot) else {
625            return src_item;
626        };
627        let ItemStack::Present(mut src_item) = src_item else {
628            return src_item;
629        };
630
631        let take_count = cmp::min(
632            cmp::min(take_count, src_item.count),
633            src_item.kind.max_stack_size() - slot_item.count(),
634        );
635        if take_count <= 0 {
636            return src_item.into();
637        }
638        let take_count = take_count as u32;
639
640        if slot_item.is_empty() {
641            *slot_item = src_item.split(take_count).into();
642        } else if let ItemStack::Present(slot_item) = slot_item
643            && slot_item.is_same_item_and_components(&src_item)
644        {
645            src_item.count -= take_count as i32;
646            slot_item.count += take_count as i32;
647        }
648
649        src_item.into()
650    }
651
652    fn try_remove(&mut self, slot: usize, count: i32, limit: i32) -> Option<ItemStack> {
653        if !self.menu().may_pickup(slot) {
654            return None;
655        }
656        let mut slot_item = self.menu().slot(slot)?.clone();
657        if !self.menu().allow_modification(slot) && limit < slot_item.count() {
658            return None;
659        }
660
661        let count = count.min(limit);
662        if count <= 0 {
663            return None;
664        }
665        // vanilla calls .remove here but i think it has the same behavior as split?
666        let removed = slot_item.split(count as u32);
667
668        if removed.is_present() && slot_item.is_empty() {
669            *self.menu_mut().slot_mut(slot).unwrap() = ItemStack::Empty;
670        }
671
672        Some(removed)
673    }
674}
675
676fn can_item_quick_replace(
677    target_slot: &ItemStack,
678    item: &ItemStack,
679    ignore_item_count: bool,
680) -> bool {
681    let ItemStack::Present(target_slot) = target_slot else {
682        return false;
683    };
684    let ItemStack::Present(item) = item else {
685        // i *think* this is what vanilla does
686        // not 100% sure lol probably doesn't matter though
687        return false;
688    };
689
690    if !item.is_same_item_and_components(target_slot) {
691        return false;
692    }
693    let count = target_slot.count as u16
694        + if ignore_item_count {
695            0
696        } else {
697            item.count as u16
698        };
699    count <= item.kind.max_stack_size() as u16
700}
701
702fn get_quick_craft_slot_count(
703    quick_craft_slots: &HashSet<u16>,
704    quick_craft_kind: &QuickCraftKind,
705    item: &mut ItemStackData,
706    slot_item_count: i32,
707) {
708    item.count = match quick_craft_kind {
709        QuickCraftKind::Left => item.count / quick_craft_slots.len() as i32,
710        QuickCraftKind::Right => 1,
711        QuickCraftKind::Middle => item.kind.max_stack_size(),
712    };
713    item.count += slot_item_count;
714}
715
716impl Default for Inventory {
717    fn default() -> Self {
718        Inventory {
719            inventory_menu: Menu::Player(azalea_inventory::Player::default()),
720            id: 0,
721            container_menu: None,
722            container_menu_title: None,
723            carried: ItemStack::Empty,
724            state_id: 0,
725            quick_craft_status: QuickCraftStatusKind::Start,
726            quick_craft_kind: QuickCraftKind::Middle,
727            quick_craft_slots: HashSet::new(),
728            selected_hotbar_slot: 0,
729        }
730    }
731}
732
733/// A Bevy trigger that's fired when our client should show a new screen (like a
734/// chest or crafting table).
735///
736/// To watch for the menu being closed, you could use
737/// [`ClientsideCloseContainerEvent`]. To close it manually, use
738/// [`CloseContainerEvent`].
739#[derive(Event, Debug, Clone)]
740pub struct MenuOpenedEvent {
741    pub entity: Entity,
742    pub window_id: i32,
743    pub menu_type: MenuKind,
744    pub title: FormattedText,
745}
746fn handle_menu_opened_trigger(event: Trigger<MenuOpenedEvent>, mut query: Query<&mut Inventory>) {
747    let mut inventory = query.get_mut(event.entity).unwrap();
748    inventory.id = event.window_id;
749    inventory.container_menu = Some(Menu::from_kind(event.menu_type));
750    inventory.container_menu_title = Some(event.title.clone());
751}
752pub fn handle_menu_opened_event(mut events: EventReader<MenuOpenedEvent>, mut commands: Commands) {
753    for event in events.read() {
754        commands.trigger(event.clone());
755    }
756}
757
758/// Tell the server that we want to close a container.
759///
760/// Note that this is also sent when the client closes its own inventory, even
761/// though there is no packet for opening its inventory.
762#[derive(Event)]
763pub struct CloseContainerEvent {
764    pub entity: Entity,
765    /// The ID of the container to close. 0 for the player's inventory. If this
766    /// is not the same as the currently open inventory, nothing will happen.
767    pub id: i32,
768}
769fn handle_container_close_event(
770    query: Query<(Entity, &Inventory)>,
771    mut events: EventReader<CloseContainerEvent>,
772    mut client_side_events: EventWriter<ClientsideCloseContainerEvent>,
773    mut commands: Commands,
774) {
775    for event in events.read() {
776        let (entity, inventory) = query.get(event.entity).unwrap();
777        if event.id != inventory.id {
778            warn!(
779                "Tried to close container with ID {}, but the current container ID is {}",
780                event.id, inventory.id
781            );
782            continue;
783        }
784
785        commands.trigger(SendPacketEvent::new(
786            entity,
787            ServerboundContainerClose {
788                container_id: inventory.id,
789            },
790        ));
791        client_side_events.write(ClientsideCloseContainerEvent {
792            entity: event.entity,
793        });
794    }
795}
796
797/// A Bevy trigger that's fired when our client closed a container.
798///
799/// This can also be triggered directly to close a container silently without
800/// sending any packets to the server. You probably don't want that though, and
801/// should instead use [`CloseContainerEvent`].
802///
803/// If you want to watch for a container being opened, you should use
804/// [`MenuOpenedEvent`].
805#[derive(Event, Clone)]
806pub struct ClientsideCloseContainerEvent {
807    pub entity: Entity,
808}
809pub fn handle_client_side_close_container_trigger(
810    event: Trigger<ClientsideCloseContainerEvent>,
811    mut query: Query<&mut Inventory>,
812) {
813    let mut inventory = query.get_mut(event.entity).unwrap();
814
815    // copy the Player part of the container_menu to the inventory_menu
816    if let Some(inventory_menu) = inventory.container_menu.take() {
817        // this isn't the same as what vanilla does. i believe vanilla synchronizes the
818        // slots between inventoryMenu and containerMenu by just having the player slots
819        // point to the same ItemStack in memory, but emulating this in rust would
820        // require us to wrap our `ItemStack`s as `Arc<Mutex<ItemStack>>` which would
821        // have kinda terrible ergonomics.
822
823        // the simpler solution i chose to go with here is to only copy the player slots
824        // when the container is closed. this is perfectly fine for vanilla, but it
825        // might cause issues if a server modifies id 0 while we have a container
826        // open...
827
828        // if we do encounter this issue in the wild then the simplest solution would
829        // probably be to just add logic for updating the container_menu when the server
830        // tries to modify id 0 for slots within `inventory`. not implemented for now
831        // because i'm not sure if that's worth worrying about.
832
833        let new_inventory = inventory_menu.slots()[inventory_menu.player_slots_range()].to_vec();
834        let new_inventory = <[ItemStack; 36]>::try_from(new_inventory).unwrap();
835        *inventory.inventory_menu.as_player_mut().inventory = new_inventory;
836    }
837
838    inventory.id = 0;
839    inventory.container_menu_title = None;
840}
841pub fn handle_client_side_close_container_event(
842    mut commands: Commands,
843    mut events: EventReader<ClientsideCloseContainerEvent>,
844) {
845    for event in events.read() {
846        commands.trigger(event.clone());
847    }
848}
849
850#[derive(Event, Debug)]
851pub struct ContainerClickEvent {
852    pub entity: Entity,
853    pub window_id: i32,
854    pub operation: ClickOperation,
855}
856pub fn handle_container_click_event(
857    mut query: Query<(
858        Entity,
859        &mut Inventory,
860        Option<&PlayerAbilities>,
861        &InstanceName,
862    )>,
863    mut events: EventReader<ContainerClickEvent>,
864    mut commands: Commands,
865    instance_container: Res<InstanceContainer>,
866) {
867    for event in events.read() {
868        let (entity, mut inventory, player_abilities, instance_name) =
869            query.get_mut(event.entity).unwrap();
870        if inventory.id != event.window_id {
871            error!(
872                "Tried to click container with ID {}, but the current container ID is {}. Click packet won't be sent.",
873                event.window_id, inventory.id
874            );
875            continue;
876        }
877
878        let Some(instance) = instance_container.get(instance_name) else {
879            continue;
880        };
881
882        let old_slots = inventory.menu().slots();
883        inventory.simulate_click(
884            &event.operation,
885            player_abilities.unwrap_or(&PlayerAbilities::default()),
886        );
887        let new_slots = inventory.menu().slots();
888
889        let registry_holder = &instance.read().registries;
890
891        // see which slots changed after clicking and put them in the map the server
892        // uses this to check if we desynced
893        let mut changed_slots: IndexMap<u16, HashedStack> = IndexMap::new();
894        for (slot_index, old_slot) in old_slots.iter().enumerate() {
895            let new_slot = &new_slots[slot_index];
896            if old_slot != new_slot {
897                changed_slots.insert(
898                    slot_index as u16,
899                    HashedStack::from_item_stack(new_slot, registry_holder),
900                );
901            }
902        }
903
904        commands.trigger(SendPacketEvent::new(
905            entity,
906            ServerboundContainerClick {
907                container_id: event.window_id,
908                state_id: inventory.state_id,
909                slot_num: event.operation.slot_num().map(|n| n as i16).unwrap_or(-999),
910                button_num: event.operation.button_num(),
911                click_type: event.operation.click_type(),
912                changed_slots,
913                carried_item: HashedStack::from_item_stack(&inventory.carried, registry_holder),
914            },
915        ));
916    }
917}
918
919/// Sent from the server when the contents of a container are replaced.
920///
921/// Usually triggered by the `ContainerSetContent` packet.
922#[derive(Event)]
923pub struct SetContainerContentEvent {
924    pub entity: Entity,
925    pub slots: Vec<ItemStack>,
926    pub container_id: i32,
927}
928pub fn handle_set_container_content_trigger(
929    event: Trigger<SetContainerContentEvent>,
930    mut query: Query<&mut Inventory>,
931) {
932    let mut inventory = query.get_mut(event.entity).unwrap();
933
934    if event.container_id != inventory.id {
935        warn!(
936            "Got SetContainerContentEvent for container with ID {}, but the current container ID is {}",
937            event.container_id, inventory.id
938        );
939        return;
940    }
941
942    let menu = inventory.menu_mut();
943    for (i, slot) in event.slots.iter().enumerate() {
944        if let Some(slot_mut) = menu.slot_mut(i) {
945            *slot_mut = slot.clone();
946        }
947    }
948}
949
950/// An ECS event to switch our hand to a different hotbar slot.
951///
952/// This is equivalent to using the scroll wheel or number keys in Minecraft.
953#[derive(Event)]
954pub struct SetSelectedHotbarSlotEvent {
955    pub entity: Entity,
956    /// The hotbar slot to select. This should be in the range 0..=8.
957    pub slot: u8,
958}
959pub fn handle_set_selected_hotbar_slot_event(
960    mut events: EventReader<SetSelectedHotbarSlotEvent>,
961    mut query: Query<&mut Inventory>,
962) {
963    for event in events.read() {
964        let mut inventory = query.get_mut(event.entity).unwrap();
965
966        // if the slot is already selected, don't send a packet
967        if inventory.selected_hotbar_slot == event.slot {
968            continue;
969        }
970
971        inventory.selected_hotbar_slot = event.slot;
972    }
973}
974
975/// The item slot that the server thinks we have selected.
976///
977/// See [`ensure_has_sent_carried_item`].
978#[derive(Component)]
979pub struct LastSentSelectedHotbarSlot {
980    pub slot: u8,
981}
982/// A system that makes sure that [`LastSentSelectedHotbarSlot`] is in sync with
983/// [`Inventory::selected_hotbar_slot`].
984///
985/// This is necessary to make sure that [`ServerboundSetCarriedItem`] is sent in
986/// the right order, since it's not allowed to happen outside of a tick.
987pub fn ensure_has_sent_carried_item(
988    mut commands: Commands,
989    query: Query<(Entity, &Inventory, Option<&LastSentSelectedHotbarSlot>)>,
990) {
991    for (entity, inventory, last_sent) in query.iter() {
992        if let Some(last_sent) = last_sent {
993            if last_sent.slot == inventory.selected_hotbar_slot {
994                continue;
995            }
996
997            commands.trigger(SendPacketEvent::new(
998                entity,
999                ServerboundSetCarriedItem {
1000                    slot: inventory.selected_hotbar_slot as u16,
1001                },
1002            ));
1003        }
1004
1005        commands.entity(entity).insert(LastSentSelectedHotbarSlot {
1006            slot: inventory.selected_hotbar_slot,
1007        });
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use azalea_registry::Item;
1014
1015    use super::*;
1016
1017    #[test]
1018    fn test_simulate_shift_click_in_crafting_table() {
1019        let spruce_planks = ItemStack::new(Item::SprucePlanks, 4);
1020
1021        let mut inventory = Inventory {
1022            inventory_menu: Menu::Player(azalea_inventory::Player::default()),
1023            id: 1,
1024            container_menu: Some(Menu::Crafting {
1025                result: spruce_planks.clone(),
1026                // simulate_click won't delete the items from here
1027                grid: SlotList::default(),
1028                player: SlotList::default(),
1029            }),
1030            container_menu_title: None,
1031            carried: ItemStack::Empty,
1032            state_id: 0,
1033            quick_craft_status: QuickCraftStatusKind::Start,
1034            quick_craft_kind: QuickCraftKind::Middle,
1035            quick_craft_slots: HashSet::new(),
1036            selected_hotbar_slot: 0,
1037        };
1038
1039        inventory.simulate_click(
1040            &ClickOperation::QuickMove(QuickMoveClick::Left { slot: 0 }),
1041            &PlayerAbilities::default(),
1042        );
1043
1044        let new_slots = inventory.menu().slots();
1045        assert_eq!(&new_slots[0], &ItemStack::Empty);
1046        assert_eq!(
1047            &new_slots[*Menu::CRAFTING_PLAYER_SLOTS.start()],
1048            &spruce_planks
1049        );
1050    }
1051}