azalea_client/plugins/
inventory.rs

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