diff --git a/crates/valence_inventory/src/lib.rs b/crates/valence_inventory/src/lib.rs index d929fb8b7..a5a8f80ee 100644 --- a/crates/valence_inventory/src/lib.rs +++ b/crates/valence_inventory/src/lib.rs @@ -72,6 +72,11 @@ pub struct Inventory { /// Contains a set bit for each modified slot in `slots`. #[doc(hidden)] pub changed: u64, + /// Makes an inventory read-only for clients. This will prevent adding + /// or removing items. If this is a player inventory + /// This will also make it impossible to drop items while not + /// in the inventory (e.g. by pressing Q) + pub readonly: bool, } impl Inventory { @@ -86,6 +91,7 @@ impl Inventory { kind, slots: vec![ItemStack::EMPTY; kind.slot_count()].into(), changed: 0, + readonly: false, } } @@ -915,6 +921,17 @@ fn handle_click_slot( if (0_i16..target_inventory.slot_count() as i16).contains(&pkt.slot_idx) { // The player is dropping an item from another inventory. + if target_inventory.readonly { + // resync target inventory + client.write_packet(&InventoryS2c { + window_id: inv_state.window_id, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(target_inventory.slot_slice()), + carried_item: Cow::Borrowed(&cursor_item.0), + }); + continue; + } + let stack = target_inventory.slot(pkt.slot_idx as u16); if !stack.is_empty() { @@ -938,6 +955,18 @@ fn handle_click_slot( } } else { // The player is dropping an item from their inventory. + + if client_inv.readonly { + // resync the client inventory + client.write_packet(&InventoryS2c { + window_id: 0, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(client_inv.slot_slice()), + carried_item: Cow::Borrowed(&cursor_item.0), + }); + continue; + } + let slot_id = convert_to_player_slot_id(target_inventory.kind, pkt.slot_idx as u16); @@ -966,6 +995,17 @@ fn handle_click_slot( // The player has no inventory open and is dropping an item from their // inventory. + if client_inv.readonly { + // resync the client inventory + client.write_packet(&InventoryS2c { + window_id: 0, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(client_inv.slot_slice()), + carried_item: Cow::Borrowed(&cursor_item.0), + }); + continue; + } + let stack = client_inv.slot(pkt.slot_idx as u16); if !stack.is_empty() { @@ -1002,7 +1042,8 @@ fn handle_click_slot( } if let Some(mut open_inventory) = open_inventory { - // The player is interacting with an inventory that is open. + // The player is interacting with an inventory that is + // open or has an inventory open while interacting with their own inventory. let Ok(mut target_inventory) = inventories.get_mut(open_inventory.entity) else { // The inventory does not exist, ignore. @@ -1026,15 +1067,32 @@ fn handle_click_slot( continue; } - cursor_item.set_if_neq(CursorItem(pkt.carried_item.clone())); - inv_state.client_updated_cursor_item = Some(pkt.carried_item.clone()); + let mut new_cursor = pkt.carried_item.clone(); for slot in pkt.slot_changes.iter() { + let transferred_between_inventories = + ((0_i16..target_inventory.slot_count() as i16).contains(&pkt.slot_idx) + && pkt.mode == ClickMode::Hotbar) + || pkt.mode == ClickMode::ShiftClick; + if (0_i16..target_inventory.slot_count() as i16).contains(&slot.idx) { - // The client is interacting with a slot in the target inventory. + if (client_inv.readonly && transferred_between_inventories) + || target_inventory.readonly + { + new_cursor = cursor_item.0.clone(); + continue; + } + target_inventory.set_slot(slot.idx as u16, slot.stack.clone()); open_inventory.client_changed |= 1 << slot.idx; } else { + if (target_inventory.readonly && transferred_between_inventories) + || client_inv.readonly + { + new_cursor = cursor_item.0.clone(); + continue; + } + // The client is interacting with a slot in their own inventory. let slot_id = convert_to_player_slot_id(target_inventory.kind, slot.idx as u16); @@ -1042,6 +1100,27 @@ fn handle_click_slot( inv_state.slots_changed |= 1 << slot_id; } } + + cursor_item.set_if_neq(CursorItem(new_cursor.clone())); + inv_state.client_updated_cursor_item = Some(new_cursor); + + if target_inventory.readonly || client_inv.readonly { + // resync the target inventory + client.write_packet(&InventoryS2c { + window_id: inv_state.window_id, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(target_inventory.slot_slice()), + carried_item: Cow::Borrowed(&cursor_item.0), + }); + + // resync the client inventory + client.write_packet(&InventoryS2c { + window_id: 0, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(client_inv.slot_slice()), + carried_item: Cow::Borrowed(&cursor_item.0), + }); + } } else { // The client is interacting with their own inventory. @@ -1062,11 +1141,14 @@ fn handle_click_slot( continue; } - cursor_item.set_if_neq(CursorItem(pkt.carried_item.clone())); - inv_state.client_updated_cursor_item = Some(pkt.carried_item.clone()); + let mut new_cursor = pkt.carried_item.clone(); for slot in pkt.slot_changes.iter() { if (0_i16..client_inv.slot_count() as i16).contains(&slot.idx) { + if client_inv.readonly { + new_cursor = cursor_item.0.clone(); + continue; + } client_inv.set_slot(slot.idx as u16, slot.stack.clone()); inv_state.slots_changed |= 1 << slot.idx; } else { @@ -1078,6 +1160,19 @@ fn handle_click_slot( ); } } + + cursor_item.set_if_neq(CursorItem(new_cursor.clone())); + inv_state.client_updated_cursor_item = Some(new_cursor); + + if client_inv.readonly { + // resync the client inventory + client.write_packet(&InventoryS2c { + window_id: 0, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(client_inv.slot_slice()), + carried_item: Cow::Borrowed(&cursor_item.0), + }); + } } click_slot_events.send(ClickSlotEvent { @@ -1096,14 +1191,32 @@ fn handle_click_slot( fn handle_player_actions( mut packets: EventReader, - mut clients: Query<(&mut Inventory, &mut ClientInventoryState, &HeldItem)>, + mut clients: Query<( + &mut Inventory, + &mut ClientInventoryState, + &HeldItem, + &mut Client, + )>, mut drop_item_stack_events: EventWriter, ) { for packet in packets.read() { if let Some(pkt) = packet.decode::() { match pkt.action { PlayerAction::DropAllItems => { - if let Ok((mut inv, mut inv_state, &held)) = clients.get_mut(packet.client) { + if let Ok((mut inv, mut inv_state, &held, mut client)) = + clients.get_mut(packet.client) + { + if inv.readonly { + // resync the client inventory + client.write_packet(&InventoryS2c { + window_id: 0, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(inv.slot_slice()), + carried_item: Cow::Borrowed(&ItemStack::EMPTY), + }); + continue; + } + let stack = inv.replace_slot(held.slot(), ItemStack::EMPTY); if !stack.is_empty() { @@ -1118,7 +1231,20 @@ fn handle_player_actions( } } PlayerAction::DropItem => { - if let Ok((mut inv, mut inv_state, held)) = clients.get_mut(packet.client) { + if let Ok((mut inv, mut inv_state, held, mut client)) = + clients.get_mut(packet.client) + { + if inv.readonly { + // resync the client inventory + client.write_packet(&InventoryS2c { + window_id: 0, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(inv.slot_slice()), + carried_item: Cow::Borrowed(&ItemStack::EMPTY), + }); + continue; + } + let mut stack = inv.replace_slot(held.slot(), ItemStack::EMPTY); if !stack.is_empty() { @@ -1142,7 +1268,21 @@ fn handle_player_actions( } } PlayerAction::SwapItemWithOffhand => { - if let Ok((mut inv, _, held)) = clients.get_mut(packet.client) { + if let Ok((mut inv, inv_state, held, mut client)) = + clients.get_mut(packet.client) + { + // this check here might not actually be necessary + if inv.readonly { + // resync the client inventory + client.write_packet(&InventoryS2c { + window_id: 0, + state_id: VarInt(inv_state.state_id.0), + slots: Cow::Borrowed(inv.slot_slice()), + carried_item: Cow::Borrowed(&ItemStack::EMPTY), + }); + continue; + } + inv.swap_slot(held.slot(), PlayerInventory::SLOT_OFFHAND); } } diff --git a/src/tests/inventory.rs b/src/tests/inventory.rs index 2709ab08b..f35a3d7f9 100644 --- a/src/tests/inventory.rs +++ b/src/tests/inventory.rs @@ -330,6 +330,79 @@ fn test_should_modify_open_inventory_click_slot() { assert_eq!(cursor_item.0, ItemStack::new(ItemKind::Diamond, 2, None)); } +#[test] +fn test_prevent_modify_open_inventory_click_slot_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // The open inventory is readonly, the client can not interact with it. + let inventory_ent = set_up_open_inventory(&mut app, client); + + let mut inventory = app + .world_mut() + .get_mut::(inventory_ent) + .expect("could not find inventory for client"); + + inventory.readonly = true; + inventory.set_slot(20, ItemStack::new(ItemKind::Diamond, 2, None)); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + // Make the client click the slot and pick up the item. + let inv_state = app.world_mut().get::(client).unwrap(); + let state_id = inv_state.state_id(); + let window_id = inv_state.window_id(); + + helper.send(&ClickSlotC2s { + window_id, + state_id: VarInt(state_id.0), + slot_idx: 20, + button: 0, + // If the inventory is readonly, this should actually not be possible, + // as you cant even select an item (so its on your cursor), + // this is also why 2 resyncs are sent, see below. + mode: ClickMode::Click, + slot_changes: vec![SlotChange { + idx: 20, + stack: ItemStack::EMPTY, + }] + .into(), + carried_item: ItemStack::new(ItemKind::Diamond, 2, None), + }); + + app.update(); + + let sent_packets = helper.collect_received(); + + // because the inventory is readonly, we need to resync the client's inventory. + // 2 resync packets are sent, see above. + sent_packets.assert_count::(2); + sent_packets.assert_count::(0); + + // Make assertions + let inventory = app + .world_mut() + .get::(inventory_ent) + .expect("could not find inventory"); + // Inventory is read-only, the item is not being moved + assert_eq!( + inventory.slot(20), + &ItemStack::new(ItemKind::Diamond, 2, None) + ); + let cursor_item = app + .world_mut() + .get::(client) + .expect("could not find client"); + // Inventory is read-only, items can not be picked up with the cursor + assert_eq!(cursor_item.0, ItemStack::EMPTY); +} + #[test] fn test_should_modify_open_inventory_server_side() { let ScenarioSingleClient { @@ -372,6 +445,374 @@ fn test_should_modify_open_inventory_server_side() { ); } +#[test] +fn test_hotbar_item_swap_container() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory for client"); + + // 36 is the first hotbar slot + player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 1, None)); + + let open_inv_ent = set_up_open_inventory(&mut app, client); + + let mut open_inventory = app + .world_mut() + .get_mut::(open_inv_ent) + .expect("could not find inventory for client"); + + open_inventory.set_slot(0, ItemStack::new(ItemKind::IronIngot, 10, None)); + + // This update makes sure we have the items in the inventory by the time the + // client wants to update these + app.update(); + helper.clear_received(); + let inv_state = app.world_mut().get::(client).unwrap(); + let state_id = inv_state.state_id(); + let window_id = inv_state.window_id(); + + // The player hovers over the iron ingots in the open inventory, and tries + // to move them to their own (via pressing 1), which should swap the iron + // for the diamonds. + helper.send(&ClickSlotC2s { + window_id, + state_id: VarInt(state_id.0), + slot_idx: 0, + button: 0, // hotbar slot starting at 0 + mode: ClickMode::Hotbar, + slot_changes: vec![ + // First SlotChange is the item is the slot in the player's hotbar. + // target slot. + SlotChange { + idx: 0, + stack: ItemStack::new(ItemKind::Diamond, 1, None), + }, + SlotChange { + // 54 is the players hotbar slot 1, when the 9x3 inventory is opnened. + idx: 54, + stack: ItemStack::new(ItemKind::IronIngot, 10, None), + }, + // The second one is the slot in the open inventory, after the ClickSlot action + // source slot. + ] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + + let sent_packets = helper.collect_received(); + + // No resyncs because the client was in sync and sent us the updates + sent_packets.assert_count::(0); + + // Make assertions + let player_inventory = app + .world_mut() + .get::(client) + .expect("could not find client"); + + // Swapped items successfully + assert_eq!( + player_inventory.slot(36), + &ItemStack::new(ItemKind::IronIngot, 10, None) + ); + + let open_inventory = app + .world_mut() + .get::(open_inv_ent) + .expect("could not find inventory"); + + assert_eq!( + open_inventory.slot(0), + &ItemStack::new(ItemKind::Diamond, 1, None) + ); +} + +#[test] +fn test_prevent_hotbar_item_click_container_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + // player inventory is not read-only + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory for client"); + + // 36 is the first hotbar slot + player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 1, None)); + + let open_inv_ent = set_up_open_inventory(&mut app, client); + + let mut open_inventory = app + .world_mut() + .get_mut::(open_inv_ent) + .expect("could not find inventory for client"); + + // Open inventory is read-only + open_inventory.readonly = true; + open_inventory.set_slot(0, ItemStack::new(ItemKind::IronIngot, 10, None)); + + // This update makes sure we have the items in the inventory by the time the + // client wants to update these + app.update(); + helper.clear_received(); + + let inv_state = app.world_mut().get::(client).unwrap(); + let state_id = inv_state.state_id(); + let window_id = inv_state.window_id(); + + // The player hovers over the iron ingots in the open inventory, and tries + // to move them to their own (via pressing 1), which should swap the iron + // for the diamonds. However the opened inventory is read-only, so nothing + // should happen. + helper.send(&ClickSlotC2s { + window_id, + state_id: VarInt(state_id.0), + slot_idx: 0, + button: 0, // hotbar slot starting at 0 + mode: ClickMode::Hotbar, + slot_changes: vec![ + // First SlotChange is the item is the slot in the player's hotbar. + // target slot. + SlotChange { + idx: 0, + stack: ItemStack::new(ItemKind::Diamond, 1, None), + }, + // The second one is the slot in the open inventory, after the ClickSlot action + // source slot. + SlotChange { + // 54 is the players hotbar slot 1, when the 9x3 inventory is opnened. + idx: 54, + stack: ItemStack::new(ItemKind::IronIngot, 10, None), + }, + ] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + + let sent_packets = helper.collect_received(); + + // 1 resync for each inventory + sent_packets.assert_count::(2); + + // Make assertions + let player_inventory = app + .world_mut() + .get::(client) + .expect("could not find client"); + + // Opened inventory is read-only, the items are not swapped. + assert_eq!( + player_inventory.slot(36), + &ItemStack::new(ItemKind::Diamond, 1, None) + ); + + let open_inventory = app + .world_mut() + .get::(open_inv_ent) + .expect("could not find inventory"); + + // Opened inventory is read-only, the items are not swapped. + assert_eq!( + open_inventory.slot(0), + &ItemStack::new(ItemKind::IronIngot, 10, None) + ); +} + +#[test] +fn test_still_allow_hotbar_item_click_in_own_inventory_if_container_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + // player inventory is not read-only + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory for client"); + + // 36 is the first hotbar slot + player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 10, None)); + + let open_inv_ent = set_up_open_inventory(&mut app, client); + + let mut open_inventory = app + .world_mut() + .get_mut::(open_inv_ent) + .expect("could not find inventory for client"); + + // Open inventory is read-only + open_inventory.readonly = true; + + // This update makes sure we have the items in the inventory by the time the + // client wants to update these + app.update(); + helper.clear_received(); + + let inv_state = app.world_mut().get::(client).unwrap(); + let state_id = inv_state.state_id(); + let window_id = inv_state.window_id(); + + // The player's inventory is not readonly, so the player should still be + // able to move items from the hotbar to other parts of the inventory even + // if the other inventory is still open. + helper.send(&ClickSlotC2s { + window_id, + state_id: VarInt(state_id.0), + slot_idx: 27, + button: 0, // hotbar slot starting at 0 + mode: ClickMode::Hotbar, + slot_changes: vec![ + SlotChange { + idx: 27, + stack: ItemStack::new(ItemKind::Diamond, 10, None), + }, + SlotChange { + idx: 54, + stack: ItemStack::EMPTY, + }, + ] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + // Make assertions + let sent_packets = helper.collect_received(); + sent_packets.assert_count::(2); + + let player_inventory = app + .world_mut() + .get::(client) + .expect("could not find client"); + + // Items swapped successfully, as player item is not read-only + assert_eq!(player_inventory.slot(36), &ItemStack::EMPTY); + assert_eq!( + player_inventory.slot(9), + &ItemStack::new(ItemKind::Diamond, 10, None) + ); +} + +#[test] +fn test_prevent_shift_item_click_container_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + // player inventory is not read-only + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory for client"); + + player_inventory.set_slot(9, ItemStack::new(ItemKind::Diamond, 64, None)); + + let open_inv_ent = set_up_open_inventory(&mut app, client); + + let mut open_inventory = app + .world_mut() + .get_mut::(open_inv_ent) + .expect("could not find inventory for client"); + + // Open inventory is read-only + open_inventory.readonly = true; + + // This update makes sure we have the items in the inventory by the time the + // client wants to update these + app.update(); + helper.clear_received(); + + let inv_state = app.world_mut().get::(client).unwrap(); + let state_id = inv_state.state_id(); + let window_id = inv_state.window_id(); + + // The player tries to Shift-click transfer the stack of diamonds into + // the open container + helper.send(&ClickSlotC2s { + window_id, + state_id: VarInt(state_id.0), + slot_idx: 27, + button: 0, // hotbar slot starting at 0 + mode: ClickMode::ShiftClick, + slot_changes: vec![ + // target + SlotChange { + idx: 0, + stack: ItemStack::new(ItemKind::Diamond, 64, None), + }, + // source + SlotChange { + idx: 27, + stack: ItemStack::EMPTY, + }, + ] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + // 1 resync per inventory + sent_packets.assert_count::(2); + + let player_inventory = app + .world_mut() + .get::(client) + .expect("could not find client"); + + assert_eq!( + player_inventory.slot(9), + &ItemStack::new(ItemKind::Diamond, 64, None) + ); + + let open_inventory = app + .world_mut() + .get::(open_inv_ent) + .expect("could not find inventory"); + + assert_eq!(open_inventory.slot(0), &ItemStack::EMPTY); +} + #[test] fn test_should_sync_entire_open_inventory() { let ScenarioSingleClient { @@ -598,14 +1039,73 @@ fn should_send_cursor_item_change_when_modified_on_the_server() { sent_packets.assert_count::(1); } -mod dropping_items { - use super::*; - use crate::inventory::{convert_to_player_slot_id, PlayerAction}; - use crate::protocol::packets::play::PlayerActionC2s; - use crate::{BlockPos, Direction}; +mod dropping_items { + use super::*; + use crate::inventory::{convert_to_player_slot_id, PlayerAction}; + use crate::protocol::packets::play::PlayerActionC2s; + use crate::{BlockPos, Direction}; + + #[test] + fn should_drop_item_player_action() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let mut inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory"); + inventory.set_slot(36, ItemStack::new(ItemKind::IronIngot, 3, None)); + + helper.send(&PlayerActionC2s { + action: PlayerAction::DropItem, + position: BlockPos::new(0, 0, 0), + direction: Direction::Down, + sequence: VarInt(0), + }); + + app.update(); + + // Make assertions + let inventory = app + .world_mut() + .get::(client) + .expect("could not find client"); + + assert_eq!( + inventory.slot(36), + &ItemStack::new(ItemKind::IronIngot, 2, None) + ); + + let events = app + .world_mut() + .get_resource::>() + .expect("expected drop item stack events"); + + let events = events.iter_current_update_events().collect::>(); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].client, client); + assert_eq!(events[0].from_slot, Some(36)); + assert_eq!( + events[0].stack, + ItemStack::new(ItemKind::IronIngot, 1, None) + ); + + let sent_packets = helper.collect_received(); + + sent_packets.assert_count::(0); + } #[test] - fn should_drop_item_player_action() { + fn prevent_drop_item_player_action_readonly_inventory() { let ScenarioSingleClient { mut app, client, @@ -621,6 +1121,7 @@ mod dropping_items { .world_mut() .get_mut::(client) .expect("could not find inventory"); + inventory.readonly = true; inventory.set_slot(36, ItemStack::new(ItemKind::IronIngot, 3, None)); helper.send(&PlayerActionC2s { @@ -640,7 +1141,8 @@ mod dropping_items { assert_eq!( inventory.slot(36), - &ItemStack::new(ItemKind::IronIngot, 2, None) + // Inventory is read-only, item is not being dropped + &ItemStack::new(ItemKind::IronIngot, 3, None) ); let events = app @@ -650,17 +1152,13 @@ mod dropping_items { let events = events.iter_current_update_events().collect::>(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].client, client); - assert_eq!(events[0].from_slot, Some(36)); - assert_eq!( - events[0].stack, - ItemStack::new(ItemKind::IronIngot, 1, None) - ); + // when the inventory is read-only we do not emit a drop event + assert_eq!(events.len(), 0); let sent_packets = helper.collect_received(); - sent_packets.assert_count::(0); + // we do need to update the player inventory so we dont desync + sent_packets.assert_count::(1); } #[test] @@ -716,6 +1214,60 @@ mod dropping_items { ); } + #[test] + fn prevent_drop_item_stack_player_action_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let mut inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory"); + inventory.readonly = true; + inventory.set_slot(36, ItemStack::new(ItemKind::IronIngot, 32, None)); + + helper.send(&PlayerActionC2s { + action: PlayerAction::DropAllItems, + position: BlockPos::new(0, 0, 0), + direction: Direction::Down, + sequence: VarInt(0), + }); + + app.update(); + + // Make assertions + let held = app + .world_mut() + .get::(client) + .expect("could not find client"); + assert_eq!(held.slot(), 36); + let inventory = app + .world_mut() + .get::(client) + .expect("could not find inventory"); + // Inventory is read-only, item is not being dropped + assert_eq!( + inventory.slot(36), + &ItemStack::new(ItemKind::IronIngot, 32, None) + ); + let events = app + .world_mut() + .get_resource::>() + .expect("expected drop item stack events"); + let events = events.iter_current_update_events().collect::>(); + + // when the inventory is read-only we do not emit a drop event + assert_eq!(events.len(), 0); + } + #[test] fn should_drop_item_stack_set_creative_mode_slot() { let ScenarioSingleClient { @@ -877,6 +1429,73 @@ mod dropping_items { ); } + #[test] + fn prevent_drop_item_click_container_with_dropkey_single_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let inv_state = app + .world_mut() + .get_mut::(client) + .expect("could not find client"); + + let state_id = inv_state.state_id().0; + + let mut inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory"); + + inventory.readonly = true; + inventory.set_slot(40, ItemStack::new(ItemKind::IronIngot, 32, None)); + + helper.send(&ClickSlotC2s { + window_id: 0, + slot_idx: 40, + button: 0, + mode: ClickMode::DropKey, + state_id: VarInt(state_id), + slot_changes: vec![SlotChange { + idx: 40, + stack: ItemStack::new(ItemKind::IronIngot, 31, None), + }] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + + // Make assertions + let inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory"); + + assert_eq!( + inventory.slot(40), + // Inventory is read-only, item is not being dropped + &ItemStack::new(ItemKind::IronIngot, 32, None) + ); + + let events = app + .world_mut() + .get_resource::>() + .expect("expected drop item stack events"); + + let events = events.iter_current_update_events().collect::>(); + + // when the inventory is read-only we do not emit a drop event + assert_eq!(events.len(), 0); + } + #[test] fn should_drop_item_stack_click_container_with_dropkey() { let ScenarioSingleClient { @@ -937,6 +1556,73 @@ mod dropping_items { ); } + #[test] + fn prevent_drop_item_stack_click_container_with_dropkey_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let inv_state = app + .world_mut() + .get_mut::(client) + .expect("could not find client"); + + let state_id = inv_state.state_id().0; + + let mut inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory"); + + inventory.readonly = true; + inventory.set_slot(40, ItemStack::new(ItemKind::IronIngot, 32, None)); + + helper.send(&ClickSlotC2s { + window_id: 0, + slot_idx: 40, + button: 1, // pressing control + mode: ClickMode::DropKey, + state_id: VarInt(state_id), + slot_changes: vec![SlotChange { + idx: 40, + stack: ItemStack::EMPTY, + }] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + + // Make assertions + let inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory"); + + assert_eq!( + inventory.slot(40), + // Inventory is read-only, item is not being dropped + &ItemStack::new(ItemKind::IronIngot, 32, None) + ); + + let events = app + .world_mut() + .get_resource::>() + .expect("expected drop item stack events"); + + let events = events.iter_current_update_events().collect::>(); + + // when the inventory is read-only we do not emit a drop event + assert_eq!(events.len(), 0); + } + /// The item should be dropped successfully, if the player has an inventory /// open and the slot id points to his inventory. #[test] @@ -1023,6 +1709,84 @@ mod dropping_items { &ItemStack::new(ItemKind::IronIngot, 31, None) ); } + + #[test] + fn prevent_drop_item_player_open_inventory_with_dropkey_readonly_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + + let mut inventory = app + .world_mut() + .get_mut::(client) + .expect("could not find inventory"); + + inventory.readonly = true; + inventory.set_slot( + convert_to_player_slot_id(InventoryKind::Generic9x3, 50), + ItemStack::new(ItemKind::IronIngot, 32, None), + ); + + let _inventory_ent = set_up_open_inventory(&mut app, client); + + app.update(); + + helper.clear_received(); + + let inv_state = app + .world_mut() + .get_mut::(client) + .expect("could not find client"); + + let state_id = inv_state.state_id().0; + let window_id = inv_state.window_id(); + + helper.send(&ClickSlotC2s { + window_id, + state_id: VarInt(state_id), + slot_idx: 50, // not pressing control + button: 0, + mode: ClickMode::DropKey, + slot_changes: vec![SlotChange { + idx: 50, + stack: ItemStack::new(ItemKind::IronIngot, 31, None), + }] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + + // Make assertions + let events = app + .world() + .get_resource::>() + .expect("expected drop item stack events"); + + let player_inventory = app + .world() + .get::(client) + .expect("could not find inventory"); + + let events = events.iter_current_update_events().collect::>(); + // when the inventory is read-only we do not emit a drop event + assert_eq!(events.len(), 0); + + // Also make sure that the player inventory was not updated (as it is + // read-only). + let expected_player_slot_id = convert_to_player_slot_id(InventoryKind::Generic9x3, 50); + assert_eq!( + player_inventory.slot(expected_player_slot_id), + // Inventory is read-only, item is not being dropped + &ItemStack::new(ItemKind::IronIngot, 32, None) + ); + } } /// The item stack should be dropped successfully, if the player has an