feat(command): add scoreboard/trigger command

This commit is contained in:
Alexander Medvedev 2026-07-13 18:09:03 +02:00
parent 6219d36c64
commit dab8474e71
7 changed files with 542 additions and 2 deletions

View File

@ -226,6 +226,7 @@ pub mod game_profile;
pub mod hex_color;
pub mod identifier;
pub mod nbt;
pub mod objective;
pub mod range;
pub mod resource_key;
pub mod slot;

View File

@ -0,0 +1,50 @@
use crate::command::{
argument_types::argument_type::{ArgumentType, JavaClientArgumentType},
context::command_context::CommandContext,
errors::command_syntax_error::CommandSyntaxError,
string_reader::StringReader,
suggestion::suggestions::{Suggestions, SuggestionsBuilder},
};
use std::pin::Pin;
/// Represents an argument type parsing a scoreboard objective name.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct ObjectiveArgumentType;
impl ArgumentType for ObjectiveArgumentType {
type Item = String;
fn parse(&self, reader: &mut StringReader) -> Result<Self::Item, CommandSyntaxError> {
let name = reader.read_unquoted_string();
Ok(name)
}
fn client_side_parser(&'_ self) -> JavaClientArgumentType {
JavaClientArgumentType::Objective
}
fn list_suggestions<'a>(
&'a self,
context: &'a CommandContext,
mut builder: SuggestionsBuilder,
) -> Pin<Box<dyn Future<Output = Suggestions> + Send + 'a>> {
Box::pin(async move {
let scoreboard = context.world().scoreboard.lock().await;
for objective_name in scoreboard.get_objectives().keys() {
builder = builder.filter_and_suggest_one(objective_name.as_str());
}
builder.build()
})
}
fn examples(&self) -> Vec<String> {
vec!["objective".to_string(), "trigger_obj".to_string()]
}
}
impl ObjectiveArgumentType {
/// Returns a [`CommandContext`]'s parsed `String` argument as a string slice.
pub fn get<'a>(context: &'a CommandContext, name: &str) -> Result<&'a str, CommandSyntaxError> {
Ok(context.get_argument::<String>(name)?.as_str())
}
}

View File

@ -44,6 +44,7 @@ mod saveall;
mod saveoff;
mod saveon;
mod say;
mod scoreboard;
mod seed;
mod setblock;
mod setidletimeout;
@ -61,6 +62,7 @@ mod time;
mod title;
mod tps;
mod transfer;
mod trigger;
mod weather;
mod whitelist;
mod worldborder;
@ -169,6 +171,8 @@ pub async fn default_dispatcher(
tag::register(&mut dispatcher, registry);
tick::register(&mut dispatcher, registry);
advancement::register(&mut dispatcher, registry);
trigger::register(&mut dispatcher, registry);
scoreboard::register(&mut dispatcher, registry);
dispatcher
}

View File

