aboutsummaryrefslogtreecommitdiff
path: root/azalea-inventory
diff options
context:
space:
mode:
authormat <27899617+mat-1@users.noreply.github.com>2023-05-03 20:57:27 -0500
committerGitHub <noreply@github.com>2023-05-03 20:57:27 -0500
commit634cb8d72c6608512aedba19e5cd669104bc35ea (patch)
treef8e76ce9eb43403d29cc0cbcf9a4f51522419dc2 /azalea-inventory
parent1fb4418f2c9cbd004c64c2f23d2d0352ee12c0e5 (diff)
downloadazalea-drasl-634cb8d72c6608512aedba19e5cd669104bc35ea.tar.xz
Inventory (#48)
* start adding azalea-inventory * design more of how inventories are defined * start working on az-inv-macros * inventory macro works * start adding inventory codegen * update some deps * add inventory codegen * manually write inventory menus * put the inventories in Client * start on containersetcontent * inventory menu should hopefully work * checks in containersetcontent * format a comment * move some variant matches * inventory.rs * inventory stuff * more inventory stuff * inventory/container tracking works * start adding interact function * sequence number * start adding HitResultComponent * implement traverse_blocks * start adding clip * add clip function * update_hit_result_component * start trying to fix * fix * make some stuff simpler * clippy * lever * chest * container handle * fix ambiguity * fix some doc tests * move some container stuff from az-client to azalea * clicking container * start implementing simulate_click * keep working on simulate click * implement more of simulate_click this is really boring * inventory fixes * start implementing shift clicking * fix panic in azalea-chat i hope * shift clicking implemented * more inventory stuff * fix items not showing in containers sometimes * fix test * fix all warnings * remove a println --------- Co-authored-by: mat <git@matdoes.dev>
Diffstat (limited to 'azalea-inventory')
-rw-r--r--azalea-inventory/Cargo.toml12
-rw-r--r--azalea-inventory/README.md2
-rw-r--r--azalea-inventory/azalea-inventory-macros/Cargo.toml14
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/lib.rs45
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/location_enum.rs59
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/menu_enum.rs70
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/menu_impl.rs448
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/parse_macro.rs69
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/utils.rs54
-rw-r--r--azalea-inventory/src/item/mod.rs21
-rw-r--r--azalea-inventory/src/lib.rs172
-rw-r--r--azalea-inventory/src/operations.rs698
-rw-r--r--azalea-inventory/src/slot.rs146
13 files changed, 1810 insertions, 0 deletions
diff --git a/azalea-inventory/Cargo.toml b/azalea-inventory/Cargo.toml
new file mode 100644
index 00000000..4b00901f
--- /dev/null
+++ b/azalea-inventory/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+edition = "2021"
+name = "azalea-inventory"
+version = "0.1.0"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+azalea-buf = { version = "0.6.0", path = "../azalea-buf" }
+azalea-inventory-macros = { version = "0.1.0", path = "./azalea-inventory-macros" }
+azalea-nbt = { version = "0.6.0", path = "../azalea-nbt" }
+azalea-registry = { version = "0.6.0", path = "../azalea-registry" }
diff --git a/azalea-inventory/README.md b/azalea-inventory/README.md
new file mode 100644
index 00000000..67030f6a
--- /dev/null
+++ b/azalea-inventory/README.md
@@ -0,0 +1,2 @@
+Representations of various inventory data structures in Minecraft.
+
diff --git a/azalea-inventory/azalea-inventory-macros/Cargo.toml b/azalea-inventory/azalea-inventory-macros/Cargo.toml
new file mode 100644
index 00000000..9ac1cd68
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "azalea-inventory-macros"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+proc-macro = true
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+proc-macro2 = "1.0.47"
+quote = "1.0.21"
+syn = "1.0.104"
diff --git a/azalea-inventory/azalea-inventory-macros/src/lib.rs b/azalea-inventory/azalea-inventory-macros/src/lib.rs
new file mode 100644
index 00000000..d3faa091
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/lib.rs
@@ -0,0 +1,45 @@
+mod location_enum;
+mod menu_enum;
+mod menu_impl;
+mod parse_macro;
+mod utils;
+
+use parse_macro::{DeclareMenus, Field};
+use proc_macro::TokenStream;
+use proc_macro2::Span;
+use quote::quote;
+use syn::{self, parse_macro_input, Ident};
+
+#[proc_macro]
+pub fn declare_menus(input: TokenStream) -> TokenStream {
+ let mut input = parse_macro_input!(input as DeclareMenus);
+
+ // implicitly add a `player` field at the end unless an `inventory` field
+ // is present
+ for menu in &mut input.menus {
+ let mut inventory_field_missing = true;
+ for field in &menu.fields {
+ if matches!(field.name.to_string().as_str(), "inventory" | "player") {
+ inventory_field_missing = false;
+ }
+ }
+ if inventory_field_missing {
+ menu.fields.push(Field {
+ name: Ident::new("player", Span::call_site()),
+ length: 36,
+ })
+ }
+ }
+
+ let menu_enum = menu_enum::generate(&input);
+ let menu_impl = menu_impl::generate(&input);
+ let location_enum = location_enum::generate(&input);
+
+ quote! {
+ #menu_enum
+ #menu_impl
+
+ #location_enum
+ }
+ .into()
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/location_enum.rs b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs
new file mode 100644
index 00000000..6cec88e6
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs
@@ -0,0 +1,59 @@
+use crate::{parse_macro::DeclareMenus, utils::to_pascal_case};
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::Ident;
+
+pub fn generate(input: &DeclareMenus) -> TokenStream {
+ // pub enum MenuLocation {
+ // Player(PlayerMenuLocation),
+ // ...
+ // }
+ // pub enum PlayerMenuLocation {
+ // CraftResult,
+ // Craft,
+ // Armor,
+ // Inventory,
+ // Offhand,
+ // }
+ // ...
+
+ let mut menu_location_variants = quote! {};
+ let mut enums = quote! {};
+ for menu in &input.menus {
+ let name_snake_case = &menu.name;
+ let variant_name = Ident::new(
+ &to_pascal_case(&name_snake_case.to_string()),
+ name_snake_case.span(),
+ );
+ let enum_name = Ident::new(
+ &format!("{}MenuLocation", variant_name),
+ variant_name.span(),
+ );
+ menu_location_variants.extend(quote! {
+ #variant_name(#enum_name),
+ });
+ let mut individual_menu_location_variants = quote! {};
+ for field in &menu.fields {
+ let field_name = &field.name;
+ let variant_name =
+ Ident::new(&to_pascal_case(&field_name.to_string()), field_name.span());
+ individual_menu_location_variants.extend(quote! {
+ #variant_name,
+ });
+ }
+ enums.extend(quote! {
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+ pub enum #enum_name {
+ #individual_menu_location_variants
+ }
+ });
+ }
+
+ quote! {
+ pub enum MenuLocation {
+ #menu_location_variants
+ }
+
+ #enums
+ }
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs b/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs
new file mode 100644
index 00000000..a9e4f430
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs
@@ -0,0 +1,70 @@
+//! Generate the `enum menu` and nothing else. Implementations are in
+//! impl_menu.rs
+
+use crate::parse_macro::{DeclareMenus, Field, Menu};
+use proc_macro2::TokenStream;
+use quote::quote;
+
+pub fn generate(input: &DeclareMenus) -> TokenStream {
+ let mut variants = quote! {};
+ let mut player_fields = None;
+ for menu in &input.menus {
+ if menu.name == "Player" {
+ player_fields = Some(generate_fields(&menu.fields, true));
+ } else {
+ variants.extend(generate_variant_for_menu(menu));
+ }
+ }
+ let player_fields = player_fields.expect("Player variant must be present");
+
+ quote! {
+ #[derive(Clone, Debug, Default)]
+ pub struct Player {
+ #player_fields
+ }
+
+ /// A menu, which is a fixed collection of slots.
+ #[derive(Clone, Debug)]
+ pub enum Menu {
+ Player(Player),
+ #variants
+ }
+ }
+}
+
+/// Player {
+/// craft_result: ItemSlot,
+/// craft: [ItemSlot; 4],
+/// armor: [ItemSlot; 4],
+/// inventory: [ItemSlot; 36],
+/// offhand: ItemSlot,
+/// },
+fn generate_variant_for_menu(menu: &Menu) -> TokenStream {
+ let name = &menu.name;
+ let fields = generate_fields(&menu.fields, false);
+
+ quote! {
+ #name {
+ #fields
+ },
+ }
+}
+
+fn generate_fields(fields: &[Field], public: bool) -> TokenStream {
+ let mut generated_fields = quote! {};
+ for field in fields {
+ let field_length = field.length;
+ let field_type = if field.length == 1 {
+ quote! { ItemSlot }
+ } else {
+ quote! { SlotList<#field_length> }
+ };
+ let field_name = &field.name;
+ if public {
+ generated_fields.extend(quote! { pub #field_name: #field_type, })
+ } else {
+ generated_fields.extend(quote! { #field_name: #field_type, })
+ }
+ }
+ generated_fields
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs
new file mode 100644
index 00000000..804f69f2
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs
@@ -0,0 +1,448 @@
+use crate::{
+ parse_macro::{DeclareMenus, Menu},
+ utils::{to_pascal_case, to_snake_case},
+};
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::Ident;
+
+pub fn generate(input: &DeclareMenus) -> TokenStream {
+ let mut slot_mut_match_variants = quote! {};
+ let mut slot_match_variants = quote! {};
+ let mut len_match_variants = quote! {};
+ let mut kind_match_variants = quote! {};
+ let mut slots_match_variants = quote! {};
+ let mut contents_match_variants = quote! {};
+ let mut location_match_variants = quote! {};
+ let mut player_slots_range_match_variants = quote! {};
+
+ let mut player_consts = quote! {};
+ let mut menu_consts = quote! {};
+
+ let mut hotbar_slots_start = 0;
+ let mut hotbar_slots_end = 0;
+ let mut inventory_without_hotbar_slots_start = 0;
+ let mut inventory_without_hotbar_slots_end = 0;
+
+ for menu in &input.menus {
+ slot_mut_match_variants.extend(generate_match_variant_for_slot_mut(menu, true));
+ slot_match_variants.extend(generate_match_variant_for_slot_mut(menu, false));
+ len_match_variants.extend(generate_match_variant_for_len(menu));
+ kind_match_variants.extend(generate_match_variant_for_kind(menu));
+ slots_match_variants.extend(generate_match_variant_for_slots(menu));
+ contents_match_variants.extend(generate_match_variant_for_contents(menu));
+ location_match_variants.extend(generate_match_variant_for_location(menu));
+ player_slots_range_match_variants
+ .extend(generate_match_variant_for_player_slots_range(menu));
+
+ // this part is only used to generate `Player::is_hotbar_slot`
+ if menu.name == "Player" {
+ let mut i = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ let start = i;
+ i += field.length;
+ let end = i - 1;
+
+ if field_name == "inventory" {
+ // it only subtracts 8 here since it's inclusive (there's 9 total hotbar slots)
+ hotbar_slots_start = end - 8;
+ hotbar_slots_end = end;
+
+ inventory_without_hotbar_slots_start = start;
+ inventory_without_hotbar_slots_end = end - 9;
+ }
+
+ if start == end {
+ let const_name = Ident::new(
+ &format!("{}_SLOT", field_name.to_string().to_uppercase()),
+ field_name.span(),
+ );
+ player_consts.extend(quote! {
+ pub const #const_name: usize = #start;
+ });
+ } else {
+ let const_name = Ident::new(
+ &format!("{}_SLOTS", field_name.to_string().to_uppercase()),
+ field_name.span(),
+ );
+ player_consts.extend(quote! {
+ pub const #const_name: RangeInclusive<usize> = #start..=#end;
+ });
+ }
+ }
+ } else {
+ menu_consts.extend(generate_menu_consts(menu));
+ }
+ }
+
+ assert!(hotbar_slots_start != 0 && hotbar_slots_end != 0);
+ quote! {
+ impl Player {
+ pub const HOTBAR_SLOTS: RangeInclusive<usize> = #hotbar_slots_start..=#hotbar_slots_end;
+ pub const INVENTORY_WITHOUT_HOTBAR_SLOTS: RangeInclusive<usize> = #inventory_without_hotbar_slots_start..=#inventory_without_hotbar_slots_end;
+ #player_consts
+
+ /// Returns whether the given protocol index is in the player's hotbar.
+ ///
+ /// Equivalent to `Player::HOTBAR_SLOTS.contains(&i)`.
+ pub fn is_hotbar_slot(i: usize) -> bool {
+ Self::HOTBAR_SLOTS.contains(&i)
+ }
+ }
+
+ impl Menu {
+ #menu_consts
+
+ /// Get a mutable reference to the [`ItemSlot`] at the given protocol index.
+ ///
+ /// If you're trying to get an item in a menu without caring about
+ /// protocol indexes, you should just `match` it and index the
+ /// [`ItemSlot`] you get.
+ ///
+ /// Use [`Menu::slot`] if you don't need a mutable reference to the slot.
+ ///
+ /// # Errors
+ ///
+ /// Returns `None` if the index is out of bounds.
+ #[inline]
+ pub fn slot_mut(&mut self, i: usize) -> Option<&mut ItemSlot> {
+ Some(match self {
+ #slot_mut_match_variants
+ })
+ }
+
+ /// Get a reference to the [`ItemSlot`] at the given protocol index.
+ ///
+ /// If you're trying to get an item in a menu without caring about
+ /// protocol indexes, you should just `match` it and index the
+ /// [`ItemSlot`] you get.
+ ///
+ /// Use [`Menu::slot_mut`] if you need a mutable reference to the slot.
+ ///
+ /// # Errors
+ ///
+ /// Returns `None` if the index is out of bounds.
+ pub fn slot(&self, i: usize) -> Option<&ItemSlot> {
+ Some(match self {
+ #slot_match_variants
+ })
+ }
+
+ /// Returns the number of slots in the menu.
+ #[allow(clippy::len_without_is_empty)]
+ pub const fn len(&self) -> usize {
+ match self {
+ #len_match_variants
+ }
+ }
+
+ pub fn from_kind(kind: azalea_registry::MenuKind) -> Self {
+ match kind {
+ #kind_match_variants
+ }
+ }
+
+ /// Return the contents of the menu, including the player's inventory.
+ ///
+ /// The indexes in this will match up with [`Menu::slot_mut`].
+ ///
+ /// If you don't want to include the player's inventory, use [`Menu::contents`] instead.
+ pub fn slots(&self) -> Vec<ItemSlot> {
+ match self {
+ #slots_match_variants
+ }
+ }
+
+ /// Return the contents of the menu, not including the player's inventory.
+ ///
+ /// If you want to include the player's inventory, use [`Menu::slots`] instead.
+ pub fn contents(&self) -> Vec<ItemSlot> {
+ match self {
+ #contents_match_variants
+ }
+ }
+
+ pub fn location_for_slot(&self, i: usize) -> Option<MenuLocation> {
+ Some(match self {
+ #location_match_variants
+ })
+ }
+
+ /// Get the range of slot indexes that contain the player's inventory. This may be different for each menu.
+ pub fn player_slots_range(&self) -> RangeInclusive<usize> {
+ match self {
+ #player_slots_range_match_variants
+ }
+ }
+
+ /// Get the range of slot indexes that contain the player's hotbar. This may be different for each menu.
+ pub fn hotbar_slots_range(&self) -> RangeInclusive<usize> {
+ // hotbar is always last 9 slots in the player's inventory
+ ((*self.player_slots_range().end() - 8)..=*self.player_slots_range().end())
+ }
+
+ /// Get the range of slot indexes that contain the player's inventory, not including the hotbar. This may be different for each menu.
+ pub fn player_slots_without_hotbar_range(&self) -> RangeInclusive<usize> {
+ (*self.player_slots_range().start()..=*self.player_slots_range().end() - 9)
+ }
+
+ /// Returns whether the given index would be in the player's hotbar.
+ ///
+ /// Equivalent to `self.hotbar_slots_range().contains(&i)`.
+ pub fn is_hotbar_slot(&self, i: usize) -> bool {
+ self.hotbar_slots_range().contains(&i)
+ }
+ }
+ }
+}
+
+/// Menu::Player {
+/// craft_result,
+/// craft,
+/// armor,
+/// inventory,
+/// offhand,
+/// } => {
+/// match i {
+/// 0 => craft_result,
+/// 1..=4 => craft,
+/// 5..=8 => armor,
+/// // ...
+/// _ => return None,
+/// }
+/// } // ...
+pub fn generate_match_variant_for_slot_mut(menu: &Menu, mutable: bool) -> TokenStream {
+ let mut match_arms = quote! {};
+ let mut i = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ let start = i;
+ i += field.length;
+ let end = i - 1;
+ match_arms.extend(if start == end {
+ quote! { #start => #field_name, }
+ } else if start == 0 {
+ if mutable {
+ quote! { #start..=#end => &mut #field_name[i], }
+ } else {
+ quote! { #start..=#end => &#field_name[i], }
+ }
+ } else if mutable {
+ quote! { #start..=#end => &mut #field_name[i - #start], }
+ } else {
+ quote! { #start..=#end => &#field_name[i - #start], }
+ });
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ match i {
+ #match_arms
+ _ => return None
+ }
+ },
+ true,
+ )
+}
+
+pub fn generate_match_variant_for_len(menu: &Menu) -> TokenStream {
+ let length = menu.fields.iter().map(|f| f.length).sum::<usize>();
+ generate_matcher(
+ menu,
+ &quote! {
+ #length
+ },
+ false,
+ )
+}
+
+pub fn generate_match_variant_for_kind(menu: &Menu) -> TokenStream {
+ // azalea_registry::MenuKind::Generic9x3 => Menu::Generic9x3 { contents:
+ // Default::default(), player: Default::default() },
+
+ let menu_name = &menu.name;
+ let menu_field_names = if menu.name == "Player" {
+ // player isn't in MenuKind
+ return quote! {};
+ } else {
+ let mut menu_field_names = quote! {};
+ for field in &menu.fields {
+ let field_name = &field.name;
+ menu_field_names.extend(quote! { #field_name: Default::default(), })
+ }
+ quote! { { #menu_field_names } }
+ };
+
+ quote! {
+ azalea_registry::MenuKind::#menu_name => Menu::#menu_name #menu_field_names,
+ }
+}
+
+pub fn generate_match_variant_for_slots(menu: &Menu) -> TokenStream {
+ let mut instructions = quote! {};
+ let mut length = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ instructions.extend(if field.length == 1 {
+ quote! { items.push(#field_name.clone()); }
+ } else {
+ quote! { items.extend(#field_name.iter().cloned()); }
+ });
+ length += field.length;
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ let mut items = Vec::with_capacity(#length);
+ #instructions
+ items
+ },
+ true,
+ )
+}
+
+pub fn generate_match_variant_for_contents(menu: &Menu) -> TokenStream {
+ let mut instructions = quote! {};
+ let mut length = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ if field_name == "player" {
+ continue;
+ }
+ instructions.extend(if field.length == 1 {
+ quote! { items.push(#field_name.clone()); }
+ } else {
+ quote! { items.extend(#field_name.iter().cloned()); }
+ });
+ length += field.length;
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ let mut items = Vec::with_capacity(#length);
+ #instructions
+ items
+ },
+ true,
+ )
+}
+
+pub fn generate_match_variant_for_location(menu: &Menu) -> TokenStream {
+ let mut match_arms = quote! {};
+ let mut i = 0;
+
+ let menu_name = Ident::new(&to_pascal_case(&menu.name.to_string()), menu.name.span());
+ let menu_enum_name = Ident::new(&format!("{menu_name}MenuLocation"), menu_name.span());
+
+ for field in &menu.fields {
+ let field_name = Ident::new(&to_pascal_case(&field.name.to_string()), field.name.span());
+ let start = i;
+ i += field.length;
+ let end = i - 1;
+ match_arms.extend(if start == end {
+ quote! { #start => #menu_enum_name::#field_name, }
+ } else {
+ quote! { #start..=#end => #menu_enum_name::#field_name, }
+ });
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ MenuLocation::#menu_name(match i {
+ #match_arms
+ _ => return None
+ })
+ },
+ false,
+ )
+}
+
+pub fn generate_match_variant_for_player_slots_range(menu: &Menu) -> TokenStream {
+ // Menu::Player(Player { .. }) => Player::INVENTORY_SLOTS_RANGE,,
+ // Menu::Generic9x3 { .. } => Menu::GENERIC9X3_SLOTS_RANGE,
+ // ..
+
+ match menu.name.to_string().as_str() {
+ "Player" => {
+ quote! {
+ Menu::Player(Player { .. }) => Player::INVENTORY_SLOTS,
+ }
+ }
+ _ => {
+ let menu_name = &menu.name;
+ let menu_slots_range_name = Ident::new(
+ &format!(
+ "{}_PLAYER_SLOTS",
+ to_snake_case(&menu.name.to_string()).to_uppercase()
+ ),
+ menu.name.span(),
+ );
+ quote! {
+ Menu::#menu_name { .. } => Menu::#menu_slots_range_name,
+ }
+ }
+ }
+}
+
+fn generate_menu_consts(menu: &Menu) -> TokenStream {
+ let mut menu_consts = quote! {};
+
+ let mut i = 0;
+
+ for field in &menu.fields {
+ let field_name_start = format!(
+ "{}_{}",
+ to_snake_case(&menu.name.to_string()).to_uppercase(),
+ to_snake_case(&field.name.to_string()).to_uppercase()
+ );
+ let field_index_start = i;
+ i += field.length;
+ let field_index_end = i - 1;
+
+ if field.length == 1 {
+ let field_name = Ident::new(
+ format!("{}_SLOT", field_name_start).as_str(),
+ field.name.span(),
+ );
+ menu_consts.extend(quote! { pub const #field_name: usize = #field_index_start; });
+ } else {
+ let field_name = Ident::new(
+ format!("{}_SLOTS", field_name_start).as_str(),
+ field.name.span(),
+ );
+ menu_consts.extend(quote! { pub const #field_name: RangeInclusive<usize> = #field_index_start..=#field_index_end; });
+ }
+ }
+
+ menu_consts
+}
+
+pub fn generate_matcher(menu: &Menu, match_arms: &TokenStream, needs_fields: bool) -> TokenStream {
+ let menu_name = &menu.name;
+ let menu_field_names = if needs_fields {
+ let mut menu_field_names = quote! {};
+ for field in &menu.fields {
+ let field_name = &field.name;
+ menu_field_names.extend(quote! { #field_name, })
+ }
+ menu_field_names
+ } else {
+ quote! { .. }
+ };
+
+ let matcher = if menu.name == "Player" {
+ quote! { (Player { #menu_field_names }) }
+ } else {
+ quote! { { #menu_field_names } }
+ };
+ quote! {
+ Menu::#menu_name #matcher => {
+ #match_arms
+ },
+ }
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs b/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs
new file mode 100644
index 00000000..8eada4ec
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs
@@ -0,0 +1,69 @@
+use syn::{
+ self, braced,
+ parse::{Parse, ParseStream, Result},
+ Ident, LitInt, Token,
+};
+
+/// An identifier, colon, and number
+/// `craft_result: 1`
+pub struct Field {
+ pub name: Ident,
+ pub length: usize,
+}
+impl Parse for Field {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let name = input.parse::<Ident>()?;
+ let _ = input.parse::<Token![:]>()?;
+ let length = input.parse::<LitInt>()?.base10_parse()?;
+ Ok(Self { name, length })
+ }
+}
+
+/// An identifier and a list of `Field` in curly brackets
+/// ```rust,ignore
+/// Player {
+/// craft_result: 1,
+/// ...
+/// }
+/// ```
+pub struct Menu {
+ /// The menu name, e.g. `Player`
+ pub name: Ident,
+ pub fields: Vec<Field>,
+}
+
+impl Parse for Menu {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let name = input.parse::<Ident>()?;
+
+ let content;
+ braced!(content in input);
+ let fields = content
+ .parse_terminated::<Field, Token![,]>(Field::parse)?
+ .into_iter()
+ .collect();
+
+ Ok(Self { name, fields })
+ }
+}
+
+/// A list of `Menu`s
+/// ```rust,ignore
+/// Player {
+/// craft_result: 1,
+/// ...
+/// },
+/// ...
+/// ```
+pub struct DeclareMenus {
+ pub menus: Vec<Menu>,
+}
+impl Parse for DeclareMenus {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let menus = input
+ .parse_terminated::<Menu, Token![,]>(Menu::parse)?
+ .into_iter()
+ .collect();
+ Ok(Self { menus })
+ }
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/utils.rs b/azalea-inventory/azalea-inventory-macros/src/utils.rs
new file mode 100644
index 00000000..568c9f71
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/utils.rs
@@ -0,0 +1,54 @@
+pub fn to_pascal_case(s: &str) -> String {
+ // we get the first item later so this is to make it impossible for that
+ // to error
+ if s.is_empty() {
+ return String::new();
+ }
+
+ let mut result = String::new();
+ let mut prev_was_underscore = true; // set to true by default so the first character is capitalized
+ if s.chars().next().unwrap().is_numeric() {
+ result.push('_');
+ }
+ for c in s.chars() {
+ if c == '_' {
+ prev_was_underscore = true;
+ } else if prev_was_underscore {
+ result.push(c.to_ascii_uppercase());
+ prev_was_underscore = false;
+ } else {
+ result.push(c);
+ }
+ }
+ result
+}
+
+pub fn to_snake_case(s: &str) -> String {
+ let mut result = String::new();
+ let mut prev_was_uppercase = true;
+ for c in s.chars() {
+ if c.is_ascii_uppercase() {
+ if !prev_was_uppercase {
+ result.push('_');
+ }
+ result.push(c.to_ascii_lowercase());
+ prev_was_uppercase = true;
+ } else {
+ result.push(c);
+ prev_was_uppercase = false;
+ }
+ }
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_snake_case() {
+ assert_eq!(to_snake_case("HelloWorld"), "hello_world");
+ assert_eq!(to_snake_case("helloWorld"), "hello_world");
+ assert_eq!(to_snake_case("hello_world"), "hello_world");
+ }
+}
diff --git a/azalea-inventory/src/item/mod.rs b/azalea-inventory/src/item/mod.rs
new file mode 100644
index 00000000..07e51363
--- /dev/null
+++ b/azalea-inventory/src/item/mod.rs
@@ -0,0 +1,21 @@
+pub trait MaxStackSizeExt {
+ /// Get the maximum stack size for this item.
+ ///
+ /// This is a signed integer to be consistent with the `count` field of
+ /// [`ItemSlotData`].
+ fn max_stack_size(&self) -> i8;
+
+ /// Whether this item can be stacked with other items.
+ ///
+ /// This is equivalent to `self.max_stack_size() > 1`.
+ fn stackable(&self) -> bool {
+ self.max_stack_size() > 1
+ }
+}
+
+impl MaxStackSizeExt for azalea_registry::Item {
+ fn max_stack_size(&self) -> i8 {
+ // TODO: have the properties for every item defined somewhere
+ 64
+ }
+}
diff --git a/azalea-inventory/src/lib.rs b/azalea-inventory/src/lib.rs
new file mode 100644
index 00000000..518c7a1d
--- /dev/null
+++ b/azalea-inventory/src/lib.rs
@@ -0,0 +1,172 @@
+#![doc = include_str!("../README.md")]
+
+pub mod item;
+pub mod operations;
+mod slot;
+
+use std::ops::{Deref, DerefMut, RangeInclusive};
+
+use azalea_inventory_macros::declare_menus;
+pub use slot::{ItemSlot, ItemSlotData};
+
+// TODO: remove this here and in azalea-inventory-macros when rust makes
+// Default be implemented for all array sizes (since right now it's only up to
+// 32)
+
+/// A fixed-size list of [`ItemSlot`]s.
+#[derive(Debug, Clone)]
+pub struct SlotList<const N: usize>([ItemSlot; N]);
+impl<const N: usize> Deref for SlotList<N> {
+ type Target = [ItemSlot; N];
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+impl<const N: usize> DerefMut for SlotList<N> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+}
+impl<const N: usize> Default for SlotList<N> {
+ fn default() -> Self {
+ SlotList([(); N].map(|_| ItemSlot::Empty))
+ }
+}
+
+impl Menu {
+ /// Get the [`Player`] from this [`Menu`].
+ ///
+ /// # Panics
+ ///
+ /// Will panic if the menu isn't `Menu::Player`.
+ pub fn as_player(&self) -> &Player {
+ if let Menu::Player(player) = &self {
+ player
+ } else {
+ unreachable!("Called `Menu::as_player` on a menu that wasn't `Player`.")
+ }
+ }
+}
+
+// the player inventory part is always the last 36 slots (except in the Player
+// menu), so we don't have to explicitly specify it
+
+// Client {
+// ...
+// pub menu: Menu,
+// pub inventory: Arc<[Slot; 36]>
+// }
+
+// Generate a `struct Player`, `enum Menu`, and `impl Menu`.
+// a "player" field gets implicitly added with the player inventory
+
+declare_menus! {
+ Player {
+ craft_result: 1,
+ craft: 4,
+ armor: 4,
+ inventory: 36,
+ offhand: 1,
+ },
+ Generic9x1 {
+ contents: 9,
+ },
+ Generic9x2 {
+ contents: 18,
+ },
+ Generic9x3 {
+ contents: 27,
+ },
+ Generic9x4 {
+ contents: 36,
+ },
+ Generic9x5 {
+ contents: 45,
+ },
+ Generic9x6 {
+ contents: 54,
+ },
+ Generic3x3 {
+ contents: 9,
+ },
+ Anvil {
+ first: 1,
+ second: 1,
+ result: 1,
+ },
+ Beacon {
+ payment: 1,
+ },
+ BlastFurnace {
+ ingredient: 1,
+ fuel: 1,
+ result: 1,
+ },
+ BrewingStand {
+ bottles: 3,
+ ingredient: 1,
+ fuel: 1,
+ },
+ Crafting {
+ result: 1,
+ grid: 9,
+ },
+ Enchantment {
+ item: 1,
+ lapis: 1,
+ },
+ Furnace {
+ ingredient: 1,
+ fuel: 1,
+ result: 1,
+ },
+ Grindstone {
+ input: 1,
+ additional: 1,
+ result: 1,
+ },
+ Hopper {
+ contents: 5,
+ },
+ Lectern {
+ book: 1,
+ },
+ Loom {
+ banner: 1,
+ dye: 1,
+ pattern: 1,
+ result: 1,
+ },
+ Merchant {
+ payments: 2,
+ result: 1,
+ },
+ ShulkerBox {
+ contents: 27,
+ },
+ LegacySmithing {
+ input: 1,
+ additional: 1,
+ result: 1,
+ },
+ Smithing {
+ template: 1,
+ base: 1,
+ additional: 1,
+ result: 1,
+ },
+ Smoker {
+ ingredient: 1,
+ fuel: 1,
+ result: 1,
+ },
+ CartographyTable {
+ map: 1,
+ additional: 1,
+ result: 1,
+ },
+ Stonecutter {
+ input: 1,
+ result: 1,
+ },
+}
diff --git a/azalea-inventory/src/operations.rs b/azalea-inventory/src/operations.rs
new file mode 100644
index 00000000..1379b8a9
--- /dev/null
+++ b/azalea-inventory/src/operations.rs
@@ -0,0 +1,698 @@
+use std::ops::RangeInclusive;
+
+use azalea_buf::McBuf;
+
+use crate::{
+ item::MaxStackSizeExt, AnvilMenuLocation, BeaconMenuLocation, BlastFurnaceMenuLocation,
+ BrewingStandMenuLocation, CartographyTableMenuLocation, CraftingMenuLocation,
+ EnchantmentMenuLocation, FurnaceMenuLocation, Generic3x3MenuLocation, Generic9x1MenuLocation,
+ Generic9x2MenuLocation, Generic9x3MenuLocation, Generic9x4MenuLocation, Generic9x5MenuLocation,
+ Generic9x6MenuLocation, GrindstoneMenuLocation, HopperMenuLocation, ItemSlot, ItemSlotData,
+ LecternMenuLocation, LegacySmithingMenuLocation, LoomMenuLocation, Menu, MenuLocation,
+ MerchantMenuLocation, Player, PlayerMenuLocation, ShulkerBoxMenuLocation, SmithingMenuLocation,
+ SmokerMenuLocation, StonecutterMenuLocation,
+};
+
+#[derive(Debug, Clone)]
+pub enum ClickOperation {
+ Pickup(PickupClick),
+ QuickMove(QuickMoveClick),
+ Swap(SwapClick),
+ Clone(CloneClick),
+ Throw(ThrowClick),
+ QuickCraft(QuickCraftClick),
+ PickupAll(PickupAllClick),
+}
+
+#[derive(Debug, Clone)]
+pub enum PickupClick {
+ /// Left mouse click. Note that in the protocol, None is represented as
+ /// -999.
+ Left { slot: Option<u16> },
+ /// Right mouse click. Note that in the protocol, None is represented as
+ /// -999.
+ Right { slot: Option<u16> },
+ /// Drop cursor stack.
+ LeftOutside,
+ /// Drop cursor single item.
+ RightOutside,
+}
+impl From<PickupClick> for ClickOperation {
+ fn from(click: PickupClick) -> Self {
+ ClickOperation::Pickup(click)
+ }
+}
+
+/// Shift click
+#[derive(Debug, Clone)]
+pub enum QuickMoveClick {
+ /// Shift + left mouse click
+ Left { slot: u16 },
+ /// Shift + right mouse click (identical behavior)
+ Right { slot: u16 },
+}
+impl From<QuickMoveClick> for ClickOperation {
+ fn from(click: QuickMoveClick) -> Self {
+ ClickOperation::QuickMove(click)
+ }
+}
+
+/// Used when you press number keys or F in an inventory.
+#[derive(Debug, Clone)]
+pub struct SwapClick {
+ pub source_slot: u16,
+ pub target_slot: u8,
+}
+
+impl From<SwapClick> for ClickOperation {
+ fn from(click: SwapClick) -> Self {
+ ClickOperation::Swap(click)
+ }
+}
+/// Middle click, only defined for creative players in non-player
+/// inventories.
+#[derive(Debug, Clone)]
+pub struct CloneClick {
+ pub slot: u16,
+}
+impl From<CloneClick> for ClickOperation {
+ fn from(click: CloneClick) -> Self {
+ ClickOperation::Clone(click)
+ }
+}
+#[derive(Debug, Clone)]
+pub enum ThrowClick {
+ /// Drop key (Q)
+ Single { slot: u16 },
+ /// Ctrl + drop key (Q)
+ All { slot: u16 },
+}
+impl From<ThrowClick> for ClickOperation {
+ fn from(click: ThrowClick) -> Self {
+ ClickOperation::Throw(click)
+ }
+}
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub struct QuickCraftClick {
+ pub kind: QuickCraftKind,
+ pub status: QuickCraftStatus,
+}
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum QuickCraftKind {
+ Left,
+ Right,
+ Middle,
+}
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum QuickCraftStatusKind {
+ /// Starting drag
+ Start,
+ /// Add slot
+ Add,
+ /// Ending drag
+ End,
+}
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum QuickCraftStatus {
+ /// Starting drag
+ Start,
+ /// Add a slot.
+ Add { slot: u16 },
+ /// Ending drag
+ End,
+}
+impl From<QuickCraftStatus> for QuickCraftStatusKind {
+ fn from(status: QuickCraftStatus) -> Self {
+ match status {
+ QuickCraftStatus::Start => QuickCraftStatusKind::Start,
+ QuickCraftStatus::Add { .. } => QuickCraftStatusKind::Add,
+ QuickCraftStatus::End => QuickCraftStatusKind::End,
+ }
+ }
+}
+
+/// Double click
+#[derive(Debug, Clone)]
+pub struct PickupAllClick {
+ /// The slot that we're double clicking on. It should be empty or at least
+ /// not pickup-able (since the carried item is used as the filter).
+ pub slot: u16,
+ /// Impossible in vanilla clients.
+ pub reversed: bool,
+}
+impl From<PickupAllClick> for ClickOperation {
+ fn from(click: PickupAllClick) -> Self {
+ ClickOperation::PickupAll(click)
+ }
+}
+
+impl ClickOperation {
+ /// Return the slot number that this operation is acting on, if any.
+ ///
+ /// Note that in the protocol, "None" is represented as -999.
+ pub fn slot_num(&self) -> Option<u16> {
+ match self {
+ ClickOperation::Pickup(pickup) => match pickup {
+ PickupClick::Left { slot } => *slot,
+ PickupClick::Right { slot } => *slot,
+ PickupClick::LeftOutside => None,
+ PickupClick::RightOutside => None,
+ },
+ ClickOperation::QuickMove(quick_move) => match quick_move {
+ QuickMoveClick::Left { slot } => Some(*slot),
+ QuickMoveClick::Right { slot } => Some(*slot),
+ },
+ ClickOperation::Swap(swap) => Some(swap.source_slot),
+ ClickOperation::Clone(clone) => Some(clone.slot),
+ ClickOperation::Throw(throw) => match throw {
+ ThrowClick::Single { slot } => Some(*slot),
+ ThrowClick::All { slot } => Some(*slot),
+ },
+ ClickOperation::QuickCraft(quick_craft) => match quick_craft.status {
+ QuickCraftStatus::Start => None,
+ QuickCraftStatus::Add { slot } => Some(slot),
+ QuickCraftStatus::End => None,
+ },
+ ClickOperation::PickupAll(pickup_all) => Some(pickup_all.slot),
+ }
+ }
+
+ pub fn button_num(&self) -> u8 {
+ match self {
+ ClickOperation::Pickup(pickup) => match pickup {
+ PickupClick::Left { .. } => 0,
+ PickupClick::Right { .. } => 1,
+ PickupClick::LeftOutside => 0,
+ PickupClick::RightOutside => 1,
+ },
+ ClickOperation::QuickMove(quick_move) => match quick_move {
+ QuickMoveClick::Left { .. } => 0,
+ QuickMoveClick::Right { .. } => 1,
+ },
+ ClickOperation::Swap(swap) => swap.target_slot,
+ ClickOperation::Clone(_) => 2,
+ ClickOperation::Throw(throw) => match throw {
+ ThrowClick::Single { .. } => 0,
+ ThrowClick::All { .. } => 1,
+ },
+ ClickOperation::QuickCraft(quick_craft) => match quick_craft {
+ QuickCraftClick {
+ kind: QuickCraftKind::Left,
+ status: QuickCraftStatus::Start,
+ } => 0,
+ QuickCraftClick {
+ kind: QuickCraftKind::Right,
+ status: QuickCraftStatus::Start,
+ } => 4,
+ QuickCraftClick {
+ kind: QuickCraftKind::Middle,
+ status: QuickCraftStatus::Start,
+ } => 8,
+ QuickCraftClick {
+ kind: QuickCraftKind::Left,
+ status: QuickCraftStatus::Add { .. },
+ } => 1,
+ QuickCraftClick {
+ kind: QuickCraftKind::Right,
+ status: QuickCraftStatus::Add { .. },
+ } => 5,
+ QuickCraftClick {
+ kind: QuickCraftKind::Middle,
+ status: QuickCraftStatus::Add { .. },
+ } => 9,
+ QuickCraftClick {
+ kind: QuickCraftKind::Left,
+ status: QuickCraftStatus::End,
+ } => 2,
+ QuickCraftClick {
+ kind: QuickCraftKind::Right,
+ status: QuickCraftStatus::End,
+ } => 6,
+ QuickCraftClick {
+ kind: QuickCraftKind::Middle,
+ status: QuickCraftStatus::End,
+ } => 10,
+ },
+ ClickOperation::PickupAll(_) => 0,
+ }
+ }
+
+ pub fn click_type(&self) -> ClickType {
+ match self {
+ ClickOperation::Pickup(_) => ClickType::Pickup,
+ ClickOperation::QuickMove(_) => ClickType::QuickMove,
+ ClickOperation::Swap(_) => ClickType::Swap,
+ ClickOperation::Clone(_) => ClickType::Clone,
+ ClickOperation::Throw(_) => ClickType::Throw,
+ ClickOperation::QuickCraft(_) => ClickType::QuickCraft,
+ ClickOperation::PickupAll(_) => ClickType::PickupAll,
+ }
+ }
+}
+
+#[derive(McBuf, Clone, Copy, Debug)]
+pub enum ClickType {
+ Pickup = 0,
+ QuickMove = 1,
+ Swap = 2,
+ Clone = 3,
+ Throw = 4,
+ QuickCraft = 5,
+ PickupAll = 6,
+}
+
+impl Menu {
+ /// Shift-click a slot in this menu.
+ pub fn quick_move_stack(&mut self, slot_index: usize) -> ItemSlot {
+ let slot = self.slot(slot_index);
+ if slot.is_none() {
+ return ItemSlot::Empty;
+ };
+
+ let slot_location = self
+ .location_for_slot(slot_index)
+ .expect("we just checked to make sure the slot is Some above, so this shouldn't be able to error");
+ match slot_location {
+ MenuLocation::Player(l) => match l {
+ PlayerMenuLocation::CraftResult => {
+ self.try_move_item_to_slots(slot_index, Player::INVENTORY_SLOTS);
+ }
+ PlayerMenuLocation::Craft => {
+ self.try_move_item_to_slots(slot_index, Player::INVENTORY_SLOTS);
+ }
+ PlayerMenuLocation::Armor => {
+ self.try_move_item_to_slots(slot_index, Player::INVENTORY_SLOTS);
+ }
+ _ => {
+ // TODO: armor handling (see quickMoveStack in
+ // InventoryMenu.java)
+
+ // if slot.kind().is_armor() &&
+
+ // also offhand handling
+
+ if l == PlayerMenuLocation::Inventory {
+ // shift-clicking in hotbar moves to inventory, and vice versa
+ if Player::is_hotbar_slot(slot_index) {
+ self.try_move_item_to_slots(
+ slot_index,
+ Player::INVENTORY_WITHOUT_HOTBAR_SLOTS,
+ );
+ } else {
+ self.try_move_item_to_slots(slot_index, Player::HOTBAR_SLOTS);
+ }
+ } else {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ }
+ },
+ MenuLocation::Generic9x1(l) => match l {
+ Generic9x1MenuLocation::Contents => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ Generic9x1MenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GENERIC9X1_CONTENTS_SLOTS,
+ );
+ }
+ },
+ MenuLocation::Generic9x2(l) => match l {
+ Generic9x2MenuLocation::Contents => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ Generic9x2MenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GENERIC9X2_CONTENTS_SLOTS,
+ );
+ }
+ },
+ MenuLocation::Generic9x3(l) => match l {
+ Generic9x3MenuLocation::Contents => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ Generic9x3MenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GENERIC9X3_CONTENTS_SLOTS,
+ );
+ }
+ },
+ MenuLocation::Generic9x4(l) => match l {
+ Generic9x4MenuLocation::Contents => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ Generic9x4MenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GENERIC9X4_CONTENTS_SLOTS,
+ );
+ }
+ },
+ MenuLocation::Generic9x5(l) => match l {
+ Generic9x5MenuLocation::Contents => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ Generic9x5MenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GENERIC9X5_CONTENTS_SLOTS,
+ );
+ }
+ },
+ MenuLocation::Generic9x6(l) => match l {
+ Generic9x6MenuLocation::Contents => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ Generic9x6MenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GENERIC9X6_CONTENTS_SLOTS,
+ );
+ }
+ },
+ MenuLocation::Generic3x3(l) => match l {
+ Generic3x3MenuLocation::Contents => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ Generic3x3MenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GENERIC3X3_CONTENTS_SLOTS,
+ );
+ }
+ },
+ MenuLocation::Anvil(l) => match l {
+ AnvilMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::ANVIL_FIRST_SLOT..=Menu::ANVIL_SECOND_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Beacon(l) => match l {
+ BeaconMenuLocation::Payment => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ BeaconMenuLocation::Player => {
+ self.try_move_item_to_slots(
+ slot_index,
+ Menu::BEACON_PAYMENT_SLOT..=Menu::BEACON_PAYMENT_SLOT,
+ );
+ }
+ },
+ MenuLocation::BlastFurnace(l) => match l {
+ BlastFurnaceMenuLocation::Player => {
+ self.try_move_item_to_slots(
+ slot_index,
+ Menu::BLAST_FURNACE_INGREDIENT_SLOT..=Menu::BLAST_FURNACE_FUEL_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::BrewingStand(l) => match l {
+ BrewingStandMenuLocation::Player => {
+ self.try_move_item_to_slots(
+ slot_index,
+ *Menu::BREWING_STAND_BOTTLES_SLOTS.start()
+ ..=Menu::BREWING_STAND_INGREDIENT_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Crafting(l) => match l {
+ CraftingMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::CRAFTING_GRID_SLOTS,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Enchantment(l) => match l {
+ EnchantmentMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::ENCHANTMENT_ITEM_SLOT..=Menu::ENCHANTMENT_LAPIS_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Furnace(l) => match l {
+ FurnaceMenuLocation::Player => {
+ self.try_move_item_to_slots(
+ slot_index,
+ Menu::FURNACE_INGREDIENT_SLOT..=Menu::FURNACE_FUEL_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Grindstone(l) => match l {
+ GrindstoneMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::GRINDSTONE_INPUT_SLOT..=Menu::GRINDSTONE_ADDITIONAL_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Hopper(l) => match l {
+ HopperMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::HOPPER_CONTENTS_SLOTS,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Lectern(l) => match l {
+ LecternMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::LECTERN_BOOK_SLOT..=Menu::LECTERN_BOOK_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Loom(l) => match l {
+ LoomMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::LOOM_BANNER_SLOT..=Menu::LOOM_PATTERN_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Merchant(l) => match l {
+ MerchantMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::MERCHANT_PAYMENTS_SLOTS,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::ShulkerBox(l) => match l {
+ ShulkerBoxMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::SHULKER_BOX_CONTENTS_SLOTS,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::LegacySmithing(l) => match l {
+ LegacySmithingMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::LEGACY_SMITHING_INPUT_SLOT..=Menu::LEGACY_SMITHING_ADDITIONAL_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Smithing(l) => match l {
+ SmithingMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::SMITHING_TEMPLATE_SLOT..=Menu::SMITHING_ADDITIONAL_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Smoker(l) => match l {
+ SmokerMenuLocation::Player => {
+ self.try_move_item_to_slots(
+ slot_index,
+ Menu::SMOKER_INGREDIENT_SLOT..=Menu::SMOKER_FUEL_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::CartographyTable(l) => match l {
+ CartographyTableMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::CARTOGRAPHY_TABLE_MAP_SLOT..=Menu::CARTOGRAPHY_TABLE_ADDITIONAL_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ MenuLocation::Stonecutter(l) => match l {
+ StonecutterMenuLocation::Player => {
+ self.try_move_item_to_slots_or_toggle_hotbar(
+ slot_index,
+ Menu::STONECUTTER_INPUT_SLOT..=Menu::STONECUTTER_INPUT_SLOT,
+ );
+ }
+ _ => {
+ self.try_move_item_to_slots(slot_index, self.player_slots_range());
+ }
+ },
+ }
+
+ ItemSlot::Empty
+ }
+
+ fn try_move_item_to_slots_or_toggle_hotbar(
+ &mut self,
+ slot_index: usize,
+ target_slot_indexes: RangeInclusive<usize>,
+ ) {
+ if !self.try_move_item_to_slots(slot_index, target_slot_indexes) {
+ self.try_move_item_to_slots(
+ slot_index,
+ if self.is_hotbar_slot(slot_index) {
+ self.player_slots_without_hotbar_range()
+ } else {
+ self.hotbar_slots_range()
+ },
+ );
+ }
+ }
+
+ /// Whether the given item could be placed in this menu.
+ ///
+ /// TODO: right now this always returns true
+ pub fn may_place(&self, _target_slot_index: usize, _item: &ItemSlotData) -> bool {
+ true
+ }
+
+ /// 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
+ }
+
+ /// Get the maximum number of items that can be placed in this slot.
+ pub fn max_stack_size(&self, _target_slot_index: usize) -> u8 {
+ 64
+ }
+
+ /// Try moving an item to a set of slots in this menu.
+ ///
+ /// Returns the updated item slot.
+ fn try_move_item_to_slots(
+ &mut self,
+ item_slot_index: usize,
+ target_slot_indexes: RangeInclusive<usize>,
+ ) -> bool {
+ let mut item_slot = self.slot(item_slot_index).unwrap().clone();
+
+ // first see if we can stack it with another item
+ if item_slot.kind().stackable() {
+ for target_slot_index in target_slot_indexes.clone() {
+ self.move_item_to_slot_if_stackable(&mut item_slot, target_slot_index);
+ if item_slot.is_empty() {
+ break;
+ }
+ }
+ }
+
+ // and if not then just try putting it in an empty slot
+ if item_slot.is_present() {
+ for target_slot_index in target_slot_indexes {
+ self.move_item_to_slot_if_empty(&mut item_slot, target_slot_index);
+ if item_slot.is_empty() {
+ break;
+ }
+ }
+ }
+
+ item_slot.is_empty()
+ }
+
+ /// Merge this item slot into the target item slot, only if the target item
+ /// slot is present and the same item.
+ fn move_item_to_slot_if_stackable(
+ &mut self,
+ item_slot: &mut ItemSlot,
+ target_slot_index: usize,
+ ) {
+ let ItemSlot::Present(item) = item_slot else {
+ return;
+ };
+ let target_slot = self.slot(target_slot_index).unwrap();
+ if let ItemSlot::Present(target_item) = target_slot {
+ // the target slot is empty, so we can just move the item there
+ if self.may_place(target_slot_index, item) && target_item.is_same_item_and_nbt(item) {
+ let slot_item_limit = self.max_stack_size(target_slot_index);
+ let new_target_slot_data = item.split(u8::min(slot_item_limit, item.count as u8));
+
+ // get the target slot again but mut this time so we can update it
+ let target_slot = self.slot_mut(target_slot_index).unwrap();
+ *target_slot = ItemSlot::Present(new_target_slot_data);
+
+ item_slot.update_empty();
+ }
+ }
+ }
+
+ fn move_item_to_slot_if_empty(&mut self, item_slot: &mut ItemSlot, target_slot_index: usize) {
+ let ItemSlot::Present(item) = item_slot else {
+ return;
+ };
+ let target_slot = self.slot(target_slot_index).unwrap();
+ if target_slot.is_empty() && self.may_place(target_slot_index, item) {
+ let slot_item_limit = self.max_stack_size(target_slot_index);
+ let new_target_slot_data = item.split(u8::min(slot_item_limit, item.count as u8));
+
+ let target_slot = self.slot_mut(target_slot_index).unwrap();
+ *target_slot = ItemSlot::Present(new_target_slot_data);
+ item_slot.update_empty();
+ }
+ }
+}
diff --git a/azalea-inventory/src/slot.rs b/azalea-inventory/src/slot.rs
new file mode 100644
index 00000000..cef555d7
--- /dev/null
+++ b/azalea-inventory/src/slot.rs
@@ -0,0 +1,146 @@
+use azalea_buf::{BufReadError, McBuf, McBufReadable, McBufWritable};
+use azalea_nbt::Nbt;
+use std::io::{Cursor, Write};
+
+/// Either an item in an inventory or nothing.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub enum ItemSlot {
+ #[default]
+ Empty,
+ Present(ItemSlotData),
+}
+
+impl ItemSlot {
+ /// Check if the slot is ItemSlot::Empty, if the count is <= 0, or if the
+ /// item is air.
+ ///
+ /// This is the opposite of [`ItemSlot::is_present`].
+ pub fn is_empty(&self) -> bool {
+ match self {
+ ItemSlot::Empty => true,
+ ItemSlot::Present(item) => item.is_empty(),
+ }
+ }
+ /// Check if the slot is not ItemSlot::Empty, if the count is > 0, and if
+ /// the item is not air.
+ ///
+ /// This is the opposite of [`ItemSlot::is_empty`].
+ pub fn is_present(&self) -> bool {
+ !self.is_empty()
+ }
+
+ /// Return the amount of the item in the slot, or 0 if the slot is empty.
+ ///
+ /// Note that it's possible for the count to be zero or negative when the
+ /// slot is present.
+ pub fn count(&self) -> i8 {
+ match self {
+ ItemSlot::Empty => 0,
+ ItemSlot::Present(i) => i.count,
+ }
+ }
+
+ /// Remove `count` items from this slot, returning the removed items.
+ pub fn split(&mut self, count: u8) -> ItemSlot {
+ match self {
+ ItemSlot::Empty => ItemSlot::Empty,
+ ItemSlot::Present(i) => {
+ let returning = i.split(count);
+ if i.is_empty() {
+ *self = ItemSlot::Empty;
+ }
+ ItemSlot::Present(returning)
+ }
+ }
+ }
+
+ /// Get the `kind` of the item in this slot, or
+ /// [`azalea_registry::Item::Air`]
+ pub fn kind(&self) -> azalea_registry::Item {
+ match self {
+ ItemSlot::Empty => azalea_registry::Item::Air,
+ ItemSlot::Present(i) => i.kind,
+ }
+ }
+
+ /// Update whether this slot is empty, based on the count.
+ pub fn update_empty(&mut self) {
+ if let ItemSlot::Present(i) = self {
+ if i.is_empty() {
+ *self = ItemSlot::Empty;
+ }
+ }
+ }
+}
+
+/// An item in an inventory, with a count and NBT. Usually you want [`ItemSlot`]
+/// or [`azalea_registry::Item`] instead.
+#[derive(Debug, Clone, McBuf, PartialEq)]
+pub struct ItemSlotData {
+ pub kind: azalea_registry::Item,
+ /// The amount of the item in this slot.
+ ///
+ /// The count can be zero or negative, but this is rare.
+ pub count: i8,
+ pub nbt: Nbt,
+}
+
+impl ItemSlotData {
+ /// Remove `count` items from this slot, returning the removed items.
+ pub fn split(&mut self, count: u8) -> ItemSlotData {
+ let returning_count = i8::min(count as i8, self.count);
+ let mut returning = self.clone();
+ returning.count = returning_count;
+ self.count -= returning_count;
+ returning
+ }
+
+ /// Check if the count of the item is <= 0 or if the item is air.
+ pub fn is_empty(&self) -> bool {
+ self.count <= 0 || self.kind == azalea_registry::Item::Air
+ }
+
+ /// Whether this item is the same as another item, ignoring the count.
+ ///
+ /// ```
+ /// # use azalea_inventory::ItemSlotData;
+ /// # use azalea_registry::Item;
+ /// let mut a = ItemSlotData {
+ /// kind: Item::Stone,
+ /// count: 1,
+ /// nbt: Default::default(),
+ /// };
+ /// let mut b = ItemSlotData {
+ /// kind: Item::Stone,
+ /// count: 2,
+ /// nbt: Default::default(),
+ /// };
+ /// assert!(a.is_same_item_and_nbt(&b));
+ ///
+ /// b.kind = Item::Dirt;
+ /// assert!(!a.is_same_item_and_nbt(&b));
+ /// ```
+ pub fn is_same_item_and_nbt(&self, other: &ItemSlotData) -> bool {
+ self.kind == other.kind && self.nbt == other.nbt
+ }
+}
+
+impl McBufReadable for ItemSlot {
+ fn read_from(buf: &mut Cursor<&[u8]>) -> Result<Self, BufReadError> {
+ let slot = Option::<ItemSlotData>::read_from(buf)?;
+ Ok(slot.map_or(ItemSlot::Empty, ItemSlot::Present))
+ }
+}
+
+impl McBufWritable for ItemSlot {
+ fn write_into(&self, buf: &mut impl Write) -> Result<(), std::io::Error> {
+ match self {
+ ItemSlot::Empty => false.write_into(buf)?,
+ ItemSlot::Present(i) => {
+ true.write_into(buf)?;
+ i.write_into(buf)?;
+ }
+ };
+ Ok(())
+ }
+}