chunkedge_inventory/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::borrow::Cow;
4use std::iter::FusedIterator;
5use std::num::Wrapping;
6use std::ops::Range;
7
8use bevy_app::prelude::*;
9use bevy_ecs::prelude::*;
10use chunkedge_server::client::{Client, FlushPacketsSet, SpawnClientsSet};
11use chunkedge_server::event_loop::{EventLoopPreUpdate, PacketEvent};
12use chunkedge_server::interact_block::InteractBlockEvent;
13pub use chunkedge_server::protocol::packets::play::container_click_c2s::{ClickMode, SlotChange};
14use chunkedge_server::protocol::packets::play::open_screen_s2c::WindowType;
15pub use chunkedge_server::protocol::packets::play::player_action_c2s::PlayerAction;
16use chunkedge_server::protocol::packets::play::{
17    ContainerClickC2s, ContainerCloseC2s, ContainerCloseS2c, ContainerSetContentS2c,
18    ContainerSetSlotS2c, OpenScreenS2c, PlayerActionC2s, SetCarriedItemC2s, SetCreativeModeSlotC2s,
19    SetHeldSlotS2c,
20};
21use chunkedge_server::protocol::{IntoTextComponent, VarInt, WritePacket};
22use chunkedge_server::text::IntoText;
23use chunkedge_server::{GameMode, Hand, ItemKind, ItemStack, Text};
24use derive_more::{Deref, DerefMut};
25use player_inventory::PlayerInventory;
26use tracing::{debug, warn};
27
28pub mod player_inventory;
29mod validate;
30
31pub struct InventoryPlugin;
32
33impl Plugin for InventoryPlugin {
34    fn build(&self, app: &mut bevy_app::App) {
35        app.add_systems(
36            PreUpdate,
37            init_new_client_inventories.after(SpawnClientsSet),
38        )
39        .add_systems(
40            PostUpdate,
41            (
42                update_client_on_close_inventory.before(update_open_inventories),
43                update_player_selected_slot,
44                update_open_inventories,
45                update_player_inventories,
46                update_cursor_item,
47            )
48                .before(FlushPacketsSet),
49        )
50        .add_systems(
51            EventLoopPreUpdate,
52            (
53                handle_update_selected_slot,
54                handle_click_slot,
55                handle_creative_inventory_action,
56                handle_close_handled_screen,
57                handle_player_actions,
58                resync_readonly_inventory_after_block_interaction,
59            ),
60        )
61        .init_resource::<InventorySettings>()
62        .add_event::<ClickSlotEvent>()
63        .add_event::<DropItemStackEvent>()
64        .add_event::<CreativeInventoryActionEvent>()
65        .add_event::<UpdateSelectedSlotEvent>();
66    }
67}
68
69#[derive(Debug, Clone, Component)]
70pub struct Inventory {
71    title: Text,
72    kind: InventoryKind,
73    slots: Box<[ItemStack]>,
74    /// Contains a set bit for each modified slot in `slots`.
75    #[doc(hidden)]
76    pub changed: u64,
77    /// Makes an inventory read-only for clients. This will prevent adding
78    /// or removing items. If this is a player inventory
79    /// This will also make it impossible to drop items while not
80    /// in the inventory (e.g. by pressing Q)
81    pub readonly: bool,
82}
83
84impl Inventory {
85    pub fn new(kind: InventoryKind) -> Self {
86        // TODO: default title to the correct translation key instead
87        Self::with_title(kind, "Inventory")
88    }
89
90    pub fn with_title<'a, T: IntoText<'a>>(kind: InventoryKind, title: T) -> Self {
91        Inventory {
92            title: title.into_cow_text().into_owned(),
93            kind,
94            slots: vec![ItemStack::EMPTY; kind.slot_count()].into(),
95            changed: 0,
96            readonly: false,
97        }
98    }
99
100    #[track_caller]
101    pub fn slot(&self, idx: u16) -> &ItemStack {
102        self.slots
103            .get(idx as usize)
104            .expect("slot index out of range")
105    }
106
107    /// Sets the slot at the given index to the given item stack.
108    ///
109    /// See also [`Inventory::replace_slot`].
110    ///
111    /// ```
112    /// # use chunkedge_inventory::*;
113    /// # use chunkedge_server::{ItemStack, ItemKind};
114    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
115    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
116    /// assert_eq!(inv.slot(0).item, ItemKind::Diamond);
117    /// ```
118    ///
119    /// # Panics
120    /// If the slot index is out of bounds.
121    #[track_caller]
122    #[inline]
123    pub fn set_slot<I: Into<ItemStack>>(&mut self, idx: u16, item: I) {
124        let _ = self.replace_slot(idx, item);
125    }
126
127    /// Replaces the slot at the given index with the given item stack, and
128    /// returns the old stack in that slot.
129    ///
130    /// See also [`Inventory::set_slot`].
131    ///
132    /// ```
133    /// # use chunkedge_inventory::*;
134    /// # use chunkedge_server::{ItemStack, ItemKind};
135    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
136    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
137    /// let old = inv.replace_slot(0, ItemStack::new(ItemKind::IronIngot, 1));
138    /// assert_eq!(old.item, ItemKind::Diamond);
139    /// ```
140    /// # Panics
141    /// If the slot index is out of bounds.
142    #[track_caller]
143    #[must_use]
144    pub fn replace_slot<I: Into<ItemStack>>(&mut self, idx: u16, item: I) -> ItemStack {
145        assert!(idx < self.slot_count(), "slot index of {idx} out of bounds");
146
147        let new = item.into();
148        let old = &mut self.slots[idx as usize];
149
150        if new != *old {
151            self.changed |= 1 << idx;
152        }
153
154        std::mem::replace(old, new)
155    }
156
157    /// Swap the contents of two slots. If the slots are the same, nothing
158    /// happens.
159    ///
160    /// ```
161    /// # use chunkedge_inventory::*;
162    /// # use chunkedge_server::{ItemStack, ItemKind};
163    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
164    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
165    /// assert!(inv.slot(1).is_empty());
166    /// inv.swap_slot(0, 1);
167    /// assert_eq!(inv.slot(1).item, ItemKind::Diamond);
168    /// ```
169    /// # Panics
170    /// If either slot index is out of bounds.
171    #[track_caller]
172    pub fn swap_slot(&mut self, idx_a: u16, idx_b: u16) {
173        assert!(
174            idx_a < self.slot_count(),
175            "slot index of {idx_a} out of bounds"
176        );
177        assert!(
178            idx_b < self.slot_count(),
179            "slot index of {idx_b} out of bounds"
180        );
181
182        if idx_a == idx_b || self.slots[idx_a as usize] == self.slots[idx_b as usize] {
183            // Nothing to do here, ignore.
184            return;
185        }
186
187        self.changed |= 1 << idx_a;
188        self.changed |= 1 << idx_b;
189
190        self.slots.swap(idx_a as usize, idx_b as usize);
191    }
192
193    /// Set the amount of items in the given slot without replacing the slot
194    /// entirely. Valid values are 1-127, inclusive, and `amount` will be
195    /// clamped to this range. If the slot is empty, nothing happens.
196    ///
197    /// ```
198    /// # use chunkedge_inventory::*;
199    /// # use chunkedge_server::{ItemStack, ItemKind};
200    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
201    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
202    /// inv.set_slot_amount(0, 64);
203    /// assert_eq!(inv.slot(0).count, 64);
204    /// ```
205    /// # Panics
206    /// If the slot index is out of bounds.
207    #[track_caller]
208    pub fn set_slot_amount(&mut self, idx: u16, amount: i8) {
209        assert!(idx < self.slot_count(), "slot index out of range");
210
211        let item = &mut self.slots[idx as usize];
212
213        if !item.is_empty() {
214            if item.count == amount {
215                return;
216            }
217            item.count = amount;
218            self.changed |= 1 << idx;
219        }
220    }
221
222    pub fn slot_count(&self) -> u16 {
223        self.slots.len() as u16
224    }
225
226    pub fn slots(
227        &self,
228    ) -> impl ExactSizeIterator<Item = &ItemStack> + DoubleEndedIterator + FusedIterator + Clone + '_
229    {
230        self.slots.iter()
231    }
232
233    pub fn kind(&self) -> InventoryKind {
234        self.kind
235    }
236
237    /// The text displayed on the inventory's title bar.
238    ///
239    /// ```
240    /// # use chunkedge_inventory::*;
241    /// # use chunkedge_server::{ItemStack, ItemKind};
242    /// # use chunkedge_server::text::Text;
243    /// let inv = Inventory::with_title(InventoryKind::Generic9x3, "Box of Holding");
244    /// assert_eq!(inv.title(), &Text::from("Box of Holding"));
245    /// ```
246    pub fn title(&self) -> &Text {
247        &self.title
248    }
249
250    /// Set the text displayed on the inventory's title bar.
251    ///
252    /// To get the old title, use [`Inventory::replace_title`].
253    ///
254    /// ```
255    /// # use chunkedge_inventory::*;
256    /// let mut inv = Inventory::new(InventoryKind::Generic9x3);
257    /// inv.set_title("Box of Holding");
258    /// ```
259    #[inline]
260    pub fn set_title<'a, T: IntoText<'a>>(&mut self, title: T) {
261        let _ = self.replace_title(title);
262    }
263
264    /// Replace the text displayed on the inventory's title bar, and returns the
265    /// old text.
266    #[must_use]
267    pub fn replace_title<'a, T: IntoText<'a>>(&mut self, title: T) -> Text {
268        // TODO: set title modified flag
269        std::mem::replace(&mut self.title, title.into_cow_text().into_owned())
270    }
271
272    pub(crate) fn slot_slice(&self) -> &[ItemStack] {
273        &self.slots
274    }
275
276    /// Returns the first empty slot in the given range, or `None` if there are
277    /// no empty slots in the range.
278    ///
279    /// ```
280    /// # use chunkedge_inventory::*;
281    /// # use chunkedge_server::*;
282    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
283    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
284    /// inv.set_slot(2, ItemStack::new(ItemKind::GoldIngot, 1));
285    /// inv.set_slot(3, ItemStack::new(ItemKind::IronIngot, 1));
286    /// assert_eq!(inv.first_empty_slot_in(0..6), Some(1));
287    /// assert_eq!(inv.first_empty_slot_in(2..6), Some(4));
288    /// ```
289    #[track_caller]
290    #[must_use]
291    pub fn first_empty_slot_in(&self, mut range: Range<u16>) -> Option<u16> {
292        assert!(
293            (0..=self.slot_count()).contains(&range.start)
294                && (0..=self.slot_count()).contains(&range.end),
295            "slot range out of range"
296        );
297
298        range.find(|&idx| self.slots[idx as usize].is_empty())
299    }
300
301    /// Returns the first empty slot in the inventory, or `None` if there are no
302    /// empty slots.
303    /// ```
304    /// # use chunkedge_inventory::*;
305    /// # use chunkedge_server::*;
306    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
307    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
308    /// inv.set_slot(2, ItemStack::new(ItemKind::GoldIngot, 1));
309    /// inv.set_slot(3, ItemStack::new(ItemKind::IronIngot, 1));
310    /// assert_eq!(inv.first_empty_slot(), Some(1));
311    /// ```
312    #[inline]
313    pub fn first_empty_slot(&self) -> Option<u16> {
314        self.first_empty_slot_in(0..self.slot_count())
315    }
316
317    /// Returns the first slot with the given [`ItemKind`] in the inventory
318    /// where `count < stack_max`, or `None` if there are no empty slots.
319    /// ```
320    /// # use chunkedge_inventory::*;
321    /// # use chunkedge_server::*;
322    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
323    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
324    /// inv.set_slot(2, ItemStack::new(ItemKind::GoldIngot, 64));
325    /// inv.set_slot(3, ItemStack::new(ItemKind::IronIngot, 1));
326    /// inv.set_slot(4, ItemStack::new(ItemKind::GoldIngot, 1));
327    /// assert_eq!(
328    ///     inv.first_slot_with_item_in(ItemKind::GoldIngot, 64, 0..5),
329    ///     Some(4)
330    /// );
331    /// ```
332    pub fn first_slot_with_item_in(
333        &self,
334        item: ItemKind,
335        stack_max: i8,
336        mut range: Range<u16>,
337    ) -> Option<u16> {
338        assert!(
339            (0..=self.slot_count()).contains(&range.start)
340                && (0..=self.slot_count()).contains(&range.end),
341            "slot range out of range"
342        );
343        assert!(stack_max > 0, "stack_max must be greater than 0");
344
345        range.find(|&idx| {
346            let stack = &self.slots[idx as usize];
347            stack.item == item && stack.count < stack_max
348        })
349    }
350
351    /// Returns the first slot with the given [`ItemKind`] in the inventory
352    /// where `count < stack_max`, or `None` if there are no empty slots.
353    /// ```
354    /// # use chunkedge_inventory::*;
355    /// # use chunkedge_server::*;
356    /// let mut inv = Inventory::new(InventoryKind::Generic9x1);
357    /// inv.set_slot(0, ItemStack::new(ItemKind::Diamond, 1));
358    /// inv.set_slot(2, ItemStack::new(ItemKind::GoldIngot, 64));
359    /// inv.set_slot(3, ItemStack::new(ItemKind::IronIngot, 1));
360    /// inv.set_slot(4, ItemStack::new(ItemKind::GoldIngot, 1));
361    /// assert_eq!(inv.first_slot_with_item(ItemKind::GoldIngot, 64), Some(4));
362    /// ```
363    #[inline]
364    pub fn first_slot_with_item(&self, item: ItemKind, stack_max: i8) -> Option<u16> {
365        self.first_slot_with_item_in(item, stack_max, 0..self.slot_count())
366    }
367}
368
369/// Miscellaneous inventory data.
370#[derive(Component, Debug)]
371pub struct ClientInventoryState {
372    /// The current window ID. Incremented when inventories are opened.
373    window_id: VarInt,
374    state_id: Wrapping<i32>,
375    /// Tracks what slots have been changed by this client in this tick, so we
376    /// don't need to send updates for them.
377    slots_changed: u64,
378    /// If `Some`: The item the user thinks they updated their cursor item to on
379    /// the last tick.
380    /// If `None`: the user did not update their cursor item in the last tick.
381    /// This is so we can inform the user of the update through change detection
382    /// when they differ in a given tick
383    client_updated_cursor_item: Option<ItemStack>,
384}
385
386impl ClientInventoryState {
387    #[doc(hidden)]
388    pub fn window_id(&self) -> VarInt {
389        self.window_id
390    }
391
392    #[doc(hidden)]
393    pub fn state_id(&self) -> Wrapping<i32> {
394        self.state_id
395    }
396}
397
398/// Indicates which hotbar slot the player is currently holding.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Component, Deref)]
400pub struct HeldItem {
401    held_item_slot: u16,
402}
403
404impl HeldItem {
405    /// The slot ID of the currently held item, in the range 36-44 inclusive.
406    /// This value is safe to use on the player's inventory directly.
407    pub fn slot(&self) -> u16 {
408        self.held_item_slot
409    }
410
411    pub fn hotbar_idx(&self) -> u8 {
412        PlayerInventory::slot_to_hotbar(self.held_item_slot)
413    }
414
415    pub fn set_slot(&mut self, slot: u16) {
416        // temp
417        assert!(
418            PlayerInventory::SLOTS_HOTBAR.contains(&slot),
419            "slot index of {slot} out of bounds"
420        );
421
422        self.held_item_slot = slot;
423    }
424
425    pub fn set_hotbar_idx(&mut self, hotbar_idx: u8) {
426        self.set_slot(PlayerInventory::hotbar_to_slot(hotbar_idx))
427    }
428}
429
430/// The item stack that the client thinks it's holding under the mouse
431/// cursor.
432#[derive(Component, Clone, PartialEq, Default, Debug, Deref, DerefMut)]
433pub struct CursorItem(pub ItemStack);
434
435/// Used to indicate that the client with this component is currently viewing
436/// an inventory.
437#[derive(Component, Clone, Debug)]
438pub struct OpenInventory {
439    /// The entity with the `Inventory` component that the client is currently
440    /// viewing.
441    pub entity: Entity,
442    client_changed: u64,
443}
444
445impl OpenInventory {
446    pub fn new(entity: Entity) -> Self {
447        OpenInventory {
448            entity,
449            client_changed: 0,
450        }
451    }
452}
453
454/// A helper to represent the inventory window that the player is currently
455/// viewing. Handles dispatching reads to the correct inventory.
456///
457/// This is a read-only version of [`InventoryWindowMut`].
458///
459/// ```
460/// # use chunkedge_inventory::*;
461/// # use chunkedge_server::*;
462/// let mut player_inventory = Inventory::new(InventoryKind::Player);
463/// player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 1));
464///
465/// let target_inventory = Inventory::new(InventoryKind::Generic9x3);
466/// let window = InventoryWindow::new(&player_inventory, Some(&target_inventory));
467///
468/// assert_eq!(window.slot(54), &ItemStack::new(ItemKind::Diamond, 1));
469/// ```
470pub struct InventoryWindow<'a> {
471    player_inventory: &'a Inventory,
472    open_inventory: Option<&'a Inventory>,
473}
474
475impl<'a> InventoryWindow<'a> {
476    pub fn new(player_inventory: &'a Inventory, open_inventory: Option<&'a Inventory>) -> Self {
477        Self {
478            player_inventory,
479            open_inventory,
480        }
481    }
482
483    #[track_caller]
484    pub fn slot(&self, idx: u16) -> &ItemStack {
485        if let Some(open_inv) = self.open_inventory.as_ref() {
486            if idx < open_inv.slot_count() {
487                open_inv.slot(idx)
488            } else {
489                self.player_inventory
490                    .slot(convert_to_player_slot_id(open_inv.kind(), idx))
491            }
492        } else {
493            self.player_inventory.slot(idx)
494        }
495    }
496
497    #[track_caller]
498    pub fn slot_count(&self) -> u16 {
499        if let Some(open_inv) = &self.open_inventory {
500            // when the window is split, we can only access the main slots of player's
501            // inventory
502            PlayerInventory::MAIN_SIZE + open_inv.slot_count()
503        } else {
504            self.player_inventory.slot_count()
505        }
506    }
507}
508
509/// A helper to represent the inventory window that the player is currently
510/// viewing. Handles dispatching reads/writes to the correct inventory.
511///
512/// This is a writable version of [`InventoryWindow`].
513///
514/// ```
515/// # use chunkedge_inventory::*;
516/// # use chunkedge_server::*;
517/// let mut player_inventory = Inventory::new(InventoryKind::Player);
518/// let mut target_inventory = Inventory::new(InventoryKind::Generic9x3);
519/// let mut window = InventoryWindowMut::new(&mut player_inventory, Some(&mut target_inventory));
520///
521/// window.set_slot(54, ItemStack::new(ItemKind::Diamond, 1));
522///
523/// assert_eq!(
524///     player_inventory.slot(36),
525///     &ItemStack::new(ItemKind::Diamond, 1)
526/// );
527/// ```
528pub struct InventoryWindowMut<'a> {
529    player_inventory: &'a mut Inventory,
530    open_inventory: Option<&'a mut Inventory>,
531}
532
533impl<'a> InventoryWindowMut<'a> {
534    pub fn new(
535        player_inventory: &'a mut Inventory,
536        open_inventory: Option<&'a mut Inventory>,
537    ) -> Self {
538        Self {
539            player_inventory,
540            open_inventory,
541        }
542    }
543
544    #[track_caller]
545    pub fn slot(&self, idx: u16) -> &ItemStack {
546        if let Some(open_inv) = self.open_inventory.as_ref() {
547            if idx < open_inv.slot_count() {
548                open_inv.slot(idx)
549            } else {
550                self.player_inventory
551                    .slot(convert_to_player_slot_id(open_inv.kind(), idx))
552            }
553        } else {
554            self.player_inventory.slot(idx)
555        }
556    }
557
558    #[track_caller]
559    #[must_use]
560    pub fn replace_slot<I: Into<ItemStack>>(&mut self, idx: u16, item: I) -> ItemStack {
561        assert!(idx < self.slot_count(), "slot index of {idx} out of bounds");
562
563        if let Some(open_inv) = self.open_inventory.as_mut() {
564            if idx < open_inv.slot_count() {
565                open_inv.replace_slot(idx, item)
566            } else {
567                self.player_inventory
568                    .replace_slot(convert_to_player_slot_id(open_inv.kind(), idx), item)
569            }
570        } else {
571            self.player_inventory.replace_slot(idx, item)
572        }
573    }
574
575    #[track_caller]
576    #[inline]
577    pub fn set_slot<I: Into<ItemStack>>(&mut self, idx: u16, item: I) {
578        let _ = self.replace_slot(idx, item);
579    }
580
581    pub fn slot_count(&self) -> u16 {
582        if let Some(open_inv) = &self.open_inventory {
583            // when the window is split, we can only access the main slots of player's
584            // inventory
585            PlayerInventory::MAIN_SIZE + open_inv.slot_count()
586        } else {
587            self.player_inventory.slot_count()
588        }
589    }
590}
591
592/// Attach the necessary inventory components to new clients.
593fn init_new_client_inventories(clients: Query<Entity, Added<Client>>, mut commands: Commands) {
594    for entity in &clients {
595        commands.entity(entity).insert((
596            Inventory::new(InventoryKind::Player),
597            CursorItem(ItemStack::EMPTY),
598            ClientInventoryState {
599                window_id: VarInt(0),
600                state_id: Wrapping(0),
601                slots_changed: 0,
602                client_updated_cursor_item: None,
603            },
604            HeldItem {
605                // First slot of the hotbar.
606                held_item_slot: 36,
607            },
608        ));
609    }
610}
611
612/// Send updates for each client's player inventory.
613fn update_player_inventories(
614    mut query: Query<
615        (
616            &mut Inventory,
617            &mut Client,
618            &mut ClientInventoryState,
619            &CursorItem,
620        ),
621        Without<OpenInventory>,
622    >,
623) {
624    for (mut inventory, mut client, mut inv_state, cursor_item) in &mut query {
625        if inventory.kind != InventoryKind::Player {
626            warn!("Inventory on client entity is not a player inventory");
627        }
628
629        if inventory.changed == u64::MAX {
630            // Update the whole inventory.
631
632            inv_state.state_id += 1;
633
634            client.write_packet(&ContainerSetContentS2c {
635                window_id: VarInt(0),
636                state_id: VarInt(inv_state.state_id.0),
637                slots: Cow::Borrowed(inventory.slot_slice()),
638                carried_item: Cow::Borrowed(&cursor_item.0),
639            });
640
641            inventory.changed = 0;
642            inv_state.slots_changed = 0;
643            // Skip updating the cursor item because we just updated the whole
644            // inventory.
645        } else if inventory.changed != 0 {
646            // Send the modified slots.
647
648            // The slots that were NOT modified by this client, and they need to be sent
649            let changed_filtered = inventory.changed & !inv_state.slots_changed;
650
651            if changed_filtered != 0 {
652                inv_state.state_id += 1;
653
654                for (i, slot) in inventory.slots.iter().enumerate() {
655                    if ((changed_filtered >> i) & 1) == 1 {
656                        client.write_packet(&ContainerSetSlotS2c {
657                            window_id: VarInt(0),
658                            state_id: VarInt(inv_state.state_id.0),
659                            slot_idx: i as i16,
660                            slot_data: Cow::Borrowed(slot),
661                        });
662                    }
663                }
664            }
665
666            inventory.changed = 0;
667            inv_state.slots_changed = 0;
668        }
669    }
670}
671
672/// Handles the `OpenInventory` component being added to a client, which
673/// indicates that the client is now viewing an inventory, and sends inventory
674/// updates to the client when the inventory is modified.
675fn update_open_inventories(
676    mut clients: Query<(
677        Entity,
678        &mut Client,
679        &mut ClientInventoryState,
680        &CursorItem,
681        &mut OpenInventory,
682    )>,
683    mut inventories: Query<&mut Inventory>,
684    mut commands: Commands,
685) {
686    // These operations need to happen in this order.
687
688    // Send the inventory contents to all clients that are viewing an inventory.
689    for (client_entity, mut client, mut inv_state, cursor_item, mut open_inventory) in &mut clients
690    {
691        // Validate that the inventory exists.
692        let Ok([inventory, player_inventory]) =
693            inventories.get_many_mut([open_inventory.entity, client_entity])
694        else {
695            // The inventory no longer exists, so close the inventory.
696            commands.entity(client_entity).remove::<OpenInventory>();
697
698            client.write_packet(&ContainerCloseS2c {
699                window_id: inv_state.window_id,
700            });
701
702            continue;
703        };
704
705        if open_inventory.is_added() {
706            // Send the inventory to the client if the client just opened the inventory.
707            inv_state.window_id = VarInt(inv_state.window_id.0 % 100 + 1);
708            open_inventory.client_changed = 0;
709
710            client.write_packet(&OpenScreenS2c {
711                window_id: inv_state.window_id,
712                window_type: WindowType::from(inventory.kind),
713                window_title: (&inventory.title).into_cow_text_component(),
714            });
715
716            client.write_packet(&ContainerSetContentS2c {
717                window_id: inv_state.window_id,
718                state_id: VarInt(inv_state.state_id.0),
719                slots: Cow::Borrowed(inventory.slot_slice()),
720                carried_item: Cow::Borrowed(&cursor_item.0),
721            });
722        } else {
723            // The client is already viewing the inventory.
724
725            if inventory.changed == u64::MAX {
726                // Send the entire inventory.
727
728                inv_state.state_id += 1;
729
730                client.write_packet(&ContainerSetContentS2c {
731                    window_id: inv_state.window_id,
732                    state_id: VarInt(inv_state.state_id.0),
733                    slots: Cow::Borrowed(inventory.slot_slice()),
734                    carried_item: Cow::Borrowed(&cursor_item.0),
735                })
736            } else {
737                // Send the changed slots.
738
739                // The slots that were NOT changed by this client, and they need to be sent.
740                let changed_filtered =
741                    u128::from(inventory.changed & !open_inventory.client_changed);
742
743                // The slots changed in the player inventory (e.g by calling
744                // `inventory.set_slot` while the player is viewing the inventory).
745                let mut player_inventory_changed = u128::from(player_inventory.changed);
746
747                // Ignore the armor and crafting grid slots because they are not part of
748                // the open inventory.
749                player_inventory_changed >>= *PlayerInventory::SLOTS_MAIN.start();
750                // "Append" the player inventory to the end of the slots belonging to the opened
751                // inventory.
752                player_inventory_changed <<= inventory.slot_count();
753
754                let changed_filtered = changed_filtered | player_inventory_changed;
755
756                if changed_filtered != 0 {
757                    for (i, slot) in inventory
758                        .slots
759                        .iter()
760                        .chain(
761                            player_inventory
762                                .slots
763                                .iter()
764                                .skip(*PlayerInventory::SLOTS_MAIN.start() as usize),
765                        )
766                        .enumerate()
767                    {
768                        if (changed_filtered >> i) & 1 == 1 {
769                            client.write_packet(&ContainerSetSlotS2c {
770                                window_id: inv_state.window_id,
771                                state_id: VarInt(inv_state.state_id.0),
772                                slot_idx: i as i16,
773                                slot_data: Cow::Borrowed(slot),
774                            });
775                        }
776                    }
777
778                    player_inventory
779                        .map_unchanged(|f| &mut f.changed)
780                        .set_if_neq(0);
781                }
782            }
783        }
784        // Since these happen every gametick we only want to trigger change detection
785        // if we actually did update these. Otherwise systems that are
786        // running looking for changes to the `Inventory`,`ClientInventoryState`
787        // or `OpenInventory` components get unneccerely ran each gametick
788        open_inventory
789            .map_unchanged(|f| &mut f.client_changed)
790            .set_if_neq(0);
791        inv_state
792            .map_unchanged(|f| &mut f.slots_changed)
793            .set_if_neq(0);
794        inventory.map_unchanged(|f| &mut f.changed).set_if_neq(0);
795    }
796}
797
798fn update_cursor_item(
799    mut clients: Query<(&mut Client, &mut ClientInventoryState, &CursorItem), Changed<CursorItem>>,
800) {
801    for (mut client, inv_state, cursor_item) in &mut clients {
802        // The cursor item was not the item the user themselves interacted with
803        if inv_state.client_updated_cursor_item.as_ref() != Some(&cursor_item.0) {
804            // Contrary to what you might think, we actually don't want to increment the
805            // state ID here because the client doesn't actually acknowledge the
806            // state_id change for this packet specifically. See #304.
807            client.write_packet(&ContainerSetSlotS2c {
808                window_id: VarInt(-1),
809                state_id: VarInt(inv_state.state_id.0),
810                slot_idx: -1,
811                slot_data: Cow::Borrowed(&cursor_item.0),
812            });
813        }
814
815        inv_state
816            .map_unchanged(|f| &mut f.client_updated_cursor_item)
817            .set_if_neq(None);
818    }
819}
820
821/// Handles clients telling the server that they are closing an inventory.
822fn handle_close_handled_screen(mut packets: EventReader<PacketEvent>, mut commands: Commands) {
823    for packet in packets.read() {
824        if packet.decode::<ContainerCloseC2s>().is_some() {
825            if let Some(mut entity) = commands.get_entity(packet.client) {
826                entity.remove::<OpenInventory>();
827            }
828        }
829    }
830}
831
832/// Detects when a client's `OpenInventory` component is removed, which
833/// indicates that the client is no longer viewing an inventory.
834fn update_client_on_close_inventory(
835    mut removals: RemovedComponents<OpenInventory>,
836    mut clients: Query<(&mut Client, &ClientInventoryState)>,
837) {
838    for entity in &mut removals.read() {
839        if let Ok((mut client, inv_state)) = clients.get_mut(entity) {
840            client.write_packet(&ContainerCloseS2c {
841                window_id: inv_state.window_id,
842            })
843        }
844    }
845}
846
847// TODO: make this event user friendly.
848#[derive(Event, Clone, Debug)]
849pub struct ClickSlotEvent {
850    pub client: Entity,
851    pub window_id: VarInt,
852    pub state_id: i32,
853    pub slot_id: i16,
854    pub button: i8,
855    pub mode: ClickMode,
856    pub slot_changes: Vec<SlotChange>,
857    pub carried_item: ItemStack,
858}
859
860#[derive(Event, Clone, Debug)]
861pub struct DropItemStackEvent {
862    pub client: Entity,
863    pub from_slot: Option<u16>,
864    pub stack: ItemStack,
865}
866
867fn handle_click_slot(
868    mut packets: EventReader<PacketEvent>,
869    mut clients: Query<(
870        &mut Client,
871        &mut Inventory,
872        &mut ClientInventoryState,
873        Option<&mut OpenInventory>,
874        &mut CursorItem,
875    )>,
876    mut inventories: Query<&mut Inventory, Without<Client>>,
877    mut drop_item_stack_events: EventWriter<DropItemStackEvent>,
878    mut click_slot_events: EventWriter<ClickSlotEvent>,
879) {
880    for packet in packets.read() {
881        let Some(pkt) = packet.decode::<ContainerClickC2s>() else {
882            // Not the packet we're looking for.
883            continue;
884        };
885
886        let Ok((mut client, mut client_inv, mut inv_state, open_inventory, mut cursor_item)) =
887            clients.get_mut(packet.client)
888        else {
889            // The client does not exist, ignore.
890            continue;
891        };
892
893        let open_inv = open_inventory
894            .as_ref()
895            .and_then(|open| inventories.get_mut(open.entity).ok());
896
897        let (new_cursor_item, new_slot_changes) = match validate::validate_click_slot_packet(
898            &pkt,
899            &client_inv,
900            open_inv.as_deref(),
901            &cursor_item,
902        ) {
903            Ok(cursor_item) => cursor_item,
904            Err(e) => {
905                debug!(
906                    "failed to validate click slot packet for client {:#?}: \"{e:#}\"
907            {pkt:#?}",
908                    packet.client
909                );
910
911                // Resync the inventory.
912                client.write_packet(&ContainerSetContentS2c {
913                    window_id: if open_inv.is_some() {
914                        inv_state.window_id
915                    } else {
916                        VarInt(0)
917                    },
918                    state_id: VarInt(inv_state.state_id.0),
919                    slots: Cow::Borrowed(open_inv.unwrap_or(client_inv).slot_slice()),
920                    carried_item: Cow::Borrowed(&cursor_item.0),
921                });
922
923                continue;
924            }
925        };
926
927        if pkt.slot_idx == PlayerInventory::SLOT_OUTSIDE_INVENTORY && pkt.mode == ClickMode::Click {
928            // The client is dropping the cursor item by clicking outside the window.
929
930            let stack = std::mem::take(&mut cursor_item.0);
931
932            if !stack.is_empty() {
933                drop_item_stack_events.send(DropItemStackEvent {
934                    client: packet.client,
935                    from_slot: None,
936                    stack,
937                });
938            }
939        } else if pkt.mode == ClickMode::DropKey {
940            // The client is dropping an item by pressing the drop key.
941
942            let entire_stack = pkt.button == 1;
943
944            // Needs to open the inventory for if the player is dropping an item while
945            // having an inventory open.
946            if let Some(open_inventory) = open_inventory {
947                // The player is interacting with an inventory that is open.
948
949                let Ok(mut target_inventory) = inventories.get_mut(open_inventory.entity) else {
950                    // The inventory does not exist, ignore.
951                    continue;
952                };
953
954                if inv_state.state_id.0 != pkt.state_id.0 {
955                    // Client is out of sync. Resync and ignore click.
956
957                    debug!("Client state id mismatch, resyncing");
958
959                    inv_state.state_id += 1;
960
961                    client.write_packet(&ContainerSetContentS2c {
962                        window_id: inv_state.window_id,
963                        state_id: VarInt(inv_state.state_id.0),
964                        slots: Cow::Borrowed(target_inventory.slot_slice()),
965                        carried_item: Cow::Borrowed(&cursor_item.0),
966                    });
967
968                    continue;
969                }
970                if pkt.slot_idx == PlayerInventory::SLOT_OUTSIDE_INVENTORY {
971                    // The player was just clicking outside the inventories without holding an item
972                    continue;
973                }
974
975                if pkt.slot_idx == PlayerInventory::SLOT_OUTSIDE_INVENTORY {
976                    // The player was just clicking outside the inventories without holding an item
977                    continue;
978                }
979
980                if (0_i16..target_inventory.slot_count() as i16).contains(&pkt.slot_idx) {
981                    // The player is dropping an item from another inventory.
982
983                    if target_inventory.readonly {
984                        // resync target inventory
985                        client.write_packet(&ContainerSetContentS2c {
986                            window_id: inv_state.window_id,
987                            state_id: VarInt(inv_state.state_id.0),
988                            slots: Cow::Borrowed(target_inventory.slot_slice()),
989                            carried_item: Cow::Borrowed(&cursor_item.0),
990                        });
991                        continue;
992                    }
993
994                    let stack = target_inventory.slot(pkt.slot_idx as u16);
995
996                    if !stack.is_empty() {
997                        let dropped = if entire_stack || stack.count == 1 {
998                            target_inventory.replace_slot(pkt.slot_idx as u16, ItemStack::EMPTY)
999                        } else {
1000                            let stack = stack.clone().with_count(stack.count - 1);
1001                            let mut old_slot =
1002                                target_inventory.replace_slot(pkt.slot_idx as u16, stack);
1003                            // we already checked that the slot was not empty and that the
1004                            // stack count is > 1
1005                            old_slot.count = 1;
1006                            old_slot
1007                        };
1008
1009                        drop_item_stack_events.send(DropItemStackEvent {
1010                            client: packet.client,
1011                            from_slot: Some(pkt.slot_idx as u16),
1012                            stack: dropped,
1013                        });
1014                    }
1015                } else {
1016                    // The player is dropping an item from their inventory.
1017
1018                    if client_inv.readonly {
1019                        // resync the client inventory
1020                        client.write_packet(&ContainerSetContentS2c {
1021                            window_id: VarInt(0),
1022                            state_id: VarInt(inv_state.state_id.0),
1023                            slots: Cow::Borrowed(client_inv.slot_slice()),
1024                            carried_item: Cow::Borrowed(&cursor_item.0),
1025                        });
1026                        continue;
1027                    }
1028
1029                    let slot_id =
1030                        convert_to_player_slot_id(target_inventory.kind, pkt.slot_idx as u16);
1031
1032                    let stack = client_inv.slot(slot_id);
1033
1034                    if !stack.is_empty() {
1035                        let dropped = if entire_stack || stack.count == 1 {
1036                            client_inv.replace_slot(slot_id, ItemStack::EMPTY)
1037                        } else {
1038                            let stack = stack.clone().with_count(stack.count - 1);
1039                            let mut old_slot = client_inv.replace_slot(slot_id, stack);
1040                            // we already checked that the slot was not empty and that the
1041                            // stack count is > 1
1042                            old_slot.count = 1;
1043                            old_slot
1044                        };
1045
1046                        drop_item_stack_events.send(DropItemStackEvent {
1047                            client: packet.client,
1048                            from_slot: Some(slot_id),
1049                            stack: dropped,
1050                        });
1051                    }
1052                }
1053            } else {
1054                // The player has no inventory open and is dropping an item from their
1055                // inventory.
1056
1057                if client_inv.readonly {
1058                    // resync the client inventory
1059                    client.write_packet(&ContainerSetContentS2c {
1060                        window_id: VarInt(0),
1061                        state_id: VarInt(inv_state.state_id.0),
1062                        slots: Cow::Borrowed(client_inv.slot_slice()),
1063                        carried_item: Cow::Borrowed(&cursor_item.0),
1064                    });
1065                    continue;
1066                }
1067                if pkt.slot_idx == PlayerInventory::SLOT_OUTSIDE_INVENTORY {
1068                    // The player was just clicking outside the inventories without holding an item
1069                    continue;
1070                }
1071
1072                if pkt.slot_idx == PlayerInventory::SLOT_OUTSIDE_INVENTORY {
1073                    // The player was just clicking outside the inventories without holding an item
1074                    continue;
1075                }
1076
1077                let stack = client_inv.slot(pkt.slot_idx as u16);
1078
1079                if !stack.is_empty() {
1080                    let dropped = if entire_stack || stack.count == 1 {
1081                        client_inv.replace_slot(pkt.slot_idx as u16, ItemStack::EMPTY)
1082                    } else {
1083                        let stack = stack.clone().with_count(stack.count - 1);
1084                        let mut old_slot = client_inv.replace_slot(pkt.slot_idx as u16, stack);
1085                        // we already checked that the slot was not empty and that the
1086                        // stack count is > 1
1087                        old_slot.count = 1;
1088                        old_slot
1089                    };
1090
1091                    drop_item_stack_events.send(DropItemStackEvent {
1092                        client: packet.client,
1093                        from_slot: Some(pkt.slot_idx as u16),
1094                        stack: dropped,
1095                    });
1096                }
1097            }
1098        } else {
1099            // The player is clicking a slot in an inventory.
1100
1101            // Validate the window id.
1102            if (pkt.window_id.0 == 0) != open_inventory.is_none() {
1103                warn!(
1104                    "Client sent a click with an invalid window id for current state: window_id = \
1105                     {}, open_inventory present = {}",
1106                    pkt.window_id.0,
1107                    open_inventory.is_some()
1108                );
1109                continue;
1110            }
1111
1112            if let Some(mut open_inventory) = open_inventory {
1113                // The player is interacting with an inventory that is
1114                // open or has an inventory open while interacting with their own inventory.
1115
1116                let Ok(mut target_inventory) = inventories.get_mut(open_inventory.entity) else {
1117                    // The inventory does not exist, ignore.
1118                    continue;
1119                };
1120
1121                if inv_state.state_id.0 != pkt.state_id.0 {
1122                    // Client is out of sync. Resync and ignore click.
1123
1124                    debug!("Client state id mismatch, resyncing");
1125
1126                    inv_state.state_id += 1;
1127
1128                    client.write_packet(&ContainerSetContentS2c {
1129                        window_id: inv_state.window_id,
1130                        state_id: VarInt(inv_state.state_id.0),
1131                        slots: Cow::Borrowed(target_inventory.slot_slice()),
1132                        carried_item: Cow::Borrowed(&cursor_item.0),
1133                    });
1134
1135                    continue;
1136                }
1137
1138                // Set the cursor based on what the validation returned
1139                let mut new_cursor = new_cursor_item.clone();
1140
1141                for slot in &new_slot_changes {
1142                    let transferred_between_inventories =
1143                        ((0_i16..target_inventory.slot_count() as i16).contains(&pkt.slot_idx)
1144                            && pkt.mode == ClickMode::Hotbar)
1145                            || pkt.mode == ClickMode::ShiftClick;
1146
1147                    if (0_i16..target_inventory.slot_count() as i16).contains(&slot.idx) {
1148                        if (client_inv.readonly && transferred_between_inventories)
1149                            || target_inventory.readonly
1150                        {
1151                            new_cursor = cursor_item.0.clone();
1152                            continue;
1153                        }
1154
1155                        target_inventory.set_slot(slot.idx as u16, slot.stack.clone());
1156                        open_inventory.client_changed |= 1 << slot.idx;
1157                    } else {
1158                        if (target_inventory.readonly && transferred_between_inventories)
1159                            || client_inv.readonly
1160                        {
1161                            new_cursor = cursor_item.0.clone();
1162                            continue;
1163                        }
1164
1165                        // The client is interacting with a slot in their own inventory.
1166                        let slot_id =
1167                            convert_to_player_slot_id(target_inventory.kind, slot.idx as u16);
1168                        client_inv.set_slot(slot_id, slot.stack.clone());
1169                        inv_state.slots_changed |= 1 << slot_id;
1170                    }
1171                }
1172
1173                cursor_item.set_if_neq(CursorItem(new_cursor.clone()));
1174                inv_state.client_updated_cursor_item = Some(new_cursor);
1175
1176                if target_inventory.readonly || client_inv.readonly {
1177                    // resync the target inventory
1178                    client.write_packet(&ContainerSetContentS2c {
1179                        window_id: inv_state.window_id,
1180                        state_id: VarInt(inv_state.state_id.0),
1181                        slots: Cow::Borrowed(target_inventory.slot_slice()),
1182                        carried_item: Cow::Borrowed(&cursor_item.0),
1183                    });
1184
1185                    // resync the client inventory
1186                    client.write_packet(&ContainerSetContentS2c {
1187                        window_id: VarInt(0),
1188                        state_id: VarInt(inv_state.state_id.0),
1189                        slots: Cow::Borrowed(client_inv.slot_slice()),
1190                        carried_item: Cow::Borrowed(&cursor_item.0),
1191                    });
1192                }
1193            } else {
1194                // The client is interacting with their own inventory.
1195
1196                if inv_state.state_id.0 != pkt.state_id.0 {
1197                    // Client is out of sync. Resync and ignore the click.
1198
1199                    debug!("Client state id mismatch, resyncing");
1200
1201                    inv_state.state_id += 1;
1202
1203                    client.write_packet(&ContainerSetContentS2c {
1204                        window_id: inv_state.window_id,
1205                        state_id: VarInt(inv_state.state_id.0),
1206                        slots: Cow::Borrowed(client_inv.slot_slice()),
1207                        carried_item: Cow::Borrowed(&cursor_item.0),
1208                    });
1209
1210                    continue;
1211                }
1212
1213                let mut new_cursor = new_cursor_item.clone();
1214
1215                for slot in &new_slot_changes {
1216                    if (0_i16..client_inv.slot_count() as i16).contains(&slot.idx) {
1217                        if client_inv.readonly {
1218                            new_cursor = cursor_item.0.clone();
1219                            continue;
1220                        }
1221                        client_inv.set_slot(slot.idx as u16, slot.stack.clone());
1222                        inv_state.slots_changed |= 1 << slot.idx;
1223                    } else {
1224                        // The client is trying to interact with a slot that does not exist,
1225                        // ignore.
1226                        warn!(
1227                            "Client attempted to interact with slot {} which does not exist",
1228                            slot.idx
1229                        );
1230                    }
1231                }
1232
1233                cursor_item.set_if_neq(CursorItem(new_cursor.clone()));
1234                inv_state.client_updated_cursor_item = Some(new_cursor);
1235
1236                if client_inv.readonly {
1237                    // resync the client inventory
1238                    client.write_packet(&ContainerSetContentS2c {
1239                        window_id: VarInt(0),
1240                        state_id: VarInt(inv_state.state_id.0),
1241                        slots: Cow::Borrowed(client_inv.slot_slice()),
1242                        carried_item: Cow::Borrowed(&cursor_item.0),
1243                    });
1244                }
1245            }
1246
1247            click_slot_events.send(ClickSlotEvent {
1248                client: packet.client,
1249                window_id: pkt.window_id,
1250                state_id: pkt.state_id.0,
1251                slot_id: pkt.slot_idx,
1252                button: pkt.button,
1253                mode: pkt.mode,
1254                slot_changes: new_slot_changes,
1255                carried_item: new_cursor_item,
1256            });
1257        }
1258    }
1259}
1260
1261fn handle_player_actions(
1262    mut packets: EventReader<PacketEvent>,
1263    mut clients: Query<(
1264        &mut Inventory,
1265        &mut ClientInventoryState,
1266        &HeldItem,
1267        &mut Client,
1268    )>,
1269    mut drop_item_stack_events: EventWriter<DropItemStackEvent>,
1270) {
1271    for packet in packets.read() {
1272        if let Some(pkt) = packet.decode::<PlayerActionC2s>() {
1273            match pkt.action {
1274                PlayerAction::DropAllItems => {
1275                    if let Ok((mut inv, mut inv_state, &held, mut client)) =
1276                        clients.get_mut(packet.client)
1277                    {
1278                        if inv.readonly {
1279                            // resync the client inventory
1280                            client.write_packet(&ContainerSetContentS2c {
1281                                window_id: VarInt(0),
1282                                state_id: VarInt(inv_state.state_id.0),
1283                                slots: Cow::Borrowed(inv.slot_slice()),
1284                                carried_item: Cow::Borrowed(&ItemStack::EMPTY),
1285                            });
1286                            continue;
1287                        }
1288
1289                        let stack = inv.replace_slot(held.slot(), ItemStack::EMPTY);
1290
1291                        if !stack.is_empty() {
1292                            inv_state.slots_changed |= 1 << held.slot();
1293
1294                            drop_item_stack_events.send(DropItemStackEvent {
1295                                client: packet.client,
1296                                from_slot: Some(held.slot()),
1297                                stack,
1298                            });
1299                        }
1300                    }
1301                }
1302                PlayerAction::DropItem => {
1303                    if let Ok((mut inv, mut inv_state, held, mut client)) =
1304                        clients.get_mut(packet.client)
1305                    {
1306                        if inv.readonly {
1307                            // resync the client inventory
1308                            client.write_packet(&ContainerSetContentS2c {
1309                                window_id: VarInt(0),
1310                                state_id: VarInt(inv_state.state_id.0),
1311                                slots: Cow::Borrowed(inv.slot_slice()),
1312                                carried_item: Cow::Borrowed(&ItemStack::EMPTY),
1313                            });
1314                            continue;
1315                        }
1316
1317                        let mut stack = inv.replace_slot(held.slot(), ItemStack::EMPTY);
1318
1319                        if !stack.is_empty() {
1320                            if stack.count > 1 {
1321                                inv.set_slot(
1322                                    held.slot(),
1323                                    stack.clone().with_count(stack.count - 1),
1324                                );
1325
1326                                stack.count = 1;
1327                            }
1328
1329                            inv_state.slots_changed |= 1 << held.slot();
1330
1331                            drop_item_stack_events.send(DropItemStackEvent {
1332                                client: packet.client,
1333                                from_slot: Some(held.slot()),
1334                                stack,
1335                            });
1336                        }
1337                    }
1338                }
1339                PlayerAction::SwapItemWithOffhand => {
1340                    if let Ok((mut inv, inv_state, held, mut client)) =
1341                        clients.get_mut(packet.client)
1342                    {
1343                        // this check here might not actually be necessary
1344                        if inv.readonly {
1345                            // resync the client inventory
1346                            client.write_packet(&ContainerSetContentS2c {
1347                                window_id: VarInt(0),
1348                                state_id: VarInt(inv_state.state_id.0),
1349                                slots: Cow::Borrowed(inv.slot_slice()),
1350                                carried_item: Cow::Borrowed(&ItemStack::EMPTY),
1351                            });
1352                            continue;
1353                        }
1354
1355                        inv.swap_slot(held.slot(), PlayerInventory::SLOT_OFFHAND);
1356                    }
1357                }
1358                _ => {}
1359            }
1360        }
1361    }
1362}
1363
1364/// If the player tries to place a block while their inventory is readonly
1365/// it will be desynced, therefore we set the slot as changed.
1366fn resync_readonly_inventory_after_block_interaction(
1367    mut clients: Query<(&mut Inventory, &HeldItem)>,
1368    mut events: EventReader<InteractBlockEvent>,
1369) {
1370    for event in events.read() {
1371        let Ok((mut inventory, held_item)) = clients.get_mut(event.client) else {
1372            continue;
1373        };
1374
1375        if !inventory.readonly {
1376            continue;
1377        }
1378
1379        let slot = if event.hand == Hand::Main {
1380            held_item.slot()
1381        } else {
1382            PlayerInventory::SLOT_OFFHAND
1383        };
1384
1385        if inventory.slot(slot).is_empty() {
1386            continue;
1387        }
1388
1389        inventory.changed |= 1 << slot;
1390    }
1391}
1392
1393// TODO: make this event user friendly.
1394#[derive(Event, Clone, Debug)]
1395pub struct CreativeInventoryActionEvent {
1396    pub client: Entity,
1397    pub slot: i16,
1398    pub clicked_item: ItemStack,
1399}
1400
1401fn handle_creative_inventory_action(
1402    mut packets: EventReader<PacketEvent>,
1403    mut clients: Query<(
1404        &mut Client,
1405        &mut Inventory,
1406        &mut ClientInventoryState,
1407        &GameMode,
1408    )>,
1409    mut inv_action_events: EventWriter<CreativeInventoryActionEvent>,
1410    mut drop_item_stack_events: EventWriter<DropItemStackEvent>,
1411) {
1412    for packet in packets.read() {
1413        if let Some(pkt) = packet.decode::<SetCreativeModeSlotC2s>() {
1414            let Ok((mut client, mut inventory, mut inv_state, game_mode)) =
1415                clients.get_mut(packet.client)
1416            else {
1417                continue;
1418            };
1419
1420            if *game_mode != GameMode::Creative {
1421                // The client is not in creative mode, ignore.
1422                continue;
1423            }
1424
1425            if pkt.slot == -1 {
1426                let stack = pkt.clicked_item.clone();
1427
1428                if !stack.is_empty() {
1429                    drop_item_stack_events.send(DropItemStackEvent {
1430                        client: packet.client,
1431                        from_slot: None,
1432                        stack,
1433                    });
1434                }
1435                continue;
1436            }
1437
1438            if pkt.slot < 0 || pkt.slot >= inventory.slot_count() as i16 {
1439                // The client is trying to interact with a slot that does not exist, ignore.
1440                continue;
1441            }
1442
1443            // Set the slot without marking it as changed.
1444            inventory.slots[pkt.slot as usize] = pkt.clicked_item.clone();
1445
1446            inv_state.state_id += 1;
1447
1448            // HACK: notchian clients rely on the server to send the slot update when in
1449            // creative mode. Simply marking the slot as changed is not enough. This was
1450            // discovered because shift-clicking the destroy item slot in creative mode does
1451            // not work without this hack.
1452            client.write_packet(&ContainerSetSlotS2c {
1453                window_id: VarInt(0),
1454                state_id: VarInt(inv_state.state_id.0),
1455                slot_idx: pkt.slot,
1456                slot_data: Cow::Borrowed(&pkt.clicked_item),
1457            });
1458
1459            inv_action_events.send(CreativeInventoryActionEvent {
1460                client: packet.client,
1461                slot: pkt.slot,
1462                clicked_item: pkt.clicked_item,
1463            });
1464        }
1465    }
1466}
1467
1468#[derive(Event, Clone, Debug)]
1469pub struct UpdateSelectedSlotEvent {
1470    pub client: Entity,
1471    pub slot: u8,
1472}
1473
1474/// Handles the `HeldItem` component being changed on a client entity, which
1475/// indicates that the server has changed the selected hotbar slot.
1476fn update_player_selected_slot(mut clients: Query<(&mut Client, &HeldItem), Changed<HeldItem>>) {
1477    for (mut client, held_item) in &mut clients {
1478        client.write_packet(&SetHeldSlotS2c {
1479            slot: i32::from(held_item.hotbar_idx()).into(),
1480        });
1481    }
1482}
1483
1484/// Client to Server `HeldItem` Slot
1485fn handle_update_selected_slot(
1486    mut packets: EventReader<PacketEvent>,
1487    mut clients: Query<&mut HeldItem>,
1488    mut events: EventWriter<UpdateSelectedSlotEvent>,
1489) {
1490    for packet in packets.read() {
1491        if let Some(pkt) = packet.decode::<SetCarriedItemC2s>() {
1492            if let Ok(mut mut_held) = clients.get_mut(packet.client) {
1493                // We bypass the change detection here because the server listens for changes
1494                // of `HeldItem` in order to send the update to the client.
1495                // This is not required here because the update is coming from the client.
1496                let held = mut_held.bypass_change_detection();
1497                if pkt.slot > 8 {
1498                    // The client is trying to interact with a slot that does not exist, ignore.
1499                    continue;
1500                }
1501
1502                held.set_hotbar_idx(pkt.slot as u8);
1503
1504                events.send(UpdateSelectedSlotEvent {
1505                    client: packet.client,
1506                    slot: pkt.slot as u8,
1507                });
1508            }
1509        }
1510    }
1511}
1512
1513/// Convert a slot that is outside a target inventory's range to a slot that is
1514/// inside the player's inventory.
1515#[doc(hidden)]
1516pub fn convert_to_player_slot_id(target_kind: InventoryKind, slot_id: u16) -> u16 {
1517    // the first slot in the player's general inventory
1518    let offset = target_kind.slot_count() as u16;
1519    slot_id - offset + 9
1520}
1521
1522#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1523pub enum InventoryKind {
1524    Generic9x1,
1525    Generic9x2,
1526    Generic9x3,
1527    Generic9x4,
1528    Generic9x5,
1529    Generic9x6,
1530    Generic3x3,
1531    Anvil,
1532    Beacon,
1533    BlastFurnace,
1534    BrewingStand,
1535    Crafting,
1536    Enchantment,
1537    Furnace,
1538    Grindstone,
1539    Hopper,
1540    Lectern,
1541    Loom,
1542    Merchant,
1543    ShulkerBox,
1544    Smithing,
1545    Smoker,
1546    Cartography,
1547    Stonecutter,
1548    Player,
1549    Crafter3x3,
1550}
1551
1552impl InventoryKind {
1553    /// The number of slots in this inventory. When the inventory is shown to
1554    /// clients, this number does not include the player's main inventory slots.
1555    pub const fn slot_count(self) -> usize {
1556        match self {
1557            InventoryKind::Generic9x1 => 9,
1558            InventoryKind::Generic9x2 => 9 * 2,
1559            InventoryKind::Generic9x3 => 9 * 3,
1560            InventoryKind::Generic9x4 => 9 * 4,
1561            InventoryKind::Generic9x5 => 9 * 5,
1562            InventoryKind::Generic9x6 => 9 * 6,
1563            InventoryKind::Generic3x3 => 3 * 3,
1564            InventoryKind::Anvil => 4,
1565            InventoryKind::Crafter3x3 => 3 * 3,
1566            InventoryKind::Beacon => 1,
1567            InventoryKind::BlastFurnace => 3,
1568            InventoryKind::BrewingStand => 5,
1569            InventoryKind::Crafting => 10,
1570            InventoryKind::Enchantment => 2,
1571            InventoryKind::Furnace => 3,
1572            InventoryKind::Grindstone => 3,
1573            InventoryKind::Hopper => 5,
1574            InventoryKind::Lectern => 1,
1575            InventoryKind::Loom => 4,
1576            InventoryKind::Merchant => 3,
1577            InventoryKind::ShulkerBox => 27,
1578            InventoryKind::Smithing => 3,
1579            InventoryKind::Smoker => 3,
1580            InventoryKind::Cartography => 3,
1581            InventoryKind::Stonecutter => 2,
1582            InventoryKind::Player => 46,
1583        }
1584    }
1585}
1586
1587impl From<InventoryKind> for WindowType {
1588    fn from(value: InventoryKind) -> Self {
1589        match value {
1590            InventoryKind::Generic9x1 => WindowType::Generic9x1,
1591            InventoryKind::Generic9x2 => WindowType::Generic9x2,
1592            InventoryKind::Generic9x3 => WindowType::Generic9x3,
1593            InventoryKind::Generic9x4 => WindowType::Generic9x4,
1594            InventoryKind::Generic9x5 => WindowType::Generic9x5,
1595            InventoryKind::Generic9x6 => WindowType::Generic9x6,
1596            InventoryKind::Generic3x3 => WindowType::Generic3x3,
1597            InventoryKind::Anvil => WindowType::Anvil,
1598            InventoryKind::Beacon => WindowType::Beacon,
1599            InventoryKind::BlastFurnace => WindowType::BlastFurnace,
1600            InventoryKind::BrewingStand => WindowType::BrewingStand,
1601            InventoryKind::Crafting => WindowType::Crafting,
1602            InventoryKind::Enchantment => WindowType::Enchantment,
1603            InventoryKind::Furnace => WindowType::Furnace,
1604            InventoryKind::Grindstone => WindowType::Grindstone,
1605            InventoryKind::Hopper => WindowType::Hopper,
1606            InventoryKind::Lectern => WindowType::Lectern,
1607            InventoryKind::Loom => WindowType::Loom,
1608            InventoryKind::Merchant => WindowType::Merchant,
1609            InventoryKind::ShulkerBox => WindowType::ShulkerBox,
1610            InventoryKind::Smithing => WindowType::Smithing,
1611            InventoryKind::Smoker => WindowType::Smoker,
1612            InventoryKind::Cartography => WindowType::Cartography,
1613            InventoryKind::Stonecutter => WindowType::Stonecutter,
1614            // arbitrarily chosen, because a player inventory technically does not have a window
1615            // type
1616            InventoryKind::Player => WindowType::Generic9x4,
1617            InventoryKind::Crafter3x3 => WindowType::Crafter3x3,
1618        }
1619    }
1620}
1621
1622impl From<WindowType> for InventoryKind {
1623    fn from(value: WindowType) -> Self {
1624        match value {
1625            WindowType::Generic9x1 => InventoryKind::Generic9x1,
1626            WindowType::Generic9x2 => InventoryKind::Generic9x2,
1627            WindowType::Generic9x3 => InventoryKind::Generic9x3,
1628            WindowType::Generic9x4 => InventoryKind::Generic9x4,
1629            WindowType::Generic9x5 => InventoryKind::Generic9x5,
1630            WindowType::Generic9x6 => InventoryKind::Generic9x6,
1631            WindowType::Generic3x3 => InventoryKind::Generic3x3,
1632            WindowType::Anvil => InventoryKind::Anvil,
1633            WindowType::Beacon => InventoryKind::Beacon,
1634            WindowType::BlastFurnace => InventoryKind::BlastFurnace,
1635            WindowType::BrewingStand => InventoryKind::BrewingStand,
1636            WindowType::Crafting => InventoryKind::Crafting,
1637            WindowType::Enchantment => InventoryKind::Enchantment,
1638            WindowType::Furnace => InventoryKind::Furnace,
1639            WindowType::Grindstone => InventoryKind::Grindstone,
1640            WindowType::Hopper => InventoryKind::Hopper,
1641            WindowType::Lectern => InventoryKind::Lectern,
1642            WindowType::Loom => InventoryKind::Loom,
1643            WindowType::Merchant => InventoryKind::Merchant,
1644            WindowType::ShulkerBox => InventoryKind::ShulkerBox,
1645            WindowType::Smithing => InventoryKind::Smithing,
1646            WindowType::Smoker => InventoryKind::Smoker,
1647            WindowType::Cartography => InventoryKind::Cartography,
1648            WindowType::Stonecutter => InventoryKind::Stonecutter,
1649            WindowType::Crafter3x3 => InventoryKind::Crafter3x3,
1650        }
1651    }
1652}
1653
1654#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Resource)]
1655pub struct InventorySettings {
1656    pub validate_actions: bool,
1657}
1658
1659impl Default for InventorySettings {
1660    fn default() -> Self {
1661        Self {
1662            validate_actions: true,
1663        }
1664    }
1665}
1666
1667#[cfg(test)]
1668mod tests {
1669    use super::*;
1670
1671    #[test]
1672    fn test_convert_to_player_slot() {
1673        assert_eq!(convert_to_player_slot_id(InventoryKind::Generic9x3, 27), 9);
1674        assert_eq!(convert_to_player_slot_id(InventoryKind::Generic9x3, 36), 18);
1675        assert_eq!(convert_to_player_slot_id(InventoryKind::Generic9x3, 54), 36);
1676        assert_eq!(convert_to_player_slot_id(InventoryKind::Generic9x1, 9), 9);
1677    }
1678
1679    #[test]
1680    fn test_convert_hotbar_slot_id() {
1681        assert_eq!(PlayerInventory::hotbar_to_slot(0), 36);
1682        assert_eq!(PlayerInventory::hotbar_to_slot(4), 40);
1683        assert_eq!(PlayerInventory::hotbar_to_slot(8), 44);
1684    }
1685}