@ -0,0 +1,209 @@
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::argument_types::objective::ObjectiveArgumentType;
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};
use crate::world::scoreboard::{ScoreboardObjective, ScoreboardScore};
use pumpkin_data::translation;
use pumpkin_protocol::codec::var_int::VarInt;
use pumpkin_protocol::java::client::play::RenderType;
use pumpkin_util::PermissionLvl;
use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry};
use pumpkin_util::text::TextComponent;
const DESCRIPTION: &str = "Manages scoreboard objectives and players.";
const PERMISSION: &str = "minecraft:command.scoreboard";
const ARG_OBJECTIVE: &str = "objective";
const ARG_CRITERION: &str = "criterion";
const ARG_DISPLAY_NAME: &str = "display_name";
const ARG_TARGETS: &str = "targets";
const DUPLICATE_OBJECTIVE_ERROR: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_SCOREBOARD_OBJECTIVES_ADD_DUPLICATE,
translation::java::COMMANDS_SCOREBOARD_OBJECTIVES_ADD_DUPLICATE,
);
const INVALID_ENABLE_ERROR: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_INVALID,
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_INVALID,
);
const FAILED_ENABLE_ERROR: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_FAILED,
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_FAILED,
);
struct ObjectivesAddExecutor {
has_display_name: bool,
}
impl CommandExecutor for ObjectivesAddExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let objective_name = StringArgumentType::get(context, ARG_OBJECTIVE)?;
let criterion = StringArgumentType::get(context, ARG_CRITERION)?;
let display_name = if self.has_display_name {
TextComponent::text(StringArgumentType::get(context, ARG_DISPLAY_NAME)?.to_string())
} else {
TextComponent::text(objective_name.to_string())
};
let world = context.world();
let mut scoreboard = world.scoreboard.lock().await;
if scoreboard.get_objectives().contains_key(objective_name) {
return Err(DUPLICATE_OBJECTIVE_ERROR.create_without_context());
}
let obj_name_static = Box::leak(objective_name.to_string().into_boxed_str());
let criterion_static = Box::leak(criterion.to_string().into_boxed_str());
let new_objective = ScoreboardObjective::new(
obj_name_static,
display_name.clone(),
RenderType::Integer,
None,
criterion_static,
);
scoreboard.add_objective(world, new_objective).await;
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_SCOREBOARD_OBJECTIVES_ADD_SUCCESS,
translation::java::COMMANDS_SCOREBOARD_OBJECTIVES_ADD_SUCCESS,
[display_name],
),
true,
)
.await;
Ok(1)
})
}
}
struct PlayersEnableExecutor;
impl CommandExecutor for PlayersEnableExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let targets = EntityArgumentType::get_players(context, ARG_TARGETS).await?;
let objective_name = ObjectiveArgumentType::get(context, ARG_OBJECTIVE)?;
let world = context.world();
let mut scoreboard = world.scoreboard.lock().await;
let objective = scoreboard
.get_objectives()
.get(objective_name)
.ok_or_else(|| INVALID_ENABLE_ERROR.create_without_context())?;
if objective.criterion != "trigger" {
return Err(INVALID_ENABLE_ERROR.create_without_context());
}
let objective_display_name = objective.display_name.clone();
let mut enabled_count = 0;
for player in &targets {
let player_name = &player.gameprofile.name;
let current_score = scoreboard
.get_scores()
.get(objective_name)
.and_then(|m| m.get(player_name));
let is_already_enabled = current_score.is_some_and(|s| !s.locked);
if !is_already_enabled {
let value = current_score.map_or(0, |s| s.value.0);
let display_name = current_score.and_then(|s| s.display_name.clone());
let number_format = current_score.and_then(|s| s.number_format.clone());
let updated_score = ScoreboardScore {
entity_name: Box::leak(player_name.clone().into_boxed_str()),
objective_name: Box::leak(objective_name.to_string().into_boxed_str()),
value: VarInt(value),
display_name,
number_format,
locked: false,
};
scoreboard.update_score(world, updated_score).await;
enabled_count += 1;
}
}
if enabled_count == 0 {
return Err(FAILED_ENABLE_ERROR.create_without_context());
}
let msg = if targets.len() == 1 {
TextComponent::translate_cross(
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_SUCCESS_SINGLE,
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_SUCCESS_SINGLE,
[
objective_display_name,
TextComponent::text(targets[0].gameprofile.name.clone()),
],
)
} else {
TextComponent::translate_cross(
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_SUCCESS_MULTIPLE,
translation::java::COMMANDS_SCOREBOARD_PLAYERS_ENABLE_SUCCESS_MULTIPLE,
[
objective_display_name,
TextComponent::text(targets.len().to_string()),
],
)
};
context.source.send_feedback(msg, true).await;
Ok(enabled_count)
})
}
}
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("scoreboard", DESCRIPTION)
.requires(PERMISSION)
.then(
literal("objectives").then(
literal("add").then(
argument(ARG_OBJECTIVE, StringArgumentType::SingleWord).then(
argument(ARG_CRITERION, StringArgumentType::SingleWord)
.executes(ObjectivesAddExecutor {
has_display_name: false,
})
.then(
argument(ARG_DISPLAY_NAME, StringArgumentType::GreedyPhrase)
.executes(ObjectivesAddExecutor {
has_display_name: true,
}),
),
),
),
),
)
.then(literal("players").then(literal("enable").then(
argument(ARG_TARGETS, EntityArgumentType::Players).then(
argument(ARG_OBJECTIVE, ObjectiveArgumentType).executes(PlayersEnableExecutor),
),
))),
);
}

View File

