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