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 <chocodev11@users.noreply.github.com>
This commit is contained in:
ChocoDev
2026-03-31 00:25:45 +07:00
committed by GitHub
parent 3613440515
commit bb5033046b
49 changed files with 1830 additions and 354 deletions

14
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"] }

View File

@@ -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

View File

@@ -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 }),

View File

@@ -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<Arg<'a>> = s_opt.map_or_else(
|| None,

View File

@@ -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<Arg<'a>> = s_opt.map_or_else(
|| None,

View File

@@ -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<Arg<'a>> = s_opt.map_or_else(
|| None,

View File

@@ -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<Arg<'a>> = s_opt
// Replace args.pop()?.parse::<T>().ok()?

View File

@@ -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 });

View File

@@ -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<Arg<'a>> =
s_opt.and_then(|s| Difficulty::from_str(s).map(Arg::Difficulty).ok());

View File

@@ -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<EntityFilter>,
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<EntityFilterSort> {
self.conditions.iter().rev().find_map(|f| {
@@ -194,44 +214,101 @@ impl FromStr for TargetSelector {
type Err = String;
fn from_str(arg: &str) -> Result<Self, Self::Err> {
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<TargetSelector, TargetSelectorParseError> {
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<TargetSelector, CommandSyntaxError> {
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::<TargetSelector>().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);
}
}

View File

@@ -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 {

View File

@@ -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 });
};

View File

@@ -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<Arg<'a>> = s_opt.and_then(|s| {
if let Ok(id) = s.parse::<i8>()

View File

@@ -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<SuggestionProviders> {
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<Self::Data, CommandError> {
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<Vec<GameProfile>, 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<GameProfile> {
{
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<GameProfile> {
{
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<String>, name: String) {
if names
.iter()
.any(|known_name| known_name.eq_ignore_ascii_case(&name))
{
return;
}
names.push(name);
}
fn selector_suggestions() -> Vec<CommandSuggestion> {
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);
}
}

View File

@@ -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)) })

View File

@@ -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<Box<dyn Future<Output = Option<Arg<'a>>> + Send + 'a>>;
pub type ConsumeResultWithSyntax<'a> =
Pin<Box<dyn Future<Output = Result<Option<Arg<'a>>, CommandSyntaxError>> + Send + 'a>>;
pub type SuggestResult<'a> =
Pin<Box<dyn Future<Output = Result<Option<Vec<CommandSuggestion>>, 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<Arc<dyn EntityBase>>),
Entity(Arc<dyn EntityBase>),
Players(Vec<Arc<Player>>),
GameProfiles(Vec<crate::net::GameProfile>),
BlockPos(BlockPos),
Pos3D(Vector3<f64>),
Pos2D(Vector2<f64>),

View File

@@ -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 {

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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<Arg<'a>> = name_opt.map_or_else(
|| None,

View File

@@ -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 {

View File

@@ -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<Arg<'a>> = name_opt.map_or_else(
|| None,

View File

@@ -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 }),

View File

@@ -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<Arg<'a>> = name_opt.map_or_else(
|| None,

View File

@@ -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) })
}

View File

@@ -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 });

View File

@@ -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) })
}

View File

@@ -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) })
}

View File

@@ -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<Arg<'a>> = s_opt.and_then(|s| {
let category = match s.to_lowercase().as_str() {

View File

@@ -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) })
}

View File

@@ -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 });

View File

@@ -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<Arg<'a>> = s_opt.and_then(|s| {
let (num_str, unit) = s

View File

@@ -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<Player>],
targets: &[GameProfile],
reason: Option<&String>,
) -> Result<i32, CommandError> {
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<String>,
) -> 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)),
)
}

View File

@@ -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),
)
}

View File

@@ -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),
)
}

View File

@@ -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),
)
}

View File

@@ -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),
),
)
}

View File

@@ -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<TextComponent> {
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<CommandSyntaxError>,
}
enum PathResult {
Matched,
Failed(PathParsingFailure),
}
fn render_syntax_error_messages(syntax_error: CommandSyntaxError) -> Vec<TextComponent> {
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::<Vec<_>>();
let syntax_errors = finalists
.iter()
.filter_map(|failure| failure.syntax_error.clone())
.collect::<Vec<_>>();
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<CommandSyntaxError>,
) -> 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<String, Command>,
@@ -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<CommandSuggestion> {
@@ -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<bool, CommandError> {
input: &str,
) -> Result<PathResult, CommandError> {
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));
}
}

