diff --git a/crates/valence/src/client/event.rs b/crates/valence/src/client/event.rs index e4e3e0e9c..9540cb7c5 100644 --- a/crates/valence/src/client/event.rs +++ b/crates/valence/src/client/event.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::cmp; use anyhow::bail; @@ -8,7 +9,7 @@ use bevy_ecs::schedule::ScheduleLabel; use bevy_ecs::system::{SystemParam, SystemState}; use glam::{DVec3, Vec3}; use paste::paste; -use tracing::warn; +use tracing::{debug, warn}; use uuid::Uuid; use valence_protocol::block_pos::BlockPos; use valence_protocol::ident::Ident; @@ -27,15 +28,19 @@ use valence_protocol::packet::c2s::play::update_structure_block::{ use valence_protocol::packet::c2s::play::{ AdvancementTabC2s, ClientStatusC2s, ResourcePackStatusC2s, UpdatePlayerAbilitiesC2s, }; +use valence_protocol::packet::s2c::play::InventoryS2c; use valence_protocol::packet::C2sPlayPacket; use valence_protocol::types::{Difficulty, Direction, Hand}; +use valence_protocol::var_int::VarInt; use super::{ CursorItem, KeepaliveState, PlayerActionSequence, PlayerInventoryState, TeleportState, }; use crate::client::Client; use crate::component::{Look, OnGround, Ping, Position}; -use crate::inventory::Inventory; +use crate::inventory::{Inventory, InventorySettings}; +use crate::packet::WritePacket; +use crate::prelude::OpenInventory; #[derive(Clone, Debug)] pub struct QueryBlockNbt { @@ -671,6 +676,7 @@ pub(crate) struct EventLoopQuery { keepalive_state: &'static mut KeepaliveState, cursor_item: &'static mut CursorItem, inventory: &'static mut Inventory, + open_inventory: Option<&'static mut OpenInventory>, position: &'static mut Position, look: &'static mut Look, on_ground: &'static mut OnGround, @@ -682,10 +688,17 @@ pub(crate) struct EventLoopQuery { /// An exclusive system for running the event loop schedule. fn run_event_loop( world: &mut World, - state: &mut SystemState<(Query, ClientEvents, Commands)>, + state: &mut SystemState<( + Query, + ClientEvents, + Commands, + Query<&Inventory, Without>, + Res, + )>, mut clients_to_check: Local>, ) { - let (mut clients, mut events, mut commands) = state.get_mut(world); + let (mut clients, mut events, mut commands, mut inventories, inventory_settings) = + state.get_mut(world); update_all_event_buffers(&mut events); @@ -703,7 +716,7 @@ fn run_event_loop( q.client.dec.queue_bytes(bytes); - match handle_one_packet(&mut q, &mut events) { + match handle_one_packet(&mut q, &mut events, &mut inventories, &inventory_settings) { Ok(had_packet) => { if had_packet { // We decoded one packet, but there might be more. @@ -723,7 +736,8 @@ fn run_event_loop( while !clients_to_check.is_empty() { world.run_schedule(EventLoopSchedule); - let (mut clients, mut events, mut commands) = state.get_mut(world); + let (mut clients, mut events, mut commands, mut inventories, inventory_settings) = + state.get_mut(world); clients_to_check.retain(|&entity| { let Ok(mut q) = clients.get_mut(entity) else { @@ -731,7 +745,7 @@ fn run_event_loop( return false; }; - match handle_one_packet(&mut q, &mut events) { + match handle_one_packet(&mut q, &mut events, &mut inventories, &inventory_settings) { Ok(had_packet) => had_packet, Err(e) => { warn!("failed to dispatch events for client {:?}: {e:?}", q.entity); @@ -748,6 +762,8 @@ fn run_event_loop( fn handle_one_packet( q: &mut EventLoopQueryItem, events: &mut ClientEvents, + inventories: &mut Query<&Inventory, Without>, + inventory_settings: &Res, ) -> anyhow::Result { let Some(pkt) = q.client.dec.try_next_packet::()? else { // No packets to decode. @@ -847,7 +863,57 @@ fn handle_one_packet( }); } C2sPlayPacket::ClickSlotC2s(p) => { - if p.slot_idx < 0 { + let open_inv = q + .open_inventory + .as_ref() + .and_then(|open| inventories.get_mut(open.entity).ok()); + if let Err(msg) = + crate::inventory::validate_click_slot_impossible(&p, &q.inventory, open_inv) + { + debug!( + "client {:#?} invalid click slot packet: \"{}\" {:#?}", + q.entity, msg, p + ); + let inventory = open_inv.unwrap_or(&q.inventory); + q.client.write_packet(&InventoryS2c { + window_id: if open_inv.is_some() { + q.player_inventory_state.window_id + } else { + 0 + }, + state_id: VarInt(q.player_inventory_state.state_id.0), + slots: Cow::Borrowed(inventory.slot_slice()), + carried_item: Cow::Borrowed(&q.cursor_item.0), + }); + return Ok(true); + } + if inventory_settings.enable_item_dupe_check { + if let Err(msg) = crate::inventory::validate_click_slot_item_duplication( + &p, + &q.inventory, + open_inv, + &q.cursor_item, + ) { + debug!( + "client {:#?} click slot packet tried to incorrectly modify items: \"{}\" \ + {:#?}", + q.entity, msg, p + ); + let inventory = open_inv.unwrap_or(&q.inventory); + q.client.write_packet(&InventoryS2c { + window_id: if open_inv.is_some() { + q.player_inventory_state.window_id + } else { + 0 + }, + state_id: VarInt(q.player_inventory_state.state_id.0), + slots: Cow::Borrowed(inventory.slot_slice()), + carried_item: Cow::Borrowed(&q.cursor_item.0), + }); + return Ok(true); + } + } + if p.slot_idx < 0 && p.mode == ClickMode::Click { if let Some(stack) = q.cursor_item.0.take() { events.2.drop_item_stack.send(DropItemStack { client: entity, diff --git a/crates/valence/src/inventory.rs b/crates/valence/src/inventory.rs index d1c0b6caa..8fc7ee07c 100644 --- a/crates/valence/src/inventory.rs +++ b/crates/valence/src/inventory.rs @@ -49,6 +49,14 @@ use crate::component::GameMode; use crate::packet::WritePacket; use crate::prelude::FlushPacketsSet; +mod validate; + +pub(crate) use validate::*; + +/// The number of slots in the "main" part of the player inventory. 3 rows of 9, +/// plus the hotbar. +pub const PLAYER_INVENTORY_MAIN_SLOTS_COUNT: u16 = 36; + #[derive(Debug, Clone, Component)] pub struct Inventory { title: Text, @@ -237,7 +245,7 @@ impl Inventory { std::mem::replace(&mut self.title, title.into()) } - fn slot_slice(&self) -> &[Option] { + pub(crate) fn slot_slice(&self) -> &[Option] { self.slots.as_ref() } @@ -303,6 +311,141 @@ impl OpenInventory { } } +/// A helper to represent the inventory window that the player is currently +/// viewing. Handles dispatching reads to the correct inventory. +/// +/// This is a read-only version of [`InventoryWindowMut`]. +/// +/// ``` +/// # use valence::prelude::*; +/// let mut player_inventory = Inventory::new(InventoryKind::Player); +/// player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 1, None)); +/// let target_inventory = Inventory::new(InventoryKind::Generic9x3); +/// let window = InventoryWindow::new(&player_inventory, Some(&target_inventory)); +/// assert_eq!( +/// window.slot(54), +/// Some(&ItemStack::new(ItemKind::Diamond, 1, None)) +/// ); +/// ``` +pub struct InventoryWindow<'a> { + player_inventory: &'a Inventory, + open_inventory: Option<&'a Inventory>, +} + +impl<'a> InventoryWindow<'a> { + pub fn new(player_inventory: &'a Inventory, open_inventory: Option<&'a Inventory>) -> Self { + Self { + player_inventory, + open_inventory, + } + } + + #[track_caller] + pub fn slot(&self, idx: u16) -> Option<&ItemStack> { + if let Some(open_inv) = self.open_inventory.as_ref() { + if idx < open_inv.slot_count() { + return open_inv.slot(idx); + } else { + return self + .player_inventory + .slot(convert_to_player_slot_id(open_inv.kind(), idx)); + } + } else { + return self.player_inventory.slot(idx); + } + } + + #[track_caller] + pub fn slot_count(&self) -> u16 { + match self.open_inventory.as_ref() { + Some(inv) => inv.slot_count() + PLAYER_INVENTORY_MAIN_SLOTS_COUNT, + None => self.player_inventory.slot_count(), + } + } +} + +/// A helper to represent the inventory window that the player is currently +/// viewing. Handles dispatching reads/writes to the correct inventory. +/// +/// This is a writable version of [`InventoryWindow`]. +/// +/// ``` +/// # use valence::prelude::*; +/// let mut player_inventory = Inventory::new(InventoryKind::Player); +/// let mut target_inventory = Inventory::new(InventoryKind::Generic9x3); +/// let mut window = InventoryWindowMut::new(&mut player_inventory, Some(&mut target_inventory)); +/// window.set_slot(54, ItemStack::new(ItemKind::Diamond, 1, None)); +/// assert_eq!( +/// player_inventory.slot(36), +/// Some(&ItemStack::new(ItemKind::Diamond, 1, None)) +/// ); +/// ``` +pub struct InventoryWindowMut<'a> { + player_inventory: &'a mut Inventory, + open_inventory: Option<&'a mut Inventory>, +} + +impl<'a> InventoryWindowMut<'a> { + pub fn new( + player_inventory: &'a mut Inventory, + open_inventory: Option<&'a mut Inventory>, + ) -> Self { + Self { + player_inventory, + open_inventory, + } + } + + #[track_caller] + pub fn slot(&self, idx: u16) -> Option<&ItemStack> { + if let Some(open_inv) = self.open_inventory.as_ref() { + if idx < open_inv.slot_count() { + return open_inv.slot(idx); + } else { + return self + .player_inventory + .slot(convert_to_player_slot_id(open_inv.kind(), idx)); + } + } else { + return self.player_inventory.slot(idx); + } + } + + #[track_caller] + #[must_use] + pub fn replace_slot( + &mut self, + idx: u16, + item: impl Into>, + ) -> Option { + assert!(idx < self.slot_count(), "slot index of {idx} out of bounds"); + + if let Some(open_inv) = self.open_inventory.as_mut() { + if idx < open_inv.slot_count() { + open_inv.replace_slot(idx, item) + } else { + self.player_inventory + .replace_slot(convert_to_player_slot_id(open_inv.kind(), idx), item) + } + } else { + self.player_inventory.replace_slot(idx, item) + } + } + + #[track_caller] + #[inline] + pub fn set_slot(&mut self, idx: u16, item: impl Into>) { + let _ = self.replace_slot(idx, item); + } + + pub fn slot_count(&self) -> u16 { + match self.open_inventory.as_ref() { + Some(inv) => inv.slot_count() + PLAYER_INVENTORY_MAIN_SLOTS_COUNT, + None => self.player_inventory.slot_count(), + } + } +} + pub(crate) struct InventoryPlugin; impl Plugin for InventoryPlugin { @@ -574,7 +717,7 @@ fn handle_click_container( continue; } - cursor_item.0 = event.carried_item.clone(); + cursor_item.set_if_neq(CursorItem(event.carried_item.clone())); for slot in event.slot_changes.clone() { if (0i16..target_inventory.slot_count() as i16).contains(&slot.idx) { @@ -608,8 +751,6 @@ fn handle_click_container( continue; } - // TODO: do more validation on the click - cursor_item.set_if_neq(CursorItem(event.carried_item.clone())); inv_state.client_updated_cursor_item = true; @@ -692,7 +833,7 @@ fn convert_to_player_slot_id(target_kind: InventoryKind, slot_id: u16) -> u16 { } fn convert_hotbar_slot_id(slot_id: u16) -> u16 { - slot_id + 36 + slot_id + PLAYER_INVENTORY_MAIN_SLOTS_COUNT } #[derive(Copy, Clone, PartialEq, Eq, Debug)] @@ -823,10 +964,25 @@ impl From for InventoryKind { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Resource)] +pub struct InventorySettings { + pub enable_item_dupe_check: bool, +} + +impl Default for InventorySettings { + fn default() -> Self { + Self { + enable_item_dupe_check: true, + } + } +} + #[cfg(test)] mod test { use bevy_app::App; use valence_protocol::item::ItemKind; + use valence_protocol::packet::c2s::play::click_slot::{ClickMode, Slot}; + use valence_protocol::packet::c2s::play::ClickSlotC2s; use valence_protocol::packet::S2cPlayPacket; use super::*; @@ -1098,6 +1254,11 @@ mod test { let mut app = App::new(); let (client_ent, mut client_helper) = scenario_single_client(&mut app); let inventory_ent = set_up_open_inventory(&mut app, client_ent); + let mut inventory = app + .world + .get_mut::(inventory_ent) + .expect("could not find inventory for client"); + inventory.set_slot(20, ItemStack::new(ItemKind::Diamond, 2, None)); // Process a tick to get past the "on join" logic. app.update(); @@ -1342,7 +1503,7 @@ mod test { mod dropping_items { use valence_protocol::block_pos::BlockPos; - use valence_protocol::packet::c2s::play::click_slot::ClickMode; + use valence_protocol::packet::c2s::play::click_slot::{ClickMode, Slot}; use valence_protocol::packet::c2s::play::player_action::Action; use valence_protocol::types::Direction; @@ -1567,7 +1728,10 @@ mod test { button: 0, mode: ClickMode::DropKey, state_id: VarInt(state_id), - slots: vec![], + slots: vec![Slot { + idx: 40, + item: Some(ItemStack::new(ItemKind::IronIngot, 31, None)), + }], carried_item: None, }); @@ -1615,7 +1779,10 @@ mod test { button: 1, // pressing control mode: ClickMode::DropKey, state_id: VarInt(state_id), - slots: vec![], + slots: vec![Slot { + idx: 40, + item: None, + }], carried_item: None, }); @@ -1638,4 +1805,69 @@ mod test { Ok(()) } } + + #[test] + fn dragging_items() -> anyhow::Result<()> { + let mut app = App::new(); + let (client_ent, mut client_helper) = scenario_single_client(&mut app); + app.world.get_mut::(client_ent).unwrap().0 = + Some(ItemStack::new(ItemKind::Diamond, 64, None)); + + // Process a tick to get past the "on join" logic. + app.update(); + client_helper.clear_sent(); + + let inv_state = app.world.get::(client_ent).unwrap(); + let window_id = inv_state.window_id; + let state_id = inv_state.state_id.0; + + let drag_packet = ClickSlotC2s { + window_id, + state_id: VarInt(state_id), + slot_idx: -999, + button: 2, + mode: ClickMode::Drag, + slots: vec![ + Slot { + idx: 9, + item: Some(ItemStack::new(ItemKind::Diamond, 21, None)), + }, + Slot { + idx: 10, + item: Some(ItemStack::new(ItemKind::Diamond, 21, None)), + }, + Slot { + idx: 11, + item: Some(ItemStack::new(ItemKind::Diamond, 21, None)), + }, + ], + carried_item: Some(ItemStack::new(ItemKind::Diamond, 1, None)), + }; + client_helper.send(&drag_packet); + + app.update(); + let sent_packets = client_helper.collect_sent()?; + assert_eq!(sent_packets.len(), 0); + + let cursor_item = app + .world + .get::(client_ent) + .expect("could not find client"); + assert_eq!( + cursor_item.0, + Some(ItemStack::new(ItemKind::Diamond, 1, None)) + ); + let inventory = app + .world + .get::(client_ent) + .expect("could not find inventory"); + for i in 9..12 { + assert_eq!( + inventory.slot(i), + Some(&ItemStack::new(ItemKind::Diamond, 21, None)) + ); + } + + Ok(()) + } } diff --git a/crates/valence/src/inventory/validate.rs b/crates/valence/src/inventory/validate.rs new file mode 100644 index 000000000..a1ff1902d --- /dev/null +++ b/crates/valence/src/inventory/validate.rs @@ -0,0 +1,821 @@ +use anyhow::{bail, ensure}; +use valence_protocol::packet::c2s::play::click_slot::ClickMode; +use valence_protocol::packet::c2s::play::ClickSlotC2s; + +use super::{Inventory, InventoryWindow, PLAYER_INVENTORY_MAIN_SLOTS_COUNT}; +use crate::prelude::CursorItem; + +/// Validates a click slot packet enforcing that all fields are valid. +pub(crate) fn validate_click_slot_impossible( + packet: &ClickSlotC2s, + player_inventory: &Inventory, + open_inventory: Option<&Inventory>, +) -> anyhow::Result<()> { + ensure!( + (packet.window_id == 0) == open_inventory.is_none(), + "window id and open inventory mismatch: window_id: {} open_inventory: {}", + packet.window_id, + open_inventory.is_some() + ); + + let max_slot = match open_inventory { + Some(inv) => inv.slot_count() + PLAYER_INVENTORY_MAIN_SLOTS_COUNT, + None => player_inventory.slot_count(), + }; + + // check all slot ids and item counts are valid + ensure!( + packet.slots.iter().all(|s| { + if !(0..=max_slot).contains(&(s.idx as u16)) { + return false; + } + if let Some(slot) = s.item.as_ref() { + let max_stack_size = slot + .item + .max_stack() + .max(slot.count()) + .min(valence_protocol::item::STACK_MAX); + if !(1..=max_stack_size).contains(&slot.count()) { + return false; + } + } + + true + }), + "invalid slot ids or item counts" + ); + + // check carried item count is valid + if let Some(carried_item) = &packet.carried_item { + let max_stack_size = carried_item + .item + .max_stack() + .max(carried_item.count()) + .min(valence_protocol::item::STACK_MAX); + ensure!( + (1..=max_stack_size).contains(&carried_item.count()), + "invalid carried item count" + ); + } + + match packet.mode { + ClickMode::Click => { + ensure!((0..=1).contains(&packet.button), "invalid button"); + ensure!( + (0..=max_slot).contains(&(packet.slot_idx as u16)) || packet.slot_idx == -999, + "invalid slot index" + ) + } + ClickMode::ShiftClick => { + ensure!((0..=1).contains(&packet.button), "invalid button"); + ensure!( + packet.carried_item.is_none(), + "carried item must be empty for a hotbar swap" + ); + ensure!( + (0..=max_slot).contains(&(packet.slot_idx as u16)), + "invalid slot index" + ) + } + ClickMode::Hotbar => { + ensure!(matches!(packet.button, 0..=8 | 40), "invalid button"); + ensure!( + packet.carried_item.is_none(), + "carried item must be empty for a hotbar swap" + ); + } + ClickMode::CreativeMiddleClick => { + ensure!(packet.button == 2, "invalid button"); + ensure!( + (0..=max_slot).contains(&(packet.slot_idx as u16)), + "invalid slot index" + ) + } + ClickMode::DropKey => { + ensure!((0..=1).contains(&packet.button), "invalid button"); + ensure!( + packet.carried_item.is_none(), + "carried item must be empty for an item drop" + ); + ensure!( + (0..=max_slot).contains(&(packet.slot_idx as u16)), + "invalid slot index" + ) + } + ClickMode::Drag => { + ensure!( + matches!(packet.button, 0..=2 | 4..=6 | 8..=10), + "invalid button" + ); + ensure!( + (0..=max_slot).contains(&(packet.slot_idx as u16)) || packet.slot_idx == -999, + "invalid slot index" + ) + } + ClickMode::DoubleClick => ensure!(packet.button == 0, "invalid button"), + } + + Ok(()) +} + +/// Validates a click slot packet, enforcing that items can't be duplicated, eg. +/// conservation of mass. +/// +/// Relies on assertions made by [`validate_click_slot_impossible`]. +pub(crate) fn validate_click_slot_item_duplication( + packet: &ClickSlotC2s, + player_inventory: &Inventory, + open_inventory: Option<&Inventory>, + cursor_item: &CursorItem, +) -> anyhow::Result<()> { + let window = InventoryWindow { + player_inventory, + open_inventory, + }; + + match packet.mode { + ClickMode::Click => { + if packet.slot_idx == -999 { + // Clicked outside the window, so the client is dropping an item + ensure!(packet.slots.is_empty(), "slot modifications must be empty"); + + // Clicked outside the window + let count_deltas = calculate_net_item_delta(packet, &window, cursor_item); + let expected_delta = match packet.button { + 1 => -1, + 0 => -cursor_item + .0 + .as_ref() + .map(|s| s.count() as i32) + .unwrap_or(0), + _ => unreachable!(), + }; + ensure!( + count_deltas == expected_delta, + "invalid item delta: expected {}, got {}", + expected_delta, + count_deltas + ); + } else { + ensure!( + packet.slots.len() == 1, + "click must modify one slot, got {}", + packet.slots.len() + ); + + let old_slot = window.slot(packet.slots[0].idx as u16); + // TODO: make sure NBT is the same + // Sometimes, the client will add nbt data to an item if it's missing, like + // "Damage" to a sword + let should_swap = packet.button == 0 + && match (old_slot, cursor_item.0.as_ref()) { + (Some(old_slot), Some(cursor_item)) => old_slot.item != cursor_item.item, + (Some(_), None) => true, + (None, Some(cursor_item)) => { + cursor_item.count() <= cursor_item.item.max_stack() + } + (None, None) => false, + }; + + if should_swap { + // assert that a swap occurs + ensure!( + old_slot == packet.carried_item.as_ref() + && cursor_item.0 == packet.slots[0].item, + "swapped items must match" + ); + } else { + // assert that a merge occurs + let count_deltas = calculate_net_item_delta(packet, &window, cursor_item); + ensure!( + count_deltas == 0, + "invalid item delta for stack merge: {}", + count_deltas + ); + } + } + } + ClickMode::ShiftClick => { + ensure!( + (2..=3).contains(&packet.slots.len()), + "shift click must modify 2 or 3 slots, got {}", + packet.slots.len() + ); + + let count_deltas = calculate_net_item_delta(packet, &window, cursor_item); + ensure!( + count_deltas == 0, + "invalid item delta: expected 0, got {}", + count_deltas + ); + + let Some(item_kind) = packet + .slots + .iter() + .filter_map(|s| s.item.as_ref()) + .next() + .map(|s| s.item) else { + bail!("shift click must move an item"); + }; + + let Some(old_slot_kind) = window.slot(packet.slot_idx as u16).map(|s| s.item) else { + bail!("shift click must move an item"); + }; + ensure!( + old_slot_kind == item_kind, + "shift click must move the same item kind as modified slots" + ); + + // assert all moved items are the same kind + ensure!( + packet + .slots + .iter() + .filter_map(|s| s.item.as_ref()) + .all(|s| s.item == item_kind), + "shift click must move the same item kind" + ); + } + + ClickMode::Hotbar => { + ensure!( + packet.slots.len() == 2, + "hotbar swap must modify two slots, got {}", + packet.slots.len() + ); + + let count_deltas = calculate_net_item_delta(packet, &window, cursor_item); + ensure!( + count_deltas == 0, + "invalid item delta: expected 0, got {}", + count_deltas + ); + + // assert that a swap occurs + let old_slots = [ + window.slot(packet.slots[0].idx as u16), + window.slot(packet.slots[1].idx as u16), + ]; + ensure!( + old_slots + .iter() + .any(|s| s == &packet.slots[0].item.as_ref()) + && old_slots + .iter() + .any(|s| s == &packet.slots[1].item.as_ref()), + "swapped items must match" + ); + } + ClickMode::CreativeMiddleClick => {} + ClickMode::DropKey => { + ensure!( + packet.slots.len() == 1, + "drop key must modify exactly one slot" + ); + ensure!( + packet.slot_idx == packet.slots.first().map(|s| s.idx).unwrap_or(-2), + "slot index does not match modified slot" + ); + + let old_slot = window.slot(packet.slot_idx as u16); + let new_slot = packet.slots[0].item.as_ref(); + let is_transmuting = match (old_slot, new_slot) { + // TODO: make sure NBT is the same + // Sometimes, the client will add nbt data to an item if it's missing, like "Damage" + // to a sword + (Some(old_slot), Some(new_slot)) => old_slot.item != new_slot.item, + (_, None) => false, + (None, Some(_)) => true, + }; + ensure!(!is_transmuting, "transmuting items is not allowed"); + + let count_deltas = calculate_net_item_delta(packet, &window, cursor_item); + + let expected_delta = match packet.button { + 0 => -1, + 1 => -old_slot.map(|s| s.count() as i32).unwrap_or(0), + _ => unreachable!(), + }; + ensure!( + count_deltas == expected_delta, + "invalid item delta: expected {}, got {}", + expected_delta, + count_deltas + ); + } + ClickMode::Drag => { + if matches!(packet.button, 2 | 6 | 10) { + let count_deltas = calculate_net_item_delta(packet, &window, cursor_item); + ensure!( + count_deltas == 0, + "invalid item delta: expected 0, got {}", + count_deltas + ); + } else { + ensure!(packet.slots.is_empty() && packet.carried_item == cursor_item.0); + } + } + ClickMode::DoubleClick => { + let count_deltas = calculate_net_item_delta(packet, &window, cursor_item); + ensure!( + count_deltas == 0, + "invalid item delta: expected 0, got {}", + count_deltas + ); + } + } + + Ok(()) +} + +/// Calculate the total difference in item counts if the changes in this packet +/// were to be applied. +/// +/// Returns a positive number if items were added to the window, and a negative +/// number if items were removed from the window. +fn calculate_net_item_delta( + packet: &ClickSlotC2s, + window: &InventoryWindow, + cursor_item: &CursorItem, +) -> i32 { + let mut net_item_delta: i32 = 0; + + for slot in &packet.slots { + let old_slot = window.slot(slot.idx as u16); + let new_slot = slot.item.as_ref(); + + net_item_delta += match (old_slot, new_slot) { + (Some(old), Some(new)) => new.count() as i32 - old.count() as i32, + (Some(old), None) => -(old.count() as i32), + (None, Some(new)) => new.count() as i32, + (None, None) => 0, + }; + } + + net_item_delta += match (cursor_item.0.as_ref(), packet.carried_item.as_ref()) { + (Some(old), Some(new)) => new.count() as i32 - old.count() as i32, + (Some(old), None) => -(old.count() as i32), + (None, Some(new)) => new.count() as i32, + (None, None) => 0, + }; + + net_item_delta +} + +#[cfg(test)] +mod test { + use valence_protocol::item::{ItemKind, ItemStack}; + use valence_protocol::packet::c2s::play::click_slot::Slot; + use valence_protocol::var_int::VarInt; + + use super::*; + use crate::prelude::InventoryKind; + + #[test] + fn net_item_delta_1() { + let drag_packet = ClickSlotC2s { + window_id: 2, + state_id: VarInt(14), + slot_idx: -999, + button: 2, + mode: ClickMode::Drag, + slots: vec![ + Slot { + idx: 4, + item: Some(ItemStack::new(ItemKind::Diamond, 21, None)), + }, + Slot { + idx: 3, + item: Some(ItemStack::new(ItemKind::Diamond, 21, None)), + }, + Slot { + idx: 5, + item: Some(ItemStack::new(ItemKind::Diamond, 21, None)), + }, + ], + carried_item: Some(ItemStack::new(ItemKind::Diamond, 1, None)), + }; + + let player_inventory = Inventory::new(InventoryKind::Player); + let inventory = Inventory::new(InventoryKind::Generic9x1); + let window = InventoryWindow::new(&player_inventory, Some(&inventory)); + let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 64, None))); + + assert_eq!( + calculate_net_item_delta(&drag_packet, &window, &cursor_item), + 0 + ); + } + + #[test] + fn net_item_delta_2() { + let drag_packet = ClickSlotC2s { + window_id: 2, + state_id: VarInt(14), + slot_idx: -999, + button: 2, + mode: ClickMode::Click, + slots: vec![ + Slot { + idx: 2, + item: Some(ItemStack::new(ItemKind::Diamond, 2, None)), + }, + Slot { + idx: 3, + item: Some(ItemStack::new(ItemKind::IronIngot, 2, None)), + }, + Slot { + idx: 4, + item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)), + }, + Slot { + idx: 5, + item: Some(ItemStack::new(ItemKind::Emerald, 2, None)), + }, + ], + carried_item: Some(ItemStack::new(ItemKind::OakWood, 2, None)), + }; + + let player_inventory = Inventory::new(InventoryKind::Player); + let inventory = Inventory::new(InventoryKind::Generic9x1); + let window = InventoryWindow::new(&player_inventory, Some(&inventory)); + let cursor_item = CursorItem::default(); + + assert_eq!( + calculate_net_item_delta(&drag_packet, &window, &cursor_item), + 10 + ); + } + + #[test] + fn click_filled_slot_with_empty_cursor_success() { + let player_inventory = Inventory::new(InventoryKind::Player); + let mut inventory = Inventory::new(InventoryKind::Generic9x1); + inventory.set_slot(0, ItemStack::new(ItemKind::Diamond, 20, None)); + let cursor_item = CursorItem::default(); + let packet = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![Slot { idx: 0, item: None }], + carried_item: inventory.slot(0).cloned(), + }; + + validate_click_slot_impossible(&packet, &player_inventory, Some(&inventory)) + .expect("packet should be valid"); + validate_click_slot_item_duplication( + &packet, + &player_inventory, + Some(&inventory), + &cursor_item, + ) + .expect("packet should not fail item duplication check"); + } + + #[test] + fn click_slot_with_filled_cursor_success() { + let player_inventory = Inventory::new(InventoryKind::Player); + let inventory1 = Inventory::new(InventoryKind::Generic9x1); + let mut inventory2 = Inventory::new(InventoryKind::Generic9x1); + inventory2.set_slot(0, ItemStack::new(ItemKind::Diamond, 10, None)); + let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 20, None))); + let packet1 = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![Slot { + idx: 0, + item: Some(ItemStack::new(ItemKind::Diamond, 20, None)), + }], + carried_item: None, + }; + let packet2 = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![Slot { + idx: 0, + item: Some(ItemStack::new(ItemKind::Diamond, 30, None)), + }], + carried_item: None, + }; + + validate_click_slot_impossible(&packet1, &player_inventory, Some(&inventory1)) + .expect("packet should be valid"); + validate_click_slot_item_duplication( + &packet1, + &player_inventory, + Some(&inventory1), + &cursor_item, + ) + .expect("packet should not fail item duplication check"); + + validate_click_slot_impossible(&packet2, &player_inventory, Some(&inventory2)) + .expect("packet should be valid"); + validate_click_slot_item_duplication( + &packet2, + &player_inventory, + Some(&inventory2), + &cursor_item, + ) + .expect("packet should not fail item duplication check"); + } + + #[test] + fn click_filled_slot_with_filled_cursor_stack_overflow_success() { + let player_inventory = Inventory::new(InventoryKind::Player); + let mut inventory = Inventory::new(InventoryKind::Generic9x1); + inventory.set_slot(0, ItemStack::new(ItemKind::Diamond, 20, None)); + let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 64, None))); + let packet = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![Slot { + idx: 0, + item: Some(ItemStack::new(ItemKind::Diamond, 64, None)), + }], + carried_item: Some(ItemStack::new(ItemKind::Diamond, 20, None)), + }; + + validate_click_slot_impossible(&packet, &player_inventory, Some(&inventory)) + .expect("packet should be valid"); + validate_click_slot_item_duplication( + &packet, + &player_inventory, + Some(&inventory), + &cursor_item, + ) + .expect("packet should not fail item duplication check"); + } + + #[test] + fn click_filled_slot_with_filled_cursor_different_item_success() { + let player_inventory = Inventory::new(InventoryKind::Player); + let mut inventory = Inventory::new(InventoryKind::Generic9x1); + inventory.set_slot(0, ItemStack::new(ItemKind::IronIngot, 2, None)); + let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 2, None))); + let packet = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![Slot { + idx: 0, + item: Some(ItemStack::new(ItemKind::Diamond, 2, None)), + }], + carried_item: Some(ItemStack::new(ItemKind::IronIngot, 2, None)), + }; + + validate_click_slot_impossible(&packet, &player_inventory, Some(&inventory)) + .expect("packet should be valid"); + validate_click_slot_item_duplication( + &packet, + &player_inventory, + Some(&inventory), + &cursor_item, + ) + .expect("packet should not fail item duplication check"); + } + + #[test] + fn click_slot_with_filled_cursor_failure() { + let player_inventory = Inventory::new(InventoryKind::Player); + let inventory1 = Inventory::new(InventoryKind::Generic9x1); + let mut inventory2 = Inventory::new(InventoryKind::Generic9x1); + inventory2.set_slot(0, ItemStack::new(ItemKind::Diamond, 10, None)); + let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 20, None))); + let packet1 = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![Slot { + idx: 0, + item: Some(ItemStack::new(ItemKind::Diamond, 22, None)), + }], + carried_item: None, + }; + let packet2 = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![Slot { + idx: 0, + item: Some(ItemStack::new(ItemKind::Diamond, 32, None)), + }], + carried_item: None, + }; + let packet3 = ClickSlotC2s { + window_id: 1, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 0, + slots: vec![ + Slot { + idx: 0, + item: Some(ItemStack::new(ItemKind::Diamond, 22, None)), + }, + Slot { + idx: 1, + item: Some(ItemStack::new(ItemKind::Diamond, 22, None)), + }, + ], + carried_item: None, + }; + + validate_click_slot_impossible(&packet1, &player_inventory, Some(&inventory1)) + .expect("packet 1 should be valid"); + validate_click_slot_item_duplication( + &packet1, + &player_inventory, + Some(&inventory1), + &cursor_item, + ) + .expect_err("packet 1 should fail item duplication check"); + + validate_click_slot_impossible(&packet2, &player_inventory, Some(&inventory2)) + .expect("packet 2 should be valid"); + validate_click_slot_item_duplication( + &packet2, + &player_inventory, + Some(&inventory2), + &cursor_item, + ) + .expect_err("packet 2 should fail item duplication check"); + + validate_click_slot_impossible(&packet3, &player_inventory, Some(&inventory1)) + .expect("packet 3 should be valid"); + validate_click_slot_item_duplication( + &packet3, + &player_inventory, + Some(&inventory1), + &cursor_item, + ) + .expect_err("packet 3 should fail item duplication check"); + } + + #[test] + fn disallow_item_transmutation() { + // no alchemy allowed - make sure that lead can't be turned into gold + + let mut player_inventory = Inventory::new(InventoryKind::Player); + player_inventory.set_slot(9, ItemStack::new(ItemKind::Lead, 2, None)); + let cursor_item = CursorItem::default(); + + let packets = vec![ + ClickSlotC2s { + window_id: 0, + button: 0, + mode: ClickMode::ShiftClick, + state_id: VarInt(0), + slot_idx: 9, + slots: vec![ + Slot { idx: 9, item: None }, + Slot { + idx: 36, + item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)), + }, + ], + carried_item: None, + }, + ClickSlotC2s { + window_id: 0, + button: 0, + mode: ClickMode::Hotbar, + state_id: VarInt(0), + slot_idx: 9, + slots: vec![ + Slot { idx: 9, item: None }, + Slot { + idx: 36, + item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)), + }, + ], + carried_item: None, + }, + ClickSlotC2s { + window_id: 0, + button: 0, + mode: ClickMode::Click, + state_id: VarInt(0), + slot_idx: 9, + slots: vec![Slot { idx: 9, item: None }], + carried_item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)), + }, + ClickSlotC2s { + window_id: 0, + button: 0, + mode: ClickMode::DropKey, + state_id: VarInt(0), + slot_idx: 9, + slots: vec![Slot { + idx: 9, + item: Some(ItemStack::new(ItemKind::GoldIngot, 1, None)), + }], + carried_item: None, + }, + ]; + + for (i, packet) in packets.iter().enumerate() { + validate_click_slot_impossible(packet, &player_inventory, None) + .unwrap_or_else(|e| panic!("packet {i} should be valid: {e}")); + validate_click_slot_item_duplication(packet, &player_inventory, None, &cursor_item) + .expect_err(&format!( + "packet {i} passed item duplication check when it should have failed" + )); + } + } + + #[test] + fn allow_shift_click_overflow_to_new_stack() { + let mut player_inventory = Inventory::new(InventoryKind::Player); + player_inventory.set_slot(9, ItemStack::new(ItemKind::Diamond, 64, None)); + player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 32, None)); + let cursor_item = CursorItem::default(); + + let packet = ClickSlotC2s { + window_id: 0, + state_id: VarInt(2), + slot_idx: 9, + button: 0, + mode: ClickMode::ShiftClick, + slots: vec![ + Slot { + idx: 37, + item: Some(ItemStack::new(ItemKind::Diamond, 32, None)), + }, + Slot { + idx: 36, + item: Some(ItemStack::new(ItemKind::Diamond, 64, None)), + }, + Slot { idx: 9, item: None }, + ], + carried_item: None, + }; + + validate_click_slot_impossible(&packet, &player_inventory, None) + .expect("packet should be valid"); + validate_click_slot_item_duplication(&packet, &player_inventory, None, &cursor_item) + .expect("packet should pass item duplication check"); + } + + #[test] + fn allow_pickup_overfull_stack_click() { + let mut player_inventory = Inventory::new(InventoryKind::Player); + player_inventory.set_slot(9, ItemStack::new(ItemKind::Apple, 100, None)); + let cursor_item = CursorItem::default(); + + let packet = ClickSlotC2s { + window_id: 0, + state_id: VarInt(2), + slot_idx: 9, + button: 0, + mode: ClickMode::Click, + slots: vec![Slot { idx: 9, item: None }], + carried_item: Some(ItemStack::new(ItemKind::Apple, 100, None)), + }; + + validate_click_slot_impossible(&packet, &player_inventory, None) + .expect("packet should be valid"); + validate_click_slot_item_duplication(&packet, &player_inventory, None, &cursor_item) + .expect("packet should pass item duplication check"); + } + + #[test] + fn allow_place_overfull_stack_click() { + let player_inventory = Inventory::new(InventoryKind::Player); + let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Apple, 100, None))); + + let packet = ClickSlotC2s { + window_id: 0, + state_id: VarInt(2), + slot_idx: 9, + button: 0, + mode: ClickMode::Click, + slots: vec![Slot { + idx: 9, + item: Some(ItemStack::new(ItemKind::Apple, 64, None)), + }], + carried_item: Some(ItemStack::new(ItemKind::Apple, 36, None)), + }; + + validate_click_slot_impossible(&packet, &player_inventory, None) + .expect("packet should be valid"); + validate_click_slot_item_duplication(&packet, &player_inventory, None, &cursor_item) + .expect("packet should pass item duplication check"); + } +} diff --git a/crates/valence/src/lib.rs b/crates/valence/src/lib.rs index 101896b00..b4deb8ff6 100644 --- a/crates/valence/src/lib.rs +++ b/crates/valence/src/lib.rs @@ -59,7 +59,9 @@ pub mod prelude { pub use entity::{EntityAnimation, EntityKind, EntityManager, EntityStatus, HeadYaw}; pub use glam::DVec3; pub use instance::{Block, BlockMut, BlockRef, Chunk, Instance}; - pub use inventory::{Inventory, InventoryKind, OpenInventory}; + pub use inventory::{ + Inventory, InventoryKind, InventoryWindow, InventoryWindowMut, OpenInventory, + }; pub use player_list::{PlayerList, PlayerListEntry}; pub use protocol::block::{BlockState, PropName, PropValue}; pub use protocol::ident::Ident; diff --git a/crates/valence/src/server.rs b/crates/valence/src/server.rs index 31a966b6f..93c55b088 100644 --- a/crates/valence/src/server.rs +++ b/crates/valence/src/server.rs @@ -25,7 +25,7 @@ use crate::config::{AsyncCallbacks, ConnectionMode, ServerPlugin}; use crate::dimension::{validate_dimensions, Dimension, DimensionId}; use crate::entity::EntityPlugin; use crate::instance::{Instance, InstancePlugin}; -use crate::inventory::InventoryPlugin; +use crate::inventory::{InventoryPlugin, InventorySettings}; use crate::player_list::PlayerListPlugin; use crate::prelude::event::ClientEventPlugin; use crate::prelude::ComponentPlugin; @@ -303,6 +303,7 @@ pub fn build_plugin( // Insert resources. app.insert_resource(server); + app.insert_resource(InventorySettings::default()); // Make the app loop forever at the configured TPS. { diff --git a/crates/valence_protocol/src/item.rs b/crates/valence_protocol/src/item.rs index 002d1797c..2963b08cb 100644 --- a/crates/valence_protocol/src/item.rs +++ b/crates/valence_protocol/src/item.rs @@ -16,8 +16,8 @@ pub struct ItemStack { pub nbt: Option, } -const STACK_MIN: u8 = 1; -const STACK_MAX: u8 = 127; +pub const STACK_MIN: u8 = 1; +pub const STACK_MAX: u8 = 127; impl ItemStack { pub fn new(item: ItemKind, count: u8, nbt: Option) -> Self { diff --git a/crates/valence_protocol/src/packet/c2s/play/click_slot.rs b/crates/valence_protocol/src/packet/c2s/play/click_slot.rs index b69b09160..fe5de83fd 100644 --- a/crates/valence_protocol/src/packet/c2s/play/click_slot.rs +++ b/crates/valence_protocol/src/packet/c2s/play/click_slot.rs @@ -7,6 +7,8 @@ pub struct ClickSlotC2s { pub window_id: u8, pub state_id: VarInt, pub slot_idx: i16, + /// The button used to click the slot. An enum can't easily be used for this + /// because the meaning of this value depends on the mode. pub button: i8, pub mode: ClickMode, pub slots: Vec,