From 342575c2fa4009ce18b88afe17db1ecc3dc1deff Mon Sep 17 00:00:00 2001 From: Niclas <130746231+NicDevTV@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:32:06 +0200 Subject: [PATCH] feat(plugin-api): support server-side command suggestions (#2344) * feat(plugin-api): support server-side command suggestions * fix(data): use deterministic block state properties * chore: update subproject commit reference for pumpkin-plugin-wit * fix(plugin-api): avoid unwrap in suggestion handler registration * fix: point WIT submodule at accessible fork * fix: restore upstream WIT submodule URL * fix: update WIT submodule for command suggestions * Update pumpkin-plugin-wit --------- Co-authored-by: Alexander Medvedev --- crates/pumpkin-plugin-api/src/commands.rs | 81 +++++++++++++++ crates/pumpkin-plugin-api/src/lib.rs | 32 +++++- crates/pumpkin-plugin-wit | 2 +- .../pumpkin/src/command/client_suggestions.rs | 15 ++- crates/pumpkin/src/command/dispatcher.rs | 78 +++++++++++---- crates/pumpkin/src/command/node/dispatcher.rs | 31 +++--- crates/pumpkin/src/command/tree/builder.rs | 16 ++- crates/pumpkin/src/command/tree/mod.rs | 17 ++++ crates/pumpkin/src/logging.rs | 2 +- .../wasm_host/wit/v0_1/commands/executor.rs | 98 ++++++++++++++++++- .../wasm/wasm_host/wit/v0_1/commands/mod.rs | 28 +++++- crates/pumpkin/src/world/loot.rs | 2 +- 12 files changed, 352 insertions(+), 50 deletions(-) diff --git a/crates/pumpkin-plugin-api/src/commands.rs b/crates/pumpkin-plugin-api/src/commands.rs index a43c2fb49..972ffe603 100644 --- a/crates/pumpkin-plugin-api/src/commands.rs +++ b/crates/pumpkin-plugin-api/src/commands.rs @@ -16,6 +16,9 @@ use crate::{ pub(crate) static NEXT_COMMAND_ID: AtomicU32 = AtomicU32::new(0); pub(crate) static COMMAND_HANDLERS: Mutex>> = Mutex::new(BTreeMap::new()); +pub(crate) static COMMAND_SUGGESTION_HANDLERS: Mutex< + BTreeMap>, +> = Mutex::new(BTreeMap::new()); /// Handles the execution of a registered command. /// @@ -37,6 +40,62 @@ pub trait CommandHandler: Send + Sync { ) -> Result; } +/// Handles server-side suggestions for a registered command argument. +/// +/// ```rust,ignore +/// use pumpkin_plugin_api::command::{CommandSuggestion, CommandSuggestions, SuggestionRequest}; +/// use pumpkin_plugin_api::commands::CommandSuggestionHandler; +/// use pumpkin_plugin_api::Server; +/// +/// struct PatternSuggestions; +/// +/// impl CommandSuggestionHandler for PatternSuggestions { +/// fn suggest( +/// &self, +/// _sender: pumpkin_plugin_api::command::CommandSender, +/// _server: Server, +/// request: SuggestionRequest, +/// ) -> CommandSuggestions { +/// let token_start = request +/// .input +/// .rfind([',', ' ']) +/// .map_or(request.start as usize, |index| index + 1); +/// let block_start = request.input[token_start..] +/// .rfind('%') +/// .map_or(token_start, |index| token_start + index + 1); +/// let prefix = &request.input[block_start..]; +/// let values = ["stone", "stripped_oak_log", "dirt", "diamond_block"] +/// .into_iter() +/// .filter(|block| block.starts_with(prefix)) +/// .map(|block| CommandSuggestion { +/// value: block.to_string(), +/// tooltip: None, +/// }) +/// .collect(); +/// +/// CommandSuggestions { +/// start: block_start as u32, +/// length: (request.input.len() - block_start) as u32, +/// values, +/// } +/// } +/// } +/// ``` +pub trait CommandSuggestionHandler: Send + Sync { + /// Computes suggestions for the current command input. + /// + /// `request.remaining` contains the replacement text currently covered by + /// the suggestion range. Handlers may return a narrower range when only part + /// of an argument should be replaced, for example after the last comma in a + /// weighted block pattern. + fn suggest( + &self, + sender: CommandSender, + server: Server, + request: SuggestionRequest, + ) -> CommandSuggestions; +} + impl Command { /// Attaches an execution handler to this command. /// @@ -74,4 +133,26 @@ impl CommandNode { self } + + /// Attaches a server-side suggestion handler to this argument node. + /// + /// The node is advertised to Java clients with `minecraft:ask_server`, and + /// the handler is called whenever the client requests completions for this + /// argument. + pub fn suggest(self, handler: H) -> Self { + let id = NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed); + + COMMAND_SUGGESTION_HANDLERS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id, Box::new(handler)); + + self.suggest_with_handler_id(id); + + self + } } + +pub use crate::wit::pumpkin::plugin::command::{ + CommandSuggestion, CommandSuggestions, SuggestionRequest, +}; diff --git a/crates/pumpkin-plugin-api/src/lib.rs b/crates/pumpkin-plugin-api/src/lib.rs index ab266914d..49a1afe87 100644 --- a/crates/pumpkin-plugin-api/src/lib.rs +++ b/crates/pumpkin-plugin-api/src/lib.rs @@ -67,8 +67,11 @@ //! ``` use crate::{ - commands::COMMAND_HANDLERS, events::EVENT_HANDLERS, logging::WitSubscriber, - scheduler::TASK_HANDLERS, text::TextComponent, + commands::{COMMAND_HANDLERS, COMMAND_SUGGESTION_HANDLERS}, + events::EVENT_HANDLERS, + logging::WitSubscriber, + scheduler::TASK_HANDLERS, + text::TextComponent, }; /// Plugin command registration and handling utilities. @@ -98,8 +101,8 @@ pub mod worldgen; /// Command WIT API re-exports. pub mod command { pub use crate::wit::pumpkin::plugin::command::{ - Arg, ArgumentType, Command, CommandError, CommandNode, CommandSender, ConsumedArgs, - StringType, + Arg, ArgumentType, Command, CommandError, CommandNode, CommandSender, CommandSuggestion, + CommandSuggestions, ConsumedArgs, StringType, SuggestionRequest, }; } @@ -249,6 +252,27 @@ impl wit::Guest for Component { ) } + /// WIT entry point — dispatches an incoming command suggestion request to the registered handler. + fn handle_command_suggestion( + handler_id: u32, + sender: command::CommandSender, + server: Server, + request: command::SuggestionRequest, + ) -> command::CommandSuggestions { + let handlers = COMMAND_SUGGESTION_HANDLERS + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some(handler) = handlers.get(&handler_id) { + handler.suggest(sender, server, request) + } else { + command::CommandSuggestions { + start: request.start, + length: 0, + values: Vec::new(), + } + } + } + /// WIT entry point — dispatches a scheduled task invocation to the registered handler for `handler_id`. fn handle_task(handler_id: u32, server: Server) { let mut handlers = TASK_HANDLERS.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/pumpkin-plugin-wit b/crates/pumpkin-plugin-wit index ddd4b082c..b44607b19 160000 --- a/crates/pumpkin-plugin-wit +++ b/crates/pumpkin-plugin-wit @@ -1 +1 @@ -Subproject commit ddd4b082c093812b8675fba698f5c398a9358151 +Subproject commit b44607b19fa796edb28562382a1f4f7f788a4504 diff --git a/crates/pumpkin/src/command/client_suggestions.rs b/crates/pumpkin/src/command/client_suggestions.rs index 7e012eb50..89c58b173 100644 --- a/crates/pumpkin/src/command/client_suggestions.rs +++ b/crates/pumpkin/src/command/client_suggestions.rs @@ -257,7 +257,11 @@ fn nodes_to_proto_node_builders<'a>( for i in children { let node = &nodes[*i]; match &node.node_type { - NodeType::Argument { name, consumer } => { + NodeType::Argument { + name, + consumer, + suggestion_provider, + } => { let (node_is_executable, node_children) = nodes_to_proto_node_builders(cmd_src, nodes, &node.children); child_nodes.push(ProtoNodeBuilder { @@ -267,8 +271,11 @@ fn nodes_to_proto_node_builders<'a>( is_executable: node_is_executable, redirect_target: None, parser: consumer.get_client_side_parser(), - override_suggestion_type: consumer - .get_client_side_suggestion_type_override(), + override_suggestion_type: if suggestion_provider.is_some() { + Some(SuggestionProviders::AskServer) + } else { + consumer.get_client_side_suggestion_type_override() + }, restricted: false, }, }); @@ -488,7 +495,7 @@ fn collect_overloads_from_nodes( }); collect_overloads_from_nodes(nodes, &node.children, &mut params, overloads, ctx); } - NodeType::Argument { name, consumer } => { + NodeType::Argument { name, consumer, .. } => { let mut params = current_params.clone(); params.push(CommandParameter { name: name.clone(), diff --git a/crates/pumpkin/src/command/dispatcher.rs b/crates/pumpkin/src/command/dispatcher.rs index 95ca20280..63a1b1e48 100644 --- a/crates/pumpkin/src/command/dispatcher.rs +++ b/crates/pumpkin/src/command/dispatcher.rs @@ -1,5 +1,4 @@ use pumpkin_data::translation; -use pumpkin_protocol::java::client::play::CommandSuggestion; use pumpkin_util::text::TextComponent; use pumpkin_util::text::click::ClickEvent; use pumpkin_util::text::color::NamedColor; @@ -15,8 +14,12 @@ use crate::command::dispatcher::CommandError::{ CommandFailed, InvalidConsumption, InvalidRequirement, PermissionDenied, SyntaxError, }; use crate::command::tree::{Command, CommandTree, NodeType, RawArg, RawArgs}; +use crate::command::{ + context::string_range::StringRange, + suggestion::{Suggestion, suggestions::Suggestions}, +}; use crate::server::Server; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; #[derive(Debug)] pub enum CommandError { @@ -268,10 +271,10 @@ impl CommandDispatcher { src: &'a CommandSender, server: &'a Server, cmd: &'a str, - ) -> Vec { + ) -> Suggestions { let mut parts = cmd.split_whitespace(); let Some(key) = parts.next() else { - return Vec::new(); + return Suggestions::empty(); }; let mut raw_args: RawArgs<'a> = parts .rev() @@ -284,7 +287,7 @@ impl CommandDispatcher { .collect(); let Ok(tree) = self.get_tree(key) else { - return Vec::new(); + return Suggestions::empty(); }; // Gate suggestions on command permissions, mirroring `dispatch`. Many @@ -293,14 +296,14 @@ impl CommandDispatcher { // consumers would leak privileged data (e.g. banned/op/whitelisted // player names) to unprivileged players via tab-complete. let Some(permission) = self.permissions.get(key) else { - return Vec::new(); + return Suggestions::empty(); }; if !src.has_permission(server, permission.as_str()).await { - return Vec::new(); + return Suggestions::empty(); } - let mut suggestions = HashSet::new(); + let mut suggestions = Vec::new(); // try paths and collect the nodes that fail // todo: make this more fine-grained @@ -312,27 +315,27 @@ impl CommandDispatcher { debug!( "Error while parsing command \"{cmd}\": {s:?} was consumed, but couldn't be parsed" ); - return Vec::new(); + return Suggestions::empty(); } Err(InvalidRequirement) => { debug!( "Error while parsing command \"{cmd}\": a requirement that was expected was not met." ); - return Vec::new(); + return Suggestions::empty(); } Err(PermissionDenied) => { debug!("Permission denied for command \"{cmd}\""); - return Vec::new(); + return Suggestions::empty(); } Err(CommandFailed(_)) => { debug!("Command failed"); - return Vec::new(); + return Suggestions::empty(); } Err(SyntaxError(_)) => { - return Vec::new(); + return Suggestions::empty(); } Ok(Some(new_suggestions)) => { - suggestions.extend(new_suggestions); + suggestions.push(new_suggestions); } Ok(None) => { debug!("Command none"); @@ -340,9 +343,7 @@ impl CommandDispatcher { } } - let mut suggestions = Vec::from_iter(suggestions); - suggestions.sort_by(|a, b| a.suggestion.cmp(&b.suggestion)); - suggestions + Suggestions::merge(cmd, suggestions) } #[allow(clippy::too_many_lines)] @@ -621,7 +622,7 @@ impl CommandDispatcher { tree: &'a CommandTree, raw_args: &mut RawArgs<'a>, input: &'a str, - ) -> Result>, CommandError> { + ) -> Result, CommandError> { //let mut parsed_args: ConsumedArgs = HashMap::new(); for node in path.iter().map(|&i| &tree.nodes[i]) { @@ -634,15 +635,50 @@ impl CommandDispatcher { return Ok(None); } } - NodeType::Argument { consumer, name: _ } => { + NodeType::Argument { + consumer, + suggestion_provider, + name: _, + } => { match consumer.consume_with_syntax(src, server, raw_args).await { Ok(Some(_consumed)) => { //parsed_args.insert(name, consumed); } Ok(None) => { return if raw_args.is_empty() { - let suggestions = consumer.suggest(src, server, input).await?; - Ok(suggestions) + let start = input + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map_or(0, |(index, c)| index + c.len_utf8()); + if let Some(provider) = suggestion_provider { + Ok(Some( + provider + .suggest(src, server, input, start, input.len()) + .await, + )) + } else { + let suggestions = consumer.suggest(src, server, input).await?; + let range = StringRange::between(start, input.len()); + Ok(suggestions.map(|suggestions| { + Suggestions::new( + range, + suggestions + .into_iter() + .map(|suggestion| match suggestion.tooltip { + Some(tooltip) => Suggestion::with_tooltip( + range, + suggestion.suggestion, + tooltip, + ), + None => Suggestion::without_tooltip( + range, + suggestion.suggestion, + ), + }) + .collect(), + ) + })) + } } else { Ok(None) }; diff --git a/crates/pumpkin/src/command/node/dispatcher.rs b/crates/pumpkin/src/command/node/dispatcher.rs index 4e075ced6..76717e770 100644 --- a/crates/pumpkin/src/command/node/dispatcher.rs +++ b/crates/pumpkin/src/command/node/dispatcher.rs @@ -712,23 +712,26 @@ impl CommandDispatcher { /// This function currently panics if the source provided was a dummy source. /// This is subject to change in the future. pub async fn suggest(&self, input: &str, source: &CommandSource) -> Vec { + self.suggest_with_range(input, source) + .await + .suggestions + .into_iter() + .map(|suggestion| CommandSuggestion { + suggestion: suggestion.text.cached_text().clone(), + tooltip: suggestion.tooltip, + }) + .collect() + } + + pub async fn suggest_with_range(&self, input: &str, source: &CommandSource) -> Suggestions { // Never suggest arguments for a command that has been turned off. if self.is_disabled(Self::command_name(input)) { - return Vec::new(); + return Suggestions::empty(); } let future1 = async move { let parsed = self.parse_input(input, source).await; - let suggestions = self.get_completion_suggestions_at_end(parsed).await; - - suggestions - .suggestions - .into_iter() - .map(|suggestion| CommandSuggestion { - suggestion: suggestion.text.cached_text().clone(), - tooltip: suggestion.tooltip, - }) - .collect::>() + self.get_completion_suggestions_at_end(parsed).await }; let future2 = async move { @@ -737,9 +740,9 @@ impl CommandDispatcher { .await }; - let (mut a, mut b) = future::join(future1, future2).await; - a.append(&mut b); - a + let (a, b) = future::join(future1, future2).await; + let suggestions = <[Suggestions; 2]>::from((a, b)); + Suggestions::merge(input, suggestions) } /// Gets all the commands usable in this dispatcher, sorted. diff --git a/crates/pumpkin/src/command/tree/builder.rs b/crates/pumpkin/src/command/tree/builder.rs index d1ee4e299..6997e40fd 100644 --- a/crates/pumpkin/src/command/tree/builder.rs +++ b/crates/pumpkin/src/command/tree/builder.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use super::CommandExecutor; use crate::command::CommandSender; use crate::command::args::{ArgumentConsumer, DefaultNameArgConsumer}; -use crate::command::tree::{CommandTree, Node, NodeType}; +use crate::command::tree::{CommandSuggestionProvider, CommandTree, Node, NodeType}; impl CommandTree { /// Add a child [Node] to the root of this [`CommandTree`]. @@ -127,6 +127,18 @@ impl NonLeafNodeBuilder { self } + + #[must_use] + pub fn suggests(mut self, provider: impl CommandSuggestionProvider + 'static) -> Self { + if let NodeType::Argument { + suggestion_provider, + .. + } = &mut self.node_type + { + *suggestion_provider = Some(Arc::new(provider)); + } + self + } } /// Matches a string literal. @@ -157,6 +169,7 @@ pub fn argument( node_type: NodeType::Argument { name: name.into(), consumer: Arc::new(consumer), + suggestion_provider: None, }, child_nodes: Vec::new(), leaf_nodes: Vec::new(), @@ -171,6 +184,7 @@ pub fn argument_default_name( node_type: NodeType::Argument { name: consumer.default_name().to_string(), consumer: Arc::new(consumer), + suggestion_provider: None, }, child_nodes: Vec::new(), leaf_nodes: Vec::new(), diff --git a/crates/pumpkin/src/command/tree/mod.rs b/crates/pumpkin/src/command/tree/mod.rs index 9f8df85ff..6abb8657b 100644 --- a/crates/pumpkin/src/command/tree/mod.rs +++ b/crates/pumpkin/src/command/tree/mod.rs @@ -1,5 +1,8 @@ use super::{CommandExecutor, args::ArgumentConsumer}; use crate::command::CommandSender; +use crate::command::suggestion::suggestions::Suggestions; +use crate::server::Server; +use std::pin::Pin; use std::{borrow::Cow, collections::VecDeque, fmt::Debug, sync::Arc}; pub mod builder; @@ -26,6 +29,19 @@ impl Debug for RawArg<'_> { /// see [`crate::command::tree::builder::argument`] pub type RawArgs<'a> = Vec>; +pub type CommandSuggestionResult<'a> = Pin + Send + 'a>>; + +pub trait CommandSuggestionProvider: Send + Sync { + fn suggest<'a>( + &'a self, + src: &'a CommandSender, + server: &'a Server, + input: &'a str, + start: usize, + end: usize, + ) -> CommandSuggestionResult<'a>; +} + #[derive(Debug, Clone)] pub struct Node { pub children: Vec, @@ -43,6 +59,7 @@ pub enum NodeType { Argument { name: String, consumer: Arc, + suggestion_provider: Option>, }, Require { predicate: Arc bool + Send + Sync>, diff --git a/crates/pumpkin/src/logging.rs b/crates/pumpkin/src/logging.rs index 6fbf6b71b..d5bae1b19 100644 --- a/crates/pumpkin/src/logging.rs +++ b/crates/pumpkin/src/logging.rs @@ -597,7 +597,7 @@ impl Completer for PumpkinCommandCompleter { candidates.push(string.clone()); } } - NodeType::Argument { name, consumer } => { + NodeType::Argument { name, consumer, .. } => { let suggest_future = consumer.suggest(&src, server, typing); if let Ok(Some(suggestions)) = suggest_future.await { diff --git a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/executor.rs b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/executor.rs index cdb07c66e..142302995 100644 --- a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/executor.rs +++ b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/executor.rs @@ -6,10 +6,16 @@ use pumpkin_util::text::{ }; use crate::{ - command::{CommandExecutor, dispatcher::CommandError}, + command::{ + CommandExecutor, + context::string_range::StringRange, + dispatcher::CommandError, + suggestion::{Suggestion, suggestions::Suggestions}, + tree::{CommandSuggestionProvider, CommandSuggestionResult}, + }, plugin::loader::wasm::wasm_host::{ DowncastResourceExt, PluginInstance, WasmPlugin, - wit::v0_1::pumpkin::plugin::command::CommandError as CommandErrorWit, + wit::v0_1::pumpkin::plugin::command::{CommandError as CommandErrorWit, SuggestionRequest}, }, server::Server, }; @@ -111,3 +117,91 @@ impl CommandExecutor for WasmCommandExecutor { }) } } + +pub struct WasmCommandSuggestionProvider { + pub handler_id: u32, + pub plugin: Arc, + pub server: Arc, +} + +impl CommandSuggestionProvider for WasmCommandSuggestionProvider { + fn suggest<'a>( + &'a self, + src: &'a crate::command::CommandSender, + _server: &'a Server, + input: &'a str, + start: usize, + end: usize, + ) -> CommandSuggestionResult<'a> { + Box::pin(async move { + let mut store = self.plugin.store.lock().await; + + let sender_resource = match store.data_mut().add_command_sender(src.clone()) { + Ok(resource) => resource, + Err(error) => { + tracing::error!( + "Failed to create command sender resource for suggestions: {error}" + ); + return Suggestions::empty(); + } + }; + let server_resource = match store.data_mut().add_server(self.server.clone()) { + Ok(resource) => resource, + Err(error) => { + tracing::error!("Failed to create server resource for suggestions: {error}"); + return Suggestions::empty(); + } + }; + + let request = SuggestionRequest { + input: input.to_string(), + cursor: input.len().try_into().unwrap_or(u32::MAX), + start: start.try_into().unwrap_or(u32::MAX), + remaining: input[start.min(input.len())..end.min(input.len())].to_string(), + }; + + let response = match self.plugin.plugin_instance { + PluginInstance::V0_1(ref plugin) => { + plugin + .call_handle_command_suggestion( + &mut *store, + self.handler_id, + sender_resource, + server_resource, + &request, + ) + .await + } + }; + + let response = match response { + Ok(response) => response, + Err(error) => { + tracing::error!("Wasm command suggestion failed: {error}"); + return Suggestions::empty(); + } + }; + + let start = response.start as usize; + let end = start.saturating_add(response.length as usize); + let range = StringRange::between(start, end.min(input.len())); + let suggestions = response + .values + .into_iter() + .map(|suggestion| { + if let Some(tooltip) = suggestion.tooltip { + Suggestion::with_tooltip( + range, + suggestion.value, + tooltip.consume(store.data_mut()).provider, + ) + } else { + Suggestion::without_tooltip(range, suggestion.value) + } + }) + .collect(); + + Suggestions::new(range, suggestions) + }) + } +} diff --git a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs index 71534b686..8d0205c8d 100644 --- a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs +++ b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs @@ -35,7 +35,7 @@ use crate::{ PluginHostState, ServerResource, TextComponentResource, }, wit::v0_1::{ - commands::executor::WasmCommandExecutor, + commands::executor::{WasmCommandExecutor, WasmCommandSuggestionProvider}, pumpkin::{ self, plugin::{ @@ -647,6 +647,32 @@ impl pumpkin::plugin::command::HostCommandNode for PluginHostState { Ok(()) } + async fn suggest_with_handler_id( + &mut self, + node: Resource, + handler_id: u32, + ) -> wasmtime::Result<()> { + let plugin = self + .plugin + .as_ref() + .and_then(std::sync::Weak::upgrade) + .ok_or_else(|| wasmtime::Error::msg("Plugin dropped"))?; + let server = self + .server + .clone() + .ok_or_else(|| wasmtime::Error::msg("Server not initialized"))?; + + let provider = WasmCommandSuggestionProvider { + handler_id, + plugin, + server, + }; + let resource = self.get_node_mut(&node)?; + let builder = std::mem::replace(&mut resource.provider, literal("")); + resource.provider = builder.suggests(provider); + Ok(()) + } + async fn require_with_handler_id( &mut self, _node: Resource, diff --git a/crates/pumpkin/src/world/loot.rs b/crates/pumpkin/src/world/loot.rs index 3c1f48b54..cf3792ce0 100644 --- a/crates/pumpkin/src/world/loot.rs +++ b/crates/pumpkin/src/world/loot.rs @@ -248,7 +248,7 @@ impl LootFunctionExt for LootFunction { Block::properties(Block::from_state_id(state.id), state.id) { let actual_props = props_data.to_props(); - let mut properties_to_copy = std::collections::HashMap::new(); + let mut properties_to_copy = std::collections::BTreeMap::new(); for &prop_name in *properties { if let Some((_, value)) = actual_props.iter().find(|(k, _)| k == &prop_name) {