View File

@@ -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;

View File

@@ -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<RawArg<'a>>;
#[derive(Debug, Clone)]
pub struct Node {

View File

@@ -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) {

View File

@@ -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<banned_ip::BannedIpList>,
pub banned_player_list: RwLock<banned_player::BannedPlayerList>,
pub operator_config: RwLock<op::OperatorConfig>,
pub user_cache: RwLock<usercache::UserCache>,
pub whitelist_config: RwLock<whitelist::WhitelistConfig>,
}
@@ -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()),
}
}

View File

@@ -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<FixedOffset>,
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<String, UserCacheEntry>,
profiles_by_uuid: HashMap<Uuid, UserCacheEntry>,
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<UserCacheEntryDisk> = 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<UserCacheEntry> {
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<UserCacheEntry> {
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<UserCacheEntry> {
let mut entries: Vec<UserCacheEntry> = self.profiles_by_uuid.values().cloned().collect();
entries.sort_by_key(|entry| Reverse(entry.last_access));
entries.truncate(limit);
entries
}
fn load_entries() -> Vec<UserCacheEntry> {
let path = Self::path();
let Ok(raw) = fs::read_to_string(path) else {
return Vec::new();
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&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<FixedOffset>) -> String {
date.format(USER_CACHE_DATE_FORMAT).to_string()
}
fn is_expired(expiration_date: DateTime<FixedOffset>) -> bool {
Utc::now() >= expiration_date.with_timezone(&Utc)
}
fn one_month_from_now() -> DateTime<FixedOffset> {
let now = Local::now().fixed_offset();
now.checked_add_months(Months::new(1))
.unwrap_or(now + TimeDelta::days(30))
}

View File

@@ -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)
}
}

View File

@@ -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<Option<(Uuid, String)>, 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")]

View File

@@ -9,6 +9,7 @@ pub enum OwnedArg {
Entities(Vec<Arc<dyn crate::entity::EntityBase>>),
Entity(Arc<dyn crate::entity::EntityBase>),
Players(Vec<Arc<Player>>),
GameProfiles(Vec<crate::net::GameProfile>),
BlockPos(pumpkin_util::math::position::BlockPos),
Pos3D(pumpkin_util::math::vector3::Vector3<f64>),
Pos2D(pumpkin_util::math::vector2::Vector2<f64>),
@@ -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),

View File

@@ -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<Arc<dyn EntityBase>> {
let iter = match &target_selector.selector_type {
EntitySelectorType::Source
) -> Vec<Arc<Player>> {
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<dyn EntityBase>].into_iter()
} else {
vec![].into_iter()
}
} else {
vec![].into_iter()
}
}
EntitySelectorType::RandomPlayer => {
if let Some(player) = self.get_random_player() {
vec![player as Arc<dyn EntityBase>].into_iter()
} else {
vec![].into_iter()
}
}
EntitySelectorType::AllPlayers => self
.get_all_players()
.into_iter()
.map(|p| p as Arc<dyn EntityBase>)
.collect::<Vec<_>>()
.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<dyn EntityBase>),
);
}
entities.into_iter()
}
EntitySelectorType::NamedPlayer(name) => {
if let Some(player) = self.get_player_by_name(name) {
vec![player as Arc<dyn EntityBase>].into_iter()
} else {
vec![].into_iter()
}
}
EntitySelectorType::Uuid(uuid) => {
if let Some(player) = self.get_player_by_uuid(*uuid) {
vec![player as Arc<dyn EntityBase>].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::<HashSet<_>>();
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::<Vec<_>>();
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(&center);
let b_distance = b.get_entity().pos.load().squared_distance_to_vec(&center);
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<Arc<dyn EntityBase>> {
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<dyn EntityBase>),
);
}
entities
};
let all_players_as_entities = || {
self.get_all_players()
.into_iter()
.map(|player| player as Arc<dyn EntityBase>)
.collect::<Vec<_>>()
};
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<dyn EntityBase>]),
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<dyn EntityBase>]),
EntitySelectorType::Uuid(uuid) => self
.get_player_by_uuid(*uuid)
.map_or_else(Vec::new, |player| vec![player as Arc<dyn EntityBase>]),
};
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::<HashSet<_>>();
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::<HashSet<_>>();
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(&center);
let b_distance = b.get_entity().pos.load().squared_distance_to_vec(&center);
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()
}
}
}