From bb5033046b6aa0d89feb49cbf07192a7701c353d Mon Sep 17 00:00:00 2001 From: ChocoDev <100467857+chocodev11@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:25:45 +0700 Subject: [PATCH] fix: offline player support for some command with usercache (#1793) * add gameprofile that support offline player * clippy * bigass refactor select_players() and select_entities() + usercache.json * formating * enhance usercache.json * better cursor on dispatch * Update dependencies in Cargo.lock Signed-off-by: ChocoDev <100467857+chocodev11@users.noreply.github.com> * using chrono instead self-write * remove unnecessary parse * clippy --------- Signed-off-by: ChocoDev <100467857+chocodev11@users.noreply.github.com> Co-authored-by: chocodev11 --- Cargo.lock | 14 + Cargo.toml | 1 + pumpkin/Cargo.toml | 2 +- pumpkin/src/command/args/block.rs | 4 +- pumpkin/src/command/args/bool.rs | 2 +- pumpkin/src/command/args/bossbar_color.rs | 2 +- pumpkin/src/command/args/bossbar_style.rs | 2 +- pumpkin/src/command/args/bounded_num.rs | 2 +- pumpkin/src/command/args/command.rs | 2 +- pumpkin/src/command/args/difficulty.rs | 2 +- pumpkin/src/command/args/entities.rs | 276 ++++++++-- pumpkin/src/command/args/entity.rs | 30 +- pumpkin/src/command/args/entity_anchor.rs | 2 +- pumpkin/src/command/args/gamemode.rs | 2 +- pumpkin/src/command/args/gameprofile.rs | 422 ++++++++++++++++ pumpkin/src/command/args/message.rs | 4 +- pumpkin/src/command/args/mod.rs | 15 + pumpkin/src/command/args/players.rs | 30 +- pumpkin/src/command/args/position_2d.rs | 4 +- pumpkin/src/command/args/position_3d.rs | 6 +- pumpkin/src/command/args/position_block.rs | 6 +- .../src/command/args/resource/damage_type.rs | 2 +- pumpkin/src/command/args/resource/effect.rs | 2 +- .../src/command/args/resource/enchantment.rs | 2 +- pumpkin/src/command/args/resource/item.rs | 4 +- pumpkin/src/command/args/resource/particle.rs | 2 +- pumpkin/src/command/args/resource_location.rs | 2 +- pumpkin/src/command/args/rotation.rs | 4 +- pumpkin/src/command/args/simple.rs | 2 +- pumpkin/src/command/args/sound.rs | 2 +- pumpkin/src/command/args/sound_category.rs | 2 +- .../src/command/args/summonable_entities.rs | 2 +- pumpkin/src/command/args/textcomponent.rs | 2 +- pumpkin/src/command/args/time.rs | 2 +- pumpkin/src/command/commands/ban.rs | 63 ++- pumpkin/src/command/commands/deop.rs | 62 ++- pumpkin/src/command/commands/op.rs | 61 +-- pumpkin/src/command/commands/pardon.rs | 61 ++- pumpkin/src/command/commands/whitelist.rs | 50 +- pumpkin/src/command/dispatcher.rs | 478 ++++++++++++++++-- pumpkin/src/command/errors/error_types.rs | 2 +- pumpkin/src/command/tree/mod.rs | 20 +- pumpkin/src/data/banned_player.rs | 2 +- pumpkin/src/data/mod.rs | 3 + pumpkin/src/data/usercache.rs | 224 ++++++++ pumpkin/src/data/whitelist.rs | 4 +- pumpkin/src/net/authentication.rs | 33 ++ .../src/plugin/loader/wasm/wasm_host/args.rs | 2 + pumpkin/src/server/mod.rs | 259 ++++++---- 49 files changed, 1830 insertions(+), 354 deletions(-) create mode 100644 pumpkin/src/command/args/gameprofile.rs create mode 100644 pumpkin/src/data/usercache.rs diff --git a/Cargo.lock b/Cargo.lock index 4c1bd606f..91ab1f610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,19 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -2686,6 +2699,7 @@ dependencies = [ "arc-swap", "base64 0.22.1", "bytes", + "chrono", "console-subscriber", "crossbeam", "flate2", diff --git a/Cargo.toml b/Cargo.toml index c7a0929ab..d21245c11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ bytes = "1.11" futures = { version = "0.3", default-features = false, features = ["executor"] } rayon = "1.11" crossbeam = "0.8" +chrono = "0.4" uuid = { version = "1.23", features = ["serde", "v3", "v4"] } serde = { version = "1.0", features = ["derive"] } diff --git a/pumpkin/Cargo.toml b/pumpkin/Cargo.toml index 51b4483f2..9fffe8184 100644 --- a/pumpkin/Cargo.toml +++ b/pumpkin/Cargo.toml @@ -76,7 +76,7 @@ base64.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -# Remove time in favor of chrono? +chrono.workspace = true time = { workspace = true, features = ["parsing", "macros"] } # plugins diff --git a/pumpkin/src/command/args/block.rs b/pumpkin/src/command/args/block.rs index 570beff06..53d203472 100644 --- a/pumpkin/src/command/args/block.rs +++ b/pumpkin/src/command/args/block.rs @@ -33,7 +33,7 @@ impl ArgumentConsumer for BlockArgumentConsumer { _server: &'a Server, args: &mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let block = args.pop(); + let block = args.pop().map(|arg| arg.value); match block { Some(s) => Box::pin(async move { Some(Arg::Block(s)) }), None => Box::pin(async move { None }), @@ -97,7 +97,7 @@ impl ArgumentConsumer for BlockPredicateArgumentConsumer { _server: &'a Server, args: &mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let block = args.pop(); + let block = args.pop().map(|arg| arg.value); match block { Some(s) => Box::pin(async move { Some(Arg::BlockPredicate(s)) }), None => Box::pin(async move { None }), diff --git a/pumpkin/src/command/args/bool.rs b/pumpkin/src/command/args/bool.rs index 8a22343e0..ab60394ce 100644 --- a/pumpkin/src/command/args/bool.rs +++ b/pumpkin/src/command/args/bool.rs @@ -24,7 +24,7 @@ impl ArgumentConsumer for BoolArgConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt.map_or_else( || None, diff --git a/pumpkin/src/command/args/bossbar_color.rs b/pumpkin/src/command/args/bossbar_color.rs index d37fa13c8..b4e719b6d 100644 --- a/pumpkin/src/command/args/bossbar_color.rs +++ b/pumpkin/src/command/args/bossbar_color.rs @@ -29,7 +29,7 @@ impl ArgumentConsumer for BossbarColorArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt.map_or_else( || None, diff --git a/pumpkin/src/command/args/bossbar_style.rs b/pumpkin/src/command/args/bossbar_style.rs index f6797e932..f2e9fecbc 100644 --- a/pumpkin/src/command/args/bossbar_style.rs +++ b/pumpkin/src/command/args/bossbar_style.rs @@ -29,7 +29,7 @@ impl ArgumentConsumer for BossbarStyleArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt.map_or_else( || None, diff --git a/pumpkin/src/command/args/bounded_num.rs b/pumpkin/src/command/args/bounded_num.rs index 169ce919c..abbdf14a4 100644 --- a/pumpkin/src/command/args/bounded_num.rs +++ b/pumpkin/src/command/args/bounded_num.rs @@ -31,7 +31,7 @@ where _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt // Replace args.pop()?.parse::().ok()? diff --git a/pumpkin/src/command/args/command.rs b/pumpkin/src/command/args/command.rs index 1049ee971..1e46721b9 100644 --- a/pumpkin/src/command/args/command.rs +++ b/pumpkin/src/command/args/command.rs @@ -33,7 +33,7 @@ impl ArgumentConsumer for CommandTreeArgumentConsumer { server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let Some(s) = s_opt else { return Box::pin(async move { None }); diff --git a/pumpkin/src/command/args/difficulty.rs b/pumpkin/src/command/args/difficulty.rs index 6720c7a03..832d8d69e 100644 --- a/pumpkin/src/command/args/difficulty.rs +++ b/pumpkin/src/command/args/difficulty.rs @@ -36,7 +36,7 @@ impl ArgumentConsumer for DifficultyArgumentConsumer { _server: &'a Server, args: &mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt.and_then(|s| Difficulty::from_str(s).map(Arg::Difficulty).ok()); diff --git a/pumpkin/src/command/args/entities.rs b/pumpkin/src/command/args/entities.rs index 24935ab50..1da2c3050 100644 --- a/pumpkin/src/command/args/entities.rs +++ b/pumpkin/src/command/args/entities.rs @@ -2,15 +2,18 @@ use std::str::FromStr; use std::sync::Arc; use crate::command::CommandSender; -use crate::command::args::ConsumeResult; +use crate::command::args::{ConsumeResult, ConsumeResultWithSyntax}; use crate::command::dispatcher::CommandError; -use crate::command::tree::RawArgs; +use crate::command::errors::command_syntax_error::{CommandSyntaxError, CommandSyntaxErrorContext}; +use crate::command::errors::error_types; +use crate::command::tree::{RawArg, RawArgs}; use crate::entity::EntityBase; use crate::server::Server; -use pumpkin_data::entity::EntityType; +use pumpkin_data::{entity::EntityType, translation}; use pumpkin_nbt::compound::NbtCompound; use pumpkin_protocol::java::client::play::{ArgumentType, SuggestionProviders}; use pumpkin_util::GameMode; +use pumpkin_util::text::TextComponent; use tracing::debug; use uuid::Uuid; @@ -126,7 +129,6 @@ impl FromStr for EntityFilter { pub struct TargetSelector { pub selector_type: EntitySelectorType, pub conditions: Vec, - pub player_only: bool, } impl TargetSelector { @@ -151,18 +153,36 @@ impl TargetSelector { _ => {} } Self { - player_only: matches!( - selector_type, - EntitySelectorType::AllPlayers - | EntitySelectorType::NearestPlayer - | EntitySelectorType::RandomPlayer - | EntitySelectorType::NamedPlayer(_) - ), selector_type, conditions: filter, } } + const fn base_includes_entities(&self) -> bool { + matches!( + self.selector_type, + EntitySelectorType::AllEntities | EntitySelectorType::NearestEntity + ) + } + + #[must_use] + pub fn includes_entities(&self) -> bool { + let player_type = EntityType::from_name("player").expect("entity type player must exist"); + let mut includes_entities = self.base_includes_entities(); + + for condition in &self.conditions { + if let EntityFilter::Type(ValueCondition::Equals(entity_type)) = condition { + includes_entities = *entity_type != player_type; + } else if let EntityFilter::Type(ValueCondition::NotEquals(entity_type)) = condition + && *entity_type == player_type + { + includes_entities = true; + } + } + + includes_entities + } + #[must_use] pub fn get_sort(&self) -> Option { self.conditions.iter().rev().find_map(|f| { @@ -194,44 +214,101 @@ impl FromStr for TargetSelector { type Err = String; fn from_str(arg: &str) -> Result { - if arg.starts_with('@') { - let (type_str, arguments) = arg - .find('[') - .map_or((arg, None), |idx| (&arg[..idx], Some(&arg[idx + 1..]))); + parse_target_selector(arg).map_err(|error| error.message) + } +} - let selector_type = match type_str { - "@a" => EntitySelectorType::AllPlayers, - "@e" => EntitySelectorType::AllEntities, - "@s" => EntitySelectorType::Source, - "@p" => EntitySelectorType::NearestPlayer, - "@r" => EntitySelectorType::RandomPlayer, - "@n" => EntitySelectorType::NearestEntity, - _ => return Err(format!("Invalid target selector type {type_str}")), - }; +#[derive(Debug)] +struct TargetSelectorParseError { + message: String, + cursor: usize, +} - let mut selector = Self::new(selector_type); +fn parse_target_selector(arg: &str) -> Result { + if !arg.starts_with('@') { + return Uuid::parse_str(arg).map_or_else( + |_| { + Ok(TargetSelector::new(EntitySelectorType::NamedPlayer( + arg.to_string(), + ))) + }, + |uuid| Ok(TargetSelector::new(EntitySelectorType::Uuid(uuid))), + ); + } - if let Some(args_content) = arguments { - let trimmed_args = args_content - .strip_suffix(']') - .ok_or_else(|| "Target selector must end with ]".to_string())?; + let selector_type_end = arg.find('[').unwrap_or(arg.len()); + let type_str = &arg[..selector_type_end]; + let selector_type = match type_str { + "@a" => EntitySelectorType::AllPlayers, + "@e" => EntitySelectorType::AllEntities, + "@s" => EntitySelectorType::Source, + "@p" => EntitySelectorType::NearestPlayer, + "@r" => EntitySelectorType::RandomPlayer, + "@n" => EntitySelectorType::NearestEntity, + _ => { + return Err(TargetSelectorParseError { + message: format!("Invalid target selector type {type_str}"), + cursor: selector_type_end.saturating_sub(1), + }); + } + }; - for s in trimmed_args - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - { - selector.conditions.push(EntityFilter::from_str(s)?); - } - } + let mut selector = TargetSelector::new(selector_type); + if selector_type_end == arg.len() { + return Ok(selector); + } - Ok(selector) - } else if let Ok(uuid) = Uuid::parse_str(arg) { - Ok(Self::new(EntitySelectorType::Uuid(uuid))) - } else { - Ok(Self::new(EntitySelectorType::NamedPlayer(arg.to_string()))) + if !arg.ends_with(']') { + return Err(TargetSelectorParseError { + message: "Target selector must end with ]".to_string(), + cursor: arg.len(), + }); + } + + let args_content = &arg[selector_type_end + 1..arg.len() - 1]; + let mut filter_start = 0usize; + for (i, c) in args_content.char_indices() { + if c == ',' { + parse_selector_filter( + &mut selector, + &args_content[filter_start..i], + selector_type_end + 1 + filter_start, + )?; + filter_start = i + 1; } } + parse_selector_filter( + &mut selector, + &args_content[filter_start..], + selector_type_end + 1 + filter_start, + )?; + + Ok(selector) +} + +fn parse_selector_filter( + selector: &mut TargetSelector, + raw_filter: &str, + filter_offset: usize, +) -> Result<(), TargetSelectorParseError> { + let trimmed_filter = raw_filter.trim(); + if trimmed_filter.is_empty() { + return Ok(()); + } + + let local_trimmed_start = raw_filter + .char_indices() + .find_map(|(index, c)| (!c.is_whitespace()).then_some(index)) + .unwrap_or(0); + let filter_cursor = filter_offset + local_trimmed_start; + + let parsed_filter = + EntityFilter::from_str(trimmed_filter).map_err(|message| TargetSelectorParseError { + message, + cursor: filter_cursor, + })?; + selector.conditions.push(parsed_filter); + Ok(()) } /// todo: implement (currently just calls [`super::arg_player::PlayerArgumentConsumer`]) @@ -257,7 +334,7 @@ impl ArgumentConsumer for EntitiesArgumentConsumer { server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let Some(s) = s_opt else { return Box::pin(async move { None }); @@ -279,6 +356,27 @@ impl ArgumentConsumer for EntitiesArgumentConsumer { Some(Arg::Entities(entities)) }) } + + fn consume_with_syntax<'a>( + &'a self, + sender: &'a CommandSender, + server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResultWithSyntax<'a> { + let Some(raw_arg) = args.pop() else { + return Box::pin(async { Ok(None) }); + }; + + let selector = match parse_target_selector_with_context(raw_arg) { + Ok(selector) => selector, + Err(error) => return Box::pin(async move { Err(error) }), + }; + + Box::pin(async move { + let entities = server.select_entities(&selector, Some(sender)); + Ok(Some(Arg::Entities(entities))) + }) + } } impl DefaultNameArgConsumer for EntitiesArgumentConsumer { @@ -297,3 +395,95 @@ impl<'a> FindArg<'a> for EntitiesArgumentConsumer { } } } + +pub(crate) fn parse_target_selector_with_context( + raw_arg: RawArg<'_>, +) -> Result { + parse_target_selector(raw_arg.value).map_err(|error| { + syntax_error_for_arg_with_cursor( + raw_arg, + TextComponent::translate(translation::ARGUMENT_ENTITY_INVALID, []), + error.cursor, + ) + }) +} + +pub(crate) fn ensure_player_only_selector( + selector: &TargetSelector, + raw_arg: RawArg<'_>, +) -> Result<(), CommandSyntaxError> { + if selector.includes_entities() { + Err(syntax_error_for_arg_with_cursor( + raw_arg, + TextComponent::translate(translation::ARGUMENT_PLAYER_ENTITIES, []), + 0, + )) + } else { + Ok(()) + } +} + +fn syntax_error_for_arg_with_cursor( + raw_arg: RawArg<'_>, + message: TextComponent, + local_cursor: usize, +) -> CommandSyntaxError { + let mut clamped_local_cursor = local_cursor.min(raw_arg.value.len()); + while clamped_local_cursor > 0 && !raw_arg.value.is_char_boundary(clamped_local_cursor) { + clamped_local_cursor -= 1; + } + + CommandSyntaxError { + error_type: &error_types::DISPATCHER_UNKNOWN_ARGUMENT, + message, + context: Some(CommandSyntaxErrorContext { + input: raw_arg.input.to_string(), + cursor: raw_arg.start + clamped_local_cursor, + }), + } +} + +#[cfg(test)] +mod test { + use pumpkin_data::translation; + + use super::{TargetSelector, ensure_player_only_selector, parse_target_selector_with_context}; + use crate::command::tree::RawArg; + + #[test] + fn selector_parse_error_points_inside_token() { + let input = "ban @e[sort=invalid]"; + let raw_arg = RawArg { + value: "@e[sort=invalid]", + start: 4, + end: input.len(), + input, + }; + + let Err(error) = parse_target_selector_with_context(raw_arg) else { + panic!("expected selector parsing to fail"); + }; + let cursor = error.context.unwrap().cursor; + assert_eq!(cursor, 7); + } + + #[test] + fn player_only_error_points_to_selector_start() { + let input = "ban @e"; + let raw_arg = RawArg { + value: "@e", + start: 4, + end: input.len(), + input, + }; + let selector = "@e".parse::().unwrap(); + + let error = ensure_player_only_selector(&selector, raw_arg).unwrap_err(); + let translate_key = match error.message.0.content.as_ref() { + pumpkin_util::text::TextContent::Translate { translate, .. } => translate.as_ref(), + _ => "", + }; + assert_eq!(translate_key, translation::ARGUMENT_PLAYER_ENTITIES); + assert_eq!(error.context.unwrap().cursor, 4); + } +} diff --git a/pumpkin/src/command/args/entity.rs b/pumpkin/src/command/args/entity.rs index 3eecb99fa..e57435563 100644 --- a/pumpkin/src/command/args/entity.rs +++ b/pumpkin/src/command/args/entity.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use crate::command::CommandSender; -use crate::command::args::ConsumeResult; use crate::command::args::entities::TargetSelector; +use crate::command::args::{ConsumeResult, ConsumeResultWithSyntax}; use crate::command::dispatcher::CommandError; use crate::command::tree::RawArgs; use crate::entity::EntityBase; @@ -11,6 +11,7 @@ use pumpkin_protocol::java::client::play::{ArgumentType, SuggestionProviders}; use tracing::debug; use super::super::args::ArgumentConsumer; +use super::entities::parse_target_selector_with_context; use super::{Arg, DefaultNameArgConsumer, FindArg, GetClientSideArgParser}; /// todo: implement for entities that aren't players @@ -40,7 +41,7 @@ impl ArgumentConsumer for EntityArgumentConsumer { server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let Some(s) = s_opt else { return Box::pin(async move { None }); @@ -66,6 +67,31 @@ impl ArgumentConsumer for EntityArgumentConsumer { entities.into_iter().next().map(Arg::Entity) }) } + + fn consume_with_syntax<'a>( + &'a self, + sender: &'a CommandSender, + server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResultWithSyntax<'a> { + let Some(raw_arg) = args.pop() else { + return Box::pin(async { Ok(None) }); + }; + + let selector = match parse_target_selector_with_context(raw_arg) { + Ok(selector) => selector, + Err(error) => return Box::pin(async move { Err(error) }), + }; + + if selector.get_limit() > 1 { + return Box::pin(async { Ok(None) }); + } + + Box::pin(async move { + let entities = server.select_entities(&selector, Some(sender)); + Ok(entities.into_iter().next().map(Arg::Entity)) + }) + } } impl DefaultNameArgConsumer for EntityArgumentConsumer { diff --git a/pumpkin/src/command/args/entity_anchor.rs b/pumpkin/src/command/args/entity_anchor.rs index 06852e543..4a49b5e67 100644 --- a/pumpkin/src/command/args/entity_anchor.rs +++ b/pumpkin/src/command/args/entity_anchor.rs @@ -47,7 +47,7 @@ impl ArgumentConsumer for EntityAnchorArgumentConsumer { _server: &'a Server, args: &mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let Some(anchor_str) = args.pop() else { + let Some(anchor_str) = args.pop().map(|arg| arg.value) else { return Box::pin(async move { None }); }; diff --git a/pumpkin/src/command/args/gamemode.rs b/pumpkin/src/command/args/gamemode.rs index 33321d4bc..d2b3316de 100644 --- a/pumpkin/src/command/args/gamemode.rs +++ b/pumpkin/src/command/args/gamemode.rs @@ -29,7 +29,7 @@ impl ArgumentConsumer for GamemodeArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt.and_then(|s| { if let Ok(id) = s.parse::() diff --git a/pumpkin/src/command/args/gameprofile.rs b/pumpkin/src/command/args/gameprofile.rs new file mode 100644 index 000000000..c0405c454 --- /dev/null +++ b/pumpkin/src/command/args/gameprofile.rs @@ -0,0 +1,422 @@ +use pumpkin_data::translation; +use pumpkin_protocol::java::client::play::{ArgumentType, CommandSuggestion, SuggestionProviders}; +use pumpkin_util::text::TextComponent; +use uuid::Uuid; + +use crate::command::errors::command_syntax_error::{CommandSyntaxError, CommandSyntaxErrorContext}; +use crate::command::errors::error_types; +use crate::{ + command::{ + CommandSender, + args::{ConsumeResult, ConsumeResultWithSyntax, SuggestResult}, + dispatcher::CommandError, + tree::{RawArg, RawArgs}, + }, + net::authentication::lookup_profile_by_name, + net::{GameProfile, offline_uuid}, + server::Server, +}; + +use super::entities::{ensure_player_only_selector, parse_target_selector_with_context}; +use super::{Arg, DefaultNameArgConsumer, FindArg, GetClientSideArgParser}; +use crate::command::args::ArgumentConsumer; + +#[derive(Clone, Copy)] +pub enum GameProfileSuggestionMode { + OnlinePlayers, + NonOpOnlinePlayers, + OpNames, + BannedNames, + NonWhitelistedOnlinePlayers, + WhitelistedNames, +} + +pub struct GameProfilesArgumentConsumer { + suggestion_mode: GameProfileSuggestionMode, + suggest_selectors: bool, +} + +impl GameProfilesArgumentConsumer { + #[must_use] + pub const fn new(suggestion_mode: GameProfileSuggestionMode, suggest_selectors: bool) -> Self { + Self { + suggestion_mode, + suggest_selectors, + } + } + + #[must_use] + pub const fn online_players_with_selectors() -> Self { + Self::new(GameProfileSuggestionMode::OnlinePlayers, true) + } +} + +impl Default for GameProfilesArgumentConsumer { + fn default() -> Self { + Self::online_players_with_selectors() + } +} + +impl GetClientSideArgParser for GameProfilesArgumentConsumer { + fn get_client_side_parser(&self) -> ArgumentType<'_> { + ArgumentType::GameProfile + } + + fn get_client_side_suggestion_type_override(&self) -> Option { + Some(SuggestionProviders::AskServer) + } +} + +impl ArgumentConsumer for GameProfilesArgumentConsumer { + fn consume<'a>( + &'a self, + sender: &'a CommandSender, + server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResult<'a> { + let Some(raw_arg) = args.pop() else { + return Box::pin(async { None }); + }; + + Box::pin(async move { + resolve_profiles_from_token(sender, server, raw_arg) + .await + .ok() + .map(Arg::GameProfiles) + }) + } + + fn consume_with_syntax<'a>( + &'a self, + sender: &'a CommandSender, + server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResultWithSyntax<'a> { + let Some(raw_arg) = args.pop() else { + return Box::pin(async { Ok(None) }); + }; + + Box::pin(async move { + let resolved = resolve_profiles_from_token(sender, server, raw_arg).await?; + Ok(Some(Arg::GameProfiles(resolved))) + }) + } + + fn suggest<'a>( + &'a self, + _sender: &CommandSender, + server: &'a Server, + _input: &'a str, + ) -> SuggestResult<'a> { + Box::pin(async move { + let mut suggestions = Vec::new(); + if self.suggest_selectors { + suggestions.extend(selector_suggestions()); + } + + let mut names = Vec::new(); + match self.suggestion_mode { + GameProfileSuggestionMode::OnlinePlayers => { + for player in server.get_all_players() { + push_name_if_missing(&mut names, player.gameprofile.name.clone()); + } + } + GameProfileSuggestionMode::NonOpOnlinePlayers => { + let ops = server.data.operator_config.read().await; + for player in server.get_all_players() { + if ops.ops.iter().all(|op| op.uuid != player.gameprofile.id) { + push_name_if_missing(&mut names, player.gameprofile.name.clone()); + } + } + } + GameProfileSuggestionMode::OpNames => { + let ops = server.data.operator_config.read().await; + for op in &ops.ops { + push_name_if_missing(&mut names, op.name.clone()); + } + } + GameProfileSuggestionMode::BannedNames => { + let banned = server.data.banned_player_list.read().await; + for entry in &banned.banned_players { + push_name_if_missing(&mut names, entry.name.clone()); + } + } + GameProfileSuggestionMode::NonWhitelistedOnlinePlayers => { + let whitelist = server.data.whitelist_config.read().await; + for player in server.get_all_players() { + if !whitelist.is_whitelisted(&player.gameprofile) { + push_name_if_missing(&mut names, player.gameprofile.name.clone()); + } + } + } + GameProfileSuggestionMode::WhitelistedNames => { + let whitelist = server.data.whitelist_config.read().await; + for entry in &whitelist.whitelist { + push_name_if_missing(&mut names, entry.name.clone()); + } + } + } + + suggestions.extend( + names + .into_iter() + .map(|name| CommandSuggestion::new(name, None)), + ); + + Ok(Some(suggestions)) + }) + } +} + +impl DefaultNameArgConsumer for GameProfilesArgumentConsumer { + fn default_name(&self) -> &'static str { + "targets" + } +} + +impl<'a> FindArg<'a> for GameProfilesArgumentConsumer { + type Data = &'a [GameProfile]; + + fn find_arg(args: &'a super::ConsumedArgs, name: &str) -> Result { + match args.get(name) { + Some(Arg::GameProfiles(data)) => Ok(data), + _ => Err(CommandError::InvalidConsumption(Some(name.to_string()))), + } + } +} + +async fn resolve_profiles_from_token( + sender: &CommandSender, + server: &Server, + raw_arg: RawArg<'_>, +) -> Result, CommandSyntaxError> { + if raw_arg.value.starts_with('@') { + let selector = parse_target_selector_with_context(raw_arg)?; + ensure_player_only_selector(&selector, raw_arg)?; + + let players = server.select_players(&selector, Some(sender)); + if players.is_empty() { + return Err(syntax_player_unknown(raw_arg)); + } + + return Ok(players + .into_iter() + .map(|player| player.gameprofile.clone()) + .collect()); + } + + if let Ok(uuid) = Uuid::parse_str(raw_arg.value) { + if let Some(player) = server.get_player_by_uuid(uuid) { + return Ok(vec![player.gameprofile.clone()]); + } + + let cached_entry = server.data.user_cache.write().await.get_by_uuid(uuid); + if let Some(entry) = cached_entry { + return Ok(vec![profile_from_uuid_name(entry.uuid, entry.name)]); + } + + if let Some(profile) = resolve_known_profile_by_uuid(server, uuid).await { + return Ok(vec![profile]); + } + + return Err(syntax_player_unknown(raw_arg)); + } + + if let Some(player) = server.get_player_by_name(raw_arg.value) { + return Ok(vec![player.gameprofile.clone()]); + } + + let cached_entry = server + .data + .user_cache + .write() + .await + .get_by_name(raw_arg.value); + if let Some(entry) = cached_entry { + return Ok(vec![profile_from_uuid_name(entry.uuid, entry.name)]); + } + + if let Some(profile) = resolve_known_profile_by_name(server, raw_arg.value).await { + return Ok(vec![profile]); + } + + if server.basic_config.online_mode { + match lookup_profile_by_name( + raw_arg.value, + &server.advanced_config.networking.authentication, + ) { + Ok(Some((uuid, resolved_name))) => { + server + .data + .user_cache + .write() + .await + .upsert(uuid, resolved_name.clone()); + return Ok(vec![profile_from_uuid_name(uuid, resolved_name)]); + } + Ok(None) | Err(_) => return Err(syntax_player_unknown(raw_arg)), + } + } + + if let Ok(uuid) = offline_uuid(raw_arg.value) { + let profile = profile_from_uuid_name(uuid, raw_arg.value.to_string()); + server + .data + .user_cache + .write() + .await + .upsert(profile.id, profile.name.clone()); + return Ok(vec![profile]); + } + + Err(syntax_player_unknown(raw_arg)) +} + +async fn resolve_known_profile_by_name(server: &Server, name: &str) -> Option { + { + let ops = server.data.operator_config.read().await; + if let Some(op) = ops.ops.iter().find(|op| op.name.eq_ignore_ascii_case(name)) { + return Some(profile_from_uuid_name(op.uuid, op.name.clone())); + } + } + + { + let banned_players = server.data.banned_player_list.read().await; + if let Some(entry) = banned_players + .banned_players + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(name)) + { + return Some(profile_from_uuid_name(entry.uuid, entry.name.clone())); + } + } + + { + let whitelist = server.data.whitelist_config.read().await; + if let Some(entry) = whitelist + .whitelist + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(name)) + { + return Some(profile_from_uuid_name(entry.uuid, entry.name.clone())); + } + } + + None +} + +async fn resolve_known_profile_by_uuid(server: &Server, uuid: Uuid) -> Option { + { + let ops = server.data.operator_config.read().await; + if let Some(op) = ops.ops.iter().find(|op| op.uuid == uuid) { + return Some(profile_from_uuid_name(op.uuid, op.name.clone())); + } + } + + { + let banned_players = server.data.banned_player_list.read().await; + if let Some(entry) = banned_players + .banned_players + .iter() + .find(|entry| entry.uuid == uuid) + { + return Some(profile_from_uuid_name(entry.uuid, entry.name.clone())); + } + } + + { + let whitelist = server.data.whitelist_config.read().await; + if let Some(entry) = whitelist.whitelist.iter().find(|entry| entry.uuid == uuid) { + return Some(profile_from_uuid_name(entry.uuid, entry.name.clone())); + } + } + + None +} + +#[allow(clippy::missing_const_for_fn)] +fn profile_from_uuid_name(uuid: Uuid, name: String) -> GameProfile { + GameProfile { + id: uuid, + name, + properties: vec![], + profile_actions: None, + } +} + +fn push_name_if_missing(names: &mut Vec, name: String) { + if names + .iter() + .any(|known_name| known_name.eq_ignore_ascii_case(&name)) + { + return; + } + names.push(name); +} + +fn selector_suggestions() -> Vec { + vec![ + CommandSuggestion::new("@s".to_string(), None), + CommandSuggestion::new("@p".to_string(), None), + CommandSuggestion::new("@r".to_string(), None), + CommandSuggestion::new("@a".to_string(), None), + CommandSuggestion::new("@e".to_string(), None), + CommandSuggestion::new("@n".to_string(), None), + ] +} + +fn syntax_player_unknown(raw_arg: RawArg<'_>) -> CommandSyntaxError { + syntax_error_for_arg_with_cursor( + raw_arg, + TextComponent::translate(translation::ARGUMENT_PLAYER_UNKNOWN, []), + 0, + ) +} + +fn syntax_error_for_arg_with_cursor( + raw_arg: RawArg<'_>, + message: TextComponent, + local_cursor: usize, +) -> CommandSyntaxError { + let mut clamped_local_cursor = local_cursor.min(raw_arg.value.len()); + while clamped_local_cursor > 0 && !raw_arg.value.is_char_boundary(clamped_local_cursor) { + clamped_local_cursor -= 1; + } + + CommandSyntaxError { + error_type: &error_types::DISPATCHER_UNKNOWN_ARGUMENT, + message, + context: Some(CommandSyntaxErrorContext { + input: raw_arg.input.to_string(), + cursor: raw_arg.start + clamped_local_cursor, + }), + } +} + +#[cfg(test)] +mod test { + use pumpkin_data::translation; + use pumpkin_util::text::TextContent; + + use super::syntax_player_unknown; + use crate::command::tree::RawArg; + + #[test] + fn unknown_player_error_uses_translation_and_arg_start_cursor() { + let input = "ban missing_player"; + let raw_arg = RawArg { + value: "missing_player", + start: 4, + end: input.len(), + input, + }; + + let error = syntax_player_unknown(raw_arg); + let translate_key = match error.message.0.content.as_ref() { + TextContent::Translate { translate, .. } => translate.as_ref(), + _ => "", + }; + assert_eq!(translate_key, translation::ARGUMENT_PLAYER_UNKNOWN); + assert_eq!(error.context.unwrap().cursor, 4); + } +} diff --git a/pumpkin/src/command/args/message.rs b/pumpkin/src/command/args/message.rs index 00cc8058d..776701432 100644 --- a/pumpkin/src/command/args/message.rs +++ b/pumpkin/src/command/args/message.rs @@ -38,13 +38,13 @@ impl ArgumentConsumer for MsgArgConsumer { let first_word_opt = args.pop(); let mut msg = match first_word_opt { - Some(word) => word.to_string(), + Some(word) => word.value.to_string(), None => return Box::pin(async { None }), }; while let Some(word) = args.pop() { msg.push(' '); - msg.push_str(word); + msg.push_str(word.value); } Box::pin(async move { Some(Arg::Msg(msg)) }) diff --git a/pumpkin/src/command/args/mod.rs b/pumpkin/src/command/args/mod.rs index 35b1c020e..f168b07bf 100644 --- a/pumpkin/src/command/args/mod.rs +++ b/pumpkin/src/command/args/mod.rs @@ -18,6 +18,7 @@ use pumpkin_util::{ use super::{ CommandSender, dispatcher::CommandError, + errors::command_syntax_error::CommandSyntaxError, tree::{CommandTree, RawArgs}, }; use crate::entity::EntityBase; @@ -38,6 +39,7 @@ pub mod entities; pub mod entity; pub mod entity_anchor; pub mod gamemode; +pub mod gameprofile; pub mod message; pub mod players; pub mod position_2d; @@ -55,6 +57,8 @@ pub mod time; /// see [`crate::commands::tree::builder::argument`] pub type ConsumeResult<'a> = Pin>> + Send + 'a>>; +pub type ConsumeResultWithSyntax<'a> = + Pin>, CommandSyntaxError>> + Send + 'a>>; pub type SuggestResult<'a> = Pin>, CommandError>> + Send + 'a>>; @@ -67,6 +71,16 @@ pub trait ArgumentConsumer: Sync + Send + GetClientSideArgParser { args: &mut RawArgs<'a>, ) -> ConsumeResult<'a>; + fn consume_with_syntax<'a>( + &'a self, + sender: &'a CommandSender, + server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResultWithSyntax<'a> { + let future = self.consume(sender, server, args); + Box::pin(async move { Ok(future.await) }) + } + /// Used for tab completion (but only if argument suggestion type is "`minecraft:ask_server`"!). /// /// NOTE: This is called after this consumer's [`ArgumentConsumer::consume`] method returned None, so if args is used here, make sure [`ArgumentConsumer::consume`] never returns None after mutating args. @@ -96,6 +110,7 @@ pub enum Arg<'a> { Entities(Vec>), Entity(Arc), Players(Vec>), + GameProfiles(Vec), BlockPos(BlockPos), Pos3D(Vector3), Pos2D(Vector2), diff --git a/pumpkin/src/command/args/players.rs b/pumpkin/src/command/args/players.rs index 7a4bfd8b2..a42a0bf62 100644 --- a/pumpkin/src/command/args/players.rs +++ b/pumpkin/src/command/args/players.rs @@ -3,13 +3,14 @@ use std::sync::Arc; use pumpkin_protocol::java::client::play::{ArgumentType, SuggestionProviders}; use crate::command::CommandSender; -use crate::command::args::ConsumeResult; +use crate::command::args::{ConsumeResult, ConsumeResultWithSyntax}; use crate::command::dispatcher::CommandError; use crate::command::tree::RawArgs; use crate::entity::player::Player; use crate::server::Server; use super::super::args::ArgumentConsumer; +use super::entities::{ensure_player_only_selector, parse_target_selector_with_context}; use super::{Arg, DefaultNameArgConsumer, FindArg, GetClientSideArgParser}; /// Select zero, one or multiple players @@ -35,7 +36,7 @@ impl ArgumentConsumer for PlayersArgumentConsumer { server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let Some(s) = s_opt else { return Box::pin(async move { None }); @@ -71,6 +72,31 @@ impl ArgumentConsumer for PlayersArgumentConsumer { players.map(Arg::Players) }) } + + fn consume_with_syntax<'a>( + &'a self, + sender: &'a CommandSender, + server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResultWithSyntax<'a> { + let Some(raw_arg) = args.pop() else { + return Box::pin(async { Ok(None) }); + }; + + let selector = match parse_target_selector_with_context(raw_arg) { + Ok(selector) => selector, + Err(error) => return Box::pin(async move { Err(error) }), + }; + + if let Err(error) = ensure_player_only_selector(&selector, raw_arg) { + return Box::pin(async move { Err(error) }); + } + + Box::pin(async move { + let players = server.select_players(&selector, Some(sender)); + Ok(Some(Arg::Players(players))) + }) + } } impl DefaultNameArgConsumer for PlayersArgumentConsumer { diff --git a/pumpkin/src/command/args/position_2d.rs b/pumpkin/src/command/args/position_2d.rs index 9f3c264fc..03f37fda7 100644 --- a/pumpkin/src/command/args/position_2d.rs +++ b/pumpkin/src/command/args/position_2d.rs @@ -34,8 +34,8 @@ impl ArgumentConsumer for Position2DArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let x_str_opt = args.pop(); - let z_str_opt = args.pop(); + let x_str_opt = args.pop().map(|arg| arg.value); + let z_str_opt = args.pop().map(|arg| arg.value); let (Some(x_str), Some(z_str)) = (x_str_opt, z_str_opt) else { return Box::pin(async move { None }); diff --git a/pumpkin/src/command/args/position_3d.rs b/pumpkin/src/command/args/position_3d.rs index 411902629..5f49001e2 100644 --- a/pumpkin/src/command/args/position_3d.rs +++ b/pumpkin/src/command/args/position_3d.rs @@ -31,9 +31,9 @@ impl ArgumentConsumer for Position3DArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let x_str_opt = args.pop(); - let y_str_opt = args.pop(); - let z_str_opt = args.pop(); + let x_str_opt = args.pop().map(|arg| arg.value); + let y_str_opt = args.pop().map(|arg| arg.value); + let z_str_opt = args.pop().map(|arg| arg.value); let (Some(x_str), Some(y_str), Some(z_str)) = (x_str_opt, y_str_opt, z_str_opt) else { return Box::pin(async move { None }); diff --git a/pumpkin/src/command/args/position_block.rs b/pumpkin/src/command/args/position_block.rs index 259d1f580..d9a28a95d 100644 --- a/pumpkin/src/command/args/position_block.rs +++ b/pumpkin/src/command/args/position_block.rs @@ -34,9 +34,9 @@ impl ArgumentConsumer for BlockPosArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let x_str_opt = args.pop(); - let y_str_opt = args.pop(); - let z_str_opt = args.pop(); + let x_str_opt = args.pop().map(|arg| arg.value); + let y_str_opt = args.pop().map(|arg| arg.value); + let z_str_opt = args.pop().map(|arg| arg.value); let (Some(x_str), Some(y_str), Some(z_str)) = (x_str_opt, y_str_opt, z_str_opt) else { return Box::pin(async move { None }); diff --git a/pumpkin/src/command/args/resource/damage_type.rs b/pumpkin/src/command/args/resource/damage_type.rs index 48480dca4..8f1b0727d 100644 --- a/pumpkin/src/command/args/resource/damage_type.rs +++ b/pumpkin/src/command/args/resource/damage_type.rs @@ -33,7 +33,7 @@ impl ArgumentConsumer for DamageTypeArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let name_opt: Option<&'a str> = args.pop(); + let name_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = name_opt.map_or_else( || None, diff --git a/pumpkin/src/command/args/resource/effect.rs b/pumpkin/src/command/args/resource/effect.rs index 8925741bc..ec91d9254 100644 --- a/pumpkin/src/command/args/resource/effect.rs +++ b/pumpkin/src/command/args/resource/effect.rs @@ -33,7 +33,7 @@ impl ArgumentConsumer for EffectTypeArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let status_effect: Option<&'a str> = args.pop(); + let status_effect: Option<&'a str> = args.pop().map(|arg| arg.value); match status_effect { Some(name) => Box::pin(async move { diff --git a/pumpkin/src/command/args/resource/enchantment.rs b/pumpkin/src/command/args/resource/enchantment.rs index 08cf36764..8e5a48ebf 100644 --- a/pumpkin/src/command/args/resource/enchantment.rs +++ b/pumpkin/src/command/args/resource/enchantment.rs @@ -33,7 +33,7 @@ impl ArgumentConsumer for EnchantmentArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let name_opt: Option<&'a str> = args.pop(); + let name_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = name_opt.map_or_else( || None, diff --git a/pumpkin/src/command/args/resource/item.rs b/pumpkin/src/command/args/resource/item.rs index 00df41219..09bf4305f 100644 --- a/pumpkin/src/command/args/resource/item.rs +++ b/pumpkin/src/command/args/resource/item.rs @@ -36,7 +36,7 @@ impl ArgumentConsumer for ItemArgumentConsumer { _server: &'a Server, args: &mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let item = args.pop(); + let item = args.pop().map(|arg| arg.value); // TODO: When supporting data components in this argument, do it for ItemPredicateArgumentConsumer as well (both tags and items) match item { Some(s) => Box::pin(async move { Some(Arg::Item(s)) }), @@ -113,7 +113,7 @@ impl ArgumentConsumer for ItemPredicateArgumentConsumer { _server: &'a Server, args: &mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let item = args.pop(); + let item = args.pop().map(|arg| arg.value); match item { Some(s) => Box::pin(async move { Some(Arg::Item(s)) }), None => Box::pin(async move { None }), diff --git a/pumpkin/src/command/args/resource/particle.rs b/pumpkin/src/command/args/resource/particle.rs index f42639518..70fcc5a63 100644 --- a/pumpkin/src/command/args/resource/particle.rs +++ b/pumpkin/src/command/args/resource/particle.rs @@ -33,7 +33,7 @@ impl ArgumentConsumer for ParticleArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let name_opt: Option<&'a str> = args.pop(); + let name_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = name_opt.map_or_else( || None, diff --git a/pumpkin/src/command/args/resource_location.rs b/pumpkin/src/command/args/resource_location.rs index d0d75db44..93c619977 100644 --- a/pumpkin/src/command/args/resource_location.rs +++ b/pumpkin/src/command/args/resource_location.rs @@ -27,7 +27,7 @@ impl ArgumentConsumer for ResourceLocationArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); Box::pin(async move { s_opt.map(Arg::ResourceLocation) }) } diff --git a/pumpkin/src/command/args/rotation.rs b/pumpkin/src/command/args/rotation.rs index f75076e7c..3a4cef3ea 100644 --- a/pumpkin/src/command/args/rotation.rs +++ b/pumpkin/src/command/args/rotation.rs @@ -51,8 +51,8 @@ impl ArgumentConsumer for RotationArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let yaw_str_opt = args.pop(); - let pitch_str_opt = args.pop(); + let yaw_str_opt = args.pop().map(|arg| arg.value); + let pitch_str_opt = args.pop().map(|arg| arg.value); let (Some(yaw_str), Some(pitch_str)) = (yaw_str_opt, pitch_str_opt) else { return Box::pin(async move { None }); diff --git a/pumpkin/src/command/args/simple.rs b/pumpkin/src/command/args/simple.rs index 1cc85856d..4d57a3528 100644 --- a/pumpkin/src/command/args/simple.rs +++ b/pumpkin/src/command/args/simple.rs @@ -35,7 +35,7 @@ impl ArgumentConsumer for SimpleArgConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); Box::pin(async move { s_opt.map(Arg::Simple) }) } diff --git a/pumpkin/src/command/args/sound.rs b/pumpkin/src/command/args/sound.rs index 608f32dbb..0cb80ec14 100644 --- a/pumpkin/src/command/args/sound.rs +++ b/pumpkin/src/command/args/sound.rs @@ -34,7 +34,7 @@ impl ArgumentConsumer for SoundArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); Box::pin(async move { s_opt.map(Arg::Block) }) } diff --git a/pumpkin/src/command/args/sound_category.rs b/pumpkin/src/command/args/sound_category.rs index 780ce019b..b59b36e47 100644 --- a/pumpkin/src/command/args/sound_category.rs +++ b/pumpkin/src/command/args/sound_category.rs @@ -31,7 +31,7 @@ impl ArgumentConsumer for SoundCategoryArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt.and_then(|s| { let category = match s.to_lowercase().as_str() { diff --git a/pumpkin/src/command/args/summonable_entities.rs b/pumpkin/src/command/args/summonable_entities.rs index 43b75549c..caf53459a 100644 --- a/pumpkin/src/command/args/summonable_entities.rs +++ b/pumpkin/src/command/args/summonable_entities.rs @@ -34,7 +34,7 @@ impl ArgumentConsumer for SummonableEntitiesArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); Box::pin(async move { s_opt.map(Arg::Block) }) } diff --git a/pumpkin/src/command/args/textcomponent.rs b/pumpkin/src/command/args/textcomponent.rs index 7f1904ae1..eef4fae9a 100644 --- a/pumpkin/src/command/args/textcomponent.rs +++ b/pumpkin/src/command/args/textcomponent.rs @@ -26,7 +26,7 @@ impl ArgumentConsumer for TextComponentArgConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let Some(s) = s_opt else { return Box::pin(async move { None }); diff --git a/pumpkin/src/command/args/time.rs b/pumpkin/src/command/args/time.rs index b6f85d75f..329a22bcb 100644 --- a/pumpkin/src/command/args/time.rs +++ b/pumpkin/src/command/args/time.rs @@ -30,7 +30,7 @@ impl ArgumentConsumer for TimeArgumentConsumer { _server: &'a Server, args: &'b mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop(); + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); let result: Option> = s_opt.and_then(|s| { let (num_str, unit) = s diff --git a/pumpkin/src/command/commands/ban.rs b/pumpkin/src/command/commands/ban.rs index ebad49090..a6675f5ae 100644 --- a/pumpkin/src/command/commands/ban.rs +++ b/pumpkin/src/command/commands/ban.rs @@ -1,16 +1,16 @@ -use std::sync::Arc; - use crate::command::CommandResult; -use crate::entity::EntityBase; use crate::{ command::{ CommandError, CommandExecutor, CommandSender, - args::{Arg, ConsumedArgs, message::MsgArgConsumer, players::PlayersArgumentConsumer}, + args::{ + Arg, ConsumedArgs, + gameprofile::{GameProfileSuggestionMode, GameProfilesArgumentConsumer}, + message::MsgArgConsumer, + }, tree::{CommandTree, builder::argument}, }, data::{SaveJSONConfiguration, banlist_serializer::BannedPlayerEntry}, - entity::player::Player, - net::DisconnectReason, + net::{DisconnectReason, GameProfile}, }; use CommandError::InvalidConsumption; use pumpkin_data::translation; @@ -32,7 +32,7 @@ impl CommandExecutor for NoReasonExecutor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let Some(Arg::Players(targets)) = args.get(&ARG_TARGET) else { + let Some(Arg::GameProfiles(targets)) = args.get(&ARG_TARGET) else { return Err(InvalidConsumption(Some(ARG_TARGET.into()))); }; @@ -51,7 +51,7 @@ impl CommandExecutor for ReasonExecutor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let Some(Arg::Players(targets)) = args.get(&ARG_TARGET) else { + let Some(Arg::GameProfiles(targets)) = args.get(&ARG_TARGET) else { return Err(InvalidConsumption(Some(ARG_TARGET.into()))); }; @@ -68,12 +68,12 @@ impl CommandExecutor for ReasonExecutor { async fn ban_players( sender: &CommandSender, server: &crate::server::Server, - targets: &[Arc], + targets: &[GameProfile], reason: Option<&String>, ) -> Result { let mut count: usize = 0; for target in targets { - if ban_player(sender, server, target, reason.cloned()).await { + if ban_profile(sender, server, target, reason.cloned()).await { count += 1; } } @@ -89,18 +89,25 @@ async fn ban_players( } /// Returns `true` if the player was successfully banned. -async fn ban_player( +async fn ban_profile( sender: &CommandSender, server: &crate::server::Server, - player: &Player, + profile: &GameProfile, reason: Option, ) -> bool { let mut banned_players = server.data.banned_player_list.write().await; let reason = reason.unwrap_or_else(|| "Banned by an operator.".to_string()); - let profile = &player.gameprofile; - if banned_players.get_entry(&player.gameprofile).is_some() { + if let Some(entry) = banned_players + .banned_players + .iter_mut() + .find(|entry| entry.uuid == profile.id) + { + if entry.name != profile.name { + entry.name.clone_from(&profile.name); + banned_players.save(); + } return false; } @@ -118,24 +125,32 @@ async fn ban_player( sender .send_message(TextComponent::translate( translation::COMMANDS_BAN_SUCCESS, - [player.get_display_name().await, TextComponent::text(reason)], + [ + TextComponent::text(profile.name.clone()), + TextComponent::text(reason), + ], )) .await; - player - .kick( - DisconnectReason::Kicked, - TextComponent::translate(translation::MULTIPLAYER_DISCONNECT_BANNED, []), - ) - .await; + if let Some(player) = server.get_player_by_uuid(profile.id) { + player + .kick( + DisconnectReason::Kicked, + TextComponent::translate(translation::MULTIPLAYER_DISCONNECT_BANNED, []), + ) + .await; + } true } pub fn init_command_tree() -> CommandTree { CommandTree::new(NAMES, DESCRIPTION).then( - argument(ARG_TARGET, PlayersArgumentConsumer) - .execute(NoReasonExecutor) - .then(argument(ARG_REASON, MsgArgConsumer).execute(ReasonExecutor)), + argument( + ARG_TARGET, + GameProfilesArgumentConsumer::new(GameProfileSuggestionMode::OnlinePlayers, true), + ) + .execute(NoReasonExecutor) + .then(argument(ARG_REASON, MsgArgConsumer).execute(ReasonExecutor)), ) } diff --git a/pumpkin/src/command/commands/deop.rs b/pumpkin/src/command/commands/deop.rs index 71744b0db..e36f44e8a 100644 --- a/pumpkin/src/command/commands/deop.rs +++ b/pumpkin/src/command/commands/deop.rs @@ -1,9 +1,11 @@ use crate::command::CommandResult; -use crate::entity::EntityBase; use crate::{ command::{ CommandError, CommandExecutor, CommandSender, - args::{Arg, ConsumedArgs, players::PlayersArgumentConsumer}, + args::{ + Arg, ConsumedArgs, + gameprofile::{GameProfileSuggestionMode, GameProfilesArgumentConsumer}, + }, tree::CommandTree, tree::builder::argument, }, @@ -28,38 +30,37 @@ impl CommandExecutor for Executor { Box::pin(async move { let mut config = server.data.operator_config.write().await; - let Some(Arg::Players(targets)) = args.get(&ARG_TARGETS) else { + let Some(Arg::GameProfiles(targets)) = args.get(&ARG_TARGETS) else { return Err(InvalidConsumption(Some(ARG_TARGETS.into()))); }; let mut succeeded_deops: i32 = 0; - for player in targets { - if let Some(op_index) = config - .ops - .iter() - .position(|o| o.uuid == player.gameprofile.id) - { + for profile in targets { + if let Some(op_index) = config.ops.iter().position(|o| o.uuid == profile.id) { config.ops.remove(op_index); - config.save(); succeeded_deops += 1; + + if let Some(player) = server.get_player_by_uuid(profile.id) { + let command_dispatcher = server.command_dispatcher.read().await; + player + .set_permission_lvl( + server, + pumpkin_util::PermissionLvl::Zero, + &command_dispatcher, + ) + .await; + } + + let msg = TextComponent::translate( + "commands.deop.success", + [TextComponent::text(profile.name.clone())], + ); + sender.send_message(msg).await; } + } - { - let command_dispatcher = server.command_dispatcher.read().await; - player - .set_permission_lvl( - server, - pumpkin_util::PermissionLvl::Zero, - &command_dispatcher, - ) - .await; - }; - - let msg = TextComponent::translate( - "commands.deop.success", - [player.get_display_name().await], - ); - sender.send_message(msg).await; + if succeeded_deops > 0 { + config.save(); } if succeeded_deops == 0 { @@ -75,6 +76,11 @@ impl CommandExecutor for Executor { } pub fn init_command_tree() -> CommandTree { - CommandTree::new(NAMES, DESCRIPTION) - .then(argument(ARG_TARGETS, PlayersArgumentConsumer).execute(Executor)) + CommandTree::new(NAMES, DESCRIPTION).then( + argument( + ARG_TARGETS, + GameProfilesArgumentConsumer::new(GameProfileSuggestionMode::OpNames, false), + ) + .execute(Executor), + ) } diff --git a/pumpkin/src/command/commands/op.rs b/pumpkin/src/command/commands/op.rs index 909c7f74b..2d1c91b55 100644 --- a/pumpkin/src/command/commands/op.rs +++ b/pumpkin/src/command/commands/op.rs @@ -1,9 +1,11 @@ use crate::command::CommandResult; -use crate::entity::EntityBase; use crate::{ command::{ CommandError, CommandExecutor, CommandSender, - args::{Arg, ConsumedArgs, players::PlayersArgumentConsumer}, + args::{ + Arg, ConsumedArgs, + gameprofile::{GameProfileSuggestionMode, GameProfilesArgumentConsumer}, + }, tree::CommandTree, tree::builder::argument, }, @@ -29,56 +31,52 @@ impl CommandExecutor for Executor { Box::pin(async move { let mut config = server.data.operator_config.write().await; - let Some(Arg::Players(targets)) = args.get(&ARG_TARGETS) else { + let Some(Arg::GameProfiles(targets)) = args.get(&ARG_TARGETS) else { return Err(InvalidConsumption(Some(ARG_TARGETS.into()))); }; let mut successes: i32 = 0; - for player in targets { - let new_level = server - .basic_config - .op_permission_level - .min(sender.permission_lvl()); + let new_level = server + .basic_config + .op_permission_level + .min(sender.permission_lvl()); - if player.permission_lvl.load() == new_level { - continue; - } + for profile in targets { + let maybe_existing_entry = config.ops.iter_mut().find(|o| o.uuid == profile.id); + + if let Some(op) = maybe_existing_entry { + if op.level == new_level { + continue; + } - if let Some(op) = config - .ops - .iter_mut() - .find(|o| o.uuid == player.gameprofile.id) - { op.level = new_level; + op.name.clone_from(&profile.name); } else { - let op_entry = Op::new( - player.gameprofile.id, - player.gameprofile.name.clone(), - new_level, - false, - ); + let op_entry = Op::new(profile.id, profile.name.clone(), new_level, false); config.ops.push(op_entry); } - config.save(); - - { + if let Some(player) = server.get_player_by_uuid(profile.id) { let command_dispatcher = server.command_dispatcher.read().await; player .set_permission_lvl(server, new_level, &command_dispatcher) .await; - }; + } sender .send_message(TextComponent::translate( "commands.op.success", - [player.get_display_name().await], + [TextComponent::text(profile.name.clone())], )) .await; successes += 1; } + if successes > 0 { + config.save(); + } + if successes == 0 { Err(CommandError::CommandFailed(TextComponent::translate( "commands.op.failed", @@ -92,6 +90,11 @@ impl CommandExecutor for Executor { } pub fn init_command_tree() -> CommandTree { - CommandTree::new(NAMES, DESCRIPTION) - .then(argument(ARG_TARGETS, PlayersArgumentConsumer).execute(Executor)) + CommandTree::new(NAMES, DESCRIPTION).then( + argument( + ARG_TARGETS, + GameProfilesArgumentConsumer::new(GameProfileSuggestionMode::NonOpOnlinePlayers, false), + ) + .execute(Executor), + ) } diff --git a/pumpkin/src/command/commands/pardon.rs b/pumpkin/src/command/commands/pardon.rs index 9be6f569f..bb51a0764 100644 --- a/pumpkin/src/command/commands/pardon.rs +++ b/pumpkin/src/command/commands/pardon.rs @@ -1,7 +1,10 @@ use crate::{ command::{ CommandError, CommandExecutor, CommandResult, CommandSender, - args::{Arg, ConsumedArgs, simple::SimpleArgConsumer}, + args::{ + Arg, ConsumedArgs, + gameprofile::{GameProfileSuggestionMode, GameProfilesArgumentConsumer}, + }, tree::{CommandTree, builder::argument}, }, data::SaveJSONConfiguration, @@ -12,7 +15,7 @@ use pumpkin_util::text::TextComponent; const NAMES: [&str; 1] = ["pardon"]; const DESCRIPTION: &str = "unbans a player"; -const ARG_TARGET: &str = "player"; +const ARG_TARGET: &str = "targets"; struct Executor; @@ -24,41 +27,51 @@ impl CommandExecutor for Executor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let Some(Arg::Simple(target)) = args.get(&ARG_TARGET) else { + let Some(Arg::GameProfiles(targets)) = args.get(&ARG_TARGET) else { return Err(InvalidConsumption(Some(ARG_TARGET.into()))); }; - let target = (*target).to_string(); let mut lock = server.data.banned_player_list.write().await; + let mut successes = 0; - let result = if let Some(idx) = lock - .banned_players - .iter() - .position(|entry| entry.name == target) - { - lock.banned_players.remove(idx); - sender - .send_message(TextComponent::translate( - "commands.pardon.success", - [TextComponent::text(target)], - )) - .await; - Ok(1) + for target in targets { + let idx = lock + .banned_players + .iter() + .position(|entry| entry.uuid == target.id); + + if let Some(idx) = idx { + lock.banned_players.remove(idx); + sender + .send_message(TextComponent::translate( + "commands.pardon.success", + [TextComponent::text(target.name.clone())], + )) + .await; + successes += 1; + } + } + + if successes > 0 { + lock.save(); + Ok(successes) } else { Err(CommandError::CommandFailed(TextComponent::translate( "commands.pardon.failed", [], ))) - }; - - lock.save(); - - result + } }) } } +#[allow(clippy::too_many_lines)] pub fn init_command_tree() -> CommandTree { - CommandTree::new(NAMES, DESCRIPTION) - .then(argument(ARG_TARGET, SimpleArgConsumer).execute(Executor)) + CommandTree::new(NAMES, DESCRIPTION).then( + argument( + ARG_TARGET, + GameProfilesArgumentConsumer::new(GameProfileSuggestionMode::BannedNames, false), + ) + .execute(Executor), + ) } diff --git a/pumpkin/src/command/commands/whitelist.rs b/pumpkin/src/command/commands/whitelist.rs index e45e79a1c..67a6e1365 100644 --- a/pumpkin/src/command/commands/whitelist.rs +++ b/pumpkin/src/command/commands/whitelist.rs @@ -5,11 +5,13 @@ use pumpkin_data::translation; use pumpkin_util::text::TextComponent; use crate::command::CommandResult; -use crate::entity::EntityBase; use crate::{ command::{ CommandExecutor, CommandSender, - args::{Arg, ConsumedArgs, players::PlayersArgumentConsumer}, + args::{ + Arg, ConsumedArgs, + gameprofile::{GameProfileSuggestionMode, GameProfilesArgumentConsumer}, + }, dispatcher::CommandError, tree::{ CommandTree, @@ -181,15 +183,21 @@ impl CommandExecutor for AddExecutor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let Some(Arg::Players(targets)) = args.get(&ARG_TARGETS) else { + let Some(Arg::GameProfiles(targets)) = args.get(&ARG_TARGETS) else { return Err(CommandError::InvalidConsumption(Some(ARG_TARGETS.into()))); }; let mut whitelist = server.data.whitelist_config.write().await; let mut successes: i32 = 0; - for player in targets { - let profile = &player.gameprofile; - if whitelist.is_whitelisted(profile) { + for profile in targets { + if let Some(existing_entry) = whitelist + .whitelist + .iter_mut() + .find(|entry| entry.uuid == profile.id) + { + if existing_entry.name != profile.name { + existing_entry.name.clone_from(&profile.name); + } continue; } whitelist @@ -228,7 +236,7 @@ impl CommandExecutor for RemoveExecutor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let Some(Arg::Players(targets)) = args.get(&ARG_TARGETS) else { + let Some(Arg::GameProfiles(targets)) = args.get(&ARG_TARGETS) else { return Err(CommandError::InvalidConsumption(Some(ARG_TARGETS.into()))); }; @@ -238,14 +246,14 @@ impl CommandExecutor for RemoveExecutor { let i = whitelist .whitelist .iter() - .position(|entry| entry.uuid == player.gameprofile.id); + .position(|entry| entry.uuid == player.id); if let Some(i) = i { whitelist.whitelist.remove(i); sender .send_message(TextComponent::translate( translation::COMMANDS_WHITELIST_REMOVE_SUCCESS, - [player.get_display_name().await], + [TextComponent::text(player.name.clone())], )) .await; successes += 1; @@ -276,11 +284,27 @@ pub fn init_command_tree() -> CommandTree { .then(literal("list").execute(ListExecutor)) .then(literal("reload").execute(ReloadExecutor)) .then( - literal("add") - .then(argument(ARG_TARGETS, PlayersArgumentConsumer).execute(AddExecutor)), + literal("add").then( + argument( + ARG_TARGETS, + GameProfilesArgumentConsumer::new( + GameProfileSuggestionMode::NonWhitelistedOnlinePlayers, + false, + ), + ) + .execute(AddExecutor), + ), ) .then( - literal("remove") - .then(argument(ARG_TARGETS, PlayersArgumentConsumer).execute(RemoveExecutor)), + literal("remove").then( + argument( + ARG_TARGETS, + GameProfilesArgumentConsumer::new( + GameProfileSuggestionMode::WhitelistedNames, + false, + ), + ) + .execute(RemoveExecutor), + ), ) } diff --git a/pumpkin/src/command/dispatcher.rs b/pumpkin/src/command/dispatcher.rs index 785e1d8d1..c749213e7 100644 --- a/pumpkin/src/command/dispatcher.rs +++ b/pumpkin/src/command/dispatcher.rs @@ -1,15 +1,20 @@ +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; use rustc_hash::FxHashMap; use tracing::{debug, error, warn}; use super::args::ConsumedArgs; +use super::errors::command_syntax_error::{CommandSyntaxError, CommandSyntaxErrorContext}; +use super::errors::error_types; use crate::command::CommandSender; use crate::command::dispatcher::CommandError::{ - CommandFailed, InvalidConsumption, InvalidRequirement, PermissionDenied, + CommandFailed, InvalidConsumption, InvalidRequirement, PermissionDenied, SyntaxError, }; -use crate::command::tree::{Command, CommandTree, NodeType, RawArgs}; +use crate::command::tree::{Command, CommandTree, NodeType, RawArg, RawArgs}; use crate::server::Server; use std::collections::{HashMap, HashSet}; @@ -27,35 +32,198 @@ pub enum CommandError { /// A general error occurred during command execution that doesn't fit into /// more specific `CommandError` variants. CommandFailed(TextComponent), + SyntaxError(CommandSyntaxError), } impl CommandError { #[must_use] - pub fn into_component(self, cmd: &str) -> TextComponent { + pub fn into_messages(self, cmd: &str) -> Vec { match self { InvalidConsumption(s) => { error!( "Error while parsing command \"{cmd}\": {s:?} was consumed, but couldn't be parsed" ); - TextComponent::text("Internal error (See logs for details)") + vec![TextComponent::text("Internal error (See logs for details)")] } InvalidRequirement => { error!( "Error while parsing command \"{cmd}\": a requirement that was expected was not met." ); - TextComponent::text("Internal error (See logs for details)") + vec![TextComponent::text("Internal error (See logs for details)")] } PermissionDenied => { warn!("Permission denied for command \"{cmd}\""); - TextComponent::text( + vec![TextComponent::text( "I'm sorry, but you do not have permission to perform this command. Please contact the server administrator if you believe this is an error.", - ) + )] } - CommandFailed(s) => s, + CommandFailed(s) => vec![s], + SyntaxError(s) => render_syntax_error_messages(s), } } } +#[derive(Debug)] +struct PathParsingFailure { + cursor: usize, + consumed_tokens: usize, + matched_any_node: bool, + syntax_error: Option, +} + +enum PathResult { + Matched, + Failed(PathParsingFailure), +} + +fn render_syntax_error_messages(syntax_error: CommandSyntaxError) -> Vec { + let Some(context) = syntax_error.context else { + return vec![syntax_error.message]; + }; + + let input = context.input; + let cursor = clamp_cursor_to_char_boundary(&input, context.cursor); + let context_start = last_n_char_start(&input, cursor, 10); + let before_cursor = &input[context_start..cursor]; + let remaining_input = &input[cursor..]; + + let mut context_message = TextComponent::text("") + .color_named(NamedColor::Gray) + .click_event(ClickEvent::SuggestCommand { + command: format!("/{input}").into(), + }); + if context_start > 0 { + context_message = context_message.add_child(TextComponent::text("...")); + } + context_message = context_message.add_child(TextComponent::text(before_cursor.to_string())); + + if !remaining_input.is_empty() { + context_message = context_message.add_child( + TextComponent::text(remaining_input.to_string()) + .color_named(NamedColor::Red) + .underlined(), + ); + } + + context_message = context_message.add_child( + TextComponent::translate(translation::COMMAND_CONTEXT_HERE, []) + .color_named(NamedColor::Red) + .italic(), + ); + + vec![syntax_error.message, context_message] +} + +fn clamp_cursor_to_char_boundary(input: &str, cursor: usize) -> usize { + let mut clamped = cursor.min(input.len()); + while clamped > 0 && !input.is_char_boundary(clamped) { + clamped -= 1; + } + clamped +} + +fn last_n_char_start(input: &str, cursor: usize, char_count: usize) -> usize { + let mut start = cursor; + let mut seen = 0usize; + for (index, _) in input[..cursor].char_indices().rev() { + start = index; + seen += 1; + if seen == char_count { + break; + } + } + if seen < char_count { 0 } else { start } +} + +fn unknown_command_syntax_error(input: &str, cursor: usize) -> CommandSyntaxError { + let context = CommandSyntaxErrorContext { + input: input.to_string(), + cursor: clamp_cursor_to_char_boundary(input, cursor), + }; + error_types::DISPATCHER_UNKNOWN_COMMAND.create(&context) +} + +fn unknown_argument_syntax_error(input: &str, cursor: usize) -> CommandSyntaxError { + let context = CommandSyntaxErrorContext { + input: input.to_string(), + cursor: clamp_cursor_to_char_boundary(input, cursor), + }; + error_types::DISPATCHER_UNKNOWN_ARGUMENT.create(&context) +} + +fn select_parse_error( + input: &str, + failures: &[PathParsingFailure], + known_command: bool, +) -> CommandSyntaxError { + if failures.is_empty() { + return if known_command { + unknown_argument_syntax_error(input, input.len()) + } else { + unknown_command_syntax_error(input, input.len()) + }; + } + + let farthest_cursor = failures + .iter() + .map(|failure| failure.cursor) + .max() + .unwrap_or(input.len()); + + let best_progress = failures + .iter() + .filter(|failure| failure.cursor == farthest_cursor) + .map(|failure| failure.consumed_tokens) + .max() + .unwrap_or(0); + + let finalists = failures + .iter() + .filter(|failure| { + failure.cursor == farthest_cursor && failure.consumed_tokens == best_progress + }) + .collect::>(); + + let syntax_errors = finalists + .iter() + .filter_map(|failure| failure.syntax_error.clone()) + .collect::>(); + if syntax_errors.len() == 1 { + return syntax_errors[0].clone(); + } + + let matched_any_node = finalists.iter().any(|failure| failure.matched_any_node); + if matched_any_node || known_command { + unknown_argument_syntax_error(input, farthest_cursor) + } else { + unknown_command_syntax_error(input, farthest_cursor) + } +} + +fn next_unread_cursor(raw_args: &RawArgs<'_>, input_len: usize) -> usize { + raw_args.last().map_or(input_len, |arg| arg.start) +} + +fn path_failure( + raw_args: &RawArgs<'_>, + total_args: usize, + input_len: usize, + matched_any_node: bool, + syntax_error: Option, +) -> PathParsingFailure { + let syntax_error_cursor = syntax_error + .as_ref() + .and_then(|error| error.context.as_ref().map(|context| context.cursor)) + .unwrap_or_else(|| next_unread_cursor(raw_args, input_len)); + + PathParsingFailure { + cursor: syntax_error_cursor, + consumed_tokens: total_args.saturating_sub(raw_args.len()), + matched_any_node, + syntax_error, + } +} + #[derive(Default)] pub struct CommandDispatcher { pub commands: FxHashMap, @@ -74,10 +242,15 @@ impl CommandDispatcher { sender.set_success_count(u32::from(result.is_ok())); if let Err(e) = result { - let text = e.into_component(cmd); - sender - .send_message(text.color_named(pumpkin_util::text::color::NamedColor::Red)) - .await; + for text in e.into_messages(cmd) { + sender + .send_message( + TextComponent::text("") + .add_child(text) + .color_named(pumpkin_util::text::color::NamedColor::Red), + ) + .await; + } } } @@ -88,7 +261,7 @@ impl CommandDispatcher { /// - do not query suggestions for the same consumer multiple times just because they are on different paths through the tree pub(crate) async fn find_suggestions<'a>( &'a self, - src: &CommandSender, + src: &'a CommandSender, server: &'a Server, cmd: &'a str, ) -> Vec { @@ -96,7 +269,15 @@ impl CommandDispatcher { let Some(key) = parts.next() else { return Vec::new(); }; - let mut raw_args: Vec<&str> = parts.rev().collect(); + let mut raw_args: RawArgs<'a> = parts + .rev() + .map(|value| RawArg { + value, + start: 0, + end: 0, + input: cmd, + }) + .collect(); let Ok(tree) = self.get_tree(key) else { return Vec::new(); @@ -130,6 +311,9 @@ impl CommandDispatcher { debug!("Command failed"); return Vec::new(); } + Err(SyntaxError(_)) => { + return Vec::new(); + } Ok(Some(new_suggestions)) => { suggestions.extend(new_suggestions); } @@ -144,7 +328,8 @@ impl CommandDispatcher { suggestions } - pub(crate) fn split_parts(cmd: &str) -> Result<(&str, Vec<&str>), CommandError> { + #[allow(clippy::too_many_lines)] + pub(crate) fn split_parts(cmd: &str) -> Result<(&str, RawArgs<'_>), CommandError> { if cmd.is_empty() { return Err(CommandFailed(TextComponent::text("Empty Command"))); } @@ -195,7 +380,12 @@ impl CommandDispatcher { && in_brackets == 0 => { if current_arg_start != i { - args.push(&cmd[current_arg_start..i]); + args.push(RawArg { + value: &cmd[current_arg_start..i], + start: current_arg_start, + end: i, + input: cmd, + }); } current_arg_start = i + 1; } @@ -203,7 +393,12 @@ impl CommandDispatcher { } } if current_arg_start != cmd.len() { - args.push(&cmd[current_arg_start..]); + args.push(RawArg { + value: &cmd[current_arg_start..], + start: current_arg_start, + end: cmd.len(), + input: cmd, + }); } if in_single_quotes || in_double_quotes { return Err(CommandFailed(TextComponent::text( @@ -223,7 +418,7 @@ impl CommandDispatcher { if args.is_empty() { return Err(CommandFailed(TextComponent::text("Empty Command"))); } - let key = args.remove(0); + let key = args.remove(0).value; Ok((key, args.into_iter().rev().collect())) } @@ -237,9 +432,7 @@ impl CommandDispatcher { let (key, raw_args) = Self::split_parts(cmd)?; if !self.commands.contains_key(key) { - return Err(CommandFailed(TextComponent::text(format!( - "Command {key} does not exist" - )))); + return Err(SyntaxError(unknown_command_syntax_error(cmd, 0))); } let Some(permission) = self.permissions.get(key) else { @@ -254,15 +447,20 @@ impl CommandDispatcher { let tree = self.get_tree(key)?; + let mut path_failures = Vec::new(); + // try paths until fitting path is found for path in tree.iter_paths() { - if Self::try_is_fitting_path(src, server, &path, tree, &mut raw_args.clone()).await? { - return Ok(()); + match Self::try_is_fitting_path(src, server, &path, tree, &mut raw_args.clone(), cmd) + .await + { + Ok(PathResult::Matched) => return Ok(()), + Ok(PathResult::Failed(failure)) => path_failures.push(failure), + Err(error) => return Err(error), } } - Err(CommandFailed(TextComponent::text(format!( - "Invalid Syntax. Usage: {tree}" - )))) + + Err(SyntaxError(select_parse_error(cmd, &path_failures, true))) } pub fn get_tree<'a>(&'a self, key: &str) -> Result<&'a CommandTree, CommandError> { @@ -287,42 +485,89 @@ impl CommandDispatcher { } } + #[allow(clippy::too_many_lines)] async fn try_is_fitting_path<'a>( src: &'a CommandSender, server: &'a Server, path: &[usize], tree: &'a CommandTree, raw_args: &mut RawArgs<'a>, - ) -> Result { + input: &str, + ) -> Result { let mut parsed_args: ConsumedArgs = HashMap::new(); + let total_args = raw_args.len(); + let mut matched_any_node = false; + let input_len = input.len(); for node in path.iter().map(|&i| &tree.nodes[i]) { match &node.node_type { NodeType::ExecuteLeaf { executor } => { return if raw_args.is_empty() { executor.execute(src, server, &parsed_args).await?; - Ok(true) + Ok(PathResult::Matched) } else { debug!( "Error while parsing command: {raw_args:?} was not consumed, but should have been" ); - Ok(false) + Ok(PathResult::Failed(path_failure( + raw_args, + total_args, + input_len, + matched_any_node, + None, + ))) }; } NodeType::Literal { string, .. } => { - if raw_args.pop() != Some(string) { + let Some(raw_arg) = raw_args.last() else { + return Ok(PathResult::Failed(path_failure( + raw_args, + total_args, + input_len, + matched_any_node, + None, + ))); + }; + if raw_arg.value != string.as_str() { debug!("Error while parsing command: {raw_args:?}: expected {string}"); - return Ok(false); + return Ok(PathResult::Failed(path_failure( + raw_args, + total_args, + input_len, + matched_any_node, + None, + ))); } + raw_args.pop(); + matched_any_node = true; } NodeType::Argument { consumer, name, .. } => { - if let Some(consumed) = consumer.consume(src, server, raw_args).await { - parsed_args.insert(name, consumed); - } else { - debug!( - "Error while parsing command: {raw_args:?}: cannot parse argument {name}" - ); - return Ok(false); + match consumer.consume_with_syntax(src, server, raw_args).await { + Ok(Some(consumed)) => { + parsed_args.insert(name, consumed); + matched_any_node = true; + } + Ok(None) => { + debug!( + "Error while parsing command: {raw_args:?}: cannot parse argument {name}" + ); + return Ok(PathResult::Failed(path_failure( + raw_args, + total_args, + input_len, + matched_any_node, + None, + ))); + } + Err(error) => { + return Ok(PathResult::Failed(path_failure( + raw_args, + total_args, + input_len, + matched_any_node, + Some(error), + ))); + } } } NodeType::Require { predicate, .. } => { @@ -330,14 +575,27 @@ impl CommandDispatcher { debug!( "Error while parsing command: {raw_args:?} does not meet the requirement" ); - return Ok(false); + return Ok(PathResult::Failed(path_failure( + raw_args, + total_args, + input_len, + matched_any_node, + None, + ))); } + matched_any_node = true; } } } debug!("Error while parsing command: {raw_args:?} was not consumed, but should have been"); - Ok(false) + Ok(PathResult::Failed(path_failure( + raw_args, + total_args, + input_len, + matched_any_node, + None, + ))) } async fn try_find_suggestions_on_path<'a>( @@ -356,16 +614,16 @@ impl CommandDispatcher { return Ok(None); } NodeType::Literal { string, .. } => { - if raw_args.pop() != Some(string) { + if raw_args.pop().map(|arg| arg.value) != Some(string.as_str()) { return Ok(None); } } NodeType::Argument { consumer, name: _ } => { - match consumer.consume(src, server, raw_args).await { - Some(_consumed) => { + match consumer.consume_with_syntax(src, server, raw_args).await { + Ok(Some(_consumed)) => { //parsed_args.insert(name, consumed); } - None => { + Ok(None) => { return if raw_args.is_empty() { let suggestions = consumer.suggest(src, server, input).await?; Ok(suggestions) @@ -373,6 +631,7 @@ impl CommandDispatcher { Ok(None) }; } + Err(_) => return Ok(None), } } NodeType::Require { predicate, .. } => { @@ -427,10 +686,28 @@ impl CommandDispatcher { #[cfg(test)] mod test { use pumpkin_config::BasicConfiguration; + use pumpkin_data::translation; use pumpkin_util::permission::PermissionRegistry; + use pumpkin_util::text::TextContent; + use pumpkin_util::text::click::ClickEvent; + use pumpkin_util::text::color::{Color, NamedColor}; use tokio::sync::RwLock; + use super::{ + PathParsingFailure, render_syntax_error_messages, select_parse_error, + unknown_argument_syntax_error, + }; + use crate::command::errors::error_types; use crate::command::{commands::default_dispatcher, tree::CommandTree}; + + fn component_plain_text(component: &pumpkin_util::text::TextComponentBase) -> Option<&str> { + if let TextContent::Text { text } = component.content.as_ref() { + Some(text.as_ref()) + } else { + None + } + } + #[tokio::test] async fn dynamic_command() { let config = BasicConfiguration::default(); @@ -441,4 +718,117 @@ mod test { let tree = CommandTree::new(["test"], "test_desc"); dispatcher.register(tree, "minecraft:test"); } + + #[test] + fn syntax_renderer_outputs_two_messages_with_context_styling() { + let input = "0123456789abcdefghij"; + let error = unknown_argument_syntax_error(input, 15); + let messages = render_syntax_error_messages(error); + + assert_eq!(messages.len(), 2); + + let context = &messages[1].0; + assert_eq!(context.style.color, Some(Color::Named(NamedColor::Gray))); + assert_eq!( + context.style.click_event, + Some(ClickEvent::SuggestCommand { + command: format!("/{input}").into() + }) + ); + assert_eq!(context.extra.len(), 4); + + assert_eq!(component_plain_text(&context.extra[0]), Some("...")); + assert_eq!(component_plain_text(&context.extra[1]), Some("56789abcde")); + assert_eq!(component_plain_text(&context.extra[2]), Some("fghij")); + assert_eq!( + context.extra[2].style.color, + Some(Color::Named(NamedColor::Red)) + ); + assert_eq!(context.extra[2].style.underlined, Some(true)); + + let here_component = &context.extra[3]; + if let TextContent::Translate { translate, .. } = here_component.content.as_ref() { + assert_eq!(translate, translation::COMMAND_CONTEXT_HERE); + } else { + panic!("expected translate component for command.context.here"); + } + assert_eq!( + here_component.style.color, + Some(Color::Named(NamedColor::Red)) + ); + assert_eq!(here_component.style.italic, Some(true)); + } + + #[test] + fn parse_error_selection_prefers_farthest_cursor_then_progress() { + let fallback_error = unknown_argument_syntax_error("test one two", 5); + let preferred_error = unknown_argument_syntax_error("test one two", 9); + + let selected = select_parse_error( + "test one two", + &[ + PathParsingFailure { + cursor: 5, + consumed_tokens: 2, + matched_any_node: true, + syntax_error: Some(fallback_error), + }, + PathParsingFailure { + cursor: 9, + consumed_tokens: 1, + matched_any_node: true, + syntax_error: None, + }, + PathParsingFailure { + cursor: 9, + consumed_tokens: 2, + matched_any_node: true, + syntax_error: Some(preferred_error.clone()), + }, + ], + true, + ); + + assert_eq!(selected.context, preferred_error.context); + } + + #[test] + fn parse_error_selection_synthesizes_unknown_argument_for_tied_syntax_errors() { + let selected = select_parse_error( + "alpha beta gamma", + &[ + PathParsingFailure { + cursor: 12, + consumed_tokens: 2, + matched_any_node: true, + syntax_error: Some(unknown_argument_syntax_error("alpha beta gamma", 12)), + }, + PathParsingFailure { + cursor: 12, + consumed_tokens: 2, + matched_any_node: true, + syntax_error: Some(unknown_argument_syntax_error("alpha beta gamma", 12)), + }, + ], + true, + ); + + assert!(selected.is(&error_types::DISPATCHER_UNKNOWN_ARGUMENT)); + } + + #[test] + fn parse_error_selection_can_synthesize_unknown_command_when_not_matched() { + let selected = select_parse_error( + "abc", + &[PathParsingFailure { + cursor: 0, + consumed_tokens: 0, + matched_any_node: false, + syntax_error: None, + }], + false, + ); + + assert!(selected.is(&error_types::DISPATCHER_UNKNOWN_COMMAND)); + } } diff --git a/pumpkin/src/command/errors/error_types.rs b/pumpkin/src/command/errors/error_types.rs index f2a9d0a04..791a9aa16 100644 --- a/pumpkin/src/command/errors/error_types.rs +++ b/pumpkin/src/command/errors/error_types.rs @@ -180,7 +180,7 @@ mod sealed { /// It exposes the common properties of such types, while making /// it more dynamic to access its properties, like the translation /// key and the number of arguments (at runtime). -pub trait AnyCommandErrorType: Send + Sync + Sealed + std::fmt::Debug { +pub trait AnyCommandErrorType: Sealed + std::fmt::Debug + Send + Sync { /// Returns the underlying translation key of this specific error type. fn text(&self) -> TemplateText; diff --git a/pumpkin/src/command/tree/mod.rs b/pumpkin/src/command/tree/mod.rs index 9bc17587e..33abf8f3c 100644 --- a/pumpkin/src/command/tree/mod.rs +++ b/pumpkin/src/command/tree/mod.rs @@ -5,8 +5,26 @@ use std::{borrow::Cow, collections::VecDeque, fmt::Debug, sync::Arc}; pub mod builder; pub mod format; +#[derive(Clone, Copy)] +pub struct RawArg<'a> { + pub value: &'a str, + pub start: usize, + pub end: usize, + pub input: &'a str, +} + +impl Debug for RawArg<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawArg") + .field("value", &self.value) + .field("start", &self.start) + .field("end", &self.end) + .finish() + } +} + /// see [`crate::commands::tree::builder::argument`] -pub type RawArgs<'a> = Vec<&'a str>; +pub type RawArgs<'a> = Vec>; #[derive(Debug, Clone)] pub struct Node { diff --git a/pumpkin/src/data/banned_player.rs b/pumpkin/src/data/banned_player.rs index 6d3d52c48..4c5306f3c 100644 --- a/pumpkin/src/data/banned_player.rs +++ b/pumpkin/src/data/banned_player.rs @@ -19,7 +19,7 @@ impl BannedPlayerList { self.remove_invalid_entries(); self.banned_players .iter() - .find(|entry| entry.name == profile.name && entry.uuid == profile.id) + .find(|entry| entry.uuid == profile.id) } fn remove_invalid_entries(&mut self) { diff --git a/pumpkin/src/data/mod.rs b/pumpkin/src/data/mod.rs index 6d53aeba1..1ace7d47d 100644 --- a/pumpkin/src/data/mod.rs +++ b/pumpkin/src/data/mod.rs @@ -12,12 +12,14 @@ pub mod banlist_serializer; pub mod banned_ip; pub mod banned_player; pub mod player_server; +pub mod usercache; pub mod whitelist; pub struct VanillaData { pub banned_ip_list: RwLock, pub banned_player_list: RwLock, pub operator_config: RwLock, + pub user_cache: RwLock, pub whitelist_config: RwLock, } @@ -28,6 +30,7 @@ impl VanillaData { banned_ip_list: RwLock::new(banned_ip::BannedIpList::load()), banned_player_list: RwLock::new(banned_player::BannedPlayerList::load()), operator_config: RwLock::new(op::OperatorConfig::load()), + user_cache: RwLock::new(usercache::UserCache::load()), whitelist_config: RwLock::new(whitelist::WhitelistConfig::load()), } } diff --git a/pumpkin/src/data/usercache.rs b/pumpkin/src/data/usercache.rs new file mode 100644 index 000000000..ef6cbb37d --- /dev/null +++ b/pumpkin/src/data/usercache.rs @@ -0,0 +1,224 @@ +use std::cmp::Reverse; +use std::collections::HashMap; +use std::{env, fs}; + +use chrono::{DateTime, FixedOffset, Local, Months, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::warn; +use uuid::Uuid; + +const USER_CACHE_PATH: &str = "usercache.json"; +const USER_CACHE_MRU_LIMIT: usize = 1000; +const USER_CACHE_DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S %z"; + +#[derive(Clone, Debug)] +pub struct UserCacheEntry { + pub uuid: Uuid, + pub name: String, + expiration_date: DateTime, + last_access: u64, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct UserCacheEntryDisk { + uuid: Uuid, + name: String, + expires_on: String, +} + +#[derive(Default)] +pub struct UserCache { + profiles_by_name: HashMap, + profiles_by_uuid: HashMap, + operation_count: u64, +} + +impl UserCache { + fn path() -> std::path::PathBuf { + env::current_dir() + .unwrap_or_else(|_| ".".into()) + .join(super::DATA_FOLDER) + .join(USER_CACHE_PATH) + } + + #[must_use] + pub fn load() -> Self { + let mut cache = Self::default(); + let mut loaded = Self::load_entries(); + loaded.reverse(); + for entry in loaded { + cache.safe_add(entry); + } + cache + } + + pub fn save(&self) { + let path = Self::path(); + if let Some(parent) = path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + warn!("Failed to create user cache directory: {error}"); + return; + } + + let to_save: Vec = self + .top_mru_profiles(USER_CACHE_MRU_LIMIT) + .into_iter() + .map(|entry| UserCacheEntryDisk { + uuid: entry.uuid, + name: entry.name, + expires_on: format_cache_date(entry.expiration_date), + }) + .collect(); + + let Ok(content) = serde_json::to_string(&to_save) else { + return; + }; + + if let Err(error) = fs::write(path, content) { + warn!("Failed to save user cache: {error}"); + } + } + + pub fn upsert(&mut self, uuid: Uuid, name: String) { + self.add_internal(uuid, name); + } + + pub fn get_by_name(&mut self, name: &str) -> Option { + let lowercase_name = name.to_ascii_lowercase(); + let mut profile = self.profiles_by_name.get(&lowercase_name).cloned(); + let mut needs_save = false; + + if let Some(entry) = &profile + && is_expired(entry.expiration_date) + { + self.profiles_by_uuid.remove(&entry.uuid); + self.profiles_by_name + .remove(&entry.name.to_ascii_lowercase()); + needs_save = true; + profile = None; + } + + if let Some(mut entry) = profile { + entry.last_access = self.next_operation(); + self.profiles_by_name + .insert(entry.name.to_ascii_lowercase(), entry.clone()); + self.profiles_by_uuid.insert(entry.uuid, entry.clone()); + return Some(entry); + } + + if needs_save { + self.save(); + } + + None + } + + pub fn get_by_uuid(&mut self, uuid: Uuid) -> Option { + let mut entry = self.profiles_by_uuid.get(&uuid).cloned()?; + entry.last_access = self.next_operation(); + self.profiles_by_name + .insert(entry.name.to_ascii_lowercase(), entry.clone()); + self.profiles_by_uuid.insert(entry.uuid, entry.clone()); + Some(entry) + } + + fn add_internal(&mut self, uuid: Uuid, name: String) -> UserCacheEntry { + let expiration_date = one_month_from_now(); + let entry = UserCacheEntry { + uuid, + name, + expiration_date, + last_access: 0, + }; + + self.safe_add(entry.clone()); + self.save(); + entry + } + + fn safe_add(&mut self, mut entry: UserCacheEntry) { + entry.last_access = self.next_operation(); + self.profiles_by_name + .insert(entry.name.to_ascii_lowercase(), entry.clone()); + self.profiles_by_uuid.insert(entry.uuid, entry); + } + + #[allow(clippy::missing_const_for_fn)] + fn next_operation(&mut self) -> u64 { + self.operation_count += 1; + self.operation_count + } + + fn top_mru_profiles(&self, limit: usize) -> Vec { + let mut entries: Vec = self.profiles_by_uuid.values().cloned().collect(); + entries.sort_by_key(|entry| Reverse(entry.last_access)); + entries.truncate(limit); + entries + } + + fn load_entries() -> Vec { + let path = Self::path(); + let Ok(raw) = fs::read_to_string(path) else { + return Vec::new(); + }; + + let Ok(json) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + + let Some(array) = json.as_array() else { + return Vec::new(); + }; + + let mut entries = Vec::new(); + for element in array { + let Some(object) = element.as_object() else { + continue; + }; + + let Some(name) = object.get("name").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some(uuid_raw) = object.get("uuid").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some(expires_on) = object.get("expiresOn").and_then(serde_json::Value::as_str) + else { + continue; + }; + + let Ok(uuid) = Uuid::parse_str(uuid_raw) else { + continue; + }; + let Ok(expiration_date) = DateTime::parse_from_str(expires_on, USER_CACHE_DATE_FORMAT) + else { + continue; + }; + + entries.push(UserCacheEntry { + uuid, + name: name.to_string(), + expiration_date, + last_access: 0, + }); + } + + entries + } +} + +fn format_cache_date(date: DateTime) -> String { + date.format(USER_CACHE_DATE_FORMAT).to_string() +} + +fn is_expired(expiration_date: DateTime) -> bool { + Utc::now() >= expiration_date.with_timezone(&Utc) +} + +fn one_month_from_now() -> DateTime { + let now = Local::now().fixed_offset(); + now.checked_add_months(Months::new(1)) + .unwrap_or(now + TimeDelta::days(30)) +} diff --git a/pumpkin/src/data/whitelist.rs b/pumpkin/src/data/whitelist.rs index ab9f4a7df..ebce99692 100644 --- a/pumpkin/src/data/whitelist.rs +++ b/pumpkin/src/data/whitelist.rs @@ -16,9 +16,7 @@ pub struct WhitelistConfig { impl WhitelistConfig { #[must_use] pub fn is_whitelisted(&self, profile: &GameProfile) -> bool { - self.whitelist - .iter() - .any(|entry| entry.uuid == profile.id && entry.name == profile.name) + self.whitelist.iter().any(|entry| entry.uuid == profile.id) } } diff --git a/pumpkin/src/net/authentication.rs b/pumpkin/src/net/authentication.rs index 8a155a356..9d57c69a4 100644 --- a/pumpkin/src/net/authentication.rs +++ b/pumpkin/src/net/authentication.rs @@ -47,6 +47,8 @@ pub const MOJANG_BEDROCK_PUBLIC_KEY_BASE64: &str = "MHYwEAYHKoZIzj0CAQYFK4EEACID const MOJANG_AUTHENTICATION_URL: &str = "https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}"; const MOJANG_PREVENT_PROXY_AUTHENTICATION_URL: &str = "https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}"; const MOJANG_SERVICES_URL: &str = "https://api.minecraftservices.com/"; +const MOJANG_PROFILE_BY_NAME_URL: &str = + "https://api.mojang.com/users/profiles/minecraft/{username}"; /// Sends a GET request to Mojang's authentication servers to verify a client's Minecraft account. /// @@ -179,6 +181,37 @@ pub fn fetch_mojang_public_keys( Ok(as_rsa_keys) } +#[derive(Deserialize, Clone, Debug)] +struct MojangProfileByNameResponse { + id: String, + name: String, +} + +pub fn lookup_profile_by_name( + name: &str, + _auth_config: &AuthenticationConfig, +) -> Result, AuthError> { + let url = MOJANG_PROFILE_BY_NAME_URL.replace("{username}", name); + + let mut response = ureq::get(url) + .call() + .map_err(|_| AuthError::FailedResponse)?; + + match response.status() { + StatusCode::OK => {} + StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => return Ok(None), + other => Err(AuthError::UnknownStatusCode(other))?, + } + + let profile: MojangProfileByNameResponse = response + .body_mut() + .read_json() + .map_err(|_| AuthError::FailedParse)?; + + let parsed_uuid = Uuid::parse_str(&profile.id).map_err(|_| AuthError::FailedParse)?; + Ok(Some((parsed_uuid, profile.name))) +} + #[derive(Error, Debug)] pub enum AuthError { #[error("Authentication servers are down")] diff --git a/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs b/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs index f8e1492f5..2985ab61a 100644 --- a/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs +++ b/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs @@ -9,6 +9,7 @@ pub enum OwnedArg { Entities(Vec>), Entity(Arc), Players(Vec>), + GameProfiles(Vec), BlockPos(pumpkin_util::math::position::BlockPos), Pos3D(pumpkin_util::math::vector3::Vector3), Pos2D(pumpkin_util::math::vector2::Vector2), @@ -50,6 +51,7 @@ impl OwnedArg { Arg::Entities(v) => Self::Entities(v.clone()), Arg::Entity(e) => Self::Entity(e.clone()), Arg::Players(v) => Self::Players(v.clone()), + Arg::GameProfiles(v) => Self::GameProfiles(v.clone()), Arg::BlockPos(p) => Self::BlockPos(*p), Arg::Pos3D(v) => Self::Pos3D(*v), Arg::Pos2D(v) => Self::Pos2D(*v), diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index f29f5a3b3..dd78a9c9b 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -18,6 +18,7 @@ use connection_cache::{CachedBranding, CachedStatus}; use key_store::KeyStore; use pumpkin_config::{AdvancedConfiguration, BasicConfiguration}; use pumpkin_data::dimension::Dimension; +use pumpkin_data::entity::EntityType; use pumpkin_util::permission::{PermissionManager, PermissionRegistry}; use pumpkin_util::text::color::NamedColor; use pumpkin_world::dimension::into_level; @@ -29,7 +30,6 @@ use pumpkin_protocol::java::client::login::CEncryptionRequest; use pumpkin_protocol::java::client::play::CChangeDifficulty; use pumpkin_protocol::{ClientPacket, java::client::config::CPluginMessage}; use pumpkin_util::Difficulty; -use pumpkin_util::math::vector3::Vector3; use pumpkin_util::text::TextComponent; use pumpkin_world::lock::LevelLocker; use pumpkin_world::lock::anvil::AnvilLevelLocker; @@ -37,7 +37,7 @@ use pumpkin_world::world_info::anvil::{ AnvilLevelInfo, LEVEL_DAT_BACKUP_FILE_NAME, LEVEL_DAT_FILE_NAME, }; use pumpkin_world::world_info::{LevelData, WorldInfoError, WorldInfoReader, WorldInfoWriter}; -use rand::seq::{IndexedRandom, IteratorRandom, SliceRandom}; +use rand::seq::{IndexedRandom, SliceRandom}; use rsa::RsaPublicKey; use std::collections::HashSet; use std::fs; @@ -424,6 +424,9 @@ impl Server { if world .add_player(player.clone()) .is_ok() { + let mut user_cache = self.data.user_cache.write().await; + user_cache.upsert(player.gameprofile.id, player.gameprofile.name.clone()); + // TODO: Config if we want increase online if let Some(config) = config { // TODO: Config so we can also just ignore this hehe @@ -795,71 +798,30 @@ impl Server { *self.tick_times_nanos.lock().await } - #[expect(clippy::too_many_lines)] - #[expect(clippy::option_if_let_else)] - pub fn select_entities( + #[allow(clippy::too_many_lines, clippy::option_if_let_else)] + pub fn select_players( &self, target_selector: &TargetSelector, source: Option<&CommandSender>, - ) -> Vec> { - let iter = match &target_selector.selector_type { - EntitySelectorType::Source + ) -> Vec> { + let mut players = match &target_selector.selector_type { + EntitySelectorType::Source => source + .and_then(CommandSender::as_player) + .map_or_else(Vec::new, |player| vec![player]), + EntitySelectorType::NearestPlayer | EntitySelectorType::NearestEntity - | EntitySelectorType::NearestPlayer => { - // todo: command context, currently the nearest entity is the player itself - if let Some(sender) = source { - if let Some(player) = sender.as_player() { - vec![player as Arc].into_iter() - } else { - vec![].into_iter() - } - } else { - vec![].into_iter() - } - } - EntitySelectorType::RandomPlayer => { - if let Some(player) = self.get_random_player() { - vec![player as Arc].into_iter() - } else { - vec![].into_iter() - } - } - EntitySelectorType::AllPlayers => self - .get_all_players() - .into_iter() - .map(|p| p as Arc) - .collect::>() - .into_iter(), - EntitySelectorType::AllEntities => { - let mut entities = Vec::new(); - for world in self.worlds.load().iter() { - entities.extend(world.entities.load().iter().cloned()); - entities.extend( - world - .players - .load() - .iter() - .cloned() - .map(|p| p as Arc), - ); - } - entities.into_iter() - } - EntitySelectorType::NamedPlayer(name) => { - if let Some(player) = self.get_player_by_name(name) { - vec![player as Arc].into_iter() - } else { - vec![].into_iter() - } - } - EntitySelectorType::Uuid(uuid) => { - if let Some(player) = self.get_player_by_uuid(*uuid) { - vec![player as Arc].into_iter() - } else { - vec![].into_iter() - } - } + | EntitySelectorType::RandomPlayer + | EntitySelectorType::AllPlayers + | EntitySelectorType::AllEntities => self.get_all_players(), + EntitySelectorType::NamedPlayer(name) => self + .get_player_by_name(name) + .map_or_else(Vec::new, |player| vec![player]), + EntitySelectorType::Uuid(uuid) => self + .get_player_by_uuid(*uuid) + .map_or_else(Vec::new, |player| vec![player]), }; + + let player_type = EntityType::from_name("player").expect("entity type player must exist"); let type_included = target_selector .conditions .iter() @@ -882,53 +844,35 @@ impl Server { } }) .collect::>(); - let type_filtered = iter.filter(|e| { - // Filter by entity type - (type_excluded.is_empty() || !type_excluded.contains(&e.get_entity().entity_type)) - && (type_included.is_empty() || type_included.contains(&e.get_entity().entity_type)) + + players.retain(|_| { + (type_excluded.is_empty() || !type_excluded.contains(player_type)) + && (type_included.is_empty() || type_included.contains(player_type)) }); - let iter = type_filtered; + + let limit = target_selector.get_limit(); + if limit == 0 { + return Vec::new(); + } + match target_selector .get_sort() .unwrap_or(EntityFilterSort::Arbitrary) { - // If the sort is arbitrary, we just return all entities in all worlds - EntityFilterSort::Arbitrary => iter.take(target_selector.get_limit()).collect(), + EntityFilterSort::Arbitrary => players.into_iter().take(limit).collect(), EntityFilterSort::Random => { - if target_selector.get_limit() == 0 { - return vec![]; - } else if target_selector.get_limit() == 1 { - // If the limit is 1, we just return a random entity - return if let Some(entity) = iter.choose(&mut rand::rng()) { - vec![entity] - } else { - vec![] - }; - } - // If the sort is random, we shuffle the entities and then take the limit - let mut entities: Vec<_> = iter.collect(); - entities.shuffle(&mut rand::rng()); - entities - .into_iter() - .take(target_selector.get_limit()) - .collect() + players.shuffle(&mut rand::rng()); + players.into_iter().take(limit).collect() } EntityFilterSort::Nearest | EntityFilterSort::Furthest => { - if target_selector.get_limit() == 0 { - return vec![]; - } - // sort entities first - // todo: command context - let center = if let Some(source) = source { - source.position().unwrap_or_default() - } else { - Vector3::default() - }; - let mut entities = iter.collect::>(); - entities.sort_by(|a, b| { + let center = source.and_then(CommandSender::position).unwrap_or_default(); + let nearest_first = target_selector + .get_sort() + .is_none_or(|sort| sort == EntityFilterSort::Nearest); + players.sort_by(|a, b| { let a_distance = a.get_entity().pos.load().squared_distance_to_vec(¢er); let b_distance = b.get_entity().pos.load().squared_distance_to_vec(¢er); - if target_selector.get_sort() == Some(EntityFilterSort::Nearest) { + if nearest_first { a_distance .partial_cmp(&b_distance) .unwrap_or(core::cmp::Ordering::Equal) @@ -938,10 +882,119 @@ impl Server { .unwrap_or(core::cmp::Ordering::Equal) } }); - entities - .into_iter() - .take(target_selector.get_limit()) - .collect() + players.into_iter().take(limit).collect() + } + } + } + + #[allow(clippy::too_many_lines, clippy::option_if_let_else)] + pub fn select_entities( + &self, + target_selector: &TargetSelector, + source: Option<&CommandSender>, + ) -> Vec> { + let all_entities_and_players = || { + let mut entities = Vec::new(); + for world in self.worlds.load().iter() { + entities.extend(world.entities.load().iter().cloned()); + entities.extend( + world + .players + .load() + .iter() + .cloned() + .map(|player| player as Arc), + ); + } + entities + }; + let all_players_as_entities = || { + self.get_all_players() + .into_iter() + .map(|player| player as Arc) + .collect::>() + }; + + let mut entities = match &target_selector.selector_type { + EntitySelectorType::Source => source + .and_then(CommandSender::as_player) + .map_or_else(Vec::new, |player| vec![player as Arc]), + EntitySelectorType::NearestPlayer + | EntitySelectorType::RandomPlayer + | EntitySelectorType::AllPlayers => all_players_as_entities(), + EntitySelectorType::NearestEntity | EntitySelectorType::AllEntities => { + all_entities_and_players() + } + EntitySelectorType::NamedPlayer(name) => self + .get_player_by_name(name) + .map_or_else(Vec::new, |player| vec![player as Arc]), + EntitySelectorType::Uuid(uuid) => self + .get_player_by_uuid(*uuid) + .map_or_else(Vec::new, |player| vec![player as Arc]), + }; + + let type_included = target_selector + .conditions + .iter() + .filter_map(|f| { + if let EntityFilter::Type(ValueCondition::Equals(entity_type)) = f { + Some(*entity_type) + } else { + None + } + }) + .collect::>(); + let type_excluded = target_selector + .conditions + .iter() + .filter_map(|f| { + if let EntityFilter::Type(ValueCondition::NotEquals(entity_type)) = f { + Some(*entity_type) + } else { + None + } + }) + .collect::>(); + entities.retain(|entity| { + // Filter by entity type + (type_excluded.is_empty() || !type_excluded.contains(&entity.get_entity().entity_type)) + && (type_included.is_empty() + || type_included.contains(&entity.get_entity().entity_type)) + }); + + let limit = target_selector.get_limit(); + if limit == 0 { + return vec![]; + } + + match target_selector + .get_sort() + .unwrap_or(EntityFilterSort::Arbitrary) + { + EntityFilterSort::Arbitrary => entities.into_iter().take(limit).collect(), + EntityFilterSort::Random => { + entities.shuffle(&mut rand::rng()); + entities.into_iter().take(limit).collect() + } + EntityFilterSort::Nearest | EntityFilterSort::Furthest => { + let center = source.and_then(CommandSender::position).unwrap_or_default(); + let nearest_first = target_selector + .get_sort() + .is_none_or(|sort| sort == EntityFilterSort::Nearest); + entities.sort_by(|a, b| { + let a_distance = a.get_entity().pos.load().squared_distance_to_vec(¢er); + let b_distance = b.get_entity().pos.load().squared_distance_to_vec(¢er); + if nearest_first { + a_distance + .partial_cmp(&b_distance) + .unwrap_or(core::cmp::Ordering::Equal) + } else { + b_distance + .partial_cmp(&a_distance) + .unwrap_or(core::cmp::Ordering::Equal) + } + }); + entities.into_iter().take(limit).collect() } } }