From f5f50b85e5d427aab6a0ef00570b4076b61babe8 Mon Sep 17 00:00:00 2001 From: mat Date: Wed, 4 Jun 2025 01:53:24 -0330 Subject: [PATCH] re-enable click prediction and fix related issues --- azalea-client/src/plugins/inventory.rs | 233 ++++++++++++++++-- azalea-client/src/plugins/loading.rs | 12 +- .../src/location_enum.rs | 1 + azalea-inventory/src/operations.rs | 39 ++- azalea-inventory/src/slot.rs | 44 ++-- azalea/src/container.rs | 37 ++- 6 files changed, 305 insertions(+), 61 deletions(-) diff --git a/azalea-client/src/plugins/inventory.rs b/azalea-client/src/plugins/inventory.rs index a7e45ffb..29a81410 100644 --- a/azalea-client/src/plugins/inventory.rs +++ b/azalea-client/src/plugins/inventory.rs @@ -1,4 +1,7 @@ -use std::collections::{HashMap, HashSet}; +use std::{ + cmp, + collections::{HashMap, HashSet}, +}; use azalea_chat::FormattedText; pub use azalea_inventory::*; @@ -341,30 +344,95 @@ impl Inventory { // player.drop(item, true); } } - ClickOperation::Pickup( - PickupClick::Left { slot: Some(slot) } | PickupClick::Right { slot: Some(slot) }, + &ClickOperation::Pickup( + // lol + ref pickup @ (PickupClick::Left { slot: Some(slot) } + | PickupClick::Right { slot: Some(slot) }), ) => { - let Some(slot_item) = self.menu().slot(*slot as usize) else { + let slot = slot as usize; + let Some(slot_item) = self.menu().slot(slot) else { return; }; - let carried = &self.carried; - // vanilla does a check called tryItemClickBehaviourOverride - // here - // i don't understand it so i didn't implement it + + if self.try_item_click_behavior_override(operation, slot) { + return; + } + + let is_left_click = matches!(pickup, PickupClick::Left { .. }); + match slot_item { - ItemStack::Empty => if carried.is_present() {}, - ItemStack::Present(_) => todo!(), + ItemStack::Empty => { + if self.carried.is_present() { + let place_count = if is_left_click { + self.carried.count() + } else { + 1 + }; + self.carried = + self.safe_insert(slot, self.carried.clone(), place_count); + } + } + ItemStack::Present(_) => { + if !self.menu().may_pickup(slot) { + return; + } + if let ItemStack::Present(carried) = self.carried.clone() { + let slot_is_same_item_as_carried = slot_item + .as_present() + .is_some_and(|s| carried.is_same_item_and_components(s)); + + if self.menu().may_place(slot, &carried) { + if slot_is_same_item_as_carried { + let place_count = if is_left_click { carried.count } else { 1 }; + self.carried = + self.safe_insert(slot, self.carried.clone(), place_count); + } else if carried.count + <= self + .menu() + .max_stack_size(slot) + .min(carried.kind.max_stack_size()) + { + // swap slot_item and carried + self.carried = slot_item.clone(); + let slot_item = self.menu_mut().slot_mut(slot).unwrap(); + *slot_item = carried.into(); + } + } else if slot_is_same_item_as_carried + && let Some(removed) = self.try_remove( + slot, + slot_item.count(), + carried.kind.max_stack_size() - carried.count, + ) + { + self.carried.as_present_mut().unwrap().count += removed.count(); + // slot.onTake(player, removed); + } + } else { + let pickup_count = if is_left_click { + slot_item.count() + } else { + (slot_item.count() + 1) / 2 + }; + if let Some(new_slot_item) = + self.try_remove(slot, pickup_count, i32::MAX) + { + self.carried = new_slot_item; + // slot.onTake(player, newSlot); + } + } + } } } - ClickOperation::QuickMove( + &ClickOperation::QuickMove( QuickMoveClick::Left { slot } | QuickMoveClick::Right { slot }, ) => { // in vanilla it also tests if QuickMove has a slot index of -999 // but i don't think that's ever possible so it's not covered here + let slot = slot as usize; loop { - let new_slot_item = self.menu_mut().quick_move_stack(*slot as usize); - let slot_item = self.menu().slot(*slot as usize).unwrap(); - if new_slot_item.is_empty() || slot_item != &new_slot_item { + let new_slot_item = self.menu_mut().quick_move_stack(slot); + let slot_item = self.menu().slot(slot).unwrap(); + if new_slot_item.is_empty() || slot_item.kind() != new_slot_item.kind() { break; } } @@ -390,15 +458,16 @@ impl Inventory { *target_slot = source_slot; } } else if source_slot.is_empty() { - let ItemStack::Present(target_item) = target_slot else { - unreachable!("target slot is not empty but is not present"); - }; + let target_item = target_slot + .as_present() + .expect("target slot was already checked to not be empty"); if self.menu().may_place(source_slot_index, target_item) { // get the target_item but mutable let source_max_stack_size = self.menu().max_stack_size(source_slot_index); let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap(); - let new_source_slot = target_slot.split(source_max_stack_size); + let new_source_slot = + target_slot.split(source_max_stack_size.try_into().unwrap()); *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot; } } else if self.menu().may_pickup(source_slot_index) { @@ -407,11 +476,12 @@ impl Inventory { }; if self.menu().may_place(source_slot_index, target_item) { let source_max_stack = self.menu().max_stack_size(source_slot_index); - if target_slot.count() > source_max_stack as i32 { + if target_slot.count() > source_max_stack { // if there's more than the max stack size in the target slot let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap(); - let new_source_slot = target_slot.split(source_max_stack); + let new_source_slot = + target_slot.split(source_max_stack.try_into().unwrap()); *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot; // if !self.inventory_menu.add(new_source_slot) { // player.drop(new_source_slot, true); @@ -535,6 +605,67 @@ impl Inventory { let hotbar_items = &inventory.slots()[inventory.hotbar_slots_range()]; hotbar_items[self.selected_hotbar_slot as usize].clone() } + + /// TODO: implement bundles + fn try_item_click_behavior_override( + &self, + _operation: &ClickOperation, + _slot_item_index: usize, + ) -> bool { + false + } + + fn safe_insert(&mut self, slot: usize, src_item: ItemStack, take_count: i32) -> ItemStack { + let Some(slot_item) = self.menu_mut().slot_mut(slot) else { + return src_item; + }; + let ItemStack::Present(mut src_item) = src_item else { + return src_item; + }; + + let take_count = cmp::min( + cmp::min(take_count, src_item.count), + src_item.kind.max_stack_size() - slot_item.count(), + ); + if take_count <= 0 { + return src_item.into(); + } + let take_count = take_count as u32; + + if slot_item.is_empty() { + *slot_item = src_item.split(take_count).into(); + } else if let ItemStack::Present(slot_item) = slot_item + && slot_item.is_same_item_and_components(&src_item) + { + src_item.count -= take_count as i32; + slot_item.count += take_count as i32; + } + + src_item.into() + } + + fn try_remove(&mut self, slot: usize, count: i32, limit: i32) -> Option { + if !self.menu().may_pickup(slot) { + return None; + } + let mut slot_item = self.menu().slot(slot)?.clone(); + if !self.menu().allow_modification(slot) && limit < slot_item.count() { + return None; + } + + let count = count.min(limit); + if count <= 0 { + return None; + } + // vanilla calls .remove here but i think it has the same behavior as split? + let removed = slot_item.split(count as u32); + + if removed.is_present() && slot_item.is_empty() { + *self.menu_mut().slot_mut(slot).unwrap() = ItemStack::Empty; + } + + Some(removed) + } } fn can_item_quick_replace( @@ -680,12 +811,12 @@ pub struct ContainerClickEvent { pub operation: ClickOperation, } pub fn handle_container_click_event( - mut query: Query<(Entity, &mut Inventory)>, + mut query: Query<(Entity, &mut Inventory, Option<&PlayerAbilities>)>, mut events: EventReader, mut commands: Commands, ) { for event in events.read() { - let (entity, mut inventory) = query.get_mut(event.entity).unwrap(); + let (entity, mut inventory, player_abilities) = query.get_mut(event.entity).unwrap(); if inventory.id != event.window_id { error!( "Tried to click container with ID {}, but the current container ID is {}. Click packet won't be sent.", @@ -694,16 +825,18 @@ pub fn handle_container_click_event( continue; } - let menu = inventory.menu_mut(); - let old_slots = menu.slots().clone(); - - // menu.click(&event.operation); + let old_slots = inventory.menu().slots(); + inventory.simulate_click( + &event.operation, + player_abilities.unwrap_or(&PlayerAbilities::default()), + ); + let new_slots = inventory.menu().slots(); // see which slots changed after clicking and put them in the hashmap // the server uses this to check if we desynced let mut changed_slots: HashMap = HashMap::new(); for (slot_index, old_slot) in old_slots.iter().enumerate() { - let new_slot = &menu.slots()[slot_index]; + let new_slot = &new_slots[slot_index]; if old_slot != new_slot { changed_slots.insert(slot_index as u16, HashedStack::from(new_slot)); } @@ -784,3 +917,49 @@ fn handle_set_selected_hotbar_slot_event( )); } } + +#[cfg(test)] +mod tests { + use azalea_registry::Item; + + use super::*; + + #[test] + fn test_simulate_shift_click_in_crafting_table() { + let spruce_planks = ItemStack::Present(ItemStackData { + count: 4, + kind: Item::SprucePlanks, + components: Default::default(), + }); + + let mut inventory = Inventory { + inventory_menu: Menu::Player(azalea_inventory::Player::default()), + id: 1, + container_menu: Some(Menu::Crafting { + result: spruce_planks.clone(), + // simulate_click won't delete the items from here + grid: SlotList::default(), + player: SlotList::default(), + }), + container_menu_title: None, + carried: ItemStack::Empty, + state_id: 0, + quick_craft_status: QuickCraftStatusKind::Start, + quick_craft_kind: QuickCraftKind::Middle, + quick_craft_slots: HashSet::new(), + selected_hotbar_slot: 0, + }; + + inventory.simulate_click( + &ClickOperation::QuickMove(QuickMoveClick::Left { slot: 0 }), + &PlayerAbilities::default(), + ); + + let new_slots = inventory.menu().slots(); + assert_eq!(&new_slots[0], &ItemStack::Empty); + assert_eq!( + &new_slots[*Menu::CRAFTING_PLAYER_SLOTS.start()], + &spruce_planks + ); + } +} diff --git a/azalea-client/src/plugins/loading.rs b/azalea-client/src/plugins/loading.rs index 33290f39..217d6f75 100644 --- a/azalea-client/src/plugins/loading.rs +++ b/azalea-client/src/plugins/loading.rs @@ -1,5 +1,5 @@ use azalea_core::tick::GameTick; -use azalea_entity::InLoadedChunk; +use azalea_entity::{InLoadedChunk, LocalEntity}; use azalea_physics::PhysicsSet; use azalea_protocol::packets::game::ServerboundPlayerLoaded; use bevy_app::{App, Plugin}; @@ -29,9 +29,17 @@ impl Plugin for PlayerLoadedPlugin { #[derive(Component)] pub struct HasClientLoaded; +#[allow(clippy::type_complexity)] pub fn player_loaded_packet( mut commands: Commands, - query: Query, Without)>, + query: Query< + Entity, + ( + With, + With, + Without, + ), + >, ) { for entity in query.iter() { commands.trigger(SendPacketEvent::new(entity, ServerboundPlayerLoaded)); diff --git a/azalea-inventory/azalea-inventory-macros/src/location_enum.rs b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs index 615f07e5..46db3e76 100644 --- a/azalea-inventory/azalea-inventory-macros/src/location_enum.rs +++ b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs @@ -48,6 +48,7 @@ pub fn generate(input: &DeclareMenus) -> TokenStream { } quote! { + #[derive(Debug)] pub enum MenuLocation { #menu_location_variants } diff --git a/azalea-inventory/src/operations.rs b/azalea-inventory/src/operations.rs index f410c2c5..e7668ab5 100644 --- a/azalea-inventory/src/operations.rs +++ b/azalea-inventory/src/operations.rs @@ -617,13 +617,26 @@ impl Menu { } /// Whether the item in the given slot could be clicked and picked up. + /// /// TODO: right now this always returns true pub fn may_pickup(&self, _source_slot_index: usize) -> bool { true } + /// Whether the item in the slot can be picked up and placed. + pub fn allow_modification(&self, target_slot_index: usize) -> bool { + if !self.may_pickup(target_slot_index) { + return false; + } + let item = self.slot(target_slot_index).unwrap(); + // the default here probably doesn't matter since we should only be calling this + // if we already checked that the slot isn't empty + item.as_present() + .is_some_and(|item| self.may_place(target_slot_index, item)) + } + /// Get the maximum number of items that can be placed in this slot. - pub fn max_stack_size(&self, _target_slot_index: usize) -> u32 { + pub fn max_stack_size(&self, _target_slot_index: usize) -> i32 { 64 } @@ -657,7 +670,10 @@ impl Menu { } } - item_slot.is_empty() + let is_source_slot_now_empty = item_slot.is_empty(); + + *self.slot_mut(item_slot_index).unwrap() = item_slot; + is_source_slot_now_empty } /// Merge this item slot into the target item slot, only if the target item @@ -677,7 +693,7 @@ impl Menu { && target_item.is_same_item_and_components(item) { let slot_item_limit = self.max_stack_size(target_slot_index); - let new_target_slot_data = item.split(u32::min(slot_item_limit, item.count as u32)); + let new_target_slot_data = item.split(i32::min(slot_item_limit, item.count) as u32); // get the target slot again but mut this time so we can update it let target_slot = self.slot_mut(target_slot_index).unwrap(); @@ -688,18 +704,23 @@ impl Menu { } } - fn move_item_to_slot_if_empty(&mut self, item_slot: &mut ItemStack, target_slot_index: usize) { - let ItemStack::Present(item) = item_slot else { + fn move_item_to_slot_if_empty( + &mut self, + source_item: &mut ItemStack, + target_slot_index: usize, + ) { + let ItemStack::Present(source_item_data) = source_item else { return; }; let target_slot = self.slot(target_slot_index).unwrap(); - if target_slot.is_empty() && self.may_place(target_slot_index, item) { + if target_slot.is_empty() && self.may_place(target_slot_index, source_item_data) { let slot_item_limit = self.max_stack_size(target_slot_index); - let new_target_slot_data = item.split(u32::min(slot_item_limit, item.count as u32)); + let new_target_slot_data = + source_item_data.split(i32::min(slot_item_limit, source_item_data.count) as u32); + source_item.update_empty(); let target_slot = self.slot_mut(target_slot_index).unwrap(); - *target_slot = ItemStack::Present(new_target_slot_data); - item_slot.update_empty(); + *target_slot = new_target_slot_data.into(); } } } diff --git a/azalea-inventory/src/slot.rs b/azalea-inventory/src/slot.rs index 66f8bf50..2d00236f 100644 --- a/azalea-inventory/src/slot.rs +++ b/azalea-inventory/src/slot.rs @@ -87,6 +87,13 @@ impl ItemStack { ItemStack::Present(i) => Some(i), } } + + pub fn as_present_mut(&mut self) -> Option<&mut ItemStackData> { + match self { + ItemStack::Empty => None, + ItemStack::Present(i) => Some(i), + } + } } /// An item in an inventory, with a count and NBT. Usually you want @@ -172,6 +179,16 @@ impl AzaleaWrite for ItemStack { } } +impl From for ItemStack { + fn from(item: ItemStackData) -> Self { + if item.is_empty() { + ItemStack::Empty + } else { + ItemStack::Present(item) + } + } +} + /// An update to an item's data components. /// /// Note that in vanilla items come with their own set of default components, @@ -311,24 +328,19 @@ impl PartialEq for DataComponentPatch { return false; } for (kind, component) in &self.components { - match other.components.get(kind) { - Some(other_component) => { - // we can't use PartialEq, but we can use our own eq method - if let Some(component) = component { - if let Some(other_component) = other_component { - if !component.eq((*other_component).clone()) { - return false; - } - } else { - return false; - } - } else if other_component.is_some() { - return false; - } - } - _ => { + let Some(other_component) = other.components.get(kind) else { + return false; + }; + // we can't use PartialEq, but we can use our own eq method + if let Some(component) = component { + let Some(other_component) = other_component else { + return false; + }; + if !component.eq((*other_component).clone()) { return false; } + } else if other_component.is_some() { + return false; } } true diff --git a/azalea/src/container.rs b/azalea/src/container.rs index da3ddb8a..3521c06d 100644 --- a/azalea/src/container.rs +++ b/azalea/src/container.rs @@ -32,7 +32,7 @@ pub trait ContainerClientExt { fn open_inventory(&self) -> Option; fn get_held_item(&self) -> ItemStack; fn get_open_container(&self) -> Option; - fn view_inventory(&self) -> Menu; + fn view_container_or_inventory(&self) -> Menu; } impl ContainerClientExt for Client { @@ -124,13 +124,19 @@ impl ContainerClientExt for Client { } } - /// Returns the player's inventory menu. + /// Returns the player's currently open container menu, or their inventory + /// if no container is open. /// - /// This is a shortcut for accessing the client's - /// [`Inventory::inventory_menu`]. - fn view_inventory(&self) -> Menu { - self.map_get_component::(|inventory| inventory.inventory_menu.clone()) - .expect("no inventory") + /// This tries to access the client's [`Inventory::container_menu`] and + /// falls back to [`Inventory::inventory_menu`]. + fn view_container_or_inventory(&self) -> Menu { + self.map_get_component::(|inventory| { + inventory + .container_menu + .clone() + .unwrap_or(inventory.inventory_menu.clone()) + }) + .expect("no inventory") } } @@ -192,6 +198,12 @@ impl ContainerHandleRef { self.menu().map(|menu| menu.contents()) } + /// Return the contents of the menu, including the player's inventory. If + /// the container is closed, this will return `None`. + pub fn slots(&self) -> Option> { + self.menu().map(|menu| menu.slots()) + } + pub fn click(&self, operation: impl Into) { let operation = operation.into(); self.client.ecs.lock().send_event(ContainerClickEvent { @@ -246,6 +258,17 @@ impl ContainerHandle { self.0.contents() } + /// Return the contents of the menu, including the player's inventory. If + /// the container is closed, this will return `None`. + pub fn slots(&self) -> Option> { + self.0.slots() + } + + /// Closes the inventory by dropping the handle. + pub fn close(self) { + // implicitly calls drop + } + pub fn click(&self, operation: impl Into) { self.0.click(operation); }