mirror of
https://github.com/Pumpkin-MC/Pumpkin.git
synced 2026-08-31 08:22:33 +00:00
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:
@@ -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
|
||||
|
||||
205
pumpkin/src/command/commands/tag.rs
Normal file
205
pumpkin/src/command/commands/tag.rs
Normal 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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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...
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user