diff --git a/pumpkin/src/command/commands/mod.rs b/pumpkin/src/command/commands/mod.rs index ad63f13fc..4deb0a7e3 100644 --- a/pumpkin/src/command/commands/mod.rs +++ b/pumpkin/src/command/commands/mod.rs @@ -53,6 +53,7 @@ mod spreadplayers; mod stop; mod stopsound; mod summon; +mod tag; mod teleport; mod tellraw; mod tick; @@ -165,6 +166,7 @@ pub async fn default_dispatcher( setidletimeout::register(&mut dispatcher, registry); spreadplayers::register(&mut dispatcher, registry); stop::register(&mut dispatcher, registry); + tag::register(&mut dispatcher, registry); tick::register(&mut dispatcher, registry); advancement::register(&mut dispatcher, registry); dispatcher diff --git a/pumpkin/src/command/commands/tag.rs b/pumpkin/src/command/commands/tag.rs new file mode 100644 index 000000000..9d649cf6a --- /dev/null +++ b/pumpkin/src/command/commands/tag.rs @@ -0,0 +1,205 @@ +use std::collections::BTreeSet; + +use pumpkin_data::translation; +use pumpkin_util::PermissionLvl; +use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; +use pumpkin_util::text::TextComponent; + +use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal}; +use crate::command::argument_types::core::string::StringArgumentType; +use crate::command::argument_types::entity::EntityArgumentType; +use crate::command::context::command_context::CommandContext; +use crate::command::errors::error_types::CommandErrorType; +use crate::command::node::dispatcher::CommandDispatcher; +use crate::command::node::{CommandExecutor, CommandExecutorResult}; + +const DESCRIPTION: &str = "Manages the scoreboard tags of entities."; + +const PERMISSION: &str = "minecraft:command.tag"; + +const ARG_TARGETS: &str = "targets"; +const ARG_NAME: &str = "name"; + +const ADD_FAILED_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new( + translation::java::COMMANDS_TAG_ADD_FAILED, + translation::bedrock::COMMANDS_TAG_ADD_FAILED, +); + +const REMOVE_FAILED_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new( + translation::java::COMMANDS_TAG_REMOVE_FAILED, + translation::bedrock::COMMANDS_TAG_REMOVE_FAILED, +); + +#[derive(Clone, Copy)] +enum Action { + Add, + Remove, +} + +struct ChangeExecutor(Action); + +impl CommandExecutor for ChangeExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let targets = EntityArgumentType::get_entities(context, ARG_TARGETS).await?; + let tag = StringArgumentType::get(context, ARG_NAME)?.to_owned(); + + let mut changed = 0; + for target in &targets { + let entity = target.get_entity(); + let success = match self.0 { + Action::Add => entity.add_scoreboard_tag(&tag).await, + Action::Remove => entity.remove_scoreboard_tag(&tag).await, + }; + if success { + changed += 1; + } + } + + if changed == 0 { + return Err(match self.0 { + Action::Add => ADD_FAILED_ERROR_TYPE.create_without_context(), + Action::Remove => REMOVE_FAILED_ERROR_TYPE.create_without_context(), + }); + } + + let (single_key, multiple_key) = match self.0 { + Action::Add => ( + ( + translation::java::COMMANDS_TAG_ADD_SUCCESS_SINGLE, + translation::bedrock::COMMANDS_TAG_ADD_SUCCESS_SINGLE, + ), + ( + translation::java::COMMANDS_TAG_ADD_SUCCESS_MULTIPLE, + translation::bedrock::COMMANDS_TAG_ADD_SUCCESS_MULTIPLE, + ), + ), + Action::Remove => ( + ( + translation::java::COMMANDS_TAG_REMOVE_SUCCESS_SINGLE, + translation::bedrock::COMMANDS_TAG_REMOVE_SUCCESS_SINGLE, + ), + ( + translation::java::COMMANDS_TAG_REMOVE_SUCCESS_MULTIPLE, + translation::bedrock::COMMANDS_TAG_REMOVE_SUCCESS_MULTIPLE, + ), + ), + }; + + let msg = if targets.len() == 1 { + TextComponent::translate_cross( + single_key.0, + single_key.1, + [ + TextComponent::text(tag), + targets[0].get_display_name().await, + ], + ) + } else { + TextComponent::translate_cross( + multiple_key.0, + multiple_key.1, + [ + TextComponent::text(tag), + TextComponent::text(targets.len().to_string()), + ], + ) + }; + + context.source.send_feedback(msg, true).await; + + Ok(changed) + }) + } +} + +struct ListExecutor; + +impl CommandExecutor for ListExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let targets = EntityArgumentType::get_entities(context, ARG_TARGETS).await?; + + // BTreeSet keeps the output deterministic. + let mut all_tags = BTreeSet::new(); + for target in &targets { + let tags = target.get_entity().scoreboard_tags.lock().await; + all_tags.extend(tags.iter().cloned()); + } + + let tag_list = + TextComponent::text(all_tags.iter().cloned().collect::>().join(", ")); + + let msg = if targets.len() == 1 { + let name = targets[0].get_display_name().await; + if all_tags.is_empty() { + TextComponent::translate_cross( + translation::java::COMMANDS_TAG_LIST_SINGLE_EMPTY, + translation::bedrock::COMMANDS_TAG_LIST_SINGLE_EMPTY, + [name], + ) + } else { + TextComponent::translate_cross( + translation::java::COMMANDS_TAG_LIST_SINGLE_SUCCESS, + translation::bedrock::COMMANDS_TAG_LIST_SINGLE_SUCCESS, + [ + name, + TextComponent::text(all_tags.len().to_string()), + tag_list, + ], + ) + } + } else { + let count = TextComponent::text(targets.len().to_string()); + if all_tags.is_empty() { + TextComponent::translate_cross( + translation::java::COMMANDS_TAG_LIST_MULTIPLE_EMPTY, + translation::bedrock::COMMANDS_TAG_LIST_MULTIPLE_EMPTY, + [count], + ) + } else { + TextComponent::translate_cross( + translation::java::COMMANDS_TAG_LIST_MULTIPLE_SUCCESS, + translation::bedrock::COMMANDS_TAG_LIST_MULTIPLE_SUCCESS, + [ + count, + TextComponent::text(all_tags.len().to_string()), + tag_list, + ], + ) + } + }; + + context.source.send_feedback(msg, false).await; + + Ok(all_tags.len() as i32) + }) + } +} + +pub fn register(dispatcher: &mut CommandDispatcher, registry: &mut PermissionRegistry) { + registry.register_permission_or_panic(Permission::new( + PERMISSION, + DESCRIPTION, + PermissionDefault::Op(PermissionLvl::Two), + )); + + dispatcher.register( + command("tag", DESCRIPTION).requires(PERMISSION).then( + argument(ARG_TARGETS, EntityArgumentType::Entities) + .then( + literal("add").then( + argument(ARG_NAME, StringArgumentType::SingleWord) + .executes(ChangeExecutor(Action::Add)), + ), + ) + .then(literal("list").executes(ListExecutor)) + .then( + literal("remove").then( + argument(ARG_NAME, StringArgumentType::SingleWord) + .executes(ChangeExecutor(Action::Remove)), + ), + ), + ), + ); +} diff --git a/pumpkin/src/entity/mod.rs b/pumpkin/src/entity/mod.rs index 4a2627f54..76312080a 100644 --- a/pumpkin/src/entity/mod.rs +++ b/pumpkin/src/entity/mod.rs @@ -69,7 +69,7 @@ use pumpkin_util::math::{ use pumpkin_util::text::TextComponent; use pumpkin_util::text::hover::HoverEvent; use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::pin::Pin; use std::sync::{ Arc, @@ -105,6 +105,9 @@ pub mod vehicle; mod combat; pub mod predicate; +/// The maximum number of scoreboard tags an entity can carry, matching Vanilla. +pub const MAX_SCOREBOARD_TAGS: usize = 1024; + /// Returns the [`EntityStatus`] that should be broadcast when the given /// equipment slot breaks. #[must_use] @@ -800,6 +803,9 @@ pub struct Entity { pub custom_name: ArcSwap>, /// Indicates whether the entity's custom name is visible pub custom_name_visible: AtomicBool, + /// Scoreboard tags attached to this entity, managed with `/tag`. + /// Vanilla allows at most [`MAX_SCOREBOARD_TAGS`] tags per entity. + pub scoreboard_tags: Mutex>, /// The data send in the Entity Spawn packet pub data: AtomicI32, /// Stores entity boolean flags (on fire, sneaking, invisible, glowing, etc.) @@ -933,6 +939,7 @@ impl Entity { portal_manager: Mutex::new(None), custom_name: ArcSwap::new(Arc::new(None)), custom_name_visible: AtomicBool::new(false), + scoreboard_tags: Mutex::new(HashSet::new()), no_clip: AtomicBool::new(false), movement_multiplier: AtomicCell::new(Vector3::default()), velocity_dirty: AtomicBool::new(true), @@ -1017,6 +1024,22 @@ impl Entity { self.age.store(age, Relaxed); } + /// Adds a scoreboard tag to this entity. + /// + /// Returns `false` if the entity already has the tag or already carries + /// [`MAX_SCOREBOARD_TAGS`] tags. + pub async fn add_scoreboard_tag(&self, tag: &str) -> bool { + let mut tags = self.scoreboard_tags.lock().await; + tags.len() < MAX_SCOREBOARD_TAGS && tags.insert(tag.to_owned()) + } + + /// Removes a scoreboard tag from this entity. + /// + /// Returns `false` if the entity did not have the tag. + pub async fn remove_scoreboard_tag(&self, tag: &str) -> bool { + self.scoreboard_tags.lock().await.remove(tag) + } + /// Sets a custom name for the entity, typically used with nametags pub fn set_custom_name(&self, name: TextComponent) { self.custom_name.store(Arc::new(Some(name.clone()))); @@ -3386,6 +3409,18 @@ impl NBTStorage for Entity { } nbt.put_bool("CustomNameVisible", self.custom_name_visible.load(Relaxed)); + let tags = self.scoreboard_tags.lock().await; + if !tags.is_empty() { + nbt.put( + "Tags", + NbtTag::List( + tags.iter() + .map(|tag| NbtTag::String(tag.as_str().into())) + .collect(), + ), + ); + } + // todo more... }) } @@ -3433,6 +3468,18 @@ impl NBTStorage for Entity { } self.custom_name_visible .store(nbt.get_bool("CustomNameVisible").unwrap_or(false), Relaxed); + + if let Some(tag_list) = nbt.get_list("Tags") { + let mut tags = self.scoreboard_tags.lock().await; + tags.clear(); + tags.extend( + tag_list + .iter() + .filter_map(|tag| tag.extract_string().map(str::to_owned)) + .take(MAX_SCOREBOARD_TAGS), + ); + } + // todo more... }) }