From 4eefa1c77b86418b9da2e8dddfde3b7d9e057782 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 21 Jun 2026 22:11:58 +0930 Subject: [PATCH 01/30] one of these days i'll remember to do incremental commits. not today tho --- Cargo.toml | 4 +- src/base/macros/src/command_derive/mod.rs | 220 +++++++++++++++++++++ src/base/macros/src/lib.rs | 6 + src/command-infra/Cargo.toml | 13 ++ src/command-infra/src/args/entity.rs | 37 ++++ src/command-infra/src/args/integer.rs | 61 ++++++ src/command-infra/src/args/mod.rs | 9 + src/command-infra/src/args/position.rs | 58 ++++++ src/command-infra/src/args/string.rs | 133 +++++++++++++ src/command-infra/src/error.rs | 31 +++ src/command-infra/src/graph.rs | 119 +++++++++++ src/command-infra/src/lib.rs | 13 ++ src/command-infra/src/metadata.rs | 121 ++++++++++++ src/command-infra/src/reader.rs | 148 ++++++++++++++ src/command-infra/tests/derive_command.rs | 189 ++++++++++++++++++ src/command-infra/tests/reader_and_args.rs | 80 ++++++++ 16 files changed, 1241 insertions(+), 1 deletion(-) create mode 100644 src/base/macros/src/command_derive/mod.rs create mode 100644 src/command-infra/Cargo.toml create mode 100644 src/command-infra/src/args/entity.rs create mode 100644 src/command-infra/src/args/integer.rs create mode 100644 src/command-infra/src/args/mod.rs create mode 100644 src/command-infra/src/args/position.rs create mode 100644 src/command-infra/src/args/string.rs create mode 100644 src/command-infra/src/error.rs create mode 100644 src/command-infra/src/graph.rs create mode 100644 src/command-infra/src/lib.rs create mode 100644 src/command-infra/src/metadata.rs create mode 100644 src/command-infra/src/reader.rs create mode 100644 src/command-infra/tests/derive_command.rs create mode 100644 src/command-infra/tests/reader_and_args.rs diff --git a/Cargo.toml b/Cargo.toml index d92a00f5..40df7477 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "src/blocks/crates/build", "src/blocks/crates/data", "src/bin", + "src/command-infra", "src/commands", "src/components", "src/core", @@ -137,6 +138,7 @@ temper-nbt = { path = "src/adapters/nbt" } temper-net-runtime = { path = "src/net/runtime" } temper-performance = { path = "src/performance" } temper-codec = { path = "src/net/codec" } +temper-command-infra = { path = "src/command-infra" } temper-protocol = { path = "src/net/protocol" } temper-encryption = { path = "src/net/encryption" } temper-profiling = { path = "src/base/profiling" } @@ -237,7 +239,7 @@ fastcache = "0.1.7" # Macros lazy_static = "1.5.0" quote = "1.0.45" -syn = { version = "2.0.117", features = ["extra-traits"] } +syn = { version = "2.0.117", features = ["extra-traits", "full"] } proc-macro2 = "1.0.106" paste = "1.0.15" maplit = "1.0.2" diff --git a/src/base/macros/src/command_derive/mod.rs b/src/base/macros/src/command_derive/mod.rs new file mode 100644 index 00000000..ee57321b --- /dev/null +++ b/src/base/macros/src/command_derive/mod.rs @@ -0,0 +1,220 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote, quote_spanned}; +use syn::{ + parse_macro_input, spanned::Spanned, Data, DeriveInput, Field, Fields, Ident, LitStr, + Result as SynResult, +}; + +pub fn derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + match expand(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn expand(input: DeriveInput) -> SynResult { + let ident = input.ident; + let command_name = command_name(&input.attrs)?; + + let Data::Enum(data_enum) = input.data else { + return Err(syn::Error::new( + ident.span(), + "Command can only be derived for enums", + )); + }; + + let mut parse_arms = Vec::new(); + let mut path_entries = Vec::new(); + let mut greedy_assertions = Vec::new(); + + for variant in data_enum.variants { + let variant_ident = variant.ident; + let fields = match variant.fields { + Fields::Unnamed(fields) => { + VariantFields::Unnamed(fields.unnamed.into_iter().map(CommandField::from).collect()) + } + Fields::Named(fields) => VariantFields::Named( + fields + .named + .into_iter() + .map(|field| { + let ident = field.ident.clone().ok_or_else(|| { + syn::Error::new(field.span(), "named command fields must have names") + })?; + Ok(CommandField { + ident: Some(ident), + field, + }) + }) + .collect::>>()?, + ), + Fields::Unit => VariantFields::Unit, + }; + + let last_field_idx = fields.fields().len().saturating_sub(1); + let mut raw_bindings = Vec::new(); + let mut tuple_value_exprs = Vec::new(); + let mut named_value_exprs = Vec::new(); + let mut segments = Vec::new(); + + for (idx, command_field) in fields.fields().iter().enumerate() { + let arg_name = arg_name(command_field)?; + let field = &command_field.field; + let ty = &field.ty; + let raw_ident = format_ident!("__raw_{idx}"); + + raw_bindings.push(quote! { + let #raw_ident = <#ty as ::temper_command_infra::CommandArg>::recognize(__reader)?; + }); + + tuple_value_exprs.push(quote! { + <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + + if let Some(field_ident) = &command_field.ident { + named_value_exprs.push(quote! { + #field_ident: <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + } + + segments.push(quote! { + ::temper_command_infra::CommandPathSegment::argument( + #arg_name, + <#ty as ::temper_command_infra::CommandArg>::argument_spec(), + ) + }); + + if idx != last_field_idx { + greedy_assertions.push(quote_spanned! { ty.span() => + const _: () = assert!( + !matches!( + <#ty as ::temper_command_infra::CommandArg>::KIND, + ::temper_command_infra::ArgKind::GreedyTail + ), + "greedy-tail command args must be the final field in a command variant" + ); + }); + } + } + + let constructor = match &fields { + VariantFields::Unnamed(_) => quote! { + Self::#variant_ident(#(#tuple_value_exprs),*) + }, + VariantFields::Named(_) => quote! { + Self::#variant_ident { #(#named_value_exprs),* } + }, + VariantFields::Unit => quote! { + Self::#variant_ident + }, + }; + + parse_arms.push(quote! { + { + let __checkpoint = __reader.checkpoint(); + let __result = (|| -> Result { + #(#raw_bindings)* + __reader.expect_end()?; + Ok(#constructor) + })(); + + match __result { + Ok(__command) => return Ok(__command), + Err(__err) => { + __best_error = Some(match __best_error.take() { + Some(__best) => __best.farthest(__err), + None => __err, + }); + __reader.rewind(__checkpoint); + } + } + } + }); + + path_entries.push(quote! { + ::temper_command_infra::CommandPath::new(#command_name, vec![#(#segments),*]) + }); + } + + Ok(quote! { + #(#greedy_assertions)* + + impl ::temper_command_infra::CommandSpec for #ident { + const NAME: &'static str = #command_name; + + fn parse_reader( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + ) -> Result { + let mut __best_error: Option<::temper_command_infra::ParseError> = None; + + #(#parse_arms)* + + Err(__best_error.unwrap_or_else(|| { + ::temper_command_infra::ParseError::expected(__reader.cursor(), "command variant") + })) + } + + fn paths() -> Vec<::temper_command_infra::CommandPath> { + vec![#(#path_entries),*] + } + } + }) +} + +enum VariantFields { + Unnamed(Vec), + Named(Vec), + Unit, +} + +impl VariantFields { + fn fields(&self) -> &[CommandField] { + match self { + VariantFields::Unnamed(fields) | VariantFields::Named(fields) => fields, + VariantFields::Unit => &[], + } + } +} + +struct CommandField { + ident: Option, + field: Field, +} + +impl From for CommandField { + fn from(field: Field) -> Self { + Self { ident: None, field } + } +} + +fn command_name(attrs: &[syn::Attribute]) -> SynResult { + for attr in attrs { + if attr.path().is_ident("command") { + return attr.parse_args::(); + } + } + + Err(syn::Error::new( + proc_macro2::Span::call_site(), + "missing #[command(\"name\")] attribute", + )) +} + +fn arg_name(command_field: &CommandField) -> SynResult { + for attr in &command_field.field.attrs { + if attr.path().is_ident("arg") { + return attr.parse_args::(); + } + } + + if let Some(ident) = &command_field.ident { + return Ok(LitStr::new(&ident.to_string(), ident.span())); + } + + Err(syn::Error::new( + command_field.field.span(), + "command tuple fields must have #[arg(\"name\")]", + )) +} diff --git a/src/base/macros/src/lib.rs b/src/base/macros/src/lib.rs index 1fc13b79..b39bc0da 100644 --- a/src/base/macros/src/lib.rs +++ b/src/base/macros/src/lib.rs @@ -4,6 +4,7 @@ use block::matches; use proc_macro::TokenStream; mod block; +mod command_derive; mod commands; mod helpers; mod item; @@ -79,6 +80,11 @@ pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream { commands::command(attr, input) } +#[proc_macro_derive(Command, attributes(command, arg))] +pub fn command_derive(input: TokenStream) -> TokenStream { + command_derive::derive(input) +} + // #[proc_macro_attribute] // pub fn arg(attr: TokenStream, input: TokenStream) -> TokenStream { // commands::arg(attr, input) diff --git a/src/command-infra/Cargo.toml b/src/command-infra/Cargo.toml new file mode 100644 index 00000000..dd610a3c --- /dev/null +++ b/src/command-infra/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "temper-command-infra" +version = "0.1.0" +edition = "2024" + +[dependencies] +thiserror = { workspace = true } + +[dev-dependencies] +temper-macros = { workspace = true } + +[lints] +workspace = true diff --git a/src/command-infra/src/args/entity.rs b/src/command-infra/src/args/entity.rs new file mode 100644 index 00000000..94fdcd9f --- /dev/null +++ b/src/command-infra/src/args/entity.rs @@ -0,0 +1,37 @@ +use std::ops::Deref; + +use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntityArg(String); + +impl Deref for EntityArg { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for EntityArg { + type Raw<'a> = &'a str; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let cursor = reader.cursor(); + let span = reader.read_word_span()?; + + if span.is_empty() { + Err(ParseError::expected(cursor, "entity")) + } else { + Ok(span) + } + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::new(ParserKind::Entity).with_suggestions("ask_server") + } +} diff --git a/src/command-infra/src/args/integer.rs b/src/command-infra/src/args/integer.rs new file mode 100644 index 00000000..c8368abb --- /dev/null +++ b/src/command-infra/src/args/integer.rs @@ -0,0 +1,61 @@ +use std::ops::Deref; + +use crate::{ + ArgumentSpec, CommandArg, CommandReader, IntegerProperties, ParseError, ParserKind, + ParserProperties, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IntegerArg(i32); + +impl Deref for IntegerArg { + type Target = i32; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for IntegerArg { + type Raw<'a> = i32; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let cursor = reader.cursor(); + let raw = reader.read_word_span()?; + let value = raw + .parse::() + .map_err(|_| ParseError::new(cursor, "integer", "invalid integer"))?; + + if value < MIN { + return Err(ParseError::new( + cursor, + "integer", + format!("integer too small: {value}, expected at least {MIN}"), + )); + } + + if value > MAX { + return Err(ParseError::new( + cursor, + "integer", + format!("integer too large: {value}, expected at most {MAX}"), + )); + } + + Ok(value) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw)) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::Integer, + ParserProperties::Integer(IntegerProperties { + min: Some(MIN), + max: Some(MAX), + }), + ) + } +} diff --git a/src/command-infra/src/args/mod.rs b/src/command-infra/src/args/mod.rs new file mode 100644 index 00000000..55e9fa55 --- /dev/null +++ b/src/command-infra/src/args/mod.rs @@ -0,0 +1,9 @@ +mod entity; +mod integer; +mod position; +mod string; + +pub use entity::EntityArg; +pub use integer::IntegerArg; +pub use position::PositionArg; +pub use string::{GreedyStringArg, QuotableStringArg, SingleWordArg}; diff --git a/src/command-infra/src/args/position.rs b/src/command-infra/src/args/position.rs new file mode 100644 index 00000000..e096125d --- /dev/null +++ b/src/command-infra/src/args/position.rs @@ -0,0 +1,58 @@ +use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PositionArg { + pub x: String, + pub y: String, + pub z: String, +} + +impl CommandArg for PositionArg { + type Raw<'a> = (&'a str, &'a str, &'a str); + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let x = read_coord(reader, "x coordinate")?; + let y = read_coord(reader, "y coordinate")?; + let z = read_coord(reader, "z coordinate")?; + + Ok((x, y, z)) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self { + x: raw.0.to_string(), + y: raw.1.to_string(), + z: raw.2.to_string(), + }) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::new(ParserKind::Position) + } +} + +fn read_coord<'a>( + reader: &mut CommandReader<'a>, + expected: &'static str, +) -> Result<&'a str, ParseError> { + let cursor = reader.cursor(); + let span = reader.read_word_span()?; + + if is_coord(span) { + Ok(span) + } else { + Err(ParseError::new( + cursor, + expected, + format!("invalid {expected}: {span}"), + )) + } +} + +fn is_coord(span: &str) -> bool { + if let Some(relative) = span.strip_prefix('~') { + relative.is_empty() || relative.parse::().is_ok() + } else { + span.parse::().is_ok() + } +} diff --git a/src/command-infra/src/args/string.rs b/src/command-infra/src/args/string.rs new file mode 100644 index 00000000..38318a04 --- /dev/null +++ b/src/command-infra/src/args/string.rs @@ -0,0 +1,133 @@ +use std::ops::Deref; + +use crate::{ + ArgKind, ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind, ParserProperties, + StringMode, reader::StringSpan, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SingleWordArg(String); + +impl Deref for SingleWordArg { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for SingleWordArg { + type Raw<'a> = &'a str; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Word), + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuotableStringArg(String); + +impl Deref for QuotableStringArg { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for QuotableStringArg { + type Raw<'a> = StringSpan<'a>; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_string_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + let parsed = match raw { + StringSpan::Bare(span) => span.to_string(), + StringSpan::Quoted(span) => unescape_quoted(span), + }; + + Ok(Self(parsed)) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Quotable), + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GreedyStringArg(String); + +impl Deref for GreedyStringArg { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for GreedyStringArg { + type Raw<'a> = &'a str; + + const KIND: ArgKind = ArgKind::GreedyTail; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_remaining_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Greedy), + ) + } +} + +fn unescape_quoted(span: &str) -> String { + let mut result = String::new(); + let mut escaped = false; + + for c in span.chars() { + if escaped { + if matches!(c, '"' | '\\') { + result.push(c); + } else { + result.push('\\'); + result.push(c); + } + escaped = false; + continue; + } + + if c == '\\' { + escaped = true; + } else { + result.push(c); + } + } + + if escaped { + result.push('\\'); + } + + result +} diff --git a/src/command-infra/src/error.rs b/src/command-infra/src/error.rs new file mode 100644 index 00000000..99a90847 --- /dev/null +++ b/src/command-infra/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +#[error("{message} at cursor {cursor}")] +pub struct ParseError { + pub cursor: usize, + pub expected: &'static str, + pub message: String, +} + +impl ParseError { + pub fn new(cursor: usize, expected: &'static str, message: impl Into) -> Self { + Self { + cursor, + expected, + message: message.into(), + } + } + + pub fn expected(cursor: usize, expected: &'static str) -> Self { + Self::new(cursor, expected, format!("expected {expected}")) + } + + pub fn farthest(self, other: Self) -> Self { + if other.cursor > self.cursor { + other + } else { + self + } + } +} diff --git a/src/command-infra/src/graph.rs b/src/command-infra/src/graph.rs new file mode 100644 index 00000000..5b3d6ad9 --- /dev/null +++ b/src/command-infra/src/graph.rs @@ -0,0 +1,119 @@ +use crate::{ArgumentSpec, CommandPath, CommandPathSegment}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandNodeKind { + Root, + Literal, + Argument, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandNode { + pub kind: CommandNodeKind, + pub name: Option, + pub argument: Option, + pub children: Vec, + pub executable: bool, +} + +impl CommandNode { + fn root() -> Self { + Self { + kind: CommandNodeKind::Root, + name: None, + argument: None, + children: Vec::new(), + executable: false, + } + } + + fn literal(name: &str) -> Self { + Self { + kind: CommandNodeKind::Literal, + name: Some(name.to_string()), + argument: None, + children: Vec::new(), + executable: false, + } + } + + fn argument(name: &str, spec: ArgumentSpec) -> Self { + Self { + kind: CommandNodeKind::Argument, + name: Some(name.to_string()), + argument: Some(spec), + children: Vec::new(), + executable: false, + } + } + + fn matches_segment(&self, segment: &CommandPathSegment) -> bool { + match segment { + CommandPathSegment::Literal(name) => { + self.kind == CommandNodeKind::Literal && self.name.as_deref() == Some(*name) + } + CommandPathSegment::Argument { name, spec } => { + self.kind == CommandNodeKind::Argument + && self.name.as_deref() == Some(*name) + && self.argument == Some(*spec) + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandGraph { + pub nodes: Vec, + pub root_idx: usize, +} + +impl Default for CommandGraph { + fn default() -> Self { + Self { + nodes: vec![CommandNode::root()], + root_idx: 0, + } + } +} + +impl CommandGraph { + pub fn from_paths(paths: &[CommandPath]) -> Self { + let mut graph = Self::default(); + for path in paths { + graph.push_path(path); + } + graph + } + + pub fn push_path(&mut self, path: &CommandPath) { + let mut current = self.root_idx; + + for literal in path.root.split_whitespace() { + current = self.push_or_reuse(current, CommandPathSegment::literal(literal)); + } + + for segment in &path.segments { + current = self.push_or_reuse(current, segment.clone()); + } + + self.nodes[current].executable = true; + } + + fn push_or_reuse(&mut self, parent: usize, segment: CommandPathSegment) -> usize { + for child_idx in self.nodes[parent].children.clone() { + if self.nodes[child_idx].matches_segment(&segment) { + return child_idx; + } + } + + let node = match segment { + CommandPathSegment::Literal(name) => CommandNode::literal(name), + CommandPathSegment::Argument { name, spec } => CommandNode::argument(name, spec), + }; + + let idx = self.nodes.len(); + self.nodes.push(node); + self.nodes[parent].children.push(idx); + idx + } +} diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs new file mode 100644 index 00000000..ae5d8a1d --- /dev/null +++ b/src/command-infra/src/lib.rs @@ -0,0 +1,13 @@ +pub mod args; +pub mod error; +pub mod graph; +pub mod metadata; +pub mod reader; + +pub use error::ParseError; +pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; +pub use metadata::{ + ArgKind, ArgumentSpec, CommandArg, CommandPath, CommandPathSegment, CommandSpec, + IntegerProperties, ParserKind, ParserProperties, StringMode, +}; +pub use reader::{Checkpoint, CommandReader}; diff --git a/src/command-infra/src/metadata.rs b/src/command-infra/src/metadata.rs new file mode 100644 index 00000000..263c19a8 --- /dev/null +++ b/src/command-infra/src/metadata.rs @@ -0,0 +1,121 @@ +use crate::{CommandReader, ParseError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ArgKind { + Normal, + GreedyTail, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StringMode { + Word, + Quotable, + Greedy, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IntegerProperties { + pub min: Option, + pub max: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ParserProperties { + String(StringMode), + Integer(IntegerProperties), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ParserKind { + Word, + Integer, + String, + Position, + Entity, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ArgumentSpec { + pub parser: ParserKind, + pub properties: Option, + pub suggestions: Option<&'static str>, +} + +impl ArgumentSpec { + pub const fn new(parser: ParserKind) -> Self { + Self { + parser, + properties: None, + suggestions: None, + } + } + + pub const fn with_properties(parser: ParserKind, properties: ParserProperties) -> ArgumentSpec { + Self { + parser, + properties: Some(properties), + suggestions: None, + } + } + + pub const fn with_suggestions(mut self, suggestions: &'static str) -> ArgumentSpec { + self.suggestions = Some(suggestions); + self + } +} + +pub trait CommandArg: Sized { + type Raw<'a>; + + const KIND: ArgKind = ArgKind::Normal; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError>; + + fn parse(raw: Self::Raw<'_>) -> Result; + + fn argument_spec() -> ArgumentSpec; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommandPathSegment { + Literal(&'static str), + Argument { + name: &'static str, + spec: ArgumentSpec, + }, +} + +impl CommandPathSegment { + pub const fn literal(name: &'static str) -> Self { + Self::Literal(name) + } + + pub const fn argument(name: &'static str, spec: ArgumentSpec) -> Self { + Self::Argument { name, spec } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandPath { + pub root: &'static str, + pub segments: Vec, +} + +impl CommandPath { + pub fn new(root: &'static str, segments: Vec) -> Self { + Self { root, segments } + } +} + +pub trait CommandSpec: Sized { + const NAME: &'static str; + + fn parse_reader(reader: &mut CommandReader<'_>) -> Result; + + fn paths() -> Vec; + + fn parse(input: &str) -> Result { + let mut reader = CommandReader::new(input); + Self::parse_reader(&mut reader) + } +} diff --git a/src/command-infra/src/reader.rs b/src/command-infra/src/reader.rs new file mode 100644 index 00000000..f87ada66 --- /dev/null +++ b/src/command-infra/src/reader.rs @@ -0,0 +1,148 @@ +use crate::ParseError; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Checkpoint { + cursor: usize, +} + +#[derive(Clone, Copy, Debug)] +pub enum StringSpan<'a> { + Bare(&'a str), + Quoted(&'a str), +} + +pub struct CommandReader<'a> { + input: &'a str, + cursor: usize, +} + +impl<'a> CommandReader<'a> { + pub fn new(input: &'a str) -> Self { + Self { input, cursor: 0 } + } + + pub fn input(&self) -> &'a str { + self.input + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn remaining(&self) -> &'a str { + &self.input[self.cursor..] + } + + pub fn checkpoint(&self) -> Checkpoint { + Checkpoint { + cursor: self.cursor, + } + } + + pub fn rewind(&mut self, checkpoint: Checkpoint) { + self.cursor = checkpoint.cursor; + } + + pub fn has_remaining(&self) -> bool { + self.cursor < self.input.len() + } + + pub fn peek(&self) -> Option { + self.remaining().chars().next() + } + + pub fn read_char(&mut self) -> Option { + let c = self.peek()?; + self.cursor += c.len_utf8(); + Some(c) + } + + pub fn skip_whitespace(&mut self) { + while self.peek().is_some_and(char::is_whitespace) { + self.read_char(); + } + } + + pub fn read_word_span(&mut self) -> Result<&'a str, ParseError> { + self.skip_whitespace(); + let start = self.cursor; + + while self.peek().is_some_and(|c| !c.is_whitespace()) { + self.read_char(); + } + + if start == self.cursor { + return Err(ParseError::expected(start, "word")); + } + + Ok(&self.input[start..self.cursor]) + } + + pub fn read_quoted_string_span(&mut self) -> Result<&'a str, ParseError> { + self.skip_whitespace(); + let quote_cursor = self.cursor; + + if self.read_char() != Some('"') { + return Err(ParseError::expected(quote_cursor, "quoted string")); + } + + let start = self.cursor; + let mut escaped = false; + + while let Some(c) = self.read_char() { + if escaped { + escaped = false; + continue; + } + + match c { + '\\' => escaped = true, + '"' => { + let end = self.cursor - c.len_utf8(); + return Ok(&self.input[start..end]); + } + _ => {} + } + } + + Err(ParseError::new( + quote_cursor, + "closing quote", + "unterminated quoted string", + )) + } + + pub fn read_string_span(&mut self) -> Result, ParseError> { + self.skip_whitespace(); + if self.peek() == Some('"') { + self.read_quoted_string_span().map(StringSpan::Quoted) + } else { + self.read_word_span().map(StringSpan::Bare) + } + } + + pub fn read_remaining_span(&mut self) -> Result<&'a str, ParseError> { + self.skip_whitespace(); + let start = self.cursor; + self.cursor = self.input.len(); + + if start == self.cursor { + return Err(ParseError::expected(start, "remaining input")); + } + + Ok(&self.input[start..]) + } + + pub fn expect_end(&mut self) -> Result<(), ParseError> { + self.skip_whitespace(); + if self.has_remaining() { + Err(ParseError::new( + self.cursor, + "end of command", + "unexpected trailing input", + )) + } else { + Ok(()) + } + } +} diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs new file mode 100644 index 00000000..c11863ee --- /dev/null +++ b/src/command-infra/tests/derive_command.rs @@ -0,0 +1,189 @@ +use temper_command_infra::args::{ + EntityArg, GreedyStringArg, IntegerArg, PositionArg, SingleWordArg, +}; +use temper_command_infra::{CommandGraph, CommandNodeKind, CommandSpec}; +use temper_macros::Command; + +#[derive(Debug, PartialEq, Command)] +#[command("tp")] +enum TpCommand { + TpToPos { + location: PositionArg, + }, + TpToEntity { + destination: EntityArg, + }, + TpEntityToPos { + target: EntityArg, + location: PositionArg, + }, + TpEntityToEntity { + target: EntityArg, + destination: EntityArg, + }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("overlap")] +enum OverlapCommand { + Word { value: SingleWordArg }, + Entity { target: EntityArg }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("say")] +enum SayCommand { + Say { message: GreedyStringArg }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("number")] +enum NumberCommand { + Number { value: IntegerArg<0, 10> }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("rename")] +enum RenameCommand { + Rename { + #[arg("display_name")] + name: SingleWordArg, + }, +} + +#[test] +fn tp_to_position_parses() { + let command = TpCommand::parse("~ ~ ~").unwrap(); + + assert!(matches!(command, TpCommand::TpToPos { .. })); +} + +#[test] +fn tp_to_entity_parses() { + let command = TpCommand::parse("Steve").unwrap(); + + match command { + TpCommand::TpToEntity { destination } => assert_eq!(&*destination, "Steve"), + _ => panic!("expected entity destination"), + } +} + +#[test] +fn tp_entity_to_position_parses() { + let command = TpCommand::parse("Steve ~ ~ ~").unwrap(); + + match command { + TpCommand::TpEntityToPos { target, location } => { + assert_eq!(&*target, "Steve"); + assert_eq!(location.x, "~"); + assert_eq!(location.y, "~"); + assert_eq!(location.z, "~"); + } + _ => panic!("expected entity to position"), + } +} + +#[test] +fn tp_entity_to_entity_parses() { + let command = TpCommand::parse("Steve Alex").unwrap(); + + match command { + TpCommand::TpEntityToEntity { + target, + destination, + } => { + assert_eq!(&*target, "Steve"); + assert_eq!(&*destination, "Alex"); + } + _ => panic!("expected entity to entity"), + } +} + +#[test] +fn failed_variants_rewind_cleanly() { + let command = TpCommand::parse("Steve 1 2 3").unwrap(); + + assert!(matches!(command, TpCommand::TpEntityToPos { .. })); +} + +#[test] +fn variant_order_breaks_ties() { + let command = OverlapCommand::parse("Steve").unwrap(); + + assert!(matches!(command, OverlapCommand::Word { .. })); +} + +#[test] +fn greedy_tail_variant_parses() { + let command = SayCommand::parse("hello there").unwrap(); + + match command { + SayCommand::Say { message } => assert_eq!(&*message, "hello there"), + } +} + +#[test] +fn parse_errors_report_farthest_failure() { + let err = NumberCommand::parse("20").unwrap_err(); + + assert_eq!(err.cursor, 0); + assert!(err.message.contains("too large")); +} + +#[test] +fn graph_generation_merges_shared_prefixes() { + let graph = CommandGraph::from_paths(&TpCommand::paths()); + + let root = &graph.nodes[graph.root_idx]; + assert_eq!(root.children.len(), 1); + + let tp_idx = root.children[0]; + let tp = &graph.nodes[tp_idx]; + assert_eq!(tp.kind, CommandNodeKind::Literal); + assert_eq!(tp.name.as_deref(), Some("tp")); + + let child_names = tp + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(child_names, vec!["location", "destination", "target"]); + + let target_idx = tp + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("target")) + .unwrap(); + let target = &graph.nodes[target_idx]; + + assert_eq!(target.children.len(), 2); + assert!(!target.executable); + assert!( + target + .children + .iter() + .all(|idx| graph.nodes[*idx].executable) + ); +} + +#[test] +fn graph_uses_arg_attribute_names() { + let graph = CommandGraph::from_paths(&NumberCommand::paths()); + let number_idx = graph.nodes[graph.root_idx].children[0]; + let value_idx = graph.nodes[number_idx].children[0]; + + assert_eq!(graph.nodes[number_idx].name.as_deref(), Some("number")); + assert_eq!(graph.nodes[value_idx].name.as_deref(), Some("value")); + assert!(graph.nodes[value_idx].executable); +} + +#[test] +fn arg_attribute_overrides_named_field_name() { + let graph = CommandGraph::from_paths(&RenameCommand::paths()); + let rename_idx = graph.nodes[graph.root_idx].children[0]; + let name_idx = graph.nodes[rename_idx].children[0]; + + assert_eq!(graph.nodes[name_idx].name.as_deref(), Some("display_name")); +} diff --git a/src/command-infra/tests/reader_and_args.rs b/src/command-infra/tests/reader_and_args.rs new file mode 100644 index 00000000..20b2c3f5 --- /dev/null +++ b/src/command-infra/tests/reader_and_args.rs @@ -0,0 +1,80 @@ +use temper_command_infra::args::{ + GreedyStringArg, IntegerArg, PositionArg, QuotableStringArg, SingleWordArg, +}; +use temper_command_infra::{CommandArg, CommandReader}; + +#[test] +fn reader_supports_checkpoint_and_rewind() { + let mut reader = CommandReader::new("alpha beta"); + let checkpoint = reader.checkpoint(); + + assert_eq!(reader.read_word_span().unwrap(), "alpha"); + assert_eq!(reader.cursor(), 5); + + reader.rewind(checkpoint); + + assert_eq!(reader.cursor(), 0); + assert_eq!(reader.read_word_span().unwrap(), "alpha"); +} + +#[test] +fn reader_reads_quoted_strings() { + let mut reader = CommandReader::new("\"hello \\\"there\\\"\" tail"); + let span = reader.read_quoted_string_span().unwrap(); + + assert_eq!(span, "hello \\\"there\\\""); + assert_eq!(reader.read_word_span().unwrap(), "tail"); +} + +#[test] +fn reader_rejects_unterminated_quoted_strings() { + let mut reader = CommandReader::new("\"hello"); + let err = reader.read_quoted_string_span().unwrap_err(); + + assert_eq!(err.expected, "closing quote"); +} + +#[test] +fn word_arg_consumes_one_token() { + let mut reader = CommandReader::new("hello there"); + let raw = SingleWordArg::recognize(&mut reader).unwrap(); + let parsed = SingleWordArg::parse(raw).unwrap(); + + assert_eq!(&*parsed, "hello"); + assert_eq!(reader.read_word_span().unwrap(), "there"); +} + +#[test] +fn quoted_string_arg_can_appear_before_later_args() { + let mut reader = CommandReader::new("\"hello there\" 5"); + let raw_string = QuotableStringArg::recognize(&mut reader).unwrap(); + let raw_int = IntegerArg::<0, 10>::recognize(&mut reader).unwrap(); + + let string = QuotableStringArg::parse(raw_string).unwrap(); + let int = IntegerArg::<0, 10>::parse(raw_int).unwrap(); + + assert_eq!(&*string, "hello there"); + assert_eq!(*int, 5); +} + +#[test] +fn greedy_arg_consumes_remainder() { + let mut reader = CommandReader::new("hello there friend"); + let raw = GreedyStringArg::recognize(&mut reader).unwrap(); + let parsed = GreedyStringArg::parse(raw).unwrap(); + + assert_eq!(&*parsed, "hello there friend"); + assert!(reader.expect_end().is_ok()); +} + +#[test] +fn position_arg_consumes_exactly_three_tokens() { + let mut reader = CommandReader::new("~ ~1 3 Steve"); + let raw = PositionArg::recognize(&mut reader).unwrap(); + let position = PositionArg::parse(raw).unwrap(); + + assert_eq!(position.x, "~"); + assert_eq!(position.y, "~1"); + assert_eq!(position.z, "3"); + assert_eq!(reader.read_word_span().unwrap(), "Steve"); +} From 63e2d505845877bae4e29da598ab964e568b31b5 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 21 Jun 2026 22:25:35 +0930 Subject: [PATCH 02/30] Set up some ECS stuff --- src/command-infra/Cargo.toml | 1 + src/command-infra/src/ecs.rs | 72 ++++++++++++++++ src/command-infra/src/lib.rs | 2 + src/command-infra/tests/ecs_registry.rs | 31 +++++++ src/game_systems/src/packets/Cargo.toml | 1 + .../src/packets/src/command_graph.rs | 37 ++++++++ src/game_systems/src/packets/src/lib.rs | 1 + src/net/protocol/Cargo.toml | 1 + src/net/protocol/src/outgoing/commands.rs | 86 ++++++++++++++++++- 9 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/command-infra/src/ecs.rs create mode 100644 src/command-infra/tests/ecs_registry.rs create mode 100644 src/game_systems/src/packets/src/command_graph.rs diff --git a/src/command-infra/Cargo.toml b/src/command-infra/Cargo.toml index dd610a3c..fabc6da4 100644 --- a/src/command-infra/Cargo.toml +++ b/src/command-infra/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +bevy_ecs = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs new file mode 100644 index 00000000..87fc5564 --- /dev/null +++ b/src/command-infra/src/ecs.rs @@ -0,0 +1,72 @@ +use bevy_ecs::prelude::{Component, Entity, Message, Resource}; + +use crate::{CommandGraph, CommandPath, CommandSpec}; + +#[derive(Clone, Debug)] +pub struct RegisteredCommand { + pub name: &'static str, + pub paths: Vec, +} + +impl RegisteredCommand { + pub fn of() -> Self { + Self { + name: C::NAME, + paths: C::paths(), + } + } +} + +#[derive(Default, Resource)] +pub struct CommandRegistry { + commands: Vec, +} + +impl CommandRegistry { + pub fn register(&mut self) { + self.commands.push(RegisteredCommand::of::()); + } + + pub fn register_command(&mut self, command: RegisteredCommand) { + self.commands.push(command); + } + + pub fn commands(&self) -> &[RegisteredCommand] { + &self.commands + } + + pub fn paths_for_player(&self, _player: Entity) -> Vec { + self.commands + .iter() + .flat_map(|command| command.paths.iter().cloned()) + .collect() + } + + pub fn build_graph_for_player(&self, player: Entity) -> CommandGraph { + CommandGraph::from_paths(&self.paths_for_player(player)) + } +} + +#[derive(Component, Clone, Debug, Eq, PartialEq)] +pub struct PlayerCommandGraph { + pub graph: CommandGraph, + pub version: u64, +} + +impl PlayerCommandGraph { + pub fn new(graph: CommandGraph) -> Self { + Self { graph, version: 0 } + } + + pub fn next(graph: CommandGraph, previous: Option<&PlayerCommandGraph>) -> Self { + Self { + graph, + version: previous.map(|graph| graph.version + 1).unwrap_or(0), + } + } +} + +#[derive(Message, Clone, Copy, Debug, Eq, PartialEq)] +pub struct RebuildCommandGraph { + pub player: Entity, +} diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index ae5d8a1d..95e1dd11 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -1,9 +1,11 @@ pub mod args; +pub mod ecs; pub mod error; pub mod graph; pub mod metadata; pub mod reader; +pub use ecs::{CommandRegistry, PlayerCommandGraph, RebuildCommandGraph, RegisteredCommand}; pub use error::ParseError; pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; pub use metadata::{ diff --git a/src/command-infra/tests/ecs_registry.rs b/src/command-infra/tests/ecs_registry.rs new file mode 100644 index 00000000..306b46e1 --- /dev/null +++ b/src/command-infra/tests/ecs_registry.rs @@ -0,0 +1,31 @@ +use bevy_ecs::entity::Entity; +use temper_command_infra::CommandRegistry; +use temper_command_infra::args::{EntityArg, PositionArg}; +use temper_macros::Command; + +#[derive(Debug, PartialEq, Command)] +#[command("tp")] +enum TpCommand { + TpToPos { location: PositionArg }, + TpToEntity { destination: EntityArg }, +} + +#[test] +fn registry_builds_graph_from_registered_commands() { + let mut registry = CommandRegistry::default(); + registry.register::(); + + let graph = registry.build_graph_for_player(Entity::PLACEHOLDER); + let root = &graph.nodes[graph.root_idx]; + let tp_idx = root.children[0]; + let tp = &graph.nodes[tp_idx]; + + assert_eq!(registry.commands().len(), 1); + assert_eq!(tp.name.as_deref(), Some("tp")); + assert_eq!(tp.children.len(), 2); + assert!( + tp.children + .iter() + .all(|child| graph.nodes[*child].executable) + ); +} diff --git a/src/game_systems/src/packets/Cargo.toml b/src/game_systems/src/packets/Cargo.toml index 65c0c69c..72b2202b 100644 --- a/src/game_systems/src/packets/Cargo.toml +++ b/src/game_systems/src/packets/Cargo.toml @@ -16,6 +16,7 @@ temper-inventories = { workspace = true } temper-commands = { workspace = true } temper-net-runtime = { workspace = true } temper-codec = { workspace = true } +temper-command-infra = { workspace = true } temper-config = { workspace = true } temper-blocks = { workspace = true } once_cell = { workspace = true } diff --git a/src/game_systems/src/packets/src/command_graph.rs b/src/game_systems/src/packets/src/command_graph.rs new file mode 100644 index 00000000..eff99d1a --- /dev/null +++ b/src/game_systems/src/packets/src/command_graph.rs @@ -0,0 +1,37 @@ +use std::collections::HashSet; + +use bevy_ecs::prelude::*; +use temper_command_infra::{CommandRegistry, PlayerCommandGraph, RebuildCommandGraph}; +use temper_net_runtime::connection::StreamWriter; +use temper_protocol::outgoing::commands::CommandsPacket; +use tracing::error; + +pub fn rebuild_and_send_command_graphs( + mut commands: Commands, + mut rebuilds: MessageReader, + registry: Res, + query: Query<(&StreamWriter, Option<&PlayerCommandGraph>)>, +) { + let players = rebuilds + .read() + .map(|rebuild| rebuild.player) + .collect::>(); + + for player in players { + let Ok((writer, previous_graph)) = query.get(player) else { + continue; + }; + + let graph = registry.build_graph_for_player(player); + let packet = CommandsPacket::from_command_infra_graph(&graph); + + if let Err(err) = writer.send_packet(packet) { + error!("failed sending rebuilt command graph to player {player:?}: {err}"); + continue; + } + + commands + .entity(player) + .insert(PlayerCommandGraph::next(graph, previous_graph)); + } +} diff --git a/src/game_systems/src/packets/src/lib.rs b/src/game_systems/src/packets/src/lib.rs index 72c02eb5..e07dc9ac 100644 --- a/src/game_systems/src/packets/src/lib.rs +++ b/src/game_systems/src/packets/src/lib.rs @@ -2,6 +2,7 @@ pub mod change_game_mode; pub mod chat_message; pub mod chunk_batch_ack; pub mod command; +pub mod command_graph; pub mod command_suggestions; pub mod confirm_player_teleport; pub mod keep_alive; diff --git a/src/net/protocol/Cargo.toml b/src/net/protocol/Cargo.toml index 0c682b99..d0ea6401 100644 --- a/src/net/protocol/Cargo.toml +++ b/src/net/protocol/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] temper-codec = { workspace = true } +temper-command-infra = { workspace = true } temper-macros = { workspace = true } tracing = { workspace = true } temper-components = { workspace = true } diff --git a/src/net/protocol/src/outgoing/commands.rs b/src/net/protocol/src/outgoing/commands.rs index 4de2b72a..c72a7950 100644 --- a/src/net/protocol/src/outgoing/commands.rs +++ b/src/net/protocol/src/outgoing/commands.rs @@ -1,5 +1,16 @@ use temper_codec::net_types::{length_prefixed_vec::LengthPrefixedVec, var_int::VarInt}; -use temper_commands::graph::{CommandGraph, node::CommandNode}; +use temper_command_infra::{ + ArgumentSpec, CommandGraph as InfraCommandGraph, CommandNode as InfraCommandNode, + CommandNodeKind as InfraCommandNodeKind, IntegerProperties, ParserKind, ParserProperties, + StringMode, +}; +use temper_commands::{ + arg::primitive::{ + PrimitiveArgumentFlags, PrimitiveArgumentType, int::IntArgumentFlags, + string::StringArgumentType, + }, + graph::{CommandGraph, node::CommandNode}, +}; use temper_macros::{NetEncode, packet}; #[derive(NetEncode, Debug)] @@ -18,6 +29,13 @@ impl CommandsPacket { } } + pub fn from_command_infra_graph(graph: &InfraCommandGraph) -> Self { + Self { + graph: LengthPrefixedVec::new(graph.nodes.iter().map(convert_node).collect()), + root_idx: VarInt::new(graph.root_idx as i32), + } + } + /// Creates a CommandsPacket using the globally registered command graph. /// /// This is the typical way to create this packet, as it includes all @@ -27,6 +45,72 @@ impl CommandsPacket { } } +fn convert_node(node: &InfraCommandNode) -> CommandNode { + let mut flags = match node.kind { + InfraCommandNodeKind::Root => 0x00, + InfraCommandNodeKind::Literal => 0x01, + InfraCommandNodeKind::Argument => 0x02, + }; + + if node.executable { + flags |= 0x04; + } + + if node.argument.and_then(|arg| arg.suggestions).is_some() { + flags |= 0x10; + } + + CommandNode { + flags, + children: LengthPrefixedVec::new( + node.children + .iter() + .map(|child| VarInt::new(*child as i32)) + .collect(), + ), + redirect_node: None, + name: node.name.clone(), + parser_id: node.argument.map(parser_id), + properties: node.argument.and_then(parser_properties), + suggestions_type: node + .argument + .and_then(|argument| argument.suggestions) + .map(str::to_string), + } +} + +fn parser_id(argument: ArgumentSpec) -> PrimitiveArgumentType { + match argument.parser { + ParserKind::Word | ParserKind::String => PrimitiveArgumentType::String, + ParserKind::Integer => PrimitiveArgumentType::Int, + ParserKind::Position => PrimitiveArgumentType::Vec3, + ParserKind::Entity => PrimitiveArgumentType::Entity, + } +} + +fn parser_properties(argument: ArgumentSpec) -> Option { + match argument.properties { + Some(ParserProperties::String(mode)) => { + Some(PrimitiveArgumentFlags::String(string_mode(mode))) + } + Some(ParserProperties::Integer(IntegerProperties { min, max })) => { + Some(PrimitiveArgumentFlags::Int(IntArgumentFlags { min, max })) + } + None if argument.parser == ParserKind::Word => { + Some(PrimitiveArgumentFlags::String(StringArgumentType::Word)) + } + None => None, + } +} + +fn string_mode(mode: StringMode) -> StringArgumentType { + match mode { + StringMode::Word => StringArgumentType::Word, + StringMode::Quotable => StringArgumentType::Quotable, + StringMode::Greedy => StringArgumentType::Greedy, + } +} + impl Default for CommandsPacket { fn default() -> Self { Self::from_global_graph() From 1c36d92ed0288d4635f26d90541a5faecf1bfe97 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 21 Jun 2026 22:39:42 +0930 Subject: [PATCH 03/30] Wiring in the new command shit --- src/game_systems/src/lib.rs | 1 + src/messages/Cargo.toml | 1 + src/messages/src/lib.rs | 2 + src/net/protocol/src/outgoing/commands.rs | 88 ++++++++++++++++++++++- src/resources/Cargo.toml | 1 + src/resources/src/lib.rs | 2 + 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/game_systems/src/lib.rs b/src/game_systems/src/lib.rs index 96e7dc32..a6d8bdb7 100644 --- a/src/game_systems/src/lib.rs +++ b/src/game_systems/src/lib.rs @@ -55,6 +55,7 @@ fn register_tick_systems(schedule: &mut Schedule) { schedule.add_systems(packets::close_container::handle); schedule.add_systems(packets::player_loaded::handle); schedule.add_systems(packets::command::handle); + schedule.add_systems(packets::command_graph::rebuild_and_send_command_graphs); schedule.add_systems(packets::command_suggestions::handle); schedule.add_systems(packets::chat_message::handle); schedule.add_systems(packets::set_creative_mode_slot::handle); diff --git a/src/messages/Cargo.toml b/src/messages/Cargo.toml index c9c54abe..37707ad2 100644 --- a/src/messages/Cargo.toml +++ b/src/messages/Cargo.toml @@ -8,6 +8,7 @@ description = "Defines all Bevy ECS messages for Temper." bevy_ecs = { workspace = true } bevy_math = { workspace = true } +temper-command-infra = { workspace = true } temper-components = { workspace = true } temper-core = { workspace = true } temper-codec = { workspace = true } diff --git a/src/messages/src/lib.rs b/src/messages/src/lib.rs index 4a5ad313..51890263 100644 --- a/src/messages/src/lib.rs +++ b/src/messages/src/lib.rs @@ -53,6 +53,7 @@ use crate::save_chunk_entities::SaveChunkEntities; use crate::teleport_player::TeleportPlayer; pub use block_break::BlockBrokenEvent; pub use block_interaction::BlockInteractMessage; +use temper_command_infra::RebuildCommandGraph; use temper_commands::messages::{CommandDispatched, ResolvedCommandDispatched}; use world_change::WorldChange; @@ -62,6 +63,7 @@ pub fn register_messages(world: &mut World) { MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); + MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); diff --git a/src/net/protocol/src/outgoing/commands.rs b/src/net/protocol/src/outgoing/commands.rs index c72a7950..92d976b2 100644 --- a/src/net/protocol/src/outgoing/commands.rs +++ b/src/net/protocol/src/outgoing/commands.rs @@ -1,3 +1,5 @@ +use std::fmt; + use temper_codec::net_types::{length_prefixed_vec::LengthPrefixedVec, var_int::VarInt}; use temper_command_infra::{ ArgumentSpec, CommandGraph as InfraCommandGraph, CommandNode as InfraCommandNode, @@ -9,10 +11,48 @@ use temper_commands::{ PrimitiveArgumentFlags, PrimitiveArgumentType, int::IntArgumentFlags, string::StringArgumentType, }, - graph::{CommandGraph, node::CommandNode}, + graph::{CommandGraph, node::CommandNode as OldCommandNode}, }; use temper_macros::{NetEncode, packet}; +#[derive(Clone, NetEncode)] +pub struct CommandNode { + pub flags: u8, + pub children: LengthPrefixedVec, + pub redirect_node: Option, + pub name: Option, + pub parser_id: Option, + pub properties: Option, + pub suggestions_type: Option, +} + +impl fmt::Debug for CommandNode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CommandNode") + .field("node_type", &command_node_kind(self.flags)) + .field("executable", &(self.flags & 0x04 != 0)) + .field("has_redirect", &(self.flags & 0x08 != 0)) + .field("has_suggestions_type", &(self.flags & 0x10 != 0)) + .field("flags", &self.flags) + .field("children", &self.children) + .field("redirect_node", &self.redirect_node) + .field("name", &self.name) + .field("parser_id", &self.parser_id) + .field("properties", &self.properties) + .field("suggestions_type", &self.suggestions_type) + .finish() + } +} + +fn command_node_kind(flags: u8) -> &'static str { + match flags & 0x03 { + 0 => "Root", + 1 => "Literal", + 2 => "Argument", + _ => "Invalid", + } +} + #[derive(NetEncode, Debug)] #[packet(packet_id = "commands", state = "play")] pub struct CommandsPacket { @@ -24,7 +64,7 @@ impl CommandsPacket { /// Creates a CommandsPacket from the provided command graph. pub fn new(graph: CommandGraph) -> Self { Self { - graph: LengthPrefixedVec::new(graph.nodes), + graph: LengthPrefixedVec::new(graph.nodes.iter().map(convert_old_node).collect()), root_idx: VarInt::new(0), } } @@ -45,6 +85,18 @@ impl CommandsPacket { } } +fn convert_old_node(node: &OldCommandNode) -> CommandNode { + CommandNode { + flags: node.flags, + children: node.children.clone(), + redirect_node: node.redirect_node.clone(), + name: node.name.clone(), + parser_id: node.parser_id.clone(), + properties: node.properties.clone(), + suggestions_type: node.suggestions_type.clone(), + } +} + fn convert_node(node: &InfraCommandNode) -> CommandNode { let mut flags = match node.kind { InfraCommandNodeKind::Root => 0x00, @@ -116,3 +168,35 @@ impl Default for CommandsPacket { Self::from_global_graph() } } + +#[cfg(test)] +mod tests { + use temper_command_infra::{ + ArgumentSpec, CommandGraph, CommandPath, CommandPathSegment, ParserKind, + }; + + use super::CommandsPacket; + + #[test] + fn converts_command_infra_graph_to_protocol_nodes() { + let graph = CommandGraph::from_paths(&[CommandPath::new( + "tp", + vec![CommandPathSegment::argument( + "target", + ArgumentSpec::new(ParserKind::Entity).with_suggestions("ask_server"), + )], + )]); + + let packet = CommandsPacket::from_command_infra_graph(&graph); + + assert_eq!(packet.root_idx.0, 0); + assert_eq!(packet.graph.data.len(), 3); + assert_eq!(packet.graph.data[1].name.as_deref(), Some("tp")); + assert_eq!(packet.graph.data[2].name.as_deref(), Some("target")); + assert!(packet.graph.data[2].flags & 0x04 != 0); + assert_eq!( + packet.graph.data[2].suggestions_type.as_deref(), + Some("ask_server") + ); + } +} diff --git a/src/resources/Cargo.toml b/src/resources/Cargo.toml index 7ccfbd13..05088f9d 100644 --- a/src/resources/Cargo.toml +++ b/src/resources/Cargo.toml @@ -5,6 +5,7 @@ version.workspace = true [dependencies] bevy_ecs = { workspace = true } +temper-command-infra = { workspace = true } temper-performance = { workspace = true } temper-net-runtime = { workspace = true } temper-state = { workspace = true } diff --git a/src/resources/src/lib.rs b/src/resources/src/lib.rs index 9786151d..9fd9b243 100644 --- a/src/resources/src/lib.rs +++ b/src/resources/src/lib.rs @@ -5,6 +5,7 @@ use crate::time::WorldTime; use crate::world_sync_tracker::WorldSyncTracker; use bevy_ecs::prelude::World; use crossbeam_channel::Receiver; +use temper_command_infra::CommandRegistry; use temper_entities::PhysicalRegistry; use temper_net_runtime::connection::NewConnection; use temper_state::GlobalStateResource; @@ -29,6 +30,7 @@ pub fn register_resources( world.insert_resource(WorldTime::default()); world.insert_resource(ServerCommandReceiver(server_command_recv)); world.insert_resource(PhysicalRegistry::new()); + world.insert_resource(CommandRegistry::default()); world.insert_resource(BossBarResource::new()); } From d749c780cbe6bdbb11695fd20ca591d5533c1bee Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 21 Jun 2026 22:49:15 +0930 Subject: [PATCH 04/30] More replacement stuff --- src/base/macros/src/command_derive/mod.rs | 10 ++++++++ src/command-infra/Cargo.toml | 1 + src/command-infra/src/ecs.rs | 24 +++++++++++++++++++ src/command-infra/src/lib.rs | 2 ++ src/command-infra/tests/ecs_registry.rs | 14 ++++++++++- src/default_commands/Cargo.toml | 1 + src/default_commands/src/lib.rs | 1 + src/default_commands/src/new/mod.rs | 1 + src/default_commands/src/new/tp.rs | 22 +++++++++++++++++ .../tests/new_command_registry.rs | 12 ++++++++++ src/resources/src/lib.rs | 2 +- 11 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/default_commands/src/new/mod.rs create mode 100644 src/default_commands/src/new/tp.rs create mode 100644 src/default_commands/tests/new_command_registry.rs diff --git a/src/base/macros/src/command_derive/mod.rs b/src/base/macros/src/command_derive/mod.rs index ee57321b..0846ee99 100644 --- a/src/base/macros/src/command_derive/mod.rs +++ b/src/base/macros/src/command_derive/mod.rs @@ -16,6 +16,7 @@ pub fn derive(input: TokenStream) -> TokenStream { fn expand(input: DeriveInput) -> SynResult { let ident = input.ident; + let register_fn = format_ident!("__{}_register_command", ident); let command_name = command_name(&input.attrs)?; let Data::Enum(data_enum) = input.data else { @@ -160,6 +161,15 @@ fn expand(input: DeriveInput) -> SynResult { vec![#(#path_entries),*] } } + + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_fn() { + ::temper_command_infra::register_static_command( + ::temper_command_infra::RegisteredCommand::of::<#ident>(), + ); + } }) } diff --git a/src/command-infra/Cargo.toml b/src/command-infra/Cargo.toml index fabc6da4..b43b7e7b 100644 --- a/src/command-infra/Cargo.toml +++ b/src/command-infra/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] bevy_ecs = { workspace = true } +ctor = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs index 87fc5564..5c2c6af4 100644 --- a/src/command-infra/src/ecs.rs +++ b/src/command-infra/src/ecs.rs @@ -1,7 +1,12 @@ +use std::sync::{LazyLock, RwLock}; + use bevy_ecs::prelude::{Component, Entity, Message, Resource}; use crate::{CommandGraph, CommandPath, CommandSpec}; +static STATIC_COMMANDS: LazyLock>> = + LazyLock::new(|| RwLock::new(Vec::new())); + #[derive(Clone, Debug)] pub struct RegisteredCommand { pub name: &'static str, @@ -17,12 +22,31 @@ impl RegisteredCommand { } } +pub fn register_static_command(command: RegisteredCommand) { + if let Ok(mut commands) = STATIC_COMMANDS.write() { + commands.push(command); + } +} + +pub fn static_commands() -> Vec { + STATIC_COMMANDS + .read() + .map(|commands| commands.clone()) + .unwrap_or_default() +} + #[derive(Default, Resource)] pub struct CommandRegistry { commands: Vec, } impl CommandRegistry { + pub fn from_static_commands() -> Self { + Self { + commands: static_commands(), + } + } + pub fn register(&mut self) { self.commands.push(RegisteredCommand::of::()); } diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index 95e1dd11..eac40827 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -5,7 +5,9 @@ pub mod graph; pub mod metadata; pub mod reader; +pub use ctor; pub use ecs::{CommandRegistry, PlayerCommandGraph, RebuildCommandGraph, RegisteredCommand}; +pub use ecs::{register_static_command, static_commands}; pub use error::ParseError; pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; pub use metadata::{ diff --git a/src/command-infra/tests/ecs_registry.rs b/src/command-infra/tests/ecs_registry.rs index 306b46e1..36ff0cbf 100644 --- a/src/command-infra/tests/ecs_registry.rs +++ b/src/command-infra/tests/ecs_registry.rs @@ -1,6 +1,6 @@ use bevy_ecs::entity::Entity; -use temper_command_infra::CommandRegistry; use temper_command_infra::args::{EntityArg, PositionArg}; +use temper_command_infra::{CommandRegistry, CommandSpec, static_commands}; use temper_macros::Command; #[derive(Debug, PartialEq, Command)] @@ -29,3 +29,15 @@ fn registry_builds_graph_from_registered_commands() { .all(|child| graph.nodes[*child].executable) ); } + +#[test] +fn derived_commands_register_static_metadata() { + let commands = static_commands(); + + assert!( + commands + .iter() + .any(|command| command.name == TpCommand::NAME), + "derived command was not registered in static command metadata" + ); +} diff --git a/src/default_commands/Cargo.toml b/src/default_commands/Cargo.toml index fb4ec906..763b0572 100644 --- a/src/default_commands/Cargo.toml +++ b/src/default_commands/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] temper-messages = { workspace = true } temper-components = { workspace = true } +temper-command-infra = { workspace = true } temper-commands = { workspace = true } temper-macros = { workspace = true } temper-text = { workspace = true } diff --git a/src/default_commands/src/lib.rs b/src/default_commands/src/lib.rs index 69fec855..5e8ec570 100644 --- a/src/default_commands/src/lib.rs +++ b/src/default_commands/src/lib.rs @@ -6,6 +6,7 @@ pub mod fly; pub mod gamemode; mod kill; pub mod nested; +pub mod new; pub mod op; pub mod permissions; mod say; diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs new file mode 100644 index 00000000..9ece253d --- /dev/null +++ b/src/default_commands/src/new/mod.rs @@ -0,0 +1 @@ +mod tp; diff --git a/src/default_commands/src/new/tp.rs b/src/default_commands/src/new/tp.rs new file mode 100644 index 00000000..dc3f67f8 --- /dev/null +++ b/src/default_commands/src/new/tp.rs @@ -0,0 +1,22 @@ +use temper_command_infra::args::{EntityArg, PositionArg}; +use temper_macros::Command; + +#[derive(Command)] +#[command("tp")] +#[allow(dead_code)] +enum TpCommand { + TpToPos { + location: PositionArg, + }, + TpToEntity { + destination: EntityArg, + }, + TpEntityToPos { + target: EntityArg, + location: PositionArg, + }, + TpEntityToEntity { + target: EntityArg, + destination: EntityArg, + }, +} diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs new file mode 100644 index 00000000..ff854fa1 --- /dev/null +++ b/src/default_commands/tests/new_command_registry.rs @@ -0,0 +1,12 @@ +use bevy_ecs::prelude::World; +use temper_command_infra::CommandRegistry; + +#[test] +fn default_commands_register_new_tp_metadata() { + temper_default_commands::init(); + + let registry = CommandRegistry::from_static_commands(); + let paths = registry.paths_for_player(World::new().spawn_empty().id()); + + assert!(paths.iter().any(|path| path.root == "tp")); +} diff --git a/src/resources/src/lib.rs b/src/resources/src/lib.rs index 9fd9b243..03c67c16 100644 --- a/src/resources/src/lib.rs +++ b/src/resources/src/lib.rs @@ -30,7 +30,7 @@ pub fn register_resources( world.insert_resource(WorldTime::default()); world.insert_resource(ServerCommandReceiver(server_command_recv)); world.insert_resource(PhysicalRegistry::new()); - world.insert_resource(CommandRegistry::default()); + world.insert_resource(CommandRegistry::from_static_commands()); world.insert_resource(BossBarResource::new()); } From bffac6706b3539671830b1035cfb1b0b1a938cb9 Mon Sep 17 00:00:00 2001 From: ReCore Date: Mon, 22 Jun 2026 16:14:23 +0930 Subject: [PATCH 05/30] Implemented the tp command and tweaked the api a little --- src/base/macros/src/command_derive/mod.rs | 10 ++ src/command-infra/Cargo.toml | 5 + src/command-infra/src/args/entity.rs | 35 ++++ src/command-infra/src/args/position.rs | 24 +++ src/command-infra/src/ecs.rs | 102 +++++++++++- src/command-infra/src/lib.rs | 10 +- src/command-infra/tests/derive_command.rs | 29 +++- src/command-infra/tests/ecs_registry.rs | 86 +++++++++- src/default_commands/Cargo.toml | 2 +- src/default_commands/src/bossbar.rs | 2 +- src/default_commands/src/deop.rs | 4 +- src/default_commands/src/echo.rs | 2 +- src/default_commands/src/kill.rs | 2 +- src/default_commands/src/new/tp.rs | 173 ++++++++++++++++++++ src/default_commands/src/op.rs | 4 +- src/default_commands/src/say.rs | 2 +- src/default_commands/src/spawn.rs | 4 +- src/default_commands/src/time.rs | 4 +- src/default_commands/src/tp.rs | 4 +- src/game_systems/Cargo.toml | 1 + src/game_systems/src/lib.rs | 5 +- src/game_systems/src/packets/src/command.rs | 12 ++ src/messages/src/lib.rs | 3 +- 23 files changed, 500 insertions(+), 25 deletions(-) diff --git a/src/base/macros/src/command_derive/mod.rs b/src/base/macros/src/command_derive/mod.rs index 0846ee99..22dd20f1 100644 --- a/src/base/macros/src/command_derive/mod.rs +++ b/src/base/macros/src/command_derive/mod.rs @@ -17,6 +17,7 @@ pub fn derive(input: TokenStream) -> TokenStream { fn expand(input: DeriveInput) -> SynResult { let ident = input.ident; let register_fn = format_ident!("__{}_register_command", ident); + let register_system_fn = format_ident!("__{}_register_command_system", ident); let command_name = command_name(&input.attrs)?; let Data::Enum(data_enum) = input.data else { @@ -170,6 +171,15 @@ fn expand(input: DeriveInput) -> SynResult { ::temper_command_infra::RegisteredCommand::of::<#ident>(), ); } + + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_system_fn() { + ::temper_command_infra::add_system( + ::temper_command_infra::dispatch_command::<#ident>, + ); + } }) } diff --git a/src/command-infra/Cargo.toml b/src/command-infra/Cargo.toml index b43b7e7b..7e7c0056 100644 --- a/src/command-infra/Cargo.toml +++ b/src/command-infra/Cargo.toml @@ -6,7 +6,12 @@ edition = "2024" [dependencies] bevy_ecs = { workspace = true } ctor = { workspace = true } +temper-core = { workspace = true } +temper-components = { workspace = true } +temper-text = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } [dev-dependencies] temper-macros = { workspace = true } diff --git a/src/command-infra/src/args/entity.rs b/src/command-infra/src/args/entity.rs index 94fdcd9f..c6184d79 100644 --- a/src/command-infra/src/args/entity.rs +++ b/src/command-infra/src/args/entity.rs @@ -1,5 +1,10 @@ use std::ops::Deref; +use bevy_ecs::entity::Entity; +use temper_components::entity_identity::Identity; +use temper_components::player::player_marker::PlayerMarker; +use uuid::Uuid; + use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind}; #[derive(Clone, Debug, Eq, PartialEq)] @@ -13,6 +18,36 @@ impl Deref for EntityArg { } } +impl EntityArg { + pub fn resolve<'a>( + &self, + iter: impl Iterator)>, + ) -> Vec { + match &**self { + "@e" => iter.map(|(entity, _, _)| entity).collect(), + "@a" => iter + .filter_map(|(entity, _, marker)| marker.map(|_| entity)) + .collect(), + "@r" => iter + .filter_map(|(entity, _, marker)| marker.map(|_| entity)) + .take(1) + .collect(), + raw => { + let uuid = Uuid::parse_str(raw).ok(); + + iter.filter_map(|(entity, identity, _)| { + if identity.name.as_deref() == Some(raw) || Some(identity.uuid) == uuid { + Some(entity) + } else { + None + } + }) + .collect() + } + } + } +} + impl CommandArg for EntityArg { type Raw<'a> = &'a str; diff --git a/src/command-infra/src/args/position.rs b/src/command-infra/src/args/position.rs index e096125d..620a518c 100644 --- a/src/command-infra/src/args/position.rs +++ b/src/command-infra/src/args/position.rs @@ -1,3 +1,5 @@ +use temper_components::player::position::Position; + use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind}; #[derive(Clone, Debug, Eq, PartialEq)] @@ -7,6 +9,16 @@ pub struct PositionArg { pub z: String, } +impl PositionArg { + pub fn resolve(&self, base: &Position) -> Position { + Position::new( + resolve_coord(&self.x, base.x), + resolve_coord(&self.y, base.y), + resolve_coord(&self.z, base.z), + ) + } +} + impl CommandArg for PositionArg { type Raw<'a> = (&'a str, &'a str, &'a str); @@ -56,3 +68,15 @@ fn is_coord(span: &str) -> bool { span.parse::().is_ok() } } + +fn resolve_coord(coord: &str, base: f64) -> f64 { + if let Some(relative) = coord.strip_prefix('~') { + if relative.is_empty() { + base + } else { + base + relative.parse::().unwrap_or(0.0) + } + } else { + coord.parse::().unwrap_or(base) + } +} diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs index 5c2c6af4..d56fa391 100644 --- a/src/command-infra/src/ecs.rs +++ b/src/command-infra/src/ecs.rs @@ -1,12 +1,24 @@ use std::sync::{LazyLock, RwLock}; +use std::{cell::RefCell, sync::Arc}; -use bevy_ecs::prelude::{Component, Entity, Message, Resource}; +use bevy_ecs::prelude::{ + Component, Entity, IntoScheduleConfigs, Message, MessageReader, Resource, Schedule, +}; +use bevy_ecs::schedule::ScheduleConfigs; +use bevy_ecs::system::{ScheduleSystem, SystemParam}; +use temper_core::mq; +use temper_text::{NamedColor, TextComponentBuilder}; +use tracing::info; -use crate::{CommandGraph, CommandPath, CommandSpec}; +use crate::{CommandGraph, CommandPath, CommandSpec, ParseError}; static STATIC_COMMANDS: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); +thread_local! { + static SYSTEMS_TO_BE_REGISTERED: RefCell>> = RefCell::new(Vec::new()); +} + #[derive(Clone, Debug)] pub struct RegisteredCommand { pub name: &'static str, @@ -35,6 +47,63 @@ pub fn static_commands() -> Vec { .unwrap_or_default() } +pub fn add_system(system: impl IntoScheduleConfigs) { + SYSTEMS_TO_BE_REGISTERED.with(|systems| { + systems.borrow_mut().push(system.into_configs()); + }); +} + +pub fn register_command_systems(schedule: &mut Schedule) { + SYSTEMS_TO_BE_REGISTERED.with(|systems| { + let mut systems = systems.borrow_mut(); + while let Some(system) = systems.pop() { + schedule.add_systems(system); + } + }); +} + +pub trait CommandHandler: CommandSpec + Sized + Send + Sync + 'static { + type SystemParam<'w, 's>: SystemParam; + + fn handle<'w, 's>(self, source: CommandSource, params: &mut Self::SystemParam<'w, 's>); + + fn handle_parse_error<'w, 's>( + source: CommandSource, + error: ParseError, + _params: &mut Self::SystemParam<'w, 's>, + ) { + send_parse_error(source, &error); + } +} + +pub fn send_parse_error(source: CommandSource, error: &ParseError) { + let message = TextComponentBuilder::new(format!("failed parsing command: {}", error.message)) + .color(NamedColor::Red) + .build(); + + match source { + CommandSource::Player(entity) => mq::queue(message, false, entity), + CommandSource::Server => info!("{}", message.to_plain_text()), + } +} + +pub fn dispatch_command( + mut commands: MessageReader, + mut params: C::SystemParam<'_, '_>, +) { + for event in commands.read() { + if command_root(&event.input) != Some(C::NAME) { + continue; + } + + let input = command_args(&event.input, C::NAME); + match C::parse(input) { + Ok(command) => command.handle(event.source, &mut params), + Err(error) => C::handle_parse_error(event.source, error, &mut params), + } + } +} + #[derive(Default, Resource)] pub struct CommandRegistry { commands: Vec, @@ -59,6 +128,15 @@ impl CommandRegistry { &self.commands } + pub fn owns_input(&self, input: &str) -> bool { + command_root(input).is_some_and(|input_root| { + self.commands + .iter() + .filter_map(|command| command_root(command.name)) + .any(|command_root| command_root == input_root) + }) + } + pub fn paths_for_player(&self, _player: Entity) -> Vec { self.commands .iter() @@ -71,6 +149,26 @@ impl CommandRegistry { } } +fn command_root(input: &str) -> Option<&str> { + input.split_whitespace().next() +} + +fn command_args<'a>(input: &'a str, root: &str) -> &'a str { + input.strip_prefix(root).unwrap_or(input).trim_start() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandSource { + Player(Entity), + Server, +} + +#[derive(Message, Clone, Debug)] +pub struct NewCommandDispatched { + pub input: Arc, + pub source: CommandSource, +} + #[derive(Component, Clone, Debug, Eq, PartialEq)] pub struct PlayerCommandGraph { pub graph: CommandGraph, diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index eac40827..3f3efe0c 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -6,8 +6,14 @@ pub mod metadata; pub mod reader; pub use ctor; -pub use ecs::{CommandRegistry, PlayerCommandGraph, RebuildCommandGraph, RegisteredCommand}; -pub use ecs::{register_static_command, static_commands}; +pub use ecs::{ + CommandHandler, CommandRegistry, CommandSource, NewCommandDispatched, PlayerCommandGraph, + RebuildCommandGraph, RegisteredCommand, +}; +pub use ecs::{ + add_system, dispatch_command, register_command_systems, register_static_command, + send_parse_error, static_commands, +}; pub use error::ParseError; pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; pub use metadata::{ diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index c11863ee..c69e0dfb 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -1,7 +1,9 @@ use temper_command_infra::args::{ EntityArg, GreedyStringArg, IntegerArg, PositionArg, SingleWordArg, }; -use temper_command_infra::{CommandGraph, CommandNodeKind, CommandSpec}; +use temper_command_infra::{ + CommandGraph, CommandHandler, CommandNodeKind, CommandSource, CommandSpec, +}; use temper_macros::Command; #[derive(Debug, PartialEq, Command)] @@ -51,6 +53,31 @@ enum RenameCommand { }, } +macro_rules! impl_noop_handler { + ($($command:ty),* $(,)?) => { + $( + impl CommandHandler for $command { + type SystemParam<'w, 's> = (); + + fn handle<'w, 's>( + self, + _source: CommandSource, + _params: &mut Self::SystemParam<'w, 's>, + ) { + } + } + )* + }; +} + +impl_noop_handler!( + TpCommand, + OverlapCommand, + SayCommand, + NumberCommand, + RenameCommand, +); + #[test] fn tp_to_position_parses() { let command = TpCommand::parse("~ ~ ~").unwrap(); diff --git a/src/command-infra/tests/ecs_registry.rs b/src/command-infra/tests/ecs_registry.rs index 36ff0cbf..cc5b8eab 100644 --- a/src/command-infra/tests/ecs_registry.rs +++ b/src/command-infra/tests/ecs_registry.rs @@ -1,6 +1,12 @@ use bevy_ecs::entity::Entity; -use temper_command_infra::args::{EntityArg, PositionArg}; -use temper_command_infra::{CommandRegistry, CommandSpec, static_commands}; +use bevy_ecs::message::MessageRegistry; +use bevy_ecs::prelude::{IntoScheduleConfigs, MessageWriter, ResMut, Resource, Schedule, World}; +use std::sync::Arc; +use temper_command_infra::args::{EntityArg, PositionArg, SingleWordArg}; +use temper_command_infra::{ + CommandHandler, CommandRegistry, CommandSource, CommandSpec, NewCommandDispatched, ParseError, + dispatch_command, static_commands, +}; use temper_macros::Command; #[derive(Debug, PartialEq, Command)] @@ -10,6 +16,61 @@ enum TpCommand { TpToEntity { destination: EntityArg }, } +impl CommandHandler for TpCommand { + type SystemParam<'w, 's> = (); + + fn handle<'w, 's>(self, _source: CommandSource, _params: &mut Self::SystemParam<'w, 's>) {} +} + +#[derive(Debug, PartialEq, Command)] +#[command("demo")] +enum DemoCommand { + Word { value: SingleWordArg }, +} + +#[derive(Default, Resource)] +struct HandledCommands { + handled: usize, + parse_errors: usize, + last_source: Option, + last_value: Option, +} + +impl CommandHandler for DemoCommand { + type SystemParam<'w, 's> = ResMut<'w, HandledCommands>; + + fn handle<'w, 's>(self, source: CommandSource, params: &mut Self::SystemParam<'w, 's>) { + let DemoCommand::Word { value } = self; + + params.handled += 1; + params.last_source = Some(source); + params.last_value = Some(value.to_string()); + } + + fn handle_parse_error<'w, 's>( + _source: CommandSource, + _error: ParseError, + params: &mut Self::SystemParam<'w, 's>, + ) { + params.parse_errors += 1; + } +} + +fn emit_demo_commands(mut writer: MessageWriter) { + writer.write(NewCommandDispatched { + input: Arc::from("demo hello"), + source: CommandSource::Player(Entity::PLACEHOLDER), + }); + writer.write(NewCommandDispatched { + input: Arc::from("demo"), + source: CommandSource::Player(Entity::PLACEHOLDER), + }); + writer.write(NewCommandDispatched { + input: Arc::from("other hello"), + source: CommandSource::Player(Entity::PLACEHOLDER), + }); +} + #[test] fn registry_builds_graph_from_registered_commands() { let mut registry = CommandRegistry::default(); @@ -30,6 +91,27 @@ fn registry_builds_graph_from_registered_commands() { ); } +#[test] +fn dispatch_command_calls_handler_trait_methods() { + let mut world = World::new(); + MessageRegistry::register_message::(&mut world); + world.init_resource::(); + + let mut schedule = Schedule::default(); + schedule.add_systems((emit_demo_commands, dispatch_command::).chain()); + schedule.run(&mut world); + + let handled = world.resource::(); + + assert_eq!(handled.handled, 1); + assert_eq!(handled.parse_errors, 1); + assert_eq!( + handled.last_source, + Some(CommandSource::Player(Entity::PLACEHOLDER)) + ); + assert_eq!(handled.last_value.as_deref(), Some("hello")); +} + #[test] fn derived_commands_register_static_metadata() { let commands = static_commands(); diff --git a/src/default_commands/Cargo.toml b/src/default_commands/Cargo.toml index 763b0572..31448951 100644 --- a/src/default_commands/Cargo.toml +++ b/src/default_commands/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "temper-default-commands" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] temper-messages = { workspace = true } diff --git a/src/default_commands/src/bossbar.rs b/src/default_commands/src/bossbar.rs index daf4bd9a..94c48724 100644 --- a/src/default_commands/src/bossbar.rs +++ b/src/default_commands/src/bossbar.rs @@ -61,9 +61,9 @@ use bevy_ecs::entity::Entity; use bevy_ecs::prelude::Query; use bevy_ecs::system::ResMut; +use temper_commands::Sender; use temper_commands::arg::bossbar_set::BossbarSetOptions; use temper_commands::arg::primitive::string::{GreedyString, QuotableString}; -use temper_commands::Sender; use temper_components::entity_identity::Identity; use temper_components::player::bossbar_sender::BossbarSender; use temper_components::player::player_marker::PlayerMarker; diff --git a/src/default_commands/src/deop.rs b/src/default_commands/src/deop.rs index 81ebf09c..18c7e164 100644 --- a/src/default_commands/src/deop.rs +++ b/src/default_commands/src/deop.rs @@ -1,11 +1,11 @@ use bevy_ecs::prelude::{Entity, Query}; -use temper_commands::arg::entities::EntityArgument; use temper_commands::Sender; +use temper_commands::arg::entities::EntityArgument; use temper_components::entity_identity::Identity; use temper_components::player::player_marker::PlayerMarker; use temper_macros::command; -use temper_permissions::player::PlayerPermission; use temper_permissions::Permissions::ALL; +use temper_permissions::player::PlayerPermission; use temper_text::TextComponent; #[command("deop")] diff --git a/src/default_commands/src/echo.rs b/src/default_commands/src/echo.rs index 520781a7..e4753bb2 100644 --- a/src/default_commands/src/echo.rs +++ b/src/default_commands/src/echo.rs @@ -1,5 +1,5 @@ use bevy_ecs::prelude::*; -use temper_commands::{arg::primitive::string::GreedyString, Sender}; +use temper_commands::{Sender, arg::primitive::string::GreedyString}; use temper_components::entity_identity::Identity; use temper_macros::command; use temper_text::{TextComponent, TextComponentBuilder}; diff --git a/src/default_commands/src/kill.rs b/src/default_commands/src/kill.rs index cb6ffd03..68276b18 100644 --- a/src/default_commands/src/kill.rs +++ b/src/default_commands/src/kill.rs @@ -1,7 +1,7 @@ use bevy_ecs::prelude::{Entity, MessageWriter, Query}; -use temper_commands::arg::entities::EntityArgument; use temper_commands::Sender; use temper_commands::Sender::Player; +use temper_commands::arg::entities::EntityArgument; use temper_components::entity_identity::Identity; use temper_components::player::player_marker::PlayerMarker; use temper_macros::command; diff --git a/src/default_commands/src/new/tp.rs b/src/default_commands/src/new/tp.rs index dc3f67f8..16cc0a82 100644 --- a/src/default_commands/src/new/tp.rs +++ b/src/default_commands/src/new/tp.rs @@ -1,5 +1,17 @@ +use bevy_ecs::entity::Entity; +use bevy_ecs::prelude::{MessageWriter, Query}; +use temper_command_infra::CommandSource::*; use temper_command_infra::args::{EntityArg, PositionArg}; +use temper_command_infra::{CommandHandler, CommandSource}; +use temper_components::entity_identity::Identity; +use temper_components::player::player_marker::PlayerMarker; +use temper_components::player::position::Position; +use temper_components::player::rotation::Rotation; +use temper_core::mq; use temper_macros::Command; +use temper_messages::teleport_player::TeleportPlayer; +use temper_text::TextComponent; +use tracing::info; #[derive(Command)] #[command("tp")] @@ -20,3 +32,164 @@ enum TpCommand { destination: EntityArg, }, } + +impl CommandHandler for TpCommand { + type SystemParam<'w, 's> = ( + Query<'w, 's, (&'static Rotation, &'static Position)>, + Query<'w, 's, (Entity, &'static Identity, Option<&'static PlayerMarker>)>, + MessageWriter<'w, TeleportPlayer>, + ); + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let (positions, identities, teleports) = params; + execute_tp(source, self, positions, identities, teleports); + } +} + +fn execute_tp( + source: CommandSource, + command: TpCommand, + positions: &Query<(&Rotation, &Position)>, + identities: &Query<(Entity, &Identity, Option<&PlayerMarker>)>, + teleports: &mut MessageWriter, +) { + match command { + TpCommand::TpToPos { location } => { + let Player(player) = source else { + send_message(source, "This command can only be used by players.".into()); + return; + }; + + let Ok((rotation, base_position)) = positions.get(player) else { + send_message(source, "Could not find your player entity.".into()); + return; + }; + + let destination = location.resolve(base_position); + teleport_entity(player, *rotation, destination, teleports); + send_message(source, format!("Teleported to ({}).", destination).into()); + } + TpCommand::TpToEntity { destination } => { + let Player(player) = source else { + send_message(source, "This command can only be used by players.".into()); + return; + }; + + let targets = destination.resolve(identities.iter()); + if targets.len() != 1 { + send_message( + source, + "You must specify exactly one target to teleport to.".into(), + ); + return; + } + + let Ok((rotation, _)) = positions.get(player) else { + send_message(source, "Could not find your player entity.".into()); + return; + }; + + let Ok((_, target_position)) = positions.get(targets[0]) else { + send_message(source, "Could not find target entity position.".into()); + return; + }; + + teleport_entity(player, *rotation, *target_position, teleports); + send_message( + source, + format!("Teleported to the entity at {}.", target_position).into(), + ); + } + TpCommand::TpEntityToPos { target, location } => { + let base_position = if let Player(entity) = source + && let Ok((_, position)) = positions.get(entity) + { + *position + } else { + Position::new(0.0, 0.0, 0.0) + }; + let destination = location.resolve(&base_position); + let targets = target.resolve(identities.iter()); + + if targets.is_empty() { + send_message(source, "No entities matched the target.".into()); + return; + } + + for entity in targets { + let Ok((rotation, _)) = positions.get(entity) else { + continue; + }; + teleport_entity(entity, *rotation, destination, teleports); + } + + send_message( + source, + format!("Teleported entities to ({}).", destination).into(), + ); + } + TpCommand::TpEntityToEntity { + target, + destination, + } => { + let targets = target.resolve(identities.iter()); + let destinations = destination.resolve(identities.iter()); + + if targets.is_empty() { + send_message(source, "No entities matched the target.".into()); + return; + } + + if destinations.len() != 1 { + send_message( + source, + "You must specify exactly one destination entity.".into(), + ); + return; + } + + let Ok((_, destination_position)) = positions.get(destinations[0]) else { + send_message(source, "Could not find destination entity position.".into()); + return; + }; + + for entity in targets { + let Ok((rotation, _)) = positions.get(entity) else { + continue; + }; + teleport_entity(entity, *rotation, *destination_position, teleports); + } + + send_message( + source, + format!("Teleported entities to {}.", destination_position).into(), + ); + } + } +} + +fn teleport_entity( + entity: Entity, + rotation: Rotation, + destination: Position, + teleports: &mut MessageWriter, +) { + teleports.write(TeleportPlayer { + entity, + x: destination.x, + y: destination.y, + z: destination.z, + vel_x: 0.0, + vel_y: 0.0, + vel_z: 0.0, + yaw: rotation.yaw, + pitch: rotation.pitch, + }); +} + +fn send_message(source: CommandSource, message: TextComponent) { + match source { + Player(entity) => mq::queue(message, false, entity), + Server => info!("{}", message.to_plain_text()), + } +} diff --git a/src/default_commands/src/op.rs b/src/default_commands/src/op.rs index cbfd7f35..51e7ebfc 100644 --- a/src/default_commands/src/op.rs +++ b/src/default_commands/src/op.rs @@ -1,12 +1,12 @@ use bevy_ecs::prelude::{Entity, Query}; -use temper_commands::arg::entities::EntityArgument; use temper_commands::Sender; +use temper_commands::arg::entities::EntityArgument; use temper_components::entity_identity::Identity; use temper_components::player::player_marker::PlayerMarker; use temper_macros::command; -use temper_permissions::player::PlayerPermission; use temper_permissions::Access::Allow; use temper_permissions::Permissions::ALL; +use temper_permissions::player::PlayerPermission; use temper_text::TextComponent; #[command("op")] diff --git a/src/default_commands/src/say.rs b/src/default_commands/src/say.rs index 5c0b5b3d..729393e3 100644 --- a/src/default_commands/src/say.rs +++ b/src/default_commands/src/say.rs @@ -1,6 +1,6 @@ use bevy_ecs::prelude::Query; -use temper_commands::arg::primitive::string::GreedyString; use temper_commands::Sender; +use temper_commands::arg::primitive::string::GreedyString; use temper_components::entity_identity::Identity; use temper_core::mq; use temper_macros::command; diff --git a/src/default_commands/src/spawn.rs b/src/default_commands/src/spawn.rs index 3b3e97df..65bb97ca 100644 --- a/src/default_commands/src/spawn.rs +++ b/src/default_commands/src/spawn.rs @@ -2,8 +2,8 @@ use bevy_ecs::prelude::MessageWriter; use bimap::BiMap; use lazy_static::lazy_static; use temper_commands::{ - arg::{primitive::PrimitiveArgument, utils::parser_error, CommandArgument, ParserResult}, CommandContext, Sender, Suggestion, + arg::{CommandArgument, ParserResult, primitive::PrimitiveArgument, utils::parser_error}, }; use temper_entities::entity_types::EntityTypeEnum; use temper_macros::command; @@ -114,7 +114,7 @@ impl CommandArgument for EntityTypeArg { None => { return Err(parser_error( format!("Unknown entity type: {}", str).as_str(), - )) + )); } }; diff --git a/src/default_commands/src/time.rs b/src/default_commands/src/time.rs index c9b4c9df..30916c42 100644 --- a/src/default_commands/src/time.rs +++ b/src/default_commands/src/time.rs @@ -1,14 +1,14 @@ use bevy_ecs::prelude::{Query, Res, ResMut}; -use temper_commands::arg::primitive::int::Integer; use temper_commands::Sender; +use temper_commands::arg::primitive::int::Integer; use temper_components::player::time::LastSentTimeUpdate; use temper_macros::command; use temper_resources::time::WorldTime; use temper_text::TextComponent; use temper_commands::{ - arg::{primitive::PrimitiveArgument, utils::parser_error, CommandArgument, ParserResult}, CommandContext, Suggestion, + arg::{CommandArgument, ParserResult, primitive::PrimitiveArgument, utils::parser_error}, }; #[derive(Debug, Clone, Copy)] diff --git a/src/default_commands/src/tp.rs b/src/default_commands/src/tp.rs index 260629be..7e00e839 100644 --- a/src/default_commands/src/tp.rs +++ b/src/default_commands/src/tp.rs @@ -1,9 +1,9 @@ use bevy_ecs::entity::Entity; use bevy_ecs::prelude::{MessageWriter, Query}; -use temper_commands::arg::entities::EntityArgument; -use temper_commands::arg::position::CommandPosition; use temper_commands::Sender; use temper_commands::Sender::Player; +use temper_commands::arg::entities::EntityArgument; +use temper_commands::arg::position::CommandPosition; use temper_components::entity_identity::Identity; use temper_components::player::player_marker::PlayerMarker; use temper_components::player::position::Position; diff --git a/src/game_systems/Cargo.toml b/src/game_systems/Cargo.toml index 89f34bd2..1dea442e 100644 --- a/src/game_systems/Cargo.toml +++ b/src/game_systems/Cargo.toml @@ -15,6 +15,7 @@ world = { path = "./src/world" } interactions = { path = "./src/interactions" } tempfile = { workspace = true } bevy_ecs = { workspace = true } +temper-command-infra = { workspace = true } temper-commands = { workspace = true } temper-config = { workspace = true } temper-scheduler = { workspace = true } diff --git a/src/game_systems/src/lib.rs b/src/game_systems/src/lib.rs index a6d8bdb7..79b9e6cc 100644 --- a/src/game_systems/src/lib.rs +++ b/src/game_systems/src/lib.rs @@ -1,7 +1,7 @@ use bevy_ecs::prelude::ApplyDeferred; use bevy_ecs::schedule::{ExecutorKind, IntoScheduleConfigs, Schedule, SystemSet}; use std::time::Duration; -use temper_commands::infrastructure::register_command_systems; +use temper_commands::infrastructure::register_command_systems as register_old_command_systems; use temper_scheduler::{MissedTickBehavior, Scheduler, TimedSchedule, drain_registered_schedules}; pub use background::lan_pinger::LanPinger; @@ -95,7 +95,8 @@ fn register_tick_systems(schedule: &mut Schedule) { schedule.add_systems(player::player_tp::teleport_player); schedule.add_systems(player::send_inventory_updates::handle_inventory_updates); - register_command_systems(schedule); + register_old_command_systems(schedule); + temper_command_infra::register_command_systems(schedule); schedule.add_systems(background::chunk_sending::handle.in_set(TickPhase::ChunkSending)); mobs::register_load_systems(schedule); diff --git a/src/game_systems/src/packets/src/command.rs b/src/game_systems/src/packets/src/command.rs index 240aa613..2cdabc02 100644 --- a/src/game_systems/src/packets/src/command.rs +++ b/src/game_systems/src/packets/src/command.rs @@ -1,4 +1,6 @@ use bevy_ecs::prelude::*; +use std::sync::Arc; +use temper_command_infra::{CommandRegistry, CommandSource, NewCommandDispatched}; use temper_commands::{ Sender, messages::{CommandDispatched, ResolvedCommandDispatched}, @@ -12,12 +14,22 @@ use tracing::info; pub fn handle( receiver: Res, + registry: Res, + mut new_dispatch_msgs: MessageWriter, mut dispatch_msgs: MessageWriter, mut resolved_dispatch_msgs: MessageWriter, state: Res, query: Query<&Identity>, ) { for (event, entity) in receiver.0.try_iter() { + if registry.owns_input(&event.command) { + new_dispatch_msgs.write(NewCommandDispatched { + input: Arc::from(event.command.clone()), + source: CommandSource::Player(entity), + }); + continue; + } + let sender = Sender::Player(entity); dispatch_msgs.write(CommandDispatched { command: event.command.clone(), diff --git a/src/messages/src/lib.rs b/src/messages/src/lib.rs index 51890263..3769b6c0 100644 --- a/src/messages/src/lib.rs +++ b/src/messages/src/lib.rs @@ -53,7 +53,7 @@ use crate::save_chunk_entities::SaveChunkEntities; use crate::teleport_player::TeleportPlayer; pub use block_break::BlockBrokenEvent; pub use block_interaction::BlockInteractMessage; -use temper_command_infra::RebuildCommandGraph; +use temper_command_infra::{NewCommandDispatched, RebuildCommandGraph}; use temper_commands::messages::{CommandDispatched, ResolvedCommandDispatched}; use world_change::WorldChange; @@ -63,6 +63,7 @@ pub fn register_messages(world: &mut World) { MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); + MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); From 64bca86ceda1b3068b124bccbbbc3fe11d6efcf4 Mon Sep 17 00:00:00 2001 From: ReCore Date: Mon, 22 Jun 2026 16:43:15 +0930 Subject: [PATCH 06/30] single/no args commands implemented --- src/base/macros/src/command_derive/mod.rs | 308 ++++++++++++------ src/command-infra/tests/derive_command.rs | 50 +++ src/default_commands/src/new/echo.rs | 41 +++ src/default_commands/src/new/mod.rs | 2 + src/default_commands/src/new/stop.rs | 36 ++ .../tests/new_command_registry.rs | 19 +- 6 files changed, 360 insertions(+), 96 deletions(-) create mode 100644 src/default_commands/src/new/echo.rs create mode 100644 src/default_commands/src/new/stop.rs diff --git a/src/base/macros/src/command_derive/mod.rs b/src/base/macros/src/command_derive/mod.rs index 22dd20f1..8e8f7bb7 100644 --- a/src/base/macros/src/command_derive/mod.rs +++ b/src/base/macros/src/command_derive/mod.rs @@ -1,8 +1,8 @@ use proc_macro::TokenStream; use quote::{format_ident, quote, quote_spanned}; use syn::{ - parse_macro_input, spanned::Spanned, Data, DeriveInput, Field, Fields, Ident, LitStr, - Result as SynResult, + parse_macro_input, spanned::Spanned, Data, DataEnum, DataStruct, DeriveInput, Field, Fields, + Ident, LitStr, Result as SynResult, }; pub fn derive(input: TokenStream) -> TokenStream { @@ -16,103 +16,53 @@ pub fn derive(input: TokenStream) -> TokenStream { fn expand(input: DeriveInput) -> SynResult { let ident = input.ident; - let register_fn = format_ident!("__{}_register_command", ident); - let register_system_fn = format_ident!("__{}_register_command_system", ident); let command_name = command_name(&input.attrs)?; - let Data::Enum(data_enum) = input.data else { - return Err(syn::Error::new( + match input.data { + Data::Enum(data_enum) => expand_enum(&ident, command_name, data_enum), + Data::Struct(data_struct) => expand_struct(&ident, command_name, data_struct), + Data::Union(_) => Err(syn::Error::new( ident.span(), - "Command can only be derived for enums", - )); - }; + "Command can only be derived for enums or structs", + )), + } +} +fn expand_enum( + ident: &Ident, + command_name: LitStr, + data_enum: DataEnum, +) -> SynResult { let mut parse_arms = Vec::new(); let mut path_entries = Vec::new(); let mut greedy_assertions = Vec::new(); for variant in data_enum.variants { let variant_ident = variant.ident; - let fields = match variant.fields { - Fields::Unnamed(fields) => { - VariantFields::Unnamed(fields.unnamed.into_iter().map(CommandField::from).collect()) - } - Fields::Named(fields) => VariantFields::Named( - fields - .named - .into_iter() - .map(|field| { - let ident = field.ident.clone().ok_or_else(|| { - syn::Error::new(field.span(), "named command fields must have names") - })?; - Ok(CommandField { - ident: Some(ident), - field, - }) - }) - .collect::>>()?, - ), - Fields::Unit => VariantFields::Unit, - }; + let fields = CommandFields::from_fields(variant.fields)?; + let field_parse = FieldParse::new(fields.fields(), ident)?; - let last_field_idx = fields.fields().len().saturating_sub(1); - let mut raw_bindings = Vec::new(); - let mut tuple_value_exprs = Vec::new(); - let mut named_value_exprs = Vec::new(); - let mut segments = Vec::new(); - - for (idx, command_field) in fields.fields().iter().enumerate() { - let arg_name = arg_name(command_field)?; - let field = &command_field.field; - let ty = &field.ty; - let raw_ident = format_ident!("__raw_{idx}"); + greedy_assertions.extend(field_parse.greedy_assertions); - raw_bindings.push(quote! { - let #raw_ident = <#ty as ::temper_command_infra::CommandArg>::recognize(__reader)?; - }); - - tuple_value_exprs.push(quote! { - <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? - }); - - if let Some(field_ident) = &command_field.ident { - named_value_exprs.push(quote! { - #field_ident: <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? - }); + let constructor = match &fields { + CommandFields::Unnamed(_) => { + let values = &field_parse.tuple_values; + quote! { + Self::#variant_ident(#(#values),*) + } } - - segments.push(quote! { - ::temper_command_infra::CommandPathSegment::argument( - #arg_name, - <#ty as ::temper_command_infra::CommandArg>::argument_spec(), - ) - }); - - if idx != last_field_idx { - greedy_assertions.push(quote_spanned! { ty.span() => - const _: () = assert!( - !matches!( - <#ty as ::temper_command_infra::CommandArg>::KIND, - ::temper_command_infra::ArgKind::GreedyTail - ), - "greedy-tail command args must be the final field in a command variant" - ); - }); + CommandFields::Named(_) => { + let values = &field_parse.named_values; + quote! { + Self::#variant_ident { #(#values),* } + } } - } - - let constructor = match &fields { - VariantFields::Unnamed(_) => quote! { - Self::#variant_ident(#(#tuple_value_exprs),*) - }, - VariantFields::Named(_) => quote! { - Self::#variant_ident { #(#named_value_exprs),* } - }, - VariantFields::Unit => quote! { + CommandFields::Unit => quote! { Self::#variant_ident }, }; + let raw_bindings = &field_parse.raw_bindings; parse_arms.push(quote! { { let __checkpoint = __reader.checkpoint(); @@ -135,12 +85,88 @@ fn expand(input: DeriveInput) -> SynResult { } }); + let segments = &field_parse.segments; path_entries.push(quote! { ::temper_command_infra::CommandPath::new(#command_name, vec![#(#segments),*]) }); } - Ok(quote! { + let parse_body = quote! { + let mut __best_error: Option<::temper_command_infra::ParseError> = None; + + #(#parse_arms)* + + Err(__best_error.unwrap_or_else(|| { + ::temper_command_infra::ParseError::expected(__reader.cursor(), "command variant") + })) + }; + + Ok(expand_command_impl( + ident, + command_name, + parse_body, + path_entries, + greedy_assertions, + )) +} + +fn expand_struct( + ident: &Ident, + command_name: LitStr, + data_struct: DataStruct, +) -> SynResult { + let fields = CommandFields::from_fields(data_struct.fields)?; + let field_parse = FieldParse::new(fields.fields(), ident)?; + let raw_bindings = &field_parse.raw_bindings; + + let constructor = match &fields { + CommandFields::Unnamed(_) => { + let values = &field_parse.tuple_values; + quote! { + Self(#(#values),*) + } + } + CommandFields::Named(_) => { + let values = &field_parse.named_values; + quote! { + Self { #(#values),* } + } + } + CommandFields::Unit => quote! { + Self + }, + }; + + let parse_body = quote! { + #(#raw_bindings)* + __reader.expect_end()?; + Ok(#constructor) + }; + + let segments = &field_parse.segments; + let path_entries = vec![quote! { + ::temper_command_infra::CommandPath::new(#command_name, vec![#(#segments),*]) + }]; + + Ok(expand_command_impl( + ident, + command_name, + parse_body, + path_entries, + field_parse.greedy_assertions, + )) +} + +fn expand_command_impl( + ident: &Ident, + command_name: LitStr, + parse_body: proc_macro2::TokenStream, + path_entries: Vec, + greedy_assertions: Vec, +) -> proc_macro2::TokenStream { + let registration = expand_registration(ident); + + quote! { #(#greedy_assertions)* impl ::temper_command_infra::CommandSpec for #ident { @@ -149,13 +175,7 @@ fn expand(input: DeriveInput) -> SynResult { fn parse_reader( __reader: &mut ::temper_command_infra::CommandReader<'_>, ) -> Result { - let mut __best_error: Option<::temper_command_infra::ParseError> = None; - - #(#parse_arms)* - - Err(__best_error.unwrap_or_else(|| { - ::temper_command_infra::ParseError::expected(__reader.cursor(), "command variant") - })) + #parse_body } fn paths() -> Vec<::temper_command_infra::CommandPath> { @@ -163,6 +183,15 @@ fn expand(input: DeriveInput) -> SynResult { } } + #registration + } +} + +fn expand_registration(ident: &Ident) -> proc_macro2::TokenStream { + let register_fn = format_ident!("__{}_register_command", ident); + let register_system_fn = format_ident!("__{}_register_command_system", ident); + + quote! { #[::temper_command_infra::ctor::ctor(unsafe)] #[allow(non_snake_case)] #[doc(hidden)] @@ -180,21 +209,112 @@ fn expand(input: DeriveInput) -> SynResult { ::temper_command_infra::dispatch_command::<#ident>, ); } - }) + } } -enum VariantFields { +enum CommandFields { Unnamed(Vec), Named(Vec), Unit, } -impl VariantFields { +impl CommandFields { + fn from_fields(fields: Fields) -> SynResult { + match fields { + Fields::Unnamed(fields) => Ok(Self::Unnamed( + fields.unnamed.into_iter().map(CommandField::from).collect(), + )), + Fields::Named(fields) => Ok(Self::Named( + fields + .named + .into_iter() + .map(|field| { + let ident = field.ident.clone().ok_or_else(|| { + syn::Error::new(field.span(), "named command fields must have names") + })?; + Ok(CommandField { + ident: Some(ident), + field, + }) + }) + .collect::>>()?, + )), + Fields::Unit => Ok(Self::Unit), + } + } + fn fields(&self) -> &[CommandField] { match self { - VariantFields::Unnamed(fields) | VariantFields::Named(fields) => fields, - VariantFields::Unit => &[], + CommandFields::Unnamed(fields) | CommandFields::Named(fields) => fields, + CommandFields::Unit => &[], + } + } +} + +struct FieldParse { + raw_bindings: Vec, + tuple_values: Vec, + named_values: Vec, + segments: Vec, + greedy_assertions: Vec, +} + +impl FieldParse { + fn new(fields: &[CommandField], _ident: &Ident) -> SynResult { + let last_field_idx = fields.len().saturating_sub(1); + let mut raw_bindings = Vec::new(); + let mut tuple_values = Vec::new(); + let mut named_values = Vec::new(); + let mut segments = Vec::new(); + let mut greedy_assertions = Vec::new(); + + for (idx, command_field) in fields.iter().enumerate() { + let arg_name = arg_name(command_field)?; + let field = &command_field.field; + let ty = &field.ty; + let raw_ident = format_ident!("__raw_{idx}"); + + raw_bindings.push(quote! { + let #raw_ident = <#ty as ::temper_command_infra::CommandArg>::recognize(__reader)?; + }); + + tuple_values.push(quote! { + <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + + if let Some(field_ident) = &command_field.ident { + named_values.push(quote! { + #field_ident: <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + } + + segments.push(quote! { + ::temper_command_infra::CommandPathSegment::argument( + #arg_name, + <#ty as ::temper_command_infra::CommandArg>::argument_spec(), + ) + }); + + if idx != last_field_idx { + greedy_assertions.push(quote_spanned! { ty.span() => + const _: () = assert!( + !matches!( + <#ty as ::temper_command_infra::CommandArg>::KIND, + ::temper_command_infra::ArgKind::GreedyTail + ), + "greedy-tail command args must be the final field in a command variant" + ); + }); + } } + + Ok(Self { + raw_bindings, + tuple_values, + named_values, + segments, + greedy_assertions, + }) } } diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index c69e0dfb..bf7d8c24 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -53,6 +53,16 @@ enum RenameCommand { }, } +#[derive(Debug, PartialEq, Command)] +#[command("stop")] +struct StopCommand; + +#[derive(Debug, PartialEq, Command)] +#[command("me")] +struct MeCommand { + action: GreedyStringArg, +} + macro_rules! impl_noop_handler { ($($command:ty),* $(,)?) => { $( @@ -76,6 +86,8 @@ impl_noop_handler!( SayCommand, NumberCommand, RenameCommand, + StopCommand, + MeCommand, ); #[test] @@ -214,3 +226,41 @@ fn arg_attribute_overrides_named_field_name() { assert_eq!(graph.nodes[name_idx].name.as_deref(), Some("display_name")); } + +#[test] +fn unit_struct_command_parses_without_args() { + let command = StopCommand::parse("").unwrap(); + + assert_eq!(command, StopCommand); + assert!(StopCommand::parse("extra").is_err()); +} + +#[test] +fn unit_struct_command_graph_executable_at_root_literal() { + let graph = CommandGraph::from_paths(&StopCommand::paths()); + let stop_idx = graph.nodes[graph.root_idx].children[0]; + let stop = &graph.nodes[stop_idx]; + + assert_eq!(stop.name.as_deref(), Some("stop")); + assert!(stop.children.is_empty()); + assert!(stop.executable); +} + +#[test] +fn named_struct_command_parses_single_arg_set() { + let command = MeCommand::parse("waves hello").unwrap(); + + assert_eq!(&*command.action, "waves hello"); +} + +#[test] +fn named_struct_command_uses_field_names_in_graph() { + let graph = CommandGraph::from_paths(&MeCommand::paths()); + let me_idx = graph.nodes[graph.root_idx].children[0]; + let action_idx = graph.nodes[me_idx].children[0]; + let action = &graph.nodes[action_idx]; + + assert_eq!(graph.nodes[me_idx].name.as_deref(), Some("me")); + assert_eq!(action.name.as_deref(), Some("action")); + assert!(action.executable); +} diff --git a/src/default_commands/src/new/echo.rs b/src/default_commands/src/new/echo.rs new file mode 100644 index 00000000..fefeffd0 --- /dev/null +++ b/src/default_commands/src/new/echo.rs @@ -0,0 +1,41 @@ +use bevy_ecs::prelude::Query; +use temper_command_infra::CommandSource::*; +use temper_command_infra::args::GreedyStringArg; +use temper_command_infra::{CommandHandler, CommandSource}; +use temper_components::entity_identity::Identity; +use temper_core::mq; +use temper_macros::Command; +use temper_text::{TextComponent, TextComponentBuilder}; +use tracing::info; + +#[derive(Command)] +#[command("echo")] +struct EchoCommand { + message: GreedyStringArg, +} + +impl CommandHandler for EchoCommand { + type SystemParam<'w, 's> = Query<'w, 's, &'static Identity>; + + fn handle(self, source: CommandSource, identities: &mut Self::SystemParam<'_, '_>) { + let username = match source { + Server => "Server".to_string(), + Player(entity) => identities + .get(entity) + .expect("sender does not exist") + .name + .as_ref() + .expect("No Player Name") + .clone(), + }; + + let message = TextComponentBuilder::new(format!("{username} said: ")) + .extra(TextComponent::from(self.message.to_string())) + .build(); + + match source { + Player(entity) => mq::queue(message, false, entity), + Server => info!("{}", message.to_plain_text()), + } + } +} diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs index 9ece253d..bd0c1fce 100644 --- a/src/default_commands/src/new/mod.rs +++ b/src/default_commands/src/new/mod.rs @@ -1 +1,3 @@ +mod echo; +mod stop; mod tp; diff --git a/src/default_commands/src/new/stop.rs b/src/default_commands/src/new/stop.rs new file mode 100644 index 00000000..fc81076b --- /dev/null +++ b/src/default_commands/src/new/stop.rs @@ -0,0 +1,36 @@ +use std::sync::atomic::Ordering::Relaxed; + +use bevy_ecs::prelude::Res; +use temper_command_infra::CommandSource::*; +use temper_command_infra::{CommandHandler, CommandSource}; +use temper_core::mq; +use temper_macros::Command; +use temper_state::GlobalStateResource; +use tracing::info; + +#[derive(Command)] +#[command("stop")] +struct StopCommand; + +impl CommandHandler for StopCommand { + type SystemParam<'w, 's> = Res<'w, GlobalStateResource>; + + fn handle(self, source: CommandSource, state: &mut Self::SystemParam<'_, '_>) { + if let Player(player) = source { + mq::queue( + "This command can only be used by the server.".into(), + false, + player, + ); + return; + } + + info!("Shutting down server..."); + state + .0 + .world + .sync() + .expect("Failed to sync world before shutdown"); + state.0.shut_down.store(true, Relaxed); + } +} diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs index ff854fa1..06350c30 100644 --- a/src/default_commands/tests/new_command_registry.rs +++ b/src/default_commands/tests/new_command_registry.rs @@ -1,12 +1,27 @@ use bevy_ecs::prelude::World; -use temper_command_infra::CommandRegistry; +use temper_command_infra::{CommandPathSegment, CommandRegistry}; #[test] -fn default_commands_register_new_tp_metadata() { +fn default_commands_register_new_metadata() { temper_default_commands::init(); let registry = CommandRegistry::from_static_commands(); let paths = registry.paths_for_player(World::new().spawn_empty().id()); assert!(paths.iter().any(|path| path.root == "tp")); + assert!(paths.iter().any(|path| path.root == "stop")); + assert!(paths.iter().any(|path| path.root == "echo")); + + let stop = paths.iter().find(|path| path.root == "stop").unwrap(); + let echo = paths.iter().find(|path| path.root == "echo").unwrap(); + + assert!(stop.segments.is_empty()); + assert_eq!(echo.segments.len(), 1); + assert!(matches!( + echo.segments[0], + CommandPathSegment::Argument { + name: "message", + .. + } + )); } From c4c65028172a13250c674adea9e0dbfe0e91ea4a Mon Sep 17 00:00:00 2001 From: ReCore Date: Mon, 22 Jun 2026 17:03:31 +0930 Subject: [PATCH 07/30] Fix for invalid graph being sent --- src/commands/src/arg/primitive/mod.rs | 20 ++++++++++ src/default_commands/src/new/echo.rs | 2 +- src/game_systems/src/player/Cargo.toml | 1 + .../src/player/src/emit_player_joined.rs | 40 +++++++++++++++++++ src/net/protocol/src/outgoing/commands.rs | 24 +++++------ src/net/runtime/src/conn_init/login.rs | 13 +----- 6 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/commands/src/arg/primitive/mod.rs b/src/commands/src/arg/primitive/mod.rs index 995f08e8..eda1d656 100644 --- a/src/commands/src/arg/primitive/mod.rs +++ b/src/commands/src/arg/primitive/mod.rs @@ -28,6 +28,25 @@ pub mod int; pub mod long; pub mod string; +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct EntityArgumentFlags { + pub single: bool, + pub players_only: bool, +} + +impl NetEncode for EntityArgumentFlags { + fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { + let mut flags = 0u8; + if self.single { + flags |= 0x01; + } + if self.players_only { + flags |= 0x02; + } + flags.encode(writer, opts) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct PrimitiveArgument { pub argument_type: PrimitiveArgumentType, @@ -94,6 +113,7 @@ pub enum PrimitiveArgumentFlags { Int(IntArgumentFlags), Long(LongArgumentFlags), String(StringArgumentType), + Entity(EntityArgumentFlags), } #[derive(Clone, Debug, PartialEq, Ordinalize)] diff --git a/src/default_commands/src/new/echo.rs b/src/default_commands/src/new/echo.rs index fefeffd0..fd641481 100644 --- a/src/default_commands/src/new/echo.rs +++ b/src/default_commands/src/new/echo.rs @@ -28,7 +28,7 @@ impl CommandHandler for EchoCommand { .expect("No Player Name") .clone(), }; - + let message = TextComponentBuilder::new(format!("{username} said: ")) .extra(TextComponent::from(self.message.to_string())) .build(); diff --git a/src/game_systems/src/player/Cargo.toml b/src/game_systems/src/player/Cargo.toml index bb4de5b1..a1e87381 100644 --- a/src/game_systems/src/player/Cargo.toml +++ b/src/game_systems/src/player/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] bevy_ecs = { workspace = true } +temper-command-infra = { workspace = true } temper-components = { workspace = true } temper-config = { workspace = true } temper-inventories = { workspace = true } diff --git a/src/game_systems/src/player/src/emit_player_joined.rs b/src/game_systems/src/player/src/emit_player_joined.rs index a55d9e61..01eda660 100644 --- a/src/game_systems/src/player/src/emit_player_joined.rs +++ b/src/game_systems/src/player/src/emit_player_joined.rs @@ -8,6 +8,7 @@ //! This ensures `PlayerJoined` events only fire when the entity is queryable. use bevy_ecs::prelude::{Added, Commands, Entity, MessageWriter, Query}; +use temper_command_infra::RebuildCommandGraph; use temper_components::player::pending_events::PendingPlayerJoin; use temper_messages::player_join::PlayerJoined; use tracing::trace; @@ -19,6 +20,7 @@ use tracing::trace; pub fn emit_player_joined( query: Query<(Entity, &PendingPlayerJoin), Added>, mut events: MessageWriter, + mut command_graph_rebuilds: MessageWriter, mut commands: Commands, ) { for (entity, pending) in query.iter() { @@ -32,9 +34,47 @@ pub fn emit_player_joined( identity: pending.0.clone(), entity, }); + command_graph_rebuilds.write(RebuildCommandGraph { player: entity }); // Remove the marker so we don't fire again. // This removal is deferred, but that's fine - Added only fires once per addition. commands.entity(entity).remove::(); } } + +#[cfg(test)] +mod tests { + use bevy_ecs::message::{MessageRegistry, Messages}; + use bevy_ecs::prelude::{Schedule, World}; + use temper_command_infra::RebuildCommandGraph; + use temper_components::entity_identity::Identity; + + use super::*; + + #[test] + fn emits_command_graph_rebuild_for_joined_player() { + let mut world = World::new(); + MessageRegistry::register_message::(&mut world); + MessageRegistry::register_message::(&mut world); + + let identity = Identity::new(Some("Player".to_string())); + let player = world.spawn(PendingPlayerJoin(identity.clone())).id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(emit_player_joined); + schedule.run(&mut world); + + let joins = world.resource::>(); + let rebuilds = world.resource::>(); + + assert_eq!(joins.len(), 1); + assert_eq!(rebuilds.len(), 1); + + let join = joins.iter_current_update_messages().next().unwrap(); + let rebuild = rebuilds.iter_current_update_messages().next().unwrap(); + + assert_eq!(join.entity, player); + assert_eq!(join.identity.uuid, identity.uuid); + assert_eq!(rebuild.player, player); + } +} diff --git a/src/net/protocol/src/outgoing/commands.rs b/src/net/protocol/src/outgoing/commands.rs index 92d976b2..c1f9e22b 100644 --- a/src/net/protocol/src/outgoing/commands.rs +++ b/src/net/protocol/src/outgoing/commands.rs @@ -8,7 +8,7 @@ use temper_command_infra::{ }; use temper_commands::{ arg::primitive::{ - PrimitiveArgumentFlags, PrimitiveArgumentType, int::IntArgumentFlags, + EntityArgumentFlags, PrimitiveArgumentFlags, PrimitiveArgumentType, int::IntArgumentFlags, string::StringArgumentType, }, graph::{CommandGraph, node::CommandNode as OldCommandNode}, @@ -75,14 +75,6 @@ impl CommandsPacket { root_idx: VarInt::new(graph.root_idx as i32), } } - - /// Creates a CommandsPacket using the globally registered command graph. - /// - /// This is the typical way to create this packet, as it includes all - /// registered server commands for tab-completion and validation. - pub fn from_global_graph() -> Self { - Self::new(temper_commands::infrastructure::get_graph()) - } } fn convert_old_node(node: &OldCommandNode) -> CommandNode { @@ -151,6 +143,9 @@ fn parser_properties(argument: ArgumentSpec) -> Option { None if argument.parser == ParserKind::Word => { Some(PrimitiveArgumentFlags::String(StringArgumentType::Word)) } + None if argument.parser == ParserKind::Entity => Some(PrimitiveArgumentFlags::Entity( + EntityArgumentFlags::default(), + )), None => None, } } @@ -163,17 +158,12 @@ fn string_mode(mode: StringMode) -> StringArgumentType { } } -impl Default for CommandsPacket { - fn default() -> Self { - Self::from_global_graph() - } -} - #[cfg(test)] mod tests { use temper_command_infra::{ ArgumentSpec, CommandGraph, CommandPath, CommandPathSegment, ParserKind, }; + use temper_commands::arg::primitive::PrimitiveArgumentFlags; use super::CommandsPacket; @@ -194,6 +184,10 @@ mod tests { assert_eq!(packet.graph.data[1].name.as_deref(), Some("tp")); assert_eq!(packet.graph.data[2].name.as_deref(), Some("target")); assert!(packet.graph.data[2].flags & 0x04 != 0); + assert!(matches!( + packet.graph.data[2].properties, + Some(PrimitiveArgumentFlags::Entity(_)) + )); assert_eq!( packet.graph.data[2].suggestions_type.as_deref(), Some("ask_server") diff --git a/src/net/runtime/src/conn_init/login.rs b/src/net/runtime/src/conn_init/login.rs index 61bfac8e..ab8f20de 100644 --- a/src/net/runtime/src/conn_init/login.rs +++ b/src/net/runtime/src/conn_init/login.rs @@ -13,8 +13,8 @@ use temper_macros::lookup_packet; use temper_protocol::ConnState::*; use temper_protocol::incoming::packet_skeleton::PacketSkeleton; use temper_protocol::outgoing::login_success::{LoginSuccessPacket, LoginSuccessProperties}; +use temper_protocol::outgoing::registry_data::REGISTRY_PACKETS; use temper_protocol::outgoing::set_default_spawn_position::DEFAULT_SPAWN_POSITION; -use temper_protocol::outgoing::{commands::CommandsPacket, registry_data::REGISTRY_PACKETS}; use temper_state::GlobalState; use temper_components::entity_identity::Identity; @@ -434,16 +434,6 @@ fn send_player_info( Ok(()) } -/// Sends the command graph to the client. -fn send_command_graph(conn_write: &StreamWriter) -> Result<(), NetError> { - conn_write.send_packet(CommandsPacket::from_global_graph())?; - trace!( - "sending command graph {:#?}", - temper_commands::infrastructure::get_graph() - ); - Ok(()) -} - /// Sends the player's inventory contents to the client. fn send_inventory_contents( conn_write: &StreamWriter, @@ -551,7 +541,6 @@ pub(super) async fn login( .await?; send_player_info(conn_write, &player_identity, &player_properties)?; send_inventory_contents(conn_write, &offline_data)?; - send_command_graph(conn_write)?; // Login complete Ok(( From e448398c403c934362b16b565be5207dd0e2518f Mon Sep 17 00:00:00 2001 From: ReCore Date: Fri, 26 Jun 2026 15:37:11 +0930 Subject: [PATCH 08/30] Suggestions + some fixes --- src/command-infra/src/args/entity.rs | 2 +- src/command-infra/src/graph.rs | 28 +- src/command-infra/tests/derive_command.rs | 14 +- .../src/packets/src/command_suggestions.rs | 332 ++++++++++++++++-- src/net/protocol/src/outgoing/commands.rs | 4 +- 5 files changed, 336 insertions(+), 44 deletions(-) diff --git a/src/command-infra/src/args/entity.rs b/src/command-infra/src/args/entity.rs index c6184d79..64f25c3b 100644 --- a/src/command-infra/src/args/entity.rs +++ b/src/command-infra/src/args/entity.rs @@ -67,6 +67,6 @@ impl CommandArg for EntityArg { } fn argument_spec() -> ArgumentSpec { - ArgumentSpec::new(ParserKind::Entity).with_suggestions("ask_server") + ArgumentSpec::new(ParserKind::Entity).with_suggestions("minecraft:ask_server") } } diff --git a/src/command-infra/src/graph.rs b/src/command-infra/src/graph.rs index 5b3d6ad9..f845eddd 100644 --- a/src/command-infra/src/graph.rs +++ b/src/command-infra/src/graph.rs @@ -52,13 +52,24 @@ impl CommandNode { CommandPathSegment::Literal(name) => { self.kind == CommandNodeKind::Literal && self.name.as_deref() == Some(*name) } - CommandPathSegment::Argument { name, spec } => { - self.kind == CommandNodeKind::Argument - && self.name.as_deref() == Some(*name) - && self.argument == Some(*spec) + CommandPathSegment::Argument { spec, .. } => { + self.kind == CommandNodeKind::Argument && self.argument == Some(*spec) } } } + + fn child_priority(&self) -> u8 { + match self.kind { + CommandNodeKind::Literal => 0, + CommandNodeKind::Argument + if self.argument.and_then(|arg| arg.suggestions).is_some() => + { + 1 + } + CommandNodeKind::Argument => 2, + CommandNodeKind::Root => 3, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -111,9 +122,16 @@ impl CommandGraph { CommandPathSegment::Argument { name, spec } => CommandNode::argument(name, spec), }; + let priority = node.child_priority(); + let insert_at = self.nodes[parent] + .children + .iter() + .position(|child_idx| self.nodes[*child_idx].child_priority() > priority) + .unwrap_or(self.nodes[parent].children.len()); + let idx = self.nodes.len(); self.nodes.push(node); - self.nodes[parent].children.push(idx); + self.nodes[parent].children.insert(insert_at, idx); idx } } diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index bf7d8c24..9b742c15 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -187,20 +187,20 @@ fn graph_generation_merges_shared_prefixes() { .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) .collect::>(); - assert_eq!(child_names, vec!["location", "destination", "target"]); + assert_eq!(child_names, vec!["destination", "location"]); - let target_idx = tp + let destination_idx = tp .children .iter() .copied() - .find(|idx| graph.nodes[*idx].name.as_deref() == Some("target")) + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("destination")) .unwrap(); - let target = &graph.nodes[target_idx]; + let destination = &graph.nodes[destination_idx]; - assert_eq!(target.children.len(), 2); - assert!(!target.executable); + assert_eq!(destination.children.len(), 2); + assert!(destination.executable); assert!( - target + destination .children .iter() .all(|idx| graph.nodes[*idx].executable) diff --git a/src/game_systems/src/packets/src/command_suggestions.rs b/src/game_systems/src/packets/src/command_suggestions.rs index 2b301d4f..82b60b23 100644 --- a/src/game_systems/src/packets/src/command_suggestions.rs +++ b/src/game_systems/src/packets/src/command_suggestions.rs @@ -1,9 +1,10 @@ -use std::sync::Arc; +use std::{collections::HashSet, sync::Arc}; use bevy_ecs::prelude::*; use temper_codec::net_types::{ length_prefixed_vec::LengthPrefixedVec, prefixed_optional::PrefixedOptional, var_int::VarInt, }; +use temper_command_infra::{CommandPathSegment, CommandRegistry, ParserKind}; use temper_commands::{Command, CommandContext, CommandInput, ROOT_COMMAND, Sender}; use temper_net_runtime::connection::StreamWriter; use temper_protocol::CommandSuggestionRequestReceiver; @@ -68,6 +69,7 @@ fn create_ctx( pub fn handle( receiver: Res, query: Query<&StreamWriter>, + registry: Res, state: Res, ) { for (request, entity) in receiver.0.try_iter() { @@ -76,6 +78,14 @@ pub fn handle( } let input = request.input; + let Ok(writer) = query.get(entity) else { + continue; + }; + + if let Some(response) = new_command_suggestions(&input, ®istry, &state) { + send_suggestions(writer, request.transaction_id, response); + continue; + } let command = find_command(input.clone()); let command_arg = input @@ -92,7 +102,7 @@ pub fn handle( Sender::Player(entity), state.0.clone(), ); - let command_arg = command_arg.clone(); // ok borrow checker + let command_arg = command_arg.clone(); let tokens = command_arg.split(" ").collect::>(); let Some(current_token) = tokens.last() else { return; // whitespace @@ -111,33 +121,297 @@ pub fn handle( } } - let length = input.len(); - let start = length - current_token.len(); - - if let Err(e) = query - .get(entity) - .unwrap() - .send_packet(CommandSuggestionsPacket { - transaction_id: request.transaction_id, - matches: LengthPrefixedVec::new( - suggestions - .into_iter() - .filter(|sug| { - sug.content - .to_lowercase() - .starts_with(¤t_token.to_lowercase()) - }) - .map(|sug| Match { - content: sug.content, - tooltip: PrefixedOptional::new(sug.tooltip), - }) - .collect(), - ), - length: VarInt::new(length as i32), - start: VarInt::new(start as i32), - }) - { - error!("failed sending command suggestions to player: {e}") + let start = input.len() - current_token.len(); + let length = current_token.len(); + + send_suggestions( + writer, + request.transaction_id, + SuggestionResponse { + start, + length, + matches: suggestions + .into_iter() + .filter(|sug| { + sug.content + .to_lowercase() + .starts_with(¤t_token.to_lowercase()) + }) + .map(|sug| Match { + content: sug.content, + tooltip: PrefixedOptional::new(sug.tooltip), + }) + .collect(), + }, + ); + } +} + +struct SuggestionResponse { + start: usize, + length: usize, + matches: Vec, +} + +fn new_command_suggestions( + input: &str, + registry: &CommandRegistry, + state: &GlobalStateResource, +) -> Option { + let command_input = input.strip_prefix('/').unwrap_or(input); + let root_end = command_input + .find(char::is_whitespace) + .unwrap_or(command_input.len()); + let root = &command_input[..root_end]; + let command = registry + .commands() + .iter() + .find(|command| command.name == root)?; + let rest = command_input[root_end..].trim_start(); + let current_token = current_token(rest); + let completed_tokens = completed_tokens(rest); + let current_token_lower = current_token.to_lowercase(); + let mut seen = HashSet::new(); + + let matches = command + .paths + .iter() + .filter_map(|path| candidate_segment(&path.segments, &completed_tokens)) + .filter_map(|segment| segment_suggestions(segment, state)) + .flatten() + .filter(|suggestion| suggestion.to_lowercase().starts_with(¤t_token_lower)) + .filter(|suggestion| seen.insert(suggestion.clone())) + .map(|content| Match { + content, + tooltip: PrefixedOptional::new(None), + }) + .collect(); + + Some(SuggestionResponse { + start: input.len() - current_token.len(), + length: current_token.len(), + matches, + }) +} + +fn current_token(input: &str) -> &str { + if input.ends_with(char::is_whitespace) { + "" + } else { + input.split_whitespace().last().unwrap_or("") + } +} + +fn completed_tokens(input: &str) -> Vec<&str> { + let mut tokens = input.split_whitespace().collect::>(); + + if !input.ends_with(char::is_whitespace) { + tokens.pop(); + } + + tokens +} + +fn candidate_segment<'a>( + segments: &'a [CommandPathSegment], + completed_tokens: &[&str], +) -> Option<&'a CommandPathSegment> { + let mut token_index = 0; + + for segment in segments { + let Some(token) = completed_tokens.get(token_index) else { + return Some(segment); + }; + + if !segment_accepts_token(segment, token) { + return None; } + + token_index += segment_width(segment); + } + + None +} + +fn segment_accepts_token(segment: &CommandPathSegment, token: &str) -> bool { + match segment { + CommandPathSegment::Literal(literal) => literal == &token, + CommandPathSegment::Argument { spec, .. } => match spec.parser { + ParserKind::Integer => token.parse::().is_ok(), + ParserKind::Position => is_coordinate_token(token), + ParserKind::Word | ParserKind::String | ParserKind::Entity => !token.is_empty(), + }, + } +} + +fn segment_width(segment: &CommandPathSegment) -> usize { + match segment { + CommandPathSegment::Argument { spec, .. } if spec.parser == ParserKind::Position => 3, + _ => 1, + } +} + +fn is_coordinate_token(token: &str) -> bool { + if let Some(relative) = token.strip_prefix('~') { + relative.is_empty() || relative.parse::().is_ok() + } else { + token.parse::().is_ok() + } +} + +fn segment_suggestions( + segment: &CommandPathSegment, + state: &GlobalStateResource, +) -> Option> { + match segment { + CommandPathSegment::Literal(literal) => Some(vec![(*literal).to_string()]), + CommandPathSegment::Argument { spec, .. } if is_ask_server(spec.suggestions) => { + match spec.parser { + ParserKind::Entity => Some(entity_suggestions(state)), + _ => Some(Vec::new()), + } + } + _ => None, + } +} + +fn is_ask_server(suggestions: Option<&str>) -> bool { + matches!(suggestions, Some("ask_server" | "minecraft:ask_server")) +} + +fn entity_suggestions(state: &GlobalStateResource) -> Vec { + let mut suggestions = vec!["@e".to_string(), "@r".to_string(), "@a".to_string()]; + suggestions.extend( + state + .0 + .players + .player_list + .iter() + .map(|player| player.value().1.clone()), + ); + suggestions +} + +fn send_suggestions(writer: &StreamWriter, transaction_id: VarInt, response: SuggestionResponse) { + if let Err(e) = writer.send_packet(CommandSuggestionsPacket { + transaction_id, + matches: LengthPrefixedVec::new(response.matches), + length: VarInt::new(response.length as i32), + start: VarInt::new(response.start as i32), + }) { + error!("failed sending command suggestions to player: {e}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::entity::Entity; + use temper_command_infra::{ArgumentSpec, CommandPath, RegisteredCommand}; + use temper_state::create_test_state; + + fn registry() -> CommandRegistry { + let mut registry = CommandRegistry::default(); + registry.register_command(RegisteredCommand { + name: "tp", + paths: vec![ + CommandPath::new( + "tp", + vec![CommandPathSegment::argument( + "location", + ArgumentSpec::new(ParserKind::Position), + )], + ), + CommandPath::new( + "tp", + vec![CommandPathSegment::argument( + "destination", + ArgumentSpec::new(ParserKind::Entity) + .with_suggestions("minecraft:ask_server"), + )], + ), + CommandPath::new( + "tp", + vec![ + CommandPathSegment::argument( + "target", + ArgumentSpec::new(ParserKind::Entity) + .with_suggestions("minecraft:ask_server"), + ), + CommandPathSegment::argument( + "location", + ArgumentSpec::new(ParserKind::Position), + ), + ], + ), + CommandPath::new( + "tp", + vec![ + CommandPathSegment::argument( + "target", + ArgumentSpec::new(ParserKind::Entity) + .with_suggestions("minecraft:ask_server"), + ), + CommandPathSegment::argument( + "destination", + ArgumentSpec::new(ParserKind::Entity) + .with_suggestions("minecraft:ask_server"), + ), + ], + ), + ], + }); + registry + } + + #[test] + fn new_command_suggestions_include_entities_for_first_tp_arg() { + let (state, _temp_dir) = create_test_state(); + state + .0 + .players + .player_list + .insert(Entity::PLACEHOLDER, (0, "Alex".to_string())); + + let suggestions = new_command_suggestions("/tp ", ®istry(), &state).unwrap(); + let matches = suggestions + .matches + .iter() + .map(|suggestion| suggestion.content.as_str()) + .collect::>(); + + assert_eq!(suggestions.start, 4); + assert_eq!(suggestions.length, 0); + assert!(matches.contains(&"@a")); + assert!(matches.contains(&"Alex")); + } + + #[test] + fn new_command_suggestions_use_current_token_range() { + let (state, _temp_dir) = create_test_state(); + state + .0 + .players + .player_list + .insert(Entity::PLACEHOLDER, (0, "Alex".to_string())); + + let suggestions = new_command_suggestions("/tp Steve A", ®istry(), &state).unwrap(); + let matches = suggestions + .matches + .iter() + .map(|suggestion| suggestion.content.as_str()) + .collect::>(); + + assert_eq!(suggestions.start, 10); + assert_eq!(suggestions.length, 1); + assert_eq!(matches, vec!["Alex"]); + } + + #[test] + fn old_command_suggestions_do_not_handle_new_roots() { + let (state, _temp_dir) = create_test_state(); + + assert!(new_command_suggestions("/tp Unknown", ®istry(), &state).is_some()); + assert!(new_command_suggestions("/time set ", ®istry(), &state).is_none()); } } diff --git a/src/net/protocol/src/outgoing/commands.rs b/src/net/protocol/src/outgoing/commands.rs index c1f9e22b..864092a1 100644 --- a/src/net/protocol/src/outgoing/commands.rs +++ b/src/net/protocol/src/outgoing/commands.rs @@ -173,7 +173,7 @@ mod tests { "tp", vec![CommandPathSegment::argument( "target", - ArgumentSpec::new(ParserKind::Entity).with_suggestions("ask_server"), + ArgumentSpec::new(ParserKind::Entity).with_suggestions("minecraft:ask_server"), )], )]); @@ -190,7 +190,7 @@ mod tests { )); assert_eq!( packet.graph.data[2].suggestions_type.as_deref(), - Some("ask_server") + Some("minecraft:ask_server") ); } } From db62dcb83034799980cb6a748ae571f6411ed372 Mon Sep 17 00:00:00 2001 From: ReCore Date: Fri, 26 Jun 2026 15:56:04 +0930 Subject: [PATCH 09/30] fixed entity tp not being synced --- src/default_commands/src/new/tp.rs | 20 +-- src/default_commands/src/tp.rs | 30 +--- src/game_systems/src/lib.rs | 2 +- src/game_systems/src/player/src/lib.rs | 2 +- src/game_systems/src/player/src/player_tp.rs | 82 --------- src/game_systems/src/player/src/teleport.rs | 178 +++++++++++++++++++ src/messages/src/lib.rs | 6 +- src/messages/src/teleport_entity.rs | 28 +++ src/messages/src/teleport_player.rs | 14 -- 9 files changed, 221 insertions(+), 141 deletions(-) delete mode 100644 src/game_systems/src/player/src/player_tp.rs create mode 100644 src/game_systems/src/player/src/teleport.rs create mode 100644 src/messages/src/teleport_entity.rs delete mode 100644 src/messages/src/teleport_player.rs diff --git a/src/default_commands/src/new/tp.rs b/src/default_commands/src/new/tp.rs index 16cc0a82..771620b2 100644 --- a/src/default_commands/src/new/tp.rs +++ b/src/default_commands/src/new/tp.rs @@ -9,7 +9,7 @@ use temper_components::player::position::Position; use temper_components::player::rotation::Rotation; use temper_core::mq; use temper_macros::Command; -use temper_messages::teleport_player::TeleportPlayer; +use temper_messages::teleport_entity::TeleportEntity; use temper_text::TextComponent; use tracing::info; @@ -37,7 +37,7 @@ impl CommandHandler for TpCommand { type SystemParam<'w, 's> = ( Query<'w, 's, (&'static Rotation, &'static Position)>, Query<'w, 's, (Entity, &'static Identity, Option<&'static PlayerMarker>)>, - MessageWriter<'w, TeleportPlayer>, + MessageWriter<'w, TeleportEntity>, ); fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { @@ -51,7 +51,7 @@ fn execute_tp( command: TpCommand, positions: &Query<(&Rotation, &Position)>, identities: &Query<(Entity, &Identity, Option<&PlayerMarker>)>, - teleports: &mut MessageWriter, + teleports: &mut MessageWriter, ) { match command { TpCommand::TpToPos { location } => { @@ -172,19 +172,9 @@ fn teleport_entity( entity: Entity, rotation: Rotation, destination: Position, - teleports: &mut MessageWriter, + teleports: &mut MessageWriter, ) { - teleports.write(TeleportPlayer { - entity, - x: destination.x, - y: destination.y, - z: destination.z, - vel_x: 0.0, - vel_y: 0.0, - vel_z: 0.0, - yaw: rotation.yaw, - pitch: rotation.pitch, - }); + teleports.write(TeleportEntity::new(entity, destination, rotation)); } fn send_message(source: CommandSource, message: TextComponent) { diff --git a/src/default_commands/src/tp.rs b/src/default_commands/src/tp.rs index 7e00e839..b3b2abfb 100644 --- a/src/default_commands/src/tp.rs +++ b/src/default_commands/src/tp.rs @@ -9,13 +9,13 @@ use temper_components::player::player_marker::PlayerMarker; use temper_components::player::position::Position; use temper_components::player::rotation::Rotation; use temper_macros::command; -use temper_messages::teleport_player::TeleportPlayer; +use temper_messages::teleport_entity::TeleportEntity; #[command("tp pos")] fn tp_command( #[sender] sender: Sender, #[arg] pos: CommandPosition, - args: (Query<(&Rotation, &Position)>, MessageWriter), + args: (Query<(&Rotation, &Position)>, MessageWriter), ) { let (mut query, mut tp_player_msg) = args; let Player(entity) = sender else { @@ -29,17 +29,7 @@ fn tp_command( }; let resolved_pos = pos.resolve(position); - tp_player_msg.write(TeleportPlayer { - entity, - x: resolved_pos.x, - y: resolved_pos.y, - z: resolved_pos.z, - vel_x: 0.0, - vel_y: 0.0, - vel_z: 0.0, - yaw: rot.yaw, - pitch: rot.pitch, - }); + tp_player_msg.write(TeleportEntity::new(entity, resolved_pos, *rot)); sender.send_message(format!("Teleported to ({}).", resolved_pos).into(), false); } @@ -50,7 +40,7 @@ fn tp_to_command( #[arg] target: EntityArgument, args: ( Query<(&Rotation, &Position)>, - MessageWriter, + MessageWriter, Query<(Entity, &Identity, Option<&PlayerMarker>)>, ), ) { @@ -80,17 +70,7 @@ fn tp_to_command( return; }; - tp_player_msg.write(TeleportPlayer { - entity: sender_e, - x: target_pos.x, - y: target_pos.y, - z: target_pos.z, - vel_x: 0.0, - vel_y: 0.0, - vel_z: 0.0, - yaw: sender_rot.yaw, - pitch: sender_rot.pitch, - }); + tp_player_msg.write(TeleportEntity::new(sender_e, *target_pos, *sender_rot)); sender.send_message( format!("Teleported to the entity at {}.", target_pos).into(), diff --git a/src/game_systems/src/lib.rs b/src/game_systems/src/lib.rs index 79b9e6cc..08d03b84 100644 --- a/src/game_systems/src/lib.rs +++ b/src/game_systems/src/lib.rs @@ -92,7 +92,7 @@ fn register_tick_systems(schedule: &mut Schedule) { schedule.add_systems(player::player_join_message::handle); schedule.add_systems(player::player_leave_message::handle); schedule.add_systems(player::player_swimming::detect_player_swimming); - schedule.add_systems(player::player_tp::teleport_player); + schedule.add_systems(player::teleport::teleport_entities); schedule.add_systems(player::send_inventory_updates::handle_inventory_updates); register_old_command_systems(schedule); diff --git a/src/game_systems/src/player/src/lib.rs b/src/game_systems/src/player/src/lib.rs index a236133c..22c9c0fa 100644 --- a/src/game_systems/src/player/src/lib.rs +++ b/src/game_systems/src/player/src/lib.rs @@ -10,6 +10,6 @@ pub mod player_join_message; pub mod player_leave_message; pub mod player_spawn; pub mod player_swimming; -pub mod player_tp; pub mod send_inventory_updates; +pub mod teleport; pub mod update_player_ping; diff --git a/src/game_systems/src/player/src/player_tp.rs b/src/game_systems/src/player/src/player_tp.rs deleted file mode 100644 index 7f7eb21d..00000000 --- a/src/game_systems/src/player/src/player_tp.rs +++ /dev/null @@ -1,82 +0,0 @@ -use bevy_ecs::prelude::{Entity, MessageReader, MessageWriter, Query}; -use temper_components::entity_identity::Identity; -use temper_components::player::position::Position; -use temper_components::player::teleport_tracker::TeleportTracker; -use temper_messages::chunk_calc::ChunkCalc; -use temper_messages::entity_update::SendEntityUpdate; -use temper_messages::teleport_player::TeleportPlayer; -use temper_net_runtime::connection::StreamWriter; -use temper_protocol::outgoing::entity_position_sync::TeleportEntityPacket; -use temper_protocol::outgoing::synchronize_player_position::SynchronizePlayerPositionPacket; -use tracing::error; - -pub fn teleport_player( - mut query: Query<(Entity, &StreamWriter, &mut Position, &mut TeleportTracker)>, - id_query: Query<&Identity>, - mut message_reader: MessageReader, - mut chunk_calc_msg: MessageWriter, - mut player_update_msg: MessageWriter, -) { - for message in message_reader.read() { - let message_entity = message.entity; - let id = match id_query.get(message_entity) { - Ok(id) => id, - Err(err) => { - error!( - "Failed to get Identity for entity {:?}: {}", - message_entity, err - ); - continue; - } - }; - for (entity, conn, mut pos, mut tracker) in query.iter_mut() { - if entity == message_entity { - // Block movement tracking until the player has been teleported - tracker.waiting_for_confirm = true; - pos.x = message.x; - pos.y = message.y; - pos.z = message.z; - // If it's the entity we are trying to teleport, send the sync player pos packet - if let Err(err) = conn.send_packet(SynchronizePlayerPositionPacket { - teleport_id: rand::random::().into(), - x: message.x, - y: message.y, - z: message.z, - vel_x: message.vel_x, - vel_y: message.vel_y, - vel_z: message.vel_z, - yaw: message.yaw, - pitch: message.pitch, - flags: 0, - }) { - error!("Failed to send teleport packet: {}", err); - continue; - } - } else { - // Otherwise send teleport entity packet. This ideally should be handled by the send - // entity updates system, but it seems to be a bit buggy - if let Err(err) = conn.send_packet(TeleportEntityPacket { - entity_id: id.entity_id.into(), - x: message.x, - y: message.y, - z: message.z, - vel_x: 0.0, - vel_y: 0.0, - vel_z: 0.0, - yaw: message.yaw, - pitch: message.pitch, - on_ground: false, - }) { - error!("Failed to send teleport packet: {}", err); - continue; - } - } - } - - // Notify the player update system to send the new position to the client - player_update_msg.write(SendEntityUpdate(message_entity)); - - // Notify the chunk calculation system to recalculate chunks for this player - chunk_calc_msg.write(ChunkCalc(message_entity)); - } -} diff --git a/src/game_systems/src/player/src/teleport.rs b/src/game_systems/src/player/src/teleport.rs new file mode 100644 index 00000000..aecadc10 --- /dev/null +++ b/src/game_systems/src/player/src/teleport.rs @@ -0,0 +1,178 @@ +use bevy_ecs::prelude::{Entity, MessageReader, MessageWriter, Query}; +use temper_components::entity_identity::Identity; +use temper_components::player::position::Position; +use temper_components::player::rotation::Rotation; +use temper_components::player::teleport_tracker::TeleportTracker; +use temper_components::player::velocity::Velocity; +use temper_messages::chunk_calc::ChunkCalc; +use temper_messages::entity_update::SendEntityUpdate; +use temper_messages::teleport_entity::TeleportEntity; +use temper_net_runtime::connection::StreamWriter; +use temper_protocol::outgoing::entity_position_sync::TeleportEntityPacket; +use temper_protocol::outgoing::synchronize_player_position::SynchronizePlayerPositionPacket; +use tracing::error; + +pub fn teleport_entities( + mut target_query: Query<( + Option<&StreamWriter>, + &mut Position, + Option<&mut Rotation>, + Option<&mut Velocity>, + Option<&mut TeleportTracker>, + )>, + player_query: Query<(Entity, &StreamWriter)>, + id_query: Query<&Identity>, + mut message_reader: MessageReader, + mut chunk_calc_msg: MessageWriter, + mut player_update_msg: MessageWriter, +) { + for message in message_reader.read() { + let message_entity = message.entity; + let id = match id_query.get(message_entity) { + Ok(id) => id, + Err(err) => { + error!( + "Failed to get Identity for entity {:?}: {}", + message_entity, err + ); + continue; + } + }; + + let is_player_target = { + let Ok((conn, mut pos, rotation, velocity, tracker)) = + target_query.get_mut(message_entity) + else { + error!( + "Failed to get teleport target components for entity {:?}", + message_entity + ); + continue; + }; + + if let Some(mut tracker) = tracker { + // Block movement tracking until the player has been teleported. + tracker.waiting_for_confirm = true; + } + + *pos = message.position; + + if let Some(mut rotation) = rotation { + *rotation = message.rotation; + } + + if let Some(mut velocity) = velocity { + *velocity = message.velocity; + } + + if let Some(conn) = conn { + if let Err(err) = conn.send_packet(SynchronizePlayerPositionPacket { + teleport_id: rand::random::().into(), + x: message.position.x, + y: message.position.y, + z: message.position.z, + vel_x: f64::from(message.velocity.x), + vel_y: f64::from(message.velocity.y), + vel_z: f64::from(message.velocity.z), + yaw: message.rotation.yaw, + pitch: message.rotation.pitch, + flags: 0, + }) { + error!("Failed to send teleport packet: {}", err); + continue; + } + + true + } else { + false + } + }; + + for (entity, conn) in player_query.iter() { + if entity == message_entity { + continue; + } + + // This ideally should be handled by the send entity updates system, but it seems to be + // a bit buggy. + if let Err(err) = conn.send_packet(TeleportEntityPacket { + entity_id: id.entity_id.into(), + x: message.position.x, + y: message.position.y, + z: message.position.z, + vel_x: f64::from(message.velocity.x), + vel_y: f64::from(message.velocity.y), + vel_z: f64::from(message.velocity.z), + yaw: message.rotation.yaw, + pitch: message.rotation.pitch, + on_ground: false, + }) { + error!("Failed to send teleport packet: {}", err); + continue; + } + } + + // Notify the player update system to send the new position to the client + player_update_msg.write(SendEntityUpdate(message_entity)); + + // Notify the chunk calculation system to recalculate chunks for this player + if is_player_target { + chunk_calc_msg.write(ChunkCalc(message_entity)); + } + } +} + +#[cfg(test)] +mod tests { + use bevy_ecs::message::MessageRegistry; + use bevy_ecs::prelude::{IntoScheduleConfigs, MessageWriter, Res, Resource, Schedule, World}; + use temper_components::entity_identity::Identity; + + use super::*; + + #[derive(Resource)] + struct TeleportTarget(Entity); + + fn emit_mob_teleport(target: Res, mut writer: MessageWriter) { + writer.write(TeleportEntity::new( + target.0, + Position::new(10.0, 65.0, -3.0), + Rotation::new(90.0, 15.0), + )); + } + + #[test] + fn teleport_updates_non_player_position() { + let mut world = World::new(); + MessageRegistry::register_message::(&mut world); + MessageRegistry::register_message::(&mut world); + MessageRegistry::register_message::(&mut world); + + let entity = world + .spawn(( + Identity::default(), + Position::new(0.0, 64.0, 0.0), + Rotation::default(), + Velocity::new(1.0, 0.0, 0.0), + )) + .id(); + world.insert_resource(TeleportTarget(entity)); + + let mut schedule = Schedule::default(); + schedule.add_systems((emit_mob_teleport, teleport_entities).chain()); + schedule.run(&mut world); + + let position = world.get::(entity).unwrap(); + let rotation = world.get::(entity).unwrap(); + let velocity = world.get::(entity).unwrap(); + + assert_eq!(position.x, 10.0); + assert_eq!(position.y, 65.0); + assert_eq!(position.z, -3.0); + assert_eq!(rotation.yaw, 90.0); + assert_eq!(rotation.pitch, 15.0); + assert_eq!(velocity.x, 0.0); + assert_eq!(velocity.y, 0.0); + assert_eq!(velocity.z, 0.0); + } +} diff --git a/src/messages/src/lib.rs b/src/messages/src/lib.rs index 3769b6c0..be576324 100644 --- a/src/messages/src/lib.rs +++ b/src/messages/src/lib.rs @@ -38,7 +38,7 @@ pub mod force_player_recount_event; pub mod load_chunk_entities; pub mod packet_messages; pub mod save_chunk_entities; -pub mod teleport_player; +pub mod teleport_entity; pub mod world_change; use crate::chunk_calc::ChunkCalc; @@ -50,7 +50,7 @@ use crate::load_chunk_entities::LoadChunkEntities; use crate::packet_messages::Movement; use crate::particle::SendParticle; use crate::save_chunk_entities::SaveChunkEntities; -use crate::teleport_player::TeleportPlayer; +use crate::teleport_entity::TeleportEntity; pub use block_break::BlockBrokenEvent; pub use block_interaction::BlockInteractMessage; use temper_command_infra::{NewCommandDispatched, RebuildCommandGraph}; @@ -83,7 +83,7 @@ pub fn register_messages(world: &mut World) { MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); - MessageRegistry::register_message::(world); + MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); MessageRegistry::register_message::(world); diff --git a/src/messages/src/teleport_entity.rs b/src/messages/src/teleport_entity.rs new file mode 100644 index 00000000..95508892 --- /dev/null +++ b/src/messages/src/teleport_entity.rs @@ -0,0 +1,28 @@ +use bevy_ecs::prelude::{Entity, Message}; +use temper_components::player::position::Position; +use temper_components::player::rotation::Rotation; +use temper_components::player::velocity::Velocity; + +#[derive(Message)] +pub struct TeleportEntity { + pub entity: Entity, + pub position: Position, + pub rotation: Rotation, + pub velocity: Velocity, +} + +impl TeleportEntity { + pub fn new(entity: Entity, position: Position, rotation: Rotation) -> Self { + Self { + entity, + position, + rotation, + velocity: Velocity::zero(), + } + } + + pub fn with_velocity(mut self, velocity: Velocity) -> Self { + self.velocity = velocity; + self + } +} diff --git a/src/messages/src/teleport_player.rs b/src/messages/src/teleport_player.rs deleted file mode 100644 index 17a4861f..00000000 --- a/src/messages/src/teleport_player.rs +++ /dev/null @@ -1,14 +0,0 @@ -use bevy_ecs::prelude::{Entity, Message}; - -#[derive(Message)] -pub struct TeleportPlayer { - pub entity: Entity, - pub x: f64, - pub y: f64, - pub z: f64, - pub vel_x: f64, - pub vel_y: f64, - pub vel_z: f64, - pub yaw: f32, - pub pitch: f32, -} From 277c0de2726b70ff36506cb653f9c26fc7d486c7 Mon Sep 17 00:00:00 2001 From: ReCore Date: Fri, 26 Jun 2026 16:41:25 +0930 Subject: [PATCH 10/30] Subcommands + some other shit --- src/base/macros/src/command_derive/attrs.rs | 85 +++++ src/base/macros/src/command_derive/expand.rs | 330 +++++++++++++++++ src/base/macros/src/command_derive/fields.rs | 157 ++++++++ src/base/macros/src/command_derive/mod.rs | 357 +------------------ src/base/macros/src/lib.rs | 2 +- src/command-infra/src/lib.rs | 1 + src/command-infra/src/metadata.rs | 6 + src/command-infra/tests/derive_command.rs | 77 ++++ src/default_commands/src/new/tp.rs | 1 - 9 files changed, 663 insertions(+), 353 deletions(-) create mode 100644 src/base/macros/src/command_derive/attrs.rs create mode 100644 src/base/macros/src/command_derive/expand.rs create mode 100644 src/base/macros/src/command_derive/fields.rs diff --git a/src/base/macros/src/command_derive/attrs.rs b/src/base/macros/src/command_derive/attrs.rs new file mode 100644 index 00000000..e01d3549 --- /dev/null +++ b/src/base/macros/src/command_derive/attrs.rs @@ -0,0 +1,85 @@ +use syn::{Attribute, LitStr, Result as SynResult}; + +pub enum CommandKind { + Root(LitStr), + Subcommand, +} + +pub enum VariantPrefix { + Literal(LitStr), + Subcommand(LitStr), +} + +pub fn command_kind(attrs: &[Attribute]) -> SynResult { + for attr in attrs { + if !attr.path().is_ident("command") { + continue; + } + + if let Ok(name) = attr.parse_args::() { + return Ok(CommandKind::Root(name)); + } + + let mut name = None; + let mut subcommand = false; + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + name = Some(meta.value()?.parse::()?); + Ok(()) + } else if meta.path.is_ident("subcommand") { + subcommand = true; + Ok(()) + } else { + Err(meta.error("unsupported command option")) + } + })?; + + return match (name, subcommand) { + (Some(name), false) => Ok(CommandKind::Root(name)), + (None, true) => Ok(CommandKind::Subcommand), + (Some(_), true) => Err(syn::Error::new_spanned( + attr, + "command cannot be both named and a subcommand", + )), + (None, false) => Err(syn::Error::new_spanned( + attr, + "expected #[command(\"name\")], #[command(name = \"name\")], or #[command(subcommand)]", + )), + }; + } + + Err(syn::Error::new( + proc_macro2::Span::call_site(), + "missing #[command(...)] attribute", + )) +} + +pub fn variant_prefix(attrs: &[Attribute]) -> SynResult> { + let mut prefix = None; + + for attr in attrs { + let next = if attr.path().is_ident("literal") { + Some(VariantPrefix::Literal(attr.parse_args::()?)) + } else if attr.path().is_ident("subcommand") { + Some(VariantPrefix::Subcommand(attr.parse_args::()?)) + } else { + None + }; + + let Some(next) = next else { + continue; + }; + + if prefix.is_some() { + return Err(syn::Error::new_spanned( + attr, + "command variants can only have one #[literal(...)] or #[subcommand(...)] attribute", + )); + } + + prefix = Some(next); + } + + Ok(prefix) +} diff --git a/src/base/macros/src/command_derive/expand.rs b/src/base/macros/src/command_derive/expand.rs new file mode 100644 index 00000000..6f5761d9 --- /dev/null +++ b/src/base/macros/src/command_derive/expand.rs @@ -0,0 +1,330 @@ +use quote::{format_ident, quote}; +use syn::{Data, DataEnum, DataStruct, DeriveInput, Ident, LitStr, Result as SynResult}; + +use super::attrs::{command_kind, variant_prefix, CommandKind, VariantPrefix}; +use super::fields::{CommandFields, FieldParse}; + +pub fn expand(input: DeriveInput) -> SynResult { + let ident = input.ident; + let command_kind = command_kind(&input.attrs)?; + + match input.data { + Data::Enum(data_enum) => expand_enum(&ident, command_kind, data_enum), + Data::Struct(data_struct) => expand_struct(&ident, command_kind, data_struct), + Data::Union(_) => Err(syn::Error::new( + ident.span(), + "Command can only be derived for enums or structs", + )), + } +} + +fn expand_enum( + ident: &Ident, + command_kind: CommandKind, + data_enum: DataEnum, +) -> SynResult { + let mut parse_arms = Vec::new(); + let mut segment_entries = Vec::new(); + let mut greedy_assertions = Vec::new(); + + for variant in data_enum.variants { + let variant_ident = variant.ident; + let prefix = variant_prefix(&variant.attrs)?; + let fields = CommandFields::from_fields(variant.fields)?; + + match prefix { + Some(VariantPrefix::Subcommand(literal)) => { + let ty = fields.single_unnamed_type()?; + let literal_parse = literal_parse(&literal); + parse_arms.push(quote! { + { + let __checkpoint = __reader.checkpoint(); + let __result = (|| -> Result { + #literal_parse + let __subcommand = <#ty as ::temper_command_infra::SubcommandSpec>::parse_reader(__reader)?; + Ok(Self::#variant_ident(__subcommand)) + })(); + + match __result { + Ok(__command) => return Ok(__command), + Err(__err) => { + __best_error = Some(match __best_error.take() { + Some(__best) => __best.farthest(__err), + None => __err, + }); + __reader.rewind(__checkpoint); + } + } + } + }); + + segment_entries.push(quote! { + <#ty as ::temper_command_infra::SubcommandSpec>::segments() + .into_iter() + .map(|mut __segments| { + let mut __path = vec![ + ::temper_command_infra::CommandPathSegment::literal(#literal), + ]; + __path.append(&mut __segments); + __path + }) + .collect::>() + }); + } + prefix => { + let field_parse = FieldParse::new(fields.fields())?; + greedy_assertions.extend(field_parse.greedy_assertions.clone()); + let constructor = constructor(ident, &variant_ident, &fields, &field_parse); + let raw_bindings = &field_parse.raw_bindings; + let prefix_parse = prefix_parse(prefix.as_ref()); + let prefix_segments = prefix_segments(prefix.as_ref()); + + parse_arms.push(quote! { + { + let __checkpoint = __reader.checkpoint(); + let __result = (|| -> Result { + #prefix_parse + #(#raw_bindings)* + __reader.expect_end()?; + Ok(#constructor) + })(); + + match __result { + Ok(__command) => return Ok(__command), + Err(__err) => { + __best_error = Some(match __best_error.take() { + Some(__best) => __best.farthest(__err), + None => __err, + }); + __reader.rewind(__checkpoint); + } + } + } + }); + + let segments = &field_parse.segments; + segment_entries.push(quote! { + vec![{ + let mut __segments = Vec::new(); + #prefix_segments + __segments.extend(vec![#(#segments),*]); + __segments + }] + }); + } + } + } + + let parse_body = quote! { + let mut __best_error: Option<::temper_command_infra::ParseError> = None; + + #(#parse_arms)* + + Err(__best_error.unwrap_or_else(|| { + ::temper_command_infra::ParseError::expected(__reader.cursor(), "command variant") + })) + }; + + Ok(expand_impl( + ident, + command_kind, + parse_body, + segment_entries, + greedy_assertions, + )) +} + +fn expand_struct( + ident: &Ident, + command_kind: CommandKind, + data_struct: DataStruct, +) -> SynResult { + let fields = CommandFields::from_fields(data_struct.fields)?; + let field_parse = FieldParse::new(fields.fields())?; + let raw_bindings = &field_parse.raw_bindings; + let constructor = struct_constructor(&fields, &field_parse); + + let parse_body = quote! { + #(#raw_bindings)* + __reader.expect_end()?; + Ok(#constructor) + }; + + let segments = &field_parse.segments; + let segment_entries = vec![quote! { + vec![vec![#(#segments),*]] + }]; + + Ok(expand_impl( + ident, + command_kind, + parse_body, + segment_entries, + field_parse.greedy_assertions, + )) +} + +fn expand_impl( + ident: &Ident, + command_kind: CommandKind, + parse_body: proc_macro2::TokenStream, + segment_entries: Vec, + greedy_assertions: Vec, +) -> proc_macro2::TokenStream { + let segment_builder = quote! { + let mut __segments = Vec::new(); + #( + __segments.extend(#segment_entries); + )* + __segments + }; + + match command_kind { + CommandKind::Root(command_name) => { + let registration = expand_registration(ident); + + quote! { + #(#greedy_assertions)* + + impl ::temper_command_infra::CommandSpec for #ident { + const NAME: &'static str = #command_name; + + fn parse_reader( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + ) -> Result { + #parse_body + } + + fn paths() -> Vec<::temper_command_infra::CommandPath> { + #segment_builder + .into_iter() + .map(|__segments| { + ::temper_command_infra::CommandPath::new(#command_name, __segments) + }) + .collect() + } + } + + #registration + } + } + CommandKind::Subcommand => quote! { + #(#greedy_assertions)* + + impl ::temper_command_infra::SubcommandSpec for #ident { + fn parse_reader( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + ) -> Result { + #parse_body + } + + fn segments() -> Vec> { + #segment_builder + } + } + }, + } +} + +fn expand_registration(ident: &Ident) -> proc_macro2::TokenStream { + let register_fn = format_ident!("__{}_register_command", ident); + let register_system_fn = format_ident!("__{}_register_command_system", ident); + + quote! { + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_fn() { + ::temper_command_infra::register_static_command( + ::temper_command_infra::RegisteredCommand::of::<#ident>(), + ); + } + + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_system_fn() { + ::temper_command_infra::add_system( + ::temper_command_infra::dispatch_command::<#ident>, + ); + } + } +} + +fn constructor( + ident: &Ident, + variant_ident: &Ident, + fields: &CommandFields, + field_parse: &FieldParse, +) -> proc_macro2::TokenStream { + match fields { + CommandFields::Unnamed(_) => { + let values = &field_parse.tuple_values; + quote! { + #ident::#variant_ident(#(#values),*) + } + } + CommandFields::Named(_) => { + let values = &field_parse.named_values; + quote! { + #ident::#variant_ident { #(#values),* } + } + } + CommandFields::Unit => quote! { + #ident::#variant_ident + }, + } +} + +fn struct_constructor( + fields: &CommandFields, + field_parse: &FieldParse, +) -> proc_macro2::TokenStream { + match fields { + CommandFields::Unnamed(_) => { + let values = &field_parse.tuple_values; + quote! { + Self(#(#values),*) + } + } + CommandFields::Named(_) => { + let values = &field_parse.named_values; + quote! { + Self { #(#values),* } + } + } + CommandFields::Unit => quote! { + Self + }, + } +} + +fn prefix_parse(prefix: Option<&VariantPrefix>) -> proc_macro2::TokenStream { + match prefix { + Some(VariantPrefix::Literal(literal)) => literal_parse(literal), + Some(VariantPrefix::Subcommand(_)) | None => quote! {}, + } +} + +fn prefix_segments(prefix: Option<&VariantPrefix>) -> proc_macro2::TokenStream { + match prefix { + Some(VariantPrefix::Literal(literal)) => quote! { + __segments.push(::temper_command_infra::CommandPathSegment::literal(#literal)); + }, + Some(VariantPrefix::Subcommand(_)) | None => quote! {}, + } +} + +fn literal_parse(literal: &LitStr) -> proc_macro2::TokenStream { + quote! { + let __literal_cursor = __reader.cursor(); + let __actual_literal = __reader.read_word_span()?; + if __actual_literal != #literal { + return Err(::temper_command_infra::ParseError::new( + __literal_cursor, + #literal, + format!("expected literal {}", #literal), + )); + } + } +} diff --git a/src/base/macros/src/command_derive/fields.rs b/src/base/macros/src/command_derive/fields.rs new file mode 100644 index 00000000..4dd270aa --- /dev/null +++ b/src/base/macros/src/command_derive/fields.rs @@ -0,0 +1,157 @@ +use quote::{format_ident, quote, quote_spanned}; +use syn::{spanned::Spanned, Field, Fields, Ident, LitStr, Result as SynResult, Type}; + +pub enum CommandFields { + Unnamed(Vec), + Named(Vec), + Unit, +} + +impl CommandFields { + pub fn from_fields(fields: Fields) -> SynResult { + match fields { + Fields::Unnamed(fields) => Ok(Self::Unnamed( + fields.unnamed.into_iter().map(CommandField::from).collect(), + )), + Fields::Named(fields) => Ok(Self::Named( + fields + .named + .into_iter() + .map(|field| { + let ident = field.ident.clone().ok_or_else(|| { + syn::Error::new(field.span(), "named command fields must have names") + })?; + Ok(CommandField { + ident: Some(ident), + field, + }) + }) + .collect::>>()?, + )), + Fields::Unit => Ok(Self::Unit), + } + } + + pub fn fields(&self) -> &[CommandField] { + match self { + CommandFields::Unnamed(fields) | CommandFields::Named(fields) => fields, + CommandFields::Unit => &[], + } + } + + pub fn single_unnamed_type(&self) -> SynResult<&Type> { + let CommandFields::Unnamed(fields) = self else { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "subcommand variants must contain exactly one unnamed field", + )); + }; + + if fields.len() != 1 { + return Err(syn::Error::new( + fields + .first() + .map(|field| field.field.span()) + .unwrap_or_else(proc_macro2::Span::call_site), + "subcommand variants must contain exactly one unnamed field", + )); + } + + Ok(&fields[0].field.ty) + } +} + +pub struct FieldParse { + pub raw_bindings: Vec, + pub tuple_values: Vec, + pub named_values: Vec, + pub segments: Vec, + pub greedy_assertions: Vec, +} + +impl FieldParse { + pub fn new(fields: &[CommandField]) -> SynResult { + let last_field_idx = fields.len().saturating_sub(1); + let mut raw_bindings = Vec::new(); + let mut tuple_values = Vec::new(); + let mut named_values = Vec::new(); + let mut segments = Vec::new(); + let mut greedy_assertions = Vec::new(); + + for (idx, command_field) in fields.iter().enumerate() { + let arg_name = arg_name(command_field)?; + let field = &command_field.field; + let ty = &field.ty; + let raw_ident = format_ident!("__raw_{idx}"); + + raw_bindings.push(quote! { + let #raw_ident = <#ty as ::temper_command_infra::CommandArg>::recognize(__reader)?; + }); + + tuple_values.push(quote! { + <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + + if let Some(field_ident) = &command_field.ident { + named_values.push(quote! { + #field_ident: <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + } + + segments.push(quote! { + ::temper_command_infra::CommandPathSegment::argument( + #arg_name, + <#ty as ::temper_command_infra::CommandArg>::argument_spec(), + ) + }); + + if idx != last_field_idx { + greedy_assertions.push(quote_spanned! { ty.span() => + const _: () = assert!( + !matches!( + <#ty as ::temper_command_infra::CommandArg>::KIND, + ::temper_command_infra::ArgKind::GreedyTail + ), + "greedy-tail command args must be the final field in a command variant" + ); + }); + } + } + + Ok(Self { + raw_bindings, + tuple_values, + named_values, + segments, + greedy_assertions, + }) + } +} + +pub struct CommandField { + ident: Option, + field: Field, +} + +impl From for CommandField { + fn from(field: Field) -> Self { + Self { ident: None, field } + } +} + +fn arg_name(command_field: &CommandField) -> SynResult { + for attr in &command_field.field.attrs { + if attr.path().is_ident("arg") { + return attr.parse_args::(); + } + } + + if let Some(ident) = &command_field.ident { + return Ok(LitStr::new(&ident.to_string(), ident.span())); + } + + Err(syn::Error::new( + command_field.field.span(), + "command tuple fields must have #[arg(\"name\")]", + )) +} diff --git a/src/base/macros/src/command_derive/mod.rs b/src/base/macros/src/command_derive/mod.rs index 8e8f7bb7..1d1e8300 100644 --- a/src/base/macros/src/command_derive/mod.rs +++ b/src/base/macros/src/command_derive/mod.rs @@ -1,360 +1,15 @@ use proc_macro::TokenStream; -use quote::{format_ident, quote, quote_spanned}; -use syn::{ - parse_macro_input, spanned::Spanned, Data, DataEnum, DataStruct, DeriveInput, Field, Fields, - Ident, LitStr, Result as SynResult, -}; +use syn::{parse_macro_input, DeriveInput}; + +mod attrs; +mod expand; +mod fields; pub fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - match expand(input) { + match expand::expand(input) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } } - -fn expand(input: DeriveInput) -> SynResult { - let ident = input.ident; - let command_name = command_name(&input.attrs)?; - - match input.data { - Data::Enum(data_enum) => expand_enum(&ident, command_name, data_enum), - Data::Struct(data_struct) => expand_struct(&ident, command_name, data_struct), - Data::Union(_) => Err(syn::Error::new( - ident.span(), - "Command can only be derived for enums or structs", - )), - } -} - -fn expand_enum( - ident: &Ident, - command_name: LitStr, - data_enum: DataEnum, -) -> SynResult { - let mut parse_arms = Vec::new(); - let mut path_entries = Vec::new(); - let mut greedy_assertions = Vec::new(); - - for variant in data_enum.variants { - let variant_ident = variant.ident; - let fields = CommandFields::from_fields(variant.fields)?; - let field_parse = FieldParse::new(fields.fields(), ident)?; - - greedy_assertions.extend(field_parse.greedy_assertions); - - let constructor = match &fields { - CommandFields::Unnamed(_) => { - let values = &field_parse.tuple_values; - quote! { - Self::#variant_ident(#(#values),*) - } - } - CommandFields::Named(_) => { - let values = &field_parse.named_values; - quote! { - Self::#variant_ident { #(#values),* } - } - } - CommandFields::Unit => quote! { - Self::#variant_ident - }, - }; - - let raw_bindings = &field_parse.raw_bindings; - parse_arms.push(quote! { - { - let __checkpoint = __reader.checkpoint(); - let __result = (|| -> Result { - #(#raw_bindings)* - __reader.expect_end()?; - Ok(#constructor) - })(); - - match __result { - Ok(__command) => return Ok(__command), - Err(__err) => { - __best_error = Some(match __best_error.take() { - Some(__best) => __best.farthest(__err), - None => __err, - }); - __reader.rewind(__checkpoint); - } - } - } - }); - - let segments = &field_parse.segments; - path_entries.push(quote! { - ::temper_command_infra::CommandPath::new(#command_name, vec![#(#segments),*]) - }); - } - - let parse_body = quote! { - let mut __best_error: Option<::temper_command_infra::ParseError> = None; - - #(#parse_arms)* - - Err(__best_error.unwrap_or_else(|| { - ::temper_command_infra::ParseError::expected(__reader.cursor(), "command variant") - })) - }; - - Ok(expand_command_impl( - ident, - command_name, - parse_body, - path_entries, - greedy_assertions, - )) -} - -fn expand_struct( - ident: &Ident, - command_name: LitStr, - data_struct: DataStruct, -) -> SynResult { - let fields = CommandFields::from_fields(data_struct.fields)?; - let field_parse = FieldParse::new(fields.fields(), ident)?; - let raw_bindings = &field_parse.raw_bindings; - - let constructor = match &fields { - CommandFields::Unnamed(_) => { - let values = &field_parse.tuple_values; - quote! { - Self(#(#values),*) - } - } - CommandFields::Named(_) => { - let values = &field_parse.named_values; - quote! { - Self { #(#values),* } - } - } - CommandFields::Unit => quote! { - Self - }, - }; - - let parse_body = quote! { - #(#raw_bindings)* - __reader.expect_end()?; - Ok(#constructor) - }; - - let segments = &field_parse.segments; - let path_entries = vec![quote! { - ::temper_command_infra::CommandPath::new(#command_name, vec![#(#segments),*]) - }]; - - Ok(expand_command_impl( - ident, - command_name, - parse_body, - path_entries, - field_parse.greedy_assertions, - )) -} - -fn expand_command_impl( - ident: &Ident, - command_name: LitStr, - parse_body: proc_macro2::TokenStream, - path_entries: Vec, - greedy_assertions: Vec, -) -> proc_macro2::TokenStream { - let registration = expand_registration(ident); - - quote! { - #(#greedy_assertions)* - - impl ::temper_command_infra::CommandSpec for #ident { - const NAME: &'static str = #command_name; - - fn parse_reader( - __reader: &mut ::temper_command_infra::CommandReader<'_>, - ) -> Result { - #parse_body - } - - fn paths() -> Vec<::temper_command_infra::CommandPath> { - vec![#(#path_entries),*] - } - } - - #registration - } -} - -fn expand_registration(ident: &Ident) -> proc_macro2::TokenStream { - let register_fn = format_ident!("__{}_register_command", ident); - let register_system_fn = format_ident!("__{}_register_command_system", ident); - - quote! { - #[::temper_command_infra::ctor::ctor(unsafe)] - #[allow(non_snake_case)] - #[doc(hidden)] - fn #register_fn() { - ::temper_command_infra::register_static_command( - ::temper_command_infra::RegisteredCommand::of::<#ident>(), - ); - } - - #[::temper_command_infra::ctor::ctor(unsafe)] - #[allow(non_snake_case)] - #[doc(hidden)] - fn #register_system_fn() { - ::temper_command_infra::add_system( - ::temper_command_infra::dispatch_command::<#ident>, - ); - } - } -} - -enum CommandFields { - Unnamed(Vec), - Named(Vec), - Unit, -} - -impl CommandFields { - fn from_fields(fields: Fields) -> SynResult { - match fields { - Fields::Unnamed(fields) => Ok(Self::Unnamed( - fields.unnamed.into_iter().map(CommandField::from).collect(), - )), - Fields::Named(fields) => Ok(Self::Named( - fields - .named - .into_iter() - .map(|field| { - let ident = field.ident.clone().ok_or_else(|| { - syn::Error::new(field.span(), "named command fields must have names") - })?; - Ok(CommandField { - ident: Some(ident), - field, - }) - }) - .collect::>>()?, - )), - Fields::Unit => Ok(Self::Unit), - } - } - - fn fields(&self) -> &[CommandField] { - match self { - CommandFields::Unnamed(fields) | CommandFields::Named(fields) => fields, - CommandFields::Unit => &[], - } - } -} - -struct FieldParse { - raw_bindings: Vec, - tuple_values: Vec, - named_values: Vec, - segments: Vec, - greedy_assertions: Vec, -} - -impl FieldParse { - fn new(fields: &[CommandField], _ident: &Ident) -> SynResult { - let last_field_idx = fields.len().saturating_sub(1); - let mut raw_bindings = Vec::new(); - let mut tuple_values = Vec::new(); - let mut named_values = Vec::new(); - let mut segments = Vec::new(); - let mut greedy_assertions = Vec::new(); - - for (idx, command_field) in fields.iter().enumerate() { - let arg_name = arg_name(command_field)?; - let field = &command_field.field; - let ty = &field.ty; - let raw_ident = format_ident!("__raw_{idx}"); - - raw_bindings.push(quote! { - let #raw_ident = <#ty as ::temper_command_infra::CommandArg>::recognize(__reader)?; - }); - - tuple_values.push(quote! { - <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? - }); - - if let Some(field_ident) = &command_field.ident { - named_values.push(quote! { - #field_ident: <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? - }); - } - - segments.push(quote! { - ::temper_command_infra::CommandPathSegment::argument( - #arg_name, - <#ty as ::temper_command_infra::CommandArg>::argument_spec(), - ) - }); - - if idx != last_field_idx { - greedy_assertions.push(quote_spanned! { ty.span() => - const _: () = assert!( - !matches!( - <#ty as ::temper_command_infra::CommandArg>::KIND, - ::temper_command_infra::ArgKind::GreedyTail - ), - "greedy-tail command args must be the final field in a command variant" - ); - }); - } - } - - Ok(Self { - raw_bindings, - tuple_values, - named_values, - segments, - greedy_assertions, - }) - } -} - -struct CommandField { - ident: Option, - field: Field, -} - -impl From for CommandField { - fn from(field: Field) -> Self { - Self { ident: None, field } - } -} - -fn command_name(attrs: &[syn::Attribute]) -> SynResult { - for attr in attrs { - if attr.path().is_ident("command") { - return attr.parse_args::(); - } - } - - Err(syn::Error::new( - proc_macro2::Span::call_site(), - "missing #[command(\"name\")] attribute", - )) -} - -fn arg_name(command_field: &CommandField) -> SynResult { - for attr in &command_field.field.attrs { - if attr.path().is_ident("arg") { - return attr.parse_args::(); - } - } - - if let Some(ident) = &command_field.ident { - return Ok(LitStr::new(&ident.to_string(), ident.span())); - } - - Err(syn::Error::new( - command_field.field.span(), - "command tuple fields must have #[arg(\"name\")]", - )) -} diff --git a/src/base/macros/src/lib.rs b/src/base/macros/src/lib.rs index b39bc0da..1b2b9893 100644 --- a/src/base/macros/src/lib.rs +++ b/src/base/macros/src/lib.rs @@ -80,7 +80,7 @@ pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream { commands::command(attr, input) } -#[proc_macro_derive(Command, attributes(command, arg))] +#[proc_macro_derive(Command, attributes(command, arg, literal, subcommand))] pub fn command_derive(input: TokenStream) -> TokenStream { command_derive::derive(input) } diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index 3f3efe0c..b4d48c20 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -16,6 +16,7 @@ pub use ecs::{ }; pub use error::ParseError; pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; +pub use metadata::SubcommandSpec; pub use metadata::{ ArgKind, ArgumentSpec, CommandArg, CommandPath, CommandPathSegment, CommandSpec, IntegerProperties, ParserKind, ParserProperties, StringMode, diff --git a/src/command-infra/src/metadata.rs b/src/command-infra/src/metadata.rs index 263c19a8..bcc020c3 100644 --- a/src/command-infra/src/metadata.rs +++ b/src/command-infra/src/metadata.rs @@ -119,3 +119,9 @@ pub trait CommandSpec: Sized { Self::parse_reader(&mut reader) } } + +pub trait SubcommandSpec: Sized { + fn parse_reader(reader: &mut CommandReader<'_>) -> Result; + + fn segments() -> Vec>; +} diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index 9b742c15..ec0834f0 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -63,6 +63,27 @@ struct MeCommand { action: GreedyStringArg, } +#[derive(Debug, PartialEq, Command)] +#[command(name = "time")] +enum TimeCommand { + #[subcommand("set")] + Set(SetTimeCommand), + #[literal("add")] + Add { amount: IntegerArg<0, 24000> }, +} + +#[derive(Debug, PartialEq, Command)] +#[command(subcommand)] +enum SetTimeCommand { + #[literal("day")] + Day, + #[literal("night")] + Night, + Ticks { + value: IntegerArg<0, 24000>, + }, +} + macro_rules! impl_noop_handler { ($($command:ty),* $(,)?) => { $( @@ -88,6 +109,7 @@ impl_noop_handler!( RenameCommand, StopCommand, MeCommand, + TimeCommand, ); #[test] @@ -264,3 +286,58 @@ fn named_struct_command_uses_field_names_in_graph() { assert_eq!(action.name.as_deref(), Some("action")); assert!(action.executable); } + +#[test] +fn nested_subcommand_literal_parses() { + let command = TimeCommand::parse("set day").unwrap(); + + assert!(matches!(command, TimeCommand::Set(SetTimeCommand::Day))); +} + +#[test] +fn nested_subcommand_arg_parses() { + let command = TimeCommand::parse("set 1200").unwrap(); + + assert!(matches!( + command, + TimeCommand::Set(SetTimeCommand::Ticks { .. }) + )); +} + +#[test] +fn literal_variant_with_args_parses() { + let command = TimeCommand::parse("add 20").unwrap(); + + assert!(matches!(command, TimeCommand::Add { .. })); +} + +#[test] +fn nested_subcommands_generate_literal_graph_paths() { + let graph = CommandGraph::from_paths(&TimeCommand::paths()); + let time_idx = graph.nodes[graph.root_idx].children[0]; + let time = &graph.nodes[time_idx]; + + let time_children = time + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(time_children, vec!["set", "add"]); + + let set_idx = time + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("set")) + .unwrap(); + let set = &graph.nodes[set_idx]; + let set_children = set + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(set_children, vec!["day", "night", "value"]); + assert!(set.children.iter().all(|idx| graph.nodes[*idx].executable)); +} diff --git a/src/default_commands/src/new/tp.rs b/src/default_commands/src/new/tp.rs index 771620b2..6450ac47 100644 --- a/src/default_commands/src/new/tp.rs +++ b/src/default_commands/src/new/tp.rs @@ -15,7 +15,6 @@ use tracing::info; #[derive(Command)] #[command("tp")] -#[allow(dead_code)] enum TpCommand { TpToPos { location: PositionArg, From 451f874981b2f8338925c2144114c7414bcb3504 Mon Sep 17 00:00:00 2001 From: ReCore Date: Fri, 26 Jun 2026 17:06:30 +0930 Subject: [PATCH 11/30] permissions and aliases on args/subcommands --- src/base/macros/src/command_derive/attrs.rs | 150 +++++++++-- src/base/macros/src/command_derive/expand.rs | 245 ++++++++++++++---- src/base/macros/src/command_derive/fields.rs | 53 +++- src/base/macros/src/lib.rs | 2 +- src/command-infra/Cargo.toml | 1 + src/command-infra/src/ecs.rs | 91 ++++++- src/command-infra/src/graph.rs | 6 +- src/command-infra/src/lib.rs | 1 + src/command-infra/src/metadata.rs | 85 +++++- src/command-infra/tests/derive_command.rs | 139 +++++++++- src/game_systems/src/packets/Cargo.toml | 1 + .../src/packets/src/command_graph.rs | 16 +- .../src/packets/src/command_suggestions.rs | 28 +- 13 files changed, 710 insertions(+), 108 deletions(-) diff --git a/src/base/macros/src/command_derive/attrs.rs b/src/base/macros/src/command_derive/attrs.rs index e01d3549..cac56dae 100644 --- a/src/base/macros/src/command_derive/attrs.rs +++ b/src/base/macros/src/command_derive/attrs.rs @@ -1,13 +1,36 @@ -use syn::{Attribute, LitStr, Result as SynResult}; +use syn::parse::{Parse, ParseStream}; +use syn::{ + Attribute, Expr, ExprArray, ExprLit, Ident, Lit, LitStr, Path, Result as SynResult, Token, +}; pub enum CommandKind { - Root(LitStr), - Subcommand, + Root(CommandAttrs), + Subcommand(SubcommandAttrs), +} + +pub struct CommandAttrs { + pub name: LitStr, + pub aliases: Vec, + pub permission: Option, +} + +pub struct SubcommandAttrs { + pub permission: Option, } pub enum VariantPrefix { - Literal(LitStr), - Subcommand(LitStr), + Literal(PrefixAttrs), + Subcommand(PrefixAttrs), +} + +pub struct PrefixAttrs { + pub name: LitStr, + pub aliases: Vec, +} + +pub struct VariantAttrs { + pub prefix: Option, + pub permission: Option, } pub fn command_kind(attrs: &[Attribute]) -> SynResult { @@ -17,16 +40,28 @@ pub fn command_kind(attrs: &[Attribute]) -> SynResult { } if let Ok(name) = attr.parse_args::() { - return Ok(CommandKind::Root(name)); + return Ok(CommandKind::Root(CommandAttrs { + name, + aliases: Vec::new(), + permission: None, + })); } let mut name = None; + let mut aliases = Vec::new(); + let mut permission = None; let mut subcommand = false; attr.parse_nested_meta(|meta| { if meta.path.is_ident("name") { name = Some(meta.value()?.parse::()?); Ok(()) + } else if meta.path.is_ident("aliases") { + aliases = parse_aliases(meta.value()?.parse::()?)?; + Ok(()) + } else if meta.path.is_ident("permission") { + permission = Some(meta.value()?.parse::()?); + Ok(()) } else if meta.path.is_ident("subcommand") { subcommand = true; Ok(()) @@ -36,8 +71,12 @@ pub fn command_kind(attrs: &[Attribute]) -> SynResult { })?; return match (name, subcommand) { - (Some(name), false) => Ok(CommandKind::Root(name)), - (None, true) => Ok(CommandKind::Subcommand), + (Some(name), false) => Ok(CommandKind::Root(CommandAttrs { + name, + aliases, + permission, + })), + (None, true) => Ok(CommandKind::Subcommand(SubcommandAttrs { permission })), (Some(_), true) => Err(syn::Error::new_spanned( attr, "command cannot be both named and a subcommand", @@ -55,31 +94,108 @@ pub fn command_kind(attrs: &[Attribute]) -> SynResult { )) } -pub fn variant_prefix(attrs: &[Attribute]) -> SynResult> { +pub fn variant_attrs(attrs: &[Attribute]) -> SynResult { let mut prefix = None; + let mut permission = None; for attr in attrs { let next = if attr.path().is_ident("literal") { - Some(VariantPrefix::Literal(attr.parse_args::()?)) + Some(VariantPrefix::Literal(attr.parse_args::()?)) } else if attr.path().is_ident("subcommand") { - Some(VariantPrefix::Subcommand(attr.parse_args::()?)) + Some(VariantPrefix::Subcommand(attr.parse_args::()?)) } else { None }; - let Some(next) = next else { + if let Some(next) = next { + if prefix.is_some() { + return Err(syn::Error::new_spanned( + attr, + "command variants can only have one #[literal(...)] or #[subcommand(...)] attribute", + )); + } + + prefix = Some(next); continue; - }; + } + + if attr.path().is_ident("permission") { + if permission.is_some() { + return Err(syn::Error::new_spanned( + attr, + "command variants can only have one #[permission(...)] attribute", + )); + } + + permission = Some(attr.parse_args::()?); + } + } + + Ok(VariantAttrs { prefix, permission }) +} + +pub fn permission_attr(attrs: &[Attribute]) -> SynResult> { + let mut permission = None; + + for attr in attrs { + if !attr.path().is_ident("permission") { + continue; + } - if prefix.is_some() { + if permission.is_some() { return Err(syn::Error::new_spanned( attr, - "command variants can only have one #[literal(...)] or #[subcommand(...)] attribute", + "fields can only have one #[permission(...)] attribute", )); } - prefix = Some(next); + permission = Some(attr.parse_args::()?); } - Ok(prefix) + Ok(permission) +} + +fn parse_aliases(aliases: ExprArray) -> SynResult> { + aliases + .elems + .into_iter() + .map(|expr| match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(alias), + .. + }) => Ok(alias), + _ => Err(syn::Error::new_spanned( + expr, + "aliases must be string literals", + )), + }) + .collect() +} + +impl Parse for PrefixAttrs { + fn parse(input: ParseStream<'_>) -> SynResult { + let name = input.parse::()?; + let mut aliases = Vec::new(); + + while input.peek(Token![,]) { + input.parse::()?; + + if input.is_empty() { + break; + } + + let option = input.parse::()?; + if option != "aliases" { + return Err(syn::Error::new_spanned( + option, + "unsupported literal/subcommand option", + )); + } + + input.parse::()?; + aliases = parse_aliases(input.parse::()?)?; + } + + Ok(Self { name, aliases }) + } } diff --git a/src/base/macros/src/command_derive/expand.rs b/src/base/macros/src/command_derive/expand.rs index 6f5761d9..f8a55585 100644 --- a/src/base/macros/src/command_derive/expand.rs +++ b/src/base/macros/src/command_derive/expand.rs @@ -1,7 +1,7 @@ use quote::{format_ident, quote}; use syn::{Data, DataEnum, DataStruct, DeriveInput, Ident, LitStr, Result as SynResult}; -use super::attrs::{command_kind, variant_prefix, CommandKind, VariantPrefix}; +use super::attrs::{command_kind, variant_attrs, CommandKind, PrefixAttrs, VariantPrefix}; use super::fields::{CommandFields, FieldParse}; pub fn expand(input: DeriveInput) -> SynResult { @@ -29,19 +29,21 @@ fn expand_enum( for variant in data_enum.variants { let variant_ident = variant.ident; - let prefix = variant_prefix(&variant.attrs)?; + let variant_attrs = variant_attrs(&variant.attrs)?; let fields = CommandFields::from_fields(variant.fields)?; - match prefix { - Some(VariantPrefix::Subcommand(literal)) => { + match variant_attrs.prefix.as_ref() { + Some(VariantPrefix::Subcommand(prefix)) => { let ty = fields.single_unnamed_type()?; - let literal_parse = literal_parse(&literal); + let permission_parse = permission_parse(variant_attrs.permission.as_ref()); + let literal_parse = literal_parse(prefix); parse_arms.push(quote! { { let __checkpoint = __reader.checkpoint(); let __result = (|| -> Result { + #permission_parse #literal_parse - let __subcommand = <#ty as ::temper_command_infra::SubcommandSpec>::parse_reader(__reader)?; + let __subcommand = <#ty as ::temper_command_infra::SubcommandSpec>::parse_reader_with_permissions(__reader, __can_use)?; Ok(Self::#variant_ident(__subcommand)) })(); @@ -58,31 +60,30 @@ fn expand_enum( } }); - segment_entries.push(quote! { - <#ty as ::temper_command_infra::SubcommandSpec>::segments() - .into_iter() - .map(|mut __segments| { - let mut __path = vec![ - ::temper_command_infra::CommandPathSegment::literal(#literal), - ]; - __path.append(&mut __segments); - __path - }) - .collect::>() - }); + segment_entries.push(subcommand_segment_entries( + prefix, + variant_attrs.permission.as_ref(), + ty, + )); } - prefix => { + _ => { let field_parse = FieldParse::new(fields.fields())?; greedy_assertions.extend(field_parse.greedy_assertions.clone()); let constructor = constructor(ident, &variant_ident, &fields, &field_parse); let raw_bindings = &field_parse.raw_bindings; - let prefix_parse = prefix_parse(prefix.as_ref()); - let prefix_segments = prefix_segments(prefix.as_ref()); + let permission_parse = permission_parse(variant_attrs.permission.as_ref()); + let prefix_parse = prefix_parse(variant_attrs.prefix.as_ref()); + let variant_segment_entries = variant_segment_entries( + variant_attrs.prefix.as_ref(), + variant_attrs.permission.as_ref(), + &field_parse.segments, + ); parse_arms.push(quote! { { let __checkpoint = __reader.checkpoint(); let __result = (|| -> Result { + #permission_parse #prefix_parse #(#raw_bindings)* __reader.expect_end()?; @@ -102,15 +103,7 @@ fn expand_enum( } }); - let segments = &field_parse.segments; - segment_entries.push(quote! { - vec![{ - let mut __segments = Vec::new(); - #prefix_segments - __segments.extend(vec![#(#segments),*]); - __segments - }] - }); + segment_entries.push(variant_segment_entries); } } } @@ -180,7 +173,11 @@ fn expand_impl( }; match command_kind { - CommandKind::Root(command_name) => { + CommandKind::Root(command_attrs) => { + let command_name = command_attrs.name; + let aliases = command_attrs.aliases; + let command_permission_parse = permission_parse(command_attrs.permission.as_ref()); + let permission_fn = permission_fn(command_attrs.permission.as_ref()); let registration = expand_registration(ident); quote! { @@ -192,9 +189,24 @@ fn expand_impl( fn parse_reader( __reader: &mut ::temper_command_infra::CommandReader<'_>, ) -> Result { + let __can_use = |_| true; + Self::parse_reader_with_permissions(__reader, &__can_use) + } + + fn parse_reader_with_permissions( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + __can_use: &dyn Fn(::temper_command_infra::Permissions) -> bool, + ) -> Result { + #command_permission_parse #parse_body } + fn aliases() -> &'static [&'static str] { + &[#(#aliases),*] + } + + #permission_fn + fn paths() -> Vec<::temper_command_infra::CommandPath> { #segment_builder .into_iter() @@ -208,21 +220,38 @@ fn expand_impl( #registration } } - CommandKind::Subcommand => quote! { - #(#greedy_assertions)* - - impl ::temper_command_infra::SubcommandSpec for #ident { - fn parse_reader( - __reader: &mut ::temper_command_infra::CommandReader<'_>, - ) -> Result { - #parse_body - } + CommandKind::Subcommand(subcommand_attrs) => { + let permission_parse = permission_parse(subcommand_attrs.permission.as_ref()); + let subcommand_segments = subcommand_segments_with_permission( + subcommand_attrs.permission.as_ref(), + segment_builder, + ); - fn segments() -> Vec> { - #segment_builder + quote! { + #(#greedy_assertions)* + + impl ::temper_command_infra::SubcommandSpec for #ident { + fn parse_reader( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + ) -> Result { + let __can_use = |_| true; + Self::parse_reader_with_permissions(__reader, &__can_use) + } + + fn parse_reader_with_permissions( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + __can_use: &dyn Fn(::temper_command_infra::Permissions) -> bool, + ) -> Result { + #permission_parse + #parse_body + } + + fn segments() -> Vec> { + #subcommand_segments + } } } - }, + } } } @@ -301,25 +330,141 @@ fn struct_constructor( fn prefix_parse(prefix: Option<&VariantPrefix>) -> proc_macro2::TokenStream { match prefix { - Some(VariantPrefix::Literal(literal)) => literal_parse(literal), + Some(VariantPrefix::Literal(prefix)) => literal_parse(prefix), Some(VariantPrefix::Subcommand(_)) | None => quote! {}, } } -fn prefix_segments(prefix: Option<&VariantPrefix>) -> proc_macro2::TokenStream { +fn variant_segment_entries( + prefix: Option<&VariantPrefix>, + permission: Option<&syn::Path>, + segments: &[proc_macro2::TokenStream], +) -> proc_macro2::TokenStream { match prefix { - Some(VariantPrefix::Literal(literal)) => quote! { - __segments.push(::temper_command_infra::CommandPathSegment::literal(#literal)); + Some(VariantPrefix::Literal(prefix)) => { + let literal_segments = literal_segments(prefix, permission); + let trailing_segments = quote! { + vec![#(#segments),*] + }; + + quote! { + vec![ + #({ + let mut __segments = vec![#literal_segments]; + __segments.extend(#trailing_segments); + __segments + }),* + ] + } + } + Some(VariantPrefix::Subcommand(_)) | None => { + quote! { + vec![vec![#(#segments),*]] + } + } + } +} + +fn subcommand_segment_entries( + prefix: &PrefixAttrs, + permission: Option<&syn::Path>, + ty: &syn::Type, +) -> proc_macro2::TokenStream { + let literal_segments = literal_segments(prefix, permission); + + quote! { + { + let __subcommand_paths = <#ty as ::temper_command_infra::SubcommandSpec>::segments(); + let mut __paths = Vec::new(); + + #( + __paths.extend(__subcommand_paths.iter().cloned().map(|mut __segments| { + let mut __path = vec![#literal_segments]; + __path.append(&mut __segments); + __path + })); + )* + + __paths + } + } +} + +fn literal_segments( + prefix: &PrefixAttrs, + permission: Option<&syn::Path>, +) -> Vec { + std::iter::once(&prefix.name) + .chain(prefix.aliases.iter()) + .map(|literal| literal_segment(literal, permission)) + .collect() +} + +fn literal_segment(literal: &LitStr, permission: Option<&syn::Path>) -> proc_macro2::TokenStream { + let segment = quote! { + ::temper_command_infra::CommandPathSegment::literal(#literal) + }; + + match permission { + Some(permission) => quote! { + #segment.with_permission(#permission) }, - Some(VariantPrefix::Subcommand(_)) | None => quote! {}, + None => segment, } } -fn literal_parse(literal: &LitStr) -> proc_macro2::TokenStream { +fn permission_parse(permission: Option<&syn::Path>) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + if !__can_use(#permission) { + return Err(::temper_command_infra::ParseError::new( + __reader.cursor(), + "permission", + "you do not have permission to use this command path", + )); + } + }, + None => quote! {}, + } +} + +fn permission_fn(permission: Option<&syn::Path>) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + fn permission() -> Option<::temper_command_infra::Permissions> { + Some(#permission) + } + }, + None => quote! {}, + } +} + +fn subcommand_segments_with_permission( + permission: Option<&syn::Path>, + segment_builder: proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + let mut __segments = #segment_builder; + for __path in &mut __segments { + if let Some(__first) = __path.first_mut() { + *__first = __first.clone().with_permission(#permission); + } + } + __segments + }, + None => segment_builder, + } +} + +fn literal_parse(prefix: &PrefixAttrs) -> proc_macro2::TokenStream { + let literal = &prefix.name; + let aliases = &prefix.aliases; + quote! { let __literal_cursor = __reader.cursor(); let __actual_literal = __reader.read_word_span()?; - if __actual_literal != #literal { + if __actual_literal != #literal #(&& __actual_literal != #aliases)* { return Err(::temper_command_infra::ParseError::new( __literal_cursor, #literal, diff --git a/src/base/macros/src/command_derive/fields.rs b/src/base/macros/src/command_derive/fields.rs index 4dd270aa..e9007533 100644 --- a/src/base/macros/src/command_derive/fields.rs +++ b/src/base/macros/src/command_derive/fields.rs @@ -1,5 +1,7 @@ use quote::{format_ident, quote, quote_spanned}; -use syn::{spanned::Spanned, Field, Fields, Ident, LitStr, Result as SynResult, Type}; +use syn::{spanned::Spanned, Field, Fields, Ident, LitStr, Path, Result as SynResult, Type}; + +use super::attrs::permission_attr; pub enum CommandFields { Unnamed(Vec), @@ -11,7 +13,11 @@ impl CommandFields { pub fn from_fields(fields: Fields) -> SynResult { match fields { Fields::Unnamed(fields) => Ok(Self::Unnamed( - fields.unnamed.into_iter().map(CommandField::from).collect(), + fields + .unnamed + .into_iter() + .map(CommandField::unnamed) + .collect::>>()?, )), Fields::Named(fields) => Ok(Self::Named( fields @@ -23,6 +29,7 @@ impl CommandFields { })?; Ok(CommandField { ident: Some(ident), + permission: permission_attr(&field.attrs)?, field, }) }) @@ -83,8 +90,10 @@ impl FieldParse { let field = &command_field.field; let ty = &field.ty; let raw_ident = format_ident!("__raw_{idx}"); + let permission_check = permission_check(command_field.permission.as_ref()); raw_bindings.push(quote! { + #permission_check let #raw_ident = <#ty as ::temper_command_infra::CommandArg>::recognize(__reader)?; }); @@ -98,12 +107,20 @@ impl FieldParse { }); } - segments.push(quote! { + let mut segment = quote! { ::temper_command_infra::CommandPathSegment::argument( #arg_name, <#ty as ::temper_command_infra::CommandArg>::argument_spec(), ) - }); + }; + + if let Some(permission) = &command_field.permission { + segment = quote! { + #segment.with_permission(#permission) + }; + } + + segments.push(segment); if idx != last_field_idx { greedy_assertions.push(quote_spanned! { ty.span() => @@ -130,12 +147,19 @@ impl FieldParse { pub struct CommandField { ident: Option, + permission: Option, field: Field, } -impl From for CommandField { - fn from(field: Field) -> Self { - Self { ident: None, field } +impl CommandField { + fn unnamed(field: Field) -> SynResult { + let permission = permission_attr(&field.attrs)?; + + Ok(Self { + ident: None, + permission, + field, + }) } } @@ -155,3 +179,18 @@ fn arg_name(command_field: &CommandField) -> SynResult { "command tuple fields must have #[arg(\"name\")]", )) } + +fn permission_check(permission: Option<&Path>) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + if !__can_use(#permission) { + return Err(::temper_command_infra::ParseError::new( + __reader.cursor(), + "permission", + "you do not have permission to use this command argument", + )); + } + }, + None => quote! {}, + } +} diff --git a/src/base/macros/src/lib.rs b/src/base/macros/src/lib.rs index 1b2b9893..fbb7fad1 100644 --- a/src/base/macros/src/lib.rs +++ b/src/base/macros/src/lib.rs @@ -80,7 +80,7 @@ pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream { commands::command(attr, input) } -#[proc_macro_derive(Command, attributes(command, arg, literal, subcommand))] +#[proc_macro_derive(Command, attributes(command, arg, literal, subcommand, permission))] pub fn command_derive(input: TokenStream) -> TokenStream { command_derive::derive(input) } diff --git a/src/command-infra/Cargo.toml b/src/command-infra/Cargo.toml index 7e7c0056..80502e30 100644 --- a/src/command-infra/Cargo.toml +++ b/src/command-infra/Cargo.toml @@ -8,6 +8,7 @@ bevy_ecs = { workspace = true } ctor = { workspace = true } temper-core = { workspace = true } temper-components = { workspace = true } +temper-permissions = { workspace = true } temper-text = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs index d56fa391..38a1aca0 100644 --- a/src/command-infra/src/ecs.rs +++ b/src/command-infra/src/ecs.rs @@ -2,11 +2,13 @@ use std::sync::{LazyLock, RwLock}; use std::{cell::RefCell, sync::Arc}; use bevy_ecs::prelude::{ - Component, Entity, IntoScheduleConfigs, Message, MessageReader, Resource, Schedule, + Component, Entity, IntoScheduleConfigs, Message, MessageReader, Query, Resource, Schedule, }; use bevy_ecs::schedule::ScheduleConfigs; use bevy_ecs::system::{ScheduleSystem, SystemParam}; use temper_core::mq; +use temper_permissions::Permissions; +use temper_permissions::player::PlayerPermission; use temper_text::{NamedColor, TextComponentBuilder}; use tracing::info; @@ -22,16 +24,34 @@ thread_local! { #[derive(Clone, Debug)] pub struct RegisteredCommand { pub name: &'static str, + pub aliases: &'static [&'static str], + pub permission: Option, pub paths: Vec, } impl RegisteredCommand { pub fn of() -> Self { + let primary_paths = C::paths() + .into_iter() + .map(|path| path.with_permission(C::permission())); + let alias_paths = C::aliases().iter().flat_map(|alias| { + let alias = *alias; + C::paths() + .into_iter() + .map(move |path| path.with_root(alias).with_permission(C::permission())) + }); + Self { name: C::NAME, - paths: C::paths(), + aliases: C::aliases(), + permission: C::permission(), + paths: primary_paths.chain(alias_paths).collect(), } } + + pub fn matches_root(&self, root: &str) -> bool { + self.name == root || self.aliases.contains(&root) + } } pub fn register_static_command(command: RegisteredCommand) { @@ -89,15 +109,29 @@ pub fn send_parse_error(source: CommandSource, error: &ParseError) { pub fn dispatch_command( mut commands: MessageReader, + permissions: Query<&PlayerPermission>, mut params: C::SystemParam<'_, '_>, ) { for event in commands.read() { - if command_root(&event.input) != Some(C::NAME) { + let Some(root) = command_root(&event.input) else { + continue; + }; + + if root != C::NAME && !C::aliases().contains(&root) { continue; } - let input = command_args(&event.input, C::NAME); - match C::parse(input) { + let can_use = |permission| source_can_use(event.source, &permissions, permission); + if let Some(permission) = C::permission() + && !can_use(permission) + { + send_permission_error(event.source); + continue; + } + + let input = command_args(&event.input, root); + let mut reader = crate::CommandReader::new(input); + match C::parse_reader_with_permissions(&mut reader, &can_use) { Ok(command) => command.handle(event.source, &mut params), Err(error) => C::handle_parse_error(event.source, error, &mut params), } @@ -132,15 +166,32 @@ impl CommandRegistry { command_root(input).is_some_and(|input_root| { self.commands .iter() - .filter_map(|command| command_root(command.name)) - .any(|command_root| command_root == input_root) + .any(|command| command.matches_root(input_root)) }) } pub fn paths_for_player(&self, _player: Entity) -> Vec { + self.paths_for_permissions(|_| true) + } + + pub fn paths_for_player_permissions( + &self, + _player: Entity, + permissions: Option<&PlayerPermission>, + ) -> Vec { + self.paths_for_permissions(|permission| permissions.is_some_and(|p| p.can(permission))) + } + + pub fn paths_for_permissions(&self, can_use: impl Fn(Permissions) -> bool) -> Vec { self.commands .iter() - .flat_map(|command| command.paths.iter().cloned()) + .flat_map(|command| { + command + .paths + .iter() + .filter(|path| path.is_allowed_by(&can_use)) + .cloned() + }) .collect() } @@ -157,6 +208,30 @@ fn command_args<'a>(input: &'a str, root: &str) -> &'a str { input.strip_prefix(root).unwrap_or(input).trim_start() } +fn source_can_use( + source: CommandSource, + permissions: &Query<&PlayerPermission>, + permission: Permissions, +) -> bool { + match source { + CommandSource::Server => true, + CommandSource::Player(entity) => permissions + .get(entity) + .is_ok_and(|player_permissions| player_permissions.can(permission)), + } +} + +fn send_permission_error(source: CommandSource) { + let message = TextComponentBuilder::new("You don't have permission to use this command.") + .color(NamedColor::Red) + .build(); + + match source { + CommandSource::Player(entity) => mq::queue(message, false, entity), + CommandSource::Server => info!("{}", message.to_plain_text()), + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CommandSource { Player(Entity), diff --git a/src/command-infra/src/graph.rs b/src/command-infra/src/graph.rs index f845eddd..7d5748ba 100644 --- a/src/command-infra/src/graph.rs +++ b/src/command-infra/src/graph.rs @@ -49,7 +49,7 @@ impl CommandNode { fn matches_segment(&self, segment: &CommandPathSegment) -> bool { match segment { - CommandPathSegment::Literal(name) => { + CommandPathSegment::Literal { name, .. } => { self.kind == CommandNodeKind::Literal && self.name.as_deref() == Some(*name) } CommandPathSegment::Argument { spec, .. } => { @@ -118,8 +118,8 @@ impl CommandGraph { } let node = match segment { - CommandPathSegment::Literal(name) => CommandNode::literal(name), - CommandPathSegment::Argument { name, spec } => CommandNode::argument(name, spec), + CommandPathSegment::Literal { name, .. } => CommandNode::literal(name), + CommandPathSegment::Argument { name, spec, .. } => CommandNode::argument(name, spec), }; let priority = node.child_priority(); diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index b4d48c20..61730595 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -22,3 +22,4 @@ pub use metadata::{ IntegerProperties, ParserKind, ParserProperties, StringMode, }; pub use reader::{Checkpoint, CommandReader}; +pub use temper_permissions::Permissions; diff --git a/src/command-infra/src/metadata.rs b/src/command-infra/src/metadata.rs index bcc020c3..8f555346 100644 --- a/src/command-infra/src/metadata.rs +++ b/src/command-infra/src/metadata.rs @@ -1,4 +1,5 @@ use crate::{CommandReader, ParseError}; +use temper_permissions::Permissions; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ArgKind { @@ -78,40 +79,109 @@ pub trait CommandArg: Sized { #[derive(Clone, Debug, Eq, PartialEq)] pub enum CommandPathSegment { - Literal(&'static str), + Literal { + name: &'static str, + permission: Option, + }, Argument { name: &'static str, spec: ArgumentSpec, + permission: Option, }, } impl CommandPathSegment { pub const fn literal(name: &'static str) -> Self { - Self::Literal(name) + Self::Literal { + name, + permission: None, + } } pub const fn argument(name: &'static str, spec: ArgumentSpec) -> Self { - Self::Argument { name, spec } + Self::Argument { + name, + spec, + permission: None, + } + } + + pub const fn with_permission(mut self, permission: Permissions) -> Self { + match &mut self { + Self::Literal { + permission: segment_permission, + .. + } + | Self::Argument { + permission: segment_permission, + .. + } => *segment_permission = Some(permission), + } + self + } + + pub const fn permission(&self) -> Option { + match self { + Self::Literal { permission, .. } | Self::Argument { permission, .. } => *permission, + } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommandPath { pub root: &'static str, + pub permission: Option, pub segments: Vec, } impl CommandPath { pub fn new(root: &'static str, segments: Vec) -> Self { - Self { root, segments } + Self { + root, + permission: None, + segments, + } + } + + pub fn with_permission(mut self, permission: Option) -> Self { + self.permission = permission; + self + } + + pub fn with_root(mut self, root: &'static str) -> Self { + self.root = root; + self + } + + pub fn is_allowed_by(&self, can_use: impl Fn(Permissions) -> bool) -> bool { + self.permission.map_or(true, &can_use) + && self + .segments + .iter() + .all(|segment| segment.permission().map_or(true, &can_use)) } } pub trait CommandSpec: Sized { const NAME: &'static str; + fn aliases() -> &'static [&'static str] { + &[] + } + + fn permission() -> Option { + None + } + fn parse_reader(reader: &mut CommandReader<'_>) -> Result; + fn parse_reader_with_permissions( + reader: &mut CommandReader<'_>, + _can_use: &dyn Fn(Permissions) -> bool, + ) -> Result { + Self::parse_reader(reader) + } + fn paths() -> Vec; fn parse(input: &str) -> Result { @@ -123,5 +193,12 @@ pub trait CommandSpec: Sized { pub trait SubcommandSpec: Sized { fn parse_reader(reader: &mut CommandReader<'_>) -> Result; + fn parse_reader_with_permissions( + reader: &mut CommandReader<'_>, + _can_use: &dyn Fn(Permissions) -> bool, + ) -> Result { + Self::parse_reader(reader) + } + fn segments() -> Vec>; } diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index ec0834f0..f26547c8 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -2,7 +2,8 @@ use temper_command_infra::args::{ EntityArg, GreedyStringArg, IntegerArg, PositionArg, SingleWordArg, }; use temper_command_infra::{ - CommandGraph, CommandHandler, CommandNodeKind, CommandSource, CommandSpec, + CommandGraph, CommandHandler, CommandNodeKind, CommandReader, CommandSource, CommandSpec, + Permissions, }; use temper_macros::Command; @@ -66,16 +67,21 @@ struct MeCommand { #[derive(Debug, PartialEq, Command)] #[command(name = "time")] enum TimeCommand { - #[subcommand("set")] + #[subcommand("set", aliases = ["s"])] + #[permission(Permissions::Op)] Set(SetTimeCommand), #[literal("add")] - Add { amount: IntegerArg<0, 24000> }, + Add { + #[permission(Permissions::Kill)] + amount: IntegerArg<0, 24000>, + }, } #[derive(Debug, PartialEq, Command)] #[command(subcommand)] enum SetTimeCommand { - #[literal("day")] + #[literal("day", aliases = ["d"])] + #[permission(Permissions::DeOp)] Day, #[literal("night")] Night, @@ -84,6 +90,14 @@ enum SetTimeCommand { }, } +#[derive(Debug, PartialEq, Command)] +#[command( + name = "alias-demo", + aliases = ["ad", "demoalias"], + permission = Permissions::Teleport +)] +struct AliasCommand; + macro_rules! impl_noop_handler { ($($command:ty),* $(,)?) => { $( @@ -110,6 +124,7 @@ impl_noop_handler!( StopCommand, MeCommand, TimeCommand, + AliasCommand, ); #[test] @@ -294,6 +309,20 @@ fn nested_subcommand_literal_parses() { assert!(matches!(command, TimeCommand::Set(SetTimeCommand::Day))); } +#[test] +fn nested_subcommand_alias_parses() { + let command = TimeCommand::parse("s night").unwrap(); + + assert!(matches!(command, TimeCommand::Set(SetTimeCommand::Night))); +} + +#[test] +fn nested_literal_alias_parses() { + let command = TimeCommand::parse("set d").unwrap(); + + assert!(matches!(command, TimeCommand::Set(SetTimeCommand::Day))); +} + #[test] fn nested_subcommand_arg_parses() { let command = TimeCommand::parse("set 1200").unwrap(); @@ -323,7 +352,7 @@ fn nested_subcommands_generate_literal_graph_paths() { .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) .collect::>(); - assert_eq!(time_children, vec!["set", "add"]); + assert_eq!(time_children, vec!["set", "s", "add"]); let set_idx = time .children @@ -338,6 +367,104 @@ fn nested_subcommands_generate_literal_graph_paths() { .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) .collect::>(); - assert_eq!(set_children, vec!["day", "night", "value"]); + assert_eq!(set_children, vec!["day", "d", "night", "value"]); assert!(set.children.iter().all(|idx| graph.nodes[*idx].executable)); + + let s_idx = time + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("s")) + .unwrap(); + let s = &graph.nodes[s_idx]; + let s_children = s + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(s_children, vec!["day", "d", "night", "value"]); + assert!(s.children.iter().all(|idx| graph.nodes[*idx].executable)); +} + +#[test] +fn command_aliases_generate_extra_roots() { + let roots = AliasCommand::paths() + .iter() + .map(|path| path.root) + .collect::>(); + + assert_eq!(AliasCommand::aliases(), &["ad", "demoalias"]); + assert_eq!(AliasCommand::permission(), Some(Permissions::Teleport)); + assert_eq!(roots, vec!["alias-demo"]); + + let registry = temper_command_infra::RegisteredCommand::of::(); + let registry_roots = registry + .paths + .iter() + .map(|path| path.root) + .collect::>(); + + assert_eq!(registry_roots, vec!["alias-demo", "ad", "demoalias"]); +} + +#[test] +fn command_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new(""); + let err = AliasCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::Teleport + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn subcommand_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new("set night"); + let err = TimeCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::Op + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn literal_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new("set day"); + let err = TimeCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::DeOp + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn arg_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new("add 20"); + let err = TimeCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::Kill + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn permission_filter_removes_disallowed_paths() { + let registry = temper_command_infra::RegisteredCommand::of::(); + let allowed_paths = registry + .paths + .iter() + .filter(|path| path.is_allowed_by(|permission| permission != Permissions::Op)) + .map(|path| path.segments.first().unwrap()) + .collect::>(); + + assert_eq!(allowed_paths.len(), 1); + assert!(matches!( + allowed_paths[0], + temper_command_infra::CommandPathSegment::Literal { name: "add", .. } + )); } diff --git a/src/game_systems/src/packets/Cargo.toml b/src/game_systems/src/packets/Cargo.toml index 72b2202b..72c61b99 100644 --- a/src/game_systems/src/packets/Cargo.toml +++ b/src/game_systems/src/packets/Cargo.toml @@ -17,6 +17,7 @@ temper-commands = { workspace = true } temper-net-runtime = { workspace = true } temper-codec = { workspace = true } temper-command-infra = { workspace = true } +temper-permissions = { workspace = true } temper-config = { workspace = true } temper-blocks = { workspace = true } once_cell = { workspace = true } diff --git a/src/game_systems/src/packets/src/command_graph.rs b/src/game_systems/src/packets/src/command_graph.rs index eff99d1a..7e740f05 100644 --- a/src/game_systems/src/packets/src/command_graph.rs +++ b/src/game_systems/src/packets/src/command_graph.rs @@ -1,8 +1,11 @@ use std::collections::HashSet; use bevy_ecs::prelude::*; -use temper_command_infra::{CommandRegistry, PlayerCommandGraph, RebuildCommandGraph}; +use temper_command_infra::{ + CommandGraph, CommandRegistry, PlayerCommandGraph, RebuildCommandGraph, +}; use temper_net_runtime::connection::StreamWriter; +use temper_permissions::player::PlayerPermission; use temper_protocol::outgoing::commands::CommandsPacket; use tracing::error; @@ -10,7 +13,11 @@ pub fn rebuild_and_send_command_graphs( mut commands: Commands, mut rebuilds: MessageReader, registry: Res, - query: Query<(&StreamWriter, Option<&PlayerCommandGraph>)>, + query: Query<( + &StreamWriter, + Option<&PlayerCommandGraph>, + Option<&PlayerPermission>, + )>, ) { let players = rebuilds .read() @@ -18,11 +25,12 @@ pub fn rebuild_and_send_command_graphs( .collect::>(); for player in players { - let Ok((writer, previous_graph)) = query.get(player) else { + let Ok((writer, previous_graph, permissions)) = query.get(player) else { continue; }; - let graph = registry.build_graph_for_player(player); + let graph = + CommandGraph::from_paths(®istry.paths_for_player_permissions(player, permissions)); let packet = CommandsPacket::from_command_infra_graph(&graph); if let Err(err) = writer.send_packet(packet) { diff --git a/src/game_systems/src/packets/src/command_suggestions.rs b/src/game_systems/src/packets/src/command_suggestions.rs index 82b60b23..b1438819 100644 --- a/src/game_systems/src/packets/src/command_suggestions.rs +++ b/src/game_systems/src/packets/src/command_suggestions.rs @@ -7,6 +7,7 @@ use temper_codec::net_types::{ use temper_command_infra::{CommandPathSegment, CommandRegistry, ParserKind}; use temper_commands::{Command, CommandContext, CommandInput, ROOT_COMMAND, Sender}; use temper_net_runtime::connection::StreamWriter; +use temper_permissions::player::PlayerPermission; use temper_protocol::CommandSuggestionRequestReceiver; use temper_protocol::outgoing::command_suggestions::{CommandSuggestionsPacket, Match}; use temper_state::{GlobalState, GlobalStateResource}; @@ -69,6 +70,7 @@ fn create_ctx( pub fn handle( receiver: Res, query: Query<&StreamWriter>, + permissions: Query<&PlayerPermission>, registry: Res, state: Res, ) { @@ -82,7 +84,9 @@ pub fn handle( continue; }; - if let Some(response) = new_command_suggestions(&input, ®istry, &state) { + if let Some(response) = + new_command_suggestions(&input, ®istry, &state, permissions.get(entity).ok()) + { send_suggestions(writer, request.transaction_id, response); continue; } @@ -157,6 +161,7 @@ fn new_command_suggestions( input: &str, registry: &CommandRegistry, state: &GlobalStateResource, + permissions: Option<&PlayerPermission>, ) -> Option { let command_input = input.strip_prefix('/').unwrap_or(input); let root_end = command_input @@ -166,7 +171,7 @@ fn new_command_suggestions( let command = registry .commands() .iter() - .find(|command| command.name == root)?; + .find(|command| command.matches_root(root))?; let rest = command_input[root_end..].trim_start(); let current_token = current_token(rest); let completed_tokens = completed_tokens(rest); @@ -176,6 +181,10 @@ fn new_command_suggestions( let matches = command .paths .iter() + .filter(|path| path.root == root) + .filter(|path| { + path.is_allowed_by(|permission| permissions.is_some_and(|p| p.can(permission))) + }) .filter_map(|path| candidate_segment(&path.segments, &completed_tokens)) .filter_map(|segment| segment_suggestions(segment, state)) .flatten() @@ -235,7 +244,7 @@ fn candidate_segment<'a>( fn segment_accepts_token(segment: &CommandPathSegment, token: &str) -> bool { match segment { - CommandPathSegment::Literal(literal) => literal == &token, + CommandPathSegment::Literal { name, .. } => name == &token, CommandPathSegment::Argument { spec, .. } => match spec.parser { ParserKind::Integer => token.parse::().is_ok(), ParserKind::Position => is_coordinate_token(token), @@ -264,7 +273,7 @@ fn segment_suggestions( state: &GlobalStateResource, ) -> Option> { match segment { - CommandPathSegment::Literal(literal) => Some(vec![(*literal).to_string()]), + CommandPathSegment::Literal { name, .. } => Some(vec![(*name).to_string()]), CommandPathSegment::Argument { spec, .. } if is_ask_server(spec.suggestions) => { match spec.parser { ParserKind::Entity => Some(entity_suggestions(state)), @@ -314,6 +323,8 @@ mod tests { let mut registry = CommandRegistry::default(); registry.register_command(RegisteredCommand { name: "tp", + aliases: &[], + permission: None, paths: vec![ CommandPath::new( "tp", @@ -373,7 +384,7 @@ mod tests { .player_list .insert(Entity::PLACEHOLDER, (0, "Alex".to_string())); - let suggestions = new_command_suggestions("/tp ", ®istry(), &state).unwrap(); + let suggestions = new_command_suggestions("/tp ", ®istry(), &state, None).unwrap(); let matches = suggestions .matches .iter() @@ -395,7 +406,8 @@ mod tests { .player_list .insert(Entity::PLACEHOLDER, (0, "Alex".to_string())); - let suggestions = new_command_suggestions("/tp Steve A", ®istry(), &state).unwrap(); + let suggestions = + new_command_suggestions("/tp Steve A", ®istry(), &state, None).unwrap(); let matches = suggestions .matches .iter() @@ -411,7 +423,7 @@ mod tests { fn old_command_suggestions_do_not_handle_new_roots() { let (state, _temp_dir) = create_test_state(); - assert!(new_command_suggestions("/tp Unknown", ®istry(), &state).is_some()); - assert!(new_command_suggestions("/time set ", ®istry(), &state).is_none()); + assert!(new_command_suggestions("/tp Unknown", ®istry(), &state, None).is_some()); + assert!(new_command_suggestions("/time set ", ®istry(), &state, None).is_none()); } } From 2c6ebeedc6bc4ffc53638f2cfebeef8111baf32a Mon Sep 17 00:00:00 2001 From: ReCore Date: Sat, 27 Jun 2026 23:23:43 +0930 Subject: [PATCH 12/30] Time command --- src/default_commands/src/lib.rs | 1 - src/default_commands/src/new/mod.rs | 1 + src/default_commands/src/new/time.rs | 198 ++++++++++++++++++ .../tests/new_command_registry.rs | 26 +++ .../src/packets/src/command_suggestions.rs | 53 +++++ 5 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/default_commands/src/new/time.rs diff --git a/src/default_commands/src/lib.rs b/src/default_commands/src/lib.rs index 5e8ec570..c7278e8f 100644 --- a/src/default_commands/src/lib.rs +++ b/src/default_commands/src/lib.rs @@ -12,7 +12,6 @@ pub mod permissions; mod say; pub mod spawn; mod stop; -pub mod time; mod tp; pub mod tps; diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs index bd0c1fce..fa8f661d 100644 --- a/src/default_commands/src/new/mod.rs +++ b/src/default_commands/src/new/mod.rs @@ -1,3 +1,4 @@ mod echo; mod stop; +mod time; mod tp; diff --git a/src/default_commands/src/new/time.rs b/src/default_commands/src/new/time.rs new file mode 100644 index 00000000..4163fec5 --- /dev/null +++ b/src/default_commands/src/new/time.rs @@ -0,0 +1,198 @@ +use bevy_ecs::prelude::{Query, ResMut}; +use temper_command_infra::args::IntegerArg; +use temper_command_infra::{ + ArgumentSpec, CommandArg, CommandHandler, CommandReader, CommandSource, ParseError, ParserKind, + ParserProperties, StringMode, +}; +use temper_components::player::time::LastSentTimeUpdate; +use temper_core::mq; +use temper_macros::Command; +use temper_resources::time::WorldTime; +use temper_text::TextComponent; +use tracing::info; + +#[derive(Command)] +#[command("time")] +enum TimeCommand { + #[subcommand("set")] + Set(TimeSetCommand), + #[literal("add")] + Add { time: IntegerArg<0, 24000> }, + #[literal("query")] + Query, +} + +#[derive(Command)] +#[command(subcommand)] +enum TimeSetCommand { + #[literal("day", aliases = ["d"])] + Day, + #[literal("noon")] + Noon, + #[literal("night", aliases = ["n"])] + Night, + #[literal("midnight")] + Midnight, + #[literal("dawn")] + Dawn, + #[literal("dusk")] + Dusk, + Ticks { + value: TimeSetArg, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TimeSetArg(u32); + +impl CommandArg for TimeSetArg { + type Raw<'a> = u32; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let cursor = reader.cursor(); + let raw = reader.read_word_span()?; + parse_time_ticks(raw).map_err(|message| ParseError::new(cursor, "time", message)) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw)) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Word), + ) + } +} + +impl CommandHandler for TimeCommand { + type SystemParam<'w, 's> = ( + ResMut<'w, WorldTime>, + Query<'w, 's, &'static mut LastSentTimeUpdate>, + ); + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let (world_time, last_sent_time) = params; + + match self { + Self::Set(command) => { + let ticks = command.ticks(); + set_time(source, world_time, last_sent_time, ticks); + } + Self::Add { time } => { + let ticks = *time as u16; + let new_time = world_time.current_time() + ticks; + + world_time.set_time(new_time); + send_message( + source, + format!("Advanced the world time by {} ticks", *time).into(), + ); + send_time_next_tick(last_sent_time); + } + Self::Query => { + send_message( + source, + format!("The current world time is: {}", world_time.current_time()).into(), + ); + } + } + } +} + +impl TimeSetCommand { + fn ticks(&self) -> u16 { + match self { + Self::Day => 1000, + Self::Noon => 6000, + Self::Night => 13000, + Self::Midnight => 18000, + Self::Dawn => 0, + Self::Dusk => 12000, + Self::Ticks { value } => (value.0 % u32::from(WorldTime::MAX_TIME)) as u16, + } + } +} + +fn parse_time_ticks(raw: &str) -> Result { + if raw.is_empty() { + return Err("empty time value"); + } + + match raw.chars().last().unwrap() { + 's' => parse_time_number(&raw[..raw.len() - 1])? + .checked_mul(20) + .ok_or("time value is too large"), + 't' => parse_time_number(&raw[..raw.len() - 1]), + _ => parse_time_number(raw), + } +} + +fn parse_time_number(raw: &str) -> Result { + raw.parse::().map_err(|_| "invalid time value") +} + +fn set_time( + source: CommandSource, + world_time: &mut WorldTime, + last_sent_time: &mut Query<&mut LastSentTimeUpdate>, + ticks: u16, +) { + world_time.set_time(ticks); + + send_message( + source, + format!("Set the world time to {} ticks", world_time.current_time()).into(), + ); + send_time_next_tick(last_sent_time); +} + +fn send_time_next_tick(last_sent_time: &mut Query<&mut LastSentTimeUpdate>) { + for mut last_sent in last_sent_time.iter_mut() { + last_sent.send_next_tick(); + } +} + +fn send_message(source: CommandSource, message: TextComponent) { + match source { + CommandSource::Player(entity) => mq::queue(message, false, entity), + CommandSource::Server => info!("{}", message.to_plain_text()), + } +} + +#[cfg(test)] +mod tests { + use temper_command_infra::CommandSpec; + + use super::*; + + #[test] + fn time_set_literals_and_aliases_parse() { + assert!(matches!( + TimeCommand::parse("set day").unwrap(), + TimeCommand::Set(TimeSetCommand::Day) + )); + assert!(matches!( + TimeCommand::parse("set d").unwrap(), + TimeCommand::Set(TimeSetCommand::Day) + )); + assert!(matches!( + TimeCommand::parse("set n").unwrap(), + TimeCommand::Set(TimeSetCommand::Night) + )); + } + + #[test] + fn time_set_numeric_values_parse() { + match TimeCommand::parse("set 10s").unwrap() { + TimeCommand::Set(TimeSetCommand::Ticks { value }) => assert_eq!(value.0, 200), + _ => panic!("expected numeric time set command"), + } + + match TimeCommand::parse("set 10t").unwrap() { + TimeCommand::Set(TimeSetCommand::Ticks { value }) => assert_eq!(value.0, 10), + _ => panic!("expected numeric time set command"), + } + } +} diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs index 06350c30..35b20c70 100644 --- a/src/default_commands/tests/new_command_registry.rs +++ b/src/default_commands/tests/new_command_registry.rs @@ -11,6 +11,7 @@ fn default_commands_register_new_metadata() { assert!(paths.iter().any(|path| path.root == "tp")); assert!(paths.iter().any(|path| path.root == "stop")); assert!(paths.iter().any(|path| path.root == "echo")); + assert!(paths.iter().any(|path| path.root == "time")); let stop = paths.iter().find(|path| path.root == "stop").unwrap(); let echo = paths.iter().find(|path| path.root == "echo").unwrap(); @@ -24,4 +25,29 @@ fn default_commands_register_new_metadata() { .. } )); + + assert!(paths.iter().any(|path| path.root == "time" + && matches!( + path.segments.as_slice(), + [ + CommandPathSegment::Literal { name: "set", .. }, + CommandPathSegment::Literal { name: "day", .. } + ] + ))); + assert!(paths.iter().any(|path| path.root == "time" + && matches!( + path.segments.as_slice(), + [ + CommandPathSegment::Literal { name: "set", .. }, + CommandPathSegment::Literal { name: "d", .. } + ] + ))); + assert!(paths.iter().any(|path| path.root == "time" + && matches!( + path.segments.as_slice(), + [ + CommandPathSegment::Literal { name: "set", .. }, + CommandPathSegment::Argument { name: "value", .. } + ] + ))); } diff --git a/src/game_systems/src/packets/src/command_suggestions.rs b/src/game_systems/src/packets/src/command_suggestions.rs index b1438819..7d6da5c2 100644 --- a/src/game_systems/src/packets/src/command_suggestions.rs +++ b/src/game_systems/src/packets/src/command_suggestions.rs @@ -375,6 +375,42 @@ mod tests { registry } + fn time_registry() -> CommandRegistry { + let mut registry = CommandRegistry::default(); + registry.register_command(RegisteredCommand { + name: "time", + aliases: &[], + permission: None, + paths: vec![ + CommandPath::new( + "time", + vec![ + CommandPathSegment::literal("set"), + CommandPathSegment::literal("day"), + ], + ), + CommandPath::new( + "time", + vec![ + CommandPathSegment::literal("set"), + CommandPathSegment::literal("d"), + ], + ), + CommandPath::new( + "time", + vec![ + CommandPathSegment::literal("set"), + CommandPathSegment::argument( + "value", + ArgumentSpec::new(ParserKind::String), + ), + ], + ), + ], + }); + registry + } + #[test] fn new_command_suggestions_include_entities_for_first_tp_arg() { let (state, _temp_dir) = create_test_state(); @@ -419,6 +455,23 @@ mod tests { assert_eq!(matches, vec!["Alex"]); } + #[test] + fn new_command_suggestions_include_time_literals() { + let (state, _temp_dir) = create_test_state(); + + let suggestions = + new_command_suggestions("/time set ", &time_registry(), &state, None).unwrap(); + let matches = suggestions + .matches + .iter() + .map(|suggestion| suggestion.content.as_str()) + .collect::>(); + + assert_eq!(suggestions.start, 10); + assert_eq!(suggestions.length, 0); + assert_eq!(matches, vec!["day", "d"]); + } + #[test] fn old_command_suggestions_do_not_handle_new_roots() { let (state, _temp_dir) = create_test_state(); From 72d1a21a87cb37d29d0140a91203a05983347ab9 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sat, 27 Jun 2026 23:46:46 +0930 Subject: [PATCH 13/30] single/multi entity args --- src/command-infra/Cargo.toml | 1 + src/command-infra/src/args/entity.rs | 85 ++++++++++++++++------- src/command-infra/src/args/mod.rs | 2 +- src/command-infra/src/lib.rs | 2 +- src/command-infra/src/metadata.rs | 18 +++++ src/command-infra/tests/derive_command.rs | 76 ++++++++++++++++++-- src/default_commands/src/new/tp.rs | 6 +- src/net/protocol/src/outgoing/commands.rs | 40 +++++++++-- 8 files changed, 185 insertions(+), 45 deletions(-) diff --git a/src/command-infra/Cargo.toml b/src/command-infra/Cargo.toml index 80502e30..b624363f 100644 --- a/src/command-infra/Cargo.toml +++ b/src/command-infra/Cargo.toml @@ -13,6 +13,7 @@ temper-text = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } +rand = { workspace = true } [dev-dependencies] temper-macros = { workspace = true } diff --git a/src/command-infra/src/args/entity.rs b/src/command-infra/src/args/entity.rs index 64f25c3b..be357f08 100644 --- a/src/command-infra/src/args/entity.rs +++ b/src/command-infra/src/args/entity.rs @@ -1,3 +1,4 @@ +use rand::prelude::IteratorRandom; use std::ops::Deref; use bevy_ecs::entity::Entity; @@ -5,12 +6,57 @@ use temper_components::entity_identity::Identity; use temper_components::player::player_marker::PlayerMarker; use uuid::Uuid; -use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind}; +use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError}; #[derive(Clone, Debug, Eq, PartialEq)] -pub struct EntityArg(String); +struct EntitySelector(String); -impl Deref for EntityArg { +macro_rules! entity_arg { + ($name:ident, single: $single:literal, players_only: $players_only:literal) => { + #[derive(Clone, Debug, Eq, PartialEq)] + pub struct $name(EntitySelector); + + impl $name { + pub fn resolve<'a>( + &self, + iter: impl Iterator)>, + ) -> Vec { + self.0.resolve(iter) + } + } + + impl Deref for $name { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl CommandArg for $name { + type Raw<'a> = &'a str; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + recognize_entity(reader) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(EntitySelector(raw.to_string()))) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::entity($single, $players_only) + } + } + }; +} + +entity_arg!(EntityArg, single: true, players_only: false); +entity_arg!(EntitiesArg, single: false, players_only: false); +entity_arg!(PlayerArg, single: true, players_only: true); +entity_arg!(PlayersArg, single: false, players_only: true); + +impl Deref for EntitySelector { type Target = str; fn deref(&self) -> &Self::Target { @@ -18,8 +64,8 @@ impl Deref for EntityArg { } } -impl EntityArg { - pub fn resolve<'a>( +impl EntitySelector { + fn resolve<'a>( &self, iter: impl Iterator)>, ) -> Vec { @@ -30,8 +76,7 @@ impl EntityArg { .collect(), "@r" => iter .filter_map(|(entity, _, marker)| marker.map(|_| entity)) - .take(1) - .collect(), + .sample(&mut rand::rng(), 1), raw => { let uuid = Uuid::parse_str(raw).ok(); @@ -48,25 +93,13 @@ impl EntityArg { } } -impl CommandArg for EntityArg { - type Raw<'a> = &'a str; - - fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { - let cursor = reader.cursor(); - let span = reader.read_word_span()?; - - if span.is_empty() { - Err(ParseError::expected(cursor, "entity")) - } else { - Ok(span) - } - } - - fn parse(raw: Self::Raw<'_>) -> Result { - Ok(Self(raw.to_string())) - } +fn recognize_entity<'a>(reader: &mut CommandReader<'a>) -> Result<&'a str, ParseError> { + let cursor = reader.cursor(); + let span = reader.read_word_span()?; - fn argument_spec() -> ArgumentSpec { - ArgumentSpec::new(ParserKind::Entity).with_suggestions("minecraft:ask_server") + if span.is_empty() { + Err(ParseError::expected(cursor, "entity")) + } else { + Ok(span) } } diff --git a/src/command-infra/src/args/mod.rs b/src/command-infra/src/args/mod.rs index 55e9fa55..6d3effe6 100644 --- a/src/command-infra/src/args/mod.rs +++ b/src/command-infra/src/args/mod.rs @@ -3,7 +3,7 @@ mod integer; mod position; mod string; -pub use entity::EntityArg; +pub use entity::{EntitiesArg, EntityArg, PlayerArg, PlayersArg}; pub use integer::IntegerArg; pub use position::PositionArg; pub use string::{GreedyStringArg, QuotableStringArg, SingleWordArg}; diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index 61730595..832bca26 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -19,7 +19,7 @@ pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; pub use metadata::SubcommandSpec; pub use metadata::{ ArgKind, ArgumentSpec, CommandArg, CommandPath, CommandPathSegment, CommandSpec, - IntegerProperties, ParserKind, ParserProperties, StringMode, + EntityProperties, IntegerProperties, ParserKind, ParserProperties, StringMode, }; pub use reader::{Checkpoint, CommandReader}; pub use temper_permissions::Permissions; diff --git a/src/command-infra/src/metadata.rs b/src/command-infra/src/metadata.rs index 8f555346..1c03e1b5 100644 --- a/src/command-infra/src/metadata.rs +++ b/src/command-infra/src/metadata.rs @@ -20,10 +20,17 @@ pub struct IntegerProperties { pub max: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EntityProperties { + pub single: bool, + pub players_only: bool, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ParserProperties { String(StringMode), Integer(IntegerProperties), + Entity(EntityProperties), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -63,6 +70,17 @@ impl ArgumentSpec { self.suggestions = Some(suggestions); self } + + pub const fn entity(single: bool, players_only: bool) -> ArgumentSpec { + Self::with_properties( + ParserKind::Entity, + ParserProperties::Entity(EntityProperties { + single, + players_only, + }), + ) + .with_suggestions("minecraft:ask_server") + } } pub trait CommandArg: Sized { diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index f26547c8..6cdb5e22 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -1,9 +1,10 @@ use temper_command_infra::args::{ - EntityArg, GreedyStringArg, IntegerArg, PositionArg, SingleWordArg, + EntitiesArg, EntityArg, GreedyStringArg, IntegerArg, PlayerArg, PlayersArg, PositionArg, + SingleWordArg, }; use temper_command_infra::{ CommandGraph, CommandHandler, CommandNodeKind, CommandReader, CommandSource, CommandSpec, - Permissions, + ParserProperties, Permissions, }; use temper_macros::Command; @@ -17,15 +18,24 @@ enum TpCommand { destination: EntityArg, }, TpEntityToPos { - target: EntityArg, + target: EntitiesArg, location: PositionArg, }, TpEntityToEntity { - target: EntityArg, + target: EntitiesArg, destination: EntityArg, }, } +#[derive(Debug, PartialEq, Command)] +#[command("entity-flags")] +enum EntityFlagsCommand { + Entity { target: EntityArg }, + Entities { targets: EntitiesArg }, + Player { player: PlayerArg }, + Players { players: PlayersArg }, +} + #[derive(Debug, PartialEq, Command)] #[command("overlap")] enum OverlapCommand { @@ -125,6 +135,7 @@ impl_noop_handler!( MeCommand, TimeCommand, AliasCommand, + EntityFlagsCommand, ); #[test] @@ -224,7 +235,7 @@ fn graph_generation_merges_shared_prefixes() { .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) .collect::>(); - assert_eq!(child_names, vec!["destination", "location"]); + assert_eq!(child_names, vec!["destination", "target", "location"]); let destination_idx = tp .children @@ -234,10 +245,21 @@ fn graph_generation_merges_shared_prefixes() { .unwrap(); let destination = &graph.nodes[destination_idx]; - assert_eq!(destination.children.len(), 2); + assert!(destination.children.is_empty()); assert!(destination.executable); + + let target_idx = tp + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("target")) + .unwrap(); + let target = &graph.nodes[target_idx]; + + assert!(!target.executable); + assert_eq!(target.children.len(), 2); assert!( - destination + target .children .iter() .all(|idx| graph.nodes[*idx].executable) @@ -255,6 +277,16 @@ fn graph_uses_arg_attribute_names() { assert!(graph.nodes[value_idx].executable); } +#[test] +fn entity_arg_types_generate_entity_flags() { + let paths = EntityFlagsCommand::paths(); + + assert_entity_flags(&paths, "target", true, false); + assert_entity_flags(&paths, "targets", false, false); + assert_entity_flags(&paths, "player", true, true); + assert_entity_flags(&paths, "players", false, true); +} + #[test] fn arg_attribute_overrides_named_field_name() { let graph = CommandGraph::from_paths(&RenameCommand::paths()); @@ -468,3 +500,33 @@ fn permission_filter_removes_disallowed_paths() { temper_command_infra::CommandPathSegment::Literal { name: "add", .. } )); } + +fn assert_entity_flags( + paths: &[temper_command_infra::CommandPath], + arg_name: &str, + single: bool, + players_only: bool, +) { + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { name, spec, .. } + if *name == arg_name => + { + Some(*spec) + } + _ => None, + }) + .unwrap(); + + assert_eq!( + spec.properties, + Some(ParserProperties::Entity( + temper_command_infra::EntityProperties { + single, + players_only + } + )) + ); +} diff --git a/src/default_commands/src/new/tp.rs b/src/default_commands/src/new/tp.rs index 6450ac47..de9781a2 100644 --- a/src/default_commands/src/new/tp.rs +++ b/src/default_commands/src/new/tp.rs @@ -1,7 +1,7 @@ use bevy_ecs::entity::Entity; use bevy_ecs::prelude::{MessageWriter, Query}; use temper_command_infra::CommandSource::*; -use temper_command_infra::args::{EntityArg, PositionArg}; +use temper_command_infra::args::{EntitiesArg, EntityArg, PositionArg}; use temper_command_infra::{CommandHandler, CommandSource}; use temper_components::entity_identity::Identity; use temper_components::player::player_marker::PlayerMarker; @@ -23,11 +23,11 @@ enum TpCommand { destination: EntityArg, }, TpEntityToPos { - target: EntityArg, + target: EntitiesArg, location: PositionArg, }, TpEntityToEntity { - target: EntityArg, + target: EntitiesArg, destination: EntityArg, }, } diff --git a/src/net/protocol/src/outgoing/commands.rs b/src/net/protocol/src/outgoing/commands.rs index 864092a1..88562b35 100644 --- a/src/net/protocol/src/outgoing/commands.rs +++ b/src/net/protocol/src/outgoing/commands.rs @@ -3,8 +3,8 @@ use std::fmt; use temper_codec::net_types::{length_prefixed_vec::LengthPrefixedVec, var_int::VarInt}; use temper_command_infra::{ ArgumentSpec, CommandGraph as InfraCommandGraph, CommandNode as InfraCommandNode, - CommandNodeKind as InfraCommandNodeKind, IntegerProperties, ParserKind, ParserProperties, - StringMode, + CommandNodeKind as InfraCommandNodeKind, EntityProperties, IntegerProperties, ParserKind, + ParserProperties, StringMode, }; use temper_commands::{ arg::primitive::{ @@ -140,6 +140,13 @@ fn parser_properties(argument: ArgumentSpec) -> Option { Some(ParserProperties::Integer(IntegerProperties { min, max })) => { Some(PrimitiveArgumentFlags::Int(IntArgumentFlags { min, max })) } + Some(ParserProperties::Entity(EntityProperties { + single, + players_only, + })) => Some(PrimitiveArgumentFlags::Entity(EntityArgumentFlags { + single, + players_only, + })), None if argument.parser == ParserKind::Word => { Some(PrimitiveArgumentFlags::String(StringArgumentType::Word)) } @@ -160,10 +167,8 @@ fn string_mode(mode: StringMode) -> StringArgumentType { #[cfg(test)] mod tests { - use temper_command_infra::{ - ArgumentSpec, CommandGraph, CommandPath, CommandPathSegment, ParserKind, - }; - use temper_commands::arg::primitive::PrimitiveArgumentFlags; + use temper_command_infra::{ArgumentSpec, CommandGraph, CommandPath, CommandPathSegment}; + use temper_commands::arg::primitive::{EntityArgumentFlags, PrimitiveArgumentFlags}; use super::CommandsPacket; @@ -173,7 +178,7 @@ mod tests { "tp", vec![CommandPathSegment::argument( "target", - ArgumentSpec::new(ParserKind::Entity).with_suggestions("minecraft:ask_server"), + ArgumentSpec::entity(false, false), )], )]); @@ -193,4 +198,25 @@ mod tests { Some("minecraft:ask_server") ); } + + #[test] + fn converts_entity_flags_to_protocol_properties() { + let graph = CommandGraph::from_paths(&[CommandPath::new( + "gamemode", + vec![CommandPathSegment::argument( + "target", + ArgumentSpec::entity(true, true), + )], + )]); + + let packet = CommandsPacket::from_command_infra_graph(&graph); + + assert!(matches!( + packet.graph.data[2].properties, + Some(PrimitiveArgumentFlags::Entity(EntityArgumentFlags { + single: true, + players_only: true, + })) + )); + } } From c7589611ffb3fc269d3abdd8bed0d92ad6ce3724 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 16:57:23 +0930 Subject: [PATCH 14/30] about 4.2 fucktillion bug fixes with suggestions --- src/base/macros/src/command_derive/expand.rs | 35 +- src/base/macros/src/command_derive/fields.rs | 25 +- src/command-infra/Cargo.toml | 1 + src/command-infra/src/args/entity.rs | 4 +- src/command-infra/src/args/integer.rs | 4 +- src/command-infra/src/args/position.rs | 6 +- src/command-infra/src/args/string.rs | 7 +- src/command-infra/src/graph.rs | 22 +- src/command-infra/src/lib.rs | 6 + src/command-infra/src/metadata.rs | 37 +- src/command-infra/src/suggestions.rs | 72 ++++ src/command-infra/tests/derive_command.rs | 146 ++++++- src/default_commands/src/new/time.rs | 4 +- .../tests/new_command_registry.rs | 58 ++- .../src/packets/src/command_suggestions.rs | 371 +++++++++++++----- src/net/protocol/src/outgoing/commands.rs | 11 +- 16 files changed, 686 insertions(+), 123 deletions(-) create mode 100644 src/command-infra/src/suggestions.rs diff --git a/src/base/macros/src/command_derive/expand.rs b/src/base/macros/src/command_derive/expand.rs index f8a55585..d2b8897f 100644 --- a/src/base/macros/src/command_derive/expand.rs +++ b/src/base/macros/src/command_derive/expand.rs @@ -26,6 +26,7 @@ fn expand_enum( let mut parse_arms = Vec::new(); let mut segment_entries = Vec::new(); let mut greedy_assertions = Vec::new(); + let mut suggestion_registrations = Vec::new(); for variant in data_enum.variants { let variant_ident = variant.ident; @@ -69,6 +70,7 @@ fn expand_enum( _ => { let field_parse = FieldParse::new(fields.fields())?; greedy_assertions.extend(field_parse.greedy_assertions.clone()); + suggestion_registrations.extend(field_parse.suggestion_registrations.clone()); let constructor = constructor(ident, &variant_ident, &fields, &field_parse); let raw_bindings = &field_parse.raw_bindings; let permission_parse = permission_parse(variant_attrs.permission.as_ref()); @@ -124,6 +126,7 @@ fn expand_enum( parse_body, segment_entries, greedy_assertions, + suggestion_registrations, )) } @@ -154,6 +157,7 @@ fn expand_struct( parse_body, segment_entries, field_parse.greedy_assertions, + field_parse.suggestion_registrations, )) } @@ -163,6 +167,7 @@ fn expand_impl( parse_body: proc_macro2::TokenStream, segment_entries: Vec, greedy_assertions: Vec, + suggestion_registrations: Vec, ) -> proc_macro2::TokenStream { let segment_builder = quote! { let mut __segments = Vec::new(); @@ -178,7 +183,7 @@ fn expand_impl( let aliases = command_attrs.aliases; let command_permission_parse = permission_parse(command_attrs.permission.as_ref()); let permission_fn = permission_fn(command_attrs.permission.as_ref()); - let registration = expand_registration(ident); + let registration = expand_registration(ident, &suggestion_registrations); quote! { #(#greedy_assertions)* @@ -226,6 +231,8 @@ fn expand_impl( subcommand_attrs.permission.as_ref(), segment_builder, ); + let suggestion_registration = + expand_suggestion_registration(ident, &suggestion_registrations); quote! { #(#greedy_assertions)* @@ -250,14 +257,20 @@ fn expand_impl( #subcommand_segments } } + + #suggestion_registration } } } } -fn expand_registration(ident: &Ident) -> proc_macro2::TokenStream { +fn expand_registration( + ident: &Ident, + suggestion_registrations: &[proc_macro2::TokenStream], +) -> proc_macro2::TokenStream { let register_fn = format_ident!("__{}_register_command", ident); let register_system_fn = format_ident!("__{}_register_command_system", ident); + let suggestion_registration = expand_suggestion_registration(ident, suggestion_registrations); quote! { #[::temper_command_infra::ctor::ctor(unsafe)] @@ -277,6 +290,24 @@ fn expand_registration(ident: &Ident) -> proc_macro2::TokenStream { ::temper_command_infra::dispatch_command::<#ident>, ); } + + #suggestion_registration + } +} + +fn expand_suggestion_registration( + ident: &Ident, + suggestion_registrations: &[proc_macro2::TokenStream], +) -> proc_macro2::TokenStream { + let register_suggestions_fn = format_ident!("__{}_register_command_arg_suggestions", ident); + + quote! { + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_suggestions_fn() { + #(#suggestion_registrations)* + } } } diff --git a/src/base/macros/src/command_derive/fields.rs b/src/base/macros/src/command_derive/fields.rs index e9007533..af909a88 100644 --- a/src/base/macros/src/command_derive/fields.rs +++ b/src/base/macros/src/command_derive/fields.rs @@ -74,6 +74,7 @@ pub struct FieldParse { pub named_values: Vec, pub segments: Vec, pub greedy_assertions: Vec, + pub suggestion_registrations: Vec, } impl FieldParse { @@ -84,6 +85,7 @@ impl FieldParse { let mut named_values = Vec::new(); let mut segments = Vec::new(); let mut greedy_assertions = Vec::new(); + let mut suggestion_registrations = Vec::new(); for (idx, command_field) in fields.iter().enumerate() { let arg_name = arg_name(command_field)?; @@ -108,10 +110,27 @@ impl FieldParse { } let mut segment = quote! { + { + let mut __spec = <#ty as ::temper_command_infra::CommandArg>::argument_spec(); + match <#ty as ::temper_command_infra::CommandArg>::SUGGESTIONS { + ::temper_command_infra::SuggestionProviderKind::None => {} + ::temper_command_infra::SuggestionProviderKind::Client(__provider) => { + __spec = __spec.with_protocol_suggestions(__provider); + } + ::temper_command_infra::SuggestionProviderKind::Server => { + __spec = __spec + .with_protocol_suggestions("minecraft:ask_server") + .with_server_suggestions( + ::temper_command_infra::command_arg_suggestion_id::<#ty>(), + ); + } + } + ::temper_command_infra::CommandPathSegment::argument( #arg_name, - <#ty as ::temper_command_infra::CommandArg>::argument_spec(), + __spec, ) + } }; if let Some(permission) = &command_field.permission { @@ -121,6 +140,9 @@ impl FieldParse { } segments.push(segment); + suggestion_registrations.push(quote! { + ::temper_command_infra::register_command_arg_suggestions::<#ty>(); + }); if idx != last_field_idx { greedy_assertions.push(quote_spanned! { ty.span() => @@ -141,6 +163,7 @@ impl FieldParse { named_values, segments, greedy_assertions, + suggestion_registrations, }) } } diff --git a/src/command-infra/Cargo.toml b/src/command-infra/Cargo.toml index b624363f..60f5ac65 100644 --- a/src/command-infra/Cargo.toml +++ b/src/command-infra/Cargo.toml @@ -9,6 +9,7 @@ ctor = { workspace = true } temper-core = { workspace = true } temper-components = { workspace = true } temper-permissions = { workspace = true } +temper-state = { workspace = true } temper-text = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/src/command-infra/src/args/entity.rs b/src/command-infra/src/args/entity.rs index be357f08..bdf270f4 100644 --- a/src/command-infra/src/args/entity.rs +++ b/src/command-infra/src/args/entity.rs @@ -6,7 +6,7 @@ use temper_components::entity_identity::Identity; use temper_components::player::player_marker::PlayerMarker; use uuid::Uuid; -use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError}; +use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, SuggestionProviderKind}; #[derive(Clone, Debug, Eq, PartialEq)] struct EntitySelector(String); @@ -47,6 +47,8 @@ macro_rules! entity_arg { fn argument_spec() -> ArgumentSpec { ArgumentSpec::entity($single, $players_only) } + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; } }; } diff --git a/src/command-infra/src/args/integer.rs b/src/command-infra/src/args/integer.rs index c8368abb..5cce5bef 100644 --- a/src/command-infra/src/args/integer.rs +++ b/src/command-infra/src/args/integer.rs @@ -2,7 +2,7 @@ use std::ops::Deref; use crate::{ ArgumentSpec, CommandArg, CommandReader, IntegerProperties, ParseError, ParserKind, - ParserProperties, + ParserProperties, SuggestionProviderKind, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -19,6 +19,8 @@ impl Deref for IntegerArg { impl CommandArg for IntegerArg { type Raw<'a> = i32; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { let cursor = reader.cursor(); let raw = reader.read_word_span()?; diff --git a/src/command-infra/src/args/position.rs b/src/command-infra/src/args/position.rs index 620a518c..8a74c545 100644 --- a/src/command-infra/src/args/position.rs +++ b/src/command-infra/src/args/position.rs @@ -1,6 +1,8 @@ use temper_components::player::position::Position; -use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind}; +use crate::{ + ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind, SuggestionProviderKind, +}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct PositionArg { @@ -22,6 +24,8 @@ impl PositionArg { impl CommandArg for PositionArg { type Raw<'a> = (&'a str, &'a str, &'a str); + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { let x = read_coord(reader, "x coordinate")?; let y = read_coord(reader, "y coordinate")?; diff --git a/src/command-infra/src/args/string.rs b/src/command-infra/src/args/string.rs index 38318a04..fe56b1a3 100644 --- a/src/command-infra/src/args/string.rs +++ b/src/command-infra/src/args/string.rs @@ -2,7 +2,7 @@ use std::ops::Deref; use crate::{ ArgKind, ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind, ParserProperties, - StringMode, reader::StringSpan, + StringMode, SuggestionProviderKind, reader::StringSpan, }; #[derive(Clone, Debug, Eq, PartialEq)] @@ -19,6 +19,8 @@ impl Deref for SingleWordArg { impl CommandArg for SingleWordArg { type Raw<'a> = &'a str; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { reader.read_word_span() } @@ -49,6 +51,8 @@ impl Deref for QuotableStringArg { impl CommandArg for QuotableStringArg { type Raw<'a> = StringSpan<'a>; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { reader.read_string_span() } @@ -85,6 +89,7 @@ impl CommandArg for GreedyStringArg { type Raw<'a> = &'a str; const KIND: ArgKind = ArgKind::GreedyTail; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { reader.read_remaining_span() diff --git a/src/command-infra/src/graph.rs b/src/command-infra/src/graph.rs index 7d5748ba..df73017a 100644 --- a/src/command-infra/src/graph.rs +++ b/src/command-infra/src/graph.rs @@ -1,4 +1,4 @@ -use crate::{ArgumentSpec, CommandPath, CommandPathSegment}; +use crate::{ArgumentSpec, CommandPath, CommandPathSegment, EntityProperties, ParserProperties}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CommandNodeKind { @@ -62,12 +62,26 @@ impl CommandNode { match self.kind { CommandNodeKind::Literal => 0, CommandNodeKind::Argument - if self.argument.and_then(|arg| arg.suggestions).is_some() => + if matches!( + self.argument.and_then(|arg| arg.properties), + Some(ParserProperties::Entity(EntityProperties { + single: false, + .. + })) + ) => { 1 } - CommandNodeKind::Argument => 2, - CommandNodeKind::Root => 3, + CommandNodeKind::Argument + if self + .argument + .and_then(|arg| arg.protocol_suggestions) + .is_some() => + { + 2 + } + CommandNodeKind::Argument => 3, + CommandNodeKind::Root => 4, } } } diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index 832bca26..18593f70 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod graph; pub mod metadata; pub mod reader; +pub mod suggestions; pub use ctor; pub use ecs::{ @@ -20,6 +21,11 @@ pub use metadata::SubcommandSpec; pub use metadata::{ ArgKind, ArgumentSpec, CommandArg, CommandPath, CommandPathSegment, CommandSpec, EntityProperties, IntegerProperties, ParserKind, ParserProperties, StringMode, + SuggestionProviderKind, }; pub use reader::{Checkpoint, CommandReader}; +pub use suggestions::{ + SuggestionInput, command_arg_suggestion_id, register_command_arg_suggestions, + suggest_command_arg, +}; pub use temper_permissions::Permissions; diff --git a/src/command-infra/src/metadata.rs b/src/command-infra/src/metadata.rs index 1c03e1b5..3ff8a088 100644 --- a/src/command-infra/src/metadata.rs +++ b/src/command-infra/src/metadata.rs @@ -1,3 +1,6 @@ +use bevy_ecs::world::World; + +use crate::SuggestionInput; use crate::{CommandReader, ParseError}; use temper_permissions::Permissions; @@ -42,11 +45,19 @@ pub enum ParserKind { Entity, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SuggestionProviderKind { + None, + Client(&'static str), + Server, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ArgumentSpec { pub parser: ParserKind, pub properties: Option, - pub suggestions: Option<&'static str>, + pub protocol_suggestions: Option<&'static str>, + pub server_suggestions: Option<&'static str>, } impl ArgumentSpec { @@ -54,7 +65,8 @@ impl ArgumentSpec { Self { parser, properties: None, - suggestions: None, + protocol_suggestions: None, + server_suggestions: None, } } @@ -62,12 +74,23 @@ impl ArgumentSpec { Self { parser, properties: Some(properties), - suggestions: None, + protocol_suggestions: None, + server_suggestions: None, } } pub const fn with_suggestions(mut self, suggestions: &'static str) -> ArgumentSpec { - self.suggestions = Some(suggestions); + self.protocol_suggestions = Some(suggestions); + self + } + + pub const fn with_protocol_suggestions(mut self, suggestions: &'static str) -> ArgumentSpec { + self.protocol_suggestions = Some(suggestions); + self + } + + pub const fn with_server_suggestions(mut self, suggestions: &'static str) -> ArgumentSpec { + self.server_suggestions = Some(suggestions); self } @@ -79,7 +102,6 @@ impl ArgumentSpec { players_only, }), ) - .with_suggestions("minecraft:ask_server") } } @@ -87,12 +109,17 @@ pub trait CommandArg: Sized { type Raw<'a>; const KIND: ArgKind = ArgKind::Normal; + const SUGGESTIONS: SuggestionProviderKind; fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError>; fn parse(raw: Self::Raw<'_>) -> Result; fn argument_spec() -> ArgumentSpec; + + fn suggest(_input: SuggestionInput<'_>, _world: &mut World) -> Vec { + Vec::new() + } } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/src/command-infra/src/suggestions.rs b/src/command-infra/src/suggestions.rs new file mode 100644 index 00000000..aa61e32b --- /dev/null +++ b/src/command-infra/src/suggestions.rs @@ -0,0 +1,72 @@ +use std::any::type_name; +use std::sync::{LazyLock, RwLock}; + +use bevy_ecs::entity::Entity; +use bevy_ecs::world::World; + +use crate::{CommandArg, SuggestionProviderKind}; + +static ARG_SUGGESTIONS: LazyLock>> = + LazyLock::new(|| RwLock::new(Vec::new())); + +#[derive(Clone, Copy, Debug)] +pub struct SuggestionInput<'a> { + pub full_input: &'a str, + pub current_token: &'a str, + pub source: Entity, +} + +#[derive(Clone, Copy)] +struct RegisteredArgSuggestions { + id: &'static str, + suggest: for<'a> fn(&mut World, SuggestionInput<'a>) -> Vec, +} + +pub fn command_arg_suggestion_id() -> &'static str { + type_name::() +} + +pub fn register_command_arg_suggestions() { + if !matches!(A::SUGGESTIONS, SuggestionProviderKind::Server) { + return; + } + + let id = command_arg_suggestion_id::(); + + if let Ok(mut suggestions) = ARG_SUGGESTIONS.write() { + if suggestions.iter().any(|suggestion| suggestion.id == id) { + return; + } + + suggestions.push(RegisteredArgSuggestions { + id, + suggest: suggest_for_arg::, + }); + } +} + +pub fn suggest_command_arg( + id: &str, + world: &mut World, + input: SuggestionInput<'_>, +) -> Option> { + let suggest = ARG_SUGGESTIONS + .read() + .ok() + .and_then(|suggestions| { + suggestions + .iter() + .find(|suggestion| suggestion.id == id) + .copied() + }) + .map(|suggestion| suggestion.suggest)?; + + Some(suggest(world, input)) +} + +fn suggest_for_arg( + world: &mut World, + input: SuggestionInput<'_>, +) -> Vec { + A::suggest(input, world) +} diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index 6cdb5e22..0eacccd0 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -1,10 +1,12 @@ +use bevy_ecs::prelude::{Entity, Resource, World}; use temper_command_infra::args::{ EntitiesArg, EntityArg, GreedyStringArg, IntegerArg, PlayerArg, PlayersArg, PositionArg, SingleWordArg, }; use temper_command_infra::{ - CommandGraph, CommandHandler, CommandNodeKind, CommandReader, CommandSource, CommandSpec, - ParserProperties, Permissions, + ArgumentSpec, CommandArg, CommandGraph, CommandHandler, CommandNodeKind, CommandReader, + CommandSource, CommandSpec, ParseError, ParserKind, ParserProperties, Permissions, + SuggestionInput, SuggestionProviderKind, }; use temper_macros::Command; @@ -108,6 +110,68 @@ enum SetTimeCommand { )] struct AliasCommand; +#[derive(Debug, PartialEq, Command)] +#[command("suggested")] +struct SuggestedCommand { + value: SuggestedWordArg, +} + +#[derive(Debug, PartialEq, Command)] +#[command("client-suggested")] +struct ClientSuggestedCommand { + value: ClientSuggestedArg, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SuggestedWordArg(String); + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ClientSuggestedArg(String); + +#[derive(Resource)] +struct SuggestedWords(Vec); + +impl CommandArg for SuggestedWordArg { + type Raw<'a> = &'a str; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::Server; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::new(ParserKind::Word) + } + + fn suggest(_input: SuggestionInput<'_>, world: &mut World) -> Vec { + world.resource::().0.clone() + } +} + +impl CommandArg for ClientSuggestedArg { + type Raw<'a> = &'a str; + + const SUGGESTIONS: SuggestionProviderKind = + SuggestionProviderKind::Client("minecraft:available_sounds"); + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::new(ParserKind::Word) + } +} + macro_rules! impl_noop_handler { ($($command:ty),* $(,)?) => { $( @@ -136,6 +200,8 @@ impl_noop_handler!( TimeCommand, AliasCommand, EntityFlagsCommand, + SuggestedCommand, + ClientSuggestedCommand, ); #[test] @@ -235,7 +301,7 @@ fn graph_generation_merges_shared_prefixes() { .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) .collect::>(); - assert_eq!(child_names, vec!["destination", "target", "location"]); + assert_eq!(child_names, vec!["target", "location", "destination"]); let destination_idx = tp .children @@ -287,6 +353,80 @@ fn entity_arg_types_generate_entity_flags() { assert_entity_flags(&paths, "players", false, true); } +#[test] +fn arg_suggestions_are_registered_from_field_types() { + let paths = SuggestedCommand::paths(); + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + let provider = temper_command_infra::command_arg_suggestion_id::(); + + assert_eq!(spec.protocol_suggestions, Some("minecraft:ask_server")); + assert_eq!(spec.server_suggestions, Some(provider)); + + let mut world = World::new(); + world.insert_resource(SuggestedWords(vec!["alpha".into(), "beta".into()])); + temper_command_infra::register_command_arg_suggestions::(); + + let suggestions = temper_command_infra::suggest_command_arg( + provider, + &mut world, + SuggestionInput { + full_input: "/suggested a", + current_token: "a", + source: Entity::PLACEHOLDER, + }, + ) + .unwrap(); + + assert_eq!(suggestions, vec!["alpha", "beta"]); +} + +#[test] +fn position_args_use_client_parser_suggestions() { + let paths = TpCommand::paths(); + let spec = paths + .iter() + .flat_map(|path| path.segments.iter()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { + name: "location", + spec, + .. + } => Some(*spec), + _ => None, + }) + .unwrap(); + + assert_eq!(spec.parser, ParserKind::Position); + assert_eq!(spec.protocol_suggestions, None); + assert_eq!(spec.server_suggestions, None); +} + +#[test] +fn client_suggestions_only_set_protocol_provider() { + let paths = ClientSuggestedCommand::paths(); + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + + assert_eq!( + spec.protocol_suggestions, + Some("minecraft:available_sounds") + ); + assert_eq!(spec.server_suggestions, None); +} + #[test] fn arg_attribute_overrides_named_field_name() { let graph = CommandGraph::from_paths(&RenameCommand::paths()); diff --git a/src/default_commands/src/new/time.rs b/src/default_commands/src/new/time.rs index 4163fec5..1eebfcb1 100644 --- a/src/default_commands/src/new/time.rs +++ b/src/default_commands/src/new/time.rs @@ -2,7 +2,7 @@ use bevy_ecs::prelude::{Query, ResMut}; use temper_command_infra::args::IntegerArg; use temper_command_infra::{ ArgumentSpec, CommandArg, CommandHandler, CommandReader, CommandSource, ParseError, ParserKind, - ParserProperties, StringMode, + ParserProperties, StringMode, SuggestionProviderKind, }; use temper_components::player::time::LastSentTimeUpdate; use temper_core::mq; @@ -48,6 +48,8 @@ struct TimeSetArg(u32); impl CommandArg for TimeSetArg { type Raw<'a> = u32; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { let cursor = reader.cursor(); let raw = reader.read_word_span()?; diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs index 35b20c70..b83392f5 100644 --- a/src/default_commands/tests/new_command_registry.rs +++ b/src/default_commands/tests/new_command_registry.rs @@ -1,5 +1,9 @@ use bevy_ecs::prelude::World; -use temper_command_infra::{CommandPathSegment, CommandRegistry}; +use temper_command_infra::{ + CommandGraph, CommandNodeKind, CommandPathSegment, CommandRegistry, ParserKind, +}; +use temper_commands::arg::primitive::PrimitiveArgumentType; +use temper_protocol::outgoing::commands::CommandsPacket; #[test] fn default_commands_register_new_metadata() { @@ -51,3 +55,55 @@ fn default_commands_register_new_metadata() { ] ))); } + +#[test] +fn default_tp_graph_uses_client_handled_entity_and_position_parsers() { + temper_default_commands::init(); + + let registry = CommandRegistry::from_static_commands(); + let graph = + CommandGraph::from_paths(®istry.paths_for_player(World::new().spawn_empty().id())); + let root = &graph.nodes[graph.root_idx]; + let tp_idx = root + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("tp")) + .unwrap(); + let tp = &graph.nodes[tp_idx]; + + let first_child = &graph.nodes[tp.children[0]]; + assert_eq!(first_child.kind, CommandNodeKind::Argument); + assert_eq!(first_child.name.as_deref(), Some("target")); + assert_eq!( + first_child.argument.map(|argument| argument.parser), + Some(ParserKind::Entity) + ); + assert_eq!( + first_child + .argument + .and_then(|argument| argument.protocol_suggestions), + None + ); + + let packet = CommandsPacket::from_command_infra_graph(&graph); + let packet_tp = &packet.graph.data[tp_idx]; + let packet_first_child_idx = packet_tp.children.data[0].0 as usize; + let packet_first_child = &packet.graph.data[packet_first_child_idx]; + + assert_eq!( + packet_first_child.parser_id, + Some(PrimitiveArgumentType::Entity) + ); + assert_eq!(packet_first_child.suggestions_type, None); + + let has_client_position_branch = packet_tp.children.data.iter().any(|child_idx| { + let child = &packet.graph.data[child_idx.0 as usize]; + child.parser_id == Some(PrimitiveArgumentType::Vec3) && child.suggestions_type.is_none() + }); + + assert_eq!( + has_client_position_branch, true, + "tp should expose a plain vec3 branch for client coordinate suggestions" + ); +} diff --git a/src/game_systems/src/packets/src/command_suggestions.rs b/src/game_systems/src/packets/src/command_suggestions.rs index 7d6da5c2..feefc81f 100644 --- a/src/game_systems/src/packets/src/command_suggestions.rs +++ b/src/game_systems/src/packets/src/command_suggestions.rs @@ -1,10 +1,14 @@ use std::{collections::HashSet, sync::Arc}; use bevy_ecs::prelude::*; +use bevy_ecs::system::SystemState; use temper_codec::net_types::{ length_prefixed_vec::LengthPrefixedVec, prefixed_optional::PrefixedOptional, var_int::VarInt, }; -use temper_command_infra::{CommandPathSegment, CommandRegistry, ParserKind}; +use temper_command_infra::{ + CommandPathSegment, CommandRegistry, EntityProperties, ParserKind, ParserProperties, + SuggestionInput, suggest_command_arg, +}; use temper_commands::{Command, CommandContext, CommandInput, ROOT_COMMAND, Sender}; use temper_net_runtime::connection::StreamWriter; use temper_permissions::player::PlayerPermission; @@ -67,87 +71,44 @@ fn create_ctx( } } -pub fn handle( - receiver: Res, - query: Query<&StreamWriter>, - permissions: Query<&PlayerPermission>, - registry: Res, - state: Res, -) { - for (request, entity) in receiver.0.try_iter() { - if !state.0.players.is_connected(entity) { - return; - } +pub fn handle(world: &mut World) { + let requests = { + let mut system_state = SystemState::>::new(world); + let receiver = system_state.get(world); + receiver.0.try_iter().collect::>() + }; - let input = request.input; - let Ok(writer) = query.get(entity) else { + for (request, entity) in requests { + let Some(suggestions) = suggestion_plan(world, &request.input, entity) else { continue; }; - if let Some(response) = - new_command_suggestions(&input, ®istry, &state, permissions.get(entity).ok()) - { - send_suggestions(writer, request.transaction_id, response); - continue; - } - - let command = find_command(input.clone()); - let command_arg = input - .clone() - .strip_prefix(&format!( - "/{} ", - command.clone().map(|c| c.name).unwrap_or_default() - )) - .unwrap_or(&input) - .to_string(); - let mut ctx = create_ctx( - command_arg.clone(), - command.clone(), - Sender::Player(entity), - state.0.clone(), - ); - let command_arg = command_arg.clone(); - let tokens = command_arg.split(" ").collect::>(); - let Some(current_token) = tokens.last() else { - return; // whitespace + let response = match suggestions { + SuggestionPlanResult::New(plan) => { + let input = request.input.clone(); + plan.into_response(|provider, current_token| { + suggest_command_arg( + provider, + world, + SuggestionInput { + full_input: &input, + current_token, + source: entity, + }, + ) + .unwrap_or_default() + }) + } + SuggestionPlanResult::Old(response) => response, }; - let mut suggestions = Vec::new(); - - if let Some(command) = command { - for arg in command.args.clone() { - let arg_suggestions = (arg.suggester)(&mut ctx); - ctx.input.skip_whitespace(u32::MAX, true); - if !ctx.input.has_remaining_input() { - suggestions = arg_suggestions; - break; - } - } - } + let mut system_state = SystemState::>::new(world); + let query = system_state.get(world); + let Ok(writer) = query.get(entity) else { + continue; + }; - let start = input.len() - current_token.len(); - let length = current_token.len(); - - send_suggestions( - writer, - request.transaction_id, - SuggestionResponse { - start, - length, - matches: suggestions - .into_iter() - .filter(|sug| { - sug.content - .to_lowercase() - .starts_with(¤t_token.to_lowercase()) - }) - .map(|sug| Match { - content: sug.content, - tooltip: PrefixedOptional::new(sug.tooltip), - }) - .collect(), - }, - ); + send_suggestions(writer, request.transaction_id, response); } } @@ -157,12 +118,108 @@ struct SuggestionResponse { matches: Vec, } +enum SuggestionPlanResult { + New(SuggestionPlan), + Old(SuggestionResponse), +} + +struct SuggestionPlan { + start: usize, + length: usize, + current_token: String, + candidates: Vec, + providers: Vec, +} + +impl SuggestionPlan { + fn into_response( + self, + mut provider_suggestions: impl FnMut(&'static str, &str) -> Vec, + ) -> SuggestionResponse { + let current_token_lower = self.current_token.to_lowercase(); + let mut seen = HashSet::new(); + let provider_candidates = self.providers.into_iter().flat_map(|provider| { + let suggestions = provider_suggestions(provider.id, &self.current_token); + + provider.suggest(suggestions).collect::>() + }); + + let matches = self + .candidates + .into_iter() + .chain(provider_candidates) + .filter(|suggestion| suggestion.to_lowercase().starts_with(¤t_token_lower)) + .filter(|suggestion| seen.insert(suggestion.clone())) + .map(|content| Match { + content, + tooltip: PrefixedOptional::new(None), + }) + .collect(); + + SuggestionResponse { + start: self.start, + length: self.length, + matches, + } + } +} + +struct ProviderSuggestions { + id: &'static str, + fallback: Vec, +} + +impl ProviderSuggestions { + fn suggest(self, suggestions: Vec) -> impl Iterator { + suggestions.into_iter().chain(self.fallback) + } +} + +fn suggestion_plan(world: &mut World, input: &str, entity: Entity) -> Option { + let mut system_state = SystemState::<( + Query<&StreamWriter>, + Query<&PlayerPermission>, + Res, + Res, + )>::new(world); + let (query, permissions, registry, state) = system_state.get(world); + + if !state.0.players.is_connected(entity) { + return None; + } + + if query.get(entity).is_err() { + return None; + } + + if let Some(plan) = + new_command_suggestion_plan(input, ®istry, &state, permissions.get(entity).ok()) + { + return Some(SuggestionPlanResult::New(plan)); + } + + old_command_suggestions(input, entity, &state).map(SuggestionPlanResult::Old) +} + +#[cfg(test)] fn new_command_suggestions( input: &str, registry: &CommandRegistry, state: &GlobalStateResource, permissions: Option<&PlayerPermission>, ) -> Option { + Some( + new_command_suggestion_plan(input, registry, state, permissions)? + .into_response(|_provider, _current_token| Vec::new()), + ) +} + +fn new_command_suggestion_plan( + input: &str, + registry: &CommandRegistry, + state: &GlobalStateResource, + permissions: Option<&PlayerPermission>, +) -> Option { let command_input = input.strip_prefix('/').unwrap_or(input); let root_end = command_input .find(char::is_whitespace) @@ -175,10 +232,11 @@ fn new_command_suggestions( let rest = command_input[root_end..].trim_start(); let current_token = current_token(rest); let completed_tokens = completed_tokens(rest); - let current_token_lower = current_token.to_lowercase(); - let mut seen = HashSet::new(); - let matches = command + let mut candidates = Vec::new(); + let mut providers = Vec::new(); + + for segment in command .paths .iter() .filter(|path| path.root == root) @@ -186,20 +244,74 @@ fn new_command_suggestions( path.is_allowed_by(|permission| permissions.is_some_and(|p| p.can(permission))) }) .filter_map(|path| candidate_segment(&path.segments, &completed_tokens)) - .filter_map(|segment| segment_suggestions(segment, state)) - .flatten() - .filter(|suggestion| suggestion.to_lowercase().starts_with(¤t_token_lower)) - .filter(|suggestion| seen.insert(suggestion.clone())) - .map(|content| Match { - content, - tooltip: PrefixedOptional::new(None), - }) - .collect(); + { + match segment_suggestions(segment, state) { + Some(SegmentSuggestions::Candidates(next_candidates)) => { + candidates.extend(next_candidates); + } + Some(SegmentSuggestions::Provider(provider)) => providers.push(provider), + None => {} + } + } + + Some(SuggestionPlan { + start: input.len() - current_token.len(), + length: current_token.len(), + current_token: current_token.to_string(), + candidates, + providers, + }) +} + +fn old_command_suggestions( + input: &str, + entity: Entity, + state: &GlobalStateResource, +) -> Option { + let command = find_command(input.to_string()); + let command_arg = input + .strip_prefix(&format!( + "/{} ", + command.clone().map(|c| c.name).unwrap_or_default() + )) + .unwrap_or(input) + .to_string(); + let mut ctx = create_ctx( + command_arg.clone(), + command.clone(), + Sender::Player(entity), + state.0.clone(), + ); + let tokens = command_arg.split(' ').collect::>(); + let current_token = tokens.last()?; + let mut suggestions = Vec::new(); + + if let Some(command) = command { + for arg in command.args.clone() { + let arg_suggestions = (arg.suggester)(&mut ctx); + ctx.input.skip_whitespace(u32::MAX, true); + if !ctx.input.has_remaining_input() { + suggestions = arg_suggestions; + break; + } + } + } Some(SuggestionResponse { start: input.len() - current_token.len(), length: current_token.len(), - matches, + matches: suggestions + .into_iter() + .filter(|sug| { + sug.content + .to_lowercase() + .starts_with(¤t_token.to_lowercase()) + }) + .map(|sug| Match { + content: sug.content, + tooltip: PrefixedOptional::new(sug.tooltip), + }) + .collect(), }) } @@ -268,16 +380,31 @@ fn is_coordinate_token(token: &str) -> bool { } } +enum SegmentSuggestions { + Candidates(Vec), + Provider(ProviderSuggestions), +} + fn segment_suggestions( segment: &CommandPathSegment, state: &GlobalStateResource, -) -> Option> { +) -> Option { match segment { - CommandPathSegment::Literal { name, .. } => Some(vec![(*name).to_string()]), - CommandPathSegment::Argument { spec, .. } if is_ask_server(spec.suggestions) => { + CommandPathSegment::Literal { name, .. } => { + Some(SegmentSuggestions::Candidates(vec![(*name).to_string()])) + } + CommandPathSegment::Argument { spec, .. } if spec.server_suggestions.is_some() => { + Some(SegmentSuggestions::Provider(ProviderSuggestions { + id: spec.server_suggestions.unwrap(), + fallback: entity_fallback_suggestions(*spec, state), + })) + } + CommandPathSegment::Argument { spec, .. } if is_ask_server(spec.protocol_suggestions) => { match spec.parser { - ParserKind::Entity => Some(entity_suggestions(state)), - _ => Some(Vec::new()), + ParserKind::Entity => Some(SegmentSuggestions::Candidates( + entity_fallback_suggestions(*spec, state), + )), + _ => Some(SegmentSuggestions::Candidates(Vec::new())), } } _ => None, @@ -288,8 +415,28 @@ fn is_ask_server(suggestions: Option<&str>) -> bool { matches!(suggestions, Some("ask_server" | "minecraft:ask_server")) } -fn entity_suggestions(state: &GlobalStateResource) -> Vec { - let mut suggestions = vec!["@e".to_string(), "@r".to_string(), "@a".to_string()]; +fn entity_fallback_suggestions( + spec: temper_command_infra::ArgumentSpec, + state: &GlobalStateResource, +) -> Vec { + let players_only = matches!( + spec.properties, + Some(ParserProperties::Entity(EntityProperties { + players_only: true, + .. + })) + ); + + entity_suggestions(state, players_only) +} + +fn entity_suggestions(state: &GlobalStateResource, players_only: bool) -> Vec { + let mut suggestions = if players_only { + vec!["@r".to_string(), "@a".to_string()] + } else { + vec!["@e".to_string(), "@r".to_string(), "@a".to_string()] + }; + suggestions.extend( state .0 @@ -338,7 +485,8 @@ mod tests { vec![CommandPathSegment::argument( "destination", ArgumentSpec::new(ParserKind::Entity) - .with_suggestions("minecraft:ask_server"), + .with_protocol_suggestions("minecraft:ask_server") + .with_server_suggestions("test:entity"), )], ), CommandPath::new( @@ -347,7 +495,8 @@ mod tests { CommandPathSegment::argument( "target", ArgumentSpec::new(ParserKind::Entity) - .with_suggestions("minecraft:ask_server"), + .with_protocol_suggestions("minecraft:ask_server") + .with_server_suggestions("test:entities"), ), CommandPathSegment::argument( "location", @@ -361,12 +510,14 @@ mod tests { CommandPathSegment::argument( "target", ArgumentSpec::new(ParserKind::Entity) - .with_suggestions("minecraft:ask_server"), + .with_protocol_suggestions("minecraft:ask_server") + .with_server_suggestions("test:entities"), ), CommandPathSegment::argument( "destination", ArgumentSpec::new(ParserKind::Entity) - .with_suggestions("minecraft:ask_server"), + .with_protocol_suggestions("minecraft:ask_server") + .with_server_suggestions("test:entity"), ), ], ), @@ -433,6 +584,28 @@ mod tests { assert!(matches.contains(&"Alex")); } + #[test] + fn new_command_suggestions_include_entities_for_bare_tp() { + let (state, _temp_dir) = create_test_state(); + state + .0 + .players + .player_list + .insert(Entity::PLACEHOLDER, (0, "Alex".to_string())); + + let suggestions = new_command_suggestions("/tp", ®istry(), &state, None).unwrap(); + let matches = suggestions + .matches + .iter() + .map(|suggestion| suggestion.content.as_str()) + .collect::>(); + + assert_eq!(suggestions.start, 3); + assert_eq!(suggestions.length, 0); + assert!(matches.contains(&"@a")); + assert!(matches.contains(&"Alex")); + } + #[test] fn new_command_suggestions_use_current_token_range() { let (state, _temp_dir) = create_test_state(); diff --git a/src/net/protocol/src/outgoing/commands.rs b/src/net/protocol/src/outgoing/commands.rs index 88562b35..db76917f 100644 --- a/src/net/protocol/src/outgoing/commands.rs +++ b/src/net/protocol/src/outgoing/commands.rs @@ -100,7 +100,11 @@ fn convert_node(node: &InfraCommandNode) -> CommandNode { flags |= 0x04; } - if node.argument.and_then(|arg| arg.suggestions).is_some() { + if node + .argument + .and_then(|arg| arg.protocol_suggestions) + .is_some() + { flags |= 0x10; } @@ -118,7 +122,7 @@ fn convert_node(node: &InfraCommandNode) -> CommandNode { properties: node.argument.and_then(parser_properties), suggestions_type: node .argument - .and_then(|argument| argument.suggestions) + .and_then(|argument| argument.protocol_suggestions) .map(str::to_string), } } @@ -178,7 +182,8 @@ mod tests { "tp", vec![CommandPathSegment::argument( "target", - ArgumentSpec::entity(false, false), + ArgumentSpec::entity(false, false) + .with_protocol_suggestions("minecraft:ask_server"), )], )]); From 7f809afbf9b53568116a524f41498207391b9e67 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 17:22:49 +0930 Subject: [PATCH 15/30] bossbar plus some bug fixes --- src/components/src/player/bossbar_sender.rs | 34 +- src/default_commands/src/new/bossbar.rs | 498 ++++++++++++++++++ src/default_commands/src/new/mod.rs | 1 + .../tests/new_command_registry.rs | 1 + .../src/packets/src/command_suggestions.rs | 64 ++- 5 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 src/default_commands/src/new/bossbar.rs diff --git a/src/components/src/player/bossbar_sender.rs b/src/components/src/player/bossbar_sender.rs index 6251e93c..5d5f1006 100644 --- a/src/components/src/player/bossbar_sender.rs +++ b/src/components/src/player/bossbar_sender.rs @@ -23,7 +23,12 @@ impl BossbarSender { } pub fn update(&mut self, uuid: Uuid) { - self.0.insert(uuid, BossbarSenderState::Update); + if matches!( + self.0.get(&uuid), + Some(BossbarSenderState::Informed | BossbarSenderState::Update) + ) { + self.0.insert(uuid, BossbarSenderState::Update); + } } pub fn remove(&mut self, uuid: Uuid) { @@ -46,3 +51,30 @@ impl BossbarSender { self.0.get(&uuid).cloned() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_does_not_start_tracking_unknown_bossbar() { + let uuid = Uuid::new_v4(); + let mut sender = BossbarSender::default(); + + sender.update(uuid); + + assert_eq!(sender.get_state(uuid), None); + } + + #[test] + fn update_marks_informed_bossbar_for_update() { + let uuid = Uuid::new_v4(); + let mut sender = BossbarSender::default(); + + sender.add(uuid); + sender.informed(uuid); + sender.update(uuid); + + assert_eq!(sender.get_state(uuid), Some(BossbarSenderState::Update)); + } +} diff --git a/src/default_commands/src/new/bossbar.rs b/src/default_commands/src/new/bossbar.rs new file mode 100644 index 00000000..fa5daf9d --- /dev/null +++ b/src/default_commands/src/new/bossbar.rs @@ -0,0 +1,498 @@ +use std::str::FromStr; + +use bevy_ecs::entity::Entity; +use bevy_ecs::prelude::{Query, ResMut}; +use bevy_ecs::world::World; +use rand::seq::IteratorRandom; +use temper_command_infra::args::GreedyStringArg; +use temper_command_infra::{ + ArgKind, ArgumentSpec, CommandArg, CommandHandler, CommandReader, CommandSource, ParseError, + ParserKind, ParserProperties, StringMode, SuggestionInput, SuggestionProviderKind, +}; +use temper_components::entity_identity::Identity; +use temper_components::player::bossbar_sender::BossbarSender; +use temper_components::player::player_marker::PlayerMarker; +use temper_core::mq; +use temper_macros::Command; +use temper_resources::bossbar::{BossBarData, BossBarResource, BossbarColor, BossbarDividers}; +use temper_text::ClickEvent::CopyToClipboard; +use temper_text::HoverEvent::ShowText; +use temper_text::{TextComponent, TextComponentBuilder}; +use tracing::info; +use uuid::Uuid; + +#[derive(Command)] +#[command("bossbar")] +enum BossbarCommand { + #[literal("add")] + Add { name: GreedyStringArg }, + #[literal("get")] + Get { id: BossbarIdArg }, + #[literal("list")] + List, + #[literal("remove")] + Remove { id: BossbarIdArg }, + #[literal("set")] + Set { + id: BossbarIdArg, + option: BossbarSetOptionArg, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct BossbarIdArg(Uuid); + +impl CommandArg for BossbarIdArg { + type Raw<'a> = Uuid; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::Server; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let cursor = reader.cursor(); + let raw = reader.read_word_span()?; + + Uuid::parse_str(raw).map_err(|_| ParseError::new(cursor, "bossbar id", "invalid UUID")) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw)) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Word), + ) + } + + fn suggest(_input: SuggestionInput<'_>, world: &mut World) -> Vec { + let Some(bossbars) = world.get_resource::() else { + return Vec::new(); + }; + + bossbars.boss_bars.keys().map(Uuid::to_string).collect() + } +} + +#[derive(Clone)] +enum BossbarSetOptionArg { + Color(BossbarColor), + Name(String), + Players(String), + Style(BossbarColor, BossbarDividers), + Value(f32), + Max(f32), +} + +impl CommandArg for BossbarSetOptionArg { + type Raw<'a> = &'a str; + + const KIND: ArgKind = ArgKind::GreedyTail; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::Server; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_remaining_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + parse_set_option(raw) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Greedy), + ) + } + + fn suggest(input: SuggestionInput<'_>, world: &mut World) -> Vec { + set_option_suggestions(input.full_input, world) + } +} + +impl CommandHandler for BossbarCommand { + type SystemParam<'w, 's> = ( + ResMut<'w, BossBarResource>, + Query< + 'w, + 's, + ( + Entity, + &'static Identity, + &'static mut BossbarSender, + Option<&'static PlayerMarker>, + ), + >, + ); + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let (bossbars, players) = params; + + match self { + Self::Add { name } => add_bossbar(source, bossbars, &name), + Self::Get { id } => get_bossbar(source, bossbars, id.0), + Self::List => list_bossbars(source, bossbars), + Self::Remove { id } => remove_bossbar(source, bossbars, id.0), + Self::Set { id, option } => set_bossbar(source, bossbars, players, id.0, option), + } + } +} + +fn add_bossbar(source: CommandSource, bossbars: &mut BossBarResource, name: &str) { + let uuid = bossbars.add_bar(BossBarData::new( + TextComponent::from(name), + 0.0, + 100.0, + BossbarColor::Pink, + )); + + send_message( + source, + TextComponent::from(format!("Created bossbar with uuid: {uuid}")) + .click_event(CopyToClipboard(uuid.to_string())) + .hover_event(ShowText(TextComponent::from(uuid.to_string()).into())), + ); +} + +fn get_bossbar(source: CommandSource, bossbars: &BossBarResource, uuid: Uuid) { + if let Some(bossbar) = bossbars.boss_bars.get(&uuid) { + send_message( + source, + TextComponentBuilder::new("Bossbar: ") + .extra(TextComponent::from(format!("{bossbar}"))) + .build(), + ); + } else { + send_missing_bossbar(source, uuid); + } +} + +fn list_bossbars(source: CommandSource, bossbars: &BossBarResource) { + if bossbars.boss_bars.is_empty() { + send_message( + source, + TextComponentBuilder::new("No bossbars exist.").build(), + ); + return; + } + + for uuid in bossbars.boss_bars.keys() { + send_message( + source, + TextComponentBuilder::new("Bossbar: ") + .extra( + TextComponent::from(uuid.to_string()) + .click_event(CopyToClipboard(uuid.to_string())) + .hover_event(ShowText(TextComponent::from(uuid.to_string()).into())), + ) + .build(), + ); + } +} + +fn remove_bossbar(source: CommandSource, bossbars: &mut BossBarResource, uuid: Uuid) { + if bossbars.boss_bars.contains_key(&uuid) { + bossbars.remove_bar(uuid); + send_message(source, TextComponentBuilder::new("removed bossbar").build()); + } else { + send_missing_bossbar(source, uuid); + } +} + +fn set_bossbar( + source: CommandSource, + bossbars: &mut BossBarResource, + players: &mut Query<(Entity, &Identity, &mut BossbarSender, Option<&PlayerMarker>)>, + uuid: Uuid, + option: BossbarSetOptionArg, +) { + let Some(bossbar) = bossbars.boss_bars.get(&uuid) else { + send_missing_bossbar(source, uuid); + return; + }; + + match option { + BossbarSetOptionArg::Color(color) => { + let dividers = bossbar.dividers; + for (_, _, mut sender, _) in players.iter_mut() { + sender.update(uuid); + } + bossbars.update_style(uuid, color, dividers); + } + BossbarSetOptionArg::Name(title) => { + for (_, _, mut sender, _) in players.iter_mut() { + sender.update(uuid); + } + bossbars.update_title(uuid, TextComponent::from(title)); + } + BossbarSetOptionArg::Players(target) => { + set_bossbar_players(bossbars, players, uuid, &target) + } + BossbarSetOptionArg::Style(color, dividers) => { + for (_, _, mut sender, _) in players.iter_mut() { + sender.update(uuid); + } + bossbars.update_style(uuid, color, dividers); + } + BossbarSetOptionArg::Value(value) => { + let max = bossbar.max; + for (_, _, mut sender, _) in players.iter_mut() { + sender.update(uuid); + } + bossbars.update_health(uuid, value, max); + } + BossbarSetOptionArg::Max(max) => { + let health = bossbar.health; + for (_, _, mut sender, _) in players.iter_mut() { + sender.update(uuid); + } + bossbars.update_health(uuid, health, max); + } + } +} + +fn set_bossbar_players( + bossbars: &BossBarResource, + players: &mut Query<(Entity, &Identity, &mut BossbarSender, Option<&PlayerMarker>)>, + uuid: Uuid, + target: &str, +) { + match target { + "@e" | "@a" => { + for (_, _, mut sender, _) in players.iter_mut() { + sender.add(uuid); + bossbars.queue_networking(uuid, true); + } + } + "@r" => { + if let Some((_, _, mut sender, _)) = players.iter_mut().choose(&mut rand::rng()) { + sender.add(uuid); + bossbars.queue_networking(uuid, true); + } + } + name => { + for (_, identity, mut sender, marker) in players.iter_mut() { + if marker.is_some() + && identity + .name + .as_ref() + .is_some_and(|player_name| player_name.eq_ignore_ascii_case(name)) + { + if sender.0.contains_key(&uuid) { + sender.remove(uuid); + bossbars.queue_networking(uuid, false); + } else { + sender.add(uuid); + bossbars.queue_networking(uuid, true); + } + } + } + } + } +} + +fn parse_set_option(raw: &str) -> Result { + let mut parts = raw.split_whitespace(); + let option = parts + .next() + .ok_or_else(|| ParseError::expected(0, "bossbar set option"))?; + + match option.to_ascii_lowercase().as_str() { + "color" => Ok(BossbarSetOptionArg::Color(parse_next( + parts.next(), + "color", + )?)), + "name" => { + let title = raw[option.len()..].trim(); + if title.is_empty() { + Err(ParseError::expected(option.len(), "bossbar name")) + } else { + Ok(BossbarSetOptionArg::Name(title.to_string())) + } + } + "players" => Ok(BossbarSetOptionArg::Players( + parts + .next() + .ok_or_else(|| ParseError::expected(option.len(), "player"))? + .to_string(), + )), + "style" => { + let color = parse_next(parts.next(), "color")?; + let dividers = parse_next(parts.next(), "style")?; + Ok(BossbarSetOptionArg::Style(color, dividers)) + } + "value" => Ok(BossbarSetOptionArg::Value(parse_float( + parts.next(), + "value", + )?)), + "max" => Ok(BossbarSetOptionArg::Max(parse_float(parts.next(), "max")?)), + _ => Err(ParseError::new( + 0, + "bossbar set option", + format!("invalid bossbar set option: {option}"), + )), + } +} + +fn parse_next(value: Option<&str>, expected: &'static str) -> Result { + let value = value.ok_or_else(|| ParseError::expected(0, expected))?; + value + .parse() + .map_err(|_| ParseError::new(0, expected, format!("invalid {expected}: {value}"))) +} + +fn parse_float(value: Option<&str>, expected: &'static str) -> Result { + parse_next(value, expected) +} + +fn set_option_suggestions(input: &str, world: &mut World) -> Vec { + let Some(after_id) = set_option_input(input) else { + return Vec::new(); + }; + let tokens = after_id.split_whitespace().collect::>(); + + match tokens.as_slice() { + [] => set_option_names(), + [option] => set_option_names() + .into_iter() + .filter(|suggestion| suggestion.starts_with(&option.to_ascii_lowercase())) + .collect(), + ["color", ..] => bossbar_colors(), + ["style", color] if !after_id.ends_with(char::is_whitespace) => bossbar_colors() + .into_iter() + .filter(|suggestion| suggestion.starts_with(&color.to_ascii_lowercase())) + .collect(), + ["style", ..] => bossbar_styles(), + ["players", player] if !after_id.ends_with(char::is_whitespace) => { + player_suggestions(world) + .into_iter() + .filter(|suggestion| suggestion.starts_with(player)) + .collect() + } + ["players", ..] => player_suggestions(world), + _ => Vec::new(), + } +} + +fn set_option_input(input: &str) -> Option<&str> { + let command = input.strip_prefix('/').unwrap_or(input); + let rest = command.strip_prefix("bossbar")?.trim_start(); + let rest = rest.strip_prefix("set")?.trim_start(); + let id_end = rest.find(char::is_whitespace)?; + + Some(rest[id_end..].trim_start()) +} + +fn set_option_names() -> Vec { + ["color", "name", "players", "style", "value", "max"] + .into_iter() + .map(str::to_string) + .collect() +} + +fn bossbar_colors() -> Vec { + ["blue", "green", "pink", "purple", "red", "white", "yellow"] + .into_iter() + .map(str::to_string) + .collect() +} + +fn bossbar_styles() -> Vec { + [ + "notched_6", + "notched_10", + "notched_12", + "notched_20", + "progress", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +fn player_suggestions(world: &mut World) -> Vec { + let mut suggestions = vec!["@e".to_string(), "@a".to_string(), "@r".to_string()]; + + for (identity, _, marker) in world + .query::<(&Identity, &BossbarSender, Option<&PlayerMarker>)>() + .iter(world) + { + if marker.is_some() + && let Some(name) = &identity.name + { + suggestions.push(name.clone()); + } + } + + suggestions +} + +fn send_missing_bossbar(source: CommandSource, uuid: Uuid) { + send_message( + source, + TextComponentBuilder::new("Bossbar doesn't exist for uuid: ") + .extra(TextComponent::from(uuid.to_string())) + .build(), + ); +} + +fn send_message(source: CommandSource, message: TextComponent) { + match source { + CommandSource::Player(entity) => mq::queue(message, false, entity), + CommandSource::Server => info!("{}", message.to_plain_text()), + } +} + +#[cfg(test)] +mod tests { + use temper_command_infra::CommandSpec; + + use super::*; + + #[test] + fn bossbar_commands_parse() { + assert!(matches!( + BossbarCommand::parse("add Hello").unwrap(), + BossbarCommand::Add { .. } + )); + assert!(matches!( + BossbarCommand::parse("list").unwrap(), + BossbarCommand::List + )); + } + + #[test] + fn bossbar_set_options_parse() { + let id = Uuid::new_v4(); + + assert!(matches!( + BossbarCommand::parse(&format!("set {id} color red")).unwrap(), + BossbarCommand::Set { + option: BossbarSetOptionArg::Color(BossbarColor::Red), + .. + } + )); + assert!(matches!( + BossbarCommand::parse(&format!("set {id} style blue notched_10")).unwrap(), + BossbarCommand::Set { + option: BossbarSetOptionArg::Style(BossbarColor::Blue, BossbarDividers::TenNotches), + .. + } + )); + } + + #[test] + fn bossbar_paths_preserve_old_syntax() { + let paths = BossbarCommand::paths(); + + assert!(paths.iter().any(|path| path.root == "bossbar" + && matches!( + path.segments.as_slice(), + [ + temper_command_infra::CommandPathSegment::Literal { name: "set", .. }, + temper_command_infra::CommandPathSegment::Argument { name: "id", .. }, + temper_command_infra::CommandPathSegment::Argument { name: "option", .. }, + ] + ))); + } +} diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs index fa8f661d..a5bee58f 100644 --- a/src/default_commands/src/new/mod.rs +++ b/src/default_commands/src/new/mod.rs @@ -1,3 +1,4 @@ +mod bossbar; mod echo; mod stop; mod time; diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs index b83392f5..c1b0f97b 100644 --- a/src/default_commands/tests/new_command_registry.rs +++ b/src/default_commands/tests/new_command_registry.rs @@ -16,6 +16,7 @@ fn default_commands_register_new_metadata() { assert!(paths.iter().any(|path| path.root == "stop")); assert!(paths.iter().any(|path| path.root == "echo")); assert!(paths.iter().any(|path| path.root == "time")); + assert!(paths.iter().any(|path| path.root == "bossbar")); let stop = paths.iter().find(|path| path.root == "stop").unwrap(); let echo = paths.iter().find(|path| path.root == "echo").unwrap(); diff --git a/src/game_systems/src/packets/src/command_suggestions.rs b/src/game_systems/src/packets/src/command_suggestions.rs index feefc81f..5e5c9133 100644 --- a/src/game_systems/src/packets/src/command_suggestions.rs +++ b/src/game_systems/src/packets/src/command_suggestions.rs @@ -339,7 +339,7 @@ fn candidate_segment<'a>( ) -> Option<&'a CommandPathSegment> { let mut token_index = 0; - for segment in segments { + for (segment_index, segment) in segments.iter().enumerate() { let Some(token) = completed_tokens.get(token_index) else { return Some(segment); }; @@ -348,6 +348,21 @@ fn candidate_segment<'a>( return None; } + if segment_index == segments.len() - 1 + && matches!( + segment, + CommandPathSegment::Argument { + spec: temper_command_infra::ArgumentSpec { + server_suggestions: Some(_), + .. + }, + .. + } + ) + { + return Some(segment); + } + token_index += segment_width(segment); } @@ -396,7 +411,7 @@ fn segment_suggestions( CommandPathSegment::Argument { spec, .. } if spec.server_suggestions.is_some() => { Some(SegmentSuggestions::Provider(ProviderSuggestions { id: spec.server_suggestions.unwrap(), - fallback: entity_fallback_suggestions(*spec, state), + fallback: provider_fallback_suggestions(*spec, state), })) } CommandPathSegment::Argument { spec, .. } if is_ask_server(spec.protocol_suggestions) => { @@ -415,6 +430,16 @@ fn is_ask_server(suggestions: Option<&str>) -> bool { matches!(suggestions, Some("ask_server" | "minecraft:ask_server")) } +fn provider_fallback_suggestions( + spec: temper_command_infra::ArgumentSpec, + state: &GlobalStateResource, +) -> Vec { + match spec.parser { + ParserKind::Entity => entity_fallback_suggestions(spec, state), + _ => Vec::new(), + } +} + fn entity_fallback_suggestions( spec: temper_command_infra::ArgumentSpec, state: &GlobalStateResource, @@ -562,6 +587,25 @@ mod tests { registry } + fn server_suggested_word_registry() -> CommandRegistry { + let mut registry = CommandRegistry::default(); + registry.register_command(RegisteredCommand { + name: "custom", + aliases: &[], + permission: None, + paths: vec![CommandPath::new( + "custom", + vec![CommandPathSegment::argument( + "value", + ArgumentSpec::new(ParserKind::String) + .with_protocol_suggestions("minecraft:ask_server") + .with_server_suggestions("test:value"), + )], + )], + }); + registry + } + #[test] fn new_command_suggestions_include_entities_for_first_tp_arg() { let (state, _temp_dir) = create_test_state(); @@ -645,6 +689,22 @@ mod tests { assert_eq!(matches, vec!["day", "d"]); } + #[test] + fn server_suggested_non_entity_args_do_not_get_entity_fallbacks() { + let (state, _temp_dir) = create_test_state(); + state + .0 + .players + .player_list + .insert(Entity::PLACEHOLDER, (0, "Alex".to_string())); + + let suggestions = + new_command_suggestions("/custom ", &server_suggested_word_registry(), &state, None) + .unwrap(); + + assert!(suggestions.matches.is_empty()); + } + #[test] fn old_command_suggestions_do_not_handle_new_roots() { let (state, _temp_dir) = create_test_state(); From ab1c6f563f297906e79b913f8a21f0e16f0997b6 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 18:31:01 +0930 Subject: [PATCH 16/30] gamemode --- src/default_commands/src/new/gamemode.rs | 138 ++++++++++++++++++ src/default_commands/src/new/mod.rs | 1 + .../tests/new_command_registry.rs | 37 +++++ 3 files changed, 176 insertions(+) create mode 100644 src/default_commands/src/new/gamemode.rs diff --git a/src/default_commands/src/new/gamemode.rs b/src/default_commands/src/new/gamemode.rs new file mode 100644 index 00000000..05e006d5 --- /dev/null +++ b/src/default_commands/src/new/gamemode.rs @@ -0,0 +1,138 @@ +use bevy_ecs::message::MessageWriter; +use bevy_ecs::prelude::{Entity, Query, With, World}; +use temper_command_infra::args::EntityArg; +use temper_command_infra::{ + ArgKind, ArgumentSpec, CommandArg, CommandHandler, CommandReader, CommandSource, ParseError, + ParserKind, ParserProperties, StringMode, SuggestionInput, SuggestionProviderKind, +}; +use temper_components::entity_identity::Identity; +use temper_components::player::gamemode::GameMode; +use temper_components::player::player_marker::PlayerMarker; +use temper_macros::Command; +use temper_messages::PlayerGameModeChanged; +use tracing::info; + +#[derive(Command)] +#[command("gamemode")] +enum GamemodeCommand { + SelfTarget(#[arg("gamemode")] GamemodeArg), + OtherTarget { + gamemode: GamemodeArg, + target: EntityArg, + }, +} + +enum GamemodeArg { + Survival, + Creative, + Adventure, + Spectator, +} + +impl CommandHandler for GamemodeCommand { + type SystemParam<'w, 's> = ( + MessageWriter<'w, PlayerGameModeChanged>, + Query< + 'w, + 's, + (Entity, &'static Identity, Option<&'static PlayerMarker>), + With, + >, + ); + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let (writer, query) = params; + let player_entity = match source { + CommandSource::Server => { + info!("Error: The server can't change gamemode."); + return; + } + CommandSource::Player(entity) => entity, + }; + + match self { + GamemodeCommand::SelfTarget(new_mode) => { + writer.write(PlayerGameModeChanged { + player: player_entity, + new_mode: match new_mode { + GamemodeArg::Survival => GameMode::Survival, + GamemodeArg::Creative => GameMode::Creative, + GamemodeArg::Adventure => GameMode::Adventure, + GamemodeArg::Spectator => GameMode::Spectator, + }, + }); + } + GamemodeCommand::OtherTarget { target, gamemode } => { + writer.write(PlayerGameModeChanged { + player: target + .resolve(query.into_iter()) + .first() + .copied() + .unwrap_or(player_entity), + new_mode: match gamemode { + GamemodeArg::Survival => GameMode::Survival, + GamemodeArg::Creative => GameMode::Creative, + GamemodeArg::Adventure => GameMode::Adventure, + GamemodeArg::Spectator => GameMode::Spectator, + }, + }); + } + } + } +} + +impl CommandArg for GamemodeArg { + type Raw<'a> = &'a str; + const KIND: ArgKind = ArgKind::Normal; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::Server; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let word = reader.read_word_span()?; + match word { + "0" | "survival" | "s" => Ok("survival"), + "1" | "creative" | "c" => Ok("creative"), + "2" | "adventure" | "a" => Ok("adventure"), + // Not actually in vanilla but seems weird not having it + "3" | "spectator" | "sp" => Ok("spectator"), + // TODO: Hook this up to the actual config + "5" | "default" | "d" => Ok("creative"), + other => Err(ParseError::new( + reader.cursor(), + "gamemode", + format!("invalid gamemode: {}", other), + )), + } + } + + fn parse(raw: Self::Raw<'_>) -> Result { + let word = raw.split_whitespace().next().unwrap_or(""); + match word.to_ascii_lowercase().as_str() { + "survival" | "0" | "s" => Ok(GamemodeArg::Survival), + "creative" | "1" | "c" => Ok(GamemodeArg::Creative), + "adventure" | "2" | "a" => Ok(GamemodeArg::Adventure), + "spectator" | "3" | "sp" => Ok(GamemodeArg::Spectator), + "default" | "5" | "d" => Ok(GamemodeArg::Creative), + other => Err(ParseError::new( + 0, + "gamemode", + format!("invalid gamemode: {}", other), + )), + } + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Word), + ) + } + + fn suggest(_input: SuggestionInput<'_>, _world: &mut World) -> Vec { + vec![ + "survival".to_string(), + "creative".to_string(), + "adventure".to_string(), + "spectator".to_string(), + ] + } +} diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs index a5bee58f..a7d2a133 100644 --- a/src/default_commands/src/new/mod.rs +++ b/src/default_commands/src/new/mod.rs @@ -1,5 +1,6 @@ mod bossbar; mod echo; +mod gamemode; mod stop; mod time; mod tp; diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs index c1b0f97b..5fedb21c 100644 --- a/src/default_commands/tests/new_command_registry.rs +++ b/src/default_commands/tests/new_command_registry.rs @@ -1,6 +1,7 @@ use bevy_ecs::prelude::World; use temper_command_infra::{ CommandGraph, CommandNodeKind, CommandPathSegment, CommandRegistry, ParserKind, + SuggestionInput, suggest_command_arg, }; use temper_commands::arg::primitive::PrimitiveArgumentType; use temper_protocol::outgoing::commands::CommandsPacket; @@ -17,6 +18,7 @@ fn default_commands_register_new_metadata() { assert!(paths.iter().any(|path| path.root == "echo")); assert!(paths.iter().any(|path| path.root == "time")); assert!(paths.iter().any(|path| path.root == "bossbar")); + assert!(paths.iter().any(|path| path.root == "gamemode")); let stop = paths.iter().find(|path| path.root == "stop").unwrap(); let echo = paths.iter().find(|path| path.root == "echo").unwrap(); @@ -57,6 +59,41 @@ fn default_commands_register_new_metadata() { ))); } +#[test] +fn default_gamemode_arg_registers_suggestions() { + temper_default_commands::init(); + + let registry = CommandRegistry::from_static_commands(); + let paths = registry.paths_for_player(World::new().spawn_empty().id()); + let provider = paths + .iter() + .filter(|path| path.root == "gamemode") + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + CommandPathSegment::Argument { spec, .. } => spec.server_suggestions, + _ => None, + }) + .unwrap(); + + let mut world = World::new(); + let source = world.spawn_empty().id(); + let suggestions = suggest_command_arg( + provider, + &mut world, + SuggestionInput { + full_input: "/gamemode ", + current_token: "", + source, + }, + ) + .unwrap(); + + assert_eq!( + suggestions, + vec!["survival", "creative", "adventure", "spectator"] + ); +} + #[test] fn default_tp_graph_uses_client_handled_entity_and_position_parsers() { temper_default_commands::init(); From 2c470cc901b89d818823b2848e3b945a9cc7bcf6 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 19:00:47 +0930 Subject: [PATCH 17/30] credits and kill command --- src/command-infra/src/ecs.rs | 17 +++--- src/default_commands/src/credits.rs | 2 +- src/default_commands/src/new/credits.rs | 42 +++++++++++++++ src/default_commands/src/new/kill.rs | 72 +++++++++++++++++++++++++ src/default_commands/src/new/mod.rs | 3 ++ src/default_commands/src/new/summon.rs | 2 + 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 src/default_commands/src/new/credits.rs create mode 100644 src/default_commands/src/new/kill.rs create mode 100644 src/default_commands/src/new/summon.rs diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs index 38a1aca0..a6cea149 100644 --- a/src/command-infra/src/ecs.rs +++ b/src/command-infra/src/ecs.rs @@ -9,7 +9,7 @@ use bevy_ecs::system::{ScheduleSystem, SystemParam}; use temper_core::mq; use temper_permissions::Permissions; use temper_permissions::player::PlayerPermission; -use temper_text::{NamedColor, TextComponentBuilder}; +use temper_text::{NamedColor, TextComponent, TextComponentBuilder}; use tracing::info; use crate::{CommandGraph, CommandPath, CommandSpec, ParseError}; @@ -225,11 +225,7 @@ fn send_permission_error(source: CommandSource) { let message = TextComponentBuilder::new("You don't have permission to use this command.") .color(NamedColor::Red) .build(); - - match source { - CommandSource::Player(entity) => mq::queue(message, false, entity), - CommandSource::Server => info!("{}", message.to_plain_text()), - } + source.send_message(message); } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -238,6 +234,15 @@ pub enum CommandSource { Server, } +impl CommandSource { + pub fn send_message(self, message: TextComponent) { + match self { + CommandSource::Player(entity) => {mq::queue(message, false, entity)}, + CommandSource::Server => {info!("{}", message.to_plain_text())} + } + } +} + #[derive(Message, Clone, Debug)] pub struct NewCommandDispatched { pub input: Arc, diff --git a/src/default_commands/src/credits.rs b/src/default_commands/src/credits.rs index 35433843..5dabb6de 100644 --- a/src/default_commands/src/credits.rs +++ b/src/default_commands/src/credits.rs @@ -7,7 +7,7 @@ use temper_net_runtime::connection::StreamWriter; use temper_protocol::outgoing::show_dialog::{DialogBody, DialogContent, ShowDialog}; use temper_text::TextComponent; -static CREDITS_TEXT: &str = include_str!("../../../assets/data/credits.txt"); +pub(crate) static CREDITS_TEXT: &str = include_str!("../../../assets/data/credits.txt"); #[command("credits")] fn credits(#[sender] sender: Sender, query: Query<&StreamWriter>) { diff --git a/src/default_commands/src/new/credits.rs b/src/default_commands/src/new/credits.rs new file mode 100644 index 00000000..4424a856 --- /dev/null +++ b/src/default_commands/src/new/credits.rs @@ -0,0 +1,42 @@ +use bevy_ecs::prelude::Query; +use temper_codec::net_types::adhoc_id::AdHocID; +use temper_command_infra::{CommandHandler, CommandSource}; +use temper_macros::Command; +use temper_nbt::NBT; +use temper_net_runtime::connection::StreamWriter; +use temper_protocol::outgoing::show_dialog::{DialogBody, DialogContent, ShowDialog}; +use temper_text::TextComponent; + +#[derive(Command)] +#[command("credits")] +struct CreditsCommand; + +impl CommandHandler for CreditsCommand { + type SystemParam<'w, 's> = Query<'w, 's, &'static StreamWriter>; + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let conn = match source { + CommandSource::Server => { + // Server cannot have credits + return; + } + CommandSource::Player(entity) => params.get(entity).expect("sender does not exist"), + }; + let lines = crate::credits::CREDITS_TEXT + .lines() + .map(|t| DialogBody { + dialog_body_type: "minecraft:plain_message".to_string(), + contents: TextComponent::from(t), + width: Some(1024), + }) + .collect::>(); + let packet = ShowDialog { + content: AdHocID::from(NBT::from(DialogContent { + dialog_content_type: "minecraft:notice".to_string(), + title: TextComponent::from("Credits"), + body: lines, + })), + }; + conn.send_packet(packet).unwrap(); + } +} \ No newline at end of file diff --git a/src/default_commands/src/new/kill.rs b/src/default_commands/src/new/kill.rs new file mode 100644 index 00000000..62cdce7f --- /dev/null +++ b/src/default_commands/src/new/kill.rs @@ -0,0 +1,72 @@ +use bevy_ecs::prelude::{Entity, MessageWriter, Query}; +use temper_command_infra::args::EntityArg; +use temper_command_infra::CommandSource::Player; +use temper_command_infra::{CommandHandler, CommandSource}; +use temper_components::entity_identity::Identity; +use temper_components::player::player_marker::PlayerMarker; +use temper_macros::Command; +use temper_messages::destroy_entity::DestroyEntity; +use temper_permissions::player::PlayerPermission; + +#[derive(Command)] +#[command("kill")] +enum KillCommand { + SelfTarget, + OtherTarget{ target: EntityArg} +} + +impl CommandHandler for KillCommand { + type SystemParam<'w, 's> = ( + Query<'w, 's, (Entity, &'static Identity, Option<&'static PlayerMarker>)>, + MessageWriter<'w, DestroyEntity>, + Query<'w, 's, &'static PlayerPermission>, + ); + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let &mut (query, ref mut writer, permissions) = params; + + let is_permitted = match source { + Player(entity) => { + if let Ok(player_perm) = permissions.get(entity) { + player_perm.can(temper_permissions::Permissions::Kill) + } else { + false + } + } + _ => true, + }; + + if !is_permitted { + source.send_message( + "You don't have permission to use this command.".into(), + ); + return; + } + + let selected_entities = match self { + KillCommand::SelfTarget => { + if let Player(entity) = source { + vec![entity] + } else { + source.send_message( + "The server cannot target itself with this command.".into(), + ); + vec![] + } + }, + KillCommand::OtherTarget { target } => target.resolve(query.iter()), + }; + + selected_entities.iter().for_each(|e| { + writer.write(DestroyEntity(*e)); + }); + + source.send_message( + format!( + "Killed {} entities (excluding players).", + selected_entities.len() + ) + .into(), + ); + } +} \ No newline at end of file diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs index a7d2a133..9b2b9d68 100644 --- a/src/default_commands/src/new/mod.rs +++ b/src/default_commands/src/new/mod.rs @@ -4,3 +4,6 @@ mod gamemode; mod stop; mod time; mod tp; +mod credits; +mod kill; +mod summon; diff --git a/src/default_commands/src/new/summon.rs b/src/default_commands/src/new/summon.rs new file mode 100644 index 00000000..99e84a2c --- /dev/null +++ b/src/default_commands/src/new/summon.rs @@ -0,0 +1,2 @@ +enum SummonCommand { +} \ No newline at end of file From 81ec3e8c8e5d2180e70d09b408e1965e5e491187 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 21:12:48 +0930 Subject: [PATCH 18/30] docs + bump deps --- Cargo.toml | 26 +-- src/base/macros/src/commands/mod.rs | 6 +- src/command-infra/src/args/position.rs | 14 +- src/command-infra/src/args/string.rs | 56 ++--- src/command-infra/src/ecs.rs | 63 ++--- src/command-infra/src/lib.rs | 106 ++++++++- src/command-infra/src/metadata.rs | 14 ++ src/command-infra/tests/derive_command.rs | 53 +++++ src/commands/src/arg/primitive/mod.rs | 1 + src/default_commands/src/credits.rs | 5 +- src/default_commands/src/lib.rs | 1 - src/default_commands/src/new/bossbar.rs | 33 +-- src/default_commands/src/new/credits.rs | 4 +- src/default_commands/src/new/echo.rs | 9 +- src/default_commands/src/new/mod.rs | 6 +- src/default_commands/src/new/summon.rs | 217 +++++++++++++++++- src/default_commands/src/new/time.rs | 41 +--- src/default_commands/src/spawn.rs | 174 -------------- .../tests/new_command_registry.rs | 29 ++- .../src/packets/src/command_suggestions.rs | 4 +- .../src/player/src/entity_spawn.rs | 15 +- .../tests/mobs/entity_persistence.rs | 10 +- src/messages/src/entity_spawn.rs | 3 +- src/net/protocol/src/outgoing/commands.rs | 35 ++- 24 files changed, 557 insertions(+), 368 deletions(-) delete mode 100644 src/default_commands/src/spawn.rs diff --git a/Cargo.toml b/Cargo.toml index 40df7477..f4375be8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -182,7 +182,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-appender = "0.2.5" tracing-tracy = { version = "0.11.4", features = ["timer-fallback", "ondemand", "fibers", "context-switch-tracing", "delayed-init"] } -log = "0.4.32" +log = "0.4.33" tracy-client = "0.18.4" # Concurrency/Parallelism @@ -197,7 +197,7 @@ reqwest = { version = "0.13.4", features = ["json", "native-tls", "blocking", "h # Error handling thiserror = "2.0.18" -anyhow = "1.0.102" +anyhow = "1.0.103" # Cryptography rand = "0.10.1" @@ -230,16 +230,16 @@ byteorder = "1.5.0" # Data types dashmap = { version = "7.0.0-rc2", features = ["serde"] } -uuid = { version = "1.23.3", features = ["v4", "v3", "serde"] } +uuid = { version = "1.23.4", features = ["v4", "v3", "serde"] } indexmap = { version = "2.14.0", features = ["serde"] } bimap = "0.6.3" -arrayvec = "0.7.6" +arrayvec = "0.7.7" fastcache = "0.1.7" # Macros lazy_static = "1.5.0" -quote = "1.0.45" -syn = { version = "2.0.117", features = ["extra-traits", "full"] } +quote = "1.0.46" +syn = { version = "2.0.118", features = ["extra-traits", "full"] } proc-macro2 = "1.0.106" paste = "1.0.15" maplit = "1.0.2" @@ -262,7 +262,7 @@ heed = "0.22.1" # Misc deepsize = "0.2.0" page_size = "0.6.0" -enum-ordinalize = "4.3.2" +enum-ordinalize = "4.4.1" regex = "1.12.4" noise = "0.9.0" ctrlc = "3.5.2" @@ -276,9 +276,9 @@ mime_guess = "2.0.5" ## TUI/CLI crossterm = "0.29.0" -ratatui-core = "0.1.1" +ratatui-core = "0.1.2" tui-input = "0.15.3" -ratatui = "0.30.1" +ratatui = "0.30.2" tui-logger = { version = "0.18.2", features = ["tracing-support", "crossterm"] } clap = { version = "4.6.1", features = ["derive", "env"] } indicatif = "0.18.4" @@ -287,7 +287,7 @@ unicode-width = "0.2.2" heck = "0.5.0" # I/O -memmap2 = "0.9.10" +memmap2 = "0.9.11" tempfile = "3.27.0" walkdir = "2.5.0" include_dir = "0.7.4" @@ -295,14 +295,14 @@ include_dir = "0.7.4" # Benchmarking criterion = { version = "0.8.2", features = ["html_reports"] } -phf = { version = "0.13.1", features = ["macros"] } -phf_codegen = { version = "0.13.1" } +phf = { version = "0.14.0", features = ["macros"] } +phf_codegen = { version = "0.14.0" } # Web server axum = { version = "0.8.9", features = ["tokio", "ws"] } # Stats -sysinfo = { version = "0.39.3", default-features = false, features = ["system"] } +sysinfo = { version = "0.39.5", default-features = false, features = ["system"] } dir-size = "0.1.1" # Compression is a real bottleneck that we can do little about, so compiling it with optimizations is needed in dev mode. diff --git a/src/base/macros/src/commands/mod.rs b/src/base/macros/src/commands/mod.rs index 5d899688..f475becb 100644 --- a/src/base/macros/src/commands/mod.rs +++ b/src/base/macros/src/commands/mod.rs @@ -212,15 +212,15 @@ pub fn command(attr: TokenStream, item: TokenStream) -> TokenStream { let call = if has_sender_arg && sender_arg_before_cmd_args { quote! { - #fn_name(#sender_param #(#arg_extractors)* #(#system_arg_pats)*); + #fn_name(#sender_param #(#arg_extractors)* #(#system_arg_pats,)*); } } else if has_sender_arg { quote! { - #fn_name(#(#arg_extractors)* #sender_param #(#system_arg_pats)*); + #fn_name(#(#arg_extractors)* #sender_param #(#system_arg_pats,)*); } } else { quote! { - #fn_name(#(#arg_extractors)* #(#system_arg_pats)*); + #fn_name(#(#arg_extractors)* #(#system_arg_pats,)*); } }; diff --git a/src/command-infra/src/args/position.rs b/src/command-infra/src/args/position.rs index 8a74c545..80f4ec06 100644 --- a/src/command-infra/src/args/position.rs +++ b/src/command-infra/src/args/position.rs @@ -54,7 +54,11 @@ fn read_coord<'a>( let cursor = reader.cursor(); let span = reader.read_word_span()?; - if is_coord(span) { + if let Some(relative) = span.strip_prefix('~') + && (relative.is_empty() || relative.parse::().is_ok()) + { + Ok(span) + } else if span.parse::().is_ok() { Ok(span) } else { Err(ParseError::new( @@ -65,14 +69,6 @@ fn read_coord<'a>( } } -fn is_coord(span: &str) -> bool { - if let Some(relative) = span.strip_prefix('~') { - relative.is_empty() || relative.parse::().is_ok() - } else { - span.parse::().is_ok() - } -} - fn resolve_coord(coord: &str, base: f64) -> f64 { if let Some(relative) = coord.strip_prefix('~') { if relative.is_empty() { diff --git a/src/command-infra/src/args/string.rs b/src/command-infra/src/args/string.rs index fe56b1a3..0594a966 100644 --- a/src/command-infra/src/args/string.rs +++ b/src/command-infra/src/args/string.rs @@ -60,7 +60,31 @@ impl CommandArg for QuotableStringArg { fn parse(raw: Self::Raw<'_>) -> Result { let parsed = match raw { StringSpan::Bare(span) => span.to_string(), - StringSpan::Quoted(span) => unescape_quoted(span), + StringSpan::Quoted(span) => { + let mut result = String::new(); + let mut escaped = false; + for c in span.chars() { + if escaped { + if matches!(c, '"' | '\\') { + result.push(c); + } else { + result.push('\\'); + result.push(c); + } + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + } else { + result.push(c); + } + } + if escaped { + result.push('\\'); + } + result + } }; Ok(Self(parsed)) @@ -106,33 +130,3 @@ impl CommandArg for GreedyStringArg { ) } } - -fn unescape_quoted(span: &str) -> String { - let mut result = String::new(); - let mut escaped = false; - - for c in span.chars() { - if escaped { - if matches!(c, '"' | '\\') { - result.push(c); - } else { - result.push('\\'); - result.push(c); - } - escaped = false; - continue; - } - - if c == '\\' { - escaped = true; - } else { - result.push(c); - } - } - - if escaped { - result.push('\\'); - } - - result -} diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs index a6cea149..429025c0 100644 --- a/src/command-infra/src/ecs.rs +++ b/src/command-infra/src/ecs.rs @@ -85,12 +85,12 @@ pub fn register_command_systems(schedule: &mut Schedule) { pub trait CommandHandler: CommandSpec + Sized + Send + Sync + 'static { type SystemParam<'w, 's>: SystemParam; - fn handle<'w, 's>(self, source: CommandSource, params: &mut Self::SystemParam<'w, 's>); + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>); - fn handle_parse_error<'w, 's>( + fn handle_parse_error( source: CommandSource, error: ParseError, - _params: &mut Self::SystemParam<'w, 's>, + _params: &mut Self::SystemParam<'_, '_>, ) { send_parse_error(source, &error); } @@ -101,10 +101,7 @@ pub fn send_parse_error(source: CommandSource, error: &ParseError) { .color(NamedColor::Red) .build(); - match source { - CommandSource::Player(entity) => mq::queue(message, false, entity), - CommandSource::Server => info!("{}", message.to_plain_text()), - } + source.send_message(message); } pub fn dispatch_command( @@ -113,7 +110,8 @@ pub fn dispatch_command( mut params: C::SystemParam<'_, '_>, ) { for event in commands.read() { - let Some(root) = command_root(&event.input) else { + let input = &event.input; + let Some(root) = input.split_whitespace().next() else { continue; }; @@ -121,15 +119,28 @@ pub fn dispatch_command( continue; } - let can_use = |permission| source_can_use(event.source, &permissions, permission); + let can_use = |permission| { + let source = event.source; + match source { + CommandSource::Server => true, + CommandSource::Player(entity) => permissions + .get(entity) + .is_ok_and(|player_permissions| player_permissions.can(permission)), + } + }; if let Some(permission) = C::permission() && !can_use(permission) { - send_permission_error(event.source); + let source = event.source; + let message = TextComponentBuilder::new("You don't have permission to use this command.") + .color(NamedColor::Red) + .build(); + source.send_message(message); continue; } - let input = command_args(&event.input, root); + let input1 = &event.input; + let input = input1.strip_prefix(root).unwrap_or(input1).trim_start(); let mut reader = crate::CommandReader::new(input); match C::parse_reader_with_permissions(&mut reader, &can_use) { Ok(command) => command.handle(event.source, &mut params), @@ -163,7 +174,7 @@ impl CommandRegistry { } pub fn owns_input(&self, input: &str) -> bool { - command_root(input).is_some_and(|input_root| { + input.split_whitespace().next().is_some_and(|input_root| { self.commands .iter() .any(|command| command.matches_root(input_root)) @@ -200,34 +211,6 @@ impl CommandRegistry { } } -fn command_root(input: &str) -> Option<&str> { - input.split_whitespace().next() -} - -fn command_args<'a>(input: &'a str, root: &str) -> &'a str { - input.strip_prefix(root).unwrap_or(input).trim_start() -} - -fn source_can_use( - source: CommandSource, - permissions: &Query<&PlayerPermission>, - permission: Permissions, -) -> bool { - match source { - CommandSource::Server => true, - CommandSource::Player(entity) => permissions - .get(entity) - .is_ok_and(|player_permissions| player_permissions.can(permission)), - } -} - -fn send_permission_error(source: CommandSource) { - let message = TextComponentBuilder::new("You don't have permission to use this command.") - .color(NamedColor::Red) - .build(); - source.send_message(message); -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CommandSource { Player(Entity), diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index 18593f70..bc3e0590 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -1,3 +1,105 @@ +//! # Commands in Temper (strap in chucklefucks) +//! +//! Defining commands is pretty simple, simple define an enum (or struct) and slap the derive macro on it: +//! ``` +//! # use temper_command_infra::args::{EntityArg, PositionArg}; +//! +//! #[derive(Command)] +//! #[command("example")] +//! enum ExampleCommand { +//! WithEntity{ entity: EntityArg}, +//! WithoutEntity, +//! #[subcommand("sub")] +//! Subcommand(ExampleSingleSubcommand), +//! +//! } +//! +//! #[derive(Command)] +//! #[command(subcommand)] +//! struct ExampleSingleSubcommand { +//! WithPos: PositionArg +//! } +//! ``` +//! and then implementing the [crate::CommandHandler] trait on it: +//! ``` +//! # use bevy_ecs::prelude::{Query, Res}; +//! # use temper_command_infra::CommandHandler; +//! # use temper_components::player::position::Position; +//! # use temper_components::player::rotation::Rotation; +//! +//! # use temper_state::GlobalState; +//! +//! impl CommandHandler for ExampleCommand { +//! // These can be whatever ECS params you need +//! type SystemParam<'w, 's> = ( +//! Res<'w, GlobalState>, +//! Query<'w, 's, &'static Position, &'static Rotation>, +//! ); +//! +//! fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { +//! let (state: GlobalState, query: Query<'_, '_, &'static Position, &'static Rotation>) = params; +//! match self { +//! WithEntity{ entity } => { +//! // do something with the entity name/uuid/selector the player gave in the first argument +//! }, +//! WithoutEntity => { +//! // do something without an entity +//! }, +//! Subcommand(subcommand) => { +//! // do something with the subcommand +//! let sub_entity = subcommand.WithPos; +//! // do something with the position the player gave in the first argument of the subcommand +//! } +//! } +//! } +//! +//! // Optional error handler method +//! fn handle_parse_error( +//! source: CommandSource, +//! error: ParseError, +//! _params: &mut Self::SystemParam<'_, '_>, +//! ) {} +//! } +//! ``` +//! The entire system revolves around the [crate::CommandHandler] trait, which is implemented on the +//! command enum/struct. Under the hood the derive macro will generate all the code needed to have +//! the command wired into the ECS, provide suggestions and parse the arguments. All you need to do +//! is define what arguments a command needs and what it does with those args. +//! +//! There are several attribute macros available including literal args: +//! ``` +//! #[derive(Command)] +//! #[command("example")] +//! enum ExampleCommand { +//! #[literal("literal")] +//! LiteralCommand, +//! } +//! ``` +//! that skip the hassle of parsing and verifying an argument when you only allow a specific set of +//! options, aliases on both commands and literals: +//! ``` +//! #[derive(Command)] +//! #[command("example", aliases = ["ex", "exmpl"])] +//! enum ExampleCommand { +//! #[literal("literal", aliases = ["lit", "l"])] +//! LiteralCommand, +//! } +//! ``` +//! and permissions: +//! ``` +//! #[derive(Command)] +//! #[command("example", permission = Permissions::ExamplePermission)] +//! enum ExampleCommand { +//! #[literal("literal", permission = Permissions::LiteralExamplePermission)] +//! LiteralCommand, +//! } +//! ``` +//! (Note that this only limits what gets suggested to the client, you still need to verify +//! permissions in handlers to prevent manually typed commands being run when they shouldn't) +//! +//! This is the general gist of using commands with existing argument types, check out [crate::args] +//! for how to make your own argument types. + pub mod args; pub mod ecs; pub mod error; @@ -20,8 +122,8 @@ pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; pub use metadata::SubcommandSpec; pub use metadata::{ ArgKind, ArgumentSpec, CommandArg, CommandPath, CommandPathSegment, CommandSpec, - EntityProperties, IntegerProperties, ParserKind, ParserProperties, StringMode, - SuggestionProviderKind, + EntityProperties, IntegerProperties, ParserKind, ParserProperties, ResourceProperties, + StringMode, SuggestionProviderKind, }; pub use reader::{Checkpoint, CommandReader}; pub use suggestions::{ diff --git a/src/command-infra/src/metadata.rs b/src/command-infra/src/metadata.rs index 3ff8a088..d1cba6dd 100644 --- a/src/command-infra/src/metadata.rs +++ b/src/command-infra/src/metadata.rs @@ -29,11 +29,17 @@ pub struct EntityProperties { pub players_only: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResourceProperties { + pub registry: &'static str, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ParserProperties { String(StringMode), Integer(IntegerProperties), Entity(EntityProperties), + Resource(ResourceProperties), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -43,6 +49,7 @@ pub enum ParserKind { String, Position, Entity, + Resource, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -103,6 +110,13 @@ impl ArgumentSpec { }), ) } + + pub const fn resource(registry: &'static str) -> ArgumentSpec { + Self::with_properties( + ParserKind::Resource, + ParserProperties::Resource(ResourceProperties { registry }), + ) + } } pub trait CommandArg: Sized { diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs index 0eacccd0..e2f6b73e 100644 --- a/src/command-infra/tests/derive_command.rs +++ b/src/command-infra/tests/derive_command.rs @@ -122,12 +122,21 @@ struct ClientSuggestedCommand { value: ClientSuggestedArg, } +#[derive(Debug, PartialEq, Command)] +#[command("summon")] +struct SummonCommand { + entity: ResourceArg, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct SuggestedWordArg(String); #[derive(Clone, Debug, Eq, PartialEq)] struct ClientSuggestedArg(String); +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResourceArg(String); + #[derive(Resource)] struct SuggestedWords(Vec); @@ -172,6 +181,24 @@ impl CommandArg for ClientSuggestedArg { } } +impl CommandArg for ResourceArg { + type Raw<'a> = &'a str; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::resource("minecraft:entity_type") + } +} + macro_rules! impl_noop_handler { ($($command:ty),* $(,)?) => { $( @@ -202,6 +229,7 @@ impl_noop_handler!( EntityFlagsCommand, SuggestedCommand, ClientSuggestedCommand, + SummonCommand, ); #[test] @@ -408,6 +436,31 @@ fn position_args_use_client_parser_suggestions() { assert_eq!(spec.server_suggestions, None); } +#[test] +fn resource_args_generate_registry_parser_metadata() { + let paths = SummonCommand::paths(); + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + + assert_eq!(spec.parser, ParserKind::Resource); + assert_eq!( + spec.properties, + Some(ParserProperties::Resource( + temper_command_infra::ResourceProperties { + registry: "minecraft:entity_type", + } + )) + ); + assert_eq!(spec.protocol_suggestions, None); + assert_eq!(spec.server_suggestions, None); +} + #[test] fn client_suggestions_only_set_protocol_provider() { let paths = ClientSuggestedCommand::paths(); diff --git a/src/commands/src/arg/primitive/mod.rs b/src/commands/src/arg/primitive/mod.rs index eda1d656..b2f9c673 100644 --- a/src/commands/src/arg/primitive/mod.rs +++ b/src/commands/src/arg/primitive/mod.rs @@ -114,6 +114,7 @@ pub enum PrimitiveArgumentFlags { Long(LongArgumentFlags), String(StringArgumentType), Entity(EntityArgumentFlags), + Resource(String), } #[derive(Clone, Debug, PartialEq, Ordinalize)] diff --git a/src/default_commands/src/credits.rs b/src/default_commands/src/credits.rs index 5dabb6de..1a8abec7 100644 --- a/src/default_commands/src/credits.rs +++ b/src/default_commands/src/credits.rs @@ -6,8 +6,7 @@ use temper_nbt::NBT; use temper_net_runtime::connection::StreamWriter; use temper_protocol::outgoing::show_dialog::{DialogBody, DialogContent, ShowDialog}; use temper_text::TextComponent; - -pub(crate) static CREDITS_TEXT: &str = include_str!("../../../assets/data/credits.txt"); +// use crate::new::credits::CREDITS_TEXT; #[command("credits")] fn credits(#[sender] sender: Sender, query: Query<&StreamWriter>) { @@ -18,7 +17,7 @@ fn credits(#[sender] sender: Sender, query: Query<&StreamWriter>) { } Sender::Player(entity) => query.get(entity).expect("sender does not exist"), }; - let lines = CREDITS_TEXT + let lines = "" .lines() .map(|t| DialogBody { dialog_body_type: "minecraft:plain_message".to_string(), diff --git a/src/default_commands/src/lib.rs b/src/default_commands/src/lib.rs index c7278e8f..bc352a60 100644 --- a/src/default_commands/src/lib.rs +++ b/src/default_commands/src/lib.rs @@ -10,7 +10,6 @@ pub mod new; pub mod op; pub mod permissions; mod say; -pub mod spawn; mod stop; mod tp; pub mod tps; diff --git a/src/default_commands/src/new/bossbar.rs b/src/default_commands/src/new/bossbar.rs index fa5daf9d..1e27bd97 100644 --- a/src/default_commands/src/new/bossbar.rs +++ b/src/default_commands/src/new/bossbar.rs @@ -12,13 +12,11 @@ use temper_command_infra::{ use temper_components::entity_identity::Identity; use temper_components::player::bossbar_sender::BossbarSender; use temper_components::player::player_marker::PlayerMarker; -use temper_core::mq; use temper_macros::Command; use temper_resources::bossbar::{BossBarData, BossBarResource, BossbarColor, BossbarDividers}; use temper_text::ClickEvent::CopyToClipboard; use temper_text::HoverEvent::ShowText; use temper_text::{TextComponent, TextComponentBuilder}; -use tracing::info; use uuid::Uuid; #[derive(Command)] @@ -146,8 +144,7 @@ fn add_bossbar(source: CommandSource, bossbars: &mut BossBarResource, name: &str BossbarColor::Pink, )); - send_message( - source, + source.send_message( TextComponent::from(format!("Created bossbar with uuid: {uuid}")) .click_event(CopyToClipboard(uuid.to_string())) .hover_event(ShowText(TextComponent::from(uuid.to_string()).into())), @@ -156,8 +153,7 @@ fn add_bossbar(source: CommandSource, bossbars: &mut BossBarResource, name: &str fn get_bossbar(source: CommandSource, bossbars: &BossBarResource, uuid: Uuid) { if let Some(bossbar) = bossbars.boss_bars.get(&uuid) { - send_message( - source, + source.send_message( TextComponentBuilder::new("Bossbar: ") .extra(TextComponent::from(format!("{bossbar}"))) .build(), @@ -169,16 +165,12 @@ fn get_bossbar(source: CommandSource, bossbars: &BossBarResource, uuid: Uuid) { fn list_bossbars(source: CommandSource, bossbars: &BossBarResource) { if bossbars.boss_bars.is_empty() { - send_message( - source, - TextComponentBuilder::new("No bossbars exist.").build(), - ); + source.send_message(TextComponentBuilder::new("No bossbars exist.").build()); return; } for uuid in bossbars.boss_bars.keys() { - send_message( - source, + source.send_message( TextComponentBuilder::new("Bossbar: ") .extra( TextComponent::from(uuid.to_string()) @@ -193,7 +185,7 @@ fn list_bossbars(source: CommandSource, bossbars: &BossBarResource) { fn remove_bossbar(source: CommandSource, bossbars: &mut BossBarResource, uuid: Uuid) { if bossbars.boss_bars.contains_key(&uuid) { bossbars.remove_bar(uuid); - send_message(source, TextComponentBuilder::new("removed bossbar").build()); + source.send_message(TextComponentBuilder::new("removed bossbar").build()); } else { send_missing_bossbar(source, uuid); } @@ -218,12 +210,14 @@ fn set_bossbar( sender.update(uuid); } bossbars.update_style(uuid, color, dividers); + source.send_message(TextComponentBuilder::new("Updated bossbar color").build()); } BossbarSetOptionArg::Name(title) => { for (_, _, mut sender, _) in players.iter_mut() { sender.update(uuid); } bossbars.update_title(uuid, TextComponent::from(title)); + source.send_message(TextComponentBuilder::new("Updated bossbar name").build()); } BossbarSetOptionArg::Players(target) => { set_bossbar_players(bossbars, players, uuid, &target) @@ -233,6 +227,7 @@ fn set_bossbar( sender.update(uuid); } bossbars.update_style(uuid, color, dividers); + source.send_message(TextComponentBuilder::new("Updated bossbar style").build()); } BossbarSetOptionArg::Value(value) => { let max = bossbar.max; @@ -240,6 +235,7 @@ fn set_bossbar( sender.update(uuid); } bossbars.update_health(uuid, value, max); + source.send_message(TextComponentBuilder::new("Updated bossbar value").build()); } BossbarSetOptionArg::Max(max) => { let health = bossbar.health; @@ -247,6 +243,7 @@ fn set_bossbar( sender.update(uuid); } bossbars.update_health(uuid, health, max); + source.send_message(TextComponentBuilder::new("Updated bossbar max").build()); } } } @@ -428,21 +425,13 @@ fn player_suggestions(world: &mut World) -> Vec { } fn send_missing_bossbar(source: CommandSource, uuid: Uuid) { - send_message( - source, + source.send_message( TextComponentBuilder::new("Bossbar doesn't exist for uuid: ") .extra(TextComponent::from(uuid.to_string())) .build(), ); } -fn send_message(source: CommandSource, message: TextComponent) { - match source { - CommandSource::Player(entity) => mq::queue(message, false, entity), - CommandSource::Server => info!("{}", message.to_plain_text()), - } -} - #[cfg(test)] mod tests { use temper_command_infra::CommandSpec; diff --git a/src/default_commands/src/new/credits.rs b/src/default_commands/src/new/credits.rs index 4424a856..151663fe 100644 --- a/src/default_commands/src/new/credits.rs +++ b/src/default_commands/src/new/credits.rs @@ -7,6 +7,8 @@ use temper_net_runtime::connection::StreamWriter; use temper_protocol::outgoing::show_dialog::{DialogBody, DialogContent, ShowDialog}; use temper_text::TextComponent; + +pub(crate) static CREDITS_TEXT: &str = include_str!("../../../../assets/data/credits.txt"); #[derive(Command)] #[command("credits")] struct CreditsCommand; @@ -22,7 +24,7 @@ impl CommandHandler for CreditsCommand { } CommandSource::Player(entity) => params.get(entity).expect("sender does not exist"), }; - let lines = crate::credits::CREDITS_TEXT + let lines = CREDITS_TEXT .lines() .map(|t| DialogBody { dialog_body_type: "minecraft:plain_message".to_string(), diff --git a/src/default_commands/src/new/echo.rs b/src/default_commands/src/new/echo.rs index fd641481..9e59ade4 100644 --- a/src/default_commands/src/new/echo.rs +++ b/src/default_commands/src/new/echo.rs @@ -1,12 +1,10 @@ use bevy_ecs::prelude::Query; -use temper_command_infra::CommandSource::*; use temper_command_infra::args::GreedyStringArg; +use temper_command_infra::CommandSource::*; use temper_command_infra::{CommandHandler, CommandSource}; use temper_components::entity_identity::Identity; -use temper_core::mq; use temper_macros::Command; use temper_text::{TextComponent, TextComponentBuilder}; -use tracing::info; #[derive(Command)] #[command("echo")] @@ -33,9 +31,6 @@ impl CommandHandler for EchoCommand { .extra(TextComponent::from(self.message.to_string())) .build(); - match source { - Player(entity) => mq::queue(message, false, entity), - Server => info!("{}", message.to_plain_text()), - } + source.send_message(message); } } diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs index 9b2b9d68..a7cb9223 100644 --- a/src/default_commands/src/new/mod.rs +++ b/src/default_commands/src/new/mod.rs @@ -1,9 +1,9 @@ mod bossbar; +mod credits; mod echo; mod gamemode; +mod kill; mod stop; +mod summon; mod time; mod tp; -mod credits; -mod kill; -mod summon; diff --git a/src/default_commands/src/new/summon.rs b/src/default_commands/src/new/summon.rs index 99e84a2c..21895a30 100644 --- a/src/default_commands/src/new/summon.rs +++ b/src/default_commands/src/new/summon.rs @@ -1,2 +1,217 @@ +use bevy_ecs::message::MessageWriter; +use bevy_ecs::prelude::Query; +use temper_command_infra::args::PositionArg; +use temper_command_infra::{ + ArgumentSpec, CommandArg, CommandHandler, CommandReader, CommandSource, ParseError, + SuggestionProviderKind, +}; +use temper_components::player::position::Position; +use temper_components::player::rotation::Rotation; +use temper_entities::entity_types::EntityTypeEnum; +use temper_macros::Command; +use temper_messages::SpawnMobCommand; + +#[derive(Debug, Command)] +#[command(name = "summon", aliases = ["spawn"])] enum SummonCommand { -} \ No newline at end of file + AtSelf { + mob_type: EntityTypeArg, + }, + AtPos { + mob_type: EntityTypeArg, + pos: PositionArg, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct EntityTypeArg { + kind: EntityTypeEnum, + name: String, +} + +impl CommandArg for EntityTypeArg { + type Raw<'a> = (&'a str, EntityTypeEnum); + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let cursor = reader.cursor(); + let raw = reader.read_word_span()?; + let Some(kind) = parse_entity_type(raw) else { + return Err(ParseError::new( + cursor, + "entity type", + format!("unknown entity type: {raw}"), + )); + }; + + Ok((raw, kind)) + } + + fn parse((raw, kind): Self::Raw<'_>) -> Result { + Ok(Self { + kind, + name: entity_type_path(raw).unwrap_or(raw).to_string(), + }) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::resource("minecraft:entity_type") + } +} + +impl CommandHandler for SummonCommand { + type SystemParam<'w, 's> = ( + MessageWriter<'w, SpawnMobCommand>, + Query<'w, 's, (&'static Position, &'static Rotation)>, + ); + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let (writer, query) = params; + let CommandSource::Player(player_entity) = source else { + source.send_message("Only players can use this command.".into()); + return; + }; + + let (entity_kind, entity_name, spawn_pos) = match self { + SummonCommand::AtSelf { mob_type } => { + let (pos, rot) = query + .get(player_entity) + .expect("player entity does not exist"); + (mob_type.kind, mob_type.name, pos.offset_forward(rot, 2.0)) + } + SummonCommand::AtPos { mob_type, pos } => ( + mob_type.kind, + mob_type.name, + pos.resolve( + query + .get(player_entity) + .expect("player entity does not exist") + .0, + ), + ), + }; + + writer.write(SpawnMobCommand { + entity_type: entity_kind, + location: spawn_pos, + }); + source.send_message(format!("{} spawned!", entity_name).into()); + } +} + +fn parse_entity_type(raw: &str) -> Option { + EntityTypeEnum::from_snake_case(entity_type_path(raw)?) +} + +fn entity_type_path(raw: &str) -> Option<&str> { + let Some((namespace, path)) = raw.split_once(':') else { + return Some(raw); + }; + + (namespace == "minecraft").then_some(path) +} + +#[cfg(test)] +mod tests { + use temper_command_infra::{ + CommandPathSegment, CommandSpec, ParserKind, ParserProperties, ResourceProperties, + }; + use temper_entities::entity_types::EntityTypeEnum; + + use super::SummonCommand; + + #[test] + fn summon_parses_entity_type_names() { + let command = SummonCommand::parse("pig").unwrap(); + + match command { + SummonCommand::AtSelf { mob_type } => { + assert_eq!(mob_type.kind, EntityTypeEnum::Pig); + assert_eq!(mob_type.name, "pig"); + } + SummonCommand::AtPos { .. } => panic!("expected summon at self"), + } + } + + #[test] + fn summon_parses_namespaced_entity_type_names() { + let command = SummonCommand::parse("minecraft:zombie").unwrap(); + + match command { + SummonCommand::AtSelf { mob_type } => { + assert_eq!(mob_type.kind, EntityTypeEnum::Zombie); + assert_eq!(mob_type.name, "zombie"); + } + SummonCommand::AtPos { .. } => panic!("expected summon at self"), + } + } + + #[test] + fn summon_parses_entity_type_with_position() { + let command = SummonCommand::parse("minecraft:pig 1 ~ 3").unwrap(); + + match command { + SummonCommand::AtPos { mob_type, pos } => { + assert_eq!(mob_type.kind, EntityTypeEnum::Pig); + assert_eq!(mob_type.name, "pig"); + assert_eq!(pos.x, "1"); + assert_eq!(pos.y, "~"); + assert_eq!(pos.z, "3"); + } + SummonCommand::AtSelf { .. } => panic!("expected summon at position"), + } + } + + #[test] + fn summon_rejects_unknown_namespaces() { + let err = SummonCommand::parse("temper:pig").unwrap_err(); + + assert_eq!(err.expected, "entity type"); + } + + #[test] + fn summon_uses_spawn_alias() { + assert_eq!(SummonCommand::aliases(), &["spawn"]); + } + + #[test] + fn summon_uses_entity_type_resource_parser_and_position_parser() { + let paths = SummonCommand::paths(); + let entity_spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + + assert_eq!(entity_spec.parser, ParserKind::Resource); + assert_eq!( + entity_spec.properties, + Some(ParserProperties::Resource(ResourceProperties { + registry: "minecraft:entity_type", + })) + ); + assert_eq!(entity_spec.protocol_suggestions, None); + assert_eq!(entity_spec.server_suggestions, None); + + assert!(paths.iter().any(|path| matches!( + path.segments.as_slice(), + [ + CommandPathSegment::Argument { + name: "mob_type", + spec: entity_spec, + .. + }, + CommandPathSegment::Argument { + name: "pos", + spec: position_spec, + .. + } + ] if entity_spec.parser == ParserKind::Resource + && position_spec.parser == ParserKind::Position + ))); + } +} diff --git a/src/default_commands/src/new/time.rs b/src/default_commands/src/new/time.rs index 1eebfcb1..4894e112 100644 --- a/src/default_commands/src/new/time.rs +++ b/src/default_commands/src/new/time.rs @@ -5,11 +5,8 @@ use temper_command_infra::{ ParserProperties, StringMode, SuggestionProviderKind, }; use temper_components::player::time::LastSentTimeUpdate; -use temper_core::mq; use temper_macros::Command; use temper_resources::time::WorldTime; -use temper_text::TextComponent; -use tracing::info; #[derive(Command)] #[command("time")] @@ -80,22 +77,24 @@ impl CommandHandler for TimeCommand { match self { Self::Set(command) => { let ticks = command.ticks(); - set_time(source, world_time, last_sent_time, ticks); + world_time.set_time(ticks); + + source.send_message( + format!("Set the world time to {} ticks", world_time.current_time()).into(), + ); + send_time_next_tick(last_sent_time); } Self::Add { time } => { let ticks = *time as u16; let new_time = world_time.current_time() + ticks; world_time.set_time(new_time); - send_message( - source, - format!("Advanced the world time by {} ticks", *time).into(), - ); + + source.send_message(format!("Advanced the world time by {} ticks", *time).into()); send_time_next_tick(last_sent_time); } Self::Query => { - send_message( - source, + source.send_message( format!("The current world time is: {}", world_time.current_time()).into(), ); } @@ -135,34 +134,12 @@ fn parse_time_number(raw: &str) -> Result { raw.parse::().map_err(|_| "invalid time value") } -fn set_time( - source: CommandSource, - world_time: &mut WorldTime, - last_sent_time: &mut Query<&mut LastSentTimeUpdate>, - ticks: u16, -) { - world_time.set_time(ticks); - - send_message( - source, - format!("Set the world time to {} ticks", world_time.current_time()).into(), - ); - send_time_next_tick(last_sent_time); -} - fn send_time_next_tick(last_sent_time: &mut Query<&mut LastSentTimeUpdate>) { for mut last_sent in last_sent_time.iter_mut() { last_sent.send_next_tick(); } } -fn send_message(source: CommandSource, message: TextComponent) { - match source { - CommandSource::Player(entity) => mq::queue(message, false, entity), - CommandSource::Server => info!("{}", message.to_plain_text()), - } -} - #[cfg(test)] mod tests { use temper_command_infra::CommandSpec; diff --git a/src/default_commands/src/spawn.rs b/src/default_commands/src/spawn.rs deleted file mode 100644 index 65bb97ca..00000000 --- a/src/default_commands/src/spawn.rs +++ /dev/null @@ -1,174 +0,0 @@ -use bevy_ecs::prelude::MessageWriter; -use bimap::BiMap; -use lazy_static::lazy_static; -use temper_commands::{ - CommandContext, Sender, Suggestion, - arg::{CommandArgument, ParserResult, primitive::PrimitiveArgument, utils::parser_error}, -}; -use temper_entities::entity_types::EntityTypeEnum; -use temper_macros::command; -use temper_messages::SpawnMobCommand; -use temper_text::TextComponent; - -/// Wrapper type for EntityType that implements CommandArgument -#[derive(Debug, Clone, Copy)] -struct EntityTypeArg(EntityTypeEnum); - -lazy_static! { - static ref MAPPED_ENTITIES: BiMap<&'static str, EntityTypeEnum> = { - let mut m = BiMap::new(); - - // Add supported entities here - m.insert("allay", EntityTypeEnum::Allay); - m.insert("armadillo", EntityTypeEnum::Armadillo); - m.insert("axolotl", EntityTypeEnum::Axolotl); - m.insert("bat", EntityTypeEnum::Bat); - m.insert("bee", EntityTypeEnum::Bee); - m.insert("camel", EntityTypeEnum::Camel); - m.insert("cat", EntityTypeEnum::Cat); - m.insert("cave_spider", EntityTypeEnum::CaveSpider); - m.insert("chicken", EntityTypeEnum::Chicken); - m.insert("cod", EntityTypeEnum::Cod); - m.insert("cow", EntityTypeEnum::Cow); - m.insert("dolphin", EntityTypeEnum::Dolphin); - m.insert("donkey", EntityTypeEnum::Donkey); - m.insert("drowned", EntityTypeEnum::Drowned); - m.insert("enderman", EntityTypeEnum::Enderman); - m.insert("fox", EntityTypeEnum::Fox); - m.insert("frog", EntityTypeEnum::Frog); - m.insert("goat", EntityTypeEnum::Goat); - m.insert("horse", EntityTypeEnum::Horse); - m.insert("iron_golem", EntityTypeEnum::IronGolem); - m.insert("llama", EntityTypeEnum::Llama); - m.insert("mooshroom", EntityTypeEnum::Mooshroom); - m.insert("ocelot", EntityTypeEnum::Ocelot); - m.insert("panda", EntityTypeEnum::Panda); - m.insert("parrot", EntityTypeEnum::Parrot); - m.insert("pig", EntityTypeEnum::Pig); - m.insert("piglin", EntityTypeEnum::Piglin); - m.insert("polar_bear", EntityTypeEnum::PolarBear); - m.insert("pufferfish", EntityTypeEnum::Pufferfish); - m.insert("rabbit", EntityTypeEnum::Rabbit); - m.insert("salmon", EntityTypeEnum::Salmon); - m.insert("sheep", EntityTypeEnum::Sheep); - m.insert("skeleton_horse", EntityTypeEnum::SkeletonHorse); - m.insert("sniffer", EntityTypeEnum::Sniffer); - m.insert("snow_golem", EntityTypeEnum::SnowGolem); - m.insert("spider", EntityTypeEnum::Spider); - m.insert("squid", EntityTypeEnum::Squid); - m.insert("strider", EntityTypeEnum::Strider); - m.insert("tadpole", EntityTypeEnum::Tadpole); - m.insert("trader_llama", EntityTypeEnum::TraderLlama); - m.insert("tropical_fish", EntityTypeEnum::TropicalFish); - m.insert("turtle", EntityTypeEnum::Turtle); - m.insert("villager", EntityTypeEnum::Villager); - m.insert("wandering_trader", EntityTypeEnum::WanderingTrader); - m.insert("wolf", EntityTypeEnum::Wolf); - m.insert("zombie_horse", EntityTypeEnum::ZombieHorse); - m.insert("zombified_piglin", EntityTypeEnum::ZombifiedPiglin); - m.insert("glow_squid", EntityTypeEnum::GlowSquid); - m.insert("mule", EntityTypeEnum::Mule); - - // Hostile entities - m.insert("blaze", EntityTypeEnum::Blaze); - m.insert("bogged", EntityTypeEnum::Bogged); - m.insert("breeze", EntityTypeEnum::Breeze); - m.insert("creaking", EntityTypeEnum::Creaking); - m.insert("creeper", EntityTypeEnum::Creeper); - m.insert("elder_guardian", EntityTypeEnum::ElderGuardian); - m.insert("endermite", EntityTypeEnum::Endermite); - m.insert("evoker", EntityTypeEnum::Evoker); - m.insert("ghast", EntityTypeEnum::Ghast); - m.insert("guardian", EntityTypeEnum::Guardian); - m.insert("hoglin", EntityTypeEnum::Hoglin); - m.insert("husk", EntityTypeEnum::Husk); - m.insert("magma_cube", EntityTypeEnum::MagmaCube); - m.insert("phantom", EntityTypeEnum::Phantom); - m.insert("piglin_brute", EntityTypeEnum::PiglinBrute); - m.insert("pillager", EntityTypeEnum::Pillager); - m.insert("ravager", EntityTypeEnum::Ravager); - m.insert("shulker", EntityTypeEnum::Shulker); - m.insert("silverfish", EntityTypeEnum::Silverfish); - m.insert("skeleton", EntityTypeEnum::Skeleton); - m.insert("slime", EntityTypeEnum::Slime); - m.insert("stray", EntityTypeEnum::Stray); - m.insert("vex", EntityTypeEnum::Vex); - m.insert("vindicator", EntityTypeEnum::Vindicator); - m.insert("warden", EntityTypeEnum::Warden); - m.insert("witch", EntityTypeEnum::Witch); - m.insert("wither_skeleton", EntityTypeEnum::WitherSkeleton); - m.insert("zoglin", EntityTypeEnum::Zoglin); - m.insert("zombie", EntityTypeEnum::Zombie); - m.insert("zombie_villager", EntityTypeEnum::ZombieVillager); - - m - }; -} - -impl CommandArgument for EntityTypeArg { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - let value = match MAPPED_ENTITIES.get_by_left(str.as_str()) { - Some(&entity_type) => entity_type, - None => { - return Err(parser_error( - format!("Unknown entity type: {}", str).as_str(), - )); - } - }; - - Ok(EntityTypeArg(value)) - } - - fn primitive() -> PrimitiveArgument { - // We're parsing a single word - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - - MAPPED_ENTITIES - .iter() - .map(|(&name, _)| Suggestion::of(name)) - .collect() - } -} - -/// Spawns an entity in front of the player. -/// -/// Usage: /spawn -/// Supported: allay, armadillo, axolotl, bat, bee, camel, cat, chicken, cod, cow, dolphin, donkey, fox, frog, goat, horse, llama, mooshroom, ocelot, panda, parrot, pig -#[command("spawn")] -fn spawn_command( - #[sender] sender: Sender, - #[arg] entity_type: EntityTypeArg, - mut spawn_commands: MessageWriter, -) { - match sender { - Sender::Player(entity) => { - // Write spawn command message - will be processed by spawn_command_processor system - spawn_commands.write(SpawnMobCommand { - entity_type: entity_type.0, - player_entity: entity, - }); - - // Get entity name for message - let entity_name = MAPPED_ENTITIES - .get_by_right(&entity_type.0) - .unwrap_or(&"unknown"); - - sender.send_message( - TextComponent::from(format!("{} spawned!", entity_name)), - false, - ); - } - Sender::Server => { - sender.send_message( - TextComponent::from("Only players can use this command"), - false, - ); - } - } -} diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs index 5fedb21c..3206968b 100644 --- a/src/default_commands/tests/new_command_registry.rs +++ b/src/default_commands/tests/new_command_registry.rs @@ -1,7 +1,7 @@ use bevy_ecs::prelude::World; use temper_command_infra::{ CommandGraph, CommandNodeKind, CommandPathSegment, CommandRegistry, ParserKind, - SuggestionInput, suggest_command_arg, + ParserProperties, ResourceProperties, SuggestionInput, suggest_command_arg, }; use temper_commands::arg::primitive::PrimitiveArgumentType; use temper_protocol::outgoing::commands::CommandsPacket; @@ -19,6 +19,33 @@ fn default_commands_register_new_metadata() { assert!(paths.iter().any(|path| path.root == "time")); assert!(paths.iter().any(|path| path.root == "bossbar")); assert!(paths.iter().any(|path| path.root == "gamemode")); + assert!(paths.iter().any(|path| path.root == "summon")); + assert!(paths.iter().any(|path| path.root == "spawn")); + + let summon = registry + .commands() + .iter() + .find(|command| command.name == "summon") + .unwrap(); + assert!(summon.aliases.contains(&"spawn")); + assert!(summon.matches_root("spawn")); + + let summon_entity_spec = paths + .iter() + .filter(|path| path.root == "summon") + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + assert_eq!(summon_entity_spec.parser, ParserKind::Resource); + assert_eq!( + summon_entity_spec.properties, + Some(ParserProperties::Resource(ResourceProperties { + registry: "minecraft:entity_type", + })) + ); let stop = paths.iter().find(|path| path.root == "stop").unwrap(); let echo = paths.iter().find(|path| path.root == "echo").unwrap(); diff --git a/src/game_systems/src/packets/src/command_suggestions.rs b/src/game_systems/src/packets/src/command_suggestions.rs index 5e5c9133..b255736c 100644 --- a/src/game_systems/src/packets/src/command_suggestions.rs +++ b/src/game_systems/src/packets/src/command_suggestions.rs @@ -375,7 +375,9 @@ fn segment_accepts_token(segment: &CommandPathSegment, token: &str) -> bool { CommandPathSegment::Argument { spec, .. } => match spec.parser { ParserKind::Integer => token.parse::().is_ok(), ParserKind::Position => is_coordinate_token(token), - ParserKind::Word | ParserKind::String | ParserKind::Entity => !token.is_empty(), + ParserKind::Word | ParserKind::String | ParserKind::Entity | ParserKind::Resource => { + !token.is_empty() + } }, } } diff --git a/src/game_systems/src/player/src/entity_spawn.rs b/src/game_systems/src/player/src/entity_spawn.rs index 2b461785..9bdbe88c 100644 --- a/src/game_systems/src/player/src/entity_spawn.rs +++ b/src/game_systems/src/player/src/entity_spawn.rs @@ -1,28 +1,15 @@ use bevy_ecs::prelude::*; -use temper_components::player::position::Position; -use temper_components::player::rotation::Rotation; use temper_entities::MobBundle; use temper_messages::{SpawnMobBundle, SpawnMobCommand}; -use tracing::warn; /// Processes `/spawn` command messages by turning them into mob bundle spawns. pub fn spawn_command_processor( mut spawn_commands: MessageReader, - query: Query<(&Position, &Rotation)>, mut mob_bundle_events: MessageWriter, ) { for command in spawn_commands.read() { - let Ok((pos, rot)) = query.get(command.player_entity) else { - warn!( - "Failed to get position for entity {:?}", - command.player_entity - ); - continue; - }; - - let spawn_pos = pos.offset_forward(rot, 2.0); mob_bundle_events.write(SpawnMobBundle { - bundle: MobBundle::new(command.entity_type, spawn_pos), + bundle: MobBundle::new(command.entity_type, command.location), persist: true, }); } diff --git a/src/game_systems/tests/mobs/entity_persistence.rs b/src/game_systems/tests/mobs/entity_persistence.rs index 468087a8..c25bc488 100644 --- a/src/game_systems/tests/mobs/entity_persistence.rs +++ b/src/game_systems/tests/mobs/entity_persistence.rs @@ -39,13 +39,13 @@ fn emit_load_for( } fn emit_spawn_command( - player_entity: Entity, + location: Position, entity_type: EntityTypeEnum, ) -> impl FnMut(MessageWriter) { move |mut writer: MessageWriter| { writer.write(SpawnMobCommand { entity_type, - player_entity, + location, }); } } @@ -306,14 +306,10 @@ fn spawn_command_cow_survives_registered_shutdown_reload() { let expected_identity = { let mut first_world = ecs_world_with_sync(state.clone()); - let player = first_world - .spawn((player_position, Rotation::new(0.0, 0.0))) - .id(); - let mut spawn_schedule = Schedule::default(); spawn_schedule.add_systems( ( - emit_spawn_command(player, EntityTypeEnum::Cow), + emit_spawn_command(expected_position, EntityTypeEnum::Cow), player::entity_spawn::spawn_command_processor, handle_spawn_mob_bundle, ) diff --git a/src/messages/src/entity_spawn.rs b/src/messages/src/entity_spawn.rs index 51644be4..71a77c8d 100644 --- a/src/messages/src/entity_spawn.rs +++ b/src/messages/src/entity_spawn.rs @@ -1,4 +1,5 @@ use bevy_ecs::prelude::{Entity, Message}; +use temper_components::player::position::Position; pub(crate) use temper_entities::entity_types::EntityTypeEnum; use temper_entities::MobBundle; @@ -9,7 +10,7 @@ use temper_entities::MobBundle; #[derive(Message)] pub struct SpawnMobCommand { pub entity_type: EntityTypeEnum, - pub player_entity: Entity, + pub location: Position, } #[derive(Message)] diff --git a/src/net/protocol/src/outgoing/commands.rs b/src/net/protocol/src/outgoing/commands.rs index db76917f..1b290575 100644 --- a/src/net/protocol/src/outgoing/commands.rs +++ b/src/net/protocol/src/outgoing/commands.rs @@ -4,7 +4,7 @@ use temper_codec::net_types::{length_prefixed_vec::LengthPrefixedVec, var_int::V use temper_command_infra::{ ArgumentSpec, CommandGraph as InfraCommandGraph, CommandNode as InfraCommandNode, CommandNodeKind as InfraCommandNodeKind, EntityProperties, IntegerProperties, ParserKind, - ParserProperties, StringMode, + ParserProperties, ResourceProperties, StringMode, }; use temper_commands::{ arg::primitive::{ @@ -133,6 +133,7 @@ fn parser_id(argument: ArgumentSpec) -> PrimitiveArgumentType { ParserKind::Integer => PrimitiveArgumentType::Int, ParserKind::Position => PrimitiveArgumentType::Vec3, ParserKind::Entity => PrimitiveArgumentType::Entity, + ParserKind::Resource => PrimitiveArgumentType::Resource, } } @@ -151,6 +152,9 @@ fn parser_properties(argument: ArgumentSpec) -> Option { single, players_only, })), + Some(ParserProperties::Resource(ResourceProperties { registry })) => { + Some(PrimitiveArgumentFlags::Resource(registry.to_string())) + } None if argument.parser == ParserKind::Word => { Some(PrimitiveArgumentFlags::String(StringArgumentType::Word)) } @@ -172,7 +176,9 @@ fn string_mode(mode: StringMode) -> StringArgumentType { #[cfg(test)] mod tests { use temper_command_infra::{ArgumentSpec, CommandGraph, CommandPath, CommandPathSegment}; - use temper_commands::arg::primitive::{EntityArgumentFlags, PrimitiveArgumentFlags}; + use temper_commands::arg::primitive::{ + EntityArgumentFlags, PrimitiveArgumentFlags, PrimitiveArgumentType, + }; use super::CommandsPacket; @@ -224,4 +230,29 @@ mod tests { })) )); } + + #[test] + fn converts_resource_args_to_protocol_resource_parser() { + let graph = CommandGraph::from_paths(&[CommandPath::new( + "summon", + vec![CommandPathSegment::argument( + "entity", + ArgumentSpec::resource("minecraft:entity_type"), + )], + )]); + + let packet = CommandsPacket::from_command_infra_graph(&graph); + + assert_eq!( + packet.graph.data[2].parser_id, + Some(PrimitiveArgumentType::Resource) + ); + assert_eq!( + packet.graph.data[2].properties, + Some(PrimitiveArgumentFlags::Resource( + "minecraft:entity_type".to_string() + )) + ); + assert_eq!(packet.graph.data[2].suggestions_type, None); + } } From 5fd66895ccd4af5a5b1eb45c0d9794b3eb20a408 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 21:43:50 +0930 Subject: [PATCH 19/30] TUI commands --- src/game_systems/src/background/Cargo.toml | 2 + .../src/background/src/server_command.rs | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/game_systems/src/background/Cargo.toml b/src/game_systems/src/background/Cargo.toml index 597b282a..3ca69b12 100644 --- a/src/game_systems/src/background/Cargo.toml +++ b/src/game_systems/src/background/Cargo.toml @@ -18,6 +18,7 @@ temper-text = { workspace = true } temper-messages = { workspace = true } temper-inventories = { workspace = true } temper-resources = { workspace = true } +temper-command-infra = { workspace = true } rand = { workspace = true } temper-entities = { workspace = true } temper-commands = { workspace = true } @@ -30,6 +31,7 @@ tokio = { workspace = true } [dev-dependencies] temper-encryption = { workspace = true } +crossbeam-channel = { workspace = true } [lints] workspace = true diff --git a/src/game_systems/src/background/src/server_command.rs b/src/game_systems/src/background/src/server_command.rs index 6fafe761..02849d86 100644 --- a/src/game_systems/src/background/src/server_command.rs +++ b/src/game_systems/src/background/src/server_command.rs @@ -1,5 +1,7 @@ use bevy_ecs::change_detection::Res; use bevy_ecs::message::MessageWriter; +use std::sync::Arc; +use temper_command_infra::{CommandRegistry, CommandSource, NewCommandDispatched}; use temper_commands::Sender; use temper_commands::messages::{CommandDispatched, ResolvedCommandDispatched}; use temper_commands::resolve::resolve; @@ -9,11 +11,21 @@ use tracing::error; pub fn handle( receiver: Res, + registry: Res, + mut new_dispatch_msgs: MessageWriter, mut dispatch_msgs: MessageWriter, mut resolved_dispatch_msgs: MessageWriter, state: Res, ) { for command in receiver.0.try_iter() { + if registry.owns_input(&command) { + new_dispatch_msgs.write(NewCommandDispatched { + input: Arc::from(command), + source: CommandSource::Server, + }); + continue; + } + let sender = Sender::Server; dispatch_msgs.write(CommandDispatched { command: command.clone(), @@ -36,3 +48,63 @@ pub fn handle( } } } + +#[cfg(test)] +mod tests { + use bevy_ecs::message::MessageRegistry; + use bevy_ecs::prelude::{Messages, Schedule, World}; + use std::sync::Arc; + use temper_command_infra::{ + CommandPath, CommandRegistry, CommandSource, NewCommandDispatched, RegisteredCommand, + }; + use temper_commands::messages::{CommandDispatched, ResolvedCommandDispatched}; + use temper_resources::server_command_rx::ServerCommandReceiver; + use temper_state::create_test_state; + + use super::handle; + + #[test] + fn tui_server_commands_owned_by_new_registry_emit_new_dispatch_messages() { + let (sender, receiver) = crossbeam_channel::unbounded(); + sender.send("stop".to_string()).unwrap(); + + let (state, _temp_dir) = create_test_state(); + let mut world = World::new(); + MessageRegistry::register_message::(&mut world); + MessageRegistry::register_message::(&mut world); + MessageRegistry::register_message::(&mut world); + world.insert_resource(ServerCommandReceiver(receiver)); + world.insert_resource(state); + world.insert_resource(new_command_registry()); + + let mut schedule = Schedule::default(); + schedule.add_systems(handle); + schedule.run(&mut world); + + let new_message_resource = world.resource::>(); + let mut new_cursor = new_message_resource.get_cursor(); + let new_messages = new_cursor + .read(new_message_resource) + .cloned() + .collect::>(); + let old_message_resource = world.resource::>(); + let mut old_cursor = old_message_resource.get_cursor(); + let old_messages = old_cursor.read(old_message_resource).collect::>(); + + assert_eq!(new_messages.len(), 1); + assert_eq!(new_messages[0].input, Arc::from("stop")); + assert_eq!(new_messages[0].source, CommandSource::Server); + assert!(old_messages.is_empty()); + } + + fn new_command_registry() -> CommandRegistry { + let mut registry = CommandRegistry::default(); + registry.register_command(RegisteredCommand { + name: "stop", + aliases: &[], + permission: None, + paths: vec![CommandPath::new("stop", Vec::new())], + }); + registry + } +} From ca23eb9294ca48585506741817712c6066d6d2ec Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 21:46:18 +0930 Subject: [PATCH 20/30] Added /stop alias --- src/default_commands/src/new/stop.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/default_commands/src/new/stop.rs b/src/default_commands/src/new/stop.rs index fc81076b..4f9fb623 100644 --- a/src/default_commands/src/new/stop.rs +++ b/src/default_commands/src/new/stop.rs @@ -9,7 +9,7 @@ use temper_state::GlobalStateResource; use tracing::info; #[derive(Command)] -#[command("stop")] +#[command(name = "stop", aliases = ["quit"])] struct StopCommand; impl CommandHandler for StopCommand { From 5bd8c3cfa7e1abc9302911446215ca395a68d025 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 21:54:10 +0930 Subject: [PATCH 21/30] op command --- src/default_commands/src/new/mod.rs | 1 + src/default_commands/src/new/op.rs | 132 ++++++++++++++++++ .../tests/new_command_registry.rs | 23 ++- 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/default_commands/src/new/op.rs diff --git a/src/default_commands/src/new/mod.rs b/src/default_commands/src/new/mod.rs index a7cb9223..b82339ed 100644 --- a/src/default_commands/src/new/mod.rs +++ b/src/default_commands/src/new/mod.rs @@ -3,6 +3,7 @@ mod credits; mod echo; mod gamemode; mod kill; +mod op; mod stop; mod summon; mod time; diff --git a/src/default_commands/src/new/op.rs b/src/default_commands/src/new/op.rs new file mode 100644 index 00000000..5c9c37ee --- /dev/null +++ b/src/default_commands/src/new/op.rs @@ -0,0 +1,132 @@ +use bevy_ecs::prelude::{Entity, Query}; +use temper_command_infra::CommandSource::Player; +use temper_command_infra::args::EntitiesArg; +use temper_command_infra::{CommandHandler, CommandSource}; +use temper_components::entity_identity::Identity; +use temper_components::player::player_marker::PlayerMarker; +use temper_macros::Command; +use temper_permissions::Access::Allow; +use temper_permissions::Permissions::{ALL, Op}; +use temper_permissions::player::PlayerPermission; +use temper_text::TextComponent; + +#[derive(Debug, Command)] +#[command("op")] +struct OpCommand { + target: EntitiesArg, +} + +impl CommandHandler for OpCommand { + type SystemParam<'w, 's> = ( + Query<'w, 's, (Entity, &'static Identity, Option<&'static PlayerMarker>)>, + Query<'w, 's, &'static mut PlayerPermission>, + ); + + fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) { + let (entities, permissions) = params; + + let is_permitted = match source { + Player(entity) => permissions + .get(entity) + .is_ok_and(|player_perm| player_perm.can(Op)), + CommandSource::Server => true, + }; + + if !is_permitted { + source.send_message("You don't have permission to use this command.".into()); + return; + } + + for entity in self.target.resolve(entities.iter()) { + if let Ok(mut player_permission) = permissions.get_mut(entity) { + player_permission.set_permission(ALL, Allow); + source.send_message(TextComponent::from("You have been opped".to_string())); + } + } + } +} + +#[cfg(test)] +mod tests { + use bevy_ecs::system::SystemState; + use temper_command_infra::{CommandHandler, CommandSource, CommandSpec}; + use temper_components::entity_identity::Identity; + use temper_components::player::player_marker::PlayerMarker; + use temper_permissions::Access::Deny; + use temper_permissions::Permissions::{ALL, Op}; + use temper_permissions::player::PlayerPermission; + + use super::OpCommand; + + #[test] + fn op_parses_target_arg() { + let command = OpCommand::parse("Steve").unwrap(); + + assert_eq!(&*command.target, "Steve"); + } + + #[test] + fn op_grants_all_permission_to_resolved_target() { + let mut world = bevy_ecs::prelude::World::new(); + let target = world + .spawn((identity("Steve"), PlayerMarker, PlayerPermission::new())) + .id(); + let command = OpCommand::parse("Steve").unwrap(); + + let mut params = + SystemState::<::SystemParam<'_, '_>>::new(&mut world); + let mut system_params = params.get_mut(&mut world); + command.handle(CommandSource::Server, &mut system_params); + drop(system_params); + params.apply(&mut world); + + assert!( + world + .get::(target) + .unwrap() + .can(temper_permissions::Permissions::Kill) + ); + } + + #[test] + fn op_requires_op_permission_for_player_senders() { + let mut world = bevy_ecs::prelude::World::new(); + let sender = world + .spawn(( + identity("Sender"), + PlayerMarker, + player_permission(Op, Deny), + )) + .id(); + let target = world + .spawn((identity("Steve"), PlayerMarker, PlayerPermission::new())) + .id(); + let command = OpCommand::parse("Steve").unwrap(); + + let mut params = + SystemState::<::SystemParam<'_, '_>>::new(&mut world); + let mut system_params = params.get_mut(&mut world); + command.handle(CommandSource::Player(sender), &mut system_params); + drop(system_params); + params.apply(&mut world); + + assert!(!world.get::(target).unwrap().can(ALL)); + } + + fn identity(name: &str) -> Identity { + Identity { + entity_id: 0, + uuid: uuid::Uuid::new_v4(), + name: Some(name.to_string()), + } + } + + fn player_permission( + permission: temper_permissions::Permissions, + access: temper_permissions::Access, + ) -> PlayerPermission { + let mut player_permission = PlayerPermission::new(); + player_permission.set_permission(permission, access); + player_permission + } +} diff --git a/src/default_commands/tests/new_command_registry.rs b/src/default_commands/tests/new_command_registry.rs index 3206968b..5ab29a0c 100644 --- a/src/default_commands/tests/new_command_registry.rs +++ b/src/default_commands/tests/new_command_registry.rs @@ -1,7 +1,7 @@ use bevy_ecs::prelude::World; use temper_command_infra::{ - CommandGraph, CommandNodeKind, CommandPathSegment, CommandRegistry, ParserKind, - ParserProperties, ResourceProperties, SuggestionInput, suggest_command_arg, + CommandGraph, CommandNodeKind, CommandPathSegment, CommandRegistry, EntityProperties, + ParserKind, ParserProperties, ResourceProperties, SuggestionInput, suggest_command_arg, }; use temper_commands::arg::primitive::PrimitiveArgumentType; use temper_protocol::outgoing::commands::CommandsPacket; @@ -19,6 +19,7 @@ fn default_commands_register_new_metadata() { assert!(paths.iter().any(|path| path.root == "time")); assert!(paths.iter().any(|path| path.root == "bossbar")); assert!(paths.iter().any(|path| path.root == "gamemode")); + assert!(paths.iter().any(|path| path.root == "op")); assert!(paths.iter().any(|path| path.root == "summon")); assert!(paths.iter().any(|path| path.root == "spawn")); @@ -47,6 +48,24 @@ fn default_commands_register_new_metadata() { })) ); + let op_target_spec = paths + .iter() + .filter(|path| path.root == "op") + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + assert_eq!(op_target_spec.parser, ParserKind::Entity); + assert_eq!( + op_target_spec.properties, + Some(ParserProperties::Entity(EntityProperties { + single: false, + players_only: false, + })) + ); + let stop = paths.iter().find(|path| path.root == "stop").unwrap(); let echo = paths.iter().find(|path| path.root == "echo").unwrap(); From d5acfd81fe6c9104262b6ac6633fdd6036623d66 Mon Sep 17 00:00:00 2001 From: ReCore Date: Sun, 28 Jun 2026 22:10:42 +0930 Subject: [PATCH 22/30] Removing the old system --- Cargo.toml | 2 - src/base/macros/src/commands/mod.rs | 259 ------- src/base/macros/src/lib.rs | 26 - src/command-infra/src/ecs.rs | 17 +- src/command-infra/src/lib.rs | 18 +- src/command-infra/tests/ecs_registry.rs | 12 +- src/commands/Cargo.toml | 27 - src/commands/src/arg/bossbar_set.rs | 90 --- src/commands/src/arg/duration.rs | 65 -- src/commands/src/arg/entities/any_entity.rs | 9 - src/commands/src/arg/entities/any_player.rs | 16 - src/commands/src/arg/entities/entity_uuid.rs | 16 - src/commands/src/arg/entities/mod.rs | 430 ----------- src/commands/src/arg/entities/player.rs | 21 - .../src/arg/entities/random_player.rs | 18 - src/commands/src/arg/gamemode.rs | 38 - src/commands/src/arg/mod.rs | 108 --- src/commands/src/arg/position.rs | 305 -------- src/commands/src/arg/primitive/bool.rs | 30 - src/commands/src/arg/primitive/char.rs | 28 - src/commands/src/arg/primitive/float.rs | 55 -- src/commands/src/arg/primitive/int.rs | 74 -- src/commands/src/arg/primitive/long.rs | 74 -- src/commands/src/arg/primitive/mod.rs | 208 ------ src/commands/src/arg/primitive/string.rs | 139 ---- src/commands/src/ctx.rs | 38 - src/commands/src/errors.rs | 15 - src/commands/src/graph/mod.rs | 264 ------- src/commands/src/graph/node.rs | 135 ---- src/commands/src/infrastructure.rs | 71 -- src/commands/src/input.rs | 143 ---- src/commands/src/lib.rs | 61 -- src/commands/src/messages.rs | 32 - src/commands/src/resolve.rs | 35 - src/commands/src/sender.rs | 29 - src/default_commands/Cargo.toml | 1 - src/default_commands/src/bossbar.rs | 672 +++++++++++------- src/default_commands/src/credits.rs | 62 +- src/default_commands/src/deop.rs | 52 -- src/default_commands/src/echo.rs | 50 +- src/default_commands/src/fly.rs | 78 -- src/default_commands/src/gamemode.rs | 155 +++- src/default_commands/src/kill.rs | 99 +-- src/default_commands/src/lib.rs | 9 +- src/default_commands/src/nested.rs | 43 -- src/default_commands/src/new/bossbar.rs | 487 ------------- src/default_commands/src/new/credits.rs | 44 -- src/default_commands/src/new/echo.rs | 36 - src/default_commands/src/new/gamemode.rs | 138 ---- src/default_commands/src/new/kill.rs | 72 -- src/default_commands/src/new/mod.rs | 10 - src/default_commands/src/new/op.rs | 132 ---- src/default_commands/src/new/stop.rs | 36 - src/default_commands/src/new/time.rs | 177 ----- src/default_commands/src/new/tp.rs | 184 ----- src/default_commands/src/op.rs | 151 +++- src/default_commands/src/permissions.rs | 29 - src/default_commands/src/say.rs | 28 - src/default_commands/src/stop.rs | 45 +- src/default_commands/src/{new => }/summon.rs | 0 src/default_commands/src/time.rs | 270 +++---- src/default_commands/src/tp.rs | 225 ++++-- src/default_commands/src/tps.rs | 151 ---- ...ommand_registry.rs => command_registry.rs} | 5 +- src/game_systems/Cargo.toml | 1 - src/game_systems/src/background/Cargo.toml | 1 - .../src/background/src/server_command.rs | 82 +-- src/game_systems/src/lib.rs | 2 - src/game_systems/src/packets/Cargo.toml | 1 - src/game_systems/src/packets/src/command.rs | 56 +- .../src/packets/src/command_suggestions.rs | 188 +---- src/messages/Cargo.toml | 1 - src/messages/src/lib.rs | 5 +- src/net/protocol/Cargo.toml | 2 +- src/net/protocol/src/outgoing/commands.rs | 161 ++++- src/net/runtime/Cargo.toml | 1 - 76 files changed, 1349 insertions(+), 5501 deletions(-) delete mode 100644 src/base/macros/src/commands/mod.rs delete mode 100644 src/commands/Cargo.toml delete mode 100644 src/commands/src/arg/bossbar_set.rs delete mode 100644 src/commands/src/arg/duration.rs delete mode 100644 src/commands/src/arg/entities/any_entity.rs delete mode 100644 src/commands/src/arg/entities/any_player.rs delete mode 100644 src/commands/src/arg/entities/entity_uuid.rs delete mode 100644 src/commands/src/arg/entities/mod.rs delete mode 100644 src/commands/src/arg/entities/player.rs delete mode 100644 src/commands/src/arg/entities/random_player.rs delete mode 100644 src/commands/src/arg/gamemode.rs delete mode 100644 src/commands/src/arg/mod.rs delete mode 100644 src/commands/src/arg/position.rs delete mode 100644 src/commands/src/arg/primitive/bool.rs delete mode 100644 src/commands/src/arg/primitive/char.rs delete mode 100644 src/commands/src/arg/primitive/float.rs delete mode 100644 src/commands/src/arg/primitive/int.rs delete mode 100644 src/commands/src/arg/primitive/long.rs delete mode 100644 src/commands/src/arg/primitive/mod.rs delete mode 100644 src/commands/src/arg/primitive/string.rs delete mode 100644 src/commands/src/ctx.rs delete mode 100644 src/commands/src/errors.rs delete mode 100644 src/commands/src/graph/mod.rs delete mode 100644 src/commands/src/graph/node.rs delete mode 100644 src/commands/src/infrastructure.rs delete mode 100644 src/commands/src/input.rs delete mode 100644 src/commands/src/lib.rs delete mode 100644 src/commands/src/messages.rs delete mode 100644 src/commands/src/resolve.rs delete mode 100644 src/commands/src/sender.rs delete mode 100644 src/default_commands/src/deop.rs delete mode 100644 src/default_commands/src/fly.rs delete mode 100644 src/default_commands/src/nested.rs delete mode 100644 src/default_commands/src/new/bossbar.rs delete mode 100644 src/default_commands/src/new/credits.rs delete mode 100644 src/default_commands/src/new/echo.rs delete mode 100644 src/default_commands/src/new/gamemode.rs delete mode 100644 src/default_commands/src/new/kill.rs delete mode 100644 src/default_commands/src/new/mod.rs delete mode 100644 src/default_commands/src/new/op.rs delete mode 100644 src/default_commands/src/new/stop.rs delete mode 100644 src/default_commands/src/new/time.rs delete mode 100644 src/default_commands/src/new/tp.rs delete mode 100644 src/default_commands/src/permissions.rs delete mode 100644 src/default_commands/src/say.rs rename src/default_commands/src/{new => }/summon.rs (100%) delete mode 100644 src/default_commands/src/tps.rs rename src/default_commands/tests/{new_command_registry.rs => command_registry.rs} (97%) diff --git a/Cargo.toml b/Cargo.toml index f4375be8..378ffb3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ members = [ "src/blocks/crates/data", "src/bin", "src/command-infra", - "src/commands", "src/components", "src/core", "src/dashboard", @@ -130,7 +129,6 @@ temper-block-data = { path = "src/blocks/crates/data" } temper-config = { path = "src/config" } temper-core = { path = "src/core" } temper-default-commands = { path = "src/default_commands" } -temper-commands = { path = "src/commands" } temper-general-purpose = { path = "src/base/general_purpose" } temper-logging = { path = "src/base/logging" } temper-macros = { path = "src/base/macros" } diff --git a/src/base/macros/src/commands/mod.rs b/src/base/macros/src/commands/mod.rs deleted file mode 100644 index f475becb..00000000 --- a/src/base/macros/src/commands/mod.rs +++ /dev/null @@ -1,259 +0,0 @@ -use proc_macro::TokenStream; -use quote::{format_ident, quote, ToTokens}; -use syn::{ - parse::{Parse, ParseStream}, - parse_macro_input, FnArg, ItemFn, LitStr, Pat, Result as SynResult, Type, -}; - -#[derive(Clone, Debug)] -struct Arg { - name: String, - required: bool, - ty: String, -} - -struct CommandAttr { - name: String, -} - -impl Parse for CommandAttr { - fn parse(input: ParseStream) -> SynResult { - let name = input.parse::()?.value(); - Ok(CommandAttr { name }) - } -} - -pub fn command(attr: TokenStream, item: TokenStream) -> TokenStream { - let mut input_fn = parse_macro_input!(item as ItemFn); - let fn_name = input_fn.clone().sig.ident; - - let command_attr = parse_macro_input!(attr as CommandAttr); - - let mut args = Vec::new(); - let mut bevy_args = Vec::<(Box, Type)>::new(); - let mut has_sender_arg = false; - let mut sender_arg_before_cmd_args = false; - let mut sender_arg_index: Option = None; - let mut first_arg_index: Option = None; - - for (idx, fn_arg) in input_fn.sig.inputs.iter_mut().enumerate() { - let FnArg::Typed(fn_arg) = fn_arg else { - return TokenStream::from(quote! { - compiler_error!("command handler cannot have receiver"); - }); - }; - - if fn_arg.attrs.is_empty() { - bevy_args.push((fn_arg.pat.clone(), *fn_arg.ty.clone())); - } - - let mut is_arg_attr = false; - let mut is_sender_attr = false; - let mut sender_arg_mismatched_ty = false; - - fn_arg.attrs.retain(|arg| { - let is_arg = arg.path().is_ident("arg"); - let is_sender = arg.path().is_ident("sender"); - - if is_arg { - is_arg_attr = true; - - let required = match *fn_arg.ty { - Type::Path(ref path) => { - !path.path.segments.iter().any(|seg| seg.ident == "Option") - } - _ => true, - }; - - args.push(Arg { - name: fn_arg.pat.to_token_stream().to_string(), - required, - ty: fn_arg.ty.to_token_stream().to_string(), - }); - } - - if is_sender { - is_sender_attr = true; - - match *fn_arg.ty { - Type::Path(ref path) => { - if path.path.segments.iter().next_back().unwrap().ident != "Sender" { - sender_arg_mismatched_ty = true; - return false; - } - } - _ => { - sender_arg_mismatched_ty = true; - return false; - } - } - - has_sender_arg = true; - } - - !is_arg && !is_sender - }); - - if sender_arg_mismatched_ty { - return TokenStream::from(quote! { - compile_error!("invalid type for sender arg - should be Sender"); - }); - } - - if is_sender_attr && sender_arg_index.is_none() { - sender_arg_index = Some(idx); - } - - if is_arg_attr && first_arg_index.is_none() { - first_arg_index = Some(idx); - } - } - - if let (Some(sender_idx), Some(arg_idx)) = (sender_arg_index, first_arg_index) { - if sender_idx < arg_idx { - sender_arg_before_cmd_args = true; - } - } - - if bevy_args.iter().any(|(_, ty)| { - if let Type::Reference(refr) = ty { - if let Type::Path(path) = *refr.clone().elem { - println!("path reference: {:?}", path.path.segments.clone()); - let is_bevy = path.path.segments.iter().any(|seg| { - println!("{}", seg.ident); - &seg.ident.to_string() == "bevy_ecs" - }); - println!("is bevy? {is_bevy}"); - println!("{}", path.path.is_ident("World")); - - path.path.is_ident("World") && (is_bevy || path.path.segments.len() == 1) - } else { - false - } - } else { - false - } - }) { - return TokenStream::from(quote! { - compile_error!("commands cannot accept bevy world arguments due to bevy restrictions") - }); - } - - let system_name = format_ident!("__{}_handler", fn_name); - let system_args = bevy_args - .clone() - .iter() - .map(|(pat, ty)| { - quote! { #pat: #ty, } - }) - .collect::>(); - let system_arg_pats = bevy_args - .clone() - .iter() - .map(|(pat, _)| match pat.as_ref() { - syn::Pat::Ident(pat_ident) => { - let ident = &pat_ident.ident; - quote!(#ident) - } - _ => quote!(#pat), - }) - .collect::>(); - - let arg_extractors = args - .clone() - .iter() - .map(|arg| { - let name = &arg.name; - let ty = syn::parse_str::(&arg.ty).expect("invalid arg type"); - - quote! { - match __ctx.arg::<#ty>(#name) { - Ok(a) => a, - Err(err) => { - sender.send_message(temper_text::TextComponentBuilder::new(format!("failed parsing {}: ", #name)) - .extra(*err) - .color(temper_text::NamedColor::Red) - .build(), false); - return; - } - }, - } - }) - .collect::>(); - - let sender_param = if has_sender_arg { - quote! { sender.clone(), } - } else { - quote! {} - }; - - let ctor_fn_name = format_ident!("__{}_register", fn_name); - let command_name = command_attr.name; - let tracing_name = format!("command:{}", command_name); - let system_tracing_name = format!("{} handler", tracing_name); - - let command_args = args - .iter() - .map(|arg| { - let name = arg.name.clone(); - let required = arg.required; - let ty = format_ident!("{}", &arg.ty); - - quote! { - temper_commands::arg::CommandArgumentNode { - name: #name.to_string(), - required: #required, - primitive: <#ty as temper_commands::arg::CommandArgument>::primitive(), - suggester: <#ty as temper_commands::arg::CommandArgument>::suggest, - }, - } - }) - .collect::>(); - - let call = if has_sender_arg && sender_arg_before_cmd_args { - quote! { - #fn_name(#sender_param #(#arg_extractors)* #(#system_arg_pats,)*); - } - } else if has_sender_arg { - quote! { - #fn_name(#(#arg_extractors)* #sender_param #(#system_arg_pats,)*); - } - } else { - quote! { - #fn_name(#(#arg_extractors)* #(#system_arg_pats,)*); - } - }; - - TokenStream::from(quote! { - #[allow(non_snake_case)] - #[allow(dead_code)] - #[doc(hidden)] - #[tracing::instrument(name = #tracing_name, skip_all)] - #input_fn - - #[allow(unused_mut)] // required to use mutable queries without clippy screaming bloody murder - #[allow(non_snake_case)] - #[allow(unused_variables)] - #[doc(hidden)] - #[tracing::instrument(name = #system_tracing_name, skip_all)] - fn #system_name(mut messages: bevy_ecs::prelude::MessageMutator, #(#system_args)*) { - for temper_commands::messages::ResolvedCommandDispatched { command: __command, ctx: __ctx, sender } in messages.read() { - if __command.name == #command_name { - #call - return // this is due to ownership issues - } - } - } - - #[ctor::ctor(unsafe)] - #[doc(hidden)] - fn #ctor_fn_name() { - temper_commands::infrastructure::add_system(#system_name); - - temper_commands::infrastructure::register_command(std::sync::Arc::new(temper_commands::Command { - name: #command_name, - args: vec![#(#command_args)*], - })); - } - }) -} diff --git a/src/base/macros/src/lib.rs b/src/base/macros/src/lib.rs index fbb7fad1..da70538c 100644 --- a/src/base/macros/src/lib.rs +++ b/src/base/macros/src/lib.rs @@ -5,7 +5,6 @@ use proc_macro::TokenStream; mod block; mod command_derive; -mod commands; mod helpers; mod item; mod misc; @@ -60,36 +59,11 @@ pub fn lookup_packet(input: TokenStream) -> TokenStream { } // #=================== PACKETS ===================# -/// Creates a command. -/// -/// A command function can take a sender argument, multiple command arguments and bevy system arguments. -/// -/// The optional sender argument is marked with `#[sender]` attribute and command arguments are marked with -/// the `#[arg]` attribute. Any other argument is treated as a bevy system arg. -/// -/// Usage example: -/// -/// ```ignore -/// #[command("hello")] -/// fn command(#[sender] sender: Sender) { -/// sender.send_message(TextComponent::from("Hello, world!"), false); -/// } -/// ``` -#[proc_macro_attribute] -pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream { - commands::command(attr, input) -} - #[proc_macro_derive(Command, attributes(command, arg, literal, subcommand, permission))] pub fn command_derive(input: TokenStream) -> TokenStream { command_derive::derive(input) } -// #[proc_macro_attribute] -// pub fn arg(attr: TokenStream, input: TokenStream) -> TokenStream { -// commands::arg(attr, input) -// } - /// Get a registry entry from the registries.json file. /// returns protocol_id (as u64) of the specified entry. #[proc_macro] diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs index 429025c0..f2d79683 100644 --- a/src/command-infra/src/ecs.rs +++ b/src/command-infra/src/ecs.rs @@ -105,7 +105,7 @@ pub fn send_parse_error(source: CommandSource, error: &ParseError) { } pub fn dispatch_command( - mut commands: MessageReader, + mut commands: MessageReader, permissions: Query<&PlayerPermission>, mut params: C::SystemParam<'_, '_>, ) { @@ -132,9 +132,10 @@ pub fn dispatch_command( && !can_use(permission) { let source = event.source; - let message = TextComponentBuilder::new("You don't have permission to use this command.") - .color(NamedColor::Red) - .build(); + let message = + TextComponentBuilder::new("You don't have permission to use this command.") + .color(NamedColor::Red) + .build(); source.send_message(message); continue; } @@ -220,14 +221,16 @@ pub enum CommandSource { impl CommandSource { pub fn send_message(self, message: TextComponent) { match self { - CommandSource::Player(entity) => {mq::queue(message, false, entity)}, - CommandSource::Server => {info!("{}", message.to_plain_text())} + CommandSource::Player(entity) => mq::queue(message, false, entity), + CommandSource::Server => { + info!("{}", message.to_plain_text()) + } } } } #[derive(Message, Clone, Debug)] -pub struct NewCommandDispatched { +pub struct CommandDispatched { pub input: Arc, pub source: CommandSource, } diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs index bc3e0590..5a31ceab 100644 --- a/src/command-infra/src/lib.rs +++ b/src/command-infra/src/lib.rs @@ -50,9 +50,9 @@ //! let sub_entity = subcommand.WithPos; //! // do something with the position the player gave in the first argument of the subcommand //! } -//! } +//! } //! } -//! +//! //! // Optional error handler method //! fn handle_parse_error( //! source: CommandSource, @@ -62,10 +62,10 @@ //! } //! ``` //! The entire system revolves around the [crate::CommandHandler] trait, which is implemented on the -//! command enum/struct. Under the hood the derive macro will generate all the code needed to have -//! the command wired into the ECS, provide suggestions and parse the arguments. All you need to do +//! command enum/struct. Under the hood the derive macro will generate all the code needed to have +//! the command wired into the ECS, provide suggestions and parse the arguments. All you need to do //! is define what arguments a command needs and what it does with those args. -//! +//! //! There are several attribute macros available including literal args: //! ``` //! #[derive(Command)] @@ -75,7 +75,7 @@ //! LiteralCommand, //! } //! ``` -//! that skip the hassle of parsing and verifying an argument when you only allow a specific set of +//! that skip the hassle of parsing and verifying an argument when you only allow a specific set of //! options, aliases on both commands and literals: //! ``` //! #[derive(Command)] @@ -94,9 +94,9 @@ //! LiteralCommand, //! } //! ``` -//! (Note that this only limits what gets suggested to the client, you still need to verify +//! (Note that this only limits what gets suggested to the client, you still need to verify //! permissions in handlers to prevent manually typed commands being run when they shouldn't) -//! +//! //! This is the general gist of using commands with existing argument types, check out [crate::args] //! for how to make your own argument types. @@ -110,7 +110,7 @@ pub mod suggestions; pub use ctor; pub use ecs::{ - CommandHandler, CommandRegistry, CommandSource, NewCommandDispatched, PlayerCommandGraph, + CommandDispatched, CommandHandler, CommandRegistry, CommandSource, PlayerCommandGraph, RebuildCommandGraph, RegisteredCommand, }; pub use ecs::{ diff --git a/src/command-infra/tests/ecs_registry.rs b/src/command-infra/tests/ecs_registry.rs index cc5b8eab..9cc09029 100644 --- a/src/command-infra/tests/ecs_registry.rs +++ b/src/command-infra/tests/ecs_registry.rs @@ -4,7 +4,7 @@ use bevy_ecs::prelude::{IntoScheduleConfigs, MessageWriter, ResMut, Resource, Sc use std::sync::Arc; use temper_command_infra::args::{EntityArg, PositionArg, SingleWordArg}; use temper_command_infra::{ - CommandHandler, CommandRegistry, CommandSource, CommandSpec, NewCommandDispatched, ParseError, + CommandDispatched, CommandHandler, CommandRegistry, CommandSource, CommandSpec, ParseError, dispatch_command, static_commands, }; use temper_macros::Command; @@ -56,16 +56,16 @@ impl CommandHandler for DemoCommand { } } -fn emit_demo_commands(mut writer: MessageWriter) { - writer.write(NewCommandDispatched { +fn emit_demo_commands(mut writer: MessageWriter) { + writer.write(CommandDispatched { input: Arc::from("demo hello"), source: CommandSource::Player(Entity::PLACEHOLDER), }); - writer.write(NewCommandDispatched { + writer.write(CommandDispatched { input: Arc::from("demo"), source: CommandSource::Player(Entity::PLACEHOLDER), }); - writer.write(NewCommandDispatched { + writer.write(CommandDispatched { input: Arc::from("other hello"), source: CommandSource::Player(Entity::PLACEHOLDER), }); @@ -94,7 +94,7 @@ fn registry_builds_graph_from_registered_commands() { #[test] fn dispatch_command_calls_handler_trait_methods() { let mut world = World::new(); - MessageRegistry::register_message::(&mut world); + MessageRegistry::register_message::(&mut world); world.init_resource::(); let mut schedule = Schedule::default(); diff --git a/src/commands/Cargo.toml b/src/commands/Cargo.toml deleted file mode 100644 index 2ca337e7..00000000 --- a/src/commands/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "temper-commands" -version = "0.1.0" -edition = "2024" - -[dependencies] -thiserror = { workspace = true } -tracing = { workspace = true } -dashmap = { workspace = true } -temper-text = { workspace = true } -temper-core = { workspace = true } -enum-ordinalize = { workspace = true } -temper-macros = { workspace = true } -bevy_ecs = { workspace = true } -temper-codec = { workspace = true } -regex = { workspace = true } -temper-components = { workspace = true } -temper-nbt = { workspace = true } -temper-state = { workspace = true } -uuid = { workspace = true } -rand = { workspace = true } - -[dev-dependencies] # Needed for the ServerState mock... :concern: -temper-world = { workspace = true } - -[lints] -workspace = true diff --git a/src/commands/src/arg/bossbar_set.rs b/src/commands/src/arg/bossbar_set.rs deleted file mode 100644 index bdafb0a3..00000000 --- a/src/commands/src/arg/bossbar_set.rs +++ /dev/null @@ -1,90 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; - -pub enum BossbarSetOptions { - Color(String), - Name(String), - Players(Vec), - Style((String, String)), - Value(f32), - Max(f32), -} - -impl CommandArgument for BossbarSetOptions { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - let value = match &*str.to_lowercase() { - "color" => { - let color = ctx.input.read_string(); - BossbarSetOptions::Color(color) - } - "name" => BossbarSetOptions::Name(ctx.input.read_string()), - "players" => { - let players = ctx.input.read_string(); - BossbarSetOptions::Players(vec![players]) - } - "style" => { - let style = ctx.input.read_string(); - let divider = ctx.input.read_string(); - BossbarSetOptions::Style((style, divider)) - } - "value" => { - let v = ctx - .input - .read_string() - .parse::() - .map_err(|_| parser_error("invalid float for value"))?; - BossbarSetOptions::Value(v) - } - "max" => { - let v = ctx - .input - .read_string() - .parse::() - .map_err(|_| parser_error("invalid float for max"))?; - BossbarSetOptions::Max(v) - } - _ => return Err(parser_error(&format!("invalid option: {str}"))), - }; - - Ok(value) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::greedy() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - let str = ctx.input.read_string(); - - let players: Vec = ctx - .state - .players - .player_list - .iter() - .map(|e| e.value().1.clone()) - .collect(); - let mut player_refs: Vec<&str> = players.iter().map(|s| s.as_str()).collect(); - player_refs.append(&mut vec!["@e", "@a", "@r"]); - - let suggestions: &[&str] = match str.to_lowercase().as_str() { - "color" => &["blue", "green", "pink", "purple", "red", "white", "yellow"], - "style" => &[ - "notched_6", - "notched_10", - "notched_12", - "notched_20", - "progress", - ], - "players" => &player_refs, - _ => &["color", "name", "players", "style", "value", "max"], - }; - - suggestions.iter().map(Suggestion::of).collect() - } -} diff --git a/src/commands/src/arg/duration.rs b/src/commands/src/arg/duration.rs deleted file mode 100644 index 7297838f..00000000 --- a/src/commands/src/arg/duration.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{sync::LazyLock, time::Duration}; - -use regex::Regex; - -use crate::{CommandContext, Suggestion}; - -use super::{CommandArgument, ParserResult, primitive::PrimitiveArgument, utils::parser_error}; - -static PATTERN: LazyLock = - LazyLock::new(|| Regex::new("(([1-9][0-9]+|[1-9])[dhms])").unwrap()); - -impl CommandArgument for Duration { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let mut duration = Duration::ZERO; - - for (_, [line, value]) in PATTERN - .captures_iter(&ctx.input.read_string()) - .map(|c| c.extract()) - { - let Some(unit) = line.chars().nth(line.len() - 1) else { - return Err(parser_error("missing time unit")); - }; - let Ok(value) = value.parse::() else { - return Err(parser_error("invalid number")); - }; - - match unit { - 'd' => duration += Duration::from_hours(value * 24), - 'h' => duration += Duration::from_hours(value), - 'm' => duration += Duration::from_mins(value), - 's' => duration += Duration::from_secs(value), - _ => return Err(parser_error("invalid unit: expected d/h/m/s")), - } - } - - if duration.is_zero() { - return Err(parser_error("invalid input")); - } - - Ok(duration) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.skip_whitespace(u32::MAX, false); - if !ctx.input.has_remaining_input() { - return (0..9).map(|i| Suggestion::of(i.to_string())).collect(); - }; - - let input = ctx.input.read_string(); - - if input.chars().last().unwrap().is_ascii_alphabetic() { - return vec![]; - } - - ['d', 'h', 'm', 's'] - .into_iter() - .filter(|unit| !input.contains(*unit)) - .map(|unit| Suggestion::of(input.clone() + &unit.to_string())) - .collect() - } -} diff --git a/src/commands/src/arg/entities/any_entity.rs b/src/commands/src/arg/entities/any_entity.rs deleted file mode 100644 index e39f441c..00000000 --- a/src/commands/src/arg/entities/any_entity.rs +++ /dev/null @@ -1,9 +0,0 @@ -use bevy_ecs::prelude::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_any_entity<'a>( - iter: impl Iterator)>, -) -> Vec { - iter.map(|(entity, _, _)| entity).collect() -} diff --git a/src/commands/src/arg/entities/any_player.rs b/src/commands/src/arg/entities/any_player.rs deleted file mode 100644 index c61117b1..00000000 --- a/src/commands/src/arg/entities/any_player.rs +++ /dev/null @@ -1,16 +0,0 @@ -use bevy_ecs::entity::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_any_player<'a>( - iter: impl Iterator)>, -) -> Vec { - let mut players = Vec::new(); - for (entity, _, player_marker) in iter { - if player_marker.is_none() { - continue; - } - players.push(entity); - } - players -} diff --git a/src/commands/src/arg/entities/entity_uuid.rs b/src/commands/src/arg/entities/entity_uuid.rs deleted file mode 100644 index c5a3e459..00000000 --- a/src/commands/src/arg/entities/entity_uuid.rs +++ /dev/null @@ -1,16 +0,0 @@ -use bevy_ecs::entity::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; -use uuid::Uuid; - -pub(crate) fn resolve_uuid<'a>( - uuid: Uuid, - iter: impl Iterator)>, -) -> Option { - for (entity, entity_id_opt, _) in iter { - if entity_id_opt.uuid == uuid { - return Some(entity); - } - } - None -} diff --git a/src/commands/src/arg/entities/mod.rs b/src/commands/src/arg/entities/mod.rs deleted file mode 100644 index cbe25ed9..00000000 --- a/src/commands/src/arg/entities/mod.rs +++ /dev/null @@ -1,430 +0,0 @@ -mod any_entity; -mod any_player; -mod entity_uuid; -mod player; -mod random_player; - -use crate::arg::primitive::PrimitiveArgument; -use crate::arg::{CommandArgument, ParserResult}; -use crate::{CommandContext, Suggestion}; -use ::uuid::Uuid; -use bevy_ecs::prelude::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -/// Represents an entity argument in a command. -/// It can be a player name, UUID, or special selectors like @e, @p, @r, @a. -/// This won't get you an entity directly, use `resolve()` to get the entities. -/// -/// # Example -/// ```ignore -/// # use temper_commands::arg::entities::EntityArgument; -/// # use temper_core::identity::entity_identity::EntityIdentity; -/// # use temper_core::identity::player_identity::Identity; -/// # use bevy_ecs::prelude::World; -/// -/// fn my_command(query: Query<(Entity, Option<&EntityIdentity>, Option<&Identity>)>) { -/// let arg = EntityArgument::PlayerName("Steve".to_string()); -/// let result = arg.resolve(query.iter()); -/// assert_eq!(result, vec![entity]); -/// } -/// ``` -#[derive(Clone, Debug, PartialEq)] -pub enum EntityArgument { - PlayerName(String), - Uuid(Uuid), - AnyEntity, - AnyPlayer, - // NearestPlayer, - RandomPlayer, -} - -impl CommandArgument for EntityArgument { - fn parse(ctx: &mut CommandContext) -> ParserResult { - const PREFIXES: &[(&str, EntityArgument)] = &[ - ("@e", EntityArgument::AnyEntity), - // ("@p", EntityArgument::NearestPlayer), - ("@r", EntityArgument::RandomPlayer), - ("@a", EntityArgument::AnyPlayer), - ]; - let input = ctx.input.read_string(); - for (prefix, entity_type) in PREFIXES { - if input == *prefix { - return Ok(entity_type.clone()); - } - } - if input.len() == 36 && input.chars().all(|c| c.is_ascii_hexdigit() || c == '-') { - let uuid = Uuid::parse_str(&input) - .map_err(|_| crate::arg::utils::parser_error("invalid UUID format"))?; - Ok(EntityArgument::Uuid(uuid)) - } else { - Ok(EntityArgument::PlayerName(input)) - } - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - let mut suggestions = vec![ - Suggestion { - content: "@e".to_string(), - tooltip: Some(temper_nbt::NBT::new("Any Entity".into())), - }, - // Suggestion { - // content: "@p".to_string(), - // tooltip: Some(temper_nbt::NBT::new("Nearest Player".into())), - // }, - Suggestion { - content: "@r".to_string(), - tooltip: Some(temper_nbt::NBT::new("Random Player".into())), - }, - Suggestion { - content: "@a".to_string(), - tooltip: Some(temper_nbt::NBT::new("All Players".into())), - }, - ]; - let state = ctx.state.clone(); - for kv in &state.clone().players.player_list { - let (_, (uuid, name)) = kv.pair(); - suggestions.push(Suggestion { - content: name.clone(), - tooltip: Some(temper_nbt::NBT::new( - Uuid::from_u128(*uuid) - .as_hyphenated() - .to_string() - .to_uppercase() - .into(), - )), - }); - } - suggestions - } -} - -impl EntityArgument { - pub fn resolve<'a>( - &self, - iter: impl Iterator)>, - ) -> Vec { - match self { - EntityArgument::PlayerName(name) => player::resolve_player_name(name.clone(), iter) - .map(|e| vec![e]) - .unwrap_or_default(), - EntityArgument::Uuid(uuid) => entity_uuid::resolve_uuid(*uuid, iter) - .map(|e| vec![e]) - .unwrap_or_default(), - EntityArgument::AnyEntity => any_entity::resolve_any_entity(iter), - EntityArgument::AnyPlayer => any_player::resolve_any_player(iter), - // EntityArgument::NearestPlayer => { - // // TODO: Figure this out - // vec![] - // } - EntityArgument::RandomPlayer => random_player::resolve_random_player(iter) - .map(|e| vec![e]) - .unwrap_or_default(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Command, CommandInput, Sender}; - use bevy_ecs::prelude::World; - use std::sync::Arc; - use temper_components::entity_identity::Identity; - use temper_state::create_test_state; - - #[test] - fn test_parse_entity_argument() { - let mut ctx = CommandContext { - input: CommandInput { - input: "Steve".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::PlayerName("Steve".to_string())); - - let mut ctx = CommandContext { - input: CommandInput { - input: "@e".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::AnyEntity); - - // let mut ctx = CommandContext { - // input: CommandInput { - // input: "@p".to_string(), - // cursor: 0, - // }, - // command: Arc::new(Command { - // name: "", - // args: vec![], - // }), - // sender: Sender::Server, - // }; - // let arg = EntityArgument::parse(&mut ctx).unwrap(); - // assert_eq!(arg, EntityArgument::NearestPlayer); - - let mut ctx = CommandContext { - input: CommandInput { - input: "@r".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::RandomPlayer); - - let mut ctx = CommandContext { - input: CommandInput { - input: "@a".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::AnyPlayer); - - let uuid_str = "123e4567-e89b-12d3-a456-426614174000"; - let mut ctx = CommandContext { - input: CommandInput { - input: uuid_str.to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!( - arg, - EntityArgument::Uuid(Uuid::parse_str(uuid_str).unwrap()) - ); - } - - #[test] - fn test_suggest_entity_argument() { - let mut ctx = CommandContext { - input: CommandInput { - input: "a".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let suggestions = EntityArgument::suggest(&mut ctx); - assert!(!ctx.input.has_remaining_input()); - assert!(suggestions.iter().any(|s| s.content == "@a")); - assert!(suggestions.iter().any(|s| s.content == "@e")); - assert!(suggestions.iter().any(|s| s.content == "@r")); - } - - #[test] - fn test_resolves_name() { - let mut world = World::new(); - let entity = world - .spawn(( - Identity { - name: Some("Steve".to_string()), - uuid: Default::default(), - entity_id: 0, - }, - PlayerMarker, - )) - .id(); - - let arg = EntityArgument::PlayerName("Steve".to_string()); - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result, vec![entity]); - } - - #[test] - fn test_resolves_uuid() { - let mut world = World::new(); - let test_uuid = Uuid::new_v4(); - let entity = world - .spawn((Identity { - entity_id: 0, - uuid: test_uuid, - name: None, - },)) - .id(); - let arg = EntityArgument::Uuid(test_uuid); - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result, vec![entity]); - } - - #[test] - fn test_resolves_any_entity() { - let mut world = World::new(); - let entity1 = world - .spawn(Identity { - entity_id: 0, - uuid: Uuid::new_v4(), - name: None, - }) - .id(); - let entity2 = world - .spawn(Identity { - entity_id: 1, - uuid: Uuid::new_v4(), - name: None, - }) - .id(); - let arg = EntityArgument::AnyEntity; - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 2); - assert!(result.contains(&entity1)); - assert!(result.contains(&entity2)); - } - - #[test] - fn test_resolves_any_player() { - let mut world = World::new(); - let entity1 = world - .spawn(( - Identity { - name: Some("Steve".to_string()), - uuid: Uuid::new_v4(), - entity_id: 0, - }, - PlayerMarker, - )) - .id(); - let entity2 = world - .spawn(( - Identity { - name: Some("Alex".to_string()), - entity_id: 1, - uuid: Uuid::new_v4(), - }, - PlayerMarker, - )) - .id(); - let non_player_entity = world - .spawn((Identity { - entity_id: 2, - uuid: Uuid::new_v4(), - name: None, - },)) - .id(); - let arg = EntityArgument::AnyPlayer; - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 2); - assert!(result.contains(&entity1)); - assert!(result.contains(&entity2)); - assert!(!result.contains(&non_player_entity)); - } - - #[test] - fn resolves_random_player() { - let mut world = World::new(); - let entity1 = world - .spawn(( - Identity { - name: Some("Steve".to_string()), - uuid: Uuid::new_v4(), - entity_id: 0, - }, - PlayerMarker, - )) - .id(); - let entity2 = world - .spawn(( - Identity { - name: Some("Alex".to_string()), - entity_id: 1, - uuid: Uuid::new_v4(), - }, - PlayerMarker, - )) - .id(); - let non_player_entity = world - .spawn((Identity { - entity_id: 2, - uuid: Uuid::new_v4(), - name: None, - },)) - .id(); - let arg = EntityArgument::RandomPlayer; - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 1); - assert!(result.contains(&entity1) || result.contains(&entity2)); - assert!(!result.contains(&non_player_entity)); - - // Run the test 500 times to ensure randomness - // Technically this could actually result in 500 identical results, but the odds of that are astronomically low (about 1 in 3.27e150) - let mut results = vec![]; - for _ in 0..500 { - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 1); - results.push(result[0]); - } - let unique_results: std::collections::HashSet<_> = results.into_iter().collect(); - assert_eq!( - unique_results.len(), - 2, - "Random player selection is not random enough" - ); - } -} diff --git a/src/commands/src/arg/entities/player.rs b/src/commands/src/arg/entities/player.rs deleted file mode 100644 index da3be1f8..00000000 --- a/src/commands/src/arg/entities/player.rs +++ /dev/null @@ -1,21 +0,0 @@ -use bevy_ecs::prelude::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_player_name<'a>( - name: String, - iter: impl Iterator)>, -) -> Option { - for (entity, id, player_marker) in iter { - if player_marker.is_some() - && id - .name - .as_ref() - .map(|n| n.eq_ignore_ascii_case(&name)) - .unwrap_or(false) - { - return Some(entity); - } - } - None -} diff --git a/src/commands/src/arg/entities/random_player.rs b/src/commands/src/arg/entities/random_player.rs deleted file mode 100644 index 54fb4875..00000000 --- a/src/commands/src/arg/entities/random_player.rs +++ /dev/null @@ -1,18 +0,0 @@ -use bevy_ecs::entity::Entity; -use rand::prelude::IteratorRandom; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_random_player<'a>( - iter: impl Iterator)>, -) -> Option { - let mut rng = rand::rng(); - iter.filter_map(|(entity, _, player_id)| { - if player_id.is_some() { - Some(entity) - } else { - None - } - }) - .choose(&mut rng) -} diff --git a/src/commands/src/arg/gamemode.rs b/src/commands/src/arg/gamemode.rs deleted file mode 100644 index 2513a76f..00000000 --- a/src/commands/src/arg/gamemode.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; -use temper_components::player::gamemode::GameMode; - -// Implement the trait directly for the enum -impl CommandArgument for GameMode { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - let value = match &*str.to_lowercase() { - "survival" | "0" => GameMode::Survival, - "creative" | "1" => GameMode::Creative, - "adventure" | "2" => GameMode::Adventure, - "spectator" | "3" => GameMode::Spectator, - _ => return Err(parser_error(&format!("invalid gamemode: {str}"))), - }; - - Ok(value) - } - - fn primitive() -> PrimitiveArgument { - // We're parsing a single word - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - - ["survival", "creative", "adventure", "spectator"] - .into_iter() - .map(Suggestion::of) - .collect() - } -} diff --git a/src/commands/src/arg/mod.rs b/src/commands/src/arg/mod.rs deleted file mode 100644 index 6b17028b..00000000 --- a/src/commands/src/arg/mod.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Command arguments. - -use primitive::PrimitiveArgument; -use temper_text::TextComponent; - -use crate::{Suggestion, ctx::CommandContext}; - -pub mod bossbar_set; -pub mod duration; -pub mod entities; -pub mod gamemode; -pub mod position; -pub mod primitive; - -pub type ParserResult = Result>; - -/// [`CommandArgument`] represents an argument that can be added to a command. -/// This is generally done by having a wrapper type around the inner value, with -/// (const) type arguments for options and implementing [`CommandArgument`] for -/// the wrapper type. -pub trait CommandArgument -where - Self: Sized, -{ - /// Parses the argument from a command context and returns the value or a text error. - fn parse(ctx: &mut CommandContext) -> ParserResult; - - /// Returns the primitive argument type of this argument. This represents the - /// vanilla parser sent to the client for client-side validation. - fn primitive() -> PrimitiveArgument; - - /// Returns the completion strings sent to the client when typing something. - /// This is called every time the client enters or removes a character. - /// - /// **Make sure to consume the input in here, even if you are not suggesting anything**. - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - vec![] - } -} - -impl CommandArgument for Option -where - T: CommandArgument + Sized, -{ - fn parse(ctx: &mut CommandContext) -> ParserResult { - if ctx.input.has_remaining_input() { - T::parse(ctx).map(|t| Some(t)) - } else { - Ok(None) - } - } - - fn primitive() -> PrimitiveArgument { - T::primitive() - } -} - -/// An instance of a command argument node consisting of a name, optionality and the -/// underlying [`PrimitiveArgument`] of this argument. -// The reason we don't implement Eq is because of float argument flags, since -// floats are not Eq. -#[derive(Clone, Debug)] -pub struct CommandArgumentNode { - /// The name of the argument. - pub name: String, - - /// Whether this argument is required or not. - pub required: bool, - - /// The [`PrimitiveArgument`] of this argument node. - pub primitive: PrimitiveArgument, - - /// Suggests autocomplete options for this argument. - pub suggester: fn(&mut CommandContext) -> Vec, -} - -impl PartialEq for CommandArgumentNode { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - && self.required == other.required - && self.primitive == other.primitive - } -} - -pub mod utils { - //! Utilities related to argument parsing errors. - - use std::error::Error; - - use temper_text::{NamedColor, TextComponent, TextComponentBuilder}; - - use crate::errors::CommandError; - - /// Creates a [`CommandError::ParserError`] parser error from the given `message`. - pub fn parser_error(message: &str) -> Box { - error(CommandError::ParserError(message.to_string())) - } - - /// Creates a parser error from the given `err`. - pub fn error(err: impl Error) -> Box { - Box::new( - TextComponentBuilder::new(err.to_string()) - .color(NamedColor::Red) - .build(), - ) - } -} diff --git a/src/commands/src/arg/position.rs b/src/commands/src/arg/position.rs deleted file mode 100644 index 249863cd..00000000 --- a/src/commands/src/arg/position.rs +++ /dev/null @@ -1,305 +0,0 @@ -use crate::arg::primitive::PrimitiveArgument; -use crate::arg::utils::parser_error; -use crate::arg::{CommandArgument, ParserResult}; -use crate::{CommandContext, Suggestion}; -use temper_components::player::position::Position; - -/// Represents a position argument in a command, which can be absolute or relative. -/// For example: "100 64 -200" (absolute) or "~10 ~ ~-5" (relative). -/// -/// The coordinates are initially opaque, in order to get an actual world position you must pass in -/// a base position to resolve against, usually the player calling the command. You can pass in a -/// 0,0,0 position if you want absolute coordinates only. -/// -/// # Example -/// ```ignore -/// use temper_commands::arg::position::CommandPosition; -/// use temper_core::transform::position::Position; -/// -/// let cmd_pos = CommandPosition::parse("~10 64 ~-5").unwrap; -/// let base_position = Position::new(100.0, 64.0, 100.0); -/// let resolved_position = cmd_pos.resolve(&base_position); -/// assert_eq!(resolved_position, Position::new(110.0, 64.0, 95.0)); -/// ``` -pub struct CommandPosition { - x: PositionType, - y: PositionType, - z: PositionType, -} - -enum PositionType { - Absolute(f64), - Relative(f64), -} - -impl CommandArgument for CommandPosition { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let mut string_input = String::new(); - while ctx.input.has_remaining_input() { - if !string_input.is_empty() { - string_input.push(' '); - } - string_input.push_str(&ctx.input.read_string()); - } - let mut parts = string_input.split_whitespace(); - let x_str = parts - .next() - .ok_or_else(|| parser_error("missing x coordinate"))?; - let y_str = parts - .next() - .ok_or_else(|| parser_error("missing y coordinate"))?; - let z_str = parts - .next() - .ok_or_else(|| parser_error("missing z coordinate"))?; - let x = if let Some(x_str) = x_str.strip_prefix('~') { - if x_str.is_empty() { - PositionType::Relative(0.0) - } else { - let offset = x_str - .parse::() - .map_err(|_| parser_error("invalid x coordinate"))?; - PositionType::Relative(offset) - } - } else { - let value = x_str - .parse::() - .map_err(|_| parser_error("invalid x coordinate"))?; - PositionType::Absolute(value) - }; - let y = if let Some(y_str) = y_str.strip_prefix('~') { - if y_str.is_empty() { - PositionType::Relative(0.0) - } else { - let offset = y_str - .parse::() - .map_err(|_| parser_error("invalid y coordinate"))?; - PositionType::Relative(offset) - } - } else { - let value = y_str - .parse::() - .map_err(|_| parser_error("invalid y coordinate"))?; - PositionType::Absolute(value) - }; - let z = if let Some(z_str) = z_str.strip_prefix('~') { - if z_str.is_empty() { - PositionType::Relative(0.0) - } else { - let offset = z_str - .parse::() - .map_err(|_| parser_error("invalid z coordinate"))?; - PositionType::Relative(offset) - } - } else { - let value = z_str - .parse::() - .map_err(|_| parser_error("invalid z coordinate"))?; - PositionType::Absolute(value) - }; - if parts.next().is_some() { - return Err(parser_error("too many coordinates provided")); - } - Ok(CommandPosition { x, y, z }) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::greedy() - } - - fn suggest(_ctx: &mut CommandContext) -> Vec { - vec![Suggestion::of("~ ~ ~")] - } -} - -impl CommandPosition { - pub fn resolve(&self, position: &Position) -> Position { - let x = match self.x { - PositionType::Absolute(val) => val, - PositionType::Relative(offset) => position.x + offset, - }; - let y = match self.y { - PositionType::Absolute(val) => val, - PositionType::Relative(offset) => position.y + offset, - }; - let z = match self.z { - PositionType::Absolute(val) => val, - PositionType::Relative(offset) => position.z + offset, - }; - Position::new(x, y, z) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Command, CommandInput, Sender}; - use std::sync::Arc; - use temper_state::create_test_state; - - #[test] - fn test_parse() { - let mut ctx = CommandContext { - input: CommandInput { - input: "~10 5 ~-10".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let cmd_pos = CommandPosition::parse(&mut ctx).unwrap(); - match cmd_pos.x { - PositionType::Relative(offset) => assert_eq!(offset, 10.0), - _ => panic!("Expected relative x"), - } - match cmd_pos.y { - PositionType::Absolute(value) => assert_eq!(value, 5.0), - _ => panic!("Expected absolute y"), - } - match cmd_pos.z { - PositionType::Relative(offset) => assert_eq!(offset, -10.0), - _ => panic!("Expected relative z"), - } - } - - #[test] - fn test_resolve() { - let cmd_pos = CommandPosition { - x: PositionType::Relative(10.0), - y: PositionType::Absolute(5.0), - z: PositionType::Relative(-10.0), - }; - let base_position = Position::new(100.0, 64.0, 100.0); - let resolved = cmd_pos.resolve(&base_position); - assert_eq!(resolved.x, 110.0); - assert_eq!(resolved.y, 5.0); - assert_eq!(resolved.z, 90.0); - } - - #[test] - fn parse_valid_inputs() { - let cases = vec![ - ( - "100 64 -200", - ( - PositionType::Absolute(100.0), - PositionType::Absolute(64.0), - PositionType::Absolute(-200.0), - ), - ), - ( - "~10 ~ ~-5", - ( - PositionType::Relative(10.0), - PositionType::Relative(0.0), - PositionType::Relative(-5.0), - ), - ), - ( - "50 ~20 30", - ( - PositionType::Absolute(50.0), - PositionType::Relative(20.0), - PositionType::Absolute(30.0), - ), - ), - ( - "~ ~ ~", - ( - PositionType::Relative(0.0), - PositionType::Relative(0.0), - PositionType::Relative(0.0), - ), - ), - ( - "1 2 ~", - ( - PositionType::Absolute(1.0), - PositionType::Absolute(2.0), - PositionType::Relative(0.0), - ), - ), - ( - "~-0 ~0 ~+0", - ( - PositionType::Relative(-0.0), - PositionType::Relative(0.0), - PositionType::Relative(0.0), - ), - ), - ]; - for (input, expected) in cases { - let mut ctx = CommandContext { - input: CommandInput { - input: input.to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let cmd_pos = CommandPosition::parse(&mut ctx) - .unwrap_or_else(|_| panic!("input `{}` should be valid", input)); - match cmd_pos.x { - PositionType::Absolute(val) => match expected.0 { - PositionType::Absolute(exp_val) => assert_eq!(val, exp_val), - _ => panic!("Expected relative x for input `{}`", input), - }, - PositionType::Relative(offset) => match expected.0 { - PositionType::Relative(exp_offset) => assert_eq!(offset, exp_offset), - _ => panic!("Expected absolute x for input `{}`", input), - }, - } - match cmd_pos.y { - PositionType::Absolute(val) => match expected.1 { - PositionType::Absolute(exp_val) => assert_eq!(val, exp_val), - _ => panic!("Expected relative y for input `{}`", input), - }, - PositionType::Relative(offset) => match expected.1 { - PositionType::Relative(exp_offset) => assert_eq!(offset, exp_offset), - _ => panic!("Expected absolute y for input `{}`", input), - }, - } - match cmd_pos.z { - PositionType::Absolute(val) => match expected.2 { - PositionType::Absolute(exp_val) => assert_eq!(val, exp_val), - _ => panic!("Expected relative z for input `{}`", input), - }, - PositionType::Relative(offset) => match expected.2 { - PositionType::Relative(exp_offset) => assert_eq!(offset, exp_offset), - _ => panic!("Expected absolute z for input `{}`", input), - }, - } - } - } - - #[test] - fn test_parse_invalid_inputs() { - let cases = vec!["", "1 2", "1 two 3", "not_a_number 5 6", "~ ~", "1 2 3 4"]; - for input in cases { - let mut ctx = CommandContext { - input: CommandInput { - input: input.to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - assert!( - CommandPosition::parse(&mut ctx).is_err(), - "input `{}` should be invalid", - input - ); - } - } -} diff --git a/src/commands/src/arg/primitive/bool.rs b/src/commands/src/arg/primitive/bool.rs deleted file mode 100644 index 7a1694ba..00000000 --- a/src/commands/src/arg/primitive/bool.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; - -impl CommandArgument for bool { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - let value = match &*str.to_lowercase() { - "true" | "yes" | "on" | "y" => true, - "false" | "no" | "off" | "n" => false, - _ => return Err(parser_error(&format!("invalid variant: {str}"))), - }; - - Ok(value) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::bool() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - - vec![Suggestion::of("true"), Suggestion::of("false")] - } -} diff --git a/src/commands/src/arg/primitive/char.rs b/src/commands/src/arg/primitive/char.rs deleted file mode 100644 index 6d8dac9b..00000000 --- a/src/commands/src/arg/primitive/char.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; - -impl CommandArgument for char { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - if str.len() > 1 || str.is_empty() { - return Err(parser_error("expected single character")); - } - - Ok(str.chars().nth(0).unwrap()) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - - ('a'..'Z').map(|c| Suggestion::of(c.to_string())).collect() - } -} diff --git a/src/commands/src/arg/primitive/float.rs b/src/commands/src/arg/primitive/float.rs deleted file mode 100644 index 98f969ab..00000000 --- a/src/commands/src/arg/primitive/float.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::io::Write; - -use temper_codec::encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}; - -use crate::{ - arg::{CommandArgument, ParserResult, utils::error}, - ctx::CommandContext, - wrapper, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Default)] -pub struct FloatArgumentFlags { - pub min: Option, - pub max: Option, -} - -impl NetEncode for FloatArgumentFlags { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - let mut flags = 0u8; - if self.min.is_some() { - flags |= 0x01; - } - if self.max.is_some() { - flags |= 0x02; - } - flags.encode(writer, opts)?; - self.min.encode(writer, opts)?; - self.max.encode(writer, opts) - } -} - -wrapper! { - /// A 32-bit floating point number. This can sadly not be bound in size since - /// floats are not valid const type parameters. The workaround for this is to - /// check it manually. - struct Float(f32); -} - -impl CommandArgument for Float { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let token = ctx.input.read_string(); - let float = match token.parse::() { - Ok(int) => int, - Err(err) => return Err(error(err)), - }; - - Ok(Float(float)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::float(None, None) - } -} diff --git a/src/commands/src/arg/primitive/int.rs b/src/commands/src/arg/primitive/int.rs deleted file mode 100644 index 53ec0cd5..00000000 --- a/src/commands/src/arg/primitive/int.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{io::Write, ops::Deref}; - -use temper_codec::encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}; - -use crate::{ - arg::{ - CommandArgument, ParserResult, - utils::{error, parser_error}, - }, - ctx::CommandContext, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Default)] -pub struct IntArgumentFlags { - pub min: Option, - pub max: Option, -} - -impl NetEncode for IntArgumentFlags { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - let mut flags = 0u8; - if self.min.is_some() { - flags |= 0x01; - } - if self.max.is_some() { - flags |= 0x02; - } - flags.encode(writer, opts)?; - self.min.encode(writer, opts)?; - self.max.encode(writer, opts) - } -} - -// Not using wrapper! here because of the complex generics -/// An integer, limited in size by the type arguments. -pub struct Integer(i32); - -impl Deref for Integer { - type Target = i32; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl CommandArgument for Integer { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let token = ctx.input.read_string(); - let int = match token.parse::() { - Ok(int) => int, - Err(err) => return Err(error(err)), - }; - - if int < MIN { - return Err(parser_error(&format!( - "integer too small: {int}, expected at least {MIN}" - ))); - } - - if int > MAX { - return Err(parser_error(&format!( - "integer too large: {int}, expected at most {MAX}" - ))); - } - - Ok(Integer(int)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::int(Some(MIN), Some(MAX)) - } -} diff --git a/src/commands/src/arg/primitive/long.rs b/src/commands/src/arg/primitive/long.rs deleted file mode 100644 index b41f6983..00000000 --- a/src/commands/src/arg/primitive/long.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{io::Write, ops::Deref}; - -use temper_codec::encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}; - -use crate::{ - arg::{ - CommandArgument, ParserResult, - utils::{error, parser_error}, - }, - ctx::CommandContext, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Default)] -pub struct LongArgumentFlags { - pub min: Option, - pub max: Option, -} - -impl NetEncode for LongArgumentFlags { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - let mut flags = 0u8; - if self.min.is_some() { - flags |= 0x01; - } - if self.max.is_some() { - flags |= 0x02; - } - flags.encode(writer, opts)?; - self.min.encode(writer, opts)?; - self.max.encode(writer, opts) - } -} - -// Not using wrapper! here because of the complex generics -/// A 64-bit integer, limited in size by the type arguments. -pub struct Long(i64); - -impl Deref for Long { - type Target = i64; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl CommandArgument for Long { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let token = ctx.input.read_string(); - let long = match token.parse::() { - Ok(int) => int, - Err(err) => return Err(error(err)), - }; - - if long < MIN { - return Err(parser_error(&format!( - "integer too small: {long}, expected at least {MIN}" - ))); - } - - if long > MIN { - return Err(parser_error(&format!( - "integer too large: {long}, expected at most {MIN}" - ))); - } - - Ok(Long(long)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::long(Some(MIN), Some(MIN)) - } -} diff --git a/src/commands/src/arg/primitive/mod.rs b/src/commands/src/arg/primitive/mod.rs deleted file mode 100644 index b2f9c673..00000000 --- a/src/commands/src/arg/primitive/mod.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Primitive command argument types. - -// TODO: -// * Entity -// * Score Holder -// * Resource or Tag -// * Resource or Tag Key -// * Resource -// * Resource Key - -use std::io::Write; - -use enum_ordinalize::Ordinalize; -use float::FloatArgumentFlags; -use int::IntArgumentFlags; -use long::LongArgumentFlags; -use string::StringArgumentType; -use temper_codec::{ - encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}, - net_types::var_int::VarInt, -}; -use temper_macros::NetEncode; - -pub mod bool; -pub mod char; -pub mod float; -pub mod int; -pub mod long; -pub mod string; - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct EntityArgumentFlags { - pub single: bool, - pub players_only: bool, -} - -impl NetEncode for EntityArgumentFlags { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - let mut flags = 0u8; - if self.single { - flags |= 0x01; - } - if self.players_only { - flags |= 0x02; - } - flags.encode(writer, opts) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PrimitiveArgument { - pub argument_type: PrimitiveArgumentType, - pub flags: Option, -} - -impl PrimitiveArgument { - pub fn word() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::String, - flags: Some(PrimitiveArgumentFlags::String(StringArgumentType::Word)), - } - } - - pub fn quotable() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::String, - flags: Some(PrimitiveArgumentFlags::String(StringArgumentType::Quotable)), - } - } - - pub fn greedy() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::String, - flags: Some(PrimitiveArgumentFlags::String(StringArgumentType::Greedy)), - } - } - - pub fn int(min: Option, max: Option) -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Int, - flags: Some(PrimitiveArgumentFlags::Int(IntArgumentFlags { min, max })), - } - } - - pub fn long(min: Option, max: Option) -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Long, - flags: Some(PrimitiveArgumentFlags::Long(LongArgumentFlags { min, max })), - } - } - - pub fn float(min: Option, max: Option) -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Float, - flags: Some(PrimitiveArgumentFlags::Float(FloatArgumentFlags { - min, - max, - })), - } - } - - pub fn bool() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Bool, - flags: None, - } - } -} - -#[derive(Clone, Debug, PartialEq, NetEncode)] -pub enum PrimitiveArgumentFlags { - Float(FloatArgumentFlags), - Int(IntArgumentFlags), - Long(LongArgumentFlags), - String(StringArgumentType), - Entity(EntityArgumentFlags), - Resource(String), -} - -#[derive(Clone, Debug, PartialEq, Ordinalize)] -pub enum PrimitiveArgumentType { - Bool, - Float, - Double, - Int, - Long, - String, - Entity, - GameProfile, - BlockPos, - ColumnPos, - Vec3, - Vec2, - BlockState, - BlockPredicate, - ItemStack, - ItemPredicate, - Color, - Component, - Style, - Message, - Nbt, - NbtTag, - NbtPath, - Objective, - ObjectiveCriteria, - Operator, - Particle, - Angle, - Rotation, - ScoreboardDisplaySlot, - ScoreHolder, - UpTo3Axes, - Team, - ItemSlot, - ResourceLocation, - Function, - EntityAnchor, - IntRange, - FloatRange, - Dimension, - GameMode, - Time, - ResourceOrTag, - ResourceOrTagKey, - Resource, - ResourceKey, - TemplateMirror, - TemplateRotation, - Heightmap, - UUID, - Position, -} - -impl NetEncode for PrimitiveArgumentType { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - VarInt::new(i32::from(self.ordinal())).encode(writer, opts) - } -} - -#[doc(hidden)] -mod utils { - //! Internal utilities related to command arguments. - - /// Macro that creates a wrapper struct around an inner type and implements Deref for the inner type. - #[macro_export] - macro_rules! wrapper { - ( - $( - $(#[$meta:meta])* - struct $name:ident $(<$($generics:tt),*>)? ($inner:ty); - )* - ) => { - $( - $(#[$meta])* - pub struct $name $(<$($generics),*>)? ($inner); - - impl std::ops::Deref for $name { - type Target = $inner; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - )* - }; - } -} diff --git a/src/commands/src/arg/primitive/string.rs b/src/commands/src/arg/primitive/string.rs deleted file mode 100644 index 2a762452..00000000 --- a/src/commands/src/arg/primitive/string.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::io::Write; - -use enum_ordinalize::Ordinalize; -use temper_codec::{ - encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}, - net_types::var_int::VarInt, -}; - -use crate::{ - arg::{CommandArgument, ParserResult, utils::parser_error}, - ctx::CommandContext, - wrapper, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Ordinalize, Default)] -pub enum StringArgumentType { - #[default] - Word, - Quotable, - Greedy, -} - -impl NetEncode for StringArgumentType { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - VarInt::new(i32::from(self.ordinal())).encode(writer, opts) - } -} - -wrapper! { - /// A single-word string. - struct SingleWord(String); - - /// A quotable string, accepting either a single-word string or a quoted multi-word string. - struct QuotableString(String); - - /// A greedy string, consuming the rest of the command input. - struct GreedyString(String); -} - -impl CommandArgument for SingleWord { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let word = ctx.input.read_string(); - - if word.is_empty() { - return Err(parser_error("string must not be empty")); - } - - Ok(SingleWord(word)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } -} - -impl CommandArgument for QuotableString { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let input = &mut ctx.input; - - input.skip_whitespace(u32::MAX, false); - - // If it starts with a single or double quote, then parse a quotable string - if input.peek() == Some('"') || input.peek() == Some('\'') { - input.read(1); // Consume opening quote - - let mut result = String::new(); - let mut escaped = false; - - while input.has_remaining_input() { - let current = input.peek(); - - match current { - None => return Err(parser_error("unterminated quoted string")), - Some(c) => { - input.read(1); - - if escaped { - match c { - '"' | '\\' => result.push(c), - _ => { - result.push('\\'); - result.push(c); - } - } - escaped = false; - } else { - match c { - '"' | '\'' => return Ok(QuotableString(result)), - '\\' => escaped = true, - _ => result.push(c), - } - } - } - } - } - - Err(parser_error("unterminated quoted string")) - } else { - let word = input.read_string(); - - if word.is_empty() { - return Err(parser_error("string must not be empty")); - } - - Ok(QuotableString(word)) - } - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::quotable() - } -} - -impl CommandArgument for GreedyString { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let input = &mut ctx.input; - let mut result_parts = Vec::new(); - - while input.has_remaining_input() { - let string = input.read_string(); - - result_parts.push(string); - } - - if result_parts.is_empty() { - return Err(parser_error("string cannot be empty")); - } - - let result = result_parts.join(" "); - - Ok(GreedyString(result)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::greedy() - } -} diff --git a/src/commands/src/ctx.rs b/src/commands/src/ctx.rs deleted file mode 100644 index 267de378..00000000 --- a/src/commands/src/ctx.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Command contexts. - -use std::sync::Arc; - -use crate::{ - Command, - arg::{CommandArgument, ParserResult, utils::parser_error}, - input::CommandInput, - sender::Sender, -}; -use temper_state::GlobalState; -use tracing::error; - -/// Context of the execution of a command. -pub struct CommandContext { - /// The command input. - pub input: CommandInput, - - /// The command. - pub command: Arc, - - /// The sender of the command. - pub sender: Sender, - - pub state: GlobalState, -} - -impl CommandContext { - /// Attempts to retrieve and parse an argument of the given `name` and parses it with the given parser. - pub fn arg(&mut self, name: &str) -> ParserResult { - if self.command.args.iter().any(|a| a.name == name) { - T::parse(self) - } else { - error!("attempted to fetch non-existant command argument"); - Err(parser_error(&format!("arg {name} does not exist"))) - } - } -} diff --git a/src/commands/src/errors.rs b/src/commands/src/errors.rs deleted file mode 100644 index 3aee4e2e..00000000 --- a/src/commands/src/errors.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Command errors. - -use thiserror::Error; - -/// Errors related to commands. -#[derive(Debug, Clone, Error)] -pub enum CommandError { - /// An argument parser failed - #[error("{0}")] - ParserError(String), - - /// A given argument could not be found. - #[error("argument not found: {0}")] - ArgumentNotFound(String), -} diff --git a/src/commands/src/graph/mod.rs b/src/commands/src/graph/mod.rs deleted file mode 100644 index 728a9215..00000000 --- a/src/commands/src/graph/mod.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! The command graph. - -use std::collections::HashMap; -use std::sync::Arc; - -use node::{CommandNode, CommandNodeFlag, CommandNodeType}; -use temper_codec::net_types::length_prefixed_vec::LengthPrefixedVec; -use temper_codec::net_types::var_int::VarInt; - -use crate::Command; - -pub mod node; - -/// The command graph of the server or an individual player. -/// As of now, only one instance of this exists on the entire -/// server and it is shared between all players. The command -/// graph holds references to all command nodes and maps them -/// to their indices, and is later sent to the client on join. -#[derive(Clone, Debug)] -pub struct CommandGraph { - /// The root node. - pub root_node: CommandNode, - - /// The root node with all its child nodes. - pub nodes: Vec, - - /// A map of command node parts to indices. - pub node_to_indices: HashMap, -} - -impl Default for CommandGraph { - fn default() -> Self { - let root_node = CommandNode { - flags: CommandNodeFlag::NodeType(CommandNodeType::Root).bitmask(), - children: LengthPrefixedVec::new(Vec::new()), - redirect_node: None, - name: None, - parser_id: None, - properties: None, - suggestions_type: None, - }; - - Self { - root_node: root_node.clone(), - nodes: vec![root_node], - node_to_indices: HashMap::new(), - } - } -} - -impl CommandGraph { - /// Adds the given `command` onto this command graph. - pub fn push(&mut self, command: Arc) { - let mut current_node_idx = 0; - - for (idx, part) in command.name.split_whitespace().enumerate() { - let is_last = idx == command.name.split_whitespace().count() - 1; - - let mut node = CommandNode { - flags: CommandNodeFlag::NodeType(CommandNodeType::Literal).bitmask(), - children: LengthPrefixedVec::new(Vec::new()), - redirect_node: None, - name: Some(part.to_string()), - parser_id: None, - properties: None, - suggestions_type: None, - }; - - if is_last - && (command.args.is_empty() - || command.args.first().is_some_and(|arg| !arg.required)) - { - node.flags |= CommandNodeFlag::Executable.bitmask(); - } - - let node_idx = self.nodes.len() as u32; - self.nodes.push(node); - self.node_to_indices.insert(part.to_string(), node_idx); - - if idx == 0 { - self.nodes[0].children.push(VarInt::new(node_idx as i32)); - } else { - let parent_node = self.nodes.get_mut(current_node_idx as usize).unwrap(); - parent_node.children.push(VarInt::new(node_idx as i32)); - } - - current_node_idx = node_idx; - } - - let mut prev_node_idx = current_node_idx; - - for (idx, arg) in command.args.iter().enumerate() { - let primitive = arg.primitive.clone(); - let is_last = idx == command.args.len() - 1; - - let mut arg_node = CommandNode { - flags: CommandNodeFlag::NodeType(CommandNodeType::Argument).bitmask() - | CommandNodeFlag::HasSuggestionsType.bitmask(), - children: LengthPrefixedVec::new(Vec::new()), - redirect_node: None, - name: Some(arg.name.clone()), - parser_id: Some(primitive.argument_type), - properties: primitive.flags, - suggestions_type: Some("ask_server".to_string()), - }; - - if is_last { - arg_node.flags |= CommandNodeFlag::Executable.bitmask(); - } - - let arg_node_idx = self.nodes.len() as u32; - self.nodes.push(arg_node); - - self.nodes[prev_node_idx as usize] - .children - .push(VarInt::new(arg_node_idx as i32)); - - prev_node_idx = arg_node_idx; - } - } - - /// Traverses the command graph with a given function. - pub fn traverse(&self, mut f: F) - where - F: FnMut(&CommandNode, u32, usize, Option), - { - self.traverse_node(0, 0, None, &mut f); - } - - fn traverse_node(&self, node_idx: u32, depth: usize, parent: Option, f: &mut F) - where - F: FnMut(&CommandNode, u32, usize, Option), - { - let current_node = &self.nodes[node_idx as usize]; - - f(current_node, node_idx, depth, parent); - - for child_idx in current_node.children.data.iter() { - self.traverse_node(child_idx.0 as u32, depth + 1, Some(node_idx), f); - } - } - - /// Attempts to find the matches to a given `input` string and returns - /// a vector of the node index and command name. - pub fn find_command<'a>(&'a self, input: &'a str) -> Vec<(u32, &'a str)> { - let mut matches = Vec::new(); - let input = input.trim(); - - self.find_command_recursive(0, input, &mut matches); - matches - } - - fn find_command_recursive<'a>( - &'a self, - node_idx: u32, - remaining_input: &'a str, - matches: &mut Vec<(u32, &'a str)>, - ) { - let current_node = &self.nodes[node_idx as usize]; - let input_words: Vec<&str> = remaining_input.split_whitespace().collect(); - - // once the input is empty and the currently selected node is executable, we've found it. - if remaining_input.is_empty() && current_node.is_executable() { - matches.push((node_idx, remaining_input)); - return; - } - - // once the input is empty but the currently selected node is not executable, we check the children. - if remaining_input.is_empty() { - return; - } - - match current_node.node_type() { - CommandNodeType::Root => { - // the root node is the root of all evil. - for child_idx in current_node.children.data.iter() { - self.find_command_recursive(child_idx.0 as u32, remaining_input, matches); - } - } - CommandNodeType::Literal => { - // for literal nodes, everything must match exactly. - if let Some(name) = ¤t_node.name - && !input_words.is_empty() - && input_words[0] == name - { - // we found a match, we continue with the remaining input. - let remaining = if input_words.len() > 1 { - remaining_input[name.len()..].trim_start() - } else { - "" - }; - - // once we found a node that is executable and the remaining input is empty, we've found something. - if remaining.is_empty() && current_node.is_executable() { - matches.push((node_idx, remaining)); - } - - // we continue checking the other children. - for child_idx in current_node.children.data.iter() { - self.find_command_recursive(child_idx.0 as u32, remaining, matches); - } - } - } - CommandNodeType::Argument => { - // for argument nodes, we consume one argument and then continue. - if !input_words.is_empty() { - let remaining = if input_words.len() > 1 { - remaining_input[input_words[0].len()..].trim_start() - } else { - "" - }; - - // if this node is executable, we add it. - matches.push((node_idx, remaining)); - - // continue checking anyway. - for child_idx in current_node.children.data.iter() { - self.find_command_recursive(child_idx.0 as u32, remaining, matches); - } - } - } - } - } - - fn collect_command_parts(&self, node_idx: u32, parts: &mut Vec) { - let node = &self.nodes[node_idx as usize]; - - if let Some(name) = &node.name - && node.node_type() == CommandNodeType::Literal - { - parts.push(name.clone()); - } - - // find the parent - for (parent_idx, parent_node) in self.nodes.iter().enumerate() { - if parent_node - .children - .data - .iter() - .any(|child| child.0 as u32 == node_idx) - { - self.collect_command_parts(parent_idx as u32, parts); - break; - } - } - } - - /// Gets the name of a command based off the `node_idx`. - pub fn get_command_name(&self, node_idx: u32) -> String { - let mut parts = Vec::new(); - self.collect_command_parts(node_idx, &mut parts); - parts.reverse(); // reverse since we want the command name in proper order - parts.join(" ") - } - - /// Attempts to find a command from the given `input` and returns the command name. - pub fn find_command_by_input(&self, input: &str) -> Option { - let matches = self.find_command(input); - - matches - .first() - .map(|(node_idx, _remaining)| self.get_command_name(*node_idx)) - } -} diff --git a/src/commands/src/graph/node.rs b/src/commands/src/graph/node.rs deleted file mode 100644 index 511be195..00000000 --- a/src/commands/src/graph/node.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Command graph nodes. - -use std::fmt; - -use enum_ordinalize::Ordinalize; -use temper_codec::net_types::{length_prefixed_vec::LengthPrefixedVec, var_int::VarInt}; -use temper_macros::NetEncode; - -use crate::arg::primitive::{PrimitiveArgumentFlags, PrimitiveArgumentType}; - -/// The type of command node. -#[derive(Clone, Debug, PartialEq, Ordinalize)] -pub enum CommandNodeType { - Root, - Literal, - Argument, -} - -impl CommandNodeType { - /// Gets the protocol ID (ordinal) of this type. - pub fn id(&self) -> u8 { - self.ordinal() as u8 - } -} - -/// Flags related to command nodes. -#[derive(Clone, Debug, PartialEq)] -pub enum CommandNodeFlag { - /// The node type. - NodeType(CommandNodeType), - - /// The node is executable. - Executable, - - /// The node has a redirect. - HasRedirect, - - /// The node has a suggestion type ([`CommandNodeType::Argument`] only). - HasSuggestionsType, -} - -impl CommandNodeFlag { - /// Gets the bitmask of this flag. - pub const fn bitmask(&self) -> u8 { - match self { - CommandNodeFlag::NodeType(CommandNodeType::Root) => 0x00, - CommandNodeFlag::NodeType(CommandNodeType::Literal) => 0x01, - CommandNodeFlag::NodeType(CommandNodeType::Argument) => 0x02, - CommandNodeFlag::Executable => 0x04, - CommandNodeFlag::HasRedirect => 0x08, - CommandNodeFlag::HasSuggestionsType => 0x10, - } - } -} - -/// An instance of a command node in a command graph. -#[derive(Clone, NetEncode)] -pub struct CommandNode { - /// The encoded [`CommandNodeFlag`] of this node. - pub flags: u8, - - /// Node indices of this node's children. - pub children: LengthPrefixedVec, - - /// Node index of the redirected node. Only [`Some`] if `flags` is [`CommandNodeFlag::HasRedirect`]. - pub redirect_node: Option, - - /// The name of this node. Only [`None`] for the root node. - pub name: Option, - - /// The [`PrimitiveArgumentType`] of this node. Only [`Some`] for argument nodes. - pub parser_id: Option, - - /// The [`PrimitiveArgumentFlags`] of this node. Only [`Some`] for argument nodes. - pub properties: Option, - - /// The type of suggestions used for this node. Only [`Some`] for argument nodes. - pub suggestions_type: Option, -} - -// We want to display the actual flags and not the encoded value -impl fmt::Debug for CommandNode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let node_type = match self.flags & 0x03 { - 0 => CommandNodeType::Root, - 1 => CommandNodeType::Literal, - 2 => CommandNodeType::Argument, - _ => panic!("Invalid node type"), - }; - - let executable = self.flags & 0x04 != 0; - let has_redirect = self.flags & 0x08 != 0; - let has_suggestions_type = self.flags & 0x10 != 0; - - f.debug_struct("CommandNode") - .field("node_type", &node_type) - .field("executable", &executable) - .field("has_redirect", &has_redirect) - .field("has_suggestions_type", &has_suggestions_type) - .field("flags", &self.flags) - .field("children", &self.children) - .field("redirect_node", &self.redirect_node) - .field("name", &self.name) - .field("parser_id", &self.parser_id) - .field("properties", &self.properties) - .field("suggestions_type", &self.suggestions_type) - .finish() - } -} - -impl CommandNode { - /// Gets the [`CommandNodeType`] from the flags. - pub fn node_type(&self) -> CommandNodeType { - match self.flags & 0x03 { - 1 => CommandNodeType::Literal, - 2 => CommandNodeType::Argument, - _ => CommandNodeType::Root, - } - } - - /// Whether this node is executable. - pub fn is_executable(&self) -> bool { - self.flags & 0x04 != 0 - } - - /// Whether this node has a redirect. - pub fn has_redirect(&self) -> bool { - self.flags & 0x08 != 0 - } - - /// Whether this node has a suggestion type. - pub fn has_suggestions_type(&self) -> bool { - self.flags & 0x10 != 0 - } -} diff --git a/src/commands/src/infrastructure.rs b/src/commands/src/infrastructure.rs deleted file mode 100644 index 66cc84ef..00000000 --- a/src/commands/src/infrastructure.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Command infrastructure - -use bevy_ecs::{prelude::*, schedule::ScheduleConfigs, system::ScheduleSystem}; -use dashmap::DashMap; -use std::{ - cell::RefCell, - sync::{Arc, LazyLock, RwLock}, -}; - -use crate::{Command, graph::CommandGraph}; - -static COMMANDS: LazyLock>> = LazyLock::new(DashMap::new); -static COMMAND_GRAPH: LazyLock> = - LazyLock::new(|| RwLock::new(CommandGraph::default())); - -thread_local! { - static SYSTEMS_TO_BE_REGISTERED: RefCell>> = RefCell::new(Vec::new()); - static EXCLUSIVE_SYSTEMS_TO_BE_REGISTERED: RefCell>> = RefCell::new(Vec::new()); -} - -/// Internal function. Adds a command system. -#[doc(hidden)] -pub fn add_system(system: impl IntoScheduleConfigs) { - SYSTEMS_TO_BE_REGISTERED.with(|systems| { - systems.borrow_mut().push(system.into_configs()); - }); -} - -/// Internal function. Registers all command systems. -#[doc(hidden)] -pub fn register_command_systems(schedule: &mut Schedule) { - SYSTEMS_TO_BE_REGISTERED.with(|systems| { - let mut systems = systems.borrow_mut(); - while let Some(sys) = systems.pop() { - schedule.add_systems(sys); - } - }); -} - -/// Registers a command. -pub fn register_command(command: Arc) { - COMMANDS.insert(command.name, command.clone()); - if let Ok(mut graph) = COMMAND_GRAPH.write() { - graph.push(command); - } -} - -/// Gets the server's command graph. -pub fn get_graph() -> CommandGraph { - if let Ok(graph) = COMMAND_GRAPH.read() { - graph.clone() - } else { - CommandGraph::default() - } -} - -/// Attempts to find a command by its `name`. -pub fn get_command_by_name(name: &str) -> Option> { - COMMANDS.get(name).map(|cmd_ref| Arc::clone(&cmd_ref)) -} - -/// Attempts to find a command by an `input` string. -pub fn find_command(input: &str) -> Option> { - let graph = get_graph(); - let name = graph.find_command_by_input(input); - if let Some(name) = name { - get_command_by_name(&name) - } else { - None - } -} diff --git a/src/commands/src/input.rs b/src/commands/src/input.rs deleted file mode 100644 index b8abd2d0..00000000 --- a/src/commands/src/input.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Command input. - -/// Input of a command. -/// This struct is mainly used for parsing arguments. -#[derive(Clone)] -pub struct CommandInput { - /// The entire input. - pub input: String, - - /// The current cursor position. - pub cursor: u32, -} - -impl CommandInput { - /// Creates a new [`CommandInput`] of a given `input` string. - pub fn of(input: String) -> Self { - Self { input, cursor: 0 } - } - - /// Appends a given `string` to this input. - pub fn append_string(&mut self, string: String) { - self.input += &*string; - } - - /// Moves the cursor by `chars`. - pub fn move_cursor(&mut self, chars: u32) { - if self.cursor + chars > self.input.len() as u32 { - return; - } - - self.cursor += chars; - } - - /// Gets the remaining length of the input. - pub fn remaining_length(&self) -> u32 { - self.input.len() as u32 - self.cursor - } - - /// Peeks one character ahead. - pub fn peek(&self) -> Option { - self.input.chars().nth(self.cursor as usize) - } - - /// Whether there is any input remaining unconsumed. - pub fn has_remaining_input(&self) -> bool { - self.cursor < self.input.len() as u32 - } - - /// Skips a max of `max_spaces` whitespace characters, whilst preserving single whitespace characters - /// if `preserve_single` is true. - pub fn skip_whitespace(&mut self, max_spaces: u32, preserve_single: bool) { - if preserve_single && self.remaining_length() == 1 && self.peek() == Some(' ') { - return; - } - - let mut i = 0; - while i < max_spaces - && self.has_remaining_input() - && self.peek().is_some_and(|c| c.is_whitespace()) - { - self.read(1); - i += 1; - } - } - - /// Gets the remaining input. - pub fn remaining_input(&self) -> String { - self.input[self.cursor as usize..].to_string() - } - - /// Peeks `chars` and returns a string of all found characters. - pub fn peek_string_chars(&self, chars: u32) -> String { - let remaining = self.remaining_input(); - if chars > remaining.len() as u32 { - return "".to_string(); - } - - remaining[0..chars as usize].to_string() - } - - /// Reads `chars` and returns a string of all found characters. - pub fn read(&mut self, chars: u32) -> String { - let read_string = self.peek_string_chars(chars); - self.move_cursor(chars); - read_string - } - - /// Counts all remaining tokens. - pub fn remaining_tokens(&self) -> u32 { - let count = self.remaining_input().split(' ').count() as u32; - if self.remaining_input().ends_with(' ') { - return count + 1; - } - count - } - - /// Reads an entire word and returns it. - pub fn read_string(&mut self) -> String { - self.skip_whitespace(u32::MAX, false); - let mut result = String::new(); - while let Some(c) = self.peek() { - if c.is_whitespace() { - break; - } - result.push(c); - self.move_cursor(1); - } - result - } - - /// Peeks an entire word and returns it. - pub fn peek_string(&self) -> String { - let remaining = self.remaining_input(); - remaining - .split_whitespace() - .next() - .unwrap_or("") - .to_string() - } - - /// Reads until `separator` is found and returns all found characters as a string. - pub fn read_until(&mut self, separator: char) -> String { - self.skip_whitespace(u32::MAX, false); - let mut result = String::new(); - while let Some(c) = self.peek() { - if c == separator { - self.move_cursor(1); - break; - } - result.push(c); - self.move_cursor(1); - } - result - } - - /// Reads an entire word whilst skipping whitespace and preserving single whitespace - /// characters if `preserve_single` is true. - pub fn read_string_skip_whitespace(&mut self, preserve_single: bool) -> String { - let read_string = self.read_string(); - self.skip_whitespace(u32::MAX, preserve_single); - read_string - } -} diff --git a/src/commands/src/lib.rs b/src/commands/src/lib.rs deleted file mode 100644 index bc156ec2..00000000 --- a/src/commands/src/lib.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Temper's Command API. - -use std::sync::{Arc, LazyLock}; - -use arg::CommandArgumentNode; - -pub mod arg; -mod ctx; -pub mod errors; -pub mod graph; -pub mod infrastructure; -mod input; -pub mod messages; -pub mod resolve; -mod sender; - -// Re-export under main module to avoid clutter. -pub use ctx::*; -pub use input::*; -pub use sender::*; -use temper_macros::NetEncode; -use temper_nbt::NBT; -use temper_text::TextComponent; - -/// An instance of a command. -#[derive(Debug, Clone, PartialEq)] -pub struct Command { - /// The name of the command. - pub name: &'static str, - - /// All possible arguments this command can take. - pub args: Vec, -} - -/// A command suggestion. -#[derive(NetEncode, Clone, Debug, PartialEq)] -pub struct Suggestion { - /// The content of the suggestion. - pub content: String, - - /// An optional tooltip that gets displayed when hovering over the suggestion. - pub tooltip: Option>, -} - -impl Suggestion { - pub fn of(content: impl AsRef) -> Suggestion { - Suggestion { - content: content.as_ref().to_string(), - tooltip: None, - } - } -} - -/// The root command. This is only for internal use and you should never ever have to rely on using this. -/// Only used in command suggestion cases when we don't know the command a player is entering yet. -pub static ROOT_COMMAND: LazyLock> = LazyLock::new(|| { - Arc::new(Command { - name: "", - args: Vec::new(), - }) -}); diff --git a/src/commands/src/messages.rs b/src/commands/src/messages.rs deleted file mode 100644 index 8dc33144..00000000 --- a/src/commands/src/messages.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Messages related to commmands. - -use std::sync::Arc; - -use bevy_ecs::message::Message; - -use crate::{Command, ctx::CommandContext, sender::Sender}; - -/// A command has been dispatched -#[derive(Message)] -pub struct CommandDispatched { - /// The command string. - pub command: String, - - /// The sender of the command. - pub sender: Sender, -} - -/// A command has been dispatched and resolved. -/// At this point in time, the command has not been executed -/// yet. This is up to the server or plugins to handle. -#[derive(Message)] -pub struct ResolvedCommandDispatched { - /// The command. - pub command: Arc, - - /// The created command context. - pub ctx: CommandContext, - - /// The sender of the command. - pub sender: Sender, -} diff --git a/src/commands/src/resolve.rs b/src/commands/src/resolve.rs deleted file mode 100644 index c453cd45..00000000 --- a/src/commands/src/resolve.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::{Command, CommandContext, CommandInput, Sender, infrastructure}; -use bevy_ecs::error; -use std::sync::Arc; -use temper_state::GlobalState; -use temper_text::{NamedColor, TextComponent, TextComponentBuilder}; - -pub fn resolve( - input: String, - sender: Sender, - state: GlobalState, -) -> error::Result<(Arc, CommandContext), Box> { - let command = infrastructure::find_command(&input); - if command.is_none() { - return Err(Box::new( - TextComponentBuilder::new("Unknown command") - .color(NamedColor::Red) - .build(), - )); - } - - let command = command.unwrap(); - let input = input - .strip_prefix(command.name) - .unwrap_or(&input) - .trim_start(); - let input = CommandInput::of(input.to_string()); - let ctx = CommandContext { - input: input.clone(), - command: command.clone(), - sender, - state, - }; - - Ok((command, ctx)) -} diff --git a/src/commands/src/sender.rs b/src/commands/src/sender.rs deleted file mode 100644 index dc67e0be..00000000 --- a/src/commands/src/sender.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Command senders. - -use bevy_ecs::prelude::*; -use temper_core::mq; -use temper_text::TextComponent; -use tracing::info; - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] -/// A possible command sender. -pub enum Sender { - /// A player has sent a command. - Player(Entity), - - /// The server console has sent a command. - Server, -} - -impl Sender { - /// Sends the given `message` to this sender, and to the action bar - /// if `actionbar` is true. - pub fn send_message(&self, message: TextComponent, actionbar: bool) { - match self { - Sender::Player(entity) => mq::queue(message, actionbar, *entity), - Sender::Server => { - info!("{}", message.to_plain_text()); // TODO: serialize into ANSI? - } - } - } -} diff --git a/src/default_commands/Cargo.toml b/src/default_commands/Cargo.toml index 31448951..df1cee1b 100644 --- a/src/default_commands/Cargo.toml +++ b/src/default_commands/Cargo.toml @@ -7,7 +7,6 @@ edition = "2024" temper-messages = { workspace = true } temper-components = { workspace = true } temper-command-infra = { workspace = true } -temper-commands = { workspace = true } temper-macros = { workspace = true } temper-text = { workspace = true } temper-core = { workspace = true } diff --git a/src/default_commands/src/bossbar.rs b/src/default_commands/src/bossbar.rs index 94c48724..1e27bd97 100644 --- a/src/default_commands/src/bossbar.rs +++ b/src/default_commands/src/bossbar.rs @@ -1,307 +1,487 @@ -//! Bossbar Command System -//! -//! This module provides command handlers for creating, managing, and assigning bossbars -//! to players. Bossbars are stored in `BossBarResource` and synced to clients via -//! `BossbarSender`. -//! -//! --- -//! -//! ## Commands -//! -//! ### `bossbar add ` -//! Creates a new bossbar with the given display name. -//! Returns the generated UUID of the bossbar. -//! -//! --- -//! -//! ### `bossbar get ` -//! Retrieves information about a specific bossbar. -//! Prints its current state if it exists. -//! -//! --- -//! -//! ### `bossbar list` -//! Lists all currently existing bossbars by UUID. -//! -//! --- -//! -//! ### `bossbar remove ` -//! Deletes a bossbar from the system and stops tracking it. -//! -//! --- -//! -//! ### `bossbar set