feat(pumpkin): add /tag command with entity scoreboard tags (#2395)

Implements the vanilla /tag command (tracked in #15) along with the
entity-side storage it needs:

- Adds a scoreboard_tags: Mutex<HashSet<String>> field to Entity, with
  add_scoreboard_tag / remove_scoreboard_tag helpers that enforce the
  vanilla 1024-tag cap and report whether they changed anything.
- Serializes tags to/from the entity's "Tags" NBT list, matching the
  vanilla format so tags round-trip through world saves.
- /tag <targets> add|remove <name> and /tag <targets> list, with the
  existing commands.tag.* translation keys and single/multiple wording.
This commit is contained in:
Mcxiaocaibug
2026-07-13 23:43:23 +08:00
committed by GitHub
parent 1f38ba8054
commit f0374c30d4
3 changed files with 255 additions and 1 deletions

View File

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

View File

@@ -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::<Vec<String>>().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)),
),
),
),
);
}

View File

@@ -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<Option<TextComponent>>,
/// 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<HashSet<String>>,
/// 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...
})
}