@ -0,0 +1,256 @@
use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal};
use crate::command::argument_types::core::integer::IntegerArgumentType;
use crate::command::argument_types::objective::ObjectiveArgumentType;
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};
use crate::world::scoreboard::ScoreboardScore;
use pumpkin_data::translation;
use pumpkin_protocol::codec::var_int::VarInt;
use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry};
use pumpkin_util::text::TextComponent;
const DESCRIPTION: &str = "Modifies a trigger scoreboard objective.";
const PERMISSION: &str = "minecraft:command.trigger";
const ARG_OBJECTIVE: &str = "objective";
const ARG_VALUE: &str = "value";
const INVALID_TRIGGER_ERROR: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_TRIGGER_FAILED_INVALID,
translation::java::COMMANDS_TRIGGER_FAILED_INVALID,
);
const UNPRIMED_TRIGGER_ERROR: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_TRIGGER_FAILED_UNPRIMED,
translation::java::COMMANDS_TRIGGER_FAILED_UNPRIMED,
);
struct SimpleTriggerExecutor;
impl CommandExecutor for SimpleTriggerExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let player = context.source.player_or_err()?;
let player_name = &player.gameprofile.name;
let objective_name = ObjectiveArgumentType::get(context, ARG_OBJECTIVE)?;
let world = context.world();
let mut scoreboard = world.scoreboard.lock().await;
let objective = scoreboard
.get_objectives()
.get(objective_name)
.ok_or_else(|| INVALID_TRIGGER_ERROR.create_without_context())?;
if objective.criterion != "trigger" {
return Err(INVALID_TRIGGER_ERROR.create_without_context());
}
let objective_display_name = objective.display_name.clone();
let is_locked = scoreboard
.get_scores()
.get(objective_name)
.and_then(|m| m.get(player_name))
.is_none_or(|s| s.locked);
if is_locked {
return Err(UNPRIMED_TRIGGER_ERROR.create_without_context());
}
let current_value = scoreboard
.get_scores()
.get(objective_name)
.and_then(|m| m.get(player_name))
.map_or(0, |s| s.value.0);
let new_value = current_value + 1;
let updated_score = ScoreboardScore {
entity_name: Box::leak(player_name.clone().into_boxed_str()),
objective_name: Box::leak(objective_name.to_string().into_boxed_str()),
value: VarInt(new_value),
display_name: None,
number_format: None,
locked: true,
};
scoreboard.update_score(world, updated_score).await;
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_TRIGGER_SIMPLE_SUCCESS,
translation::java::COMMANDS_TRIGGER_SIMPLE_SUCCESS,
[objective_display_name],
),
false,
)
.await;
Ok(new_value)
})
}
}
struct AddTriggerExecutor;
impl CommandExecutor for AddTriggerExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let player = context.source.player_or_err()?;
let player_name = &player.gameprofile.name;
let objective_name = ObjectiveArgumentType::get(context, ARG_OBJECTIVE)?;
let value = IntegerArgumentType::get(context, ARG_VALUE)?;
let world = context.world();
let mut scoreboard = world.scoreboard.lock().await;
let objective = scoreboard
.get_objectives()
.get(objective_name)
.ok_or_else(|| INVALID_TRIGGER_ERROR.create_without_context())?;
if objective.criterion != "trigger" {
return Err(INVALID_TRIGGER_ERROR.create_without_context());
}
let objective_display_name = objective.display_name.clone();
let is_locked = scoreboard
.get_scores()
.get(objective_name)
.and_then(|m| m.get(player_name))
.is_none_or(|s| s.locked);
if is_locked {
return Err(UNPRIMED_TRIGGER_ERROR.create_without_context());
}
let current_value = scoreboard
.get_scores()
.get(objective_name)
.and_then(|m| m.get(player_name))
.map_or(0, |s| s.value.0);
let new_value = current_value + value;
let updated_score = ScoreboardScore {
entity_name: Box::leak(player_name.clone().into_boxed_str()),
objective_name: Box::leak(objective_name.to_string().into_boxed_str()),
value: VarInt(new_value),
display_name: None,
number_format: None,
locked: true,
};
scoreboard.update_score(world, updated_score).await;
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_TRIGGER_ADD_SUCCESS,
translation::java::COMMANDS_TRIGGER_ADD_SUCCESS,
[
objective_display_name,
TextComponent::text(value.to_string()),
],
),
false,
)
.await;
Ok(new_value)
})
}
}
struct SetTriggerExecutor;
impl CommandExecutor for SetTriggerExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let player = context.source.player_or_err()?;
let player_name = &player.gameprofile.name;
let objective_name = ObjectiveArgumentType::get(context, ARG_OBJECTIVE)?;
let value = IntegerArgumentType::get(context, ARG_VALUE)?;
let world = context.world();
let mut scoreboard = world.scoreboard.lock().await;
let objective = scoreboard
.get_objectives()
.get(objective_name)
.ok_or_else(|| INVALID_TRIGGER_ERROR.create_without_context())?;
if objective.criterion != "trigger" {
return Err(INVALID_TRIGGER_ERROR.create_without_context());
}
let objective_display_name = objective.display_name.clone();
let is_locked = scoreboard
.get_scores()
.get(objective_name)
.and_then(|m| m.get(player_name))
.is_none_or(|s| s.locked);
if is_locked {
return Err(UNPRIMED_TRIGGER_ERROR.create_without_context());
}
let updated_score = ScoreboardScore {
entity_name: Box::leak(player_name.clone().into_boxed_str()),
objective_name: Box::leak(objective_name.to_string().into_boxed_str()),
value: VarInt(value),
display_name: None,
number_format: None,
locked: true,
};
scoreboard.update_score(world, updated_score).await;
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_TRIGGER_SET_SUCCESS,
translation::java::COMMANDS_TRIGGER_SET_SUCCESS,
[
objective_display_name,
TextComponent::text(value.to_string()),
],
),
false,
)
.await;
Ok(value)
})
}
}
pub fn register(dispatcher: &mut CommandDispatcher, registry: &mut PermissionRegistry) {
registry.register_permission_or_panic(Permission::new(
PERMISSION,
DESCRIPTION,
PermissionDefault::Allow,
));
dispatcher.register(
command("trigger", DESCRIPTION).requires(PERMISSION).then(
argument(ARG_OBJECTIVE, ObjectiveArgumentType)
.executes(SimpleTriggerExecutor)
.then(literal("add").then(
argument(ARG_VALUE, IntegerArgumentType::any()).executes(AddTriggerExecutor),
))
.then(literal("set").then(
argument(ARG_VALUE, IntegerArgumentType::any()).executes(SetTriggerExecutor),
)),
),
);
}

