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 #[doc(hidden)]
76 pub changed: u64,
77 pub readonly: bool,
82}
83
84impl Inventory {
85 pub fn new(kind: InventoryKind) -> Self {
86 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 #[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 #[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 #[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 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 #[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 pub fn title(&self) -> &Text {
247 &self.title
248 }
249
250 #[inline]
260 pub fn set_title<'a, T: IntoText<'a>>(&mut self, title: T) {
261 let _ = self.replace_title(title);
262 }
263
264 #[must_use]
267 pub fn replace_title<'a, T: IntoText<'a>>(&mut self, title: T) -> Text {
268 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 #[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 #[inline]
313 pub fn first_empty_slot(&self) -> Option<u16> {
314 self.first_empty_slot_in(0..self.slot_count())
315 }
316
317 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 #[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#[derive(Component, Debug)]
371pub struct ClientInventoryState {
372 window_id: VarInt,
374 state_id: Wrapping<i32>,
375 slots_changed: u64,
378 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Component, Deref)]
400pub struct HeldItem {
401 held_item_slot: u16,
402}
403
404impl HeldItem {
405 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 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#[derive(Component, Clone, PartialEq, Default, Debug, Deref, DerefMut)]
433pub struct CursorItem(pub ItemStack);
434
435#[derive(Component, Clone, Debug)]
438pub struct OpenInventory {
439 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
454pub 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 PlayerInventory::MAIN_SIZE + open_inv.slot_count()
503 } else {
504 self.player_inventory.slot_count()
505 }
506 }
507}
508
509pub 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 PlayerInventory::MAIN_SIZE + open_inv.slot_count()
586 } else {
587 self.player_inventory.slot_count()
588 }
589 }
590}
591
592fn 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 held_item_slot: 36,
607 },
608 ));
609 }
610}
611
612fn 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 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 } else if inventory.changed != 0 {
646 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
672fn 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 for (client_entity, mut client, mut inv_state, cursor_item, mut open_inventory) in &mut clients
690 {
691 let Ok([inventory, player_inventory]) =
693 inventories.get_many_mut([open_inventory.entity, client_entity])
694 else {
695 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 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 if inventory.changed == u64::MAX {
726 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 let changed_filtered =
741 u128::from(inventory.changed & !open_inventory.client_changed);
742
743 let mut player_inventory_changed = u128::from(player_inventory.changed);
746
747 player_inventory_changed >>= *PlayerInventory::SLOTS_MAIN.start();
750 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 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 if inv_state.client_updated_cursor_item.as_ref() != Some(&cursor_item.0) {
804 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
821fn 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
832fn 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#[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 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 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 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 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 let entire_stack = pkt.button == 1;
943
944 if let Some(open_inventory) = open_inventory {
947 let Ok(mut target_inventory) = inventories.get_mut(open_inventory.entity) else {
950 continue;
952 };
953
954 if inv_state.state_id.0 != pkt.state_id.0 {
955 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 continue;
973 }
974
975 if pkt.slot_idx == PlayerInventory::SLOT_OUTSIDE_INVENTORY {
976 continue;
978 }
979
980 if (0_i16..target_inventory.slot_count() as i16).contains(&pkt.slot_idx) {
981 if target_inventory.readonly {
984 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 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 if client_inv.readonly {
1019 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 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 if client_inv.readonly {
1058 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 continue;
1070 }
1071
1072 if pkt.slot_idx == PlayerInventory::SLOT_OUTSIDE_INVENTORY {
1073 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 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 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 let Ok(mut target_inventory) = inventories.get_mut(open_inventory.entity) else {
1117 continue;
1119 };
1120
1121 if inv_state.state_id.0 != pkt.state_id.0 {
1122 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 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 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 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 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 if inv_state.state_id.0 != pkt.state_id.0 {
1197 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 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 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 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 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 if inv.readonly {
1345 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
1364fn 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#[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 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 continue;
1441 }
1442
1443 inventory.slots[pkt.slot as usize] = pkt.clicked_item.clone();
1445
1446 inv_state.state_id += 1;
1447
1448 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
1474fn 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
1484fn 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 let held = mut_held.bypass_change_detection();
1497 if pkt.slot > 8 {
1498 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#[doc(hidden)]
1516pub fn convert_to_player_slot_id(target_kind: InventoryKind, slot_id: u16) -> u16 {
1517 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 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 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}