View File

@ -41,8 +41,13 @@ impl scoreboard::HostScoreboard for PluginHostState {
RenderType::Hearts => pumpkin_protocol::java::client::play::RenderType::Hearts,
};
let objective =
ScoreboardObjective::new(Box::leak(name.into_boxed_str()), display_name, rt, None);
let objective = ScoreboardObjective::new(
Box::leak(name.into_boxed_str()),
display_name,
rt,
None,
"dummy",
);
world
.scoreboard
.lock()

View File

@ -26,6 +26,16 @@ pub struct Scoreboard {
}
impl Scoreboard {
#[must_use]
pub const fn get_objectives(&self) -> &HashMap<String, ScoreboardObjective<'static>> {
&self.objectives
}
#[must_use]
pub const fn get_scores(&self) -> &HashMap<String, HashMap<String, ScoreboardScore<'static>>> {
&self.scores
}
async fn broadcast_editioned<J: ClientPacket, B: BClientPacket>(
world: &World,
je_packet: &J,
@ -281,6 +291,7 @@ pub struct ScoreboardObjective<'a> {
pub display_name: TextComponent,
pub render_type: RenderType,
pub number_format: Option<NumberFormat>,
pub criterion: &'a str,
}
impl<'a> ScoreboardObjective<'a> {
@ -290,12 +301,14 @@ impl<'a> ScoreboardObjective<'a> {
display_name: TextComponent,
render_type: RenderType,
number_format: Option<NumberFormat>,
criterion: &'a str,
) -> Self {
Self {
name,
display_name,
render_type,
number_format,
criterion,
}
}
}
@ -306,6 +319,7 @@ pub struct ScoreboardScore<'a> {
pub value: VarInt,
pub display_name: Option<TextComponent>,
pub number_format: Option<NumberFormat>,
pub locked: bool,
}
impl<'a> ScoreboardScore<'a> {
@ -323,6 +337,7 @@ impl<'a> ScoreboardScore<'a> {
value,
display_name,
number_format,
locked: true,
}
}
}